2026-02-02 20:34:35 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* 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';
|
2026-03-29 12:41:28 +01:00
|
|
|
|
import { schoolUrl } from '@/lib/utils';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-03-30 09:13:14 +01:00
|
|
|
|
referencePoint?: [number, number];
|
2026-02-02 20:34:35 +00:00
|
|
|
|
onMarkerClick?: (school: School) => void;
|
2026-04-13 14:29:55 +01:00
|
|
|
|
nationalAvgRwm?: number | null;
|
|
|
|
|
|
laAverages?: Record<string, number | null>;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 14:29:55 +01:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-13 14:32:55 +01:00
|
|
|
|
function escapeHtml(s: string): string {
|
|
|
|
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-13 14:29:55 +01:00
|
|
|
|
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<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
|
|
|
|
|
|
const colours: Record<number, string> = {
|
|
|
|
|
|
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) {
|
2026-02-02 20:34:35 +00:00
|
|
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
|
|
|
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
2026-03-30 09:13:14 +01:00
|
|
|
|
const refMarkerRef = useRef<L.Marker | null>(null);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
|
|
|
|
maxZoom: 19,
|
|
|
|
|
|
}).addTo(mapRef.current);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 09:13:14 +01:00
|
|
|
|
// Clear existing school markers (not the reference pin)
|
2026-02-02 20:34:35 +00:00
|
|
|
|
mapRef.current.eachLayer((layer) => {
|
2026-03-30 09:13:14 +01:00
|
|
|
|
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
|
2026-02-02 20:34:35 +00:00
|
|
|
|
mapRef.current!.removeLayer(layer);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-30 09:13:14 +01:00
|
|
|
|
// Add reference pin (search location)
|
|
|
|
|
|
if (refMarkerRef.current) {
|
|
|
|
|
|
refMarkerRef.current.remove();
|
|
|
|
|
|
refMarkerRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (referencePoint && mapRef.current) {
|
|
|
|
|
|
const refIcon = L.divIcon({
|
|
|
|
|
|
html: `<div style="
|
|
|
|
|
|
width: 20px; height: 20px;
|
|
|
|
|
|
background: #e07256;
|
|
|
|
|
|
border: 3px solid white;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.35);
|
|
|
|
|
|
"></div>`,
|
|
|
|
|
|
iconSize: [20, 20],
|
|
|
|
|
|
iconAnchor: [10, 10],
|
|
|
|
|
|
className: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
refMarkerRef.current = L.marker(referencePoint, { icon: refIcon, zIndexOffset: 1000 })
|
|
|
|
|
|
.addTo(mapRef.current)
|
|
|
|
|
|
.bindPopup('<strong>Search location</strong>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 20:34:35 +00:00
|
|
|
|
// 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
|
2026-04-13 14:29:55 +01:00
|
|
|
|
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 ? '+' : '';
|
2026-04-13 14:32:55 +01:00
|
|
|
|
// Att8 scores range 0–90 in 0.1 increments; ±0.5 is meaningful here
|
|
|
|
|
|
// vs primary RWM % where ±2 pts is the threshold
|
2026-04-13 14:29:55 +01:00
|
|
|
|
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
|
2026-04-13 14:32:55 +01:00
|
|
|
|
const laName = escapeHtml(school.local_authority ?? 'LA');
|
2026-04-13 14:29:55 +01:00
|
|
|
|
deltaLine = `<div style="font-size:11px;font-weight:600;color:${colour}">${sign}${diff} vs ${laName} avg</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
metricHtml = `<div style="margin-bottom:4px">
|
|
|
|
|
|
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
|
|
|
|
|
|
<span style="font-size:11px;color:#8a847a;margin-left:4px">Attainment 8</span>
|
|
|
|
|
|
${deltaLine}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
} 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 = `<div style="font-size:11px;font-weight:600;color:${colour}">${text}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
metricHtml = `<div style="margin-bottom:4px">
|
|
|
|
|
|
<span style="font-size:20px;font-weight:700;color:#1a1612;font-family:Georgia,serif">${rwm}%</span>
|
|
|
|
|
|
<span style="font-size:11px;color:#8a847a;margin-left:4px">Reading, Writing & Maths</span>
|
|
|
|
|
|
${deltaLine}
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const slug = schoolUrl(school.urn, school.school_name);
|
|
|
|
|
|
|
|
|
|
|
|
const popupContent = `<div style="font-family:system-ui,sans-serif;min-width:240px;max-width:280px;padding:0">
|
|
|
|
|
|
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:6px">
|
2026-04-13 14:32:55 +01:00
|
|
|
|
<strong style="font-size:13px;color:#1a1612;line-height:1.3">${escapeHtml(school.school_name)}</strong>
|
2026-04-13 14:29:55 +01:00
|
|
|
|
<span style="font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap;flex-shrink:0;${badge.style}">${badge.label}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style="font-size:11px;color:#8a847a;margin-bottom:8px">
|
2026-04-13 14:32:55 +01:00
|
|
|
|
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
|
2026-04-13 14:29:55 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
${metricHtml}
|
|
|
|
|
|
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:#2d7d7d;color:white;border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
|
|
|
|
|
|
</div>`;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
};
|
2026-04-13 14:32:55 +01:00
|
|
|
|
}, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
// Cleanup map on unmount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (mapRef.current) {
|
|
|
|
|
|
mapRef.current.remove();
|
|
|
|
|
|
mapRef.current = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return <div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />;
|
|
|
|
|
|
}
|