From d0f89c6783075bc60a380845b029748feac914c9 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 26 Apr 2026 08:26:56 +0000 Subject: [PATCH] feat(map): polygon boundary, zoom-to-feature, Wikidata link cleanup Three improvements: 1. When a place has a boundary polygon (from Nominatim), render its outline on the map using a dashed accent line. Falls back to the existing pulsing ring for places without polygons. 2. Selecting a feature now smoothly zooms the map to fit: - With polygon: fitBounds to polygon bbox - Without polygon: zoom level based on feature kind (city=11, region=7, POI=16, etc.) Terrain clicks do not change zoom. 3. Wikidata IDs render as styled 'View on Wikidata' links instead of raw 'Wikidata: Qxxxxx' strings. --- src/components/MapView.jsx | 111 +++++++++++++++++++++++++++++++++ src/components/PlaceDetail.jsx | 20 +++++- 2 files changed, 128 insertions(+), 3 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index c717749..cc717de 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -13,6 +13,8 @@ import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' const ROUTE_SOURCE = 'route-source' +const BOUNDARY_SOURCE = 'boundary-source' +const BOUNDARY_LAYER = 'boundary-layer' const ROUTE_LAYER_PREFIX = 'route-layer-' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' @@ -968,6 +970,23 @@ const MapView = forwardRef(function MapView(_, ref) { data: { type: 'FeatureCollection', features: [] }, }) + // Boundary polygon source for selected places + map.addSource(BOUNDARY_SOURCE, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }) + map.addLayer({ + id: BOUNDARY_LAYER, + type: 'line', + source: BOUNDARY_SOURCE, + paint: { + 'line-color': 'var(--accent)', + 'line-width': 2, + 'line-opacity': 0.7, + 'line-dasharray': [3, 2], + }, + }) + // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') @@ -1099,6 +1118,23 @@ const MapView = forwardRef(function MapView(_, ref) { data: { type: 'FeatureCollection', features: [] }, }) + // Boundary polygon source + map.addSource(BOUNDARY_SOURCE, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + }) + map.addLayer({ + id: BOUNDARY_LAYER, + type: 'line', + source: BOUNDARY_SOURCE, + paint: { + 'line-color': 'var(--accent)', + 'line-width': 2, + 'line-opacity': 0.7, + 'line-dasharray': [3, 2], + }, + }) + // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map) if (activeLayersRef.current.traffic) addTraffic(map) @@ -1159,6 +1195,81 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [selectedPlace]) + // Boundary polygon and zoom-to-feature + useEffect(() => { + const map = mapInstance.current + if (!map || !map.isStyleLoaded()) return + + const source = map.getSource(BOUNDARY_SOURCE) + if (!source) return + + // Clear boundary if no place selected + if (!selectedPlace) { + source.setData({ type: 'FeatureCollection', features: [] }) + return + } + + // Get boundary from selectedPlace (may come from API response) + const boundary = selectedPlace.boundary || selectedPlace.raw?.boundary + + // Update boundary layer + if (boundary && (boundary.type === 'Polygon' || boundary.type === 'MultiPolygon')) { + source.setData({ + type: 'Feature', + geometry: boundary, + properties: {}, + }) + + // Zoom to fit boundary + try { + const coords = boundary.type === 'Polygon' + ? boundary.coordinates[0] + : boundary.coordinates.flat(1) + + if (coords.length > 0) { + let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity + for (const [lng, lat] of coords) { + if (lng < minLng) minLng = lng + if (lng > maxLng) maxLng = lng + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + } + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: 50, + duration: 700, + maxZoom: 16, + }) + } + } catch (e) { + console.warn('fitBounds error:', e) + } + } else { + // No boundary - clear the layer and zoom based on feature kind + source.setData({ type: 'FeatureCollection', features: [] }) + + // Only zoom for feature mode selections (not terrain clicks) + if (selectedPlace.mode === 'feature' && selectedPlace.source === 'basemap_label') { + const kind = selectedPlace.raw?.kind || selectedPlace.type || '' + let targetZoom = null + + if (kind.includes('country')) targetZoom = 5 + else if (kind.includes('region' ) || kind.includes('state')) targetZoom = 7 + else if (kind.includes('locality' ) || kind.includes('city')) targetZoom = 11 + else if (kind.includes('subplace' ) || kind.includes('neighbourhood') || kind.includes('neighborhood')) targetZoom = 13 + else if (kind.includes('poi')) targetZoom = 16 + + // Only zoom in, never zoom out + if (targetZoom && map.getZoom() < targetZoom) { + map.flyTo({ + center: [selectedPlace.lon, selectedPlace.lat], + zoom: targetZoom, + duration: 700, + }) + } + } + } + }, [selectedPlace]) + // Update route polyline when route changes useEffect(() => { const map = mapInstance.current diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx index a5c7a9f..1ec4270 100644 --- a/src/components/PlaceDetail.jsx +++ b/src/components/PlaceDetail.jsx @@ -339,10 +339,10 @@ function EnrichmentSections({ details }) { href={`https://www.wikidata.org/wiki/${et.wikidata}`} target="_blank" rel="noopener noreferrer" - className="flex items-center gap-2 text-xs font-mono" - style={{ color: 'var(--text-tertiary)' }} + className="text-[11px]" + style={{ color: 'var(--text-tertiary)', textDecoration: 'underline' }} > - Wikidata: {et.wikidata} + View on Wikidata )} @@ -474,6 +474,13 @@ export default function PlaceDetail() { fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => { if (!controller.signal.aborted) { setPlaceDetails(data || null) + // Update selectedPlace with boundary if present + if (data?.boundary) { + const current = useStore.getState().selectedPlace + if (current) { + useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary }) + } + } } }) @@ -503,6 +510,13 @@ export default function PlaceDetail() { ...data.extratags, }, })) + // Update selectedPlace with boundary if present + if (data?.boundary) { + const current = useStore.getState().selectedPlace + if (current) { + useStore.getState().setSelectedPlace({ ...current, boundary: data.boundary }) + } + } } })