mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +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
|
|
@ -2,5 +2,5 @@
|
|||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
npm run build
|
||||
rsync -av --delete dist/ /mnt/nav/frontend/
|
||||
echo "Deployed to /mnt/nav/frontend/"
|
||||
rsync -av --delete dist/ zvx@192.168.1.130:/mnt/nav/frontend/
|
||||
echo "Deployed to recon-vm:/mnt/nav/frontend/"
|
||||
|
|
|
|||
100
package-lock.json
generated
100
package-lock.json
generated
|
|
@ -1,18 +1,22 @@
|
|||
{
|
||||
"name": "navi-tmp",
|
||||
"name": "navi",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "navi-tmp",
|
||||
"name": "navi",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"maplibre-gl": "^5.23.0",
|
||||
"pmtiles": "^4.4.1",
|
||||
"protomaps-themes-base": "^4.5.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
|
@ -283,6 +287,59 @@
|
|||
"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": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
||||
|
|
@ -1294,7 +1351,7 @@
|
|||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
|
|
@ -1611,7 +1668,7 @@
|
|||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
|
|
@ -3094,9 +3151,7 @@
|
|||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
|
|
@ -3298,6 +3353,35 @@
|
|||
"peerDependencies": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"maplibre-gl": "^5.23.0",
|
||||
"pmtiles": "^4.4.1",
|
||||
"protomaps-themes-base": "^4.5.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
89
src/api.js
Normal file
89
src/api.js
Normal 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)
|
||||
}
|
||||
}
|
||||
130
src/components/ManeuverList.jsx
Normal file
130
src/components/ManeuverList.jsx
Normal 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
256
src/components/MapView.jsx
Normal 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
|
||||
33
src/components/ModeSelector.jsx
Normal file
33
src/components/ModeSelector.jsx
Normal 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
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>
|
||||
)
|
||||
}
|
||||
183
src/components/SearchBar.jsx
Normal file
183
src/components/SearchBar.jsx
Normal 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} · {r.confidence}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
src/components/StopItem.jsx
Normal file
76
src/components/StopItem.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/StopList.jsx
Normal file
58
src/components/StopList.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,4 +4,48 @@ html, body, #root {
|
|||
margin: 0;
|
||||
padding: 0;
|
||||
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
65
src/store.js
Normal 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
43
src/utils/decode.js
Normal 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
|
||||
}
|
||||
|
|
@ -1,7 +1,16 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue