GdzieWjade/src/components/MapView.tsx

347 lines
16 KiB
TypeScript
Raw Normal View History

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';
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 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;
/** Re-measure when the pane becomes visible. */
visible: boolean;
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 14:29:28 +02:00
'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],
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
},
});
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] },
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;
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>
);
}