980615b8e6
CI / Test (push) Successful in 18s
CI / Lint & Typecheck (push) Successful in 27s
CI / Build & Push Docker Image (push) Successful in 14s
CI / Test (pull_request) Successful in 20s
CI / Lint & Typecheck (pull_request) Successful in 30s
CI / Build & Push Docker Image (pull_request) Successful in 41s
98 lines
3.0 KiB
TypeScript
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='© <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>
|
|
);
|
|
}
|