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
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
228 lines
9.2 KiB
TypeScript
228 lines
9.2 KiB
TypeScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
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: '© <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 & 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%' }} />;
|
||
}
|