feat(search): align the map view with the mockup and open it on desktop
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m14s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 19s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m20s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s

The map view now follows option B of the results-controls mockups:

- The list sits in a pane on the left, under the result count and sort,
  and the map fills the rest of the screen below the pinned toolbar. The
  toolbar's height is measured, so the split ends 1rem above the bottom
  of the screen however the controls wrap.
- Pins are brand-teal dots and the selected one is coral. The search
  location is an ink dot, the search radius a dashed circle with its
  distance, and the view fits that circle. Tiles are muted and the zoom
  sits under the fullscreen button.
- A school picked on the map or in the list opens a card on the map
  (View, + Compare, following the basket), and its list card is ringed
  and scrolled into view. Phones keep the bottom sheet.
- The list cards show the full name, Ofsted and school type, the
  headline figure and pupils.

The map cards now follow the list rows: no England benchmark for
special schools, PRUs and AP, and no placeholder all-zero RWM (Greenmead
showed "0% RWM -62 pts vs national"). That rule moves to a shared
listRwmValue helper.

A postcode search opens on the map for desktop browsers, chosen on the
server from the user agent so the list never paints first. The client
falls back to the list below 1024px, and follows the default through
client-side navigation from the hero until the reader picks a view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5.5 committed 2026-09-30 21:30:33 +01:00
1 parent 0e177ca2ec
commit 4e4b30e812
14 files changed
+1118 -440

No files matched your search

+232 -167
View File
@@ -9,209 +9,274 @@ import { useEffect, useRef } 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<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)';
// ---------------------------------------------------------------------------
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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<number, string> = { 1: 'Outstanding', 2: 'Good', 3: 'Req. Improvement', 4: 'Inadequate' };
const colours: Record<number, string> = {
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<string, number | null>;
}
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 = ` <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 refMarkerRef = useRef<L.Marker | null>(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);
// 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: '&copy; <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
}, []);
// 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> 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: `<div style="
width: 20px; height: 20px;
background: var(--brand);
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>');
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 = `<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:var(--text-primary);font-family:Georgia,serif">${score.toFixed(1)}</span>
<span style="font-size:11px;color:var(--text-muted);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 ? '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 = `<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:var(--text-primary);font-family:Georgia,serif">${rwm}%</span>
<span style="font-size:11px;color:var(--text-muted);margin-left:4px">Reading, Writing &amp; 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">
<strong style="font-size:13px;color:var(--text-primary);line-height:1.3">${escapeHtml(school.school_name)}</strong>
<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:var(--text-muted);margin-bottom:8px">
${phaseLabel}${school.local_authority ? ` · ${escapeHtml(school.local_authority)}` : ''}${distanceStr}
</div>
${metricHtml}
<a href="${slug}" style="display:block;text-align:center;padding:6px;background:var(--status-above);color:var(--text-inverse);border-radius:5px;text-decoration:none;font-size:12px;font-weight:600;margin-top:8px">View Details →</a>
</div>`;
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);
}
}, [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, schools]);
// 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(() => {