2026-10-03 13:33:50 +02:00
|
|
|
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';
|
2026-10-03 14:29:28 +02:00
|
|
|
import type { Hotspot } from '../lib/civic';
|
2026-10-03 13:33:50 +02:00
|
|
|
import { HARD_FLAGS, type RouteResult } from '../lib/route';
|
|
|
|
|
import type { Place, Verdict } from '../lib/types';
|
2026-10-03 18:22:27 +02:00
|
|
|
import { CATEGORY_ICON, glyphBitmap } from '../lib/categoryIcon';
|
2026-10-03 13:33:50 +02:00
|
|
|
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: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">OpenStreetMap</a>',
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
|
|
|
|
|
};
|
2026-10-03 14:29:28 +02:00
|
|
|
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' } });
|
2026-10-03 13:33:50 +02:00
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
function applyBasemap(map: MLMap, b: 'map' | 'satellite') {
|
|
|
|
|
map.setLayoutProperty('osm', 'visibility', b === 'map' ? 'visible' : 'none');
|
|
|
|
|
map.setLayoutProperty('sat', 'visibility', b === 'satellite' ? 'visible' : 'none');
|
|
|
|
|
}
|
2026-10-03 18:22:27 +02:00
|
|
|
/** 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 */ }
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 13:33:50 +02:00
|
|
|
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;
|
2026-10-03 14:29:28 +02:00
|
|
|
/** Non-selected route options, drawn faintly for comparison. */
|
|
|
|
|
altRoutes: LatLon[][];
|
2026-10-03 13:33:50 +02:00
|
|
|
focus: { at: LatLon; n: number } | null;
|
2026-10-03 15:02:13 +02:00
|
|
|
/** Changes whenever the layout around the map changes (bottom sheet moved); triggers a re-measure. */
|
|
|
|
|
visible: unknown;
|
2026-10-03 13:33:50 +02:00
|
|
|
bbox: [number, number, number, number];
|
|
|
|
|
online: boolean;
|
2026-10-03 14:29:28 +02:00
|
|
|
basemap: 'map' | 'satellite';
|
|
|
|
|
onBasemap: (b: 'map' | 'satellite') => void;
|
|
|
|
|
userPos: LatLon | null;
|
|
|
|
|
follow: boolean;
|
|
|
|
|
reach: [LatLon, LatLon][] | null;
|
|
|
|
|
hotspots: Hotspot[];
|
|
|
|
|
hotspotFocus: Hotspot | null;
|
|
|
|
|
reduceMotion: boolean;
|
2026-10-03 13:33:50 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
export function MapView({ places, showPlaces, selectedId, onSelect, route, routeEnds, altRoutes, focus, visible, bbox, online, basemap, onBasemap, userPos, follow, reach, hotspots, hotspotFocus, reduceMotion }: Props) {
|
2026-10-03 13:33:50 +02:00
|
|
|
const { t, lang } = useI18n();
|
|
|
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
|
const mapRef = useRef<MLMap | null>(null);
|
|
|
|
|
const markers = useRef<maplibregl.Marker[]>([]);
|
|
|
|
|
const onSelectRef = useRef(onSelect);
|
|
|
|
|
onSelectRef.current = onSelect;
|
2026-10-03 14:29:28 +02:00
|
|
|
const hotMarkers = useRef<maplibregl.Marker[]>([]);
|
|
|
|
|
const meMarker = useRef<maplibregl.Marker | null>(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;
|
2026-10-03 13:33:50 +02:00
|
|
|
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: [] } });
|
2026-10-03 14:29:28 +02:00
|
|
|
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 },
|
|
|
|
|
});
|
2026-10-03 13:33:50 +02:00
|
|
|
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',
|
2026-10-03 14:29:28 +02:00
|
|
|
layout: { 'circle-sort-key': ['match', ['get', 'verdict'], 'incomplete', 0, 1] },
|
2026-10-03 13:33:50 +02:00
|
|
|
paint: {
|
2026-10-03 18:22:27 +02:00
|
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 13, ['match', ['get', 'verdict'], 'incomplete', 2.5, 5.5], 17, ['match', ['get', 'verdict'], 'incomplete', 9, 11.5]],
|
2026-10-03 14:29:28 +02:00
|
|
|
'circle-opacity': ['match', ['get', 'verdict'], 'incomplete', 0.8, 1],
|
2026-10-03 13:33:50 +02:00
|
|
|
'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'],
|
2026-10-03 14:29:28 +02:00
|
|
|
'circle-stroke-color': ['match', ['get', 'verdict'], 'incomplete', '#565c65', '#ffffff'],
|
|
|
|
|
'circle-stroke-width': ['match', ['get', 'verdict'], 'incomplete', 1.5, 2],
|
2026-10-03 13:33:50 +02:00
|
|
|
},
|
|
|
|
|
});
|
2026-10-03 18:22:27 +02:00
|
|
|
void addGlyphs(map);
|
2026-10-03 13:33:50 +02:00
|
|
|
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 = ''; });
|
2026-10-03 14:29:28 +02:00
|
|
|
applyBasemap(map, basemapRef.current);
|
2026-10-03 13:33:50 +02:00
|
|
|
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] },
|
2026-10-03 18:22:27 +02:00
|
|
|
properties: { id: place.id, verdict, cat: place.cat },
|
2026-10-03 13:33:50 +02:00
|
|
|
})),
|
|
|
|
|
}),
|
|
|
|
|
[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;
|
2026-10-03 14:29:28 +02:00
|
|
|
if (sel && showPlaces) map.easeTo({ center: [sel.lon, sel.lat], zoom: Math.max(map.getZoom(), 17), duration: reduced() ? 0 : 500 });
|
2026-10-03 13:33:50 +02:00
|
|
|
// 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 = `<svg class="usa-icon" aria-hidden="true" focusable="false"><use href="${SPRITE}#${hard ? 'error' : 'warning'}"></use></svg>`;
|
|
|
|
|
addMarker(el, b.at, text);
|
|
|
|
|
}
|
|
|
|
|
const bounds = new maplibregl.LngLatBounds();
|
|
|
|
|
route.coords.forEach((c) => bounds.extend(lngLat(c)));
|
2026-10-03 14:29:28 +02:00
|
|
|
map.fitBounds(bounds, { padding: 70, duration: reduced() ? 0 : 600, maxZoom: 17 });
|
2026-10-03 13:33:50 +02:00
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [ready, route, routeEnds, lang]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const map = mapRef.current;
|
|
|
|
|
if (!ready || !map || !focus) return;
|
2026-10-03 14:29:28 +02:00
|
|
|
map.easeTo({ center: lngLat(focus.at), zoom: Math.max(map.getZoom(), 18), duration: reduced() ? 0 : 500 });
|
2026-10-03 13:33:50 +02:00
|
|
|
}, [ready, focus]);
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
// ---- 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]);
|
|
|
|
|
|
2026-10-03 13:33:50 +02:00
|
|
|
return (
|
|
|
|
|
<div className="kbb-map-wrap">
|
2026-10-03 14:29:28 +02:00
|
|
|
<div className="kbb-basemap" role="group" aria-label={t('map.layer')}>
|
|
|
|
|
<button type="button" aria-pressed={basemap === 'map'} onClick={() => onBasemap('map')}>{t('map.layer.map')}</button>
|
|
|
|
|
<button type="button" aria-pressed={basemap === 'satellite'} onClick={() => onBasemap('satellite')}>{t('map.layer.sat')}</button>
|
|
|
|
|
</div>
|
2026-10-03 13:33:50 +02:00
|
|
|
<div ref={ref} className="kbb-map" role="region" aria-label={t('map.label')} aria-describedby="map-alt" />
|
|
|
|
|
<p id="map-alt" className="usa-sr-only">{t('map.alt')}</p>
|
|
|
|
|
{!online && <p className="kbb-map-note usa-alert usa-alert--warning usa-alert--slim margin-0"><span className="usa-alert__body"><span className="usa-alert__text">{t('map.offline')}</span></span></p>}
|
|
|
|
|
{showPlaces && (
|
|
|
|
|
<details className="kbb-legend">
|
|
|
|
|
<summary>{t('map.legend')}</summary>
|
|
|
|
|
<ul className="usa-list--unstyled margin-0 padding-0">
|
|
|
|
|
{(['match', 'partial', 'conflict', 'incomplete', 'fail'] as Verdict[]).map((v) => (
|
|
|
|
|
<li key={v} className="display-flex flex-align-center">
|
|
|
|
|
<span className="kbb-swatch" style={{ background: VERDICT_STYLE[v].hex, borderColor: v === 'incomplete' ? '#565c65' : '#1b1b1b' }} aria-hidden="true" />
|
|
|
|
|
{t(`verdict.${v}`)}{v === 'incomplete' ? ` (${t('map.hollow')})` : ''}
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</details>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|