From e7b08a7dc9330d51b2245bddaf43ac59aa53a685 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 20 Apr 2026 16:50:53 +0000 Subject: [PATCH] feat: search, multi-stop routing, and route display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy.sh | 4 +- package-lock.json | 100 ++++++++++++- package.json | 6 +- src/App.jsx | 120 +++++++++++---- src/api.js | 89 +++++++++++ src/components/ManeuverList.jsx | 130 ++++++++++++++++ src/components/MapView.jsx | 256 ++++++++++++++++++++++++++++++++ src/components/ModeSelector.jsx | 33 ++++ src/components/Panel.jsx | 188 +++++++++++++++++++++++ src/components/SearchBar.jsx | 183 +++++++++++++++++++++++ src/components/StopItem.jsx | 76 ++++++++++ src/components/StopList.jsx | 58 ++++++++ src/index.css | 44 ++++++ src/store.js | 65 ++++++++ src/utils/decode.js | 43 ++++++ vite.config.js | 11 +- 16 files changed, 1363 insertions(+), 43 deletions(-) create mode 100644 src/api.js create mode 100644 src/components/ManeuverList.jsx create mode 100644 src/components/MapView.jsx create mode 100644 src/components/ModeSelector.jsx create mode 100644 src/components/Panel.jsx create mode 100644 src/components/SearchBar.jsx create mode 100644 src/components/StopItem.jsx create mode 100644 src/components/StopList.jsx create mode 100644 src/store.js create mode 100644 src/utils/decode.js diff --git a/deploy.sh b/deploy.sh index 4e9439e..a8f7e3c 100755 --- a/deploy.sh +++ b/deploy.sh @@ -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/" diff --git a/package-lock.json b/package-lock.json index 2b0f9e3..d470af6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 + } + } } } } diff --git a/package.json b/package.json index 5d8629c..8b2b551 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index e710478..ba7cadd 100644 --- a/src/App.jsx +++ b/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: 'Protomaps | OSM', - }, - }, - 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
+ // 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 ( +
+ + +
+ ) } diff --git a/src/api.js b/src/api.js new file mode 100644 index 0000000..7da9c0a --- /dev/null +++ b/src/api.js @@ -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} 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} 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) + } +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx new file mode 100644 index 0000000..11636cb --- /dev/null +++ b/src/components/ManeuverList.jsx @@ -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 ( +
+
+ Calculating route... +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + 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 ( +
+ {/* Route summary */} +
+ + {formatDist(totalDist)} + + + {formatTime(totalTime)} + +
+ + {/* Maneuver steps */} +
+ {allManeuvers.map((man, i) => ( + + ))} +
+
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx new file mode 100644 index 0000000..fc09269 --- /dev/null +++ b/src/components/MapView.jsx @@ -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: + 'Protomaps | OSM', + }, + }, + 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( + `
+ ${stop.name} +
+
` + ) + .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
+}) + +export default MapView diff --git a/src/components/ModeSelector.jsx b/src/components/ModeSelector.jsx new file mode 100644 index 0000000..c4501cf --- /dev/null +++ b/src/components/ModeSelector.jsx @@ -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 ( +
+ {MODES.map((m) => ( + + ))} +
+ ) +} diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx new file mode 100644 index 0000000..3b56e4e --- /dev/null +++ b/src/components/Panel.jsx @@ -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 = ( + <> + + + {/* Stop list */} +
+ +
+ + {/* Mode selector + optimize */} + {stops.length >= 1 && ( +
+ + {showOptimize && ( + + )} +
+ )} + + {/* Maneuver list */} + {(route || routeLoading || routeError) && ( +
+ +
+ )} + + {/* TODO: Recents / saved places placeholder */} + {stops.length === 0 && !route && ( +
+ {/* TODO: Wire recents + favorites in a later phase */} +

Recent places will appear here

+
+ )} + + ) + + // Desktop: side panel + if (!isMobile) { + return ( +
+

Navi

+ {content} +
+ ) + } + + // Mobile: bottom sheet + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+ {/* Drag handle */} +
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {content} +
+ )} +
+ ) +} diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx new file mode 100644 index 0000000..6923d93 --- /dev/null +++ b/src/components/SearchBar.jsx @@ -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 ( +
+
+
+ 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 && ( +
+
+
+ )} +
+
+ + {/* Autocomplete dropdown */} + {autocompleteOpen && results.length > 0 && ( +
    + {results.map((r, i) => ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + {r.name} + + {r.match_code?.housenumber === 'matched' && ( + + exact match + + )} + {r.source} + +
    +
    + {r.type} · {r.confidence} +
    +
  • + ))} +
+ )} +
+ ) +} diff --git a/src/components/StopItem.jsx b/src/components/StopItem.jsx new file mode 100644 index 0000000..a433e94 --- /dev/null +++ b/src/components/StopItem.jsx @@ -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 ( +
+ {/* Drag handle */} + + + {/* Pin indicator */} + + {pinLabel} + + + {/* Stop name */} + {stop.name} + + {/* Remove button */} + +
+ ) +} diff --git a/src/components/StopList.jsx b/src/components/StopList.jsx new file mode 100644 index 0000000..a6b0c6e --- /dev/null +++ b/src/components/StopList.jsx @@ -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 ( +
+ {geoPermission === 'denied' + ? 'Add a starting point and destination above' + : 'Search and add stops to build your route'} +
+ ) + } + + return ( + + s.id)} strategy={verticalListSortingStrategy}> +
+ {stops.map((stop, i) => ( + + ))} +
+
+
+ ) +} diff --git a/src/index.css b/src/index.css index added85..e8999cf 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..4769a6b --- /dev/null +++ b/src/store.js @@ -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 }), +})) diff --git a/src/utils/decode.js b/src/utils/decode.js new file mode 100644 index 0000000..c3111c1 --- /dev/null +++ b/src/utils/decode.js @@ -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 +} diff --git a/vite.config.js b/vite.config.js index 8b0f57b..d50278b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -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', + }, + }, })