/** * LeafletMapInner Component * Internal Leaflet map implementation (client-side only) */ 'use client'; import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { School } from '@/lib/types'; import { schoolUrl } from '@/lib/utils'; // Fix for default marker icons in Next.js delete (L.Icon.Default.prototype as any)._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', }); interface LeafletMapInnerProps { schools: School[]; center: [number, number]; zoom: number; referencePoint?: [number, number]; onMarkerClick?: (school: School) => void; nationalAvgRwm?: number | null; laAverages?: Record; } // --------------------------------------------------------------------------- // Popup helpers (must work in plain JS string templates — no React / CSS Modules) // --------------------------------------------------------------------------- function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } interface PopupBadge { label: string; style: string; } function buildPopupBadge(school: School): PopupBadge { const year = school.ofsted_date ? new Date(school.ofsted_date).getFullYear() : null; const yearStr = year ? ` · ${year}` : ''; if (school.ofsted_grade) { const labels: Record = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' }; const colours: Record = { 1: 'background:#d4f0ea;color:#2d7d7d', 2: 'background:rgba(60,140,60,0.12);color:#3c8c3c', 3: 'background:#fef3cd;color:#b8920e', 4: 'background:#fde8e0;color:#e07256', }; return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] }; } if (school.ofsted_framework === 'ReportCard') { return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' }; } return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' }; } export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) { const mapRef = useRef(null); const mapContainerRef = useRef(null); const refMarkerRef = useRef(null); useEffect(() => { if (!mapContainerRef.current) return; // Initialize map if (!mapRef.current) { mapRef.current = L.map(mapContainerRef.current).setView(center, zoom); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(mapRef.current); } // Clear existing school markers (not the reference pin) mapRef.current.eachLayer((layer) => { if (layer instanceof L.Marker && layer !== refMarkerRef.current) { mapRef.current!.removeLayer(layer); } }); // Add reference pin (search location) if (refMarkerRef.current) { refMarkerRef.current.remove(); refMarkerRef.current = null; } if (referencePoint && mapRef.current) { const refIcon = L.divIcon({ html: `
`, iconSize: [20, 20], iconAnchor: [10, 10], className: '', }); refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 }) .addTo(mapRef.current) .bindPopup('Search location'); } // Add markers for schools schools.forEach((school) => { if (school.latitude && school.longitude && mapRef.current) { const marker = L.marker([school.latitude, school.longitude]).addTo(mapRef.current); // Create popup content const badge = buildPopupBadge(school); const isSecondary = school.attainment_8_score != null; // Phase label const rawPhase = (school.phase ?? '').toLowerCase(); const phaseLabel = rawPhase.includes('secondary') ? 'Secondary' : rawPhase === 'all-through' ? 'All-through' : rawPhase.includes('primary') ? 'Primary' : isSecondary ? 'Secondary' : 'Primary'; // Distance string const distanceStr = school.distance != null ? ` · ${school.distance.toFixed(1)} mi` : ''; // Headline metric let metricHtml = ''; if (isSecondary) { const score = school.attainment_8_score!; const laAvg = school.local_authority ? (laAverages?.[school.local_authority] ?? null) : null; let deltaLine = ''; if (laAvg != null) { const diff = Math.round((score - laAvg) * 10) / 10; const sign = diff >= 0 ? '+' : ''; // Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here // vs primary RWM % where ±2 pts is the threshold const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a'; const laName = escapeHtml(school.local_authority ?? 'LA'); deltaLine = `
${sign}${diff} vs ${laName} avg
`; } metricHtml = `
${score.toFixed(1)} Attainment 8 ${deltaLine}
`; } else if (school.rwm_expected_pct != null) { const rwm = school.rwm_expected_pct; let deltaLine = ''; if (nationalAvgRwm != null) { const diff = Math.round(rwm - nationalAvgRwm); const colour = diff >= 2 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a'; const text = diff >= 2 ? `+${diff} pts vs national` : diff <= -2 ? `${diff} pts vs national` : '≈ national avg'; deltaLine = `
${text}
`; } metricHtml = `
${rwm}% Reading, Writing & Maths ${deltaLine}
`; } const slug = schoolUrl(school.urn, school.school_name); const popupContent = `
${escapeHtml(school.school_name)} ${badge.label}
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
${metricHtml} View Details →
`; marker.bindPopup(popupContent); if (onMarkerClick) { marker.on('click', () => onMarkerClick(school)); } } }); // Update map view if (schools.length > 1) { const bounds = L.latLngBounds( schools .filter(s => s.latitude && s.longitude) .map(s => [s.latitude!, s.longitude!] as [number, number]) ); mapRef.current.fitBounds(bounds, { padding: [50, 50] }); } else { mapRef.current.setView(center, zoom); } // Cleanup return () => { // Don't destroy map on every update, just clean markers }; }, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]); // Cleanup map on unmount useEffect(() => { return () => { if (mapRef.current) { mapRef.current.remove(); mapRef.current = null; } }; }, []); return
; }