298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
/**
|
||
* 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, isIndependentSchool, 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<string, number | null>;
|
||
}
|
||
|
||
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, '>').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<string, number | null>;
|
||
}
|
||
|
||
/** 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 && !isIndependentSchool(school) && 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) {
|
||
const mapRef = useRef<L.Map | null>(null);
|
||
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||
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);
|
||
// 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: '© <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
|
||
}, []);
|
||
|
||
// 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 <div ref={mapContainerRef} style={{ width: '100%', height: '100%' }} />;
|
||
}
|