mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
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:
parent
ce32014896
commit
e7b08a7dc9
16 changed files with 1364 additions and 44 deletions
120
src/App.jsx
120
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: '<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue