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 { 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' }],
};
const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
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;
focus: { at: LatLon; n: number } | null;
/** Re-measure when the pane becomes visible. */
visible: boolean;
bbox: [number, number, number, number];
online: boolean;
}
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, focus, visible, bbox, online }: Props) {
const { t, lang } = useI18n();
const ref = useRef(null);
const mapRef = useRef(null);
const markers = useRef([]);
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
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.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',
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, 3.5, 17, 9],
'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', '#1b1b1b'],
'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 2.5, 1.5],
},
});
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 = ''; });
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: reduceMotion() ? 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: reduceMotion() ? 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: reduceMotion() ? 0 : 500 });
}, [ready, focus]);
return (
{t('map.alt')}
{!online &&
{t('map.offline')}
}
{showPlaces && (
{t('map.legend')}
{(['match', 'partial', 'conflict', 'incomplete', 'fail'] as Verdict[]).map((v) => (
-
{t(`verdict.${v}`)}{v === 'incomplete' ? ` (${t('map.hollow')})` : ''}
))}
)}
);
}