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

@ -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: '<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>',
},
},
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 <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>
)
}