feat: search, multi-stop routing, and route display

Full navigation UI with:
- Search bar with 150ms debounced autocomplete from /api/geocode
- Keyboard navigation (arrow keys, Enter, Escape)
- Exact match badge for verified address results
- Multi-stop list with drag-to-reorder (dnd-kit)
- 10-stop cap with disabled state
- Mode selector (drive/walk/bike)
- Valhalla route display with per-leg color polyline
- Maneuver list with instructions, distance, time remaining
- Click maneuver to fly map to that point
- Optimize stops button (3+ stops, uses /optimized_route)
- Responsive: side panel (desktop ≥768px), bottom sheet (mobile)
- Stop pins: green origin, red destination, blue intermediate
- Pin popup with remove button
- Geolocation permission requested on first route, not on load
- Error handling for unroutable pairs
- nginx proxy for /api/ and /valhalla/ endpoints

Dependencies added: zustand, @dnd-kit/core, @dnd-kit/sortable,
@dnd-kit/utilities

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-04-20 16:50:53 +00:00
commit e7b08a7dc9
16 changed files with 1364 additions and 44 deletions

View file

@ -2,5 +2,5 @@
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
npm run build npm run build
rsync -av --delete dist/ /mnt/nav/frontend/ rsync -av --delete dist/ zvx@192.168.1.130:/mnt/nav/frontend/
echo "Deployed to /mnt/nav/frontend/" echo "Deployed to recon-vm:/mnt/nav/frontend/"

100
package-lock.json generated
View file

@ -1,18 +1,22 @@
{ {
"name": "navi-tmp", "name": "navi",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "navi-tmp", "name": "navi",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"maplibre-gl": "^5.23.0", "maplibre-gl": "^5.23.0",
"pmtiles": "^4.4.1", "pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0", "protomaps-themes-base": "^4.5.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@ -283,6 +287,59 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/core": {
"version": "1.9.2", "version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@ -1294,7 +1351,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@ -1611,7 +1668,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -3094,9 +3151,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
@ -3298,6 +3353,35 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "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
}
}
} }
} }
} }

View file

@ -10,11 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"maplibre-gl": "^5.23.0", "maplibre-gl": "^5.23.0",
"pmtiles": "^4.4.1", "pmtiles": "^4.4.1",
"protomaps-themes-base": "^4.5.0", "protomaps-themes-base": "^4.5.0",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View file

@ -1,42 +1,100 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useCallback } from 'react'
import maplibregl from 'maplibre-gl' import { useStore } from './store'
import 'maplibre-gl/dist/maplibre-gl.css' import { requestRoute } from './api'
import { Protocol } from 'pmtiles' import { decodePolyline } from './utils/decode'
import { layers, namedTheme } from 'protomaps-themes-base' import MapView from './components/MapView'
import Panel from './components/Panel'
export default function App() { export default function App() {
const mapContainer = useRef(null) const mapViewRef = useRef(null)
const routeDebounceRef = useRef(null)
useEffect(() => { const stops = useStore((s) => s.stops)
const protocol = new Protocol() const mode = useStore((s) => s.mode)
maplibregl.addProtocol('pmtiles', protocol.tile) 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({ // Request geolocation on first route action (2+ stops)
container: mapContainer.current, const requestGeo = useCallback(() => {
style: { const { geoPermission } = useStore.getState()
version: 8, if (geoPermission !== 'prompt') return
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf', if (!navigator.geolocation) {
sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/dark', setGeoPermission('denied')
sources: { return
protomaps: { }
type: 'vector', navigator.geolocation.getCurrentPosition(
url: 'pmtiles:///tiles/idaho.pmtiles', (pos) => {
attribution: '<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>', setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude })
}, setGeoPermission('granted')
},
layers: layers('protomaps', namedTheme('dark'), { lang: 'en' }),
}, },
center: [-114.5, 44.0], () => setGeoPermission('denied'),
zoom: 6, { 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 () => { return () => {
maplibregl.removeProtocol('pmtiles') if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current)
map.remove()
} }
}, []) }, [stops, mode, clearRoute, setRoute, setRouteLoading, setRouteError, requestGeo])
return <div ref={mapContainer} className="w-screen h-screen" /> // 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 (
<div className="relative w-screen h-screen overflow-hidden">
<MapView ref={mapViewRef} />
<Panel onManeuverClick={handleManeuverClick} />
</div>
)
} }

