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 { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon'; 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'); } /** Category pictograms drawn on the place dots once zoomed in far enough to read them. */ async function addGlyphs(map: MLMap) { try { const text = await (await fetch(SPRITE)).text(); for (const [cat, icon] of Object.entries(CATEGORY_ICON)) { for (const [tone, color] of [['l', '#ffffff'], ['d', '#1b1b1b']] as const) { const bmp = await glyphBitmap(text, icon, color); if (bmp && !map.hasImage(`ic-${cat}-${tone}`)) map.addImage(`ic-${cat}-${tone}`, bmp); } } if (map.getLayer('place-glyphs')) return; map.addLayer({ id: 'place-glyphs', type: 'symbol', source: 'places', minzoom: 15.5, layout: { 'icon-image': ['concat', 'ic-', ['get', 'cat'], '-', ['match', ['get', 'verdict'], ['partial', 'incomplete'], 'd', 'l']], 'icon-size': ['interpolate', ['linear'], ['zoom'], 15.5, ['match', ['get', 'verdict'], 'incomplete', 0.14, 0.2], 17, ['match', ['get', 'verdict'], 'incomplete', 0.21, 0.3]], 'icon-allow-overlap': true, 'icon-ignore-placement': true, }, }, 'places-selected'); } catch { /* glyphs are decoration: the coloured dots still work without them */ } } 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; /** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */ visible: unknown; 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; /** Public-transport journey to draw: walking legs, rides and the stops where you board / get off. */ journey: JourneyOverlay | null; } export interface JourneyOverlay { walks: LatLon[][]; rides: { path: LatLon[]; mode: 'bus' | 'tram' }[]; stops: { at: LatLon; label: string }[]; } export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion, journey }: 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 journeyMarkers = 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('journey', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addLayer({ id: 'journey-casing', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'ride'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': '#ffffff', 'line-width': 10 } }); map.addLayer({ id: 'journey-ride', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'ride'], layout: { 'line-cap': 'round', 'line-join': 'round' }, paint: { 'line-color': ['match', ['get', 'mode'], 'tram', '#a2461c', '#54278f'], 'line-width': 6 } }); map.addLayer({ id: 'journey-walk', type: 'line', source: 'journey', filter: ['==', ['get', 'kind'], 'walk'], layout: { 'line-cap': 'butt', 'line-join': 'round' }, paint: { 'line-color': '#005ea2', 'line-width': 4.5, 'line-dasharray': [1.5, 1.5] } }); 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]); // Dots with a verdict are always shown; grey "no data" dots only once zoomed in (they are noise at city scale). const dots = (id: string, filter: maplibregl.FilterSpecification, minzoom: number) => map.addLayer({ id, type: 'circle', source: 'places', filter, minzoom, 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', 9, 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], }, }); dots('places', ['!=', ['get', 'verdict'], 'incomplete'], 0); dots('places-nodata', ['==', ['get', 'verdict'], 'incomplete'], 15); void addGlyphs(map); 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 }, }); for (const layer of ['places', 'places-nodata']) { map.on('mouseenter', layer, () => { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseleave', layer, () => { map.getCanvas().style.cursor = ''; }); } map.on('click', ['places', 'places-nodata'], (ev: maplibregl.MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const id = ev.features?.[0]?.properties?.id as string | undefined; if (id) onSelectRef.current(id); }); 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, cat: place.cat }, })), }), [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]); // ---- public-transport journey ---- useEffect(() => { const map = mapRef.current; if (!ready || !map) return; journeyMarkers.current.forEach((m) => m.remove()); journeyMarkers.current = []; const src = map.getSource('journey') as GeoJSONSource; if (!journey) { src.setData({ type: 'FeatureCollection', features: [] }); return; } src.setData({ type: 'FeatureCollection', features: [ ...journey.walks.map((w) => ({ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: w.map(lngLat) }, properties: { kind: 'walk', mode: '' } })), ...journey.rides.map((r) => ({ type: 'Feature' as const, geometry: { type: 'LineString' as const, coordinates: r.path.map(lngLat) }, properties: { kind: 'ride', mode: r.mode } })), ], }); const mark = (el: HTMLElement, at: LatLon, popup?: string) => { const m = new maplibregl.Marker({ element: el }).setLngLat(lngLat(at)); if (popup) m.setPopup(new maplibregl.Popup({ offset: 16 }).setText(popup)); m.addTo(map); journeyMarkers.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) { mark(pin('A', t('route.start'), 'kbb-pin--start'), routeEnds.from); mark(pin('B', t('route.end'), 'kbb-pin--end'), routeEnds.to); } for (const s of journey.stops) { const el = document.createElement('div'); el.className = 'kbb-pin kbb-pin--stop'; el.setAttribute('role', 'img'); el.setAttribute('aria-label', s.label); el.innerHTML = ``; mark(el, s.at, s.label); } const bounds = new maplibregl.LngLatBounds(); [...journey.walks.flat(), ...journey.rides.flatMap((r) => r.path)].forEach((c) => bounds.extend(lngLat(c))); if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 16 }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, journey, 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) => (
  • ))}
)}
); }