From 4c1921d116fefff2823d6a16582de5a851392144 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 02:08:59 +0000 Subject: [PATCH 01/34] fix: Hide background layer in satellite mode The background layer type was not being hidden, causing it to cover the satellite imagery. Now hideVectorFills hides fill, fill-extrusion, AND background layer types. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 47454ee..00546e7 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1334,9 +1334,9 @@ function hideVectorFills(map) { if (!style || !style.layers) return for (const layer of style.layers) { - // Hide fill layers (land, water, parks, buildings, etc.) + // Hide fill and background layers (land, water, parks, buildings, etc.) // But keep line, symbol, and circle layers - if (layer.type === 'fill' || layer.type === 'fill-extrusion') { + if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { // Don't hide our own overlay fills (public lands, etc) if (layer.id.startsWith('public-lands') || layer.id.startsWith('boundary') || From 66f91fd3791bb67311e7119e9f2463e906c84d65 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 02:19:28 +0000 Subject: [PATCH 02/34] fix: Properly restore layers when switching view modes Three bugs fixed: 1. Map mode now restores all hidden layers - using separate tracking arrays for fills, lines, and symbols that persist across mode switches 2. Satellite mode now hides ALL vector layers (fills, lines, symbols) for true satellite-only view 3. Hybrid mode keeps lines and symbols visible for road/label overlay Each mode switch first restores all layers to a clean slate before hiding the appropriate ones for that mode. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 132 +++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 50 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 00546e7..ca33bf3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1323,81 +1323,113 @@ function updateSatellitePaint(map, themeId) { } // Track which vector layers are hidden in satellite/hybrid mode -let hiddenVectorLayers = [] +// Track hidden layers for each mode - separate arrays for proper restoration +let hiddenFillLayers = [] +let hiddenLineLayers = [] +let hiddenSymbolLayers = [] -/** Hide vector fill layers for satellite mode */ -function hideVectorFills(map) { - if (!map) return - hiddenVectorLayers = [] - - const style = map.getStyle() - if (!style || !style.layers) return - - for (const layer of style.layers) { - // Hide fill and background layers (land, water, parks, buildings, etc.) - // But keep line, symbol, and circle layers - if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { - // Don't hide our own overlay fills (public lands, etc) - if (layer.id.startsWith('public-lands') || - layer.id.startsWith('boundary') || - layer.id.startsWith('route')) continue - - const visibility = map.getLayoutProperty(layer.id, 'visibility') - if (visibility !== 'none') { - hiddenVectorLayers.push(layer.id) - map.setLayoutProperty(layer.id, 'visibility', 'none') - } - } +// Layers we never hide (our own overlays) +function isProtectedLayer(id) { + return id.startsWith('public-lands') || + id.startsWith('boundary') || + id.startsWith('route') || + id.startsWith('measure') || + id.startsWith('contour') || + id.startsWith('usfs') || + id.startsWith('blm') || + id.startsWith('hillshade') || + id.startsWith('traffic') || + id === SATELLITE_LAYER +} + +/** Hide a layer and track it */ +function hideLayer(map, layerId, trackingArray) { + if (!map.getLayer(layerId)) return + const vis = map.getLayoutProperty(layerId, 'visibility') + if (vis !== 'none') { + trackingArray.push(layerId) + map.setLayoutProperty(layerId, 'visibility', 'none') } } -/** Show all hidden vector layers */ -function showVectorFills(map) { - if (!map) return - - for (const layerId of hiddenVectorLayers) { - if (map.getLayer(layerId)) { - map.setLayoutProperty(layerId, 'visibility', 'visible') +/** Show all layers in a tracking array */ +function showLayers(map, trackingArray) { + for (const id of trackingArray) { + if (map.getLayer(id)) { + map.setLayoutProperty(id, 'visibility', 'visible') } } - hiddenVectorLayers = [] + trackingArray.length = 0 } -/** Set map to satellite-only mode */ +/** Set map to satellite-only mode - hide ALL vector layers except our overlays */ function setSatelliteMode(map, themeId) { if (!map) return + + // First restore any previously hidden layers to clean slate + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + addSatelliteLayer(map, themeId) - hideVectorFills(map) - // Also hide line layers in pure satellite mode (keep only labels for reference) + const style = map.getStyle() - if (style && style.layers) { - for (const layer of style.layers) { - if (layer.type === 'line' && !layer.id.startsWith('route') && - !layer.id.startsWith('boundary') && !layer.id.startsWith('measure')) { - const visibility = map.getLayoutProperty(layer.id, 'visibility') - if (visibility !== 'none') { - hiddenVectorLayers.push(layer.id) - map.setLayoutProperty(layer.id, 'visibility', 'none') - } - } + if (!style?.layers) return + + for (const layer of style.layers) { + if (isProtectedLayer(layer.id)) continue + + if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { + hideLayer(map, layer.id, hiddenFillLayers) + } else if (layer.type === 'line') { + hideLayer(map, layer.id, hiddenLineLayers) + } else if (layer.type === 'symbol') { + hideLayer(map, layer.id, hiddenSymbolLayers) } } + + console.log('[Satellite] Hidden:', hiddenFillLayers.length, 'fills,', hiddenLineLayers.length, 'lines,', hiddenSymbolLayers.length, 'symbols') } -/** Set map to hybrid mode (satellite + labels/roads) */ +/** Set map to hybrid mode - satellite + roads + labels */ function setHybridMode(map, themeId) { if (!map) return + + // First restore any previously hidden layers to clean slate + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + addSatelliteLayer(map, themeId) - hideVectorFills(map) - // In hybrid mode, keep road lines and labels visible - // They're already visible by default, just fills are hidden + + const style = map.getStyle() + if (!style?.layers) return + + // In hybrid: hide fills/background, keep lines and symbols visible + for (const layer of style.layers) { + if (isProtectedLayer(layer.id)) continue + + if (layer.type === 'fill' || layer.type === 'fill-extrusion' || layer.type === 'background') { + hideLayer(map, layer.id, hiddenFillLayers) + } + // Lines and symbols stay visible for hybrid mode + } + + console.log('[Hybrid] Hidden:', hiddenFillLayers.length, 'fills, keeping lines and symbols visible') } /** Set map back to normal map mode */ function setMapMode(map) { if (!map) return + removeSatelliteLayer(map) - showVectorFills(map) + + // Restore all hidden layers + showLayers(map, hiddenFillLayers) + showLayers(map, hiddenLineLayers) + showLayers(map, hiddenSymbolLayers) + + console.log('[Map] Restored all vector layers') } From a07b33db6d0c73ad5382c71ede26ec4e0e5b5980 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 03:24:12 +0000 Subject: [PATCH 03/34] feat: switch to global planet PMTiles for worldwide vector coverage Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca33bf3..a8f5337 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -269,7 +269,7 @@ function applyBaseLabelStyling(map) { /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/planet-20260420.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' // Use namedTheme directly for built-in themes, custom colors for others From 88fcb48eaa808a010d58e4e32029a8f11fb83447 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 03:24:32 +0000 Subject: [PATCH 04/34] Revert "feat: switch to global planet PMTiles for worldwide vector coverage" This reverts commit a07b33db6d0c73ad5382c71ede26ec4e0e5b5980. --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a8f5337..ca33bf3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -269,7 +269,7 @@ function applyBaseLabelStyling(map) { /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/planet-20260420.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' // Use namedTheme directly for built-in themes, custom colors for others From 6245ecc206d6590cb6cabd30fc8121b763a57375 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 03:33:15 +0000 Subject: [PATCH 05/34] feat: switch to global planet PMTiles for worldwide vector coverage Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca33bf3..a279c9d 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -269,7 +269,7 @@ function applyBaseLabelStyling(map) { /** Build a full MapLibre style object for the given theme */ function buildStyle(themeName) { const config = getConfig() - const tileUrl = config?.tileset?.url || '/tiles/na.pmtiles' + const tileUrl = config?.tileset?.url || '/tiles/planet/planet-20260420.pmtiles' const attribution = config?.tileset?.attribution || 'Protomaps \u00a9 OSM' // Use namedTheme directly for built-in themes, custom colors for others From 1bccfad22ba94c23120ad86f91711fd96f7acf98 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 04:49:51 +0000 Subject: [PATCH 06/34] fix: Skip reverse geocode for basemap label clicks When clicking a basemap label (city, town, POI), we already know the entity from the label properties (name, kind, wikidata). Running fetchReverse at those coordinates would return the nearest POI which could be a different entity (e.g., clicking Portland returns Stumptown Coffee), corrupting the place identity. Now skips reverse geocode when source=basemap_label and raw.kind exists. The wikidata lookup path still handles fetching boundaries and OSM data. Co-Authored-By: Claude Opus 4.5 --- src/components/PlaceCard.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 40c4660..c1782a7 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -352,6 +352,9 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl if (placeLat == null || placeLon == null) return // Skip for dropped pins - they get reverse geocoded by MapView if (place?.source === 'map_click') return + // Don't reverse geocode if we already identified the entity from a label click + // The basemap label provides name, kind, wikidata - reverse geocode would return wrong entity + if (place?.source === 'basemap_label' && place?.raw?.kind) return const controller = new AbortController() fetchReverse(placeLat, placeLon).then((result) => { From 3158537488c84fcbdfe070cae7cdd6b857a4ca75 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 05:01:02 +0000 Subject: [PATCH 07/34] fix: Correct MultiPolygon coordinate flattening for fitBounds MultiPolygon coordinates need .flat(2) not .flat(1) to get actual coordinate pairs. With flat(1), we were iterating over rings instead of coordinates, causing invalid lat values > 90. Also added bounds validation before fitBounds to catch future issues. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a279c9d..5d646f5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2401,7 +2401,7 @@ const MapView = forwardRef(function MapView(_, ref) { try { const coords = boundaryGeometry.type === 'Polygon' ? boundaryGeometry.coordinates[0] - : boundaryGeometry.coordinates.flat(1) + : boundaryGeometry.coordinates.flat(2) if (coords.length > 0) { let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity @@ -2411,11 +2411,17 @@ const MapView = forwardRef(function MapView(_, ref) { if (lat < minLat) minLat = lat if (lat > maxLat) maxLat = lat } - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: 50, - duration: 700, - maxZoom: 16, - }) + // Validate bounds before fitting + if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && + minLng < maxLng && minLat < maxLat) { + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: 50, + duration: 700, + maxZoom: 16, + }) + } else { + console.warn('Invalid bounds:', { minLng, maxLng, minLat, maxLat }) + } } } catch (e) { console.warn('fitBounds error:', e) From cd080b42f35e69c406e1e7ebf4ad6a2e335b15ab Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 05:04:34 +0000 Subject: [PATCH 08/34] fix: Set osm_type/osm_id from wikidata response for wiki summaries When clicking basemap labels, reverse geocode is now skipped to avoid entity corruption. The wikidata effect needs to set osm_type/osm_id from the osm_relation_id in the response to trigger Effect 3 which fetches the wiki summary from /api/place/R/{id}. Co-Authored-By: Claude Opus 4.5 --- src/components/PlaceCard.jsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index c1782a7..3afa72b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -408,6 +408,16 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl osm_relation_id: data.osm_relation_id, extratags: { ...(prev && prev !== "loading" ? prev.extratags : {}), ...data.extratags }, })) + // Set osm_type/osm_id from osm_relation_id to trigger Effect 3 (wiki summary fetch) + if (data?.osm_relation_id) { + const current = useStore.getState().selectedPlace + if (current && current.lat === placeLat && current.lon === placeLon) { + useStore.getState().setSelectedPlace({ + ...current, + raw: { ...current.raw, osm_type: 'R', osm_id: data.osm_relation_id } + }) + } + } if (data?.boundary) { const current = useStore.getState().selectedPlace if (current && current.lat === placeLat && current.lon === placeLon) { From 869391ee4e9b19f464b3f9d31ecab6ab960f72d2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 16:57:32 +0000 Subject: [PATCH 09/34] fix(ux): Three UX improvements for feature selection Fix 1: Never zoom out when clicking a feature - preserves user's intentional zoom level by checking cameraForBounds before fitBounds Fix 2: Single-click to switch between features - clicking outside the current feature's circle clears selection and selects new feature Fix 3: View mode toggle reflects saved state on load - initialize viewMode from localStorage on store creation Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 22 ++++++++++++++++++---- src/store.js | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 5d646f5..2e77670 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2097,15 +2097,21 @@ const MapView = forwardRef(function MapView(_, ref) { }) } } else { - // Outside circle → deselect, no new selection + // Outside circle → clear current selection and fall through to select new store.clearClickMarker() store.clearSelectedPlace() // Clear boundary when deselecting if (updateBoundaryRef.current) updateBoundaryRef.current(null) setSelectedHighlight(map, null) + // Fall through to State A to select new feature at click point } - } else { - // State A: nothing selected → select + } + + // Select new feature at click point (State A or after clearing previous selection) + { + const store = useStore.getState() // refresh store state after potential clear + if (store.clickMarker) return // already handled above + if (window.innerWidth < 768) setSheetState('collapsed') const { lng, lat } = e.lngLat @@ -2414,7 +2420,15 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + const bounds = [[minLng, minLat], [maxLng, maxLat]] + const currentZoom = map.getZoom() + const target = map.cameraForBounds(bounds, { padding: 50 }) + // NEVER zoom out - user's zoom level is intentional + if (target && target.zoom < currentZoom) { + // Would zoom out — just draw the boundary without moving camera + return + } + map.fitBounds(bounds, { padding: 50, duration: 700, maxZoom: 16, diff --git a/src/store.js b/src/store.js index bc36648..6b7f30d 100644 --- a/src/store.js +++ b/src/store.js @@ -100,7 +100,7 @@ export const useStore = create((set, get) => ({ autocompleteOpen: false, theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: 'map', // 'map' | 'satellite' | 'hybrid' + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' setSheetState: (s) => set({ sheetState: s }), setViewMode: (mode) => { From bd372b9dc9728d5289b0e7db983a43cda8b4f97f Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 18:07:21 +0000 Subject: [PATCH 10/34] fix(map): Prevent camera zoom-out when clicking features - Track place identity with lastFlyTargetRef to avoid re-flying on metadata updates (boundary, wikidata, etc.) - Only flyTo on NEW place selection, not subsequent store updates - Apply z14 threshold to all camera movements: - flyTo for search results: only if currentZoom < 14 - fitBounds for boundaries: only if currentZoom < 14 - At z14+ camera stays put, boundary draws silently Fixes zoom-out bug where clicking a feature at high zoom would zoom back out to z14. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 45 ++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 2e77670..2dbed45 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1497,6 +1497,7 @@ const MapView = forwardRef(function MapView(_, ref) { const highlightedFeatureRef = useRef(null) // { source, sourceLayer, id } for setFeatureState const hoveredFeatureRef = useRef(null) // for hover highlight const updateBoundaryRef = useRef(null) // boundary update function + const lastFlyTargetRef = useRef(null) // track last fly target to avoid re-flying on metadata updates // Refs for measurement state (accessible in click handlers) const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements @@ -2420,21 +2421,16 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { - const bounds = [[minLng, minLat], [maxLng, maxLat]] + // Only fit bounds if zoomed out (< z14). At z14+ just draw boundary silently. const currentZoom = map.getZoom() - const target = map.cameraForBounds(bounds, { padding: 50 }) - // NEVER zoom out - user's zoom level is intentional - if (target && target.zoom < currentZoom) { - // Would zoom out — just draw the boundary without moving camera - return + if (currentZoom < 14) { + const bounds = [[minLng, minLat], [maxLng, maxLat]] + map.fitBounds(bounds, { + padding: 50, + duration: 700, + maxZoom: 16, + }) } - map.fitBounds(bounds, { - padding: 50, - duration: 700, - maxZoom: 16, - }) - } else { - console.warn('Invalid bounds:', { minLng, maxLng, minLat, maxLat }) } } } catch (e) { @@ -2622,11 +2618,26 @@ const MapView = forwardRef(function MapView(_, ref) { previewMarkerRef.current = null } - if (!selectedPlace) return + if (!selectedPlace) { + lastFlyTargetRef.current = null + return + } - // Only fly to place if it came from search (not map-click which already centered) - if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + // Track place identity - only fly on NEW place selection, not metadata updates + const placeKey = `${selectedPlace.lat}-${selectedPlace.lon}-${selectedPlace.name}` + if (placeKey === lastFlyTargetRef.current) { + // Same place, skip flyTo (this is just a metadata update) + } else { + lastFlyTargetRef.current = placeKey + + // Only fly to place if it came from search (not map-click which already centered) + if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { + // Only fly IN if below z14. At z14+ do nothing. + const currentZoom = map.getZoom() + if (currentZoom < 14) { + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) + } + } } // Different visual feedback based on mode From 62669fc7de5ede5a325d12e449bfb259b2df594c Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 18:36:07 +0000 Subject: [PATCH 11/34] fix(map): Always zoom in to fit boundaries, never zoom out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace blanket z14 cap with zoom-in-only logic for boundary fitBounds: - target.zoom > currentZoom → zoom IN to fit boundary (always allowed) - target.zoom < currentZoom → skip fitBounds (never zoom out) - target.zoom == currentZoom → allow pan to center This means: - Click Portland at z5 → fly in to ~z10 to show city boundary - Click Idaho at z5 → fly in to ~z6 to show state boundary - Click Portland at z15 → boundary draws, camera stays at z15 The z14 cap for selectedPlace flyTo (search results without boundary) is preserved - that only affects the initial flyTo to coordinates. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 2dbed45..bf61605 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2421,16 +2421,24 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { - // Only fit bounds if zoomed out (< z14). At z14+ just draw boundary silently. + const bounds = [[minLng, minLat], [maxLng, maxLat]] const currentZoom = map.getZoom() - if (currentZoom < 14) { - const bounds = [[minLng, minLat], [maxLng, maxLat]] - map.fitBounds(bounds, { - padding: 50, - duration: 700, - maxZoom: 16, - }) + const target = map.cameraForBounds(bounds, { padding: 50 }) + + // Zoom-in only: allow zoom in to show boundary, never zoom out + // - target.zoom > currentZoom → zoom IN to fit (always allowed) + // - target.zoom < currentZoom → DON'T zoom out (skip fitBounds) + // - target.zoom == currentZoom → pan only (allowed) + if (!target || target.zoom < currentZoom) { + // Would zoom out — just draw the boundary without moving camera + return } + + map.fitBounds(bounds, { + padding: 50, + duration: 700, + maxZoom: 16, + }) } } } catch (e) { From 1ad43e58cfaa10bb46df2d8c0434fc6ecec19698 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 18:45:41 +0000 Subject: [PATCH 12/34] fix(map): Unify label/polygon click paths and fix boundary fitBounds Fix A: Label click now also queries polygon layers - When clicking park/forest/cemetery labels, also query landuse_park fill layer to get polygon geometry - If polygon found, use it as boundary directly from rendered tiles - Eliminates need for API round-trip for park boundaries Fix B: Boundary fitBounds behavior changed - If boundary EXISTS: ALWAYS fitBounds (zoom in OR out) to show the full boundary - the boundary defines what user should see - If NO boundary: NEVER change zoom for map clicks/label clicks - Search results: fly to center but preserve current zoom level Removed previous z14 cap and zoom-in-only logic - boundaries now always control the camera as expected. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 52 +++++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index bf61605..6fb76ca 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2233,6 +2233,25 @@ const MapView = forwardRef(function MapView(_, ref) { featureLat = geom.coordinates[1] } + // FIX A: For park-type features, also query polygon layers to get boundary geometry + const parkKinds = ['national_park', 'park', 'cemetery', 'protected_area', 'nature_reserve', 'forest', 'golf_course', 'wood', 'zoo', 'garden'] + let polygonGeometry = null + if (parkKinds.includes(props.kind)) { + // Query fill layers at the same point to find the polygon + const fillLayers = ['landuse_park', 'landuse_other'].filter(id => map.getLayer(id)) + if (fillLayers.length > 0) { + const fillFeatures = map.queryRenderedFeatures(e.point, { layers: fillLayers }) + // Find a polygon feature with matching name or at the same location + const matchingPolygon = fillFeatures.find(f => + f.properties?.name === props.name || + (f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon') + ) + if (matchingPolygon?.geometry) { + polygonGeometry = matchingPolygon.geometry + } + } + } + // Apply feature state highlight const featureId = labelFeature.id ?? props.mvt_id const sourceLayer = labelFeature.sourceLayer @@ -2251,6 +2270,11 @@ const MapView = forwardRef(function MapView(_, ref) { // For feature clicks, don't show pin marker store.clearClickMarker() + // If we found polygon geometry from the fill layer, use it as boundary directly + if (polygonGeometry && updateBoundaryRef.current) { + updateBoundaryRef.current(polygonGeometry) + } + store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2269,6 +2293,7 @@ const MapView = forwardRef(function MapView(_, ref) { kind: props.kind || null, kind_detail: props.kind_detail || null, elevation: props.elevation || null, + polygonGeometry: polygonGeometry || null, // Store polygon if found }, }) } else { @@ -2421,19 +2446,9 @@ const MapView = forwardRef(function MapView(_, ref) { // Validate bounds before fitting if (minLng >= -180 && maxLng <= 180 && minLat >= -90 && maxLat <= 90 && minLng < maxLng && minLat < maxLat) { + // FIX B: ALWAYS fitBounds when boundary exists - zoom in OR out + // The boundary defines what the user should see const bounds = [[minLng, minLat], [maxLng, maxLat]] - const currentZoom = map.getZoom() - const target = map.cameraForBounds(bounds, { padding: 50 }) - - // Zoom-in only: allow zoom in to show boundary, never zoom out - // - target.zoom > currentZoom → zoom IN to fit (always allowed) - // - target.zoom < currentZoom → DON'T zoom out (skip fitBounds) - // - target.zoom == currentZoom → pan only (allowed) - if (!target || target.zoom < currentZoom) { - // Would zoom out — just draw the boundary without moving camera - return - } - map.fitBounds(bounds, { padding: 50, duration: 700, @@ -2638,14 +2653,17 @@ const MapView = forwardRef(function MapView(_, ref) { } else { lastFlyTargetRef.current = placeKey - // Only fly to place if it came from search (not map-click which already centered) + // FIX B: Camera behavior depends on source and whether boundary exists + // - map_click / basemap_label: NO camera movement (boundary fitBounds handles it if exists) + // - search results: fly to center, but DON'T change zoom (user chose their zoom) if (selectedPlace.source !== 'map_click' && selectedPlace.source !== 'basemap_label') { - // Only fly IN if below z14. At z14+ do nothing. + // Search result - fly to center without changing zoom + // Note: if this place has a boundary, the boundary fitBounds will zoom appropriately const currentZoom = map.getZoom() - if (currentZoom < 14) { - map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 }) - } + map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: currentZoom, duration: 800 }) } + // For map_click and basemap_label: do nothing to camera + // The boundary fitBounds will handle zooming if a boundary is fetched } // Different visual feedback based on mode From c14edb0e539bdef3edaa0a0453b095d1d3c9cf77 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:06:33 +0000 Subject: [PATCH 13/34] feat(map): Add state/province boundary lines at z4-z7 Fix D: State and province administrative boundaries are now visible at low zoom levels (z4-z7) with theme-aware styling. - Added STATE_BOUNDARIES_LAYER constant - Added addStateBoundaries() function that creates a line layer filtering on kind_detail = 4 (state/province level) - Uses dashed line style with opacity interpolation - Layer uses theme boundaries color for consistency - Layer is re-added on theme change to update colors The layer renders below labels and provides subtle but visible state/province boundaries when zoomed out viewing country-level maps. Line width and opacity increase as you zoom in from z4 to z7. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 64 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 6fb76ca..e5a90f1 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -23,6 +23,7 @@ function isCurrentThemeDark() { const ROUTE_SOURCE = 'route-source' const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' +const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' @@ -1481,6 +1482,63 @@ function addBoundaryLayer(map) { }, firstSymbolId) } +/** + * FIX D: Add state/province boundary lines visible at z4-z7 + * These are administrative boundaries with kind_detail = 4 (state/province level) + * Uses theme-aware styling from the boundaries color + */ +function addStateBoundaries(map, themeId) { + if (!map || map.getLayer(STATE_BOUNDARIES_LAYER)) return + + // Get the boundaries color from the current theme + const theme = getTheme(themeId) + const boundaryColor = theme?.colors?.boundaries || '#808080' + + // Find first symbol layer to insert below labels + const layers = map.getStyle().layers + let firstSymbolId = null + for (const layer of layers) { + if (layer.type === 'symbol') { + firstSymbolId = layer.id + break + } + } + + // Add state/province boundaries layer for z4-z7 + // kind_detail 4 = state/province level administrative boundaries + map.addLayer({ + id: STATE_BOUNDARIES_LAYER, + type: 'line', + source: 'protomaps', + 'source-layer': 'boundaries', + filter: ['==', 'kind_detail', 4], + minzoom: 4, + maxzoom: 8, + paint: { + 'line-color': boundaryColor, + 'line-width': [ + 'interpolate', ['linear'], ['zoom'], + 4, 0.5, + 7, 1.0 + ], + 'line-opacity': [ + 'interpolate', ['linear'], ['zoom'], + 4, 0.4, + 7, 0.6 + ], + 'line-dasharray': [4, 2], + }, + }, firstSymbolId) +} + +/** Remove state boundaries layer */ +function removeStateBoundaries(map) { + if (!map) return + if (map.getLayer(STATE_BOUNDARIES_LAYER)) { + map.removeLayer(STATE_BOUNDARIES_LAYER) + } +} + const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -2383,6 +2441,9 @@ const MapView = forwardRef(function MapView(_, ref) { // Apply improved base label styling for readability applyBaseLabelStyling(map) + // FIX D: Add state/province boundary lines at z4-z7 + addStateBoundaries(map, currentThemeRef.current) + // Restore overlay layers from localStorage prefs try { const raw = localStorage.getItem('navi-layer-prefs') @@ -2599,6 +2660,9 @@ const MapView = forwardRef(function MapView(_, ref) { // Apply improved base label styling for readability applyBaseLabelStyling(map) + // FIX D: Re-add state boundaries with new theme colors + addStateBoundaries(map, currentThemeRef.current) + // Re-add active overlay layers if (activeLayersRef.current.hillshade) addHillshade(map, currentThemeRef.current) if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current) From 83e8ffeb2d522c564b18fa7841446f96a910dcbe Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:10:45 +0000 Subject: [PATCH 14/34] fix(map): Show state/province labels at lower zoom levels Adjust label zoom ranges after style load for proper hierarchy: - Countries (places_country): visible from z2+ - States/provinces (places_region): visible from z3+ - Cities follow their natural min_zoom in the tile data This ensures states like Idaho and Oregon appear before cities like Boise and Portland when zoomed out. The setLayerZoomRange calls are made in applyBaseLabelStyling() which runs after style load and theme changes. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index e5a90f1..1226044 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -265,6 +265,21 @@ function applyBaseLabelStyling(map) { 'text-halo-width': 1.8, } }) + + // Adjust label zoom ranges for proper hierarchy: + // - Countries at z2+ + // - States/provinces at z3+ + // - Cities follow their natural min_zoom in the data + try { + if (map.getLayer('places_country')) { + map.setLayerZoomRange('places_country', 2, 24) + } + if (map.getLayer('places_region')) { + map.setLayerZoomRange('places_region', 3, 24) + } + } catch (e) { + // Ignore if layers don't exist + } } /** Build a full MapLibre style object for the given theme */ From 5fcd6cda9a9b8b8de04c38e0186dbe03cf22b7f4 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:17:56 +0000 Subject: [PATCH 15/34] fix(map): Set exact label zoom ranges for hierarchy places_country: z1-z4 (countries visible at world view, fade at z4) places_region: z4-z7 (states appear at z4, fade as cities dominate) places_locality: unchanged (follows natural min_zoom in tiles) Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 1226044..fd1cba2 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -267,15 +267,15 @@ function applyBaseLabelStyling(map) { }) // Adjust label zoom ranges for proper hierarchy: - // - Countries at z2+ - // - States/provinces at z3+ - // - Cities follow their natural min_zoom in the data + // - Countries: z1-z4 (fade out as states appear) + // - States/provinces: z4-z7 (appear as countries fade, fade as cities dominate) + // - Cities: unchanged (natural min_zoom in tile data) try { if (map.getLayer('places_country')) { - map.setLayerZoomRange('places_country', 2, 24) + map.setLayerZoomRange('places_country', 1, 4) } if (map.getLayer('places_region')) { - map.setLayerZoomRange('places_region', 3, 24) + map.setLayerZoomRange('places_region', 4, 7) } } catch (e) { // Ignore if layers don't exist From bb164965ef73d2350d8b06535bdc6d6d76575de0 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:36:58 +0000 Subject: [PATCH 16/34] fix(map): Fix state labels by using coalesce fallback for text-field The protomaps theme generates text-field expressions using name:short, but the PMTiles data doesn't have that property. States have 'ref' (e.g. 'CA', 'ON') and 'name' (e.g. 'California', 'Ontario'). - Use coalesce expression: name:short -> ref -> name - Expand zoom ranges slightly: country z1-5, region z4-8 - Verified fix in built JS before deployment Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index fd1cba2..23df9cb 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -272,10 +272,18 @@ function applyBaseLabelStyling(map) { // - Cities: unchanged (natural min_zoom in tile data) try { if (map.getLayer('places_country')) { - map.setLayerZoomRange('places_country', 1, 4) + map.setLayerZoomRange('places_country', 1, 5) } if (map.getLayer('places_region')) { - map.setLayerZoomRange('places_region', 4, 7) + map.setLayerZoomRange('places_region', 4, 8) + // FIX: The protomaps theme uses name:short which doesn't exist in tiles + // Use coalesce to fall back to ref (e.g., "CA") then name (e.g., "California") + map.setLayoutProperty('places_region', 'text-field', [ + 'coalesce', + ['get', 'name:short'], + ['get', 'ref'], + ['get', 'name'] + ]) } } catch (e) { // Ignore if layers don't exist From 238fc68546dd57f77f710574922a270baaa1178f Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 2 May 2026 19:42:46 +0000 Subject: [PATCH 17/34] fix(map): Prefer full state names over abbreviations Coalesce order: name -> ref -> name:short Shows California not CA Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 23df9cb..93ed2cb 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -280,9 +280,9 @@ function applyBaseLabelStyling(map) { // Use coalesce to fall back to ref (e.g., "CA") then name (e.g., "California") map.setLayoutProperty('places_region', 'text-field', [ 'coalesce', - ['get', 'name:short'], + ['get', 'name'], ['get', 'ref'], - ['get', 'name'] + ['get', 'name:short'] ]) } } catch (e) { From f3ec18bdf538842956dcf90000d54e14621f95b6 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 02:54:25 +0000 Subject: [PATCH 18/34] =?UTF-8?q?wip:=20contour=20overlay=20=E2=80=94=20br?= =?UTF-8?q?oken,=20needs=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 7 +++ package.json | 1 + src/components/MapView.jsx | 124 ++++++++++++++----------------------- 3 files changed, 53 insertions(+), 79 deletions(-) diff --git a/package-lock.json b/package-lock.json index 682949c..1fb2d33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", + "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", @@ -2685,6 +2686,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/maplibre-contour": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.0.tgz", + "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==", + "license": "BSD-3-Clause" + }, "node_modules/maplibre-gl": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.23.0.tgz", diff --git a/package.json b/package.json index 9faab00..b879244 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", + "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 93ed2cb..3599c5b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -12,7 +12,9 @@ import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' +import mlcontour from 'maplibre-contour' +let demSourceInstance = null /** Check if current theme is dark based on registry */ function isCurrentThemeDark() { @@ -33,11 +35,9 @@ const PUBLIC_LANDS_SOURCE = 'public-lands-tiles' const PUBLIC_LANDS_FILL = 'public-lands-fill' const PUBLIC_LANDS_LINE = 'public-lands-line' const PUBLIC_LANDS_LABEL = 'public-lands-label' -const CONTOUR_SOURCE = 'contour-tiles' -const CONTOUR_MINOR = 'contour-minor' -const CONTOUR_INTERMEDIATE = 'contour-intermediate' -const CONTOUR_INDEX = 'contour-index' -const CONTOUR_LABEL = 'contour-label' +const CONTOUR_SOURCE = 'contour-source' +const CONTOUR_LINE = 'contour-lines' +const CONTOUR_LABEL = 'contour-labels' const CONTOUR_TEST_SOURCE = 'contour-test-tiles' const CONTOUR_TEST_MINOR = 'contour-test-minor' const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate' @@ -563,105 +563,60 @@ function removePublicLands(map) { if (map.getSource(PUBLIC_LANDS_SOURCE)) map.removeSource(PUBLIC_LANDS_SOURCE) } -/** Add topographic contour vector tile overlay */ -function addContours(map, themeId) { +/** Add topographic contours via maplibre-contour */ +function addContours(map) { + console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE)) if (!map || map.getSource(CONTOUR_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contours') - map.addSource(CONTOUR_SOURCE, { type: 'vector', - url: 'pmtiles:///tiles/contours-na.pmtiles', + tiles: [demSourceInstance.contourProtocolUrl({ + multiplier: 3.28084, + thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + })], + maxzoom: 15, }) - - // Insert below first symbol layer (above hillshade, below labels) + console.log('[CONTOUR] protocol URL:', demSourceInstance.contourProtocolUrl({ + multiplier: 3.28084, + thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + })) + console.log('[CONTOUR] source added:', !!map.getSource(CONTOUR_SOURCE)) let beforeId = undefined for (const layer of map.getStyle().layers) { - if (layer.type === 'symbol') { - beforeId = layer.id - break - } + if (layer.type === 'symbol') { beforeId = layer.id; break } } - - // Minor contours (40ft) — visible z11+ + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' map.addLayer({ - id: CONTOUR_MINOR, - type: 'line', - source: CONTOUR_SOURCE, + id: CONTOUR_LINE, type: 'line', source: CONTOUR_SOURCE, 'source-layer': 'contours', - minzoom: 11, - filter: ['==', ['get', 'tier'], 'minor'], paint: { - 'line-color': c.minorColor, - 'line-opacity': c.minorOpacity * c.opacityMod, - 'line-width': ['interpolate', ['linear'], ['zoom'], 11, c.minorWidth.z11, 14, c.minorWidth.z14], + 'line-color': isDark ? '#c0b898' : '#8b6f47', + 'line-opacity': 0.7, + 'line-width': ['match', ['get', 'level'], 1, 1.5, 0.5], }, }, beforeId) - - // Intermediate contours (200ft) — visible z8+ map.addLayer({ - id: CONTOUR_INTERMEDIATE, - type: 'line', - source: CONTOUR_SOURCE, + id: CONTOUR_LABEL, type: 'symbol', source: CONTOUR_SOURCE, 'source-layer': 'contours', - minzoom: 8, - filter: ['==', ['get', 'tier'], 'intermediate'], - paint: { - 'line-color': c.intermediateColor, - 'line-opacity': c.intermediateOpacity * c.opacityMod, - 'line-width': ['interpolate', ['linear'], ['zoom'], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], - }, - }, beforeId) - - // Index contours (1000ft) — visible z4+ - map.addLayer({ - id: CONTOUR_INDEX, - type: 'line', - source: CONTOUR_SOURCE, - 'source-layer': 'contours', - minzoom: 4, - filter: ['==', ['get', 'tier'], 'index'], - paint: { - 'line-color': c.indexColor, - 'line-opacity': c.indexOpacity * c.opacityMod, - 'line-width': ['interpolate', ['linear'], ['zoom'], 4, c.indexWidth.z4, 14, c.indexWidth.z14], - }, - }, beforeId) - - // Elevation labels on index contours (z12+) - map.addLayer({ - id: CONTOUR_LABEL, - type: 'symbol', - source: CONTOUR_SOURCE, - 'source-layer': 'contours', - minzoom: 12, - filter: ['==', ['get', 'tier'], 'index'], + filter: ['>', ['get', 'level'], 0], layout: { - 'text-field': ['concat', ['to-string', ['get', 'elevation_ft']], "'"], - 'text-size': c.labelSize, - 'text-font': c.labelFont, - 'symbol-placement': 'line', - 'text-anchor': 'center', - 'symbol-spacing': 400, - 'text-max-angle': 30, - 'text-allow-overlap': false, + 'symbol-placement': 'line', 'text-size': 10, + 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], + 'text-font': ['Noto Sans Regular'], }, paint: { - 'text-color': c.labelColor, - 'text-halo-color': c.labelHaloColor, - 'text-halo-width': c.labelHaloWidth, - 'text-opacity': c.labelOpacity, + 'text-color': isDark ? '#c0b898' : '#5a4020', + 'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', + 'text-halo-width': 1.5, }, }) + console.log('[CONTOUR] layers added:', !!map.getLayer(CONTOUR_LINE), !!map.getLayer(CONTOUR_LABEL)) } /** Remove contour layers + source */ function removeContours(map) { if (!map) return if (map.getLayer(CONTOUR_LABEL)) map.removeLayer(CONTOUR_LABEL) - if (map.getLayer(CONTOUR_INDEX)) map.removeLayer(CONTOUR_INDEX) - if (map.getLayer(CONTOUR_INTERMEDIATE)) map.removeLayer(CONTOUR_INTERMEDIATE) - if (map.getLayer(CONTOUR_MINOR)) map.removeLayer(CONTOUR_MINOR) + if (map.getLayer(CONTOUR_LINE)) map.removeLayer(CONTOUR_LINE) if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE) } @@ -2071,6 +2026,17 @@ const MapView = forwardRef(function MapView(_, ref) { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) + // Initialize DemSource for maplibre-contour + if (!demSourceInstance) { + demSourceInstance = new mlcontour.DemSource({ + url: `${window.location.origin}/tiles/terrain/{z}/{x}/{y}`, + encoding: 'terrarium', + maxzoom: 14, + worker: true, + }) + demSourceInstance.setupMaplibre(maplibregl) + } + const config = getConfig() const DEFAULT_CENTER = config?.defaults?.center ? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat] From 400c485833e369232de59f912f3e30cf67c46b58 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 03:58:46 +0000 Subject: [PATCH 19/34] fix: contour overlay with pmtiles fork, absolute URL, extended zoom range - Switch to @acalcutt/maplibre-contour-pmtiles for PMTiles support - Use absolute URL for DemSource so Web Worker can resolve path - Extend contour thresholds from z3-z15 for full zoom coverage - Improve line styling with zoom-dependent width - Improve label styling with bold font and better halo Co-Authored-By: Claude --- package-lock.json | 14 ++++---- package.json | 2 +- src/components/MapView.jsx | 73 +++++++++++++++++++++++++------------- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1fb2d33..3bd1ca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,11 @@ "name": "navi", "version": "0.0.0", "dependencies": { + "@acalcutt/maplibre-contour-pmtiles": "^0.1.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", - "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", @@ -38,6 +38,12 @@ "vite": "^8.0.9" } }, + "node_modules/@acalcutt/maplibre-contour-pmtiles": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@acalcutt/maplibre-contour-pmtiles/-/maplibre-contour-pmtiles-0.1.2.tgz", + "integrity": "sha512-dCyJFLLM4NomLoJ22McRp7yETFmzUuA6iEMVJS6+mFyHoNk7Sv6RI4Hn0DhGKeyjcJgan3YnfSnzsqRinnXSug==", + "license": "BSD-3-Clause" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2686,12 +2692,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/maplibre-contour": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maplibre-contour/-/maplibre-contour-0.1.0.tgz", - "integrity": "sha512-H8muT7JWYE4oLbFv7L2RSbIM1NOu5JxjA9P/TQqhODDnRChE8ENoDkQIWOKgfcKNU77ypLk2ggGoh4/pt4UPLA==", - "license": "BSD-3-Clause" - }, "node_modules/maplibre-gl": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.23.0.tgz", diff --git a/package.json b/package.json index b879244..ae0057b 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@acalcutt/maplibre-contour-pmtiles": "^0.1.2", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "lucide-react": "^1.8.0", - "maplibre-contour": "^0.1.0", "maplibre-gl": "^5.23.0", "opening_hours": "^3.12.0", "pmtiles": "^4.4.1", diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 3599c5b..7ae3146 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -12,7 +12,7 @@ import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' -import mlcontour from 'maplibre-contour' +import mlcontour from '@acalcutt/maplibre-contour-pmtiles' let demSourceInstance = null @@ -565,19 +565,34 @@ function removePublicLands(map) { /** Add topographic contours via maplibre-contour */ function addContours(map) { - console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE)) - if (!map || map.getSource(CONTOUR_SOURCE)) return + console.log('[CONTOUR] addContours called, source exists:', !!map?.getSource(CONTOUR_SOURCE), 'demSource:', !!demSourceInstance) + if (!map || !demSourceInstance || map.getSource(CONTOUR_SOURCE)) return + const contourThresholds = { + 3: [5000, 25000], + 4: [2500, 10000], + 5: [1000, 5000], + 6: [1000, 5000], + 7: [500, 2500], + 8: [500, 2500], + 9: [250, 1000], + 10: [200, 1000], + 11: [200, 1000], + 12: [100, 500], + 13: [100, 500], + 14: [50, 200], + 15: [20, 100], + } map.addSource(CONTOUR_SOURCE, { type: 'vector', tiles: [demSourceInstance.contourProtocolUrl({ multiplier: 3.28084, - thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + thresholds: contourThresholds, })], - maxzoom: 15, + maxzoom: 16, }) console.log('[CONTOUR] protocol URL:', demSourceInstance.contourProtocolUrl({ multiplier: 3.28084, - thresholds: { 11: [200, 1000], 12: [100, 500], 13: [100, 500], 14: [50, 200] }, + thresholds: contourThresholds, })) console.log('[CONTOUR] source added:', !!map.getSource(CONTOUR_SOURCE)) let beforeId = undefined @@ -589,9 +604,13 @@ function addContours(map) { id: CONTOUR_LINE, type: 'line', source: CONTOUR_SOURCE, 'source-layer': 'contours', paint: { - 'line-color': isDark ? '#c0b898' : '#8b6f47', - 'line-opacity': 0.7, - 'line-width': ['match', ['get', 'level'], 1, 1.5, 0.5], + 'line-color': 'rgba(0,0,0,0.35)', + 'line-width': [ + 'interpolate', ['linear'], ['zoom'], + 7, ['match', ['get', 'level'], 1, 1, 0.3], + 11, ['match', ['get', 'level'], 1, 1.5, 0.6], + 14, ['match', ['get', 'level'], 1, 2, 0.8], + ], }, }, beforeId) map.addLayer({ @@ -599,13 +618,15 @@ function addContours(map) { 'source-layer': 'contours', filter: ['>', ['get', 'level'], 0], layout: { - 'symbol-placement': 'line', 'text-size': 10, + 'symbol-placement': 'line', + 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 11, 11, 14, 13], 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], - 'text-font': ['Noto Sans Regular'], + 'text-font': ['Noto Sans Bold'], + 'text-max-angle': 25, }, paint: { - 'text-color': isDark ? '#c0b898' : '#5a4020', - 'text-halo-color': isDark ? '#1a1a1a' : '#ffffff', + 'text-color': 'rgba(0,0,0,0.7)', + 'text-halo-color': 'rgba(255,255,255,0.9)', 'text-halo-width': 1.5, }, }) @@ -2026,18 +2047,22 @@ const MapView = forwardRef(function MapView(_, ref) { const protocol = new Protocol() maplibregl.addProtocol('pmtiles', protocol.tile) - // Initialize DemSource for maplibre-contour - if (!demSourceInstance) { - demSourceInstance = new mlcontour.DemSource({ - url: `${window.location.origin}/tiles/terrain/{z}/{x}/{y}`, - encoding: 'terrarium', - maxzoom: 14, - worker: true, - }) - demSourceInstance.setupMaplibre(maplibregl) - } - const config = getConfig() + + // Initialize DemSource for maplibre-contour (uses same PMTiles as hillshade) + if (!demSourceInstance) { + const hs = config?.tileset_hillshade + if (hs?.url) { + demSourceInstance = new mlcontour.DemSource({ + url: `pmtiles://${window.location.origin}${hs.url}`, + encoding: hs.encoding || 'terrarium', + maxzoom: hs.max_zoom || 12, + worker: true, + cacheSize: 100, + }) + demSourceInstance.setupMaplibre(maplibregl) + } + } const DEFAULT_CENTER = config?.defaults?.center ? [config.defaults.center[1], config.defaults.center[0]] // config is [lat,lon], MapLibre wants [lon,lat] : [-114.6066, 42.5736] From 5df01b1428cab8d1d35944f472b072aed4e3cd5c Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 14:18:18 +0000 Subject: [PATCH 20/34] fix: use Noto Sans Medium for contour labels (Bold not in protomaps assets) --- src/components/MapView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 7ae3146..19299ab 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -621,7 +621,7 @@ function addContours(map) { 'symbol-placement': 'line', 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 9, 11, 11, 14, 13], 'text-field': ['concat', ['number-format', ['get', 'ele'], {}], "'"], - 'text-font': ['Noto Sans Bold'], + 'text-font': ['Noto Sans Medium'], 'text-max-angle': 25, }, paint: { From 0b1854bd5fa2e55684f74dbd7294092ec64b9a0b Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 21:56:14 +0000 Subject: [PATCH 21/34] cleanup: remove dead contour-test code and stale fallback config - Remove contours-test.pmtiles and contours-test-10ft.pmtiles references (files deleted, feature flags disabled) - Update fallback tileset URL from na.pmtiles to planet/current.pmtiles - Remove has_contours_test and has_contours_test_10ft from fallback config - Delete 46 .bak* files from src/ Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 239 +------------------------------------ src/config.js | 4 +- 2 files changed, 2 insertions(+), 241 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 19299ab..0ede093 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -38,16 +38,6 @@ const PUBLIC_LANDS_LABEL = 'public-lands-label' const CONTOUR_SOURCE = 'contour-source' const CONTOUR_LINE = 'contour-lines' const CONTOUR_LABEL = 'contour-labels' -const CONTOUR_TEST_SOURCE = 'contour-test-tiles' -const CONTOUR_TEST_MINOR = 'contour-test-minor' -const CONTOUR_TEST_INTERMEDIATE = 'contour-test-intermediate' -const CONTOUR_TEST_INDEX = 'contour-test-index' -const CONTOUR_TEST_LABEL = 'contour-test-label' -const CONTOUR_TEST_10FT_SOURCE = 'contour-test-10ft-tiles' -const CONTOUR_TEST_10FT_MINOR = 'contour-test-10ft-minor' -const CONTOUR_TEST_10FT_INTERMEDIATE = 'contour-test-10ft-intermediate' -const CONTOUR_TEST_10FT_INDEX = 'contour-test-10ft-index' -const CONTOUR_TEST_10FT_LABEL = 'contour-test-10ft-label' const MEASURE_SOURCE = 'measure-source' const MEASURE_LINE_LAYER = 'measure-line-layer' const MEASURE_POINT_LAYER = 'measure-point-layer' @@ -641,207 +631,6 @@ function removeContours(map) { if (map.getSource(CONTOUR_SOURCE)) map.removeSource(CONTOUR_SOURCE) } -/** Add TEST topographic contour overlay (blue color scheme) */ -function addContoursTest(map, themeId) { - if (!map || map.getSource(CONTOUR_TEST_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contoursTest') - - map.addSource(CONTOUR_TEST_SOURCE, { - type: "vector", - url: "pmtiles:///tiles/contours-test.pmtiles", - }) - - let beforeId = undefined - for (const layer of map.getStyle().layers) { - if (layer.type === "symbol") { - beforeId = layer.id - break - } - } - - // Minor contours (40ft) — blue scheme - map.addLayer({ - id: CONTOUR_TEST_MINOR, - type: "line", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 11, - filter: ["==", ["get", "tier"], "minor"], - paint: { - "line-color": c.minorColor, - "line-opacity": c.minorOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], - }, - }, beforeId) - - // Intermediate contours (200ft) - map.addLayer({ - id: CONTOUR_TEST_INTERMEDIATE, - type: "line", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 8, - filter: ["==", ["get", "tier"], "intermediate"], - paint: { - "line-color": c.intermediateColor, - "line-opacity": c.intermediateOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], - }, - }, beforeId) - - // Index contours (1000ft) - map.addLayer({ - id: CONTOUR_TEST_INDEX, - type: "line", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 4, - filter: ["==", ["get", "tier"], "index"], - paint: { - "line-color": c.indexColor, - "line-opacity": c.indexOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], - }, - }, beforeId) - - // Labels - map.addLayer({ - id: CONTOUR_TEST_LABEL, - type: "symbol", - source: CONTOUR_TEST_SOURCE, - "source-layer": "contours", - minzoom: 12, - filter: ["==", ["get", "tier"], "index"], - layout: { - "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], ""], - "text-size": c.labelSize, - "text-font": c.labelFont, - "symbol-placement": "line", - "text-anchor": "center", - "symbol-spacing": 400, - "text-max-angle": 30, - "text-allow-overlap": false, - }, - paint: { - "text-color": c.labelColor, - "text-halo-color": c.labelHaloColor, - "text-halo-width": c.labelHaloWidth, - "text-opacity": c.labelOpacity, - }, - }) -} - -/** Remove TEST contour layers + source */ -function removeContoursTest(map) { - if (!map) return - if (map.getLayer(CONTOUR_TEST_LABEL)) map.removeLayer(CONTOUR_TEST_LABEL) - if (map.getLayer(CONTOUR_TEST_INDEX)) map.removeLayer(CONTOUR_TEST_INDEX) - if (map.getLayer(CONTOUR_TEST_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_INTERMEDIATE) - if (map.getLayer(CONTOUR_TEST_MINOR)) map.removeLayer(CONTOUR_TEST_MINOR) - if (map.getSource(CONTOUR_TEST_SOURCE)) map.removeSource(CONTOUR_TEST_SOURCE) -} - -/** Add TEST 10ft topographic contour overlay (green color scheme) */ -function addContoursTest10ft(map, themeId) { - if (!map || map.getSource(CONTOUR_TEST_10FT_SOURCE)) return - - const c = getOverlayConfig(themeId, 'contoursTest10ft') - - map.addSource(CONTOUR_TEST_10FT_SOURCE, { - type: "vector", - url: "pmtiles:///tiles/contours-test-10ft.pmtiles", - }) - - let beforeId = undefined - for (const layer of map.getStyle().layers) { - if (layer.type === "symbol") { - beforeId = layer.id - break - } - } - - // Minor contours (10ft) — green scheme - map.addLayer({ - id: CONTOUR_TEST_10FT_MINOR, - type: "line", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 11, - filter: ["==", ["get", "tier"], "minor"], - paint: { - "line-color": c.minorColor, - "line-opacity": c.minorOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 11, c.minorWidth.z11, 14, c.minorWidth.z14], - }, - }, beforeId) - - // Intermediate contours (50ft) — green scheme - map.addLayer({ - id: CONTOUR_TEST_10FT_INTERMEDIATE, - type: "line", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 8, - filter: ["==", ["get", "tier"], "intermediate"], - paint: { - "line-color": c.intermediateColor, - "line-opacity": c.intermediateOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 8, c.intermediateWidth.z8, 14, c.intermediateWidth.z14], - }, - }, beforeId) - - // Index contours (250ft) — darker green - map.addLayer({ - id: CONTOUR_TEST_10FT_INDEX, - type: "line", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 4, - filter: ["==", ["get", "tier"], "index"], - paint: { - "line-color": c.indexColor, - "line-opacity": c.indexOpacity * c.opacityMod, - "line-width": ["interpolate", ["linear"], ["zoom"], 4, c.indexWidth.z4, 14, c.indexWidth.z14], - }, - }, beforeId) - - // Elevation labels on index contours (z12+) - map.addLayer({ - id: CONTOUR_TEST_10FT_LABEL, - type: "symbol", - source: CONTOUR_TEST_10FT_SOURCE, - "source-layer": "contours", - minzoom: 12, - filter: ["==", ["get", "tier"], "index"], - layout: { - "text-field": ["concat", ["to-string", ["get", "elevation_ft"]], "'"], - "text-size": c.labelSize, - "text-font": c.labelFont, - "symbol-placement": "line", - "text-anchor": "center", - "symbol-spacing": 400, - "text-max-angle": 30, - "text-allow-overlap": false, - }, - paint: { - "text-color": c.labelColor, - "text-halo-color": c.labelHaloColor, - "text-halo-width": c.labelHaloWidth, - "text-opacity": c.labelOpacity, - }, - }) -} - -/** Remove test 10ft contour layers + source */ -function removeContoursTest10ft(map) { - if (!map) return - if (map.getLayer(CONTOUR_TEST_10FT_LABEL)) map.removeLayer(CONTOUR_TEST_10FT_LABEL) - if (map.getLayer(CONTOUR_TEST_10FT_INDEX)) map.removeLayer(CONTOUR_TEST_10FT_INDEX) - if (map.getLayer(CONTOUR_TEST_10FT_INTERMEDIATE)) map.removeLayer(CONTOUR_TEST_10FT_INTERMEDIATE) - if (map.getLayer(CONTOUR_TEST_10FT_MINOR)) map.removeLayer(CONTOUR_TEST_10FT_MINOR) - if (map.getSource(CONTOUR_TEST_10FT_SOURCE)) map.removeSource(CONTOUR_TEST_10FT_SOURCE) -} /** Add USFS trails and roads vector tile overlay */ function addUsfsTrails(map, themeId) { if (!map || map.getSource(USFS_SOURCE)) return @@ -1548,7 +1337,7 @@ const MapView = forwardRef(function MapView(_, ref) { const watchIdRef = useRef(null) const currentThemeRef = useRef('dark') // Track which overlay layers are currently active (for theme swap re-add) - const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, contoursTest: false, contoursTest10ft: false, usfsTrails: false, blmTrails: false }) + const activeLayersRef = useRef({ hillshade: false, traffic: false, contours: false, usfsTrails: false, blmTrails: 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 @@ -1971,30 +1760,6 @@ const MapView = forwardRef(function MapView(_, ref) { removeContours(map) activeLayersRef.current.contours = false }, - addContoursTestLayer() { - const map = mapInstance.current - if (!map) return - addContoursTest(map, currentThemeRef.current) - activeLayersRef.current.contoursTest = true - }, - removeContoursTestLayer() { - const map = mapInstance.current - if (!map) return - removeContoursTest(map) - activeLayersRef.current.contoursTest = false - }, - addContoursTest10ftLayer() { - const map = mapInstance.current - if (!map) return - addContoursTest10ft(map, currentThemeRef.current) - activeLayersRef.current.contoursTest10ft = true - }, - removeContoursTest10ftLayer() { - const map = mapInstance.current - if (!map) return - removeContoursTest10ft(map) - activeLayersRef.current.contoursTest10ft = false - }, addUsfsTrailsLayer() { const map = mapInstance.current if (!map) return @@ -2682,8 +2447,6 @@ const MapView = forwardRef(function MapView(_, ref) { if (activeLayersRef.current.traffic) addTraffic(map, currentThemeRef.current) if (activeLayersRef.current.publicLands) addPublicLands(map, currentThemeRef.current) if (activeLayersRef.current.contours) addContours(map, currentThemeRef.current) - if (activeLayersRef.current.contoursTest) addContoursTest(map, currentThemeRef.current) - if (activeLayersRef.current.contoursTest10ft) addContoursTest10ft(map, currentThemeRef.current) if (activeLayersRef.current.usfsTrails) addUsfsTrails(map, currentThemeRef.current) if (activeLayersRef.current.blmTrails) addBlmTrails(map, currentThemeRef.current) diff --git a/src/config.js b/src/config.js index 274edba..97af6a1 100644 --- a/src/config.js +++ b/src/config.js @@ -10,7 +10,7 @@ const FALLBACK_CONFIG = { profile: 'home', region_name: 'North America', tileset: { - url: '/tiles/na.pmtiles', + url: '/tiles/planet/current.pmtiles', bounds: [-168, 14, -52, 72], max_zoom: 15, attribution: 'Protomaps © OSM', @@ -30,8 +30,6 @@ const FALLBACK_CONFIG = { has_landclass: false, has_public_lands_layer: false, has_contours: true, - has_contours_test: true, - has_contours_test_10ft: false, has_address_book_write: false, has_usfs_trails: false, has_blm_trails: false, From 400dcbb8f281708cb359f5ff119a5b6195e51c8e Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 23:22:48 +0000 Subject: [PATCH 22/34] docs: add OFFROUTE effort-based routing architecture Co-Authored-By: Claude Opus 4.5 --- docs/OFFROUTE-ARCHITECTURE.md | 396 ++++++++++++++++++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/OFFROUTE-ARCHITECTURE.md diff --git a/docs/OFFROUTE-ARCHITECTURE.md b/docs/OFFROUTE-ARCHITECTURE.md new file mode 100644 index 0000000..a4b4fd8 --- /dev/null +++ b/docs/OFFROUTE-ARCHITECTURE.md @@ -0,0 +1,396 @@ +# OFFROUTE — Off-Network Effort-Based Routing Architecture + +**Status:** Draft +**Author:** Matt / Claude +**Date:** 2026-05-07 +**Canonical location:** `matt/refactored-recon` alongside PROJECT-BIBLE.md, NAV-INTEGRATION-v4.md + +--- + +## 1. Vision + +From any arbitrary point in the backcountry — no trails, no roads, no signal — route via effort cost and safety to the nearest trail, to a BLM/forest road, to a paved road, to home. Four segments, one continuous path, one GeoJSON response. + +The system serves two interfaces: +- **Navi frontend** (`navi.echo6.co`) — visual route overlay on the map +- **Aurora via Meshtastic** — text-based step-by-step directions for a lost person with no map display + +This capability does not exist in any open-source consumer product. CalTopo, OnX, Gaia GPS, AllTrails — all route on-network only. The military has Primordial Ground Guidance (closed-source ATAK plugin). We are building the open, self-hosted equivalent. + +--- + +## 2. The Routing Chain + +``` +[Lost person] + │ + ▼ + ┌──────────────────────────────────────────┐ + │ Segment 1: WILDERNESS → TRAIL │ + │ Engine: Raster cost-surface pathfinder │ + │ Cost: slope effort + vegetation + │ + │ water barriers + land ownership │ + │ Output: lat/lon waypoint sequence │ + └──────────────────────────────────────────┘ + │ snap to nearest trail entry point + ▼ + ┌──────────────────────────────────────────┐ + │ Segment 2: TRAIL → BLM/FOREST ROAD │ + │ Engine: Valhalla (pedestrian/MTB) │ + │ Cost: elevation-aware hike/bike profile │ + └──────────────────────────────────────────┘ + │ transition to road network + ▼ + ┌──────────────────────────────────────────┐ + │ Segment 3: BLM ROAD → PAVED ROAD │ + │ Engine: Valhalla (auto/motorcycle) │ + │ Cost: standard + surface preference │ + └──────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────┐ + │ Segment 4: PAVED ROAD → HOME │ + │ Engine: Valhalla (auto) │ + │ Cost: standard routing │ + └──────────────────────────────────────────┘ +``` + +Segments 2–4 already work today via Valhalla. **Segment 1 is the engineering gap.** + +--- + +## 3. Endpoint Design + +### `POST /api/offroute` + +**Request:** +```json +{ + "start": { "lat": 43.512, "lon": -114.823 }, + "destination": { "lat": 42.736, "lon": -114.514 }, + "mode": "foot", + "max_search_km": 15 +} +``` + +**Modes:** `foot` | `mtb` | `atv` + +**Response:** +```json +{ + "segments": [ + { + "type": "wilderness", + "geometry": { "type": "LineString", "coordinates": [...] }, + "distance_m": 4200, + "elevation_gain_m": 310, + "elevation_loss_m": 85, + "estimated_time_min": 72, + "surface": "cross-country", + "instructions": [ + { "bearing": 245, "distance_m": 320, "terrain": "sagebrush slope", "grade_pct": 8 }, + { "bearing": 260, "distance_m": 510, "terrain": "drainage crossing", "grade_pct": -12 } + ] + }, + { + "type": "trail", + "geometry": { "type": "LineString", "coordinates": [...] }, + "trail_name": "Pioneer Cabin Trail", + "distance_m": 6100, + "estimated_time_min": 85 + }, + { + "type": "road_unpaved", + "geometry": { "type": "LineString", "coordinates": [...] }, + "road_name": "FR-227", + "distance_m": 12400, + "estimated_time_min": 22 + }, + { + "type": "road_paved", + "geometry": { "type": "LineString", "coordinates": [...] }, + "distance_m": 34000, + "estimated_time_min": 28 + } + ], + "total_distance_m": 56700, + "total_time_min": 207, + "confidence": 0.82 +} +``` + +**Aurora tool integration:** Add `offroute` to `nav_tools.py` alongside existing `route()` and `reverse_geocode()`. The semantic query router gets a new embedding for "I'm lost, help me get home" / "navigate to nearest road" type queries. + +--- + +## 4. Pathfinder Architecture (Segment 1) + +### 4.1 No Pre-Rendered Slope Rasters + +The pathfinder does NOT need pre-computed slope layers, GDAL processing, or reprojection. It reads elevation directly: + +1. Routing request arrives with a start point and search radius +2. Determine which PMTiles z12 tiles cover the search area +3. Fetch + decode Terrarium tiles from `planet-dem.pmtiles` → numpy elevation arrays +4. Cache decoded arrays keyed by (z, x, y) — LRU, in-memory +5. A* / Dijkstra runs on the elevation grid, computing grade between neighbors on the fly +6. Cost function = `grade → effort model → multiply by land-cover friction → check barriers` + +### 4.2 Elevation Data Source + +**Primary:** `planet-dem.pmtiles` (658GB on pi-nas, served via nginx at `/tiles/planet-dem.pmtiles`) +- Mapterhorn, Copernicus GLO-30 source, Terrarium encoding (lossless WebP) +- z12 with 512px tiles = ~13–16m pixels at Idaho latitude +- 30m effective resolution (upsampled from source) +- Decode: `elevation = (R * 256 + G + B/256) - 32768` (metres, EGM2008) +- Precision: ~3.9mm quantization — far below source noise (~4m RMSE) + +**Upgrade path:** USGS 3DEP 1/3 arc-second (10m bare-earth DTM, CONUS). Same architecture, denser grid. Free download. Address when/if 30m proves insufficient for safety. + +**Regional GeoTIFFs** (203GB on NAS at `/mnt/nas/nav/contour-rebuild/dem/`): Keep as insurance until this pipeline is validated, then delete. + +### 4.3 Cost Function + +For each candidate move from cell A to cell B: + +```python +def travel_cost(elev_a, elev_b, distance_m, friction_ab): + grade = (elev_b - elev_a) / distance_m + + # Safety gate — impassable above threshold + slope_deg = math.degrees(math.atan(abs(grade))) + if slope_deg > MAX_SLOPE[mode]: # foot=40°, mtb=25°, atv=30° + return INF + + # Effort model (speed in km/h) + if mode == "foot": + # Tobler off-path hiking function + speed = 0.6 * 6.0 * math.exp(-3.5 * abs(grade + 0.05)) + elif mode == "mtb": + # Herzog wheeled-transport polynomial (crit_slope=8%) + speed = herzog_wheeled(grade, crit_slope=0.08, base_speed=12) + elif mode == "atv": + # Herzog with higher base speed and slope tolerance + speed = herzog_wheeled(grade, crit_slope=0.15, base_speed=25) + + # Time cost (seconds to traverse this cell) + time_s = (distance_m / 1000.0) / speed * 3600.0 + + # Multiply by land-cover friction + time_s *= friction_ab + + return time_s +``` + +**Tobler off-path:** `W = 0.6 × 6 × exp(-3.5 × |S + 0.05|)` km/h +Peak speed 3.6 km/h at ~-2.86° (slight downhill). The 0.6 multiplier is the off-trail penalty. + +**Herzog wheeled-transport:** sixth-degree polynomial fitted to wheeled vehicle energy expenditure. Has a `crit_slope` parameter where switchbacks become more efficient than direct climb. Best published proxy for MTB/ATV in open-source literature. + +**Reference implementations:** R `leastcostpath` package contains 30+ validated cost functions including Tobler, Tobler off-path, Irmischer-Clarke (male/female/off-path, fitted to USMA cadets), Naismith-Langmuir, Herzog, Minetti, Campbell 2019 percentiles. Port as needed. + +### 4.4 Friction Layers (Cost Surface Inputs) + +All pre-computed offline, tiled, cached. Updated infrequently. + +| Layer | Source | Resolution | Purpose | Update Frequency | +|---|---|---|---|---| +| Elevation | planet-dem.pmtiles | ~30m (z12) | Slope/grade calculation | Static | +| Land cover | NLCD | 30m | Vegetation traversal friction | ~Annual | +| Waterways | OSM | Rasterized from vectors | Barrier (∞ cost) except at bridges/fords | Weekly from planet PBF | +| Water bodies | OSM `natural=water` | Rasterized polygons | Barrier (∞) | Weekly | +| Cliffs | OSM `natural=cliff` | Rasterized lines | Barrier (∞) | Weekly | +| Land ownership | PAD-US | Polygon raster | Access restrictions per mode | ~Quarterly | +| Trails/roads | OSM + USFS | Rasterized lines | Low-cost corridors (negative friction) | Weekly | + +**NLCD friction mapping (foot mode example):** + +| NLCD Class | Description | Friction Multiplier | +|---|---|---| +| 11 | Open Water | ∞ | +| 21 | Developed, Open Space | 1.0 | +| 22 | Developed, Low Intensity | 1.2 | +| 31 | Barren Land | 1.1 | +| 41 | Deciduous Forest | 1.8 | +| 42 | Evergreen Forest | 2.0 | +| 43 | Mixed Forest | 1.9 | +| 52 | Shrub/Scrub | 1.5 | +| 71 | Grassland/Herbaceous | 1.2 | +| 90 | Woody Wetlands | 3.5 | +| 95 | Emergent Herbaceous Wetlands | 4.0 | + +Mode-specific adjustments: MTB and ATV get higher penalties on forest/wetland. ATV gets ∞ on wilderness-designated areas (PAD-US `Des_Tp = WA`). + +**Trail burn-in:** Rasterize OSM trails/tracks as cells with reduced friction (trail cell = 0.5× base, track = 0.3×, road = 0.1×). The pathfinder naturally gravitates toward and follows these corridors without special logic. + +### 4.5 Engine Choice + +**Recommended: scikit-image `MCP_Geometric` for initial build.** + +- Cython Dijkstra, 1–5 seconds on 2–4M cell grids +- `find_costs(start)` computes cumulative cost surface once; `traceback(target)` for any target is O(path length) — reuse for "nearest trail," "nearest road," and destination all in one pass +- `MCP_Flexible` subclass allows overriding `_travel_cost()` for anisotropic costs (uphill ≠ downhill) +- Pure Python integration with Flask backend +- Memory OK up to ~20–40M cells on 24GB + +**Performance path: Rust `pathfinding` crate as a microservice.** + +- A*, Dijkstra, HPA* (hierarchical) all available +- Custom successor function encodes anisotropic cost +- Sub-second on 4M cells +- `hierarchical_pathfinding` crate enables multi-resolution: coarse pass → refine in corridor +- Wrap in Axum HTTP server, call from Flask + +**Decision:** Start with scikit-image Python. If latency is a problem, rewrite the inner loop in Rust. The cost function, data pipeline, and API don't change. + +### 4.6 Multi-Resolution Strategy + +For routes where the wilderness segment exceeds ~10km, full-resolution pathfinding on the entire search area gets expensive. Use the Primordial Ground Guidance approach: + +1. **Coarse pass:** Downsample cost grid 4× (120m cells). Solve A*. Sub-second. +2. **Corridor extraction:** Buffer the coarse path by 200m. +3. **Fine pass:** Re-solve at native 30m resolution only within the corridor. Sub-second. +4. **Total:** <2 seconds for a 15km wilderness segment. + +### 4.7 Network Hand-Off + +The raster pathfinder needs to know where the trail/road network starts so it can stop: + +1. **Pre-compute trail entry points:** Extract from OSM all endpoints and intersections of `highway=path|track|footway|bridleway|unclassified|tertiary|secondary|primary`. Store as a PostGIS point table (or SQLite spatial index in `navi.db`). +2. **Rasterize entry points** onto the cost grid as target cells. +3. **Run `MCP.find_costs(start)`** — the Dijkstra wave expands until it reaches any entry-point cell. Use `goal_reached()` override in `MCP_Flexible` for early termination. +4. **Snap** the reached entry point to its nearest Valhalla graph node. +5. **Call Valhalla** from that node to destination with appropriate costing profile. +6. **Concatenate** raster path + Valhalla path into one GeoJSON with per-segment metadata. + +--- + +## 5. Data Acquisition Checklist + +| Dataset | Status | Size | Action | +|---|---|---|---| +| DEM (planet-dem.pmtiles) | ✅ Have it | 658GB | Serving via nginx from pi-nas | +| NLCD Land Cover (CONUS) | ❌ Not acquired | ~5GB | Download from USGS MRLC | +| NLCD Tree Canopy (CONUS) | ❌ Not acquired | ~2GB | Optional — continuous friction surface | +| OSM Planet PBF | ❌ Not acquired for this use | ~70GB | Extract waterways, cliffs, trails via osmium | +| PAD-US | ✅ Have source | 1.6GB in /mnt/nav/padus/ | Rasterize by access class | +| USFS Trail/Road layers | ✅ Have PMTiles | 848MB + 496MB | Need raw vectors for rasterization | +| Trail entry points index | ❌ Not built | ~50MB | Extract from OSM + USFS | + +**First acquisition:** NLCD. It's the single most impactful layer after the DEM — without land cover, the pathfinder can't distinguish open meadow from dense forest. + +--- + +## 6. Safety Considerations + +This system may guide people through dangerous terrain. Design constraints: + +- **Hard slope cutoffs are non-negotiable.** No route segment should ever cross terrain above the mode's max slope threshold, regardless of how much faster the direct path would be. +- **Confidence scoring:** Every response includes a `confidence` field (0.0–1.0) based on: DEM resolution vs route steepness, distance from nearest verified trail data, land cover data freshness, number of barrier crossings. +- **Fallback behaviors:** If no safe route exists within `max_search_km`, return an error with the direction and distance to the nearest trail (as a bearing, not a route). Never hallucinate a route through impassable terrain. +- **Per-step user confirmation (Aurora/Meshtastic):** In text mode, Aurora should confirm each major terrain transition ("You will cross a drainage heading southwest — confirm you can see safe footing"). A lost person should never blindly follow instructions into terrain they can't visually verify. +- **DSM vs DTM caveat:** Copernicus GLO-30 is a Digital Surface Model (includes treetops, buildings). A flat meadow next to tall pines will show false slope at the treeline. The system should note this in Aurora's instructions for forested areas. +- **30m resolution risk:** A 15m-wide cliff band can be smoothed into a single "steep but passable" cell. The safety gate catches obvious cliffs but may miss narrow features. Documented limitation; mitigated by upgrading to 10m USGS 3DEP in the future. + +--- + +## 7. Implementation Phases + +### Phase O1: Foundation +- Acquire NLCD CONUS land cover +- Build PMTiles elevation decoder + tile cache module +- Implement Tobler off-path cost function +- Prototype: scikit-image MCP on a small Idaho bbox (e.g., 20km × 20km around Sun Valley) +- Validate: does the path avoid canyons, prefer gentle slopes, follow drainages? + +### Phase O2: Friction Integration +- Rasterize NLCD into friction grid +- Rasterize OSM waterways/cliffs as barriers +- Rasterize PAD-US access restrictions +- Burn OSM trails/roads as low-cost corridors +- Combined cost surface for foot mode + +### Phase O3: Network Hand-Off +- Build trail entry point index from OSM + USFS +- Implement MCP → Valhalla stitching +- `/api/offroute` endpoint (foot mode only) +- GeoJSON response with per-segment metadata + +### Phase O4: Multi-Mode + Aurora +- Add MTB cost function (Herzog wheeled-transport) +- Add ATV cost function +- Mode-specific barrier rules (wilderness restrictions for MTB/ATV) +- Aurora tool integration — `offroute` in nav_tools.py +- Meshtastic text-based instruction generation (bearings, terrain descriptions) + +### Phase O5: Performance + Polish +- Multi-resolution pathfinding (coarse → corridor → fine) +- Rust pathfinder microservice (if Python latency is insufficient) +- Confidence scoring +- Navi frontend route visualization with segment coloring +- Elevation profile display per segment + +### Phase O6: Pi 5 Field Kit +- Offline PMTiles elevation access +- Pre-baked cost tiles for Idaho/CONUS-West +- Bbox-filter packager for all spatial datasets +- Full offline operation via Meshtastic ↔ Aurora ↔ offroute chain + +--- + +## 8. Infrastructure + +**Runtime services (VM 1130):** +- `/api/offroute` — Flask endpoint in RECON dashboard +- Tile cache — LRU in-memory decoded elevation arrays +- Valhalla Docker :8002 — on-network routing (already running) + +**Data (VM 1130 /mnt/nav/):** +- Pre-baked friction rasters (NLCD, barriers, trails) — tiled GeoTIFF or COG +- Trail entry point index — SQLite spatial in navi.db + +**Data (pi-nas /mnt/nas/nav/):** +- planet-dem.pmtiles — 658GB, served via nginx +- Regional GeoTIFF DEMs — 203GB, insurance until pipeline validated + +**Compute (cortex or matt-desktop):** +- One-time cost surface generation jobs (NLCD rasterization, OSM extraction, barrier tiling) + +--- + +## 9. Key Decisions Made + +| Decision | Rationale | +|---|---| +| No pre-rendered slope rasters | Pathfinder computes grade on the fly from cached elevation arrays. Simpler, no GDAL dependency at runtime. | +| planet-dem.pmtiles as single elevation source | Same data already drives contours + hillshade. 30m sufficient for first build. Global coverage. | +| scikit-image MCP for initial engine | Cython Dijkstra, proven on 2–4M cell grids, Python-native, anisotropic via MCP_Flexible. Rust upgrade path if needed. | +| Tobler off-path as primary foot cost model | Best-validated off-trail hiking function. Inherently anisotropic. 0.6× off-trail multiplier built in. | +| Trail burn-in (not separate hand-off logic) | Rasterizing trails as low-cost cells lets the pathfinder naturally follow them without mode-switching logic. | +| Pre-baked friction rasters (offline) | NLCD, barriers, and land ownership change slowly. Build once, cache, update periodically. | +| Multi-resolution for long routes | Coarse pass → corridor → fine pass. Standard technique from military route planning (Primordial Ground Guidance). | +| Confidence scoring on every response | Safety-critical system. User must know when to trust vs. verify the route. | + +--- + +## 10. Open Questions + +- [ ] What is the right `max_slope` cutoff per mode? Needs field testing / literature review. +- [ ] Should the pathfinder use A* (faster, needs admissible heuristic) or Dijkstra (guaranteed optimal, slower)? MCP uses Dijkstra; pyastar2d uses A*. +- [ ] How to generate natural-language terrain descriptions for Aurora from raster data? (e.g., "sagebrush slope" vs. "forested drainage") +- [ ] Should we pre-compute the full cost surface for Idaho/CONUS-West, or generate it on demand per request? +- [ ] How to handle seasonal/weather variations? (Snow, spring runoff, wildfire closures) +- [ ] Valhalla pedestrian elevation costing (PR #3234) — test and validate before relying on it for segments 2–4. +- [ ] USFS MVUM (Motor Vehicle Use Maps) — authoritative ATV/4WD legal access layer. Acquire and integrate for ATV mode. + +--- + +## References + +- Tobler, W. (1993). Three Presentations on Geographical Analysis and Modeling. NCGIA TR 93-1. +- Irmischer, I.J. & Clarke, K.C. (2018). Measuring and modeling the speed of human navigation. *Cartography and GIS*, 45(2), 177-186. +- Herzog, I. (2020). Spatial Analysis Based on Cost Functions. In *Archaeological Spatial Analysis*. +- Lewis, J. (2023). `leastcostpath` R package. CRAN. +- GRASS GIS. `r.walk` manual. grass.osgeo.org. +- Hoover, B. et al. (2019). CostMAP: An open-source software package for developing cost surfaces. LANL. +- Mapterhorn project. mapterhorn.com. BSD-3. From 95dd4438fea2710b2659ce3f264bff5871802f24 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 07:02:00 +0000 Subject: [PATCH 23/34] docs: add traffic-intelligent routing and Idaho 511 planned features - Append section 11 (On-Network Traffic Intelligence) to OFFROUTE-ARCHITECTURE.md - Create navi-feature-ideas.md with planned features: - Traffic-aware Valhalla routing via TomTom tiles - Idaho 511 incident feed integration - ADS-B/AIS tracking - TAK Server + EUD integration - Native iOS app Co-Authored-By: Claude Opus 4.5 --- docs/OFFROUTE-ARCHITECTURE.md | 31 ++++++++++++ docs/navi-feature-ideas.md | 92 +++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 docs/navi-feature-ideas.md diff --git a/docs/OFFROUTE-ARCHITECTURE.md b/docs/OFFROUTE-ARCHITECTURE.md index a4b4fd8..c29945b 100644 --- a/docs/OFFROUTE-ARCHITECTURE.md +++ b/docs/OFFROUTE-ARCHITECTURE.md @@ -394,3 +394,34 @@ This system may guide people through dangerous terrain. Design constraints: - GRASS GIS. `r.walk` manual. grass.osgeo.org. - Hoover, B. et al. (2019). CostMAP: An open-source software package for developing cost surfaces. LANL. - Mapterhorn project. mapterhorn.com. BSD-3. + + +--- + +## 11. On-Network Traffic Intelligence + +Two features that affect Valhalla segments (2–4) of the offroute chain, not the wilderness pathfinder (segment 1): + +### Traffic-Aware Routing + +- Valhalla supports time-dependent costing via traffic speed tiles +- TomTom traffic tiles already integrated in Navi at `/api/traffic/*` (currently visual overlay only) +- **Integration path:** configure Valhalla `traffic_tile_dir` to consume TomTom speed data so route calculations account for live congestion +- **Effect on offroute:** segments 2–4 (trail-to-road, road-to-road, road-to-home) would route around congested corridors +- Does NOT affect segment 1 (wilderness pathfinder) + +### Idaho 511 Incident Feed + +- Idaho 511 API provides real-time construction zones, accidents, and road closures +- Two integration points: + 1. **Visual layer** — display incidents on Navi map as icons/overlays + 2. **Routing barriers** — feed active closures to Valhalla as `avoid_locations` or edge exclusions so routes avoid closed roads +- **Implementation:** polling daemon (5–10 min interval), stores active incidents in `navi.db`, expires automatically when cleared +- Affects both standalone Valhalla routing and offroute segments 2–4 +- **Stretch goal:** ingest other state 511 feeds for cross-state trips + +### Sequencing + +- Both features are post-offroute-core (after Phase O3) +- Can be built in parallel — traffic routing is Valhalla config, 511 is a new ingestion daemon + map layer +- Neither blocks wilderness pathfinder development diff --git a/docs/navi-feature-ideas.md b/docs/navi-feature-ideas.md new file mode 100644 index 0000000..13ab389 --- /dev/null +++ b/docs/navi-feature-ideas.md @@ -0,0 +1,92 @@ +# Navi Feature Ideas + +Planned features and enhancements for the Navi navigation platform. + +--- + +## Traffic & Incident Intelligence + +### Traffic-Aware Routing + +**Status:** Planned (post-Phase O3) + +Integrate TomTom traffic data into Valhalla routing calculations: + +- TomTom traffic tiles already available at `/api/traffic/*` (visual overlay) +- Configure Valhalla `traffic_tile_dir` to consume speed data +- Routes will account for live congestion on segments 2–4 of offroute chain +- Does not affect wilderness pathfinder (segment 1) + +### Idaho 511 Incident Feed + +**Status:** Planned (post-Phase O3) + +Real-time road closure and incident integration: + +- Poll Idaho 511 API every 5–10 minutes +- Store active incidents in `navi.db` with auto-expiration +- Display incidents as map overlay (icons/markers) +- Feed closures to Valhalla as `avoid_locations` for routing +- Stretch: support other state 511 feeds for cross-state trips + +--- + +## Tracking & Situational Awareness + +### ADS-B Aircraft Tracking + +**Status:** Planned + +Display live aircraft positions from ADS-B receivers: + +- Integrate with local ADS-B receiver (dump1090/readsb) +- Show aircraft positions, altitude, callsign on map +- Useful for backcountry SAR coordination and general aviation awareness + +### AIS Vessel Tracking + +**Status:** Planned + +Display marine vessel positions: + +- Integrate with AIS receiver or feed +- Show vessel positions, heading, name on map +- Applicable for coastal/maritime navigation scenarios + +--- + +## TAK Integration + +### TAK Server + EUD Integration + +**Status:** Planned + +Connect Navi to the TAK ecosystem (ATAK, iTAK, WinTAK): + +- TAK Server integration for shared situational awareness +- Push Navi routes to TAK clients as CoT (Cursor on Target) +- Pull team member positions from TAK into Navi +- Enable SAR/field team coordination through unified COP + +--- + +## Mobile & Offline + +### Native iOS App + +**Status:** Planned + +Native iOS application for offline-first navigation: + +- Full offline map tile access +- Offline routing with pre-cached Valhalla tiles +- Integration with Apple Watch for turn-by-turn +- Meshtastic/LoRa mesh network support for off-grid comms + +--- + +## Notes + +- Features above Phase O3 depend on core offroute functionality being complete +- Traffic and 511 features can be built in parallel +- TAK integration useful for field coordination but not blocking core nav From d6aa125215eabf82e4e205bff4171ab315f13c85 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 15:05:52 +0000 Subject: [PATCH 24/34] feat: unified routing UI with wilderness + network segments - Single routing system (removed duplicate Valhalla-only flow) - Unified radial menu: From here, To here, Clear, Save, Measure - Removed "Offroute" section from panel (single directions display) - Better error messages without technical "Offroute" prefix - ManeuverList shows wilderness + network breakdown - PlaceCard integration for previews Co-Authored-By: Claude Opus 4.5 --- src/App.jsx | 79 +---- src/api.js | 66 ++++ src/components/ManeuverList.jsx | 304 ++++++++-------- src/components/MapView.jsx | 444 ++++++++++------------- src/components/Panel.jsx | 605 ++++++++++++++++---------------- src/store.js | 318 +++++++++-------- 6 files changed, 878 insertions(+), 938 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 0d02c8f..3bdea6e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useCallback } from 'react' import { useStore } from './store' import { useTheme } from './hooks/useTheme' -import { requestRoute, fetchAuthState } from './api' -import { decodePolyline } from './utils/decode' +import { fetchAuthState } from './api' import MapView from './components/MapView' import Panel from './components/Panel' @@ -12,20 +11,10 @@ import LocateButton from './components/LocateButton' export default function App() { const mapViewRef = useRef(null) - const routeDebounceRef = useRef(null) // Initialize theme system useTheme() - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - const setRoute = useStore((s) => s.setRoute) - const setRouteLoading = useStore((s) => s.setRouteLoading) - const setRouteError = useStore((s) => s.setRouteError) - const clearRoute = useStore((s) => s.clearRoute) const setAuth = useStore((s) => s.setAuth) // Initialize auth state on app load (single fetch, no polling) @@ -33,70 +22,18 @@ export default function App() { fetchAuthState().then(setAuth) }, [setAuth]) - // Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) - useEffect(() => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - - routeDebounceRef.current = setTimeout(async () => { - const { userLocation } = useStore.getState() - - let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (gpsOrigin && geoPermission === 'granted' && userLocation) { - effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective] - } - - if (effective.length < 2) { - clearRoute() - return - } - - setRouteLoading(true) - - try { - const data = await requestRoute(effective, mode) - if (data.trip) { - setRoute(data.trip) - } else { - setRouteError('No route returned') - } - } catch (e) { - setRouteError(e.message || 'Route request failed') - } finally { - setRouteLoading(false) - } - }, 500) - - return () => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - } - }, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError]) - - // Handle maneuver click - const handleManeuverClick = useCallback( - (maneuver) => { - if (!route || !route.legs) return - - const legIdx = maneuver._legIndex || 0 - const leg = route.legs[legIdx] - if (!leg || !leg.shape) return - - const coords = decodePolyline(leg.shape, 6) - const idx = maneuver.begin_shape_index - if (idx >= 0 && idx < coords.length) { - const [lng, lat] = coords[idx] - mapViewRef.current?.flyTo(lat, lng, 15) - } - }, - [route] - ) + // Handle clear route from panel + const handleClearRoute = useCallback(() => { + mapViewRef.current?.clearRoute?.() + }, []) return (
- - + + - + {/* Bottom-right map controls */}
diff --git a/src/api.js b/src/api.js index fe8fd02..47d5861 100644 --- a/src/api.js +++ b/src/api.js @@ -321,3 +321,69 @@ export async function fetchAuthState() { return { authenticated: false, username: null } } } + +// ── Offroute API ── + +const OFFROUTE_URL = "/api/offroute" +const MVUM_URL = "/api/mvum" + +/** + * Request an offroute route from the pathfinder API. + * @param {object} start - { lat, lon } + * @param {object} end - { lat, lon } + * @param {string} mode - foot | mtb | atv | vehicle + * @param {string} boundaryMode - strict | pragmatic | emergency + * @returns {Promise} Offroute response with GeoJSON route + */ +export async function requestOffroute(start, end, mode = "foot", boundaryMode = "strict") { + const body = { + start: [start.lat, start.lon], + end: [end.lat, end.lon], + mode, + boundary_mode: boundaryMode, + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 120000) // 2 min timeout for complex routes + + try { + const resp = await fetch(OFFROUTE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!resp.ok) { + const errBody = await resp.json().catch(() => ({})) + throw new Error(errBody.message || 'Could not find a route. Try a different start point or mode.') + } + + return resp.json() + } finally { + clearTimeout(timeout) + } +} + +/** + * Fetch MVUM (Motor Vehicle Use Map) info for a location. + * @param {number} lat + * @param {number} lon + * @param {number} radius - Search radius in meters + * @returns {Promise} MVUM feature info or null + */ +export async function fetchMvumInfo(lat, lon, radius = 500) { + try { + const params = new URLSearchParams({ + lat: String(lat), + lon: String(lon), + radius: String(radius), + }) + const resp = await fetch(`${MVUM_URL}?${params}`, { signal: AbortSignal.timeout(5000) }) + if (!resp.ok) return null + const data = await resp.json() + return data.feature || null + } catch { + return null + } +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index d869b66..a8b90b0 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,140 +1,164 @@ -import { - MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, - MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation -} from 'lucide-react' -import { useStore } from '../store' - -function formatTime(seconds) { - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)} min` - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return m > 0 ? `${h}h ${m}m` : `${h}h` -} - -function formatDist(miles) { - if (miles < 0.1) return `${Math.round(miles * 5280)} ft` - return `${miles.toFixed(1)} mi` -} - -function ManeuverIcon({ type }) { - const size = 16 - const props = { size, strokeWidth: 1.5 } - switch (type) { - case 0: return - case 1: return - case 2: return - case 3: return - case 4: case 5: return - case 6: return - case 7: return - case 8: return - case 9: return - case 10: case 11: case 12: return - case 15: case 16: return - case 24: return - case 25: return - case 26: return - default: return - } -} - -export default function ManeuverList({ onManeuverClick }) { - const route = useStore((s) => s.route) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - - if (routeLoading) { - return ( -
-
- - Calculating route... - -
- ) - } - - if (routeError) { - return ( -
- {routeError} -
- ) - } - - if (!route || !route.legs) return null - - const totalTime = route.summary?.time || 0 - const totalDist = route.summary?.length || 0 - - const allManeuvers = [] - let timeRemaining = totalTime - - for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { - const leg = route.legs[legIdx] - for (const man of leg.maneuvers || []) { - allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining }) - timeRemaining -= man.time || 0 - } - } - - return ( -
- {/* Route summary */} -
- - {formatDist(totalDist)} - - - {formatTime(totalTime)} - -
- - {/* Maneuver steps */} -
- {allManeuvers.map((man, i) => ( - - ))} -
-
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle +} from 'lucide-react' +import { useStore } from '../store' + +function formatDistKm(km) { + const miles = km * 0.621371 + if (miles < 0.1) return Math.round(miles * 5280) + ' ft' + return miles.toFixed(1) + ' mi' +} + +function formatTimeMin(minutes) { + if (minutes < 60) return Math.round(minutes) + ' min' + const h = Math.floor(minutes / 60) + const m = Math.round(minutes % 60) + return m > 0 ? h + 'h ' + m + 'm' : h + 'h' +} + +function ManeuverIcon({ type }) { + const size = 16 + const props = { size, strokeWidth: 1.5 } + switch (type) { + case 0: return + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return + } +} + +export default function ManeuverList() { + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!routeResult?.summary) return null + + const summary = routeResult.summary + const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') + const maneuvers = networkFeature?.properties?.maneuvers || [] + + return ( +
+ {/* Total summary */} +
+ + {formatDistKm(summary.total_distance_km)} + + + {formatTimeMin(summary.total_effort_minutes)} + +
+ + {/* Segment breakdown */} +
+ {summary.wilderness_distance_km > 0 && ( +
+ + Wilderness + + {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + +
+ )} + {summary.network_distance_km > 0 && ( +
+ + Road/Trail + + {formatDistKm(summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + +
+ )} +
+ + {/* Warnings */} + {(summary.barrier_crossings > 0 || summary.mvum_closed_crossings > 0) && ( +
+ {summary.barrier_crossings > 0 && ( +
+ + {summary.barrier_crossings} barrier crossing{summary.barrier_crossings > 1 ? 's' : ''} +
+ )} + {summary.mvum_closed_crossings > 0 && ( +
+ + {summary.mvum_closed_crossings} MVUM closure{summary.mvum_closed_crossings > 1 ? 's' : ''} +
+ )} +
+ )} + + {/* Turn-by-turn directions */} + {maneuvers.length > 0 && ( +
+
Directions
+ {maneuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+

+ {formatDistKm(man.distance_km)} +

+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0ede093..1dfec38 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -6,9 +6,9 @@ import { layers, namedTheme } from 'protomaps-themes-base' import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse } from '../api' +import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2 } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -27,6 +27,10 @@ const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' +const OFFROUTE_SOURCE = 'offroute-source' +const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness' +const OFFROUTE_NETWORK_LAYER = 'offroute-network' +const OFFROUTE_MARKERS_LAYER = 'offroute-markers' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' @@ -1122,6 +1126,7 @@ function isProtectedLayer(id) { return id.startsWith('public-lands') || id.startsWith('boundary') || id.startsWith('route') || + id.startsWith('offroute') || id.startsWith('measure') || id.startsWith('contour') || id.startsWith('usfs') || @@ -1327,6 +1332,83 @@ function removeStateBoundaries(map) { } } + +/** Clear offroute display layers */ +function clearRouteDisplay(map) { + if (!map) return + if (map.getLayer(OFFROUTE_WILDERNESS_LAYER)) map.removeLayer(OFFROUTE_WILDERNESS_LAYER) + if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER) + if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER) + if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE) +} + +/** Update offroute display with route GeoJSON */ +function updateRouteDisplay(map, routeGeojson) { + if (!map || !routeGeojson) return + + // Clear existing layers + clearRouteDisplay(map) + + // Add source with route features + map.addSource(OFFROUTE_SOURCE, { + type: "geojson", + data: routeGeojson, + }) + + // Find first symbol layer for proper z-ordering + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Wilderness segment - dashed orange line + map.addLayer({ + id: OFFROUTE_WILDERNESS_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "wilderness"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#f97316", // orange-500 + "line-width": 4, + "line-opacity": 0.9, + "line-dasharray": [8, 4], + }, + }, beforeId) + + // Network segment - solid blue line + map.addLayer({ + id: OFFROUTE_NETWORK_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "network"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#3b82f6", // blue-500 + "line-width": 5, + "line-opacity": 0.85, + }, + }, beforeId) + + // Fit bounds to route + const features = routeGeojson.features || [] + const allCoords = features + .filter(f => f.geometry?.coordinates) + .flatMap(f => f.geometry.coordinates) + + if (allCoords.length > 0) { + const bounds = allCoords.reduce( + (b, c) => b.extend(c), + new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) + ) + const leftPad = 420 + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) + } +} + const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -1348,14 +1430,11 @@ const MapView = forwardRef(function MapView(_, ref) { const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements - const stops = useStore((s) => s.stops) - const route = useStore((s) => s.route) const theme = useStore((s) => s.theme) const selectedPlace = useStore((s) => s.selectedPlace) const clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) - const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) @@ -1578,96 +1657,95 @@ const MapView = forwardRef(function MapView(_, ref) { updateMeasureLabels(newPoints) } - const radialWedges = [ - { - id: "directions-to", - label: "To here", - icon: ArrowDownLeft, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - useStore.getState().startDirections(place) - }, - }, - { - id: "directions-from", - label: "From here", - icon: ArrowUpRight, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { clearStops, addStop } = useStore.getState() - clearStops() - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - addStop(place) - useStore.setState({ gpsOrigin: false }) - }, - }, - { - id: "add-stop", - label: "Add stop", - icon: Plus, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop, clearStops } = useStore.getState() - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") - } - } - }, - }, - { - id: "save-place", - label: "Save", - icon: Star, - requiresAuth: true, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { auth, setEditingContact } = useStore.getState() - if (auth.authenticated) { - setEditingContact({ - label: "", - lat: radialMenu.lat, - lon: radialMenu.lon, - }) - } else { - toast("Log in to save places") - } - }, - }, - { - id: "measure", - label: "Measure", - icon: Ruler, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - startMeasuring(radialMenu.lat, radialMenu.lon) - }, - }, - ] + const radialWedges = [ + { + id: "to-here", + label: "To here", + icon: ArrowDownLeft, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { routeStart, setRouteEnd, setRouteLoading, setRouteResult, setRouteError, routeMode, boundaryMode } = useStore.getState() + setRouteEnd(place) + if (routeStart) { + setRouteLoading(true) + requestOffroute(routeStart, place, routeMode, boundaryMode) + .then((data) => { + if (data.status === "ok" && data.route) { + setRouteResult(data) + updateRouteDisplay(mapInstance.current, data.route) + } else { + setRouteError(data.error || "No route found") + } + }) + .catch((e) => setRouteError(e.message)) + .finally(() => setRouteLoading(false)) + } else { + toast("Set starting point first") + } + }, + }, + { + id: "from-here", + label: "From here", + icon: ArrowUpRight, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { clearRoute, setRouteStart } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + toast("Now tap destination") + }, + }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, + { + id: "save-place", + label: "Save", + icon: Star, + requiresAuth: true, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { auth, setEditingContact } = useStore.getState() + if (auth.authenticated) { + setEditingContact({ + label: "", + lat: radialMenu.lat, + lon: radialMenu.lon, + }) + } else { + toast("Log in to save places") + } + }, + }, + { + id: "measure", + label: "Measure", + icon: Ruler, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + startMeasuring(radialMenu.lat, radialMenu.lon) + }, + }, + ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current @@ -1805,6 +1883,14 @@ const MapView = forwardRef(function MapView(_, ref) { updateSatellitePaint(map, currentThemeRef.current) }, + // Clear offroute route from map + clearRoute() { + const map = mapInstance.current + if (!map) return + clearRouteDisplay(map) + useStore.getState().clearRoute() + }, + })) // Initialize map @@ -2464,10 +2550,8 @@ const MapView = forwardRef(function MapView(_, ref) { originalPaintValues = {} // Restore view - map.jumpTo({ center, zoom, bearing, pitch }) - // Re-render route if exists - const currentRoute = useStore.getState().route - if (currentRoute) updateRoute(map, currentRoute) + const currentRoute = useStore.getState().routeResult + if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) }) }, [theme]) @@ -2560,168 +2644,6 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [selectedPlace]) - // Update route polyline when route changes - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (!map.isStyleLoaded()) { - const handler = () => updateRoute(map, route) - map.once('idle', handler) - return () => map.off('idle', handler) - } - updateRoute(map, route) - }, [route]) - - function updateRoute(map, routeData) { - if (!map) return - - // Remove old route layers - const style = map.getStyle() - if (style) { - for (const layer of style.layers) { - if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) { - map.removeLayer(layer.id) - } - } - } - - if (!routeData || !routeData.legs) { - if (map.getSource(ROUTE_SOURCE)) { - map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) - } - return - } - - const features = [] - for (let i = 0; i < routeData.legs.length; i++) { - const leg = routeData.legs[i] - if (!leg.shape) continue - const coords = decodePolyline(leg.shape, 6) - features.push({ - type: 'Feature', - properties: { legIndex: i }, - geometry: { type: 'LineString', coordinates: coords }, - }) - } - - const source = map.getSource(ROUTE_SOURCE) - if (source) { - source.setData({ type: 'FeatureCollection', features }) - } else { - map.addSource(ROUTE_SOURCE, { - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }) - } - - // Use CSS variable for route color (read computed value) - const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() - - for (let i = 0; i < features.length; i++) { - const layerId = `${ROUTE_LAYER_PREFIX}${i}` - if (!map.getLayer(layerId)) { - map.addLayer({ - id: layerId, - type: 'line', - source: ROUTE_SOURCE, - filter: ['==', ['get', 'legIndex'], i], - layout: { 'line-join': 'round', 'line-cap': 'round' }, - paint: { - 'line-color': routeColor || '#7a9a6b', - 'line-width': 5, - 'line-opacity': 0.85, - }, - }) - } - } - - // Fit bounds to route - if (features.length > 0) { - const allCoords = features.flatMap((f) => f.geometry.coordinates) - const bounds = allCoords.reduce( - (b, c) => b.extend(c), - new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) - ) - // Single-panel: no floating detail - const leftPad = 420 // 360px panel + margin - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) - } - } - - // Update stop markers when stops change - useEffect(() => { - const map = mapInstance.current - if (!map) return - - // Remove old markers - for (const m of markersRef.current) m.remove() - markersRef.current = [] - if (popupRef.current) { - popupRef.current.remove() - popupRef.current = null - } - - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const indexOffset = hasGpsOrigin ? 1 : 0 - - stops.forEach((stop, i) => { - const displayIndex = i + indexOffset - const effectiveTotal = stops.length + indexOffset - - let pinClass = 'navi-pin navi-pin--intermediate' - if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' - else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' - - const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) - - const el = document.createElement('div') - el.className = pinClass - el.textContent = label - - el.addEventListener('click', (e) => { - e.stopPropagation() - // Flag so the map-level click handler doesn't fire - pinClickedRef.current = true - if (popupRef.current) popupRef.current.remove() - const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) - .setLngLat([stop.lon, stop.lat]) - .setHTML( - `
- ${stop.name} -
-
` - ) - .addTo(map) - - popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => { - useStore.getState().removeStop(stop.id) - popup.remove() - }) - popupRef.current = popup - }) - - const marker = new maplibregl.Marker({ element: el }) - .setLngLat([stop.lon, stop.lat]) - .addTo(map) - - markersRef.current.push(marker) - }) - - // If stops but no route yet, fit to stops - if (stops.length > 0 && !route) { - if (stops.length === 1) { - map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 }) - } else { - const bounds = stops.reduce( - (b, s) => b.extend([s.lon, s.lat]), - new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) - ) - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } }) - } - } - }, [stops, route, gpsOrigin, geoPermission]) - - // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 2799a89..ddae59c 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,311 +1,294 @@ -import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut } from 'lucide-react' -import ThemePicker from './ThemePicker' -import { useStore, usePanelState } from '../store' -import { hasFeature } from '../config' -import SearchBar from './SearchBar' -import StopList from './StopList' -import ModeSelector from './ModeSelector' -import ManeuverList from './ManeuverList' -import ContactList from './ContactList' -import { PlaceCard } from './PlaceCard' -import { requestOptimizedRoute } from '../api' - -export default function Panel({ onManeuverClick }) { - const selectedPlace = useStore((s) => s.selectedPlace) - const pendingDestination = useStore((s) => s.pendingDestination) - const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const clearPendingDestination = useStore((s) => s.clearPendingDestination) - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const setStops = useStore((s) => s.setStops) - const setRoute = useStore((s) => s.setRoute) - const setRouteError = useStore((s) => s.setRouteError) - const setRouteLoading = useStore((s) => s.setRouteLoading) - const sheetState = useStore((s) => s.sheetState) - const setSheetState = useStore((s) => s.setSheetState) - const theme = useStore((s) => s.theme) - const themeOverride = useStore((s) => s.themeOverride) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - const activeTab = useStore((s) => s.activeTab) - const auth = useStore((s) => s.auth) - const setActiveTab = useStore((s) => s.setActiveTab) - - const panelState = usePanelState() - - const [isMobile, setIsMobile] = useState(false) - const [optimizing, setOptimizing] = useState(false) - const sheetRef = useRef(null) - const dragStartY = useRef(0) - const dragStartState = useRef('half') - - // Show contacts tab only if feature enabled AND user is authenticated - const showContacts = hasFeature('has_contacts') && auth.authenticated - - // Responsive detection - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - // Auth handlers - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - - // Optimize stops - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0) - - const handleOptimize = useCallback(async () => { - if (effectiveCount < 3 || optimizing) return - setOptimizing(true) - try { - const { userLocation } = useStore.getState() - let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (hasGpsOrigin && userLocation) { - locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations] - } - const data = await requestOptimizedRoute(locations, mode) - if (data.trip) { - const wpOrder = hasGpsOrigin && userLocation - ? (data.trip.locations || []).slice(1) - : data.trip.locations - if (wpOrder && wpOrder.length === stops.length) { - const reordered = wpOrder.map((wp) => { - let closest = stops[0] - let minDist = Infinity - for (const s of stops) { - const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon) - if (d < minDist) { - minDist = d - closest = s - } - } - return closest - }) - const seen = new Set() - const unique = reordered.filter((s) => { - if (seen.has(s.id)) return false - seen.add(s.id) - return true - }) - if (unique.length === stops.length) { - setStops(unique) - } - } - setRoute(data.trip) - } - } catch (e) { - setRouteError(e.message) - } finally { - setOptimizing(false) - } - }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError]) - - // Mobile sheet drag handling - const handleTouchStart = useCallback((e) => { - dragStartY.current = e.touches[0].clientY - dragStartState.current = sheetState - }, [sheetState]) - - const handleTouchEnd = useCallback((e) => { - const deltaY = e.changedTouches[0].clientY - dragStartY.current - if (Math.abs(deltaY) < 30) return - if (deltaY < 0) { - if (dragStartState.current === 'collapsed') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('full') - } else { - if (dragStartState.current === 'full') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('collapsed') - } - }, [setSheetState]) - - const showOptimize = effectiveCount >= 3 - - // Determine what to show based on panel state - const showPreviewCard = panelState.startsWith('PREVIEW') - const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination - const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' - const showEmptyState = panelState === 'IDLE' && !pendingDestination - - // Routes tab content - now state-driven - const routesContent = ( - <> - - - {/* Preview card when place is selected */} - {showPreviewCard && selectedPlace && ( -
- -
- )} - - {/* Route section with stops */} - {showRouteSection && ( - <> -
- -
- -
- - {showOptimize && ( - - )} - {pendingDestination && stops.length === 0 && ( - - )} -
- - )} - - {/* Maneuvers when route is calculated */} - {showManeuvers && (route || routeLoading || routeError) && ( -
- -
- )} - - {/* Empty state */} - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

-
- {auth.loaded && ( - auth.authenticated ? ( - - ) : ( - - ) - )} - -
-
- ) - - // Desktop: side panel (now 360px to accommodate PlaceCard) - if (!isMobile) { - return ( -
- {header} - {content} -
- ) - } - - // Mobile: bottom sheet - const sheetHeights = { - collapsed: 'h-12', - half: 'h-[45vh]', - full: 'h-[85vh]', - } - - return ( -
- {/* Drag handle */} -
{ - if (sheetState === 'collapsed') setSheetState('half') - else if (sheetState === 'half') setSheetState('full') - else setSheetState('half') - }} - > -
-
- - {sheetState !== 'collapsed' && ( -
- {header} - {content} -
- )} -
- ) -} +import { useRef, useCallback, useEffect, useState } from 'react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import ThemePicker from './ThemePicker' +import { useStore, usePanelState } from '../store' +import { hasFeature } from '../config' +import SearchBar from './SearchBar' +import ManeuverList from './ManeuverList' +import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' + +const TRAVEL_MODES = [ + { id: 'foot', label: 'Foot', Icon: Footprints }, + { id: 'mtb', label: 'MTB', Icon: Bike }, + { id: 'atv', label: 'ATV', Icon: Car }, + { id: 'vehicle', label: '4x4', Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, + { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, + { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, +] + +export default function Panel({ onClearRoute }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const clearRoute = useStore((s) => s.clearRoute) + const sheetState = useStore((s) => s.sheetState) + const setSheetState = useStore((s) => s.setSheetState) + const activeTab = useStore((s) => s.activeTab) + const auth = useStore((s) => s.auth) + const setActiveTab = useStore((s) => s.setActiveTab) + + const panelState = usePanelState() + + const [isMobile, setIsMobile] = useState(false) + const sheetRef = useRef(null) + const dragStartY = useRef(0) + const dragStartState = useRef('half') + + const showContacts = hasFeature('has_contacts') && auth.authenticated + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + + const handleTouchStart = useCallback((e) => { + dragStartY.current = e.touches[0].clientY + dragStartState.current = sheetState + }, [sheetState]) + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - dragStartY.current + if (Math.abs(deltaY) < 30) return + if (deltaY < 0) { + if (dragStartState.current === 'collapsed') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('full') + } else { + if (dragStartState.current === 'full') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('collapsed') + } + }, [setSheetState]) + + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } + + const showPreviewCard = panelState.startsWith('PREVIEW') + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + + const routesContent = ( + <> + + + {showPreviewCard && selectedPlace && ( +
+ +
+ )} + + {showRouteSection && ( +
+
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Right-click to set start'} + +
+
+ + + {routeEnd?.name || 'Right-click to set destination'} + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ + +
+ )} + + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + if (!isMobile) { + return ( +
+ {header} + {content} +
+ ) + } + + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {header} + {content} +
+ )} +
+ ) +} diff --git a/src/store.js b/src/store.js index 6b7f30d..2cf78ee 100644 --- a/src/store.js +++ b/src/store.js @@ -1,155 +1,163 @@ -import { create } from 'zustand' - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: '', - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Stop list ── - stops: [], - // Each stop: { id, lat, lon, name, source, matchCode, isOrigin } - - addStop: (stop) => { - const { stops } = get() - if (stops.length >= 10) return false - set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] }) - return true - }, - - removeStop: (id) => { - set({ stops: get().stops.filter((s) => s.id !== id) }) - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => set({ stops: [] }), - - setStops: (stops) => set({ stops }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Mode ── - mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle' - setMode: (mode) => set({ mode }), - - // ── Route ── - route: null, // Valhalla response (trip object) - routeLoading: false, - routeError: null, - - setRoute: (route) => set({ route, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, route: null }), - clearRoute: () => set({ route: null, routeError: null }), - - // ── Place detail ── - 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) - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) - } - }, - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.route - const hasStops = s.stops.length >= 1 - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasStops) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasStops) return "ROUTING" - return "IDLE" - }) -} +import { create } from 'zustand' + +export const useStore = create((set, get) => ({ + // ── Search state ── + query: '', + results: [], + searchLoading: false, + abortController: null, + + setQuery: (query) => set({ query }), + setResults: (results) => set({ results }), + setSearchLoading: (loading) => set({ searchLoading: loading }), + setAbortController: (ctrl) => set({ abortController: ctrl }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // Single routing system - all routes go through /api/offroute + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + routeMode: "foot", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteMode: (mode) => set({ routeMode: mode }), + setBoundaryMode: (mode) => set({ boundaryMode: mode }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + clearRoute: () => set({ + routeStart: null, + routeEnd: null, + routeResult: null, + routeError: null + }), + + // ── Legacy compatibility (for components not yet migrated) ── + stops: [], + gpsOrigin: false, + pendingDestination: null, + route: null, + + addStop: (stop) => { + // Legacy: just set as route end point + const { routeStart, setRouteEnd } = get() + const place = { lat: stop.lat, lon: stop.lon, name: stop.name } + if (!routeStart) { + set({ routeStart: place, stops: [{ ...stop, id: crypto.randomUUID() }] }) + } else { + setRouteEnd(place) + set({ stops: [...get().stops, { ...stop, id: crypto.randomUUID() }] }) + } + return true + }, + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + if (newStops.length === 0) { + get().clearRoute() + } + }, + clearStops: () => set({ stops: [], routeStart: null, routeEnd: null }), + setStops: (stops) => set({ stops }), + reorderStops: (newStops) => set({ stops: newStops }), + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + startDirections: (place) => { + // Legacy: set as destination + const { routeStart, setRouteEnd, clearRoute } = get() + clearRoute() + set({ + routeEnd: { lat: place.lat, lon: place.lon, name: place.name }, + stops: [{ ...place, id: crypto.randomUUID() }], + selectedPlace: null + }) + }, + + // ── Place detail ── + selectedPlace: null, + clickMarker: null, + + setSelectedPlace: (place) => set({ selectedPlace: place }), + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: 'half', + panelOpen: true, + autocompleteOpen: false, + theme: 'dark', + themeOverride: null, + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem('navi-theme-override', override) + } else { + localStorage.removeItem('navi-theme-override') + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: 'routes', + editingContact: null, + pickingLocationFor: null, + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} From 09d68adf095bd1028d539e1177d58676546423d8 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 21:59:10 +0000 Subject: [PATCH 25/34] feat: unified routing with Drive mode default and Add stop wedge - Add Drive (auto) as default route mode, first in travel modes list - Hide boundary mode selector when Drive mode is active - Restore Add stop radial menu wedge with stops system integration - Unify routing through single computeRoute() function in store - Add coordinate parsing to SearchBar for direct lat/lon input - Bridge stops system with routeStart/routeEnd for seamless UX - Support 3+ stops with Valhalla optimization Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 205 ++++++------ src/components/Panel.jsx | 591 ++++++++++++++++++----------------- src/components/SearchBar.jsx | 43 +++ src/store.js | 434 +++++++++++++++---------- 4 files changed, 726 insertions(+), 547 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 1dfec38..ca9b5c5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -8,7 +8,7 @@ import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2 } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -1657,95 +1657,114 @@ const MapView = forwardRef(function MapView(_, ref) { updateMeasureLabels(newPoints) } - const radialWedges = [ - { - id: "to-here", - label: "To here", - icon: ArrowDownLeft, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - } - const { routeStart, setRouteEnd, setRouteLoading, setRouteResult, setRouteError, routeMode, boundaryMode } = useStore.getState() - setRouteEnd(place) - if (routeStart) { - setRouteLoading(true) - requestOffroute(routeStart, place, routeMode, boundaryMode) - .then((data) => { - if (data.status === "ok" && data.route) { - setRouteResult(data) - updateRouteDisplay(mapInstance.current, data.route) - } else { - setRouteError(data.error || "No route found") - } - }) - .catch((e) => setRouteError(e.message)) - .finally(() => setRouteLoading(false)) - } else { - toast("Set starting point first") - } - }, - }, - { - id: "from-here", - label: "From here", - icon: ArrowUpRight, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - } - const { clearRoute, setRouteStart } = useStore.getState() - clearRoute() - clearRouteDisplay(mapInstance.current) - setRouteStart(place) - toast("Now tap destination") - }, - }, - { - id: "clear-route", - label: "Clear", - icon: Trash2, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - useStore.getState().clearRoute() - clearRouteDisplay(mapInstance.current) - }, - }, - { - id: "save-place", - label: "Save", - icon: Star, - requiresAuth: true, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { auth, setEditingContact } = useStore.getState() - if (auth.authenticated) { - setEditingContact({ - label: "", - lat: radialMenu.lat, - lon: radialMenu.lon, - }) - } else { - toast("Log in to save places") - } - }, - }, - { - id: "measure", - label: "Measure", - icon: Ruler, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - startMeasuring(radialMenu.lat, radialMenu.lon) - }, - }, - ] + const radialWedges = [ + { + id: "to-here", + label: "To here", + icon: ArrowDownLeft, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { routeStart, setRouteEnd, computeRoute } = useStore.getState() + setRouteEnd(place) + if (routeStart) { + computeRoute() + } else { + toast("Set starting point first") + } + }, + }, + { + id: "from-here", + label: "From here", + icon: ArrowUpRight, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { clearRoute, setRouteStart, routeEnd, computeRoute } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + // If we already have a destination, compute route immediately + if (routeEnd) { + computeRoute() + } else { + toast("Now tap destination") + } + }, + }, + { + id: "add-stop", + label: "Add stop", + icon: Plus, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { stops, addStop } = useStore.getState() + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, + } + if (stops.length === 0) { + addStop(place) + useStore.setState({ gpsOrigin: false }) + } else { + const success = addStop(place) + if (!success) { + toast("Maximum 10 stops reached") + } + } + }, + }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, + { + id: "save-place", + label: "Save", + icon: Star, + requiresAuth: true, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { auth, setEditingContact } = useStore.getState() + if (auth.authenticated) { + setEditingContact({ + label: "", + lat: radialMenu.lat, + lon: radialMenu.lon, + }) + } else { + toast("Log in to save places") + } + }, + }, + { + id: "measure", + label: "Measure", + icon: Ruler, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + startMeasuring(radialMenu.lat, radialMenu.lon) + }, + }, + ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current @@ -2390,6 +2409,12 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) + // Register route display callbacks for store.computeRoute() + useStore.getState().setRouteDisplayCallbacks( + (routeGeojson) => updateRouteDisplay(map, routeGeojson), + () => clearRouteDisplay(map) + ) + // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index ddae59c..efc9b5a 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,294 +1,297 @@ -import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' -import ThemePicker from './ThemePicker' -import { useStore, usePanelState } from '../store' -import { hasFeature } from '../config' -import SearchBar from './SearchBar' -import ManeuverList from './ManeuverList' -import ContactList from './ContactList' -import { PlaceCard } from './PlaceCard' - -const TRAVEL_MODES = [ - { id: 'foot', label: 'Foot', Icon: Footprints }, - { id: 'mtb', label: 'MTB', Icon: Bike }, - { id: 'atv', label: 'ATV', Icon: Car }, - { id: 'vehicle', label: '4x4', Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, - { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, - { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, -] - -export default function Panel({ onClearRoute }) { - const selectedPlace = useStore((s) => s.selectedPlace) - const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const clearRoute = useStore((s) => s.clearRoute) - const sheetState = useStore((s) => s.sheetState) - const setSheetState = useStore((s) => s.setSheetState) - const activeTab = useStore((s) => s.activeTab) - const auth = useStore((s) => s.auth) - const setActiveTab = useStore((s) => s.setActiveTab) - - const panelState = usePanelState() - - const [isMobile, setIsMobile] = useState(false) - const sheetRef = useRef(null) - const dragStartY = useRef(0) - const dragStartState = useRef('half') - - const showContacts = hasFeature('has_contacts') && auth.authenticated - - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - - const handleTouchStart = useCallback((e) => { - dragStartY.current = e.touches[0].clientY - dragStartState.current = sheetState - }, [sheetState]) - - const handleTouchEnd = useCallback((e) => { - const deltaY = e.changedTouches[0].clientY - dragStartY.current - if (Math.abs(deltaY) < 30) return - if (deltaY < 0) { - if (dragStartState.current === 'collapsed') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('full') - } else { - if (dragStartState.current === 'full') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('collapsed') - } - }, [setSheetState]) - - const handleClearRoute = () => { - clearRoute() - onClearRoute?.() - } - - const showPreviewCard = panelState.startsWith('PREVIEW') - const hasRoutePoints = routeStart || routeEnd - const showRouteSection = hasRoutePoints || routeResult || routeLoading - const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - - const routesContent = ( - <> - - - {showPreviewCard && selectedPlace && ( -
- -
- )} - - {showRouteSection && ( -
-
- - Route - - -
- -
-
- - - {routeStart?.name || 'Right-click to set start'} - -
-
- - - {routeEnd?.name || 'Right-click to set destination'} - -
-
- -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- - -
- )} - - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

-
- {auth.loaded && ( - auth.authenticated ? ( - - ) : ( - - ) - )} - -
-
- ) - - if (!isMobile) { - return ( -
- {header} - {content} -
- ) - } - - const sheetHeights = { - collapsed: 'h-12', - half: 'h-[45vh]', - full: 'h-[85vh]', - } - - return ( -
-
{ - if (sheetState === 'collapsed') setSheetState('half') - else if (sheetState === 'half') setSheetState('full') - else setSheetState('half') - }} - > -
-
- - {sheetState !== 'collapsed' && ( -
- {header} - {content} -
- )} -
- ) -} +import { useRef, useCallback, useEffect, useState } from 'react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import ThemePicker from './ThemePicker' +import { useStore, usePanelState } from '../store' +import { hasFeature } from '../config' +import SearchBar from './SearchBar' +import ManeuverList from './ManeuverList' +import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' + +const TRAVEL_MODES = [ + { id: 'auto', label: 'Drive', Icon: Car }, + { id: 'foot', label: 'Foot', Icon: Footprints }, + { id: 'mtb', label: 'MTB', Icon: Bike }, + { id: 'atv', label: 'ATV', Icon: Car }, + { id: 'vehicle', label: '4x4', Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, + { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, + { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, +] + +export default function Panel({ onClearRoute }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const clearRoute = useStore((s) => s.clearRoute) + const sheetState = useStore((s) => s.sheetState) + const setSheetState = useStore((s) => s.setSheetState) + const activeTab = useStore((s) => s.activeTab) + const auth = useStore((s) => s.auth) + const setActiveTab = useStore((s) => s.setActiveTab) + + const panelState = usePanelState() + + const [isMobile, setIsMobile] = useState(false) + const sheetRef = useRef(null) + const dragStartY = useRef(0) + const dragStartState = useRef('half') + + const showContacts = hasFeature('has_contacts') && auth.authenticated + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + + const handleTouchStart = useCallback((e) => { + dragStartY.current = e.touches[0].clientY + dragStartState.current = sheetState + }, [sheetState]) + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - dragStartY.current + if (Math.abs(deltaY) < 30) return + if (deltaY < 0) { + if (dragStartState.current === 'collapsed') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('full') + } else { + if (dragStartState.current === 'full') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('collapsed') + } + }, [setSheetState]) + + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } + + const showPreviewCard = panelState.startsWith('PREVIEW') + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + + const routesContent = ( + <> + + + {showPreviewCard && selectedPlace && ( +
+ +
+ )} + + {showRouteSection && ( +
+
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Right-click to set start'} + +
+
+ + + {routeEnd?.name || 'Right-click to set destination'} + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {routeMode !== 'auto' && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + +
+ )} + + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + if (!isMobile) { + return ( +
+ {header} + {content} +
+ ) + } + + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {header} + {content} +
+ )} +
+ ) +} diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 2e47bd9..1215a08 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -6,6 +6,30 @@ import { buildAddress } from '../utils/place' import { searchGeocode } from '../api' import { hasFeature } from '../config' + +/** Parse coordinate input like "42.35, -114.30" or "42.35 -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const trimmed = input.trim() + + // Pattern: lat, lon or lat lon (with optional comma) + // Supports: "42.35, -114.30", "42.35 -114.30", "42.35,-114.30" + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = trimmed.match(pattern) + + if (!match) return null + + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + + // Validate ranges + if (isNaN(lat) || isNaN(lon)) return null + if (lat < -90 || lat > 90) return null + if (lon < -180 || lon > 180) return null + + return { lat, lon } +} + /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' @@ -71,6 +95,25 @@ const SearchBar = forwardRef(function SearchBar(_, ref) { return } + // Check for coordinate input first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setAutocompleteOpen(true) + setSearchLoading(false) + return + } + // Prepend matching contacts let contactResults = [] if (hasFeature('has_contacts') && contacts.length > 0) { diff --git a/src/store.js b/src/store.js index 2cf78ee..a4039dc 100644 --- a/src/store.js +++ b/src/store.js @@ -1,163 +1,271 @@ -import { create } from 'zustand' - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: '', - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Unified Route State ── - // Single routing system - all routes go through /api/offroute - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - routeMode: "foot", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteMode: (mode) => set({ routeMode: mode }), - setBoundaryMode: (mode) => set({ boundaryMode: mode }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - clearRoute: () => set({ - routeStart: null, - routeEnd: null, - routeResult: null, - routeError: null - }), - - // ── Legacy compatibility (for components not yet migrated) ── - stops: [], - gpsOrigin: false, - pendingDestination: null, - route: null, - - addStop: (stop) => { - // Legacy: just set as route end point - const { routeStart, setRouteEnd } = get() - const place = { lat: stop.lat, lon: stop.lon, name: stop.name } - if (!routeStart) { - set({ routeStart: place, stops: [{ ...stop, id: crypto.randomUUID() }] }) - } else { - setRouteEnd(place) - set({ stops: [...get().stops, { ...stop, id: crypto.randomUUID() }] }) - } - return true - }, - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - if (newStops.length === 0) { - get().clearRoute() - } - }, - clearStops: () => set({ stops: [], routeStart: null, routeEnd: null }), - setStops: (stops) => set({ stops }), - reorderStops: (newStops) => set({ stops: newStops }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - // Legacy: set as destination - const { routeStart, setRouteEnd, clearRoute } = get() - clearRoute() - set({ - routeEnd: { lat: place.lat, lon: place.lon, name: place.name }, - stops: [{ ...place, id: crypto.randomUUID() }], - selectedPlace: null - }) - }, - - // ── Place detail ── - selectedPlace: null, - clickMarker: null, - - setSelectedPlace: (place) => set({ selectedPlace: place }), - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - - // ── UI state ── - sheetState: 'half', - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', - themeOverride: null, - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', - editingContact: null, - pickingLocationFor: null, - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.routeResult - const hasRoutePoints = !!s.routeStart || !!s.routeEnd - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasRoutePoints) return "ROUTING" - return "IDLE" - }) -} +import { create } from 'zustand' +import { requestOffroute, requestOptimizedRoute } from './api' + +export const useStore = create((set, get) => ({ + // ── Search state ── + query: '', + results: [], + searchLoading: false, + abortController: null, + + setQuery: (query) => set({ query }), + setResults: (results) => set({ results }), + setSearchLoading: (loading) => set({ searchLoading: loading }), + setAbortController: (ctrl) => set({ abortController: ctrl }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // Single routing system - all routes go through /api/offroute + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + routeMode: "auto", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + // Map display callback - set by MapView + _updateRouteDisplay: null, + _clearRouteDisplay: null, + setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + + // Mode/boundary setters that trigger recalculation + setRouteMode: (mode) => { + set({ routeMode: mode }) + get().computeRoute() + }, + setBoundaryMode: (mode) => { + set({ boundaryMode: mode }) + get().computeRoute() + }, + + clearRoute: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ + routeStart: null, + routeEnd: null, + routeResult: null, + routeError: null, + stops: [], + route: null + }) + }, + + // ── UNIFIED ROUTING TRIGGER ── + // This is the SINGLE routing function for everything + computeRoute: async () => { + const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + + // Need both endpoints to route + if (!routeStart || !routeEnd) return + + set({ routeLoading: true, routeError: null }) + + try { + const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) + + if (data.status === "ok" && data.route) { + set({ routeResult: data, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(data.route) + } else { + set({ routeError: data.message || data.error || "No route found", routeResult: null }) + } + } catch (e) { + set({ routeError: e.message, routeResult: null }) + } finally { + set({ routeLoading: false }) + } + }, + + // ── Stop list (master compatibility) ── + stops: [], + gpsOrigin: true, // whether GPS should be used as origin when available + pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) + route: null, // Legacy Valhalla response (for 3+ stop optimization) + + addStop: (stop) => { + const { stops, routeMode, _updateRouteDisplay } = get() + if (stops.length >= 10) return false + const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] + set({ stops: newStops }) + + // Route logic depends on stop count + if (newStops.length === 1) { + // Single stop = origin, waiting for second + const origin = newStops[0] + set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) + } else if (newStops.length === 2) { + // Two stops = use offroute (handles on-road and wilderness) + const origin = newStops[0] + const dest = newStops[1] + set({ + routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, + routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } + }) + get().computeRoute() + } else { + // 3+ stops = use Valhalla multi-stop optimization + set({ routeLoading: true, routeError: null }) + const locations = newStops.map((s) => ({ lat: s.lat, lon: s.lon })) + const costing = routeMode === "auto" ? "auto" : routeMode === "foot" ? "pedestrian" : routeMode === "mtb" ? "bicycle" : "auto" + requestOptimizedRoute(locations, costing) + .then((data) => { + if (data.trip) { + set({ route: data.trip, routeError: null }) + // Update display via legacy route handler if available + if (_updateRouteDisplay && data.trip) { + // Multi-stop uses legacy route format, need to convert or use separate handler + } + } + }) + .catch((e) => set({ routeError: e.message })) + .finally(() => set({ routeLoading: false })) + } + + return true + }, + + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + if (newStops.length === 0) { + get().clearRoute() + } else if (newStops.length === 1) { + // Back to single stop + const origin = newStops[0] + set({ + routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, + routeEnd: null, + routeResult: null + }) + } + }, + + reorderStops: (newStops) => set({ stops: newStops }), + + clearStops: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) + }, + + setStops: (stops) => set({ stops }), + + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + // Master startDirections - restored verbatim + startDirections: (place) => { + const { geoPermission, stops, addStop, clearStops } = get() + if (geoPermission === 'granted') { + clearStops() + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + set({ gpsOrigin: true, selectedPlace: null }) + } else if (stops.length > 0) { + const origin = stops[0] + clearStops() + addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + set({ selectedPlace: null }) + } else { + // GPS denied, no stops: set pendingDestination only; origin-picker will add both + set({ pendingDestination: place, selectedPlace: null }) + } + }, + + // Legacy route setter (for 3+ stop Valhalla optimization) + setRoute: (route) => set({ route, routeError: null }), + setRouteError: (err) => set({ routeError: err, route: null }), + + // ── Place detail ── + 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 + + setSelectedPlace: (place) => set({ selectedPlace: place }), + + // Boundary rendering function - set by MapView, called by PlaceCard + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: 'half', // 'collapsed' | 'half' | 'full' + panelOpen: true, + autocompleteOpen: false, + theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) + themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem('navi-theme-override', override) + } else { + localStorage.removeItem('navi-theme-override') + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: 'routes', // 'routes' | 'contacts' + editingContact: null, // null=closed, {}=new, {id:N}=edit + pickingLocationFor: null, // form data while user picks location on map + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +// Returns string state, prioritizing preview to allow it alongside any route state +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} From 7523ddd0a2478adcbd64de3e3603dd651cd875a8 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 22:44:45 +0000 Subject: [PATCH 26/34] feat: add directions panel with editable origin/destination inputs New UX for Get Directions: - DirectionsPanel component with two stacked input fields - LocationInput component with autocomplete, coordinate parsing - Swap button to flip origin/destination - Travel mode selector (Drive default, Foot, MTB, ATV, 4x4) - Boundary selector (only visible for non-Drive modes) - Map click fills active input field with crosshair cursor - Auto-route when both endpoints are filled - X button closes directions and returns to search view Store changes: - directionsMode state for panel switching - activeDirectionsField for map click targeting - startDirections now enters directions mode with destination pre-filled Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 263 +++++++++++++++++++++++++ src/components/LocationInput.jsx | 301 +++++++++++++++++++++++++++++ src/components/MapView.jsx | 50 ++++- src/components/Panel.jsx | 10 +- src/store.js | 49 +++-- 5 files changed, 656 insertions(+), 17 deletions(-) create mode 100644 src/components/DirectionsPanel.jsx create mode 100644 src/components/LocationInput.jsx diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx new file mode 100644 index 0000000..d2f7db4 --- /dev/null +++ b/src/components/DirectionsPanel.jsx @@ -0,0 +1,263 @@ +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Insert a stop between origin and destination + // For now, this adds to the stops array + // The UI will show intermediate stops + } + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) // Everything except first and last + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message */} + {routeError && ( +
+ {routeError} +
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

+ Enter addresses, paste coordinates, or click the map +

+
+ )} +
+ ) +} diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx new file mode 100644 index 0000000..a15b1bb --- /dev/null +++ b/src/components/LocationInput.jsx @@ -0,0 +1,301 @@ +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" +import { useStore } from "../store" +import { searchGeocode } from "../api" +import { buildAddress } from "../utils/place" +import { hasFeature } from "../config" + +/** Parse coordinate input like "42.35, -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = input.trim().match(pattern) + if (!match) return null + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null + return { lat, lon } +} + +function CategoryIcon({ result, size = 14 }) { + const type = result.type || "" + const source = result.source || "" + if (result._isContact) return + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(false) + return + } + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, -1)) + break + case "Enter": + e.preventDefault() + if (activeIndex >= 0 && activeIndex < results.length) { + selectResult(results[activeIndex]) + } + break + case "Escape": + e.preventDefault() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca9b5c5..a46eac5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1441,6 +1441,8 @@ const MapView = forwardRef(function MapView(_, ref) { const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) + const directionsMode = useStore((s) => s.directionsMode) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1999,7 +2001,37 @@ const MapView = forwardRef(function MapView(_, ref) { return } - + // Handle directions mode — click fills the active field + const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() + if (directionsMode && activeDirectionsField) { + const { lng, lat } = e.lngLat + // Reverse geocode for name + fetchReverse(lat, lng).then((place) => { + const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } else if (activeDirectionsField.startsWith("stop-")) { + // Handle intermediate stops - would need more logic + setActiveDirectionsField(null) + } + }).catch(() => { + const name = lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } + }) + return + } const store = useStore.getState() const marker = store.clickMarker @@ -2694,6 +2726,22 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) + // Handle directions mode cursor + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (directionsMode && activeDirectionsField) { + map.getCanvas().style.cursor = 'crosshair' + } else if (!measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + return () => { + if (map && !measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + } + }, [directionsMode, activeDirectionsField]) + // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index efc9b5a..a708734 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -7,6 +7,7 @@ import SearchBar from './SearchBar' import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' +import DirectionsPanel from './DirectionsPanel' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -39,6 +40,8 @@ export default function Panel({ onClearRoute }) { const activeTab = useStore((s) => s.activeTab) const auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) + const directionsMode = useStore((s) => s.directionsMode) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) const panelState = usePanelState() @@ -86,7 +89,12 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - const routesContent = ( + const routesContent = directionsMode ? ( + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + ) : ( <> diff --git a/src/store.js b/src/store.js index a4039dc..4ea2839 100644 --- a/src/store.js +++ b/src/store.js @@ -173,23 +173,38 @@ export const useStore = create((set, get) => ({ setPendingDestination: (place) => set({ pendingDestination: place }), clearPendingDestination: () => set({ pendingDestination: null }), - // Master startDirections - restored verbatim + // Master startDirections - enters directions mode with destination pre-filled startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + // Set destination from the clicked place + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, } + + // Set origin from GPS if available + let origin = null + if (geoPermission === 'granted' && userLocation) { + origin = { + lat: userLocation.lat, + lon: userLocation.lon, + name: 'Your location', + source: 'gps', + } + } + + set({ + routeEnd: destination, + routeStart: origin, + directionsMode: true, + activeDirectionsField: origin ? null : 'origin', // Focus origin if empty + selectedPlace: null, + }) }, // Legacy route setter (for 3+ stop Valhalla optimization) @@ -213,6 +228,8 @@ export const useStore = create((set, get) => ({ sheetState: 'half', // 'collapsed' | 'half' | 'full' panelOpen: true, autocompleteOpen: false, + directionsMode: false, // true when directions panel is active + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -224,6 +241,8 @@ export const useStore = create((set, get) => ({ }, setPanelOpen: (open) => set({ panelOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), setTheme: (theme) => set({ theme }), setThemeOverride: (override) => { set({ themeOverride: override }) From a6942b35ea1c58476b81e268351257925ba42442 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 23:08:38 +0000 Subject: [PATCH 27/34] fix: preserve click coordinates for wilderness routing When clicking on a labeled feature (e.g., "Monument Peak"), the code was using the feature's canonical coordinates instead of the actual click coordinates. This caused wilderness clicks to snap to named places that might be on roads, bypassing wilderness routing. Fix: Always use click coordinates (e.lngLat) for routing purposes. Feature coordinates are only used for display/detail fetching. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a46eac5..00b7bf5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2199,12 +2199,16 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // Get feature coordinates (Point geometry) - let featureLat = lat - let featureLon = lng + // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) + // Feature coordinates are only for display/fetching details + let featureLat = lat // Click coordinate - used for routing + let featureLon = lng // Click coordinate - used for routing + let displayLat = lat // May be updated to feature coords for display + let displayLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - featureLon = geom.coordinates[0] - featureLat = geom.coordinates[1] + // Store feature's canonical coords separately - NOT for routing + displayLon = geom.coordinates[0] + displayLat = geom.coordinates[1] } // FIX A: For park-type features, also query polygon layers to get boundary geometry From 19a96cba5e42dc2c4730812bfa8d1def4b163dc0 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 03:37:05 +0000 Subject: [PATCH 28/34] feat: improve directions panel with route legend and place card below - Add route legend showing wilderness (dashed orange) vs road (solid blue) - Show place card below directions panel when clicking map during routing - Clean up error messages to be user-friendly (no offroute text) - Legend only appears when route has wilderness segments Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 562 +++++++++++++++-------------- src/components/Panel.jsx | 21 +- 2 files changed, 316 insertions(+), 267 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index d2f7db4..794cad6 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,263 +1,299 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" -import { useStore } from "../store" -import LocationInput from "./LocationInput" -import ManeuverList from "./ManeuverList" - -const TRAVEL_MODES = [ - { id: "auto", label: "Drive", Icon: Car }, - { id: "foot", label: "Foot", Icon: Footprints }, - { id: "mtb", label: "MTB", Icon: Bike }, - { id: "atv", label: "ATV", Icon: Car }, - { id: "vehicle", label: "4x4", Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, - { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, - { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, -] - -export default function DirectionsPanel({ onClose }) { - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const stops = useStore((s) => s.stops) - const userLocation = useStore((s) => s.userLocation) - const geoPermission = useStore((s) => s.geoPermission) - - const setRouteStart = useStore((s) => s.setRouteStart) - const setRouteEnd = useStore((s) => s.setRouteEnd) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const computeRoute = useStore((s) => s.computeRoute) - const clearRoute = useStore((s) => s.clearRoute) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) - const addStop = useStore((s) => s.addStop) - const removeStop = useStore((s) => s.removeStop) - const reorderStops = useStore((s) => s.reorderStops) - - // Auto-fill origin with GPS if available and origin is empty - useEffect(() => { - if (!routeStart && geoPermission === "granted" && userLocation) { - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - } - }, [routeStart, geoPermission, userLocation, setRouteStart]) - - // Auto-compute route when both endpoints are set - useEffect(() => { - if (routeStart && routeEnd) { - computeRoute() - } - }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - // Insert a stop between origin and destination - // For now, this adds to the stops array - // The UI will show intermediate stops - } - - // Multi-stop support: show intermediate stops from the stops array - const intermediateStops = stops.slice(1, -1) // Everything except first and last - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - - - {/* Swap button - positioned between inputs */} - - - {/* Intermediate stops (for multi-stop routes) */} - {intermediateStops.map((stop, idx) => ( -
- { - if (place) { - const newStops = [...stops] - newStops[idx + 1] = { ...newStops[idx + 1], ...place } - reorderStops(newStops) - } else { - removeStop(stop.id) - } - }} - placeholder="Stop" - icon="stop" - fieldId={`stop-${idx}`} - /> -
- ))} - - {/* Destination */} - - - {/* Add stop button */} - {routeStart && routeEnd && stops.length < 10 && ( - - )} -
- - {/* Travel mode selector */} -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {/* Boundary mode selector (only for non-auto modes) */} - {routeMode !== "auto" && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - {/* Loading indicator */} - {routeLoading && ( -
-
- - Finding route... - -
- )} - - {/* Error message */} - {routeError && ( -
- {routeError} -
- )} - - {/* Route summary and maneuvers */} - {routeResult && !routeLoading && ( -
- -
- )} - - {/* Hint when waiting for input */} - {!routeStart && !routeEnd && !routeLoading && ( -
-

- Enter addresses, paste coordinates, or click the map -

-
- )} -
- ) -} +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // For now, show a message - multi-stop UI is complex + // TODO: Implement full multi-stop UI + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message - friendly text, no "offroute" */} + {routeError && ( +
+ {routeError.includes("No route") || routeError.includes("not found") + ? "No route found. Try a different start point or mode." + : routeError.includes("entry point") + ? "No roads found nearby — try Foot mode for trails." + : routeError} +
+ )} + + {/* Route legend - only shown when route has wilderness segment */} + {routeResult && hasWilderness && !routeLoading && ( +
+
+ + + + Wilderness (on foot) +
+
+ + + + Road/Trail +
+
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

+ Enter addresses, paste coordinates, or click the map +

+
+ )} +
+ ) +} diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index a708734..98e9f16 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -90,10 +90,23 @@ export default function Panel({ onClearRoute }) { const showEmptyState = panelState === 'IDLE' && !hasRoutePoints const routesContent = directionsMode ? ( - { - setDirectionsMode(false) - onClearRoute?.() - }} /> + <> + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + {/* Show place card below directions when clicking map during routing */} + {selectedPlace && ( +
+ +
+ )} + ) : ( <> From 816ea8dd1f84380968892cb5d7239dee8449eea2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 06:09:14 +0000 Subject: [PATCH 29/34] feat: wilderness maneuvers, pick-from-map, distance formatting, place card panel - Wilderness maneuvers render with compass arrows and cardinal directions - Network maneuvers prefixed with transport mode (Drive/Walk/Ride) - Distances under 1 mile show feet with commas - Pick-from-map mode replaces auto-fill-on-focus (crosshair + toast) - ESC cancels pick mode - Place card slides out right during active routing - Removed debug toasts Co-Authored-By: Claude Opus 4.5 --- src/api.js | 1 + src/components/LocationInput.jsx | 622 ++++++++++++++++--------------- src/components/ManeuverList.jsx | 233 ++++++++++-- src/components/MapView.jsx | 27 +- src/components/Panel.jsx | 147 ++++++-- src/components/PlaceCard.jsx | 1 + src/store.js | 10 +- 7 files changed, 663 insertions(+), 378 deletions(-) diff --git a/src/api.js b/src/api.js index 47d5861..bed21ec 100644 --- a/src/api.js +++ b/src/api.js @@ -342,6 +342,7 @@ export async function requestOffroute(start, end, mode = "foot", boundaryMode = mode, boundary_mode: boundaryMode, } + console.log('[TRACE-API] requestOffroute body:', JSON.stringify(body)) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 120000) // 2 min timeout for complex routes diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx index a15b1bb..eb0a204 100644 --- a/src/components/LocationInput.jsx +++ b/src/components/LocationInput.jsx @@ -1,301 +1,321 @@ -import { useRef, useEffect, useCallback, useState } from "react" -import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" -import { useStore } from "../store" -import { searchGeocode } from "../api" -import { buildAddress } from "../utils/place" -import { hasFeature } from "../config" - -/** Parse coordinate input like "42.35, -114.30" */ -function parseCoordinates(input) { - if (!input) return null - const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ - const match = input.trim().match(pattern) - if (!match) return null - const lat = parseFloat(match[1]) - const lon = parseFloat(match[2]) - if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null - return { lat, lon } -} - -function CategoryIcon({ result, size = 14 }) { - const type = result.type || "" - const source = result.source || "" - if (result._isContact) return - if (source === "nickname") return - if (type === "coordinates") return - if (type === "locality" || type === "city") return - const osmVal = result.raw?.osm_value || "" - if (osmVal.includes("cafe") || osmVal.includes("coffee")) return - if (osmVal.includes("fuel") || osmVal.includes("gas")) return - if (osmVal.includes("shop") || osmVal.includes("supermarket")) return - if (osmVal.includes("hotel") || osmVal.includes("motel")) return - return -} - -export default function LocationInput({ - value, // { lat, lon, name } or null - onChange, // (place) => void - placeholder, - icon, // "origin" | "destination" | "stop" - fieldId, // unique id for this field (for map click targeting) - onFocus, // () => void - autoFocus, -}) { - const inputRef = useRef(null) - const [query, setQuery] = useState(value?.name || "") - const [results, setResults] = useState([]) - const [loading, setLoading] = useState(false) - const [open, setOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(-1) - const debounceRef = useRef(null) - const abortRef = useRef(null) - - const contacts = useStore((s) => s.contacts) - const activeDirectionsField = useStore((s) => s.activeDirectionsField) - const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) - - // Sync display value when external value changes - useEffect(() => { - if (value?.name && value.name !== query) { - setQuery(value.name) - } else if (!value && query && !open) { - // Value cleared externally - setQuery("") - } - }, [value?.name, value?.lat, value?.lon]) - - const doSearch = useCallback(async (q) => { - if (abortRef.current) abortRef.current.abort() - - if (!q.trim()) { - setResults([]) - setOpen(false) - setLoading(false) - return - } - - // Check coordinates first - const coords = parseCoordinates(q) - if (coords) { - const coordResult = { - lat: coords.lat, - lon: coords.lon, - name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), - address: "Coordinates", - type: "coordinates", - source: "coordinates", - match_code: null, - raw: {}, - } - setResults([coordResult]) - setOpen(true) - setLoading(false) - return - } - - // Contact matches - let contactResults = [] - if (hasFeature("has_contacts") && contacts.length > 0) { - const lower = q.trim().toLowerCase() - contactResults = contacts - .filter((c) => - (c.label || "").toLowerCase().startsWith(lower) || - (c.name || "").toLowerCase().startsWith(lower) || - (c.call_sign || "").toLowerCase().startsWith(lower) - ) - .slice(0, 3) - .map((c) => ({ - lat: c.lat, - lon: c.lon, - name: c.label, - address: c.address || c.name || "", - type: "contact", - source: "contacts", - match_code: null, - raw: { contact: c }, - _isContact: true, - })) - } - - const ctrl = new AbortController() - abortRef.current = ctrl - setLoading(true) - - try { - const data = await searchGeocode(q.trim(), 5, ctrl.signal) - const combined = [...contactResults, ...(data.results || [])] - setResults(combined) - setOpen(combined.length > 0) - setActiveIndex(-1) - } catch (e) { - if (e.name !== "AbortError") { - if (contactResults.length > 0) { - setResults(contactResults) - setOpen(true) - } else { - setResults([]) - setOpen(false) - } - } - } finally { - setLoading(false) - } - }, [contacts]) - - const handleChange = (e) => { - const val = e.target.value - setQuery(val) - if (debounceRef.current) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => doSearch(val), 150) - } - - const handleClear = () => { - setQuery("") - setResults([]) - setOpen(false) - onChange(null) - inputRef.current?.focus() - } - - const selectResult = (result) => { - onChange({ - lat: result.lat, - lon: result.lon, - name: result.name, - source: result.source, - matchCode: result.match_code, - }) - setQuery(result.name) - setResults([]) - setOpen(false) - setActiveIndex(-1) - } - - const handleKeyDown = (e) => { - if (!open || results.length === 0) { - if (e.key === "Escape") setOpen(false) - return - } - switch (e.key) { - case "ArrowDown": - e.preventDefault() - setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)) - break - case "ArrowUp": - e.preventDefault() - setActiveIndex((prev) => Math.max(prev - 1, -1)) - break - case "Enter": - e.preventDefault() - if (activeIndex >= 0 && activeIndex < results.length) { - selectResult(results[activeIndex]) - } - break - case "Escape": - e.preventDefault() - setOpen(false) - setActiveIndex(-1) - break - } - } - - const handleFocus = () => { - setActiveDirectionsField(fieldId) - if (results.length > 0) setOpen(true) - onFocus?.() - } - - const handleBlur = () => { - // Delay to allow click on dropdown - setTimeout(() => setOpen(false), 150) - } - - const isActive = activeDirectionsField === fieldId - - const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" - - return ( -
-
- {icon === "origin" ? ( - - ) : ( - - )} - - {loading ? ( -
- ) : query ? ( - - ) : null} -
- - {open && results.length > 0 && ( -
    - {results.map((r, i) => { - const isPoi = r.type === "poi" && r.raw?.name - const isContact = r._isContact - const primary = isContact ? r.name : isPoi ? r.raw.name : r.name - const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null - return ( -
  • selectResult(r)} - onMouseEnter={() => setActiveIndex(i)} - > -
    - - - - - {primary} - -
    - {secondary && ( -
    - {secondary} -
    - )} -
  • - ) - })} -
- )} -
- ) -} +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2, Target } from "lucide-react" +import toast from "react-hot-toast" +import { useStore } from "../store" +import { searchGeocode } from "../api" +import { buildAddress } from "../utils/place" +import { hasFeature } from "../config" + +/** Parse coordinate input like "42.35, -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = input.trim().match(pattern) + if (!match) return null + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null + return { lat, lon } +} + +function CategoryIcon({ result, size = 14 }) { + const type = result.type || "" + const source = result.source || "" + if (result._isContact) return + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(false) + return + } + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, -1)) + break + case "Enter": + e.preventDefault() + if (activeIndex >= 0 && activeIndex < results.length) { + selectResult(results[activeIndex]) + } + break + case "Escape": + e.preventDefault() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) // For styling only, not map clicks + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handlePickFromMap = () => { + setPickingRouteField(fieldId) + toast("Click map to set location", { icon: "🎯", duration: 3000 }) + inputRef.current?.blur() // Unfocus input so user focuses on map + } + + const isPicking = pickingRouteField === fieldId + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {/* Pick from map button */} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index a8b90b0..44d1ffc 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,13 +1,32 @@ import { MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, + Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, + ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin } from 'lucide-react' import { useStore } from '../store' -function formatDistKm(km) { - const miles = km * 0.621371 - if (miles < 0.1) return Math.round(miles * 5280) + ' ft' +/** + * Format distance with commas for feet, one decimal for miles. + * Under 1 mile: "2,640 ft" + * 1+ miles: "1.3 mi" + */ +function formatDistance(distanceM, distanceKm) { + let meters = null + if (distanceM !== undefined && distanceM !== null) { + meters = distanceM + } else if (distanceKm !== undefined && distanceKm !== null) { + meters = distanceKm * 1000 + } + + if (meters === null) return '' + + const miles = meters / 1609.34 + if (miles < 1) { + const feet = Math.round(meters * 3.28084) + return feet.toLocaleString() + ' ft' + } return miles.toFixed(1) + ' mi' } @@ -18,6 +37,51 @@ function formatTimeMin(minutes) { return m > 0 ? h + 'h ' + m + 'm' : h + 'h' } +// Compass arrow icon based on cardinal direction with rotation +function CompassIcon({ cardinal, bearing, size = 16 }) { + // Use bearing to rotate arrow, or fall back to cardinal-based icon + if (bearing !== undefined && bearing !== null) { + return ( + + ) + } + + const props = { size, strokeWidth: 2 } + const arrowMap = { + 'N': ArrowUp, + 'NNE': ArrowUpRight, + 'NE': ArrowUpRight, + 'ENE': ArrowRight, + 'E': ArrowRight, + 'ESE': ArrowRight, + 'SE': ArrowDownRight, + 'SSE': ArrowDownRight, + 'S': ArrowDown, + 'SSW': ArrowDownLeft, + 'SW': ArrowDownLeft, + 'WSW': ArrowLeft, + 'W': ArrowLeft, + 'WNW': ArrowLeft, + 'NW': ArrowUpLeft, + 'NNW': ArrowUpLeft, + } + const Icon = arrowMap[cardinal] || Compass + return +} + +// Wilderness maneuver icon +function WildernessIcon({ type, cardinal, bearing, size = 16 }) { + if (type === 'arrival') { + return + } + return +} + +// Network maneuver icon (Valhalla types) function ManeuverIcon({ type }) { const size = 16 const props = { size, strokeWidth: 1.5 } @@ -40,10 +104,55 @@ function ManeuverIcon({ type }) { } } +/** + * Add transport mode prefix to network maneuver instruction. + * "Drive east on..." for auto, "Walk south on..." for foot, "Ride north on..." for mtb + */ +function formatNetworkInstruction(instruction, mode) { + if (!instruction) return '' + + // Get verb based on mode + const modeVerbs = { + 'auto': 'Drive', + 'foot': 'Walk', + 'pedestrian': 'Walk', + 'mtb': 'Ride', + 'bicycle': 'Ride', + 'atv': 'Drive', + 'vehicle': 'Drive', + } + const verb = modeVerbs[mode] || 'Go' + + // Check if instruction starts with a direction verb we should replace + const startsWithVerbs = [ + 'Turn left', 'Turn right', 'Bear left', 'Bear right', + 'Keep left', 'Keep right', 'Continue', 'Head', 'Go', + 'Proceed', 'Make a', 'Take a', 'Start', 'Merge', 'Exit' + ] + + for (const v of startsWithVerbs) { + if (instruction.startsWith(v)) { + // Already has a verb, return as-is (Valhalla instructions are already good) + return instruction + } + } + + // If instruction starts with direction (north, south, etc.), prepend verb + const directions = ['north', 'south', 'east', 'west', 'onto', 'on '] + for (const dir of directions) { + if (instruction.toLowerCase().startsWith(dir)) { + return `${verb} ${instruction}` + } + } + + return instruction +} + export default function ManeuverList() { const routeResult = useStore((s) => s.routeResult) const routeLoading = useStore((s) => s.routeLoading) const routeError = useStore((s) => s.routeError) + const routeMode = useStore((s) => s.routeMode) if (routeLoading) { return ( @@ -77,8 +186,25 @@ export default function ManeuverList() { if (!routeResult?.summary) return null const summary = routeResult.summary - const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') - const maneuvers = networkFeature?.properties?.maneuvers || [] + const features = routeResult.route?.features || [] + const networkMode = summary.network_mode || routeMode || 'foot' + + // Extract maneuvers from each segment type + const wildernessStartFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'start' + ) + const networkFeature = features.find(f => f.properties?.segment_type === 'network') + const wildernessEndFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'end' + ) + + const wildernessStartManeuvers = wildernessStartFeature?.properties?.maneuvers || [] + const networkManeuvers = networkFeature?.properties?.maneuvers || [] + const wildernessEndManeuvers = wildernessEndFeature?.properties?.maneuvers || [] + + const hasManeuvers = wildernessStartManeuvers.length > 0 || + networkManeuvers.length > 0 || + wildernessEndManeuvers.length > 0 return (
@@ -88,7 +214,7 @@ export default function ManeuverList() { style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }} > - {formatDistKm(summary.total_distance_km)} + {formatDistance(null, summary.total_distance_km)} {formatTimeMin(summary.total_effort_minutes)} @@ -102,7 +228,7 @@ export default function ManeuverList() { Wilderness - {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)}
)} @@ -111,7 +237,7 @@ export default function ManeuverList() { Road/Trail - {formatDistKm(summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)}
)} @@ -136,27 +262,80 @@ export default function ManeuverList() { )} {/* Turn-by-turn directions */} - {maneuvers.length > 0 && ( + {hasManeuvers && (
Directions
- {maneuvers.map((man, i) => ( -
- - - -
-

- {man.instruction} -

-

- {formatDistKm(man.distance_km)} -

+ + {/* Wilderness start maneuvers */} + {wildernessStartManeuvers.length > 0 && ( + <> +
+ Wilderness — On Foot
-
- ))} + {wildernessStartManeuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+
+
+ ))} + + )} + + {/* Network maneuvers */} + {networkManeuvers.length > 0 && ( + <> + {wildernessStartManeuvers.length > 0 && ( +
+ Road/Trail +
+ )} + {networkManeuvers.map((man, i) => ( +
+ + + +
+

+ {formatNetworkInstruction(man.instruction, networkMode)} +

+

+ {formatDistance(null, man.distance_km)} +

+
+
+ ))} + + )} + + {/* Wilderness end maneuvers */} + {wildernessEndManeuvers.length > 0 && ( + <> +
+ Wilderness — On Foot +
+ {wildernessEndManeuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+
+
+ ))} + + )}
)}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 00b7bf5..c77994b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1443,6 +1443,7 @@ const MapView = forwardRef(function MapView(_, ref) { const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) const directionsMode = useStore((s) => s.directionsMode) const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -2001,34 +2002,30 @@ const MapView = forwardRef(function MapView(_, ref) { return } - // Handle directions mode — click fills the active field - const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() - if (directionsMode && activeDirectionsField) { + // Handle explicit pick-from-map mode for route inputs + const { pickingRouteField, setRouteStart, setRouteEnd, clearPickingRouteField } = useStore.getState() + if (pickingRouteField) { const { lng, lat } = e.lngLat + map.getCanvas().style.cursor = '' // Reverse geocode for name fetchReverse(lat, lng).then((place) => { const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) const location = { lat, lon: lng, name, source: "map_click" } - if (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) - } else if (activeDirectionsField.startsWith("stop-")) { - // Handle intermediate stops - would need more logic - setActiveDirectionsField(null) } + clearPickingRouteField() }).catch(() => { const name = lat.toFixed(5) + ", " + lng.toFixed(5) const location = { lat, lon: lng, name, source: "map_click" } - if (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) } + clearPickingRouteField() }) return } @@ -2253,6 +2250,7 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current(polygonGeometry) } + console.log('[TRACE-CLICK] Feature click setSelectedPlace:', { featureLat, featureLon, clickLat: lat, clickLng: lng, name: props.name }) store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2284,6 +2282,7 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) + console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 98e9f16..b89c661 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,5 +1,5 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react' import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' @@ -8,6 +8,7 @@ import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' import DirectionsPanel from './DirectionsPanel' +import PlaceDetail from './PlaceDetail' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -34,6 +35,8 @@ export default function Panel({ onClearRoute }) { const routeLoading = useStore((s) => s.routeLoading) const setRouteMode = useStore((s) => s.setRouteMode) const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) const clearRoute = useStore((s) => s.clearRoute) const sheetState = useStore((s) => s.sheetState) const setSheetState = useStore((s) => s.setSheetState) @@ -89,29 +92,20 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + // Show side panel place card when building route (either mode) and place is selected + const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace + const routesContent = directionsMode ? ( - <> - { - setDirectionsMode(false) - onClearRoute?.() - }} /> - {/* Show place card below directions when clicking map during routing */} - {selectedPlace && ( -
- -
- )} - + // Directions mode: just the directions panel, place card is shown in side panel + { + setDirectionsMode(false) + onClearRoute?.() + }} /> ) : ( <> - {showPreviewCard && selectedPlace && ( + {showPreviewCard && selectedPlace && !showRouteSection && (
- - {routeStart?.name || 'Right-click to set start'} + + {routeStart?.name || 'Click pin to pick start'} +
- - {routeEnd?.name || 'Right-click to set destination'} + + {routeEnd?.name || 'Click pin to pick destination'} +
@@ -263,19 +273,85 @@ export default function Panel({ onClearRoute }) {
) + // Side panel for place card during directions mode (desktop only) + const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+ {/* Use PlaceCard in compact preview mode */} + +
+ ) + + // Mobile overlay for place card during directions mode + const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+
+ +
+
+ ) + if (!isMobile) { return ( -
- {header} - {content} -
+ <> +
+ {header} + {content} +
+ {sidePlaceCardPanel} + ) } @@ -308,9 +384,10 @@ export default function Panel({ onClearRoute }) {
{sheetState !== 'collapsed' && ( -
+
{header} {content} + {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 3afa72b..84ecb6b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -476,6 +476,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const handleDirections = () => { + console.log('[TRACE-DIRECTIONS] PlaceCard handleDirections, place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) // No toast - empty origin slot is the visual prompt startDirections(place) } diff --git a/src/store.js b/src/store.js index 4ea2839..8b98599 100644 --- a/src/store.js +++ b/src/store.js @@ -72,6 +72,10 @@ export const useStore = create((set, get) => ({ // This is the SINGLE routing function for everything computeRoute: async () => { const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + console.log('[TRACE-ROUTE] computeRoute called with:', { + startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, + endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name + }) // Need both endpoints to route if (!routeStart || !routeEnd) return @@ -175,6 +179,7 @@ export const useStore = create((set, get) => ({ // Master startDirections - enters directions mode with destination pre-filled startDirections: (place) => { + console.log('[TRACE-STORE] startDirections received place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) const { geoPermission, userLocation, clearRoute } = get() clearRoute() @@ -229,7 +234,8 @@ export const useStore = create((set, get) => ({ panelOpen: true, autocompleteOpen: false, directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) + pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -243,6 +249,8 @@ export const useStore = create((set, get) => ({ setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), + setPickingRouteField: (field) => set({ pickingRouteField: field }), + clearPickingRouteField: () => set({ pickingRouteField: null }), setTheme: (theme) => set({ theme }), setThemeOverride: (override) => { set({ themeOverride: override }) From 2345334bc71db886748213fce6dbfb0ad2776fca Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 08:23:38 +0000 Subject: [PATCH 30/34] feat: wire up radial menu directions and multi-stop add button - Radial menu "From here" now sets origin and opens directions panel - Radial menu "To here" now sets destination, opens directions panel, and uses GPS as origin fallback when available - DirectionsPanel "Add stop" button now creates intermediate stops - Stops array initialized from routeStart/routeEnd when adding stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 39 ++++++++++++++++++++++++++++-- src/components/MapView.jsx | 25 +++++++++++++------ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 794cad6..b485636 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -74,8 +74,43 @@ export default function DirectionsPanel({ onClose }) { } const handleAddStop = () => { - // For now, show a message - multi-stop UI is complex - // TODO: Implement full multi-stop UI + // Build stops array from current route endpoints if not already populated + let newStops = [...stops] + + // If stops is empty but we have endpoints, initialize from routeStart/routeEnd + if (newStops.length === 0) { + if (routeStart) { + newStops.push({ + id: crypto.randomUUID(), + lat: routeStart.lat, + lon: routeStart.lon, + name: routeStart.name || "Start", + }) + } + if (routeEnd) { + newStops.push({ + id: crypto.randomUUID(), + lat: routeEnd.lat, + lon: routeEnd.lon, + name: routeEnd.name || "Destination", + }) + } + } + + // Create placeholder intermediate stop + const newStop = { + id: crypto.randomUUID(), + lat: null, + lon: null, + name: "", + } + + // Insert before destination (last position), or at end if no destination + const insertIdx = Math.max(0, newStops.length - 1) + newStops.splice(insertIdx, 0, newStop) + + // Update stops array - reorderStops triggers UI update + reorderStops(newStops) } // Check if route has wilderness segments diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index c77994b..0f13fd1 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1672,13 +1672,24 @@ const MapView = forwardRef(function MapView(_, ref) { lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), } - const { routeStart, setRouteEnd, computeRoute } = useStore.getState() + const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() + setRouteEnd(place) + setDirectionsMode(true) + if (routeStart) { computeRoute() - } else { - toast("Set starting point first") + } else if (geoPermission === "granted" && userLocation) { + // Use GPS as origin fallback + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + computeRoute() } + // If no origin and no GPS, directions panel opens and origin field auto-focuses }, }, { @@ -1692,16 +1703,16 @@ const MapView = forwardRef(function MapView(_, ref) { lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), } - const { clearRoute, setRouteStart, routeEnd, computeRoute } = useStore.getState() + const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() clearRoute() clearRouteDisplay(mapInstance.current) setRouteStart(place) - // If we already have a destination, compute route immediately + setDirectionsMode(true) + if (routeEnd) { computeRoute() - } else { - toast("Now tap destination") } + // If no destination, directions panel opens and destination field auto-focuses }, }, { From 79413014a5cd2fbd6631af4c1298cc04a8f9176a Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 14:59:31 +0000 Subject: [PATCH 31/34] fix: separate stops[] from routeStart/routeEnd for multi-stop routing - stops[] now contains ONLY intermediate waypoints - routeStart and routeEnd are separate sources of truth - addIntermediateStop() adds empty placeholder to stops[] - updateStop() and removeStop() manage intermediate waypoints - computeRoute() chains sequential 2-point routes for multi-stop - DirectionsPanel renders: origin -> stops.map() -> destination - Each intermediate stop has remove button (Trash2 icon) Test scenarios verified: - Origin + destination routes normally (no stops involved) - Add Stop creates empty input between origin and destination - Setting intermediate location triggers route recalculation - Multiple stops can be added sequentially - Removing a stop recalculates route without it - Clear all returns to empty state Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 638 ++++++++++++++--------------- src/store.js | 609 +++++++++++++-------------- 2 files changed, 615 insertions(+), 632 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index b485636..2900e75 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,334 +1,304 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" -import { useStore } from "../store" -import LocationInput from "./LocationInput" -import ManeuverList from "./ManeuverList" - -const TRAVEL_MODES = [ - { id: "auto", label: "Drive", Icon: Car }, - { id: "foot", label: "Foot", Icon: Footprints }, - { id: "mtb", label: "MTB", Icon: Bike }, - { id: "atv", label: "ATV", Icon: Car }, - { id: "vehicle", label: "4x4", Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, - { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, - { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, -] - -export default function DirectionsPanel({ onClose }) { - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const stops = useStore((s) => s.stops) - const userLocation = useStore((s) => s.userLocation) - const geoPermission = useStore((s) => s.geoPermission) - - const setRouteStart = useStore((s) => s.setRouteStart) - const setRouteEnd = useStore((s) => s.setRouteEnd) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const computeRoute = useStore((s) => s.computeRoute) - const clearRoute = useStore((s) => s.clearRoute) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) - const addStop = useStore((s) => s.addStop) - const removeStop = useStore((s) => s.removeStop) - const reorderStops = useStore((s) => s.reorderStops) - - // Auto-fill origin with GPS if available and origin is empty - useEffect(() => { - if (!routeStart && geoPermission === "granted" && userLocation) { - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - } - }, [routeStart, geoPermission, userLocation, setRouteStart]) - - // Auto-compute route when both endpoints are set - useEffect(() => { - if (routeStart && routeEnd) { - computeRoute() - } - }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - // Build stops array from current route endpoints if not already populated - let newStops = [...stops] - - // If stops is empty but we have endpoints, initialize from routeStart/routeEnd - if (newStops.length === 0) { - if (routeStart) { - newStops.push({ - id: crypto.randomUUID(), - lat: routeStart.lat, - lon: routeStart.lon, - name: routeStart.name || "Start", - }) - } - if (routeEnd) { - newStops.push({ - id: crypto.randomUUID(), - lat: routeEnd.lat, - lon: routeEnd.lon, - name: routeEnd.name || "Destination", - }) - } - } - - // Create placeholder intermediate stop - const newStop = { - id: crypto.randomUUID(), - lat: null, - lon: null, - name: "", - } - - // Insert before destination (last position), or at end if no destination - const insertIdx = Math.max(0, newStops.length - 1) - newStops.splice(insertIdx, 0, newStop) - - // Update stops array - reorderStops triggers UI update - reorderStops(newStops) - } - - // Check if route has wilderness segments - const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 - - // Multi-stop support: show intermediate stops from the stops array - const intermediateStops = stops.slice(1, -1) - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - - - {/* Swap button - positioned between inputs */} - - - {/* Intermediate stops (for multi-stop routes) */} - {intermediateStops.map((stop, idx) => ( -
- { - if (place) { - const newStops = [...stops] - newStops[idx + 1] = { ...newStops[idx + 1], ...place } - reorderStops(newStops) - } else { - removeStop(stop.id) - } - }} - placeholder="Stop" - icon="stop" - fieldId={`stop-${idx}`} - /> -
- ))} - - {/* Destination */} - - - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 10 && ( - - )} -
- - {/* Travel mode selector */} -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {/* Boundary mode selector (only for non-auto modes) */} - {routeMode !== "auto" && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - {/* Loading indicator */} - {routeLoading && ( -
-
- - Finding route... - -
- )} - - {/* Error message - friendly text, no "offroute" */} - {routeError && ( -
- {routeError.includes("No route") || routeError.includes("not found") - ? "No route found. Try a different start point or mode." - : routeError.includes("entry point") - ? "No roads found nearby — try Foot mode for trails." - : routeError} -
- )} - - {/* Route legend - only shown when route has wilderness segment */} - {routeResult && hasWilderness && !routeLoading && ( -
-
- - - - Wilderness (on foot) -
-
- - - - Road/Trail -
-
- )} - - {/* Route summary and maneuvers */} - {routeResult && !routeLoading && ( -
- -
- )} - - {/* Hint when waiting for input */} - {!routeStart && !routeEnd && !routeLoading && ( -
-

- Enter addresses, paste coordinates, or click the map -

-
- )} -
- ) -} +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addIntermediateStop = useStore((s) => s.addIntermediateStop) + const updateStop = useStore((s) => s.updateStop) + const removeStop = useStore((s) => s.removeStop) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Simply add a new empty intermediate stop + addIntermediateStop() + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Intermediate stops - rendered between origin and destination */} + {stops.map((stop, idx) => ( +
+
+ { + if (place) { + updateStop(stop.id, place) + } + }} + placeholder={`Stop ${idx + 1}`} + icon="stop" + fieldId={`stop-${idx}`} + autoFocus={stop.lat == null} + /> +
+ +
+ ))} + + {/* Swap button - positioned between origin and destination (or after stops) */} + + + {/* Destination */} + + + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message - friendly text, no "offroute" */} + {routeError && ( +
+ {routeError.includes("No route") || routeError.includes("not found") + ? "No route found. Try a different start point or mode." + : routeError.includes("entry point") + ? "No roads found nearby — try Foot mode for trails." + : routeError} +
+ )} + + {/* Route legend - only shown when route has wilderness segment */} + {routeResult && hasWilderness && !routeLoading && ( +
+
+ + + + Wilderness (on foot) +
+
+ + + + Road/Trail +
+
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

+ Enter addresses, paste coordinates, or click the map +

+
+ )} +
+ ) +} diff --git a/src/store.js b/src/store.js index 8b98599..474163f 100644 --- a/src/store.js +++ b/src/store.js @@ -1,298 +1,311 @@ -import { create } from 'zustand' -import { requestOffroute, requestOptimizedRoute } from './api' - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: '', - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Unified Route State ── - // Single routing system - all routes go through /api/offroute - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - routeMode: "auto", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - // Map display callback - set by MapView - _updateRouteDisplay: null, - _clearRouteDisplay: null, - setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - - // Mode/boundary setters that trigger recalculation - setRouteMode: (mode) => { - set({ routeMode: mode }) - get().computeRoute() - }, - setBoundaryMode: (mode) => { - set({ boundaryMode: mode }) - get().computeRoute() - }, - - clearRoute: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ - routeStart: null, - routeEnd: null, - routeResult: null, - routeError: null, - stops: [], - route: null - }) - }, - - // ── UNIFIED ROUTING TRIGGER ── - // This is the SINGLE routing function for everything - computeRoute: async () => { - const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() - console.log('[TRACE-ROUTE] computeRoute called with:', { - startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, - endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name - }) - - // Need both endpoints to route - if (!routeStart || !routeEnd) return - - set({ routeLoading: true, routeError: null }) - - try { - const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) - - if (data.status === "ok" && data.route) { - set({ routeResult: data, routeError: null }) - if (_updateRouteDisplay) _updateRouteDisplay(data.route) - } else { - set({ routeError: data.message || data.error || "No route found", routeResult: null }) - } - } catch (e) { - set({ routeError: e.message, routeResult: null }) - } finally { - set({ routeLoading: false }) - } - }, - - // ── Stop list (master compatibility) ── - stops: [], - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - route: null, // Legacy Valhalla response (for 3+ stop optimization) - - addStop: (stop) => { - const { stops, routeMode, _updateRouteDisplay } = get() - if (stops.length >= 10) return false - const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] - set({ stops: newStops }) - - // Route logic depends on stop count - if (newStops.length === 1) { - // Single stop = origin, waiting for second - const origin = newStops[0] - set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) - } else if (newStops.length === 2) { - // Two stops = use offroute (handles on-road and wilderness) - const origin = newStops[0] - const dest = newStops[1] - set({ - routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, - routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } - }) - get().computeRoute() - } else { - // 3+ stops = use Valhalla multi-stop optimization - set({ routeLoading: true, routeError: null }) - const locations = newStops.map((s) => ({ lat: s.lat, lon: s.lon })) - const costing = routeMode === "auto" ? "auto" : routeMode === "foot" ? "pedestrian" : routeMode === "mtb" ? "bicycle" : "auto" - requestOptimizedRoute(locations, costing) - .then((data) => { - if (data.trip) { - set({ route: data.trip, routeError: null }) - // Update display via legacy route handler if available - if (_updateRouteDisplay && data.trip) { - // Multi-stop uses legacy route format, need to convert or use separate handler - } - } - }) - .catch((e) => set({ routeError: e.message })) - .finally(() => set({ routeLoading: false })) - } - - return true - }, - - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - if (newStops.length === 0) { - get().clearRoute() - } else if (newStops.length === 1) { - // Back to single stop - const origin = newStops[0] - set({ - routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, - routeEnd: null, - routeResult: null - }) - } - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) - }, - - setStops: (stops) => set({ stops }), - - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - // Master startDirections - enters directions mode with destination pre-filled - startDirections: (place) => { - console.log('[TRACE-STORE] startDirections received place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) - const { geoPermission, userLocation, clearRoute } = get() - clearRoute() - - // Set destination from the clicked place - const destination = { - lat: place.lat, - lon: place.lon, - name: place.name, - source: place.source, - matchCode: place.matchCode, - } - - // Set origin from GPS if available - let origin = null - if (geoPermission === 'granted' && userLocation) { - origin = { - lat: userLocation.lat, - lon: userLocation.lon, - name: 'Your location', - source: 'gps', - } - } - - set({ - routeEnd: destination, - routeStart: origin, - directionsMode: true, - activeDirectionsField: origin ? null : 'origin', // Focus origin if empty - selectedPlace: null, - }) - }, - - // Legacy route setter (for 3+ stop Valhalla optimization) - setRoute: (route) => set({ route, routeError: null }), - setRouteError: (err) => set({ routeError: err, route: null }), - - // ── Place detail ── - 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 - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) - pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), - setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), - setPickingRouteField: (field) => set({ pickingRouteField: field }), - clearPickingRouteField: () => set({ pickingRouteField: null }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.routeResult - const hasRoutePoints = !!s.routeStart || !!s.routeEnd - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasRoutePoints) return "ROUTING" - return "IDLE" - }) -} +import { create } from "zustand" +import { requestOffroute } from "./api" + +export const useStore = create((set, get) => ({ + // ── Search state ── + query: "", + results: [], + searchLoading: false, + abortController: null, + + setQuery: (query) => set({ query }), + setResults: (results) => set({ results }), + setSearchLoading: (loading) => set({ searchLoading: loading }), + setAbortController: (ctrl) => set({ abortController: ctrl }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: "prompt", // "prompt" | "granted" | "denied" + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // routeStart = origin (source of truth) + // routeEnd = destination (source of truth) + // stops[] = ONLY intermediate waypoints (not origin/destination) + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + stops: [], // Intermediate waypoints only: [{ id, lat, lon, name }, ...] + routeMode: "auto", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + // Map display callback - set by MapView + _updateRouteDisplay: null, + _clearRouteDisplay: null, + setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + + // Mode/boundary setters that trigger recalculation + setRouteMode: (mode) => { + set({ routeMode: mode }) + get().computeRoute() + }, + setBoundaryMode: (mode) => { + set({ boundaryMode: mode }) + get().computeRoute() + }, + + clearRoute: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ + routeStart: null, + routeEnd: null, + stops: [], + routeResult: null, + routeError: null, + }) + }, + + // ── INTERMEDIATE STOPS MANAGEMENT ── + // stops[] contains ONLY intermediate waypoints, not origin/destination + + addIntermediateStop: () => { + const { stops } = get() + if (stops.length >= 8) return false // Max 8 intermediate stops + const newStop = { + id: crypto.randomUUID(), + lat: null, + lon: null, + name: "", + } + set({ stops: [...stops, newStop] }) + return true + }, + + updateStop: (id, place) => { + const { stops } = get() + const newStops = stops.map((s) => + s.id === id ? { ...s, lat: place.lat, lon: place.lon, name: place.name } : s + ) + set({ stops: newStops }) + // Trigger route recalculation if all waypoints have coordinates + get().computeRoute() + }, + + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + // Recalculate route without this stop + get().computeRoute() + }, + + setStops: (stops) => set({ stops }), + + // ── UNIFIED ROUTING TRIGGER ── + // Handles both 2-point and multi-point routing + computeRoute: async () => { + const { routeStart, routeEnd, stops, routeMode, boundaryMode, _updateRouteDisplay } = get() + + // Need both endpoints to route + if (!routeStart || !routeEnd) return + + // Filter out incomplete stops (no coordinates yet) + const validStops = stops.filter((s) => s.lat != null && s.lon != null) + + // Build full waypoint list: [origin, ...intermediates, destination] + const waypoints = [ + routeStart, + ...validStops, + routeEnd, + ] + + console.log("[TRACE-ROUTE] computeRoute with waypoints:", waypoints.length, waypoints.map(w => w.name)) + + set({ routeLoading: true, routeError: null }) + + try { + if (waypoints.length === 2) { + // Simple 2-point routing + const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) + if (data.status === "ok" && data.route) { + set({ routeResult: data, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(data.route) + } else { + set({ routeError: data.message || data.error || "No route found", routeResult: null }) + } + } else { + // Multi-point routing: chain sequential 2-point routes and merge + const segments = [] + let totalDistanceKm = 0 + let totalEffortMinutes = 0 + let allFeatures = [] + + for (let i = 0; i < waypoints.length - 1; i++) { + const from = waypoints[i] + const to = waypoints[i + 1] + const segmentData = await requestOffroute(from, to, routeMode, boundaryMode) + + if (segmentData.status !== "ok" || !segmentData.route) { + throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint")) + } + + segments.push(segmentData) + + // Accumulate totals + if (segmentData.summary) { + totalDistanceKm += segmentData.summary.total_distance_km || 0 + totalEffortMinutes += segmentData.summary.total_effort_minutes || 0 + } + + // Collect features + if (segmentData.route?.features) { + allFeatures.push(...segmentData.route.features) + } + } + + // Build merged result + const mergedResult = { + status: "ok", + summary: { + total_distance_km: totalDistanceKm, + total_effort_minutes: totalEffortMinutes, + waypoint_count: waypoints.length, + }, + route: { + type: "FeatureCollection", + features: allFeatures, + }, + } + + set({ routeResult: mergedResult, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(mergedResult.route) + } + } catch (e) { + set({ routeError: e.message, routeResult: null }) + } finally { + set({ routeLoading: false }) + } + }, + + // ── Legacy compatibility ── + gpsOrigin: true, + pendingDestination: null, + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + // Master startDirections - enters directions mode with destination pre-filled + startDirections: (place) => { + console.log("[TRACE-STORE] startDirections received place:", { lat: place?.lat, lon: place?.lon, name: place?.name }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, + } + + let origin = null + if (geoPermission === "granted" && userLocation) { + origin = { + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + } + } + + set({ + routeEnd: destination, + routeStart: origin, + directionsMode: true, + activeDirectionsField: origin ? null : "origin", + selectedPlace: null, + }) + }, + + // ── Place detail ── + selectedPlace: null, + clickMarker: null, + + setSelectedPlace: (place) => set({ selectedPlace: place }), + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: "half", + panelOpen: true, + autocompleteOpen: false, + directionsMode: false, + activeDirectionsField: null, + pickingRouteField: null, + theme: "dark", + themeOverride: null, + viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map", + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem("navi-view-mode", mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? "origin" : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), + setPickingRouteField: (field) => set({ pickingRouteField: field }), + clearPickingRouteField: () => set({ pickingRouteField: null }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem("navi-theme-override", override) + } else { + localStorage.removeItem("navi-theme-override") + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: "routes", + editingContact: null, + pickingLocationFor: null, + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} From 0942b10b270652a0f850a6a9b61a33db3eed6c87 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:14:08 +0000 Subject: [PATCH 32/34] fix: swap button layout and add stop reorder buttons - Swap button now inline on origin row (not absolute positioned) - Swap button no longer overlaps intermediate stop controls - Added up/down chevron buttons on each intermediate stop row - Reordering stops triggers route recalculation - Destination row has spacer to align with origin row Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 123 ++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 38 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 2900e75..44c0f98 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,5 @@ import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" import { useStore } from "../store" import LocationInput from "./LocationInput" import ManeuverList from "./ManeuverList" @@ -40,6 +40,7 @@ export default function DirectionsPanel({ onClose }) { const addIntermediateStop = useStore((s) => s.addIntermediateStop) const updateStop = useStore((s) => s.updateStop) const removeStop = useStore((s) => s.removeStop) + const setStops = useStore((s) => s.setStops) // Auto-fill origin with GPS if available and origin is empty useEffect(() => { @@ -74,10 +75,29 @@ export default function DirectionsPanel({ onClose }) { } const handleAddStop = () => { - // Simply add a new empty intermediate stop addIntermediateStop() } + const handleMoveStopUp = (idx) => { + if (idx === 0) return + const newStops = [...stops] + const temp = newStops[idx] + newStops[idx] = newStops[idx - 1] + newStops[idx - 1] = temp + setStops(newStops) + computeRoute() + } + + const handleMoveStopDown = (idx) => { + if (idx >= stops.length - 1) return + const newStops = [...stops] + const temp = newStops[idx] + newStops[idx] = newStops[idx + 1] + newStops[idx + 1] = temp + setStops(newStops) + computeRoute() + } + // Check if route has wilderness segments const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 @@ -97,21 +117,37 @@ export default function DirectionsPanel({ onClose }) {
- {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - + {/* Origin/Destination inputs */} +
+ {/* Origin row with swap button on right */} +
+
+ +
+ {/* Swap button - only on origin row, swaps origin and destination */} + +
{/* Intermediate stops - rendered between origin and destination */} {stops.map((stop, idx) => ( -
+
+ {/* Reorder buttons */} +
+ + +
+ {/* Remove button */}
))} - {/* Swap button - positioned between origin and destination (or after stops) */} - - - {/* Destination */} - + {/* Destination row */} +
+
+ +
+ {/* Spacer to align with origin row swap button */} +
+
{/* Add stop button - only show when route exists */} {routeStart && routeEnd && stops.length < 8 && ( From bc453ff375a79a57795ce4496008e13d3c4440f9 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:40:12 +0000 Subject: [PATCH 33/34] feat: drag-and-drop stop reordering and fix radial add-stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addIntermediateStop() now accepts optional place parameter - Radial menu add-stop wedge uses addIntermediateStop with coordinates - Replaced up/down chevron buttons with @dnd-kit drag-and-drop - All rows (origin, stops, destination) can be reordered by dragging - GripVertical drag handle on left of each row - On drag end: first item → origin, last → destination, middle → stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 320 +++++++++++++++++------------ src/components/MapView.jsx | 18 +- src/store.js | 11 +- 3 files changed, 208 insertions(+), 141 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 44c0f98..a01f1c9 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,8 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" +import { useEffect, useMemo } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react" +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { useStore } from "../store" import LocationInput from "./LocationInput" import ManeuverList from "./ManeuverList" @@ -18,6 +21,40 @@ const BOUNDARY_MODES = [ { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, ] +// Sortable row component +function SortableRow({ id, children }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 1, + } + + return ( +
+ {/* Drag handle */} + + {children} +
+ ) +} + export default function DirectionsPanel({ onClose }) { const routeStart = useStore((s) => s.routeStart) const routeEnd = useStore((s) => s.routeEnd) @@ -42,6 +79,36 @@ export default function DirectionsPanel({ onClose }) { const removeStop = useStore((s) => s.removeStop) const setStops = useStore((s) => s.setStops) + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // Build unified list for drag-and-drop: origin + stops + destination + // Each item has: { id, type, data } + const unifiedList = useMemo(() => { + const items = [] + if (routeStart) { + items.push({ id: "origin", type: "origin", data: routeStart }) + } + stops.forEach((stop) => { + items.push({ id: stop.id, type: "stop", data: stop }) + }) + if (routeEnd) { + items.push({ id: "destination", type: "destination", data: routeEnd }) + } + return items + }, [routeStart, stops, routeEnd]) + + const itemIds = useMemo(() => unifiedList.map((item) => item.id), [unifiedList]) + // Auto-fill origin with GPS if available and origin is empty useEffect(() => { if (!routeStart && geoPermission === "granted" && userLocation) { @@ -61,13 +128,6 @@ export default function DirectionsPanel({ onClose }) { } }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - const handleClose = () => { clearRoute() setDirectionsMode(false) @@ -78,24 +138,56 @@ export default function DirectionsPanel({ onClose }) { addIntermediateStop() } - const handleMoveStopUp = (idx) => { - if (idx === 0) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx - 1] - newStops[idx - 1] = temp - setStops(newStops) - computeRoute() - } + // Handle drag end - reorder the unified list + const handleDragEnd = (event) => { + const { active, over } = event + if (!over || active.id === over.id) return - const handleMoveStopDown = (idx) => { - if (idx >= stops.length - 1) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx + 1] - newStops[idx + 1] = temp + const oldIndex = unifiedList.findIndex((item) => item.id === active.id) + const newIndex = unifiedList.findIndex((item) => item.id === over.id) + + if (oldIndex === -1 || newIndex === -1) return + + // Reorder the unified list + const reordered = arrayMove(unifiedList, oldIndex, newIndex) + + // Extract new origin, stops, and destination from reordered list + // First item becomes origin, last becomes destination, middle are stops + if (reordered.length === 0) return + + const newOriginItem = reordered[0] + const newDestItem = reordered.length > 1 ? reordered[reordered.length - 1] : null + const newStopItems = reordered.length > 2 ? reordered.slice(1, -1) : [] + + // Convert items to proper format + const newOrigin = newOriginItem.data ? { + lat: newOriginItem.data.lat, + lon: newOriginItem.data.lon, + name: newOriginItem.data.name, + source: newOriginItem.data.source, + } : null + + const newDest = newDestItem?.data ? { + lat: newDestItem.data.lat, + lon: newDestItem.data.lon, + name: newDestItem.data.name, + source: newDestItem.data.source, + } : null + + const newStops = newStopItems.map((item) => ({ + id: item.id === "origin" || item.id === "destination" ? crypto.randomUUID() : item.id, + lat: item.data?.lat ?? null, + lon: item.data?.lon ?? null, + name: item.data?.name ?? "", + })) + + // Update state + setRouteStart(newOrigin) + setRouteEnd(newDest) setStops(newStops) - computeRoute() + + // Trigger route recalculation + setTimeout(() => computeRoute(), 0) } // Check if route has wilderness segments @@ -117,113 +209,87 @@ export default function DirectionsPanel({ onClose }) {
- {/* Origin/Destination inputs */} -
- {/* Origin row with swap button on right */} -
-
- -
- {/* Swap button - only on origin row, swaps origin and destination */} - -
+ {/* Drag-and-drop location list */} + + +
+ {unifiedList.map((item, idx) => ( + +
+ {item.type === "origin" && ( + + )} + {item.type === "destination" && ( + + )} + {item.type === "stop" && ( + { + if (place) { + updateStop(item.id, place) + } + }} + placeholder={`Stop ${idx}`} + icon="stop" + fieldId={`stop-${item.id}`} + autoFocus={item.data.lat == null} + /> + )} +
+ {/* Remove button for intermediate stops only */} + {item.type === "stop" && ( + + )} + {/* Spacer for origin/destination to align with stops that have remove button */} + {item.type !== "stop" && ( +
+ )} + + ))} - {/* Intermediate stops - rendered between origin and destination */} - {stops.map((stop, idx) => ( -
-
- { - if (place) { - updateStop(stop.id, place) - } + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( +
- {/* Reorder buttons */} -
- - -
- {/* Remove button */} - + )}
- ))} - - {/* Destination row */} -
-
- -
- {/* Spacer to align with origin row swap button */} -
-
- - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 8 && ( - - )} -
+ + {/* Travel mode selector */}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0f13fd1..6f6d26b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1721,22 +1721,20 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop } = useStore.getState() + const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") + const success = addIntermediateStop(place) + if (success) { + // If we have both origin and destination, recalculate route + if (routeStart && routeEnd) { + computeRoute() } + } else { + toast("Maximum 8 intermediate stops reached") } }, }, diff --git a/src/store.js b/src/store.js index 474163f..069be9f 100644 --- a/src/store.js +++ b/src/store.js @@ -73,14 +73,17 @@ export const useStore = create((set, get) => ({ // ── INTERMEDIATE STOPS MANAGEMENT ── // stops[] contains ONLY intermediate waypoints, not origin/destination - addIntermediateStop: () => { + // Add intermediate stop - can be called with or without place + // With place: creates pre-filled stop (from radial menu) + // Without place: creates empty placeholder (from Add Stop button) + addIntermediateStop: (place) => { const { stops } = get() if (stops.length >= 8) return false // Max 8 intermediate stops const newStop = { id: crypto.randomUUID(), - lat: null, - lon: null, - name: "", + lat: place?.lat ?? null, + lon: place?.lon ?? null, + name: place?.name ?? "", } set({ stops: [...stops, newStop] }) return true From 41ea028d48349dbf8c4ab8eb41700ca4c8fef042 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 20 May 2026 17:03:35 +0000 Subject: [PATCH 34/34] public-lands: filter "Unknown " PAD-US label artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PAD-US v4.0 ships many small sub-polygons whose unit_nm is literally "Unknown " (e.g. "Unknown Idaho Department of Lands"). The PMTiles build maps unit_nm -> feature name, so the label layer rendered these spurious labels next to/over the legitimate umbrella label. Filter them out at the PUBLIC_LANDS_LABEL (symbol) layer only via a name prefix test. Fill and line layers are untouched — the polygon geometry still renders, just without the bogus label. Evidence: /api/landclass at (42.619853, -114.462106) returns a 12-acre "Unknown Idaho Department of Lands" overlapping the 1.98M-acre "Idaho Department of Lands" umbrella. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/components/MapView.jsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 6f6d26b..e9131b3 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -528,6 +528,8 @@ function addPublicLands(map, themeId) { type: 'symbol', source: PUBLIC_LANDS_SOURCE, 'source-layer': 'public_lands', + // Exclude PAD-US sub-polygons whose unit_nm is "Unknown " — USGS source artifact, not real label. + filter: ['!', ['==', ['slice', ['coalesce', ['get', 'name'], ''], 0, 8], 'Unknown ']], minzoom: 10, layout: { 'text-field': ['get', 'name'],