diff --git a/deploy.sh b/deploy.sh
index 4e9439e..a8f7e3c 100755
--- a/deploy.sh
+++ b/deploy.sh
@@ -2,5 +2,5 @@
set -euo pipefail
cd "$(dirname "$0")"
npm run build
-rsync -av --delete dist/ /mnt/nav/frontend/
-echo "Deployed to /mnt/nav/frontend/"
+rsync -av --delete dist/ zvx@192.168.1.130:/mnt/nav/frontend/
+echo "Deployed to recon-vm:/mnt/nav/frontend/"
diff --git a/package-lock.json b/package-lock.json
index 2b0f9e3..d470af6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,18 +1,22 @@
{
- "name": "navi-tmp",
+ "name": "navi",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "navi-tmp",
+ "name": "navi",
"version": "0.0.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"maplibre-gl": "^5.23.0",
"pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0",
"react": "^19.2.5",
- "react-dom": "^19.2.5"
+ "react-dom": "^19.2.5",
+ "zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -283,6 +287,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -1294,7 +1351,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -1611,7 +1668,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -3094,9 +3151,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "dev": true,
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -3298,6 +3353,35 @@
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
+ "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index 5d8629c..8b2b551 100644
--- a/package.json
+++ b/package.json
@@ -10,11 +10,15 @@
"preview": "vite preview"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"maplibre-gl": "^5.23.0",
"pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0",
"react": "^19.2.5",
- "react-dom": "^19.2.5"
+ "react-dom": "^19.2.5",
+ "zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
diff --git a/src/App.jsx b/src/App.jsx
index e710478..ba7cadd 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,42 +1,100 @@
-import { useEffect, useRef } from 'react'
-import maplibregl from 'maplibre-gl'
-import 'maplibre-gl/dist/maplibre-gl.css'
-import { Protocol } from 'pmtiles'
-import { layers, namedTheme } from 'protomaps-themes-base'
+import { useEffect, useRef, useCallback } from 'react'
+import { useStore } from './store'
+import { requestRoute } from './api'
+import { decodePolyline } from './utils/decode'
+import MapView from './components/MapView'
+import Panel from './components/Panel'
export default function App() {
- const mapContainer = useRef(null)
+ const mapViewRef = useRef(null)
+ const routeDebounceRef = useRef(null)
- useEffect(() => {
- const protocol = new Protocol()
- maplibregl.addProtocol('pmtiles', protocol.tile)
+ const stops = useStore((s) => s.stops)
+ const mode = useStore((s) => s.mode)
+ const route = useStore((s) => s.route)
+ 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 setUserLocation = useStore((s) => s.setUserLocation)
+ const setGeoPermission = useStore((s) => s.setGeoPermission)
- const map = new maplibregl.Map({
- container: mapContainer.current,
- style: {
- version: 8,
- glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
- sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/dark',
- sources: {
- protomaps: {
- type: 'vector',
- url: 'pmtiles:///tiles/idaho.pmtiles',
- attribution: 'Protomaps | OSM',
- },
- },
- layers: layers('protomaps', namedTheme('dark'), { lang: 'en' }),
+ // Request geolocation on first route action (2+ stops)
+ const requestGeo = useCallback(() => {
+ const { geoPermission } = useStore.getState()
+ if (geoPermission !== 'prompt') return
+ if (!navigator.geolocation) {
+ setGeoPermission('denied')
+ return
+ }
+ navigator.geolocation.getCurrentPosition(
+ (pos) => {
+ setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude })
+ setGeoPermission('granted')
},
- center: [-114.5, 44.0],
- zoom: 6,
- })
+ () => setGeoPermission('denied'),
+ { enableHighAccuracy: true, timeout: 10000 }
+ )
+ }, [setUserLocation, setGeoPermission])
- map.addControl(new maplibregl.NavigationControl(), 'top-right')
+ // Fetch route when stops or mode change (debounced 500ms)
+ useEffect(() => {
+ if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
+
+ if (stops.length < 2) {
+ clearRoute()
+ return
+ }
+
+ routeDebounceRef.current = setTimeout(async () => {
+ // Try to get geolocation for potential use
+ requestGeo()
+
+ const locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
+ setRouteLoading(true)
+
+ try {
+ const data = await requestRoute(locations, 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 () => {
- maplibregl.removeProtocol('pmtiles')
- map.remove()
+ if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
}
- }, [])
+ }, [stops, mode, clearRoute, setRoute, setRouteLoading, setRouteError, requestGeo])
- return
+ // Handle maneuver click — fly to that point on the map
+ 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]
+ )
+
+ return (
+
+ )
}
diff --git a/src/api.js b/src/api.js
new file mode 100644
index 0000000..7da9c0a
--- /dev/null
+++ b/src/api.js
@@ -0,0 +1,89 @@
+const GEOCODE_URL = '/api/geocode'
+const VALHALLA_URL = '/valhalla/route'
+const VALHALLA_OPTIMIZED_URL = '/valhalla/optimized_route'
+
+/**
+ * Search geocode API with abort support.
+ * @param {string} query
+ * @param {number} limit
+ * @param {AbortSignal} signal
+ * @returns {Promise<{query, results, count}>}
+ */
+export async function searchGeocode(query, limit = 6, signal) {
+ const params = new URLSearchParams({ q: query, limit: String(limit) })
+ const resp = await fetch(`${GEOCODE_URL}?${params}`, { signal, timeout: 5000 })
+ if (!resp.ok) throw new Error(`Geocode error: ${resp.status}`)
+ return resp.json()
+}
+
+/**
+ * Request a route from Valhalla.
+ * @param {Array<{lat, lon}>} locations
+ * @param {string} costing - 'auto' | 'pedestrian' | 'bicycle'
+ * @returns {Promise