PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 21s
Trial the map as the default for every postcode search, not only on desktop (DEFAULT_LOCATION_VIEW in HomeView; results_view_changed shows how often readers switch away). The view is now derived: the reader's choice once made, else the default for the kind of search. That follows a hero search through client-side navigation without syncing state. With nothing left varying by device, the user-agent default goes (lib/device.ts, the headers() read in page.tsx). That settles two review points: the HTML no longer needs Vary: User-Agent, and there is no narrow-window fallback left to flash the map before the list. Review fix in LeafletMapInner: the card reopened only when the selection or the school list changed, so a rebuild caused by the centre, reference point or radius closed it for good while the page still had the school selected. The selection effect now follows a pins-rebuilt counter. SchoolMap memoises its fallback centre, which was a fresh array on every render and would have rebuilt every pin each time. Phones hide the map view's list pane, so it no longer builds up to 500 hidden cards there (decided after mount, so hydration still matches). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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, 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 && 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%' }} />;
|
||
}
|