import * as maplibregl from 'maplibre-gl'; import type { GeoJSONSource, Map as MLMap, StyleSpecification } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useI18n, type Key } from '../lib/i18n'; import type { LatLon } from '../lib/geo'; import type { Hotspot } from '../lib/civic'; import { HARD_FLAGS, type RouteResult } from '../lib/route'; import type { Place, Verdict } from '../lib/types'; import { VERDICT_STYLE } from './ui'; maplibregl.setWorkerUrl(new URL(`${import.meta.env.BASE_URL}maplibre/maplibre-gl-worker.mjs`, document.baseURI).href); const STYLE: StyleSpecification = { version: 8, sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 19, attribution: '© OpenStreetMap', }, }, layers: [{ id: 'osm', type: 'raster', source: 'osm' }], }; STYLE.sources.sat = { type: 'raster', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], tileSize: 256, maxzoom: 19, attribution: 'Imagery © Esri, Maxar, Earthstar Geographics', }; STYLE.layers.push({ id: 'sat', type: 'raster', source: 'sat', layout: { visibility: 'none' } }); function applyBasemap(map: MLMap, b: 'map' | 'satellite') { map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none'); map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none'); } const lngLat = (p: LatLon): [number, number] => [p[1], p[0]]; const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`; interface Props { places: { place: Place; verdict: Verdict }[]; showPlaces: boolean; selectedId: string | null; onSelect: (id: string) => void; route: RouteResult | null; routeEnds: { from: LatLon; to: LatLon } | null; /** Non-selected route options, drawn faintly for comparison. */ altRoutes: LatLon[][]; focus: { at: LatLon; n: number } | null; /** Re-measure when the pane becomes visible. */ visible: boolean; bbox: [number, number, number, number]; online: boolean; basemap: 'map' | 'satellite'; onBasemap: (b: 'map' | 'satellite') => void; userPos: LatLon | null; follow: boolean; reach: [LatLon, LatLon][] | null; hotspots: Hotspot[]; hotspotFocus: Hotspot | null; reduceMotion: boolean; } export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion }: Props) { const { t, lang } = useI18n(); const ref = useRef(null); const mapRef = useRef(null); const markers = useRef([]); const onSelectRef = useRef(onSelect); onSelectRef.current = onSelect; const hotMarkers = useRef([]); const meMarker = useRef(null); const basemapRef = useRef(basemap); basemapRef.current = basemap; const rmRef = useRef(reduceMotion); rmRef.current = reduceMotion; const reduced = () => rmRef.current || window.matchMedia('(prefers-reduced-motion: reduce)').matches; const [ready, setReady] = useState(false); // ---- init ---- useEffect(() => { if (!ref.current) return; const [s, w, n, e] = bbox; const map = new maplibregl.Map({ container: ref.current, style: STYLE, center: [(w + e) / 2, (s + n) / 2], zoom: 15, minZoom: 12, maxBounds: [[w - 0.04, s - 0.03], [e + 0.04, n + 0.03]], attributionControl: { compact: true }, locale: { 'NavigationControl.ZoomIn': t('map.zoomIn'), 'NavigationControl.ZoomOut': t('map.zoomOut'), 'AttributionControl.ToggleAttribution': t('map.attrToggle'), }, }); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); map.on('load', () => { map.addSource('places', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addSource('route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addSource('routeAlt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addLayer({ id: 'route-alt', type: 'line', source: 'routeAlt', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#565c65', 'line-width': 4, 'line-opacity': 0.55 } }); map.addSource('reach', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addLayer({ id: 'reach', type: 'line', source: 'reach', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#0b6e99', 'line-width': 3.5, 'line-opacity': 0.75 }, }); map.addLayer({ id: 'route-casing', type: 'line', source: 'route', layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 9 }, }); const line = (id: string, status: string, color: string, width: number, dash?: number[]) => map.addLayer({ id, type: 'line', source: 'route', filter: ['==', ['get', 'status'], status], layout: { 'line-cap': dash ? 'butt' : 'round', 'line-join': 'round' }, paint: { 'line-color': color, 'line-width': width, ...(dash ? { 'line-dasharray': dash } : {}) }, }); line('route-ok', 'ok', '#005ea2', 5); line('route-warn', 'warn', '#a2461c', 5, [2, 1.5]); line('route-blocked', 'blocked', '#b50909', 6, [0.4, 1.6]); map.addLayer({ id: 'places', type: 'circle', source: 'places', layout: { 'circle-sort-key': ['match', ['get', 'verdict'], 'incomplete', 0, 1] }, paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, ['match', ['get', 'verdict'], 'incomplete', 2.5, 5.5], 17, ['match', ['get', 'verdict'], 'incomplete', 6, 11.5]], 'circle-opacity': ['match', ['get', 'verdict'], 'incomplete', 0.8, 1], 'circle-color': ['match', ['get', 'verdict'], 'match', VERDICT_STYLE.match.hex, 'partial', VERDICT_STYLE.partial.hex, 'conflict', VERDICT_STYLE.conflict.hex, 'fail', VERDICT_STYLE.fail.hex, '#ffffff'], 'circle-stroke-color': ['match', ['get', 'verdict'], 'incomplete', '#565c65', '#ffffff'], 'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2], }, }); map.addLayer({ id: 'places-selected', type: 'circle', source: 'places', filter: ['==', ['get', 'id'], ''], paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 8, 17, 15], 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': '#005ea2', 'circle-stroke-width': 4 }, }); map.on('click', 'places', (ev: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const id = ev.features?.[0]?.properties?.id as string | undefined; if (id) onSelectRef.current(id); }); map.on('mouseenter', 'places', () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', 'places', () => { map.getCanvas().style.cursor = ''; }); applyBasemap(map, basemapRef.current); setReady(true); }); mapRef.current = map; const ro = new ResizeObserver(() => map.resize()); ro.observe(ref.current); return () => { ro.disconnect(); markers.current.forEach((m) => m.remove()); map.remove(); mapRef.current = null; setReady(false); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (visible) mapRef.current?.resize(); }, [visible]); // ---- places ---- const fc = useMemo( () => ({ type: 'FeatureCollection' as const, features: places.map(({ place, verdict }) => ({ type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: [place.lon, place.lat] }, properties: { id: place.id, verdict }, })), }), [places], ); useEffect(() => { const map = mapRef.current; if (!ready || !map) return; (map.getSource('places') as GeoJSONSource).setData(showPlaces ? fc : { type: 'FeatureCollection', features: [] }); }, [ready, fc, showPlaces]); useEffect(() => { const map = mapRef.current; if (!ready || !map) return; map.setFilter('places-selected', ['==', ['get', 'id'], showPlaces ? selectedId ?? '' : '']); const sel = selectedId ? places.find((p) => p.place.id === selectedId)?.place : undefined; if (sel && showPlaces) map.easeTo({ center: [sel.lon, sel.lat], zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 500 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, selectedId, showPlaces]); // ---- route ---- useEffect(() => { const map = mapRef.current; if (!ready || !map) return; markers.current.forEach((m) => m.remove()); markers.current = []; const src = map.getSource('route') as GeoJSONSource; if (!route || route.status === 'none') { src.setData({ type: 'FeatureCollection', features: [] }); return; } src.setData({ type: 'FeatureCollection', features: route.segments.map((s) => { const hard = s.flags.some((f) => HARD_FLAGS.has(f)); const warn = s.flags.some((f) => f !== 'surface-unknown' && f !== 'ramp-steps' && !HARD_FLAGS.has(f)); return { type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: s.coords.map(lngLat) }, properties: { status: hard ? 'blocked' : warn ? 'warn' : 'ok' }, }; }), }); const addMarker = (el: HTMLElement, at: LatLon, popupText?: string) => { const m = new maplibregl.Marker({ element: el }).setLngLat(lngLat(at)); if (popupText) m.setPopup(new maplibregl.Popup({ offset: 16, closeButton: true }).setText(popupText)); m.addTo(map); markers.current.push(m); }; const pin = (label: string, aria: string, cls: string) => { const b = document.createElement('div'); b.className = `kbb-pin ${cls}`; b.textContent = label; b.setAttribute('role', 'img'); b.setAttribute('aria-label', aria); return b; }; if (routeEnds) { addMarker(pin('A', t('route.start'), 'kbb-pin--start'), routeEnds.from); addMarker(pin('B', t('route.end'), 'kbb-pin--end'), routeEnds.to); } for (const b of route.barriers) { if (b.flag === 'surface-unknown' || b.flag === 'ramp-steps') continue; const hard = HARD_FLAGS.has(b.flag); const el = document.createElement('button'); el.type = 'button'; el.className = `kbb-pin kbb-pin--barrier ${hard ? 'kbb-pin--hard' : ''}`; const text = `${t(`flag.${b.flag}` as Key)}${b.name ? ` · ${b.name}` : ''}`; el.setAttribute('aria-label', text); el.innerHTML = ``; addMarker(el, b.at, text); } const bounds = new maplibregl.LngLatBounds(); route.coords.forEach((c) => bounds.extend(lngLat(c))); map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 17 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, route, routeEnds, lang]); useEffect(() => { const map = mapRef.current; if (!ready || !map || !focus) return; map.easeTo({ center: lngLat(focus.at), zoom: Math.max(map.getZoom(), 18), duration: reduced() ? 0 : 500 }); }, [ready, focus]); // ---- basemap (street map / satellite) ---- useEffect(() => { const map = mapRef.current; if (ready && map) applyBasemap(map, basemap); }, [ready, basemap]); // ---- live position (guidance) ---- useEffect(() => { const map = mapRef.current; if (!ready || !map) return; if (!userPos) { meMarker.current?.remove(); meMarker.current = null; return; } if (!meMarker.current) { const el = document.createElement('div'); el.className = 'kbb-me'; el.setAttribute('role', 'img'); el.setAttribute('aria-label', t('guide.you')); meMarker.current = new maplibregl.Marker({ element: el }).setLngLat(lngLat(userPos)).addTo(map); } else meMarker.current.setLngLat(lngLat(userPos)); if (follow) map.easeTo({ center: lngLat(userPos), zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 600 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, userPos, follow]); // ---- alternative routes (context) ---- useEffect(() => { const map = mapRef.current; if (!ready || !map) return; (map.getSource('routeAlt') as GeoJSONSource).setData({ type: 'FeatureCollection', features: altRoutes.map((c) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: c.map(lngLat) } })), }); }, [ready, altRoutes]); // ---- reach ---- useEffect(() => { const map = mapRef.current; if (!ready || !map) return; (map.getSource('reach') as GeoJSONSource).setData({ type: 'FeatureCollection', features: (reach ?? []).map(([a, b]) => ({ type: 'Feature' as const, properties: {}, geometry: { type: 'LineString' as const, coordinates: [lngLat(a), lngLat(b)] } })), }); if (reach && reach.length) { const bounds = new maplibregl.LngLatBounds(); reach.forEach(([a, b]) => { bounds.extend(lngLat(a)); bounds.extend(lngLat(b)); }); map.fitBounds(bounds, { padding: 60, duration: reduced() ? 0 : 500, maxZoom: 17 }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, reach]); // ---- hotspots (city panel) ---- useEffect(() => { const map = mapRef.current; if (!ready || !map) return; hotMarkers.current.forEach((m) => m.remove()); hotMarkers.current = hotspots.map((h, i) => { const el = document.createElement('button'); el.type = 'button'; el.className = `kbb-pin kbb-pin--hard`; el.textContent = String(i + 1); const label = `${i + 1}. ${h.street ?? ''} — ${t(h.kerb === 'raised' ? 'dash.hot.kerbRaised' : 'dash.hot.kerbUnknown')}`; el.setAttribute('aria-label', label); return new maplibregl.Marker({ element: el }).setLngLat(lngLat(h.at)).setPopup(new maplibregl.Popup({ offset: 16 }).setText(label)).addTo(map); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, hotspots, lang]); useEffect(() => { const map = mapRef.current; if (!ready || !map || !hotspotFocus) return; map.easeTo({ center: lngLat(hotspotFocus.at), zoom: 18, duration: reduced() ? 0 : 500 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, hotspotFocus]); return (

{t('map.alt')}

{!online &&

{t('map.offline')}

} {showPlaces && (
{t('map.legend')}
    {(['match', 'partial', 'conflict', 'incomplete', 'fail'] as Verdict[]).map((v) => (
  • ))}
)}
); }