169 lines
5.8 KiB
TypeScript
169 lines
5.8 KiB
TypeScript
/**
|
|||
|
|
* LeafletCutoffMapInner
|
||
|
|
* The cut-off distances drawn around the school.
|
||
|
|
*
|
||
|
|
* L.circle takes a radius in metres and projects it properly, which is exactly
|
||
|
|
* what these rings are: a straight-line distance from the school. (L.circleMarker
|
||
|
|
* would take pixels and would silently stop meaning anything as the user zoomed.)
|
||
|
|
*
|
||
|
|
* The latest year is filled and labelled; earlier years are hairlines behind it,
|
||
|
|
* so a tightening cut-off reads as a set of shrinking circles without needing a
|
||
|
|
* legend to decode it. The map auto-fits the widest ring, so the whole history
|
||
|
|
* is in frame whatever the distances are.
|
||
|
|
*/
|
||
|
|
|
||
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useRef } from 'react';
|
||
|
|
import L from 'leaflet';
|
||
|
|
import 'leaflet/dist/leaflet.css';
|
||
|
|
|
||
|
|
export interface CutoffRing {
|
||
|
|
year: number;
|
||
|
|
radiusM: number;
|
||
|
|
latest: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
lat: number;
|
||
|
|
lng: number;
|
||
|
|
rings: CutoffRing[];
|
||
|
|
/** The parent's geocoded postcode, once they have entered one. */
|
||
|
|
home: { lat: number; lng: number } | null;
|
||
|
|
interactive: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const SCHOOL_PIN = `
|
||
|
|
<div style="position:relative;width:22px;height:22px">
|
||
|
|
<span style="position:absolute;left:50%;top:50%;width:26px;height:26px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand-bg)"></span>
|
||
|
|
<span style="position:absolute;left:50%;top:50%;width:14px;height:14px;transform:translate(-50%,-50%);border-radius:50%;background:var(--brand);border:3px solid var(--bg-card)"></span>
|
||
|
|
</div>`;
|
||
|
|
|
||
|
|
// A square rotated 45° — a different SHAPE from the school's circle, not just a
|
||
|
|
// different colour, so the two are still distinguishable to anyone who cannot
|
||
|
|
// separate the hues.
|
||
|
|
const HOME_PIN = `
|
||
|
|
<div style="width:18px;height:18px;display:grid;place-items:center">
|
||
|
|
<span style="width:12px;height:12px;background:var(--status-above);border:2.5px solid var(--bg-card);transform:rotate(45deg);border-radius:2px"></span>
|
||
|
|
</div>`;
|
||
|
|
|
||
|
|
function cssVar(name: string, fallback: string): string {
|
||
|
|
if (typeof window === 'undefined') return fallback;
|
||
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function LeafletCutoffMapInner({ lat, lng, rings, home, interactive }: Props) {
|
||
|
|
const elRef = useRef<HTMLDivElement>(null);
|
||
|
|
const mapRef = useRef<L.Map | null>(null);
|
||
|
|
const layersRef = useRef<L.Layer[]>([]);
|
||
|
|
const zoomCtrlRef = useRef<L.Control.Zoom | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!elRef.current || mapRef.current) return;
|
||
|
|
const map = L.map(elRef.current, {
|
||
|
|
zoomControl: false,
|
||
|
|
attributionControl: true,
|
||
|
|
dragging: false,
|
||
|
|
scrollWheelZoom: false,
|
||
|
|
doubleClickZoom: false,
|
||
|
|
boxZoom: false,
|
||
|
|
keyboard: false,
|
||
|
|
touchZoom: false,
|
||
|
|
}).setView([lat, lng], 14);
|
||
|
|
|
||
|
|
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(map);
|
||
|
|
|
||
|
|
mapRef.current = map;
|
||
|
|
setTimeout(() => map.invalidateSize(), 60);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
map.remove();
|
||
|
|
mapRef.current = null;
|
||
|
|
layersRef.current = [];
|
||
|
|
zoomCtrlRef.current = null;
|
||
|
|
};
|
||
|
|
}, [lat, lng]);
|
||
|
|
|
||
|
|
// Redraw rings, pins and the viewport whenever the data changes.
|
||
|
|
useEffect(() => {
|
||
|
|
const map = mapRef.current;
|
||
|
|
if (!map) return;
|
||
|
|
|
||
|
|
layersRef.current.forEach((l) => l.remove());
|
||
|
|
layersRef.current = [];
|
||
|
|
|
||
|
|
const brand = cssVar('--brand', '#0F766E');
|
||
|
|
const above = cssVar('--status-above', '#2F855A');
|
||
|
|
|
||
|
|
// Widest first, so the small recent ring is not buried under the old ones.
|
||
|
|
const ordered = [...rings].sort((a, b) => b.radiusM - a.radiusM);
|
||
|
|
for (const r of ordered) {
|
||
|
|
const circle = L.circle([lat, lng], {
|
||
|
|
radius: r.radiusM,
|
||
|
|
color: brand,
|
||
|
|
weight: r.latest ? 3 : 1.5,
|
||
|
|
opacity: r.latest ? 1 : 0.45,
|
||
|
|
fill: r.latest,
|
||
|
|
fillColor: brand,
|
||
|
|
fillOpacity: r.latest ? 0.12 : 0,
|
||
|
|
interactive: false,
|
||
|
|
}).addTo(map);
|
||
|
|
layersRef.current.push(circle);
|
||
|
|
}
|
||
|
|
|
||
|
|
const school = L.marker([lat, lng], {
|
||
|
|
icon: L.divIcon({ className: '', iconSize: [22, 22], iconAnchor: [11, 11], html: SCHOOL_PIN }),
|
||
|
|
keyboard: false,
|
||
|
|
interactive: false,
|
||
|
|
}).addTo(map);
|
||
|
|
layersRef.current.push(school);
|
||
|
|
|
||
|
|
if (home) {
|
||
|
|
const pin = L.marker([home.lat, home.lng], {
|
||
|
|
icon: L.divIcon({ className: '', iconSize: [18, 18], iconAnchor: [9, 9], html: HOME_PIN }),
|
||
|
|
keyboard: false,
|
||
|
|
interactive: false,
|
||
|
|
}).addTo(map);
|
||
|
|
layersRef.current.push(pin);
|
||
|
|
|
||
|
|
const line = L.polyline([[lat, lng], [home.lat, home.lng]], {
|
||
|
|
color: above,
|
||
|
|
weight: 2,
|
||
|
|
dashArray: '5 5',
|
||
|
|
opacity: 0.85,
|
||
|
|
interactive: false,
|
||
|
|
}).addTo(map);
|
||
|
|
layersRef.current.push(line);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Frame the widest ring — and the home pin when it falls outside it, so a
|
||
|
|
// family beyond every cut-off can still see where they sit.
|
||
|
|
const widest = ordered[0]?.radiusM ?? 500;
|
||
|
|
let bounds = L.latLng(lat, lng).toBounds(widest * 2.2);
|
||
|
|
if (home) bounds = bounds.extend(L.latLng(home.lat, home.lng));
|
||
|
|
map.fitBounds(bounds, { padding: [16, 16] });
|
||
|
|
}, [lat, lng, rings, home]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const map = mapRef.current;
|
||
|
|
if (!map) return;
|
||
|
|
const handlers = [
|
||
|
|
map.dragging, map.scrollWheelZoom, map.doubleClickZoom, map.boxZoom, map.keyboard, map.touchZoom,
|
||
|
|
];
|
||
|
|
handlers.forEach((h) => { if (h) { interactive ? h.enable() : h.disable(); } });
|
||
|
|
|
||
|
|
if (interactive && !zoomCtrlRef.current) {
|
||
|
|
zoomCtrlRef.current = L.control.zoom({ position: 'topleft' });
|
||
|
|
zoomCtrlRef.current.addTo(map);
|
||
|
|
} else if (!interactive && zoomCtrlRef.current) {
|
||
|
|
zoomCtrlRef.current.remove();
|
||
|
|
zoomCtrlRef.current = null;
|
||
|
|
}
|
||
|
|
setTimeout(() => map.invalidateSize(), 80);
|
||
|
|
}, [interactive]);
|
||
|
|
|
||
|
|
return <div ref={elRef} style={{ width: '100%', height: '100%' }} />;
|
||
|
|
}
|