/** * LeafletCutoffMapInner * The cut-off distances drawn around the school. * * L.circle takes a radius in metres and projects it properly, which is exactly * what these rings are: a straight-line distance from the school. (L.circleMarker * would take pixels and would silently stop meaning anything as the user zoomed.) * * The latest year is filled and labelled; earlier years are hairlines behind it, * so a tightening cut-off reads as a set of shrinking circles without needing a * legend to decode it. The map auto-fits the widest ring, so the whole history * is in frame whatever the distances are. */ 'use client'; import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; export interface CutoffRing { year: number; radiusM: number; latest: boolean; } interface Props { lat: number; lng: number; rings: CutoffRing[]; /** The parent's geocoded postcode, once they have entered one. */ home: { lat: number; lng: number } | null; interactive: boolean; } const SCHOOL_PIN = `
`; // A square rotated 45° — a different SHAPE from the school's circle, not just a // different colour, so the two are still distinguishable to anyone who cannot // separate the hues. const HOME_PIN = `
`; function cssVar(name: string, fallback: string): string { if (typeof window === 'undefined') return fallback; return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) { const elRef = useRef(null); const mapRef = useRef(null); const layersRef = useRef([]); const zoomCtrlRef = useRef(null); useEffect(() => { if (!elRef.current || mapRef.current) return; const map = L.map(elRef.current, { zoomControl: false, attributionControl: true, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false, }).setView([lat, lng], 14); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(map); mapRef.current = map; setTimeout(() => map.invalidateSize(), 60); return () => { map.remove(); mapRef.current = null; layersRef.current = []; zoomCtrlRef.current = null; }; }, [lat, lng]); // Redraw rings, pins and the viewport whenever the data changes. useEffect(() => { const map = mapRef.current; if (!map) return; layersRef.current.forEach((l) => l.remove()); layersRef.current = []; const brand = cssVar('--brand', '#0F766E'); const above = cssVar('--status-above', '#2F855A'); // Widest first, so the small recent ring is not buried under the old ones. const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM); for (const r of ordered) { const circle = L.circle([lat, lng], { radius: r.radiusM, color: brand, weight: r.latest ? 3 : 1.5, opacity: r.latest ? 1 : 0.45, fill: r.latest, fillColor: brand, fillOpacity: r.latest ? 0.12 : 0, interactive: false, }).addTo(map); layersRef.current.push(circle); } const school = L.marker([lat, lng], { icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }), keyboard: false, interactive: false, }).addTo(map); layersRef.current.push(school); if (home) { const pin = L.marker([home.lat, home.lng], { icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }), keyboard: false, interactive: false, }).addTo(map); layersRef.current.push(pin); const line = L.polyline([[lat, lng], [home.lat, home.lng]], { color: above, weight: 2, dashArray: '5 5', opacity: 0.85, interactive: false, }).addTo(map); layersRef.current.push(line); } // Frame the widest ring — and the home pin when it falls outside it, so a // family beyond every cut-off can still see where they sit. const widest = ordered[0]?.radiusM ?? 500; let bounds = L.latLng(lat, lng).toBounds(widest * 2.2); if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng)); map.fitBounds(bounds, { padding: [16, 16] }); }, [lat, lng, rings, home]); useEffect(() => { const map = mapRef.current; if (!map) return; const handlers = [ map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom, ]; handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } }); if (interactive && !zoomCtrlRef.current) { zoomCtrlRef.current = L.control.zoom({ position: 'topleft' }); zoomCtrlRef.current.addTo(map); } else if (!interactive && zoomCtrlRef.current) { zoomCtrlRef.current.remove(); zoomCtrlRef.current = null; } setTimeout(() => map.invalidateSize(), 80); }, [interactive]); return
; }