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
224 lines
8.7 KiB
TypeScript
224 lines
8.7 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 {
|
||
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: '© <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 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 ? '#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 & 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%' }} />;
|
||
}
|