mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
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.
This commit is contained in:
parent
b354fd0aa0
commit
d0f89c6783
2 changed files with 128 additions and 3 deletions
|
|
@ -13,6 +13,8 @@ import useContextMenu from '../hooks/useContextMenu'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
const ROUTE_SOURCE = 'route-source'
|
const ROUTE_SOURCE = 'route-source'
|
||||||
|
const BOUNDARY_SOURCE = 'boundary-source'
|
||||||
|
const BOUNDARY_LAYER = 'boundary-layer'
|
||||||
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
||||||
const HILLSHADE_SOURCE = 'hillshade-dem'
|
const HILLSHADE_SOURCE = 'hillshade-dem'
|
||||||
const HILLSHADE_LAYER = 'hillshade-layer'
|
const HILLSHADE_LAYER = 'hillshade-layer'
|
||||||
|
|
@ -968,6 +970,23 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
data: { type: 'FeatureCollection', features: [] },
|
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
|
// Restore overlay layers from localStorage prefs
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem('navi-layer-prefs')
|
const raw = localStorage.getItem('navi-layer-prefs')
|
||||||
|
|
@ -1099,6 +1118,23 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
data: { type: 'FeatureCollection', features: [] },
|
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
|
// Re-add active overlay layers
|
||||||
if (activeLayersRef.current.hillshade) addHillshade(map)
|
if (activeLayersRef.current.hillshade) addHillshade(map)
|
||||||
if (activeLayersRef.current.traffic) addTraffic(map)
|
if (activeLayersRef.current.traffic) addTraffic(map)
|
||||||
|
|
@ -1159,6 +1195,81 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
}
|
}
|
||||||
}, [selectedPlace])
|
}, [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
|
// Update route polyline when route changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapInstance.current
|
const map = mapInstance.current
|
||||||
|
|
|
||||||
|
|
@ -339,10 +339,10 @@ function EnrichmentSections({ details }) {
|
||||||
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
|
href={`https://www.wikidata.org/wiki/${et.wikidata}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-2 text-xs font-mono"
|
className="text-[11px]"
|
||||||
style={{ color: 'var(--text-tertiary)' }}
|
style={{ color: 'var(--text-tertiary)', textDecoration: 'underline' }}
|
||||||
>
|
>
|
||||||
Wikidata: {et.wikidata}
|
View on Wikidata
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -474,6 +474,13 @@ export default function PlaceDetail() {
|
||||||
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
|
fetchPlaceDetails(osmType, osmId, controller.signal).then((data) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
setPlaceDetails(data || null)
|
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,
|
...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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue