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

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
}