From f3ec18bdf538842956dcf90000d54e14621f95b6 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 7 May 2026 02:54:25 +0000 Subject: [PATCH 01/17] =?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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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 08/17] 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 09/17] 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 10/17] 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 11/17] 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 12/17] 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 13/17] 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 14/17] 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 15/17] 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 16/17] 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 17/17] 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'],