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:
Matt 2026-04-26 08:15:09 +00:00
commit b354fd0aa0
5 changed files with 123 additions and 20 deletions

View file

@ -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) {

View file

@ -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
if (labelFeature) {
// 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')
el.className = 'navi-pin-preview'
// Add precise center dot
const dot = document.createElement('div')
dot.className = 'navi-pin-center-dot'
el.appendChild(dot)
if (isFeatureMode) {
// Feature mode: subtle ring indicator
el.className = 'navi-feature-highlight'
} else {
// 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 })
.setLngLat([selectedPlace.lon, selectedPlace.lat])
.addTo(map)

View file

@ -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) {

View file

@ -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 {

View file

@ -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)