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
188
src/components/Panel.jsx
Normal file
188
src/components/Panel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue