/** * LeafletMapInner Component * Internal Leaflet map implementation (client-side only) */ 'use client'; import { useEffect, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { School } from '@/lib/types'; import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils'; interface LeafletMapInnerProps { schools: School[]; center: [number, number]; zoom: number; referencePoint?: [number, number]; /** Search radius in miles. Drawn as a dashed circle the view fits to. */ radiusMiles?: number; /** The school whose pin is highlighted and, on wide screens, whose card is open. */ selectedUrn?: number | null; onMarkerClick?: (school: School) => void; /** The open card was closed from the map itself (its × or a click elsewhere). */ onDeselect?: () => void; onAddToCompare?: (school: School) => void; compareUrns?: number[]; nationalAvgRwm?: number | null; laAverages?: Record; } const METRES_PER_MILE = 1609.344; /* * Wide screens show the selected school as a card on the map. Narrow ones do * not: HomeView shows the same card as a bottom sheet there, and a popup on a * 390px map would cover most of it. */ const CARD_ON_MAP_QUERY = '(min-width: 769px)'; // --------------------------------------------------------------------------- // Card helpers (must work in plain JS string templates — no React / CSS Modules) // Class names are global and styled in SchoolMap.module.css. // --------------------------------------------------------------------------- function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function pinIcon(selected: boolean): L.DivIcon { const size = selected ? 32 : 22; return L.divIcon({ className: selected ? 'sc-pin sc-pin--selected' : 'sc-pin', html: '', iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } interface CardContext { inCompare: boolean; nationalAvgRwm?: number | null; laAverages?: Record; } /** The headline figure and its comparison, the same rule as the list rows. */ function metricHtml(school: School, { nationalAvgRwm, laAverages }: CardContext): string { const special = isSpecialSchool(school); if (school.attainment_8_score != null) { const score = school.attainment_8_score; const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null; let delta = ''; if (!special && laAvg != null) { const diff = Math.round((score - laAvg) * 10) / 10; // Att8 runs 0–90 in 0.1 steps; ±0.5 is meaningful, where RWM needs ±2. const cls = diff >= 0.5 ? 'sc-up' : diff <= -0.5 ? 'sc-down' : ''; delta = ` ${diff >= 0 ? '+' : ''}${diff} vs LA`; } return `${score.toFixed(1)} Att 8${delta}`; } const rwm = listRwmValue(school); if (rwm != null) { let delta = ''; if (!special && nationalAvgRwm != null) { const diff = Math.round(rwm - nationalAvgRwm); const cls = diff >= 2 ? 'sc-up' : diff <= -2 ? 'sc-down' : ''; const text = diff >= 2 ? `+${diff} pts` : diff <= -2 ? `${diff} pts` : '≈ national'; delta = ` ${text}`; } return `${rwm}% RWM${delta}`; } return ''; } function cardHtml(school: School, ctx: CardContext): string { const badge = buildOfstedListBadge(school); const href = schoolUrl(school.urn, school.school_name); const distance = school.distance != null ? `${school.distance.toFixed(1)} mi` : ''; const type = school.school_type ? `${escapeHtml(school.school_type)}` : ''; const metric = metricHtml(school, ctx); const pupils = school.total_pupils != null ? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : ''; return `
${escapeHtml(badge.label)}${type}
${metric || pupils ? `
${metric}${pupils}
` : ''}
View
`; } export default function LeafletMapInner({ schools, center, zoom, referencePoint, radiusMiles, selectedUrn, onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages, }: LeafletMapInnerProps) { const mapRef = useRef(null); const mapContainerRef = useRef(null); const markersRef = useRef>(new Map()); const overlayRef = useRef(null); const popupRef = useRef(null); const selectedRef = useRef(null); // Bumped whenever the pins are rebuilt, for any reason, so the selection // effect puts the card back on the new pin rather than only when the // selection or the school list changes. const [pinsVersion, setPinsVersion] = useState(0); // The popup is plain HTML outside React, so its handlers read the latest // props through a ref rather than closing over the render they were made in. const latest = useRef({ onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages }); latest.current = { onMarkerClick, onDeselect, onAddToCompare, compareUrns, nationalAvgRwm, laAverages }; const cardHtmlFor = (school: School) => cardHtml(school, { inCompare: !!latest.current.compareUrns?.includes(school.urn), nationalAvgRwm: latest.current.nationalAvgRwm, laAverages: latest.current.laAverages, }); // Map, tiles and controls: once. useEffect(() => { if (!mapContainerRef.current || mapRef.current) return; const map = L.map(mapContainerRef.current, { zoomControl: false }).setView(center, zoom); L.control.zoom({ position: 'topright' }).addTo(map); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(map); overlayRef.current = L.layerGroup().addTo(map); mapRef.current = map; // center/zoom only seed the first view; later changes go through the // markers effect, which fits the view to the results. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Search location, radius and school pins. useEffect(() => { const map = mapRef.current; const overlay = overlayRef.current; if (!map || !overlay) return; // Forget the open card before closing it, so its close handler does not // read this rebuild as the reader deselecting; the selection effect below // reopens it on the new pin. popupRef.current = null; map.closePopup(); overlay.clearLayers(); markersRef.current.clear(); let radiusBounds: L.LatLngBounds | null = null; if (referencePoint) { if (radiusMiles) { const circle = L.circle(referencePoint, { radius: radiusMiles * METRES_PER_MILE, className: 'sc-radius', interactive: false, }).addTo(overlay); radiusBounds = circle.getBounds(); const label = radiusMiles === 1 ? '1 mile' : `${radiusMiles} miles`; L.marker([radiusBounds.getNorth(), referencePoint[1]], { icon: L.divIcon({ className: 'sc-radius-label', html: label, iconSize: undefined }), interactive: false, keyboard: false, }).addTo(overlay); } L.marker(referencePoint, { icon: L.divIcon({ className: 'sc-home', html: '', iconSize: [16, 16], iconAnchor: [8, 8] }), zIndexOffset: 500, title: 'Search location', alt: 'Search location', }).addTo(overlay); } for (const school of schools) { if (!school.latitude || !school.longitude) continue; const selected = school.urn === selectedRef.current; const marker = L.marker([school.latitude, school.longitude], { icon: pinIcon(selected), zIndexOffset: selected ? 1000 : 0, title: school.school_name, alt: school.school_name, riseOnHover: true, }).addTo(overlay); marker.on('click', () => latest.current.onMarkerClick?.(school)); markersRef.current.set(school.urn, marker); } // Fit the search radius when there is one: every result lies inside it, // and it keeps the view the same shape whatever the pins happen to be. if (radiusBounds) { map.fitBounds(radiusBounds, { padding: [16, 16] }); } else if (schools.length > 1) { const points = schools .filter(s => s.latitude && s.longitude) .map(s => [s.latitude!, s.longitude!] as [number, number]); if (points.length) map.fitBounds(L.latLngBounds(points), { padding: [50, 50] }); } else { map.setView(center, zoom); } setPinsVersion(v => v + 1); }, [schools, center, zoom, referencePoint, radiusMiles]); // Selection: restyle the pin, and on wide screens open its card. useEffect(() => { const map = mapRef.current; if (!map) return; const previous = selectedRef.current; selectedRef.current = selectedUrn ?? null; if (previous != null && previous !== selectedUrn) { const old = markersRef.current.get(previous); old?.setIcon(pinIcon(false)); old?.setZIndexOffset(0); } const marker = selectedUrn != null ? markersRef.current.get(selectedUrn) : undefined; const school = selectedUrn != null ? schools.find(s => s.urn === selectedUrn) : undefined; if (!marker || !school) { popupRef.current = null; map.closePopup(); return; } marker.setIcon(pinIcon(true)); marker.setZIndexOffset(1000); if (!window.matchMedia(CARD_ON_MAP_QUERY).matches) { if (!map.getBounds().contains(marker.getLatLng())) map.panTo(marker.getLatLng()); return; } const popup = L.popup({ className: 'sc-popup', maxWidth: 300, minWidth: 260, offset: [0, -12], autoPanPadding: [24, 24], }).setLatLng(marker.getLatLng()).setContent(cardHtmlFor(school)); popup.on('remove', () => { // Closed from the map, not replaced by another selection. if (popupRef.current === popup) { popupRef.current = null; latest.current.onDeselect?.(); } }); popupRef.current = popup; popup.openOn(map); // One listener on the card's shell: it outlives the content swaps below. popup.getElement()?.addEventListener('click', (e) => { if ((e.target as HTMLElement).closest('[data-compare]')) latest.current.onAddToCompare?.(school); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedUrn, pinsVersion]); // The open card follows the compare basket and the averages as they arrive. useEffect(() => { const popup = popupRef.current; if (!popup || selectedUrn == null) return; const school = schools.find(s => s.urn === selectedUrn); if (school) popup.setContent(cardHtmlFor(school)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [compareUrns, nationalAvgRwm, laAverages]); // Cleanup map on unmount useEffect(() => { return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; } }; }, []); return
; }