import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import './trailMap.css'; /** * A map, for things that have a position. * * ── Why leaflet directly and not react-leaflet ────────────────────────────── * * A leaflet map is an imperative object that owns a DOM node and must be torn * down by hand — `remove()`, or the tile layer keeps fetching and the container * keeps its `_leaflet_id` and refuses to be reused. React-leaflet wraps that in * components and adds a second package that has to track React's major version * forever. The wrapping is about forty lines; it is written here instead. * * ── Why the pins are divIcons ─────────────────────────────────────────────── * * Leaflet's default marker is a PNG resolved relative to the stylesheet, which * every bundler rewrites and breaks — the classic "markers are invisible" * bug, usually patched by re-pointing the icon URLs at a CDN. A `divIcon` is * markup, so it ships with the bundle, takes the console's brand colour and * needs no image at all. */ export interface MapTrail { id: string | number; label: string; points: readonly { lat: number; lng: number }[]; colour: string; } export interface MapPin { id: string | number; lat: number; lng: number; label: string; /** Shown under the label in the popup. Plain text, one line per entry. */ lines?: string[]; colour?: string; /** A hollow ring rather than a filled pin — for a last-known, not-live point. */ isFaded?: boolean; } /** Tamil Nadu, so an empty map still shows the right part of the world. */ const FALLBACK: L.LatLngExpression = [11.0168, 76.9558]; export function TrailMap({ trails = [], pins = [], height = 380, emptyNote = 'Nothing to place on the map yet.', }: { trails?: readonly MapTrail[]; pins?: readonly MapPin[]; height?: number; emptyNote?: string; }) { const host = useRef(null); const map = useRef(null); /* Everything drawn, kept together so a redraw clears exactly what it drew. Clearing the map wholesale would take the tile layer with it. */ const drawn = useRef(null); const isEmpty = trails.every((trail) => trail.points.length === 0) && pins.length === 0; useEffect(() => { if (!host.current || map.current) return; const instance = L.map(host.current, { center: FALLBACK, zoom: 12, // The console scrolls; a wheel over the map should scroll the page, not // zoom. Ctrl+wheel and the +/− buttons still zoom, which is what people // expect from a map embedded in a document. scrollWheelZoom: false, attributionControl: true, }); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '© OpenStreetMap contributors', }).addTo(instance); drawn.current = L.layerGroup().addTo(instance); map.current = instance; return () => { instance.remove(); map.current = null; drawn.current = null; }; }, []); useEffect(() => { const instance = map.current; const layer = drawn.current; if (!instance || !layer) return; layer.clearLayers(); const bounds = L.latLngBounds([]); for (const trail of trails) { if (trail.points.length < 2) continue; const line = trail.points.map((point) => [point.lat, point.lng] as [number, number]); L.polyline(line, { color: trail.colour, weight: 3, opacity: 0.85, // Rounded joins, or a dense GPS trail draws spikes at every turn. lineJoin: 'round', lineCap: 'round', }) .bindTooltip(trail.label, { sticky: true }) .addTo(layer); line.forEach((point) => bounds.extend(point)); } for (const pin of pins) { const colour = pin.colour ?? 'var(--color-brand)'; L.marker([pin.lat, pin.lng], { title: pin.label, icon: L.divIcon({ className: 'trail-pin-wrap', html: ``, iconSize: [16, 16], iconAnchor: [8, 8], }), }) .bindPopup( `${escapeHtml(pin.label)}` + (pin.lines ?? []).map((line) => `
${escapeHtml(line)}`).join(''), ) .addTo(layer); bounds.extend([pin.lat, pin.lng]); } if (bounds.isValid()) { // `maxZoom` matters: a single pin, or a rider who never left one street, // otherwise zooms to building level and the map shows one grey rectangle // with no landmarks to orient by. instance.fitBounds(bounds, { padding: [28, 28], maxZoom: 16 }); } }, [trails, pins]); /* Leaflet measures its container once, at construction. Inside a drawer or a tab the container is often zero-height at that moment, and the map renders as a grey strip with one tile in the corner until something resizes the window. Re-measuring whenever the box changes size fixes it for good, including when the drawer animates open. */ useEffect(() => { const node = host.current; const instance = map.current; if (!node || !instance || typeof ResizeObserver === 'undefined') return; const observer = new ResizeObserver(() => instance.invalidateSize()); observer.observe(node); return () => observer.disconnect(); }, []); return (
{isEmpty ?
{emptyNote}
: null}
); } /** * Distinct, legible line colours. * * Hand-picked rather than generated from a hue wheel: evenly spaced hues put * two yellows next to each other on an OSM tile and both vanish. These are all * dark enough to read over map detail and different enough to tell apart at the * width of a polyline. */ export const TRAIL_COLOURS = [ '#662582', '#0f8a5f', '#c2410c', '#1d4ed8', '#b91c1c', '#0e7490', '#7c2d12', '#4d7c0f', ] as const; export function trailColour(index: number): string { return TRAIL_COLOURS[index % TRAIL_COLOURS.length] as string; } function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } /** Popup and icon HTML is a string, so a rider named `` must not become markup. */ function escapeAttr(value: string): string { return value.replace(/["'<>]/g, ''); }