Files
school_compare/nextjs-app/components/LeafletMapInner.tsx
T
Tudor Sitaru 9c50c49e1f
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 24s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 53s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 13s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
fix(map): add effect deps, escape HTML in popup, document Att8 delta threshold
2026-04-13 14:32:55 +01:00

224 lines
8.7 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 {
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:#d4f0ea;color:#2d7d7d',
2: 'background:rgba(60,140,60,0.12);color:#3c8c3c',
3: 'background:#fef3cd;color:#b8920e',
4: 'background:#fde8e0;color:#e07256',
};
return { label: `${labels[school.ofsted_grade]}${yearStr}`, style: colours[school.ofsted_grade] };
}
if (school.ofsted_framework === 'ReportCard') {
return { label: `Report Card${yearStr}`, style: 'background:#5a3a6e;color:#fff' };
}
return { label: 'Not yet inspected', style: 'background:#e0e0e0;color:#666' };
}
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: #e07256;
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 090 in 0.1 increments; ±0.5 is meaningful here
// vs primary RWM % where ±2 pts is the threshold
const colour = diff >= 0.5 ? '#2d7d7d' : diff <= -0.5 ? '#e07256' : '#8a847a';
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:#1a1612;font-family:Georgia,serif">${score.toFixed(1)}</span>
<span style="font-size:11px;color:#8a847a;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 ? '#2d7d7d' : diff <= -2 ? '#e07256' : '#8a847a';
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:#1a1612;font-family:Georgia,serif">${rwm}%</span>
<span style="font-size:11px;color:#8a847a;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:#1a1612;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:#8a847a;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:#2d7d7d;color:white;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%' }} />;
}