89
src/api.js Normal file
View file

@ -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<object>} Valhalla trip response
*/
export async function requestRoute(locations, costing = 'auto') {
const body = {
locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })),
costing,
units: 'miles',
directions_options: { units: 'miles' },
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
const resp = await fetch(VALHALLA_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.error || errBody.status_message || `Route error: ${resp.status}`)
}
return resp.json()
} finally {
clearTimeout(timeout)
}
}
/**
* Request an optimized route from Valhalla.
* @param {Array<{lat, lon}>} locations
* @param {string} costing
* @returns {Promise<object>} Valhalla optimized trip response
*/
export async function requestOptimizedRoute(locations, costing = 'auto') {
const body = {
locations: locations.map((l) => ({ lat: l.lat, lon: l.lon })),
costing,
units: 'miles',
directions_options: { units: 'miles' },
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
const resp = await fetch(VALHALLA_OPTIMIZED_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.error || errBody.status_message || `Optimize error: ${resp.status}`)
}
return resp.json()
} finally {
clearTimeout(timeout)
}
}

View file

@ -0,0 +1,130 @@
import { useStore } from '../store'
/** Format seconds into human-friendly string */
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`
}
/** Format distance in miles */
function formatDist(miles) {
if (miles < 0.1) return `${Math.round(miles * 5280)} ft`
return `${miles.toFixed(1)} mi`
}
/** Get a maneuver type icon */
function maneuverIcon(type) {
switch (type) {
case 0: return '→' // straight
case 1: return '↗' // slight right
case 2: return '→' // right
case 3: return '↘' // sharp right
case 4: return '↩' // u-turn right
case 5: return '↩' // u-turn left
case 6: return '↙' // sharp left
case 7: return '←' // left
case 8: return '↖' // slight left
case 9: return '●' // depart
case 10: return '●' // arrive (straight)
case 11: return '●' // arrive (right)
case 12: return '●' // arrive (left)
case 15: return '◎' // roundabout enter
case 16: return '◎' // roundabout exit
case 24: return '▲' // merge
case 25: return '⤴' // on ramp
case 26: return '⤵' // off ramp
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 (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
<span className="ml-2 text-sm text-gray-400">Calculating route...</span>
</div>
)
}
if (routeError) {
return (
<div className="px-3 py-2 bg-red-900/30 border border-red-700 rounded text-sm text-red-300">
{routeError}
</div>
)
}
if (!route || !route.legs) return null
// Compute total summary
const totalTime = route.summary?.time || 0
const totalDist = route.summary?.length || 0
// Flatten all maneuvers with cumulative time remaining
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 (
<div className="flex flex-col">
{/* Route summary */}
<div className="flex items-center justify-between px-3 py-2 bg-gray-800/60 rounded mb-2">
<span className="text-sm font-medium text-white">
{formatDist(totalDist)}
</span>
<span className="text-sm text-gray-300">
{formatTime(totalTime)}
</span>
</div>
{/* Maneuver steps */}
<div className="flex flex-col divide-y divide-gray-700 max-h-[50vh] overflow-y-auto">
{allManeuvers.map((man, i) => (
<button
key={i}
onClick={() => {
if (man.begin_shape_index != null && onManeuverClick) {
onManeuverClick(man)
}
}}
className="flex items-start gap-2 px-2 py-2 text-left hover:bg-gray-800/60 transition-colors"
>
<span className="text-base w-6 text-center shrink-0 text-cyan-400">
{maneuverIcon(man.type)}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200 leading-tight">
{man.instruction || man.verbal_pre_transition_instruction || 'Continue'}
</p>
<p className="text-[11px] text-gray-500 mt-0.5">
{formatDist(man.length || 0)}
{man.timeRemaining > 0 && (
<span className="ml-2">{formatTime(man.timeRemaining)} remaining</span>
)}
</p>
</div>
</button>
))}
</div>
</div>
)
}

256
src/components/MapView.jsx Normal file
View file

@ -0,0 +1,256 @@
import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } 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 { useStore } from '../store'
import { decodePolyline } from '../utils/decode'
const ROUTE_SOURCE = 'route-source'
const ROUTE_LAYER_PREFIX = 'route-layer-'
const STOPS_SOURCE = 'stops-source'
const STOPS_LAYER = 'stops-layer'
const MapView = forwardRef(function MapView({ onMapClick }, ref) {
const mapRef = useRef(null)
const mapInstance = useRef(null)
const markersRef = useRef([])
const popupRef = useRef(null)
const stops = useStore((s) => s.stops)
const route = useStore((s) => s.route)
const setSheetState = useStore((s) => s.setSheetState)
// Expose map methods to parent
useImperativeHandle(ref, () => ({
flyTo(lat, lon, zoom = 14) {
mapInstance.current?.flyTo({ center: [lon, lat], zoom })
},
getMap() {
return mapInstance.current
},
}))
// Initialize map
useEffect(() => {
const protocol = new Protocol()
maplibregl.addProtocol('pmtiles', protocol.tile)
const map = new maplibregl.Map({
container: mapRef.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:
'<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>',
},
},
layers: layers('protomaps', namedTheme('dark'), { lang: 'en' }),
},
center: [-114.5, 44.0],
zoom: 6,
})
map.addControl(new maplibregl.NavigationControl(), 'top-right')
map.addControl(
new maplibregl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: false,
}),
'top-right'
)
map.on('click', () => {
// Mobile: collapse sheet when map is tapped
if (window.innerWidth < 768) {
setSheetState('collapsed')
}
})
// Add empty route source on load
map.on('load', () => {
map.addSource(ROUTE_SOURCE, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
})
})
mapInstance.current = map
return () => {
maplibregl.removeProtocol('pmtiles')
map.remove()
}
}, [setSheetState])
// Update route polyline when route changes
useEffect(() => {
const map = mapInstance.current
if (!map || !map.isStyleLoaded()) {
// Wait for style to load
const handler = () => updateRoute(map)
map?.on('load', handler)
return () => map?.off('load', handler)
}
updateRoute(map)
}, [route])
function updateRoute(map) {
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 (!route || !route.legs) {
if (map.getSource(ROUTE_SOURCE)) {
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
}
return
}
// Build GeoJSON features from route legs
const features = []
const legColors = ['#22d3ee', '#06b6d4', '#0891b2', '#0e7490', '#155e75']
for (let i = 0; i < route.legs.length; i++) {
const leg = route.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 },
})
}
// Add route layers (one per leg for color variation)
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': legColors[i % legColors.length],
'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])
)
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, 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
}
stops.forEach((stop, i) => {
let color = '#3b82f6' // blue
if (i === 0) color = '#22c55e' // green
else if (i === stops.length - 1 && stops.length > 1) color = '#ef4444' // red
const label = String.fromCharCode(65 + Math.min(i, 25))
const el = document.createElement('div')
el.className = 'navi-marker'
el.style.cssText = `
width: 28px; height: 28px; border-radius: 50%;
background: ${color}; border: 2px solid white;
display: flex; align-items: center; justify-content: center;
color: white; font-size: 12px; font-weight: bold;
cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.4);
`
el.textContent = label
el.addEventListener('click', (e) => {
e.stopPropagation()
if (popupRef.current) popupRef.current.remove()
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
.setLngLat([stop.lon, stop.lat])
.setHTML(
`<div style="color:#fff;font-size:12px;max-width:200px">
<strong>${stop.name}</strong>
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:#dc2626;border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
</div>`
)
.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: 340, right: 60 } })
}
}
}, [stops, route])
return <div ref={mapRef} className="w-full h-full" />
})
export default MapView

View file

@ -0,0 +1,33 @@
import { useStore } from '../store'
const MODES = [
{ id: 'auto', label: 'Drive', icon: '🚗' },
{ id: 'pedestrian', label: 'Walk', icon: '🚶' },
{ id: 'bicycle', label: 'Bike', icon: '🚴' },
]
export default function ModeSelector() {
const mode = useStore((s) => s.mode)
const setMode = useStore((s) => s.setMode)
return (
<div className="flex gap-1" role="radiogroup" aria-label="Travel mode">
{MODES.map((m) => (
<button
key={m.id}
role="radio"
aria-checked={mode === m.id}
onClick={() => setMode(m.id)}
className={`flex-1 py-1.5 px-2 rounded text-xs font-medium transition-colors ${
mode === m.id
? 'bg-cyan-600 text-white'
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
}`}
>
<span className="mr-1">{m.icon}</span>
{m.label}
</button>
))}
</div>
)
}

188
src/components/Panel.jsx Normal file
View file

@ -0,0 +1,188 @@
import { useRef, useCallback, useEffect, useState } from 'react'
import { useStore } from '../store'
import SearchBar from './SearchBar'
import StopList from './StopList'
import ModeSelector from './ModeSelector'
import ManeuverList from './ManeuverList'
import { requestOptimizedRoute } from '../api'
export default function Panel({ onManeuverClick }) {
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 [isMobile, setIsMobile] = useState(false)
const [optimizing, setOptimizing] = useState(false)
const sheetRef = useRef(null)
const dragStartY = useRef(0)
const dragStartState = useRef('half')
// Responsive detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
// Optimize stops
const handleOptimize = useCallback(async () => {
if (stops.length < 3 || optimizing) return
setOptimizing(true)
try {
const locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
const data = await requestOptimizedRoute(locations, mode)
if (data.trip) {
// Reorder stops based on optimized waypoint order
const wpOrder = data.trip.locations
if (wpOrder && wpOrder.length === stops.length) {
// Match optimized locations back to original stops by proximity
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
})
// Deduplicate (in case of matching issues)
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, 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) {
// Swipe up
if (dragStartState.current === 'collapsed') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('full')
} else {
// Swipe down
if (dragStartState.current === 'full') setSheetState('half')
else if (dragStartState.current === 'half') setSheetState('collapsed')
}
}, [setSheetState])
const showOptimize = stops.length >= 3
const content = (
<>
<SearchBar />
{/* Stop list */}
<div className="mt-3">
<StopList />
</div>
{/* Mode selector + optimize */}
{stops.length >= 1 && (
<div className="mt-3 flex flex-col gap-2">
<ModeSelector />
{showOptimize && (
<button
onClick={handleOptimize}
disabled={optimizing || routeLoading}
className="w-full py-1.5 px-3 text-xs font-medium bg-yellow-700 hover:bg-yellow-600 text-white rounded disabled:opacity-50 transition-colors"
>
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
</button>
)}
</div>
)}
{/* Maneuver list */}
{(route || routeLoading || routeError) && (
<div className="mt-3">
<ManeuverList onManeuverClick={onManeuverClick} />
</div>
)}
{/* TODO: Recents / saved places placeholder */}
{stops.length === 0 && !route && (
<div className="mt-6 text-center text-gray-600 text-xs">
{/* TODO: Wire recents + favorites in a later phase */}
<p>Recent places will appear here</p>
</div>
)}
</>
)
// Desktop: side panel
if (!isMobile) {
return (
<div className="absolute top-0 left-0 z-10 w-80 h-full bg-gray-900/95 backdrop-blur-sm border-r border-gray-700 overflow-y-auto p-4 flex flex-col">
<h1 className="text-lg font-semibold text-cyan-400 mb-3">Navi</h1>
{content}
</div>
)
}
// Mobile: bottom sheet
const sheetHeights = {
collapsed: 'h-12',
half: 'h-[45vh]',
full: 'h-[85vh]',
}
return (
<div
ref={sheetRef}
className={`absolute bottom-0 left-0 right-0 z-10 bg-gray-900/95 backdrop-blur-sm border-t border-gray-700 rounded-t-2xl transition-all duration-300 ${sheetHeights[sheetState]}`}
>
{/* Drag handle */}
<div
className="flex justify-center py-2 cursor-grab"
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onClick={() => {
if (sheetState === 'collapsed') setSheetState('half')
else if (sheetState === 'half') setSheetState('full')
else setSheetState('half')
}}
>
<div className="w-10 h-1 bg-gray-600 rounded-full" />
</div>
{sheetState !== 'collapsed' && (
<div className="px-4 pb-4 overflow-y-auto h-[calc(100%-2rem)]">
{content}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,183 @@
import { useRef, useEffect, useCallback, useState } from 'react'
import { useStore } from '../store'
import { searchGeocode } from '../api'
export default function SearchBar() {
const inputRef = useRef(null)
const [activeIndex, setActiveIndex] = useState(-1)
const debounceRef = useRef(null)
const query = useStore((s) => s.query)
const results = useStore((s) => s.results)
const searchLoading = useStore((s) => s.searchLoading)
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
const stops = useStore((s) => s.stops)
const setQuery = useStore((s) => s.setQuery)
const setResults = useStore((s) => s.setResults)
const setSearchLoading = useStore((s) => s.setSearchLoading)
const setAbortController = useStore((s) => s.setAbortController)
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
const addStop = useStore((s) => s.addStop)
// Focus on mount
useEffect(() => {
inputRef.current?.focus()
}, [])
const doSearch = useCallback(
async (q) => {
// Abort previous
const prev = useStore.getState().abortController
if (prev) prev.abort()
if (!q.trim()) {
setResults([])
setAutocompleteOpen(false)
setSearchLoading(false)
return
}
const ctrl = new AbortController()
setAbortController(ctrl)
setSearchLoading(true)
try {
const data = await searchGeocode(q.trim(), 6, ctrl.signal)
setResults(data.results || [])
setAutocompleteOpen(data.results?.length > 0)
setActiveIndex(-1)
} catch (e) {
if (e.name !== 'AbortError') {
setResults([])
setAutocompleteOpen(false)
}
} finally {
setSearchLoading(false)
}
},
[setResults, setAutocompleteOpen, setSearchLoading, setAbortController]
)
const handleChange = (e) => {
const val = e.target.value
setQuery(val)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => doSearch(val), 150)
}
const selectResult = (result) => {
addStop({
lat: result.lat,
lon: result.lon,
name: result.name,
source: result.source,
matchCode: result.match_code,
})
setQuery('')
setResults([])
setAutocompleteOpen(false)
setActiveIndex(-1)
inputRef.current?.focus()
}
const handleKeyDown = (e) => {
if (!autocompleteOpen || results.length === 0) {
if (e.key === 'Escape') {
setAutocompleteOpen(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()
setAutocompleteOpen(false)
setActiveIndex(-1)
break
}
}
const atCap = stops.length >= 10
return (
<div className="relative">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
ref={inputRef}
type="text"
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
placeholder={atCap ? 'Max 10 stops reached' : 'Search for a place...'}
disabled={atCap}
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-cyan-400 focus:ring-1 focus:ring-cyan-400 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
aria-label="Search places"
aria-expanded={autocompleteOpen}
aria-autocomplete="list"
role="combobox"
/>
{searchLoading && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<div className="w-4 h-4 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
</div>
{/* Autocomplete dropdown */}
{autocompleteOpen && results.length > 0 && (
<ul
className="absolute z-50 mt-1 w-full bg-gray-800 border border-gray-600 rounded-lg shadow-lg overflow-hidden max-h-72 overflow-y-auto"
role="listbox"
>
{results.map((r, i) => (
<li
key={`${r.lat}-${r.lon}-${i}`}
role="option"
aria-selected={i === activeIndex}
className={`px-3 py-2 cursor-pointer text-sm border-b border-gray-700 last:border-b-0 ${
i === activeIndex
? 'bg-gray-700 text-white'
: 'text-gray-200 hover:bg-gray-700'
}`}
onClick={() => selectResult(r)}
onMouseEnter={() => setActiveIndex(i)}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate flex-1">{r.name}</span>
<span className="flex items-center gap-1 shrink-0">
{r.match_code?.housenumber === 'matched' && (
<span className="text-[10px] px-1.5 py-0.5 bg-green-800 text-green-200 rounded font-medium">
exact match
</span>
)}
<span className="text-[10px] text-gray-500">{r.source}</span>
</span>
</div>
<div className="text-[11px] text-gray-400 mt-0.5">
{r.type} &middot; {r.confidence}
</div>
</li>
))}
</ul>
)}
</div>
)
}

View file

@ -0,0 +1,76 @@
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '../store'
export default function StopItem({ stop, index, total }) {
const removeStop = useStore((s) => s.removeStop)
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: stop.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
// Pin color logic
let pinColor = 'bg-blue-500' // intermediate
let pinLabel = String(index + 1)
if (index === 0) {
pinColor = 'bg-green-500'
pinLabel = 'A'
} else if (index === total - 1 && total > 1) {
pinColor = 'bg-red-500'
pinLabel = String.fromCharCode(65 + Math.min(index, 25)) // A-Z
} else {
pinLabel = String.fromCharCode(65 + Math.min(index, 25))
}
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 py-1.5 px-2 bg-gray-800/60 rounded border border-gray-700 group"
>
{/* Drag handle */}
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-gray-500 hover:text-gray-300 touch-none"
aria-label="Drag to reorder"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<circle cx="4" cy="2" r="1" />
<circle cx="8" cy="2" r="1" />
<circle cx="4" cy="6" r="1" />
<circle cx="8" cy="6" r="1" />
<circle cx="4" cy="10" r="1" />
<circle cx="8" cy="10" r="1" />
</svg>
</button>
{/* Pin indicator */}
<span
className={`${pinColor} text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center shrink-0`}
>
{pinLabel}
</span>
{/* Stop name */}
<span className="flex-1 text-sm text-gray-200 truncate">{stop.name}</span>
{/* Remove button */}
<button
onClick={() => removeStop(stop.id)}
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 transition-opacity"
aria-label={`Remove stop ${stop.name}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
)
}

View file

@ -0,0 +1,58 @@
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { useStore } from '../store'
import StopItem from './StopItem'
export default function StopList() {
const stops = useStore((s) => s.stops)
const reorderStops = useStore((s) => s.reorderStops)
const geoPermission = useStore((s) => s.geoPermission)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
function handleDragEnd(event) {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = stops.findIndex((s) => s.id === active.id)
const newIndex = stops.findIndex((s) => s.id === over.id)
reorderStops(arrayMove(stops, oldIndex, newIndex))
}
if (stops.length === 0) {
return (
<div className="text-gray-500 text-xs px-2 py-3 text-center">
{geoPermission === 'denied'
? 'Add a starting point and destination above'
: 'Search and add stops to build your route'}
</div>
)
}
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-1">
{stops.map((stop, i) => (
<StopItem key={stop.id} stop={stop} index={i} total={stops.length} />
))}
</div>
</SortableContext>
</DndContext>
)
}

View file

@ -4,4 +4,48 @@ html, body, #root {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; height: 100%;
overflow: hidden;
}
/* MapLibre popup styling to match dark theme */
.maplibregl-popup-content {
background: #1f2937 !important;
border: 1px solid #374151 !important;
border-radius: 8px !important;
padding: 8px 12px !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important;
}
.maplibregl-popup-tip {
border-top-color: #1f2937 !important;
border-bottom-color: #1f2937 !important;
}
.maplibregl-popup-close-button {
color: #9ca3af !important;
font-size: 16px !important;
padding: 2px 6px !important;
}
.maplibregl-popup-close-button:hover {
color: #fff !important;
background: transparent !important;
}
/* Custom scrollbar for panels */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #6b7280;
} }

