diff --git a/src/api.js b/src/api.js index cc410a5..f67a9d5 100644 --- a/src/api.js +++ b/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) { diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ec47531..c717749 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -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) diff --git a/src/components/PlaceDetail.jsx b/src/components/PlaceDetail.jsx index 5fb89bb..a5c7a9f 100644 --- a/src/components/PlaceDetail.jsx +++ b/src/components/PlaceDetail.jsx @@ -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) { diff --git a/src/index.css b/src/index.css index 1a87652..21a44e7 100644 --- a/src/index.css +++ b/src/index.css @@ -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 { diff --git a/src/store.js b/src/store.js index 03aabc8..8e310d0 100644 --- a/src/store.js +++ b/src/store.js @@ -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)