mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +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 ──
|
// ── Contacts API ──
|
||||||
|
|
||||||
export async function fetchContacts(signal) {
|
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 })
|
const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false })
|
||||||
// Flag to suppress map-click when a stop pin was clicked
|
// Flag to suppress map-click when a stop pin was clicked
|
||||||
const pinClickedRef = useRef(false)
|
const pinClickedRef = useRef(false)
|
||||||
|
const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState
|
||||||
|
|
||||||
const stops = useStore((s) => s.stops)
|
const stops = useStore((s) => s.stops)
|
||||||
const route = useStore((s) => s.route)
|
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)
|
// Find first feature with a name (respects layer order = priority)
|
||||||
const labelFeature = features.find(f => f.properties?.name)
|
const labelFeature = features.find(f => f.properties?.name)
|
||||||
|
|
||||||
// Set click marker
|
// Clear previous feature highlight
|
||||||
store.setClickMarker({
|
if (highlightedFeatureRef.current) {
|
||||||
lat,
|
const { source, sourceLayer, id } = highlightedFeatureRef.current
|
||||||
lon: lng,
|
try {
|
||||||
circleRadiusPx: MARKER_RADIUS_PX,
|
map.setFeatureState({ source, sourceLayer, id }, { selected: false })
|
||||||
})
|
} catch (e) { /* ignore if layer removed */ }
|
||||||
|
highlightedFeatureRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
if (labelFeature) {
|
if (labelFeature) {
|
||||||
// Clicked a labeled feature — use its properties
|
// Clicked a labeled feature — snap to geometry and highlight
|
||||||
const props = labelFeature.properties
|
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({
|
store.setSelectedPlace({
|
||||||
lat,
|
lat: featureLat,
|
||||||
lon: lng,
|
lon: featureLon,
|
||||||
name: props.name || 'Unknown',
|
name: props.name || 'Unknown',
|
||||||
address: null,
|
address: null,
|
||||||
type: props.kind_detail || props.kind || null,
|
type: props.kind_detail || props.kind || null,
|
||||||
source: 'basemap_label',
|
source: 'basemap_label',
|
||||||
matchCode: null,
|
matchCode: null,
|
||||||
|
mode: 'feature',
|
||||||
|
featureId: featureId,
|
||||||
|
featureLayer: labelFeature.layer?.id || null,
|
||||||
|
wikidata: props.wikidata || null,
|
||||||
raw: {
|
raw: {
|
||||||
wikidata: props.wikidata || null,
|
wikidata: props.wikidata || null,
|
||||||
population: props.population || null,
|
population: props.population || null,
|
||||||
|
|
@ -897,7 +928,13 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} 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({
|
store.setSelectedPlace({
|
||||||
lat,
|
lat,
|
||||||
lon: lng,
|
lon: lng,
|
||||||
|
|
@ -906,6 +943,7 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
type: null,
|
type: null,
|
||||||
source: 'map_click',
|
source: 'map_click',
|
||||||
matchCode: null,
|
matchCode: null,
|
||||||
|
mode: 'reticle',
|
||||||
raw: {},
|
raw: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -1089,17 +1127,26 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
if (!selectedPlace) return
|
if (!selectedPlace) return
|
||||||
|
|
||||||
// Only fly to place if it came from search (not map-click which already centered)
|
// 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 })
|
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')
|
const el = document.createElement('div')
|
||||||
el.className = 'navi-pin-preview'
|
if (isFeatureMode) {
|
||||||
// Add precise center dot
|
// Feature mode: subtle ring indicator
|
||||||
const dot = document.createElement('div')
|
el.className = 'navi-feature-highlight'
|
||||||
dot.className = 'navi-pin-center-dot'
|
} else {
|
||||||
el.appendChild(dot)
|
// Reticle mode: pin with center dot
|
||||||
|
el.className = 'navi-pin-preview'
|
||||||
|
const dot = document.createElement('div')
|
||||||
|
dot.className = 'navi-pin-center-dot'
|
||||||
|
el.appendChild(dot)
|
||||||
|
}
|
||||||
|
|
||||||
previewMarkerRef.current = new maplibregl.Marker({ element: el })
|
previewMarkerRef.current = new maplibregl.Marker({ element: el })
|
||||||
.setLngLat([selectedPlace.lon, selectedPlace.lat])
|
.setLngLat([selectedPlace.lon, selectedPlace.lat])
|
||||||
.addTo(map)
|
.addTo(map)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import {
|
||||||
import OpeningHours from 'opening_hours'
|
import OpeningHours from 'opening_hours'
|
||||||
import toast from 'react-hot-toast'
|
import toast from 'react-hot-toast'
|
||||||
import { useStore } from '../store'
|
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 { hasFeature } from '../config'
|
||||||
import { buildAddress } from '../utils/place'
|
import { buildAddress } from '../utils/place'
|
||||||
|
|
||||||
|
|
@ -480,6 +480,35 @@ export default function PlaceDetail() {
|
||||||
return () => controller.abort()
|
return () => controller.abort()
|
||||||
}, [osmType, osmId])
|
}, [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
|
// Fetch drive time when place or user location changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!userLocation || placeLat == null || placeLon == null) {
|
if (!userLocation || placeLat == null || placeLon == null) {
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,20 @@ body {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
pointer-events: none;
|
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 ═══ */
|
/* ═══ PLACE DETAIL PANEL ═══ */
|
||||||
.navi-place-detail {
|
.navi-place-detail {
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const useStore = create((set, get) => ({
|
||||||
clearRoute: () => set({ route: null, routeError: null }),
|
clearRoute: () => set({ route: null, routeError: null }),
|
||||||
|
|
||||||
// ── Place detail ──
|
// ── 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
|
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection
|
||||||
gpsOrigin: true, // whether GPS should be used as origin when available
|
gpsOrigin: true, // whether GPS should be used as origin when available
|
||||||
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue