2026-02-02 20:34:35 +00:00
|
|
|
|
/**
|
|
|
|
|
|
* LeafletMapInner Component
|
|
|
|
|
|
* Internal Leaflet map implementation (client-side only)
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
'use client';
|
|
|
|
|
|
|
2026-09-30 21:50:34 +01:00
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
import L from 'leaflet';
|
|
|
|
|
|
import 'leaflet/dist/leaflet.css';
|
|
|
|
|
|
import type { School } from '@/lib/types';
|
2026-09-30 21:30:33 +01:00
|
|
|
|
import { schoolUrl, isSpecialSchool, buildOfstedListBadge, listRwmValue } from '@/lib/utils';
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
|
|
|
|
|
interface LeafletMapInnerProps {
|
|
|
|
|
|
schools: School[];
|
|
|
|
|
|
center: [number, number];
|
|
|
|
|
|
zoom: number;
|
2026-03-30 09:13:14 +01:00
|
|
|
|
referencePoint?: [number, number];
|
2026-09-30 21:30:33 +01:00
|
|
|
|
/** 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;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
onMarkerClick?: (school: School) => void;
|
2026-09-30 21:30:33 +01:00
|
|
|
|
/** The open card was closed from the map itself (its × or a click elsewhere). */
|
|
|
|
|
|
onDeselect?: () => void;
|
|
|
|
|
|
onAddToCompare?: (school: School) => void;
|
|
|
|
|
|
compareUrns?: number[];
|
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-09-30 21:30:33 +01:00
|
|
|
|
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)';
|
|
|
|
|
|
|
2026-04-13 14:29:55 +01:00
|
|
|
|
// ---------------------------------------------------------------------------
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// Card helpers (must work in plain JS string templates — no React / CSS Modules)
|
|
|
|
|
|
// Class names are global and styled in SchoolMap.module.css.
|
2026-04-13 14:29:55 +01:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-04-13 14:32:55 +01:00
|
|
|
|
function escapeHtml(s: string): string {
|
|
|
|
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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],
|
|
|
|
|
|
});
|
2026-04-13 14:29:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
interface CardContext {
|
|
|
|
|
|
inCompare: boolean;
|
|
|
|
|
|
nationalAvgRwm?: number | null;
|
|
|
|
|
|
laAverages?: Record<string, number | null>;
|
2026-04-13 14:29:55 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
/** 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 = ` <span class="${cls}">${diff >= 0 ? '+' : ''}${diff} vs LA</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<strong>${score.toFixed(1)}</strong> 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 = ` <span class="${cls}">${text}</span>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `<strong>${rwm}%</strong> 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
|
|
|
|
|
|
? `<span class="sc-dist">${school.distance.toFixed(1)} mi</span>` : '';
|
|
|
|
|
|
const type = school.school_type
|
|
|
|
|
|
? `<span class="sc-tag">${escapeHtml(school.school_type)}</span>` : '';
|
|
|
|
|
|
const metric = metricHtml(school, ctx);
|
|
|
|
|
|
const pupils = school.total_pupils != null
|
|
|
|
|
|
? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
|
|
|
|
|
|
return `<div class="sc-card">
|
|
|
|
|
|
<div class="sc-card-head"><a class="sc-card-name" href="${href}">${escapeHtml(school.school_name)}</a>${distance}</div>
|
|
|
|
|
|
<div class="sc-card-tags"><span class="sc-ofsted sc-${badge.cssClass}">${escapeHtml(badge.label)}</span>${type}</div>
|
|
|
|
|
|
${metric || pupils ? `<div class="sc-card-metric">${metric}${pupils}</div>` : ''}
|
|
|
|
|
|
<div class="sc-card-actions">
|
|
|
|
|
|
<a class="btn btn-tertiary btn-sm" href="${href}">View</a>
|
|
|
|
|
|
<button type="button" class="btn ${ctx.inCompare ? 'btn-active' : 'btn-secondary'} btn-sm" data-compare>${ctx.inCompare ? '✓ Comparing' : '+ Compare'}</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function LeafletMapInner({
|
|
|
|
|
|
schools, center, zoom, referencePoint, radiusMiles, selectedUrn,
|
|
|
|
|
|
onMarkerClick, onDeselect, onAddToCompare, compareUrns,
|
|
|
|
|
|
nationalAvgRwm, laAverages,
|
|
|
|
|
|
}: LeafletMapInnerProps) {
|
2026-02-02 20:34:35 +00:00
|
|
|
|
const mapRef = useRef<L.Map | null>(null);
|
|
|
|
|
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
2026-09-30 21:30:33 +01:00
|
|
|
|
const markersRef = useRef<Map<number, L.Marker>>(new Map());
|
|
|
|
|
|
const overlayRef = useRef<L.LayerGroup | null>(null);
|
|
|
|
|
|
const popupRef = useRef<L.Popup | null>(null);
|
|
|
|
|
|
const selectedRef = useRef<number | null>(null);
|
2026-09-30 21:50:34 +01:00
|
|
|
|
// 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);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// 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.
|
2026-02-02 20:34:35 +00:00
|
|
|
|
useEffect(() => {
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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
|
|
|
|
|
|
}, []);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// Search location, radius and school pins.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
|
const overlay = overlayRef.current;
|
|
|
|
|
|
if (!map || !overlay) return;
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// 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();
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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);
|
2026-03-30 09:13:14 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-02 20:34:35 +00:00
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
// 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] });
|
2026-02-02 20:34:35 +00:00
|
|
|
|
} else {
|
2026-09-30 21:30:33 +01:00
|
|
|
|
map.setView(center, zoom);
|
|
|
|
|
|
}
|
2026-09-30 21:50:34 +01:00
|
|
|
|
setPinsVersion(v => v + 1);
|
2026-09-30 21:30:33 +01:00
|
|
|
|
}, [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);
|
2026-02-02 20:34:35 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-30 21:30:33 +01:00
|
|
|
|
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
|
2026-09-30 21:50:34 +01:00
|
|
|
|
}, [selectedUrn, pinsVersion]);
|
2026-09-30 21:30:33 +01:00
|
|
|
|
|
|
|
|
|
|
// 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]);
|
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%' }} />;
|
|
|
|
|
|
}
|