65
src/store.js Normal file
View file

@ -0,0 +1,65 @@
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 }),
// ── 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 }),
// ── UI state ──
sheetState: 'half', // 'collapsed' | 'half' | 'full'
panelOpen: true,
autocompleteOpen: false,
setSheetState: (s) => set({ sheetState: s }),
setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
}))

43
src/utils/decode.js Normal file
View file

@ -0,0 +1,43 @@
/**
* Decode a Valhalla/Google-encoded polyline string into [lng, lat] coordinate pairs.
* Valhalla uses precision 6 by default.
* @param {string} encoded
* @param {number} precision - decimal precision (6 for Valhalla)
* @returns {Array<[number, number]>} Array of [lng, lat] pairs for GeoJSON
*/
export function decodePolyline(encoded, precision = 6) {
const factor = Math.pow(10, precision)
const coords = []
let lat = 0
let lng = 0
let i = 0
while (i < encoded.length) {
let shift = 0
let result = 0
let byte
do {
byte = encoded.charCodeAt(i++) - 63
result |= (byte & 0x1f) << shift
shift += 5
} while (byte >= 0x20)
lat += result & 1 ? ~(result >> 1) : result >> 1
shift = 0
result = 0
do {
byte = encoded.charCodeAt(i++) - 63
result |= (byte & 0x1f) << shift
shift += 5
} while (byte >= 0x20)
lng += result & 1 ? ~(result >> 1) : result >> 1
coords.push([lng / factor, lat / factor])
}
return coords
}

View file

@ -1,7 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
proxy: {
'/api': 'http://100.64.0.24:8420',
'/valhalla': {
target: 'http://100.64.0.24:8002',
rewrite: (path) => path.replace(/^\/valhalla/, ''),
},
'/tiles': 'http://100.64.0.24:8420',
},
},
}) })