mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat: Consolidated UX improvements for map selection
- Snap selection to feature geometry when clicking labeled places - Add wikidata enrichment for basemap labels (population, description) - Differentiate visual feedback: reticle marker vs pulsing highlight - Clear previous feature highlight when selection changes - Store selection mode (reticle/feature) and feature info in state Frontend: MapView click handler, PlaceDetail wikidata fetch, CSS Backend: /api/place/wikidata/<id> route for Wikidata API lookups Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
4fcc3d1af4
commit
b354fd0aa0
5 changed files with 123 additions and 20 deletions
13
src/api.js
13
src/api.js
|
|
@ -194,6 +194,19 @@ export async function fetchPlaceDetails(osmType, osmId, signal) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function fetchPlaceByWikidata(wikidataId, signal) {
|
||||
try {
|
||||
const resp = await fetch(`/api/place/wikidata/${wikidataId}`, {
|
||||
signal,
|
||||
headers: { "Accept": "application/json" },
|
||||
})
|
||||
if (!resp.ok) return null
|
||||
return resp.json()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contacts API ──
|
||||
|
||||
export async function fetchContacts(signal) {
|
||||
|
|
|
|||
|
|
@ -605,6 +605,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false })
|
||||
// Flag to suppress map-click when a stop pin was clicked
|
||||
const pinClickedRef = useRef(false)
|
||||
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
||||
|
||||
const stops = useStore((s) => s.stops)
|
||||
const route = useStore((s) => s.route)
|
||||
|
|
@ -870,24 +871,54 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
// Find first feature with a name (respects layer order = priority)
|
||||
const labelFeature = features.find(f => f.properties?.name)
|
||||
|
||||
// Set click marker
|
||||
store.setClickMarker({
|
||||
lat,
|
||||
lon: lng,
|
||||
circleRadiusPx: MARKER_RADIUS_PX,
|
||||
})
|
||||
// Clear previous feature highlight
|
||||
if (highlightedFeatureRef.current) {
|
||||
const { source, sourceLayer, id } = highlightedFeatureRef.current
|
||||
try {
|
||||
map.setFeatureState({ source, sourceLayer, id }, { selected: false })
|
||||
} catch (e) { /* ignore if layer removed */ }
|
||||
highlightedFeatureRef.current = null
|
||||
}
|
||||
|
||||
if (labelFeature) {
|
||||
// Clicked a labeled feature — use its properties
|
||||
// Clicked a labeled feature — snap to geometry and highlight
|
||||
const props = labelFeature.properties
|
||||
const geom = labelFeature.geometry
|
||||
|
||||
// Get feature coordinates (Point geometry)
|
||||
let featureLat = lat
|
||||
let featureLon = lng
|
||||
if (geom && geom.type === 'Point' && geom.coordinates) {
|
||||
featureLon = geom.coordinates[0]
|
||||
featureLat = geom.coordinates[1]
|
||||
}
|
||||
|
||||
// Apply feature state highlight
|
||||
const featureId = labelFeature.id ?? props.mvt_id
|
||||
const sourceLayer = labelFeature.sourceLayer
|
||||
const source = labelFeature.source
|
||||
if (featureId != null && source) {
|
||||
try {
|
||||
map.setFeatureState({ source, sourceLayer, id: featureId }, { selected: true })
|
||||
highlightedFeatureRef.current = { source, sourceLayer, id: featureId }
|
||||
} catch (e) { console.warn('setFeatureState error:', e) }
|
||||
}
|
||||
|
||||
// For feature clicks, don't show pin marker
|
||||
store.clearClickMarker()
|
||||
|
||||
store.setSelectedPlace({
|
||||
lat,
|
||||
lon: lng,
|
||||
lat: featureLat,
|
||||
lon: featureLon,
|
||||
name: props.name || 'Unknown',
|
||||
address: null,
|
||||
type: props.kind_detail || props.kind || null,
|
||||
source: 'basemap_label',
|
||||
matchCode: null,
|
||||
mode: 'feature',
|
||||
featureId: featureId,
|
||||
featureLayer: labelFeature.layer?.id || null,
|
||||
wikidata: props.wikidata || null,
|
||||
raw: {
|
||||
wikidata: props.wikidata || null,
|
||||
population: props.population || null,
|
||||
|
|
@ -897,7 +928,13 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
},
|
||||
})
|
||||
} else {
|
||||
// No labeled feature — fall back to reverse geocode
|
||||
// No labeled feature — show reticle at click point
|
||||
store.setClickMarker({
|
||||
lat,
|
||||
lon: lng,
|
||||
circleRadiusPx: MARKER_RADIUS_PX,
|
||||
})
|
||||
|
||||
store.setSelectedPlace({
|
||||
lat,
|
||||
lon: lng,
|
||||
|
|
@ -906,6 +943,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
type: null,
|
||||
source: 'map_click',
|
||||
matchCode: null,
|
||||
mode: 'reticle',
|
||||
raw: {},
|
||||
})
|
||||
|
||||
|
|
@ -1089,17 +1127,26 @@ const MapView = forwardRef(function MapView(_, ref) {
|
|||
if (!selectedPlace) return
|
||||
|
||||
// Only fly to place if it came from search (not map-click which already centered)
|
||||
if (selectedPlace.source !== 'map_click') {
|
||||
if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') {
|
||||
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
|
||||
}
|
||||
|
||||
// Create preview marker
|
||||
// Different visual feedback based on mode
|
||||
const isFeatureMode = selectedPlace.mode === 'feature'
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div')
|
||||
if (isFeatureMode) {
|
||||
// Feature mode: subtle ring indicator
|
||||
el.className = 'navi-feature-highlight'
|
||||
} else {
|
||||
// Reticle mode: pin with center dot
|
||||
el.className = 'navi-pin-preview'
|
||||
// Add precise center dot
|
||||
const dot = document.createElement('div')
|
||||
dot.className = 'navi-pin-center-dot'
|
||||
el.appendChild(dot)
|
||||
}
|
||||
|
||||
previewMarkerRef.current = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([selectedPlace.lon, selectedPlace.lat])
|
||||
.addTo(map)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
import OpeningHours from 'opening_hours'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { fetchElevation, fetchPlaceDetails, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
|
||||
import { fetchElevation, fetchPlaceDetails, fetchPlaceByWikidata, fetchDriveTime, fetchNearbyContacts, fetchLandclass } from '../api'
|
||||
import { hasFeature } from '../config'
|
||||
import { buildAddress } from '../utils/place'
|
||||
|
||||
|
|
@ -480,6 +480,35 @@ export default function PlaceDetail() {
|
|||
return () => controller.abort()
|
||||
}, [osmType, osmId])
|
||||
|
||||
// Fetch wikidata enrichment when place has wikidata but no OSM details
|
||||
const wikidataId = selectedPlace?.wikidata || selectedPlace?.raw?.wikidata
|
||||
useEffect(() => {
|
||||
// Skip if OSM details are available (they provide richer data)
|
||||
if (osmType && osmId) return
|
||||
// Skip if no wikidata ID
|
||||
if (!wikidataId) return
|
||||
|
||||
const controller = new AbortController()
|
||||
|
||||
fetchPlaceByWikidata(wikidataId, controller.signal).then((data) => {
|
||||
if (!controller.signal.aborted && data) {
|
||||
// Merge wikidata info into placeDetails (description, population, etc.)
|
||||
setPlaceDetails((prev) => ({
|
||||
...(prev === 'loading' ? {} : prev || {}),
|
||||
description: data.description,
|
||||
population: data.population,
|
||||
osm_relation_id: data.osm_relation_id,
|
||||
extratags: {
|
||||
...(prev && prev !== 'loading' ? prev.extratags : {}),
|
||||
...data.extratags,
|
||||
},
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [wikidataId, osmType, osmId])
|
||||
|
||||
// Fetch drive time when place or user location changes
|
||||
useEffect(() => {
|
||||
if (!userLocation || placeLat == null || placeLon == null) {
|
||||
|
|
|
|||
|
|
@ -279,6 +279,20 @@ body {
|
|||
background: var(--accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.navi-feature-highlight {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--accent);
|
||||
background: var(--accent-muted);
|
||||
animation: navi-pulse 1.5s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes navi-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.2); opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* ═══ PLACE DETAIL PANEL ═══ */
|
||||
.navi-place-detail {
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const useStore = create((set, get) => ({
|
|||
clearRoute: () => set({ route: null, routeError: null }),
|
||||
|
||||
// ── Place detail ──
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw }
|
||||
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? }
|
||||
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue