Files
school_compare/nextjs-app/components/LeafletMapInner.tsx
T
TudorandClaude Opus 5 a7829d591a
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 46s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 15s
fix(map): the popup never took the dark theme
leaflet.css paints `background: white; color: #333` on the popup card and its
tip. LeafletMapInner binds themed content into it — the school name and the
headline figure are var(--text-primary) — so in dark mode #E9EEF0 landed on
#FFFFFF at 1.17:1. The two things the popup exists to say were the two least
readable things on the page.

Every other foreground in that popup failed too, from the same cause: the
muted phase line at 2.90:1, the vs-national delta at 1.94:1, the Ofsted badge
at 1.74:1. Moving the surface onto --bg-card fixes all of them at once —
13.52, 5.45, 8.14 and 9.11:1 respectively. In light mode --bg-card is #FFFFFF,
so the popup renders exactly as it did.

globals.css already pulls the rest of Leaflet's chrome onto the tokens, and
says why: "this matters most in dark mode, where Leaflet's white attribution
bar would otherwise sit on a near-black page." The popup was simply missed.

The View Details button needed its own fix. It pairs background:var(--status-
above) with a literal white label, which theming the card does not reach:
--status-above is #36743F in light but #7FCB8A in dark, taking the label from
5.63:1 to 1.94:1. --text-inverse is the token for ink on a saturated fill, and
the popup's own Ofsted badge already uses it.

darkThemeSafety already guards this defect class, but only inside .module.css.
Neither half of this one lives there — the surface is a third party's, the
text is inline in a TSX template — so it scanned clean throughout. Two rules
added for the layer it could not see. Fixing the grouped-selector blind spot
in its rules() helper was needed to write them: taking only a selector's last
line discarded every selector in a grouped rule but the final one, which makes
a safety guard fail open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
2026-08-27 22:34:18 +01:00

228 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* LeafletMapInner Component
* Internal Leaflet map implementation (client-side only)
*/
'use client';
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',
});
interface LeafletMapInnerProps {
schools: School[];
center: [number, number];
zoom: number;
referencePoint?: [number, number];
onMarkerClick?: (school: School) => void;
nationalAvgRwm?: number | null;
laAverages?: Record<string, number | null>;
}
// ---------------------------------------------------------------------------
// Popup helpers (must work in plain JS string templates — no React / CSS Modules)
// ---------------------------------------------------------------------------
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 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)' };
}
export default function LeafletMapInner({ schools, center, zoom, referencePoint, onMarkerClick, nationalAvgRwm, laAverages }: LeafletMapInnerProps) {
const mapRef = useRef<L.Map | null>(null);
const mapContainerRef = useRef<HTMLDivElement>(null);
const refMarkerRef = useRef<L.Marker | null>(null);
useEffect(() => {
if (!mapContainerRef.current) return;
// Initialize map
if (!mapRef.current) {
mapRef.current = L.map(mapContainerRef.current).setView(center, zoom);
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);
}
// Clear existing school markers (not the reference pin)
mapRef.current.eachLayer((layer) => {
if (layer instanceof L.Marker && layer !== refMarkerRef.current) {
mapRef.current!.removeLayer(layer);
}
});
// 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>');
}
// 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);
// 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] });
} else {
mapRef.current.setView(center, zoom);
}
// Cleanup
return () => {
// Don't destroy map on every update, just clean markers
};
}, [schools, center, zoom, referencePoint, onMarkerClick, 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%' }} />;
}