+
+ {isLocationSearch && initialSchools.location_info
+ ? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
+ : `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
+ }
+
+
+ {
+ const params = new URLSearchParams(searchParams);
+ if (e.target.value === 'default') {
+ params.delete('sort');
+ } else {
+ params.set('sort', e.target.value);
+ }
+ router.push(`${pathname}?${params.toString()}`);
+ }}
+ className={styles.sortSelect}
+ >
+ Sort: Relevance
+ {(!isSecondaryView || isMixedView) && Highest Reading, Writing & Maths % }
+ {(!isSecondaryView || isMixedView) && Lowest Reading, Writing & Maths % }
+ {(isSecondaryView || isMixedView) && Highest Attainment 8 }
+ {(isSecondaryView || isMixedView) && Lowest Attainment 8 }
+ {isLocationSearch && Nearest first }
+ Name A–Z
+
+
+
+ );
+
return (
/* The landing arrangement owns its own vertical rhythm (one gap, set in
CSS); the search arrangement is a filter bar directly above its results
and wants none of it. */
-
+
{/* Hero: a Sand panel with the proposition and the search on the left and
the brand landscape bleeding to the panel edge on the right. The
search lives inside the panel here and above the results elsewhere,
@@ -669,43 +770,9 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
)}
- {hasSearch && (
-
-
- {isLocationSearch && initialSchools.location_info
- ? `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} within ${(initialSchools.location_info.radius / 1.60934).toFixed(1)} miles of ${initialSchools.location_info.postcode}`
- : `${initialSchools.total.toLocaleString()} school${initialSchools.total !== 1 ? 's' : ''} found`
- }
-
-
- {resultsView === 'list' && (
- {
- const params = new URLSearchParams(searchParams);
- if (e.target.value === 'default') {
- params.delete('sort');
- } else {
- params.set('sort', e.target.value);
- }
- router.push(`${pathname}?${params.toString()}`);
- }}
- className={styles.sortSelect}
- >
- Sort: Relevance
- {(!isSecondaryView || isMixedView) && Highest Reading, Writing & Maths % }
- {(!isSecondaryView || isMixedView) && Lowest Reading, Writing & Maths % }
- {(isSecondaryView || isMixedView) && Highest Attainment 8 }
- {(isSecondaryView || isMixedView) && Lowest Attainment 8 }
- {isLocationSearch && Nearest first }
- Name A–Z
-
- )}
-
-
- )}
+ {hasSearch && !isMapView && resultsHeader}
- {isSearchActive && (
+ {isSearchActive && !isMapView && (
{searchParams.get('search') &&
Search: {searchParams.get('search')} { e.preventDefault(); }}>× }
{searchParams.get('local_authority') &&
{searchParams.get('local_authority')} }
@@ -724,35 +791,41 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
},
}}
/>
- ) : initialSchools.schools.length > 0 && resultsView === 'map' && isLocationSearch ? (
- /* Map View Layout */
+ ) : isMapView ? (
+ /* Map View Layout: the list beside the map, as one card. */
+
+ {resultsHeader}
+
+ {listPaneShown && mapListSchools.map((school) => (
+
+ ))}
+
+
-
- {(isLoadingMap ? initialSchools.schools : mapSchools).map((school) => (
-
- s.urn === school.urn)}
- nationalAvgRwm={nationalAvgRwm}
- />
-
- ))}
-
-
) : (
/* List View Layout */
@@ -829,8 +902,10 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
s.urn === selectedMapSchool.urn)}
+ isInCompare={compareUrns.includes(selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
+ laAverages={laAverages}
+ showView
/>
)}
@@ -840,100 +915,104 @@ export function HomeView({ initialSchools, filters, totalSchools, howItWorks, ed
);
}
-/* Compact School Item for Map View */
+/* Compact School Item: a card in the map view's list, and the phone's bottom sheet. */
interface CompactSchoolItemProps {
school: School;
onAddToCompare: (school: School) => void;
isInCompare: boolean;
nationalAvgRwm?: number | null;
+ laAverages?: Record
;
+ isSelected?: boolean;
+ /** Clicking the card (not its link or button) picks its pin on the map. */
+ onSelect?: (school: School) => void;
+ /** The bottom sheet has no list around it, so it carries its own View. */
+ showView?: boolean;
}
-function CompactSchoolItem({ school, onAddToCompare, isInCompare, nationalAvgRwm }: CompactSchoolItemProps) {
+function CompactSchoolItem({
+ school, onAddToCompare, isInCompare, nationalAvgRwm, laAverages, isSelected, onSelect, showView,
+}: CompactSchoolItemProps) {
const ofstedBadge = buildOfstedListBadge(school);
- const isSecondary = school.attainment_8_score != null;
+ const special = isSpecialSchool(school);
+ const href = schoolUrl(school.urn, school.school_name);
- // vs-national delta for primary schools
- const rwmDelta =
- !isSecondary && school.rwm_expected_pct != null && nationalAvgRwm != null
- ? Math.round(school.rwm_expected_pct - nationalAvgRwm)
- : null;
+ /*
+ * The headline figure, then its comparison. Same rules as the list rows:
+ * no placeholder all-zero RWM, and no mainstream benchmark for special
+ * schools, PRUs or AP.
+ */
+ let figure: React.ReactNode = null;
+ if (school.attainment_8_score != null) {
+ const laAvg = school.local_authority ? laAverages?.[school.local_authority] : undefined;
+ const diff = !special && laAvg != null
+ ? Math.round((school.attainment_8_score - laAvg) * 10) / 10 : null;
+ figure = (
+ <>
+ {school.attainment_8_score.toFixed(1)} Att 8
+ {diff != null && (
+ = 0.5 ? styles.deltaUp : diff <= -0.5 ? styles.deltaDown : undefined}>
+ {' '}{diff >= 0 ? '+' : ''}{diff} vs LA
+
+ )}
+ >
+ );
+ } else {
+ const rwm = listRwmValue(school);
+ if (rwm != null) {
+ const diff = !special && nationalAvgRwm != null ? Math.round(rwm - nationalAvgRwm) : null;
+ figure = (
+ <>
+ {rwm}% RWM
+ {diff != null && (
+ = 2 ? styles.deltaUp : diff <= -2 ? styles.deltaDown : undefined}>
+ {' '}{diff >= 2 ? `+${diff} pts` : diff <= -2 ? `${diff} pts` : '≈ national'}
+
+ )}
+ >
+ );
+ }
+ }
- const deltaStyle: React.CSSProperties =
- rwmDelta == null
- ? {}
- : rwmDelta >= 2
- ? { fontSize: '0.7rem', color: 'var(--status-above)', fontWeight: 600 }
- : rwmDelta <= -2
- ? { fontSize: '0.7rem', color: 'var(--brand)', fontWeight: 600 }
- : { fontSize: '0.7rem', color: 'var(--text-muted)' };
+ const handleClick = (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest('a, button')) return;
+ onSelect?.(school);
+ };
return (
-
-
-
-
- {school.school_name}
-
- {school.distance !== undefined && school.distance !== null && (
-
- {school.distance.toFixed(1)} mi
-
- )}
-
- {/* Ofsted badge */}
-
-
- {ofstedBadge.label}
-
-
- {/* Headline metric + delta */}
-
-
-
- {isSecondary
- ? (school.attainment_8_score != null ? school.attainment_8_score.toFixed(1) : '-')
- : (school.rwm_expected_pct != null ? `${school.rwm_expected_pct}%` : '-')}
-
- {' '}
- {isSecondary ? 'Att 8' : 'RWM'}
-
- {rwmDelta != null && (
-
- {rwmDelta >= 2
- ? `+${rwmDelta} pts vs national`
- : rwmDelta <= -2
- ? `${rwmDelta} pts vs national`
- : '≈ national avg'}
-
- )}
-
+
+
-
-
onAddToCompare(school)}
- >
- {isInCompare ? '✓ Comparing' : '+ Compare'}
-
-
- View
-
+
+
+ {ofstedBadge.label}
+
+ {school.school_type && {school.school_type} }
+
+
+
+ {figure}
+ {school.total_pupils != null && (
+ <>{figure ? ' · ' : ''}{school.total_pupils.toLocaleString('en-GB')} pupils>
+ )}
+
+
+ {showView &&
View }
+
onAddToCompare(school)}
+ >
+ {isInCompare ? '✓ Comparing' : '+ Compare'}
+
+
);
diff --git a/nextjs-app/components/LeafletMapInner.tsx b/nextjs-app/components/LeafletMapInner.tsx
index 9132b43..e7d1bec 100644
--- a/nextjs-app/components/LeafletMapInner.tsx
+++ b/nextjs-app/components/LeafletMapInner.tsx
@@ -5,213 +5,283 @@
'use client';
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useState } 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',
-});
+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
;
}
+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)';
+
// ---------------------------------------------------------------------------
-// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
+// 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, '"');
}
-interface PopupBadge {
- label: string;
- style: string;
+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],
+ });
}
-function buildPopupBadge(school: School): PopupBadge {
- // A report card wins over any carried-forward legacy grade — its presence is
- // signalled by ofsted_rc_date (the list has no full report_card object, and
- // ofsted_framework is the raw event grouping, never "ReportCard").
- if (school.ofsted_rc_date) {
- const rcYear = new Date(school.ofsted_rc_date).getFullYear();
- return { label: `Report Card · ${rcYear}`, style: 'background:var(--phase-secondary-text);color:var(--text-inverse)' };
- }
- 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:var(--status-above-bg);color:var(--status-above)',
- 2: 'background:var(--status-above-bg);color:var(--status-above)',
- 3: 'background:var(--status-below-bg);color:var(--status-below)',
- 4: 'background:var(--status-below);color:var(--text-inverse)',
- };
- return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
- }
- return { label: 'Not yet inspected', style: 'background:var(--bg-secondary);color:var(--text-muted)' };
+interface CardContext {
+ inCompare: boolean;
+ nationalAvgRwm?: number | null;
+ laAverages?: Record;
}
-export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
+/** 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 = ` ${diff >= 0 ? '+' : ''}${diff} vs LA `;
+ }
+ return `${score.toFixed(1)} 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 = ` ${text} `;
+ }
+ return `${rwm}% 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
+ ? `${school.distance.toFixed(1)} mi ` : '';
+ const type = school.school_type
+ ? `${escapeHtml(school.school_type)} ` : '';
+ const metric = metricHtml(school, ctx);
+ const pupils = school.total_pupils != null
+ ? `${metric ? ' · ' : ''}${school.total_pupils.toLocaleString('en-GB')} pupils` : '';
+ return `
+
+
${escapeHtml(badge.label)} ${type}
+ ${metric || pupils ? `
${metric}${pupils}
` : ''}
+
+
View
+
${ctx.inCompare ? '✓ Comparing' : '+ Compare'}
+
+
`;
+}
+
+export default function LeafletMapInner({
+ schools, center, zoom, referencePoint, radiusMiles, selectedUrn,
+ onMarkerClick, onDeselect, onAddToCompare, compareUrns,
+ nationalAvgRwm, laAverages,
+}: LeafletMapInnerProps) {
const mapRef = useRef(null);
const mapContainerRef = useRef(null);
- const refMarkerRef = useRef(null);
+ const markersRef = useRef>(new Map());
+ const overlayRef = useRef(null);
+ const popupRef = useRef(null);
+ const selectedRef = useRef(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) return;
+ 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: '© OpenStreetMap 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
+ }, []);
- // Initialize map
- if (!mapRef.current) {
- mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
+ // Search location, radius and school pins.
+ useEffect(() => {
+ const map = mapRef.current;
+ const overlay = overlayRef.current;
+ if (!map || !overlay) return;
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
- attribution: '© OpenStreetMap contributors',
- maxZoom: 19,
- }).addTo(mapRef.current);
- }
+ // 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();
- // Clear existing school markers (not the reference pin)
- mapRef.current.eachLayer((layer) => {
- if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
- mapRef.current!.removeLayer(layer);
+ 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);
}
- });
-
- // 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 ');
+ 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);
}
- // 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);
+ 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);
+ }
- // 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 ? 'var(--status-above)' : diff <= -0.5 ? 'var(--brand)' : 'var(--text-muted)';
- 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 ? 'var(--status-above)' : diff <= -2 ? 'var(--brand)' : 'var(--text-muted)';
- 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] });
+ // 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 {
- mapRef.current.setView(center, zoom);
+ 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);
}
- // Cleanup
- return () => {
- // Don't destroy map on every update, just clean markers
- };
- }, [schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages]);
+ 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(() => {
diff --git a/nextjs-app/components/SchoolMap.module.css b/nextjs-app/components/SchoolMap.module.css
index cb61c65..fc16a88 100644
--- a/nextjs-app/components/SchoolMap.module.css
+++ b/nextjs-app/components/SchoolMap.module.css
@@ -74,3 +74,173 @@
font-size: 0.9375rem;
margin: 0;
}
+
+/* ── Results map skin ──────────────────────────────────────────────────────
+ Leaflet renders these outside React, so they are global class names, scoped
+ under .mapWrapper to keep this module pure. Pins are brand teal, the chosen
+ one coral; the search location is ink, ringed. */
+
+/* Muted tiles so the pins, not the streets, carry the colour. */
+.mapWrapper :global(.leaflet-tile-pane) {
+ filter: saturate(0.45);
+}
+
+/* The fullscreen button owns the top-right corner; zoom stacks under it. */
+.mapWrapper :global(.leaflet-top.leaflet-right) {
+ top: 2.75rem;
+}
+
+.mapWrapper :global(.sc-pin) {
+ box-sizing: border-box;
+ border-radius: 50%;
+ background: var(--brand);
+ border: 2.5px solid var(--bg-card);
+ box-shadow: 0 2px 5px rgba(var(--shadow-rgb), 0.35);
+ transition: transform 0.12s ease;
+}
+
+.mapWrapper :global(.sc-pin:hover) {
+ transform: scale(1.15);
+}
+
+.mapWrapper :global(.sc-pin--selected) {
+ background: var(--action);
+ border-width: 3px;
+}
+
+.mapWrapper :global(.sc-pin:focus-visible) {
+ outline: 3px solid var(--text-primary);
+ outline-offset: 2px;
+}
+
+.mapWrapper :global(.sc-home) {
+ border-radius: 50%;
+ background: var(--text-primary);
+ box-shadow: 0 0 0 6px rgba(var(--shadow-rgb), 0.18);
+}
+
+.mapWrapper :global(.sc-radius) {
+ stroke: var(--brand);
+ stroke-width: 2px;
+ stroke-dasharray: 6 6;
+ fill: var(--brand);
+ fill-opacity: 0.05;
+}
+
+.mapWrapper :global(.sc-radius-label) {
+ width: auto !important;
+ height: auto !important;
+ transform-origin: center;
+ translate: -50% -50%;
+ padding: 0.125rem 0.625rem;
+ background: var(--bg-card);
+ border: 1px solid var(--brand);
+ border-radius: 999px;
+ font-family: var(--font-ui);
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--brand-strong);
+ white-space: nowrap;
+}
+
+/* The selected school's card, drawn as a card rather than a speech bubble. */
+.mapWrapper :global(.sc-popup .leaflet-popup-content-wrapper) {
+ border-radius: 14px;
+ box-shadow: 0 10px 28px rgba(var(--shadow-rgb), 0.22);
+}
+
+.mapWrapper :global(.sc-popup .leaflet-popup-content) {
+ margin: 0.875rem 1rem;
+ font-family: var(--font-ui);
+}
+
+.mapWrapper :global(.sc-card) {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.mapWrapper :global(.sc-card-head) {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding-right: 1rem; /* clear of the popup's × */
+}
+
+.mapWrapper :global(.sc-card-name) {
+ font-family: var(--font-display);
+ font-size: 0.9375rem;
+ font-weight: 700;
+ line-height: 1.3;
+ color: var(--text-primary);
+ text-decoration: none;
+}
+
+.mapWrapper :global(.sc-card-name:hover) {
+ text-decoration: underline;
+}
+
+.mapWrapper :global(.sc-dist) {
+ flex-shrink: 0;
+ padding: 0.125rem 0.375rem;
+ border-radius: 4px;
+ background: var(--brand);
+ color: var(--brand-on);
+ font-size: 0.75rem;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.mapWrapper :global(.sc-card-tags) {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem;
+}
+
+.mapWrapper :global(.sc-ofsted),
+.mapWrapper :global(.sc-tag) {
+ padding: 0.125rem 0.4375rem;
+ border-radius: 4px;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.mapWrapper :global(.sc-tag) {
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.mapWrapper :global(.sc-ofsted1),
+.mapWrapper :global(.sc-ofsted2) { background: var(--status-above-bg); color: var(--status-above); }
+.mapWrapper :global(.sc-ofsted3) { background: var(--status-below-bg); color: var(--status-below); }
+.mapWrapper :global(.sc-ofsted4) { background: var(--status-below); color: var(--text-inverse); }
+.mapWrapper :global(.sc-ofstedRc) { background: var(--phase-secondary-text); color: var(--text-inverse); }
+.mapWrapper :global(.sc-ofstedInspected) { background: var(--phase-primary-bg); color: var(--phase-primary-text); }
+.mapWrapper :global(.sc-ofstedPending) { background: var(--border); color: var(--text-muted); }
+
+.mapWrapper :global(.sc-card-metric) {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+
+.mapWrapper :global(.sc-card-metric strong) {
+ font-size: 0.9375rem;
+ color: var(--text-primary);
+}
+
+.mapWrapper :global(.sc-up) { color: var(--status-above); font-weight: 600; }
+.mapWrapper :global(.sc-down) { color: var(--status-below); font-weight: 600; }
+
+.mapWrapper :global(.sc-card-actions) {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.mapWrapper :global(.sc-card-actions > *) {
+ flex: 1;
+ justify-content: center;
+ text-align: center;
+}
diff --git a/nextjs-app/components/SchoolMap.tsx b/nextjs-app/components/SchoolMap.tsx
index dd58f4c..a839daa 100644
--- a/nextjs-app/components/SchoolMap.tsx
+++ b/nextjs-app/components/SchoolMap.tsx
@@ -6,7 +6,7 @@
'use client';
import dynamic from 'next/dynamic';
-import { useRef, useState, useEffect, useCallback } from 'react';
+import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
import type { School } from '@/lib/types';
import styles from './SchoolMap.module.css';
@@ -26,12 +26,17 @@ interface SchoolMapProps {
center?: [number, number];
zoom?: number;
referencePoint?: [number, number];
+ radiusMiles?: number;
+ selectedUrn?: number | null;
onMarkerClick?: (school: School) => void;
+ onDeselect?: () => void;
+ onAddToCompare?: (school: School) => void;
+ compareUrns?: number[];
nationalAvgRwm?: number | null;
laAverages?: Record;
}
-export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: SchoolMapProps) {
+export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarkerClick, nationalAvgRwm, laAverages, ...selection }: SchoolMapProps) {
const wrapperRef = useRef(null);
const [nativeFullscreen, setNativeFullscreen] = useState(false);
// iOS Safari has no Element.requestFullscreen — fall back to a fixed-position
@@ -80,8 +85,9 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
}
}, [fallbackFullscreen]);
- // Calculate center if not provided
- const mapCenter: [number, number] = center || (() => {
+ // Calculate center if not provided. Memoised: a fresh array on every render
+ // would make the map refit and rebuild every pin each time.
+ const mapCenter = useMemo<[number, number]>(() => center || (() => {
if (schools.length === 0) return [51.5074, -0.1278];
if (schools.length === 1 && schools[0].latitude && schools[0].longitude) {
return [schools[0].latitude, schools[0].longitude];
@@ -90,8 +96,8 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
if (validSchools.length === 0) return [51.5074, -0.1278];
const avgLat = validSchools.reduce((sum, s) => sum + (s.latitude || 0), 0) / validSchools.length;
const avgLng = validSchools.reduce((sum, s) => sum + (s.longitude || 0), 0) / validSchools.length;
- return [avgLat, avgLng];
- })();
+ return [avgLat, avgLng] as [number, number];
+ })(), [center, schools]);
return (
@@ -123,6 +129,7 @@ export function SchoolMap({ schools, center, zoom = 13, referencePoint, onMarker
onMarkerClick={onMarkerClick}
nationalAvgRwm={nationalAvgRwm}
laAverages={laAverages}
+ {...selection}
/>
);
diff --git a/nextjs-app/components/SchoolRow.tsx b/nextjs-app/components/SchoolRow.tsx
index 16e73ad..306363a 100644
--- a/nextjs-app/components/SchoolRow.tsx
+++ b/nextjs-app/components/SchoolRow.tsx
@@ -9,7 +9,7 @@
*/
import type { School } from '@/lib/types';
-import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool } from '@/lib/utils';
+import { formatPercentage, calculateTrend, getPhaseStyle, schoolUrl, buildOfstedListBadge, formatAgeRange, isProposedToClose, isSpecialSchool, listRwmValue } from '@/lib/utils';
import styles from './SchoolRow.module.css';
interface SchoolRowProps {
@@ -38,18 +38,10 @@ export function SchoolRow({
school.religious_denomination &&
school.religious_denomination !== 'Does not apply';
- // A placeholder all-zero row (every subject 0 — a special/suppressed
- // signature, matching SchoolDetailView's ks2Placeholder) isn't a real score,
- // so its figure is hidden. A genuine 0% combined (some pupils met individual
- // subjects but not all three) is NOT all-zero and stays shown.
- const rwmPlaceholder =
- school.rwm_expected_pct === 0 &&
- (school.reading_expected_pct ?? 0) === 0 &&
- (school.writing_expected_pct ?? 0) === 0 &&
- (school.maths_expected_pct ?? 0) === 0;
// The school's OWN figure and its year-over-year trend are same-school
- // measures — shown whenever there's a real value, special schools included.
- const showRwmValue = school.rwm_expected_pct != null && !rwmPlaceholder;
+ // measures — shown whenever there's a real value (not the all-zero
+ // placeholder, see listRwmValue), special schools included.
+ const showRwmValue = listRwmValue(school) != null;
// The vs-England delta is a mainstream benchmark: additionally dropped for
// special schools / PRUs / AP, whose pupils aren't measured against it fairly.
const rwmDelta =
diff --git a/nextjs-app/lib/utils.ts b/nextjs-app/lib/utils.ts
index 0cbe3ac..b528e7e 100644
--- a/nextjs-app/lib/utils.ts
+++ b/nextjs-app/lib/utils.ts
@@ -898,3 +898,27 @@ export function isSpecialSchool(school: { school_type?: string | null }): boolea
const t = (school.school_type ?? '').toLowerCase();
return /\bspecial\b/.test(t) || /pupil referral/.test(t) || /alternative provision/.test(t);
}
+
+/**
+ * The school's combined Reading, Writing & Maths figure, or null when there is
+ * no real one to show.
+ *
+ * A placeholder all-zero row (every subject 0, the special/suppressed
+ * signature that SchoolDetailView calls ks2Placeholder) is not a score. A
+ * genuine 0% combined, where some pupils met individual subjects but not all
+ * three, is not all-zero and stays shown.
+ */
+export function listRwmValue(school: {
+ rwm_expected_pct?: number | null;
+ reading_expected_pct?: number | null;
+ writing_expected_pct?: number | null;
+ maths_expected_pct?: number | null;
+}): number | null {
+ if (school.rwm_expected_pct == null) return null;
+ const placeholder =
+ school.rwm_expected_pct === 0 &&
+ (school.reading_expected_pct ?? 0) === 0 &&
+ (school.writing_expected_pct ?? 0) === 0 &&
+ (school.maths_expected_pct ?? 0) === 0;
+ return placeholder ? null : school.rwm_expected_pct;
+}