Files
web/src/components/RouteMap.tsx
T
Flea Flicker c58e4e4b23
CI / Test (push) Successful in 24s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Image (push) Successful in 12s
feat(GRO-2158): route planner page at /admin/routes (#60)
2026-06-09 01:50:49 +00:00

98 lines
3.0 KiB
TypeScript

import { useEffect } from "react";
import {
MapContainer,
TileLayer,
Marker,
Polyline,
Tooltip,
useMap,
} from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// This component is loaded via React.lazy from the route planner page so that
// Leaflet + react-leaflet land in a separate code-split chunk and never weigh
// down the main admin bundle.
export interface RouteMapStop {
id: string;
stopOrder: number;
latitude: number;
longitude: number;
clientName: string;
}
interface RouteMapProps {
stops: RouteMapStop[];
primaryColor: string;
}
/** A numbered teardrop pin rendered as an inline-SVG divIcon (no image assets). */
function numberedIcon(order: number, color: string): L.DivIcon {
return L.divIcon({
className: "route-stop-pin",
html: `<div style="position:relative;width:28px;height:40px">
<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
<path d="M14 0C6.27 0 0 6.27 0 14c0 9.5 14 26 14 26s14-16.5 14-26C28 6.27 21.73 0 14 0z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
</svg>
<span style="position:absolute;top:5px;left:0;width:28px;text-align:center;color:#fff;font-size:13px;font-weight:700;font-family:system-ui,sans-serif">${order}</span>
</div>`,
iconSize: [28, 40],
iconAnchor: [14, 40],
tooltipAnchor: [0, -34],
});
}
/** Keeps the viewport framed around all stops whenever the route changes. */
function FitBounds({ stops }: { stops: RouteMapStop[] }) {
const map = useMap();
useEffect(() => {
if (stops.length === 0) return;
const latlngs = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
if (latlngs.length === 1) {
map.setView(latlngs[0]!, 14);
} else {
map.fitBounds(L.latLngBounds(latlngs), { padding: [40, 40] });
}
}, [map, stops]);
return null;
}
export default function RouteMap({ stops, primaryColor }: RouteMapProps) {
// Fallback centre (London) only used briefly before FitBounds runs or when the
// route has no geocoded stops.
const center: [number, number] = stops[0]
? [stops[0].latitude, stops[0].longitude]
: [51.505, -0.09];
const line = stops.map((s) => [s.latitude, s.longitude] as [number, number]);
return (
<MapContainer
center={center}
zoom={12}
scrollWheelZoom
style={{ height: "100%", width: "100%", borderRadius: 8 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{line.length >= 2 && (
<Polyline positions={line} color={primaryColor} weight={4} opacity={0.7} />
)}
{stops.map((s) => (
<Marker
key={s.id}
position={[s.latitude, s.longitude]}
icon={numberedIcon(s.stopOrder, primaryColor)}
>
<Tooltip>
{s.stopOrder}. {s.clientName}
</Tooltip>
</Marker>
))}
<FitBounds stops={stops} />
</MapContainer>
);
}