From d6aa125215eabf82e4e205bff4171ab315f13c85 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 15:05:52 +0000 Subject: [PATCH 01/10] feat: unified routing UI with wilderness + network segments - Single routing system (removed duplicate Valhalla-only flow) - Unified radial menu: From here, To here, Clear, Save, Measure - Removed "Offroute" section from panel (single directions display) - Better error messages without technical "Offroute" prefix - ManeuverList shows wilderness + network breakdown - PlaceCard integration for previews Co-Authored-By: Claude Opus 4.5 --- src/App.jsx | 79 +---- src/api.js | 66 ++++ src/components/ManeuverList.jsx | 304 ++++++++-------- src/components/MapView.jsx | 444 ++++++++++------------- src/components/Panel.jsx | 605 ++++++++++++++++---------------- src/store.js | 318 +++++++++-------- 6 files changed, 878 insertions(+), 938 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 0d02c8f..3bdea6e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useCallback } from 'react' import { useStore } from './store' import { useTheme } from './hooks/useTheme' -import { requestRoute, fetchAuthState } from './api' -import { decodePolyline } from './utils/decode' +import { fetchAuthState } from './api' import MapView from './components/MapView' import Panel from './components/Panel' @@ -12,20 +11,10 @@ import LocateButton from './components/LocateButton' export default function App() { const mapViewRef = useRef(null) - const routeDebounceRef = useRef(null) // Initialize theme system useTheme() - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - 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 setAuth = useStore((s) => s.setAuth) // Initialize auth state on app load (single fetch, no polling) @@ -33,70 +22,18 @@ export default function App() { fetchAuthState().then(setAuth) }, [setAuth]) - // Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) - useEffect(() => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - - routeDebounceRef.current = setTimeout(async () => { - const { userLocation } = useStore.getState() - - let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (gpsOrigin && geoPermission === 'granted' && userLocation) { - effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective] - } - - if (effective.length < 2) { - clearRoute() - return - } - - setRouteLoading(true) - - try { - const data = await requestRoute(effective, 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 () => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - } - }, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError]) - - // Handle maneuver click - 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] - ) + // Handle clear route from panel + const handleClearRoute = useCallback(() => { + mapViewRef.current?.clearRoute?.() + }, []) return (
- - + + - + {/* Bottom-right map controls */}
diff --git a/src/api.js b/src/api.js index fe8fd02..47d5861 100644 --- a/src/api.js +++ b/src/api.js @@ -321,3 +321,69 @@ export async function fetchAuthState() { return { authenticated: false, username: null } } } + +// ── Offroute API ── + +const OFFROUTE_URL = "/api/offroute" +const MVUM_URL = "/api/mvum" + +/** + * Request an offroute route from the pathfinder API. + * @param {object} start - { lat, lon } + * @param {object} end - { lat, lon } + * @param {string} mode - foot | mtb | atv | vehicle + * @param {string} boundaryMode - strict | pragmatic | emergency + * @returns {Promise} Offroute response with GeoJSON route + */ +export async function requestOffroute(start, end, mode = "foot", boundaryMode = "strict") { + const body = { + start: [start.lat, start.lon], + end: [end.lat, end.lon], + mode, + boundary_mode: boundaryMode, + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 120000) // 2 min timeout for complex routes + + try { + const resp = await fetch(OFFROUTE_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.message || 'Could not find a route. Try a different start point or mode.') + } + + return resp.json() + } finally { + clearTimeout(timeout) + } +} + +/** + * Fetch MVUM (Motor Vehicle Use Map) info for a location. + * @param {number} lat + * @param {number} lon + * @param {number} radius - Search radius in meters + * @returns {Promise} MVUM feature info or null + */ +export async function fetchMvumInfo(lat, lon, radius = 500) { + try { + const params = new URLSearchParams({ + lat: String(lat), + lon: String(lon), + radius: String(radius), + }) + const resp = await fetch(`${MVUM_URL}?${params}`, { signal: AbortSignal.timeout(5000) }) + if (!resp.ok) return null + const data = await resp.json() + return data.feature || null + } catch { + return null + } +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index d869b66..a8b90b0 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,140 +1,164 @@ -import { - MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, - MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation -} from 'lucide-react' -import { useStore } from '../store' - -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` -} - -function formatDist(miles) { - if (miles < 0.1) return `${Math.round(miles * 5280)} ft` - return `${miles.toFixed(1)} mi` -} - -function ManeuverIcon({ type }) { - const size = 16 - const props = { size, strokeWidth: 1.5 } - switch (type) { - case 0: return - case 1: return - case 2: return - case 3: return - case 4: case 5: return - case 6: return - case 7: return - case 8: return - case 9: return - case 10: case 11: case 12: return - case 15: case 16: return - case 24: return - case 25: return - case 26: return - 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 - - const totalTime = route.summary?.time || 0 - const totalDist = route.summary?.length || 0 - - 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) => ( - - ))} -
-
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle +} from 'lucide-react' +import { useStore } from '../store' + +function formatDistKm(km) { + const miles = km * 0.621371 + if (miles < 0.1) return Math.round(miles * 5280) + ' ft' + return miles.toFixed(1) + ' mi' +} + +function formatTimeMin(minutes) { + if (minutes < 60) return Math.round(minutes) + ' min' + const h = Math.floor(minutes / 60) + const m = Math.round(minutes % 60) + return m > 0 ? h + 'h ' + m + 'm' : h + 'h' +} + +function ManeuverIcon({ type }) { + const size = 16 + const props = { size, strokeWidth: 1.5 } + switch (type) { + case 0: return + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return + } +} + +export default function ManeuverList() { + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!routeResult?.summary) return null + + const summary = routeResult.summary + const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') + const maneuvers = networkFeature?.properties?.maneuvers || [] + + return ( +
+ {/* Total summary */} +
+ + {formatDistKm(summary.total_distance_km)} + + + {formatTimeMin(summary.total_effort_minutes)} + +
+ + {/* Segment breakdown */} +
+ {summary.wilderness_distance_km > 0 && ( +
+ + Wilderness + + {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + +
+ )} + {summary.network_distance_km > 0 && ( +
+ + Road/Trail + + {formatDistKm(summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + +
+ )} +
+ + {/* Warnings */} + {(summary.barrier_crossings > 0 || summary.mvum_closed_crossings > 0) && ( +
+ {summary.barrier_crossings > 0 && ( +
+ + {summary.barrier_crossings} barrier crossing{summary.barrier_crossings > 1 ? 's' : ''} +
+ )} + {summary.mvum_closed_crossings > 0 && ( +
+ + {summary.mvum_closed_crossings} MVUM closure{summary.mvum_closed_crossings > 1 ? 's' : ''} +
+ )} +
+ )} + + {/* Turn-by-turn directions */} + {maneuvers.length > 0 && ( +
+
Directions
+ {maneuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+

+ {formatDistKm(man.distance_km)} +

+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0ede093..1dfec38 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -6,9 +6,9 @@ import { layers, namedTheme } from 'protomaps-themes-base' import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse } from '../api' +import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2 } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -27,6 +27,10 @@ const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' +const OFFROUTE_SOURCE = 'offroute-source' +const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness' +const OFFROUTE_NETWORK_LAYER = 'offroute-network' +const OFFROUTE_MARKERS_LAYER = 'offroute-markers' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' @@ -1122,6 +1126,7 @@ function isProtectedLayer(id) { return id.startsWith('public-lands') || id.startsWith('boundary') || id.startsWith('route') || + id.startsWith('offroute') || id.startsWith('measure') || id.startsWith('contour') || id.startsWith('usfs') || @@ -1327,6 +1332,83 @@ function removeStateBoundaries(map) { } } + +/** Clear offroute display layers */ +function clearRouteDisplay(map) { + if (!map) return + if (map.getLayer(OFFROUTE_WILDERNESS_LAYER)) map.removeLayer(OFFROUTE_WILDERNESS_LAYER) + if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER) + if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER) + if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE) +} + +/** Update offroute display with route GeoJSON */ +function updateRouteDisplay(map, routeGeojson) { + if (!map || !routeGeojson) return + + // Clear existing layers + clearRouteDisplay(map) + + // Add source with route features + map.addSource(OFFROUTE_SOURCE, { + type: "geojson", + data: routeGeojson, + }) + + // Find first symbol layer for proper z-ordering + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Wilderness segment - dashed orange line + map.addLayer({ + id: OFFROUTE_WILDERNESS_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "wilderness"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#f97316", // orange-500 + "line-width": 4, + "line-opacity": 0.9, + "line-dasharray": [8, 4], + }, + }, beforeId) + + // Network segment - solid blue line + map.addLayer({ + id: OFFROUTE_NETWORK_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "network"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#3b82f6", // blue-500 + "line-width": 5, + "line-opacity": 0.85, + }, + }, beforeId) + + // Fit bounds to route + const features = routeGeojson.features || [] + const allCoords = features + .filter(f => f.geometry?.coordinates) + .flatMap(f => f.geometry.coordinates) + + if (allCoords.length > 0) { + const bounds = allCoords.reduce( + (b, c) => b.extend(c), + new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) + ) + const leftPad = 420 + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) + } +} + const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -1348,14 +1430,11 @@ const MapView = forwardRef(function MapView(_, ref) { const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements - const stops = useStore((s) => s.stops) - const route = useStore((s) => s.route) const theme = useStore((s) => s.theme) const selectedPlace = useStore((s) => s.selectedPlace) const clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) - const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) @@ -1578,96 +1657,95 @@ const MapView = forwardRef(function MapView(_, ref) { updateMeasureLabels(newPoints) } - const radialWedges = [ - { - id: "directions-to", - label: "To here", - icon: ArrowDownLeft, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - useStore.getState().startDirections(place) - }, - }, - { - id: "directions-from", - label: "From here", - icon: ArrowUpRight, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { clearStops, addStop } = useStore.getState() - clearStops() - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - addStop(place) - useStore.setState({ gpsOrigin: false }) - }, - }, - { - id: "add-stop", - label: "Add stop", - icon: Plus, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop, clearStops } = useStore.getState() - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, - } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") - } - } - }, - }, - { - id: "save-place", - label: "Save", - icon: Star, - requiresAuth: true, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { auth, setEditingContact } = useStore.getState() - if (auth.authenticated) { - setEditingContact({ - label: "", - lat: radialMenu.lat, - lon: radialMenu.lon, - }) - } else { - toast("Log in to save places") - } - }, - }, - { - id: "measure", - label: "Measure", - icon: Ruler, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - startMeasuring(radialMenu.lat, radialMenu.lon) - }, - }, - ] + const radialWedges = [ + { + id: "to-here", + label: "To here", + icon: ArrowDownLeft, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { routeStart, setRouteEnd, setRouteLoading, setRouteResult, setRouteError, routeMode, boundaryMode } = useStore.getState() + setRouteEnd(place) + if (routeStart) { + setRouteLoading(true) + requestOffroute(routeStart, place, routeMode, boundaryMode) + .then((data) => { + if (data.status === "ok" && data.route) { + setRouteResult(data) + updateRouteDisplay(mapInstance.current, data.route) + } else { + setRouteError(data.error || "No route found") + } + }) + .catch((e) => setRouteError(e.message)) + .finally(() => setRouteLoading(false)) + } else { + toast("Set starting point first") + } + }, + }, + { + id: "from-here", + label: "From here", + icon: ArrowUpRight, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { clearRoute, setRouteStart } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + toast("Now tap destination") + }, + }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, + { + id: "save-place", + label: "Save", + icon: Star, + requiresAuth: true, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { auth, setEditingContact } = useStore.getState() + if (auth.authenticated) { + setEditingContact({ + label: "", + lat: radialMenu.lat, + lon: radialMenu.lon, + }) + } else { + toast("Log in to save places") + } + }, + }, + { + id: "measure", + label: "Measure", + icon: Ruler, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + startMeasuring(radialMenu.lat, radialMenu.lon) + }, + }, + ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current @@ -1805,6 +1883,14 @@ const MapView = forwardRef(function MapView(_, ref) { updateSatellitePaint(map, currentThemeRef.current) }, + // Clear offroute route from map + clearRoute() { + const map = mapInstance.current + if (!map) return + clearRouteDisplay(map) + useStore.getState().clearRoute() + }, + })) // Initialize map @@ -2464,10 +2550,8 @@ const MapView = forwardRef(function MapView(_, ref) { originalPaintValues = {} // Restore view - map.jumpTo({ center, zoom, bearing, pitch }) - // Re-render route if exists - const currentRoute = useStore.getState().route - if (currentRoute) updateRoute(map, currentRoute) + const currentRoute = useStore.getState().routeResult + if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) }) }, [theme]) @@ -2560,168 +2644,6 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [selectedPlace]) - // Update route polyline when route changes - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (!map.isStyleLoaded()) { - const handler = () => updateRoute(map, route) - map.once('idle', handler) - return () => map.off('idle', handler) - } - updateRoute(map, route) - }, [route]) - - function updateRoute(map, routeData) { - 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 (!routeData || !routeData.legs) { - if (map.getSource(ROUTE_SOURCE)) { - map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) - } - return - } - - const features = [] - for (let i = 0; i < routeData.legs.length; i++) { - const leg = routeData.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 }, - }) - } - - // Use CSS variable for route color (read computed value) - const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() - - 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': routeColor || '#7a9a6b', - '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]) - ) - // Single-panel: no floating detail - const leftPad = 420 // 360px panel + margin - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, 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 - } - - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const indexOffset = hasGpsOrigin ? 1 : 0 - - stops.forEach((stop, i) => { - const displayIndex = i + indexOffset - const effectiveTotal = stops.length + indexOffset - - let pinClass = 'navi-pin navi-pin--intermediate' - if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' - else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' - - const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) - - const el = document.createElement('div') - el.className = pinClass - el.textContent = label - - el.addEventListener('click', (e) => { - e.stopPropagation() - // Flag so the map-level click handler doesn't fire - pinClickedRef.current = true - 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: 420, right: 60 } }) - } - } - }, [stops, route, gpsOrigin, geoPermission]) - - // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 2799a89..ddae59c 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,311 +1,294 @@ -import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut } from 'lucide-react' -import ThemePicker from './ThemePicker' -import { useStore, usePanelState } from '../store' -import { hasFeature } from '../config' -import SearchBar from './SearchBar' -import StopList from './StopList' -import ModeSelector from './ModeSelector' -import ManeuverList from './ManeuverList' -import ContactList from './ContactList' -import { PlaceCard } from './PlaceCard' -import { requestOptimizedRoute } from '../api' - -export default function Panel({ onManeuverClick }) { - const selectedPlace = useStore((s) => s.selectedPlace) - const pendingDestination = useStore((s) => s.pendingDestination) - const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const clearPendingDestination = useStore((s) => s.clearPendingDestination) - 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 theme = useStore((s) => s.theme) - const themeOverride = useStore((s) => s.themeOverride) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - const activeTab = useStore((s) => s.activeTab) - const auth = useStore((s) => s.auth) - const setActiveTab = useStore((s) => s.setActiveTab) - - const panelState = usePanelState() - - const [isMobile, setIsMobile] = useState(false) - const [optimizing, setOptimizing] = useState(false) - const sheetRef = useRef(null) - const dragStartY = useRef(0) - const dragStartState = useRef('half') - - // Show contacts tab only if feature enabled AND user is authenticated - const showContacts = hasFeature('has_contacts') && auth.authenticated - - // Responsive detection - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - // Auth handlers - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - - // Optimize stops - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0) - - const handleOptimize = useCallback(async () => { - if (effectiveCount < 3 || optimizing) return - setOptimizing(true) - try { - const { userLocation } = useStore.getState() - let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (hasGpsOrigin && userLocation) { - locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations] - } - const data = await requestOptimizedRoute(locations, mode) - if (data.trip) { - const wpOrder = hasGpsOrigin && userLocation - ? (data.trip.locations || []).slice(1) - : data.trip.locations - if (wpOrder && wpOrder.length === stops.length) { - 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 - }) - 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, effectiveCount, hasGpsOrigin, 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) { - if (dragStartState.current === 'collapsed') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('full') - } else { - if (dragStartState.current === 'full') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('collapsed') - } - }, [setSheetState]) - - const showOptimize = effectiveCount >= 3 - - // Determine what to show based on panel state - const showPreviewCard = panelState.startsWith('PREVIEW') - const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination - const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' - const showEmptyState = panelState === 'IDLE' && !pendingDestination - - // Routes tab content - now state-driven - const routesContent = ( - <> - - - {/* Preview card when place is selected */} - {showPreviewCard && selectedPlace && ( -
- -
- )} - - {/* Route section with stops */} - {showRouteSection && ( - <> -
- -
- -
- - {showOptimize && ( - - )} - {pendingDestination && stops.length === 0 && ( - - )} -
- - )} - - {/* Maneuvers when route is calculated */} - {showManeuvers && (route || routeLoading || routeError) && ( -
- -
- )} - - {/* Empty state */} - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

-
- {auth.loaded && ( - auth.authenticated ? ( - - ) : ( - - ) - )} - -
-
- ) - - // Desktop: side panel (now 360px to accommodate PlaceCard) - if (!isMobile) { - return ( -
- {header} - {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' && ( -
- {header} - {content} -
- )} -
- ) -} +import { useRef, useCallback, useEffect, useState } from 'react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import ThemePicker from './ThemePicker' +import { useStore, usePanelState } from '../store' +import { hasFeature } from '../config' +import SearchBar from './SearchBar' +import ManeuverList from './ManeuverList' +import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' + +const TRAVEL_MODES = [ + { id: 'foot', label: 'Foot', Icon: Footprints }, + { id: 'mtb', label: 'MTB', Icon: Bike }, + { id: 'atv', label: 'ATV', Icon: Car }, + { id: 'vehicle', label: '4x4', Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, + { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, + { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, +] + +export default function Panel({ onClearRoute }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const clearRoute = useStore((s) => s.clearRoute) + const sheetState = useStore((s) => s.sheetState) + const setSheetState = useStore((s) => s.setSheetState) + const activeTab = useStore((s) => s.activeTab) + const auth = useStore((s) => s.auth) + const setActiveTab = useStore((s) => s.setActiveTab) + + const panelState = usePanelState() + + const [isMobile, setIsMobile] = useState(false) + const sheetRef = useRef(null) + const dragStartY = useRef(0) + const dragStartState = useRef('half') + + const showContacts = hasFeature('has_contacts') && auth.authenticated + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + + 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) { + if (dragStartState.current === 'collapsed') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('full') + } else { + if (dragStartState.current === 'full') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('collapsed') + } + }, [setSheetState]) + + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } + + const showPreviewCard = panelState.startsWith('PREVIEW') + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + + const routesContent = ( + <> + + + {showPreviewCard && selectedPlace && ( +
+ +
+ )} + + {showRouteSection && ( +
+
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Right-click to set start'} + +
+
+ + + {routeEnd?.name || 'Right-click to set destination'} + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ + +
+ )} + + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + if (!isMobile) { + return ( +
+ {header} + {content} +
+ ) + } + + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {header} + {content} +
+ )} +
+ ) +} diff --git a/src/store.js b/src/store.js index 6b7f30d..2cf78ee 100644 --- a/src/store.js +++ b/src/store.js @@ -1,155 +1,163 @@ -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 }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── 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 }), - - // ── Place detail ── - selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } - clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) - } - }, - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.route - const hasStops = s.stops.length >= 1 - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasStops) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasStops) return "ROUTING" - return "IDLE" - }) -} +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 }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // Single routing system - all routes go through /api/offroute + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + routeMode: "foot", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteMode: (mode) => set({ routeMode: mode }), + setBoundaryMode: (mode) => set({ boundaryMode: mode }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + clearRoute: () => set({ + routeStart: null, + routeEnd: null, + routeResult: null, + routeError: null + }), + + // ── Legacy compatibility (for components not yet migrated) ── + stops: [], + gpsOrigin: false, + pendingDestination: null, + route: null, + + addStop: (stop) => { + // Legacy: just set as route end point + const { routeStart, setRouteEnd } = get() + const place = { lat: stop.lat, lon: stop.lon, name: stop.name } + if (!routeStart) { + set({ routeStart: place, stops: [{ ...stop, id: crypto.randomUUID() }] }) + } else { + setRouteEnd(place) + set({ stops: [...get().stops, { ...stop, id: crypto.randomUUID() }] }) + } + return true + }, + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + if (newStops.length === 0) { + get().clearRoute() + } + }, + clearStops: () => set({ stops: [], routeStart: null, routeEnd: null }), + setStops: (stops) => set({ stops }), + reorderStops: (newStops) => set({ stops: newStops }), + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + startDirections: (place) => { + // Legacy: set as destination + const { routeStart, setRouteEnd, clearRoute } = get() + clearRoute() + set({ + routeEnd: { lat: place.lat, lon: place.lon, name: place.name }, + stops: [{ ...place, id: crypto.randomUUID() }], + selectedPlace: null + }) + }, + + // ── Place detail ── + selectedPlace: null, + clickMarker: null, + + setSelectedPlace: (place) => set({ selectedPlace: place }), + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: 'half', + panelOpen: true, + autocompleteOpen: false, + theme: 'dark', + themeOverride: null, + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem('navi-theme-override', override) + } else { + localStorage.removeItem('navi-theme-override') + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: 'routes', + editingContact: null, + pickingLocationFor: null, + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} From 09d68adf095bd1028d539e1177d58676546423d8 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 21:59:10 +0000 Subject: [PATCH 02/10] feat: unified routing with Drive mode default and Add stop wedge - Add Drive (auto) as default route mode, first in travel modes list - Hide boundary mode selector when Drive mode is active - Restore Add stop radial menu wedge with stops system integration - Unify routing through single computeRoute() function in store - Add coordinate parsing to SearchBar for direct lat/lon input - Bridge stops system with routeStart/routeEnd for seamless UX - Support 3+ stops with Valhalla optimization Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 205 ++++++------ src/components/Panel.jsx | 591 ++++++++++++++++++----------------- src/components/SearchBar.jsx | 43 +++ src/store.js | 434 +++++++++++++++---------- 4 files changed, 726 insertions(+), 547 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 1dfec38..ca9b5c5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -8,7 +8,7 @@ import { useStore } from '../store' import { decodePolyline } from '../utils/decode' import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2 } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -1657,95 +1657,114 @@ const MapView = forwardRef(function MapView(_, ref) { updateMeasureLabels(newPoints) } - const radialWedges = [ - { - id: "to-here", - label: "To here", - icon: ArrowDownLeft, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - } - const { routeStart, setRouteEnd, setRouteLoading, setRouteResult, setRouteError, routeMode, boundaryMode } = useStore.getState() - setRouteEnd(place) - if (routeStart) { - setRouteLoading(true) - requestOffroute(routeStart, place, routeMode, boundaryMode) - .then((data) => { - if (data.status === "ok" && data.route) { - setRouteResult(data) - updateRouteDisplay(mapInstance.current, data.route) - } else { - setRouteError(data.error || "No route found") - } - }) - .catch((e) => setRouteError(e.message)) - .finally(() => setRouteLoading(false)) - } else { - toast("Set starting point first") - } - }, - }, - { - id: "from-here", - label: "From here", - icon: ArrowUpRight, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const place = { - lat: radialMenu.lat, - lon: radialMenu.lon, - name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - } - const { clearRoute, setRouteStart } = useStore.getState() - clearRoute() - clearRouteDisplay(mapInstance.current) - setRouteStart(place) - toast("Now tap destination") - }, - }, - { - id: "clear-route", - label: "Clear", - icon: Trash2, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - useStore.getState().clearRoute() - clearRouteDisplay(mapInstance.current) - }, - }, - { - id: "save-place", - label: "Save", - icon: Star, - requiresAuth: true, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - const { auth, setEditingContact } = useStore.getState() - if (auth.authenticated) { - setEditingContact({ - label: "", - lat: radialMenu.lat, - lon: radialMenu.lon, - }) - } else { - toast("Log in to save places") - } - }, - }, - { - id: "measure", - label: "Measure", - icon: Ruler, - onSelect: () => { - setRadialMenu((m) => ({ ...m, open: false })) - startMeasuring(radialMenu.lat, radialMenu.lon) - }, - }, - ] + const radialWedges = [ + { + id: "to-here", + label: "To here", + icon: ArrowDownLeft, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { routeStart, setRouteEnd, computeRoute } = useStore.getState() + setRouteEnd(place) + if (routeStart) { + computeRoute() + } else { + toast("Set starting point first") + } + }, + }, + { + id: "from-here", + label: "From here", + icon: ArrowUpRight, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + } + const { clearRoute, setRouteStart, routeEnd, computeRoute } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + // If we already have a destination, compute route immediately + if (routeEnd) { + computeRoute() + } else { + toast("Now tap destination") + } + }, + }, + { + id: "add-stop", + label: "Add stop", + icon: Plus, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { stops, addStop } = useStore.getState() + const place = { + lat: radialMenu.lat, + lon: radialMenu.lon, + name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), + source: "radial_menu", + matchCode: null, + } + if (stops.length === 0) { + addStop(place) + useStore.setState({ gpsOrigin: false }) + } else { + const success = addStop(place) + if (!success) { + toast("Maximum 10 stops reached") + } + } + }, + }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, + { + id: "save-place", + label: "Save", + icon: Star, + requiresAuth: true, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + const { auth, setEditingContact } = useStore.getState() + if (auth.authenticated) { + setEditingContact({ + label: "", + lat: radialMenu.lat, + lon: radialMenu.lon, + }) + } else { + toast("Log in to save places") + } + }, + }, + { + id: "measure", + label: "Measure", + icon: Ruler, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + startMeasuring(radialMenu.lat, radialMenu.lon) + }, + }, + ] // Context menu trigger handler const handleContextMenuTrigger = ({ x, y }) => { const map = mapInstance.current @@ -2390,6 +2409,12 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current = updateBoundaryFn useStore.getState().setUpdateBoundary(updateBoundaryFn) + // Register route display callbacks for store.computeRoute() + useStore.getState().setRouteDisplayCallbacks( + (routeGeojson) => updateRouteDisplay(map, routeGeojson), + () => clearRouteDisplay(map) + ) + // POI/label hover affordance — cursor pointer + highlight const interactiveLayers = ['pois', 'places_locality', 'places_region', 'places_country', 'places_subplace'] diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index ddae59c..efc9b5a 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,294 +1,297 @@ -import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' -import ThemePicker from './ThemePicker' -import { useStore, usePanelState } from '../store' -import { hasFeature } from '../config' -import SearchBar from './SearchBar' -import ManeuverList from './ManeuverList' -import ContactList from './ContactList' -import { PlaceCard } from './PlaceCard' - -const TRAVEL_MODES = [ - { id: 'foot', label: 'Foot', Icon: Footprints }, - { id: 'mtb', label: 'MTB', Icon: Bike }, - { id: 'atv', label: 'ATV', Icon: Car }, - { id: 'vehicle', label: '4x4', Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, - { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, - { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, -] - -export default function Panel({ onClearRoute }) { - const selectedPlace = useStore((s) => s.selectedPlace) - const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const clearRoute = useStore((s) => s.clearRoute) - const sheetState = useStore((s) => s.sheetState) - const setSheetState = useStore((s) => s.setSheetState) - const activeTab = useStore((s) => s.activeTab) - const auth = useStore((s) => s.auth) - const setActiveTab = useStore((s) => s.setActiveTab) - - const panelState = usePanelState() - - const [isMobile, setIsMobile] = useState(false) - const sheetRef = useRef(null) - const dragStartY = useRef(0) - const dragStartState = useRef('half') - - const showContacts = hasFeature('has_contacts') && auth.authenticated - - useEffect(() => { - const check = () => setIsMobile(window.innerWidth < 768) - check() - window.addEventListener('resize', check) - return () => window.removeEventListener('resize', check) - }, []) - - const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } - const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - - 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) { - if (dragStartState.current === 'collapsed') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('full') - } else { - if (dragStartState.current === 'full') setSheetState('half') - else if (dragStartState.current === 'half') setSheetState('collapsed') - } - }, [setSheetState]) - - const handleClearRoute = () => { - clearRoute() - onClearRoute?.() - } - - const showPreviewCard = panelState.startsWith('PREVIEW') - const hasRoutePoints = routeStart || routeEnd - const showRouteSection = hasRoutePoints || routeResult || routeLoading - const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - - const routesContent = ( - <> - - - {showPreviewCard && selectedPlace && ( -
- -
- )} - - {showRouteSection && ( -
-
- - Route - - -
- -
-
- - - {routeStart?.name || 'Right-click to set start'} - -
-
- - - {routeEnd?.name || 'Right-click to set destination'} - -
-
- -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- - -
- )} - - {showEmptyState && ( -
-

Search or tap the map to explore

-
- )} - - ) - - const content = ( - <> - {showContacts && ( -
- - -
- )} - - {(!showContacts || activeTab === 'routes') ? routesContent : } - - ) - - const header = ( -
-

Navi

-
- {auth.loaded && ( - auth.authenticated ? ( - - ) : ( - - ) - )} - -
-
- ) - - if (!isMobile) { - return ( -
- {header} - {content} -
- ) - } - - const sheetHeights = { - collapsed: 'h-12', - half: 'h-[45vh]', - full: 'h-[85vh]', - } - - return ( -
-
{ - if (sheetState === 'collapsed') setSheetState('half') - else if (sheetState === 'half') setSheetState('full') - else setSheetState('half') - }} - > -
-
- - {sheetState !== 'collapsed' && ( -
- {header} - {content} -
- )} -
- ) -} +import { useRef, useCallback, useEffect, useState } from 'react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import ThemePicker from './ThemePicker' +import { useStore, usePanelState } from '../store' +import { hasFeature } from '../config' +import SearchBar from './SearchBar' +import ManeuverList from './ManeuverList' +import ContactList from './ContactList' +import { PlaceCard } from './PlaceCard' + +const TRAVEL_MODES = [ + { id: 'auto', label: 'Drive', Icon: Car }, + { id: 'foot', label: 'Foot', Icon: Footprints }, + { id: 'mtb', label: 'MTB', Icon: Bike }, + { id: 'atv', label: 'ATV', Icon: Car }, + { id: 'vehicle', label: '4x4', Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: 'strict', label: 'Strict', Icon: Shield, title: 'Avoid barriers' }, + { id: 'pragmatic', label: 'Cross', Icon: AlertTriangle, title: 'Cross with penalty' }, + { id: 'emergency', label: 'Ignore', Icon: Zap, title: 'Ignore barriers' }, +] + +export default function Panel({ onClearRoute }) { + const selectedPlace = useStore((s) => s.selectedPlace) + const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const clearRoute = useStore((s) => s.clearRoute) + const sheetState = useStore((s) => s.sheetState) + const setSheetState = useStore((s) => s.setSheetState) + const activeTab = useStore((s) => s.activeTab) + const auth = useStore((s) => s.auth) + const setActiveTab = useStore((s) => s.setActiveTab) + + const panelState = usePanelState() + + const [isMobile, setIsMobile] = useState(false) + const sheetRef = useRef(null) + const dragStartY = useRef(0) + const dragStartState = useRef('half') + + const showContacts = hasFeature('has_contacts') && auth.authenticated + + useEffect(() => { + const check = () => setIsMobile(window.innerWidth < 768) + check() + window.addEventListener('resize', check) + return () => window.removeEventListener('resize', check) + }, []) + + const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } + const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } + + 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) { + if (dragStartState.current === 'collapsed') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('full') + } else { + if (dragStartState.current === 'full') setSheetState('half') + else if (dragStartState.current === 'half') setSheetState('collapsed') + } + }, [setSheetState]) + + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } + + const showPreviewCard = panelState.startsWith('PREVIEW') + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + + const routesContent = ( + <> + + + {showPreviewCard && selectedPlace && ( +
+ +
+ )} + + {showRouteSection && ( +
+
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Right-click to set start'} + +
+
+ + + {routeEnd?.name || 'Right-click to set destination'} + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {routeMode !== 'auto' && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + +
+ )} + + {showEmptyState && ( +
+

Search or tap the map to explore

+
+ )} + + ) + + const content = ( + <> + {showContacts && ( +
+ + +
+ )} + + {(!showContacts || activeTab === 'routes') ? routesContent : } + + ) + + const header = ( +
+

Navi

+
+ {auth.loaded && ( + auth.authenticated ? ( + + ) : ( + + ) + )} + +
+
+ ) + + if (!isMobile) { + return ( +
+ {header} + {content} +
+ ) + } + + const sheetHeights = { + collapsed: 'h-12', + half: 'h-[45vh]', + full: 'h-[85vh]', + } + + return ( +
+
{ + if (sheetState === 'collapsed') setSheetState('half') + else if (sheetState === 'half') setSheetState('full') + else setSheetState('half') + }} + > +
+
+ + {sheetState !== 'collapsed' && ( +
+ {header} + {content} +
+ )} +
+ ) +} diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx index 2e47bd9..1215a08 100644 --- a/src/components/SearchBar.jsx +++ b/src/components/SearchBar.jsx @@ -6,6 +6,30 @@ import { buildAddress } from '../utils/place' import { searchGeocode } from '../api' import { hasFeature } from '../config' + +/** Parse coordinate input like "42.35, -114.30" or "42.35 -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const trimmed = input.trim() + + // Pattern: lat, lon or lat lon (with optional comma) + // Supports: "42.35, -114.30", "42.35 -114.30", "42.35,-114.30" + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = trimmed.match(pattern) + + if (!match) return null + + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + + // Validate ranges + if (isNaN(lat) || isNaN(lon)) return null + if (lat < -90 || lat > 90) return null + if (lon < -180 || lon > 180) return null + + return { lat, lon } +} + /** Get category icon based on result type/source */ function CategoryIcon({ result }) { const type = result.type || '' @@ -71,6 +95,25 @@ const SearchBar = forwardRef(function SearchBar(_, ref) { return } + // Check for coordinate input first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setAutocompleteOpen(true) + setSearchLoading(false) + return + } + // Prepend matching contacts let contactResults = [] if (hasFeature('has_contacts') && contacts.length > 0) { diff --git a/src/store.js b/src/store.js index 2cf78ee..a4039dc 100644 --- a/src/store.js +++ b/src/store.js @@ -1,163 +1,271 @@ -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 }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Unified Route State ── - // Single routing system - all routes go through /api/offroute - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - routeMode: "foot", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteMode: (mode) => set({ routeMode: mode }), - setBoundaryMode: (mode) => set({ boundaryMode: mode }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - clearRoute: () => set({ - routeStart: null, - routeEnd: null, - routeResult: null, - routeError: null - }), - - // ── Legacy compatibility (for components not yet migrated) ── - stops: [], - gpsOrigin: false, - pendingDestination: null, - route: null, - - addStop: (stop) => { - // Legacy: just set as route end point - const { routeStart, setRouteEnd } = get() - const place = { lat: stop.lat, lon: stop.lon, name: stop.name } - if (!routeStart) { - set({ routeStart: place, stops: [{ ...stop, id: crypto.randomUUID() }] }) - } else { - setRouteEnd(place) - set({ stops: [...get().stops, { ...stop, id: crypto.randomUUID() }] }) - } - return true - }, - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - if (newStops.length === 0) { - get().clearRoute() - } - }, - clearStops: () => set({ stops: [], routeStart: null, routeEnd: null }), - setStops: (stops) => set({ stops }), - reorderStops: (newStops) => set({ stops: newStops }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - // Legacy: set as destination - const { routeStart, setRouteEnd, clearRoute } = get() - clearRoute() - set({ - routeEnd: { lat: place.lat, lon: place.lon, name: place.name }, - stops: [{ ...place, id: crypto.randomUUID() }], - selectedPlace: null - }) - }, - - // ── Place detail ── - selectedPlace: null, - clickMarker: null, - - setSelectedPlace: (place) => set({ selectedPlace: place }), - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - - // ── UI state ── - sheetState: 'half', - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', - themeOverride: null, - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', - editingContact: null, - pickingLocationFor: null, - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.routeResult - const hasRoutePoints = !!s.routeStart || !!s.routeEnd - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasRoutePoints) return "ROUTING" - return "IDLE" - }) -} +import { create } from 'zustand' +import { requestOffroute, requestOptimizedRoute } from './api' + +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 }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // Single routing system - all routes go through /api/offroute + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + routeMode: "auto", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + // Map display callback - set by MapView + _updateRouteDisplay: null, + _clearRouteDisplay: null, + setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + + // Mode/boundary setters that trigger recalculation + setRouteMode: (mode) => { + set({ routeMode: mode }) + get().computeRoute() + }, + setBoundaryMode: (mode) => { + set({ boundaryMode: mode }) + get().computeRoute() + }, + + clearRoute: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ + routeStart: null, + routeEnd: null, + routeResult: null, + routeError: null, + stops: [], + route: null + }) + }, + + // ── UNIFIED ROUTING TRIGGER ── + // This is the SINGLE routing function for everything + computeRoute: async () => { + const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + + // Need both endpoints to route + if (!routeStart || !routeEnd) return + + set({ routeLoading: true, routeError: null }) + + try { + const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) + + if (data.status === "ok" && data.route) { + set({ routeResult: data, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(data.route) + } else { + set({ routeError: data.message || data.error || "No route found", routeResult: null }) + } + } catch (e) { + set({ routeError: e.message, routeResult: null }) + } finally { + set({ routeLoading: false }) + } + }, + + // ── Stop list (master compatibility) ── + stops: [], + gpsOrigin: true, // whether GPS should be used as origin when available + pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) + route: null, // Legacy Valhalla response (for 3+ stop optimization) + + addStop: (stop) => { + const { stops, routeMode, _updateRouteDisplay } = get() + if (stops.length >= 10) return false + const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] + set({ stops: newStops }) + + // Route logic depends on stop count + if (newStops.length === 1) { + // Single stop = origin, waiting for second + const origin = newStops[0] + set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) + } else if (newStops.length === 2) { + // Two stops = use offroute (handles on-road and wilderness) + const origin = newStops[0] + const dest = newStops[1] + set({ + routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, + routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } + }) + get().computeRoute() + } else { + // 3+ stops = use Valhalla multi-stop optimization + set({ routeLoading: true, routeError: null }) + const locations = newStops.map((s) => ({ lat: s.lat, lon: s.lon })) + const costing = routeMode === "auto" ? "auto" : routeMode === "foot" ? "pedestrian" : routeMode === "mtb" ? "bicycle" : "auto" + requestOptimizedRoute(locations, costing) + .then((data) => { + if (data.trip) { + set({ route: data.trip, routeError: null }) + // Update display via legacy route handler if available + if (_updateRouteDisplay && data.trip) { + // Multi-stop uses legacy route format, need to convert or use separate handler + } + } + }) + .catch((e) => set({ routeError: e.message })) + .finally(() => set({ routeLoading: false })) + } + + return true + }, + + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + if (newStops.length === 0) { + get().clearRoute() + } else if (newStops.length === 1) { + // Back to single stop + const origin = newStops[0] + set({ + routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, + routeEnd: null, + routeResult: null + }) + } + }, + + reorderStops: (newStops) => set({ stops: newStops }), + + clearStops: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) + }, + + setStops: (stops) => set({ stops }), + + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + // Master startDirections - restored verbatim + startDirections: (place) => { + const { geoPermission, stops, addStop, clearStops } = get() + if (geoPermission === 'granted') { + clearStops() + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + set({ gpsOrigin: true, selectedPlace: null }) + } else if (stops.length > 0) { + const origin = stops[0] + clearStops() + addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) + addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) + set({ selectedPlace: null }) + } else { + // GPS denied, no stops: set pendingDestination only; origin-picker will add both + set({ pendingDestination: place, selectedPlace: null }) + } + }, + + // Legacy route setter (for 3+ stop Valhalla optimization) + setRoute: (route) => set({ route, routeError: null }), + setRouteError: (err) => set({ routeError: err, route: null }), + + // ── Place detail ── + selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } + clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection + + setSelectedPlace: (place) => set({ selectedPlace: place }), + + // Boundary rendering function - set by MapView, called by PlaceCard + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: 'half', // 'collapsed' | 'half' | 'full' + panelOpen: true, + autocompleteOpen: false, + theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) + themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) + viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem('navi-view-mode', mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem('navi-theme-override', override) + } else { + localStorage.removeItem('navi-theme-override') + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: 'routes', // 'routes' | 'contacts' + editingContact: null, // null=closed, {}=new, {id:N}=edit + pickingLocationFor: null, // form data while user picks location on map + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +// Returns string state, prioritizing preview to allow it alongside any route state +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} From 7523ddd0a2478adcbd64de3e3603dd651cd875a8 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 22:44:45 +0000 Subject: [PATCH 03/10] feat: add directions panel with editable origin/destination inputs New UX for Get Directions: - DirectionsPanel component with two stacked input fields - LocationInput component with autocomplete, coordinate parsing - Swap button to flip origin/destination - Travel mode selector (Drive default, Foot, MTB, ATV, 4x4) - Boundary selector (only visible for non-Drive modes) - Map click fills active input field with crosshair cursor - Auto-route when both endpoints are filled - X button closes directions and returns to search view Store changes: - directionsMode state for panel switching - activeDirectionsField for map click targeting - startDirections now enters directions mode with destination pre-filled Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 263 +++++++++++++++++++++++++ src/components/LocationInput.jsx | 301 +++++++++++++++++++++++++++++ src/components/MapView.jsx | 50 ++++- src/components/Panel.jsx | 10 +- src/store.js | 49 +++-- 5 files changed, 656 insertions(+), 17 deletions(-) create mode 100644 src/components/DirectionsPanel.jsx create mode 100644 src/components/LocationInput.jsx diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx new file mode 100644 index 0000000..d2f7db4 --- /dev/null +++ b/src/components/DirectionsPanel.jsx @@ -0,0 +1,263 @@ +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Insert a stop between origin and destination + // For now, this adds to the stops array + // The UI will show intermediate stops + } + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) // Everything except first and last + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message */} + {routeError && ( +
+ {routeError} +
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

+ Enter addresses, paste coordinates, or click the map +

+
+ )} +
+ ) +} diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx new file mode 100644 index 0000000..a15b1bb --- /dev/null +++ b/src/components/LocationInput.jsx @@ -0,0 +1,301 @@ +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" +import { useStore } from "../store" +import { searchGeocode } from "../api" +import { buildAddress } from "../utils/place" +import { hasFeature } from "../config" + +/** Parse coordinate input like "42.35, -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = input.trim().match(pattern) + if (!match) return null + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null + return { lat, lon } +} + +function CategoryIcon({ result, size = 14 }) { + const type = result.type || "" + const source = result.source || "" + if (result._isContact) return + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(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() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca9b5c5..a46eac5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1441,6 +1441,8 @@ const MapView = forwardRef(function MapView(_, ref) { const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) + const directionsMode = useStore((s) => s.directionsMode) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1999,7 +2001,37 @@ const MapView = forwardRef(function MapView(_, ref) { return } - + // Handle directions mode — click fills the active field + const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() + if (directionsMode && activeDirectionsField) { + const { lng, lat } = e.lngLat + // Reverse geocode for name + fetchReverse(lat, lng).then((place) => { + const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } else if (activeDirectionsField.startsWith("stop-")) { + // Handle intermediate stops - would need more logic + setActiveDirectionsField(null) + } + }).catch(() => { + const name = lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } + }) + return + } const store = useStore.getState() const marker = store.clickMarker @@ -2694,6 +2726,22 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) + // Handle directions mode cursor + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (directionsMode && activeDirectionsField) { + map.getCanvas().style.cursor = 'crosshair' + } else if (!measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + return () => { + if (map && !measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + } + }, [directionsMode, activeDirectionsField]) + // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index efc9b5a..a708734 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -7,6 +7,7 @@ import SearchBar from './SearchBar' import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' +import DirectionsPanel from './DirectionsPanel' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -39,6 +40,8 @@ export default function Panel({ onClearRoute }) { const activeTab = useStore((s) => s.activeTab) const auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) + const directionsMode = useStore((s) => s.directionsMode) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) const panelState = usePanelState() @@ -86,7 +89,12 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - const routesContent = ( + const routesContent = directionsMode ? ( + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + ) : ( <> diff --git a/src/store.js b/src/store.js index a4039dc..4ea2839 100644 --- a/src/store.js +++ b/src/store.js @@ -173,23 +173,38 @@ export const useStore = create((set, get) => ({ setPendingDestination: (place) => set({ pendingDestination: place }), clearPendingDestination: () => set({ pendingDestination: null }), - // Master startDirections - restored verbatim + // Master startDirections - enters directions mode with destination pre-filled startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + // Set destination from the clicked place + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, } + + // Set origin from GPS if available + let origin = null + if (geoPermission === 'granted' && userLocation) { + origin = { + lat: userLocation.lat, + lon: userLocation.lon, + name: 'Your location', + source: 'gps', + } + } + + set({ + routeEnd: destination, + routeStart: origin, + directionsMode: true, + activeDirectionsField: origin ? null : 'origin', // Focus origin if empty + selectedPlace: null, + }) }, // Legacy route setter (for 3+ stop Valhalla optimization) @@ -213,6 +228,8 @@ export const useStore = create((set, get) => ({ sheetState: 'half', // 'collapsed' | 'half' | 'full' panelOpen: true, autocompleteOpen: false, + directionsMode: false, // true when directions panel is active + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -224,6 +241,8 @@ export const useStore = create((set, get) => ({ }, setPanelOpen: (open) => set({ panelOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), setTheme: (theme) => set({ theme }), setThemeOverride: (override) => { set({ themeOverride: override }) From a6942b35ea1c58476b81e268351257925ba42442 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 8 May 2026 23:08:38 +0000 Subject: [PATCH 04/10] fix: preserve click coordinates for wilderness routing When clicking on a labeled feature (e.g., "Monument Peak"), the code was using the feature's canonical coordinates instead of the actual click coordinates. This caused wilderness clicks to snap to named places that might be on roads, bypassing wilderness routing. Fix: Always use click coordinates (e.lngLat) for routing purposes. Feature coordinates are only used for display/detail fetching. Co-Authored-By: Claude Opus 4.5 --- src/components/MapView.jsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index a46eac5..00b7bf5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -2199,12 +2199,16 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // Get feature coordinates (Point geometry) - let featureLat = lat - let featureLon = lng + // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) + // Feature coordinates are only for display/fetching details + let featureLat = lat // Click coordinate - used for routing + let featureLon = lng // Click coordinate - used for routing + let displayLat = lat // May be updated to feature coords for display + let displayLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - featureLon = geom.coordinates[0] - featureLat = geom.coordinates[1] + // Store feature's canonical coords separately - NOT for routing + displayLon = geom.coordinates[0] + displayLat = geom.coordinates[1] } // FIX A: For park-type features, also query polygon layers to get boundary geometry From 19a96cba5e42dc2c4730812bfa8d1def4b163dc0 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 03:37:05 +0000 Subject: [PATCH 05/10] feat: improve directions panel with route legend and place card below - Add route legend showing wilderness (dashed orange) vs road (solid blue) - Show place card below directions panel when clicking map during routing - Clean up error messages to be user-friendly (no offroute text) - Legend only appears when route has wilderness segments Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 562 +++++++++++++++-------------- src/components/Panel.jsx | 21 +- 2 files changed, 316 insertions(+), 267 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index d2f7db4..794cad6 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,263 +1,299 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" -import { useStore } from "../store" -import LocationInput from "./LocationInput" -import ManeuverList from "./ManeuverList" - -const TRAVEL_MODES = [ - { id: "auto", label: "Drive", Icon: Car }, - { id: "foot", label: "Foot", Icon: Footprints }, - { id: "mtb", label: "MTB", Icon: Bike }, - { id: "atv", label: "ATV", Icon: Car }, - { id: "vehicle", label: "4x4", Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, - { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, - { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, -] - -export default function DirectionsPanel({ onClose }) { - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const stops = useStore((s) => s.stops) - const userLocation = useStore((s) => s.userLocation) - const geoPermission = useStore((s) => s.geoPermission) - - const setRouteStart = useStore((s) => s.setRouteStart) - const setRouteEnd = useStore((s) => s.setRouteEnd) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const computeRoute = useStore((s) => s.computeRoute) - const clearRoute = useStore((s) => s.clearRoute) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) - const addStop = useStore((s) => s.addStop) - const removeStop = useStore((s) => s.removeStop) - const reorderStops = useStore((s) => s.reorderStops) - - // Auto-fill origin with GPS if available and origin is empty - useEffect(() => { - if (!routeStart && geoPermission === "granted" && userLocation) { - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - } - }, [routeStart, geoPermission, userLocation, setRouteStart]) - - // Auto-compute route when both endpoints are set - useEffect(() => { - if (routeStart && routeEnd) { - computeRoute() - } - }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - // Insert a stop between origin and destination - // For now, this adds to the stops array - // The UI will show intermediate stops - } - - // Multi-stop support: show intermediate stops from the stops array - const intermediateStops = stops.slice(1, -1) // Everything except first and last - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - - - {/* Swap button - positioned between inputs */} - - - {/* Intermediate stops (for multi-stop routes) */} - {intermediateStops.map((stop, idx) => ( -
- { - if (place) { - const newStops = [...stops] - newStops[idx + 1] = { ...newStops[idx + 1], ...place } - reorderStops(newStops) - } else { - removeStop(stop.id) - } - }} - placeholder="Stop" - icon="stop" - fieldId={`stop-${idx}`} - /> -
- ))} - - {/* Destination */} - - - {/* Add stop button */} - {routeStart && routeEnd && stops.length < 10 && ( - - )} -
- - {/* Travel mode selector */} -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {/* Boundary mode selector (only for non-auto modes) */} - {routeMode !== "auto" && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - {/* Loading indicator */} - {routeLoading && ( -
-
- - Finding route... - -
- )} - - {/* Error message */} - {routeError && ( -
- {routeError} -
- )} - - {/* Route summary and maneuvers */} - {routeResult && !routeLoading && ( -
- -
- )} - - {/* Hint when waiting for input */} - {!routeStart && !routeEnd && !routeLoading && ( -
-

- Enter addresses, paste coordinates, or click the map -

-
- )} -
- ) -} +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // For now, show a message - multi-stop UI is complex + // TODO: Implement full multi-stop UI + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message - friendly text, no "offroute" */} + {routeError && ( +
+ {routeError.includes("No route") || routeError.includes("not found") + ? "No route found. Try a different start point or mode." + : routeError.includes("entry point") + ? "No roads found nearby — try Foot mode for trails." + : routeError} +
+ )} + + {/* Route legend - only shown when route has wilderness segment */} + {routeResult && hasWilderness && !routeLoading && ( +
+
+ + + + Wilderness (on foot) +
+
+ + + + Road/Trail +
+
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

+ Enter addresses, paste coordinates, or click the map +

+
+ )} +
+ ) +} diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index a708734..98e9f16 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -90,10 +90,23 @@ export default function Panel({ onClearRoute }) { const showEmptyState = panelState === 'IDLE' && !hasRoutePoints const routesContent = directionsMode ? ( - { - setDirectionsMode(false) - onClearRoute?.() - }} /> + <> + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + {/* Show place card below directions when clicking map during routing */} + {selectedPlace && ( +
+ +
+ )} + ) : ( <> From 816ea8dd1f84380968892cb5d7239dee8449eea2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 06:09:14 +0000 Subject: [PATCH 06/10] feat: wilderness maneuvers, pick-from-map, distance formatting, place card panel - Wilderness maneuvers render with compass arrows and cardinal directions - Network maneuvers prefixed with transport mode (Drive/Walk/Ride) - Distances under 1 mile show feet with commas - Pick-from-map mode replaces auto-fill-on-focus (crosshair + toast) - ESC cancels pick mode - Place card slides out right during active routing - Removed debug toasts Co-Authored-By: Claude Opus 4.5 --- src/api.js | 1 + src/components/LocationInput.jsx | 622 ++++++++++++++++--------------- src/components/ManeuverList.jsx | 233 ++++++++++-- src/components/MapView.jsx | 27 +- src/components/Panel.jsx | 147 ++++++-- src/components/PlaceCard.jsx | 1 + src/store.js | 10 +- 7 files changed, 663 insertions(+), 378 deletions(-) diff --git a/src/api.js b/src/api.js index 47d5861..bed21ec 100644 --- a/src/api.js +++ b/src/api.js @@ -342,6 +342,7 @@ export async function requestOffroute(start, end, mode = "foot", boundaryMode = mode, boundary_mode: boundaryMode, } + console.log('[TRACE-API] requestOffroute body:', JSON.stringify(body)) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 120000) // 2 min timeout for complex routes diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx index a15b1bb..eb0a204 100644 --- a/src/components/LocationInput.jsx +++ b/src/components/LocationInput.jsx @@ -1,301 +1,321 @@ -import { useRef, useEffect, useCallback, useState } from "react" -import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" -import { useStore } from "../store" -import { searchGeocode } from "../api" -import { buildAddress } from "../utils/place" -import { hasFeature } from "../config" - -/** Parse coordinate input like "42.35, -114.30" */ -function parseCoordinates(input) { - if (!input) return null - const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ - const match = input.trim().match(pattern) - if (!match) return null - const lat = parseFloat(match[1]) - const lon = parseFloat(match[2]) - if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null - return { lat, lon } -} - -function CategoryIcon({ result, size = 14 }) { - const type = result.type || "" - const source = result.source || "" - if (result._isContact) return - if (source === "nickname") return - if (type === "coordinates") return - if (type === "locality" || type === "city") return - const osmVal = result.raw?.osm_value || "" - if (osmVal.includes("cafe") || osmVal.includes("coffee")) return - if (osmVal.includes("fuel") || osmVal.includes("gas")) return - if (osmVal.includes("shop") || osmVal.includes("supermarket")) return - if (osmVal.includes("hotel") || osmVal.includes("motel")) return - return -} - -export default function LocationInput({ - value, // { lat, lon, name } or null - onChange, // (place) => void - placeholder, - icon, // "origin" | "destination" | "stop" - fieldId, // unique id for this field (for map click targeting) - onFocus, // () => void - autoFocus, -}) { - const inputRef = useRef(null) - const [query, setQuery] = useState(value?.name || "") - const [results, setResults] = useState([]) - const [loading, setLoading] = useState(false) - const [open, setOpen] = useState(false) - const [activeIndex, setActiveIndex] = useState(-1) - const debounceRef = useRef(null) - const abortRef = useRef(null) - - const contacts = useStore((s) => s.contacts) - const activeDirectionsField = useStore((s) => s.activeDirectionsField) - const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) - - // Sync display value when external value changes - useEffect(() => { - if (value?.name && value.name !== query) { - setQuery(value.name) - } else if (!value && query && !open) { - // Value cleared externally - setQuery("") - } - }, [value?.name, value?.lat, value?.lon]) - - const doSearch = useCallback(async (q) => { - if (abortRef.current) abortRef.current.abort() - - if (!q.trim()) { - setResults([]) - setOpen(false) - setLoading(false) - return - } - - // Check coordinates first - const coords = parseCoordinates(q) - if (coords) { - const coordResult = { - lat: coords.lat, - lon: coords.lon, - name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), - address: "Coordinates", - type: "coordinates", - source: "coordinates", - match_code: null, - raw: {}, - } - setResults([coordResult]) - setOpen(true) - setLoading(false) - return - } - - // Contact matches - let contactResults = [] - if (hasFeature("has_contacts") && contacts.length > 0) { - const lower = q.trim().toLowerCase() - contactResults = contacts - .filter((c) => - (c.label || "").toLowerCase().startsWith(lower) || - (c.name || "").toLowerCase().startsWith(lower) || - (c.call_sign || "").toLowerCase().startsWith(lower) - ) - .slice(0, 3) - .map((c) => ({ - lat: c.lat, - lon: c.lon, - name: c.label, - address: c.address || c.name || "", - type: "contact", - source: "contacts", - match_code: null, - raw: { contact: c }, - _isContact: true, - })) - } - - const ctrl = new AbortController() - abortRef.current = ctrl - setLoading(true) - - try { - const data = await searchGeocode(q.trim(), 5, ctrl.signal) - const combined = [...contactResults, ...(data.results || [])] - setResults(combined) - setOpen(combined.length > 0) - setActiveIndex(-1) - } catch (e) { - if (e.name !== "AbortError") { - if (contactResults.length > 0) { - setResults(contactResults) - setOpen(true) - } else { - setResults([]) - setOpen(false) - } - } - } finally { - setLoading(false) - } - }, [contacts]) - - const handleChange = (e) => { - const val = e.target.value - setQuery(val) - if (debounceRef.current) clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => doSearch(val), 150) - } - - const handleClear = () => { - setQuery("") - setResults([]) - setOpen(false) - onChange(null) - inputRef.current?.focus() - } - - const selectResult = (result) => { - onChange({ - lat: result.lat, - lon: result.lon, - name: result.name, - source: result.source, - matchCode: result.match_code, - }) - setQuery(result.name) - setResults([]) - setOpen(false) - setActiveIndex(-1) - } - - const handleKeyDown = (e) => { - if (!open || results.length === 0) { - if (e.key === "Escape") setOpen(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() - setOpen(false) - setActiveIndex(-1) - break - } - } - - const handleFocus = () => { - setActiveDirectionsField(fieldId) - if (results.length > 0) setOpen(true) - onFocus?.() - } - - const handleBlur = () => { - // Delay to allow click on dropdown - setTimeout(() => setOpen(false), 150) - } - - const isActive = activeDirectionsField === fieldId - - const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" - - return ( -
-
- {icon === "origin" ? ( - - ) : ( - - )} - - {loading ? ( -
- ) : query ? ( - - ) : null} -
- - {open && results.length > 0 && ( -
    - {results.map((r, i) => { - const isPoi = r.type === "poi" && r.raw?.name - const isContact = r._isContact - const primary = isContact ? r.name : isPoi ? r.raw.name : r.name - const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null - return ( -
  • selectResult(r)} - onMouseEnter={() => setActiveIndex(i)} - > -
    - - - - - {primary} - -
    - {secondary && ( -
    - {secondary} -
    - )} -
  • - ) - })} -
- )} -
- ) -} +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2, Target } from "lucide-react" +import toast from "react-hot-toast" +import { useStore } from "../store" +import { searchGeocode } from "../api" +import { buildAddress } from "../utils/place" +import { hasFeature } from "../config" + +/** Parse coordinate input like "42.35, -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = input.trim().match(pattern) + if (!match) return null + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null + return { lat, lon } +} + +function CategoryIcon({ result, size = 14 }) { + const type = result.type || "" + const source = result.source || "" + if (result._isContact) return + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(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() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) // For styling only, not map clicks + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handlePickFromMap = () => { + setPickingRouteField(fieldId) + toast("Click map to set location", { icon: "🎯", duration: 3000 }) + inputRef.current?.blur() // Unfocus input so user focuses on map + } + + const isPicking = pickingRouteField === fieldId + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {/* Pick from map button */} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index a8b90b0..44d1ffc 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,13 +1,32 @@ import { MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, + Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, + ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin } from 'lucide-react' import { useStore } from '../store' -function formatDistKm(km) { - const miles = km * 0.621371 - if (miles < 0.1) return Math.round(miles * 5280) + ' ft' +/** + * Format distance with commas for feet, one decimal for miles. + * Under 1 mile: "2,640 ft" + * 1+ miles: "1.3 mi" + */ +function formatDistance(distanceM, distanceKm) { + let meters = null + if (distanceM !== undefined && distanceM !== null) { + meters = distanceM + } else if (distanceKm !== undefined && distanceKm !== null) { + meters = distanceKm * 1000 + } + + if (meters === null) return '' + + const miles = meters / 1609.34 + if (miles < 1) { + const feet = Math.round(meters * 3.28084) + return feet.toLocaleString() + ' ft' + } return miles.toFixed(1) + ' mi' } @@ -18,6 +37,51 @@ function formatTimeMin(minutes) { return m > 0 ? h + 'h ' + m + 'm' : h + 'h' } +// Compass arrow icon based on cardinal direction with rotation +function CompassIcon({ cardinal, bearing, size = 16 }) { + // Use bearing to rotate arrow, or fall back to cardinal-based icon + if (bearing !== undefined && bearing !== null) { + return ( + + ) + } + + const props = { size, strokeWidth: 2 } + const arrowMap = { + 'N': ArrowUp, + 'NNE': ArrowUpRight, + 'NE': ArrowUpRight, + 'ENE': ArrowRight, + 'E': ArrowRight, + 'ESE': ArrowRight, + 'SE': ArrowDownRight, + 'SSE': ArrowDownRight, + 'S': ArrowDown, + 'SSW': ArrowDownLeft, + 'SW': ArrowDownLeft, + 'WSW': ArrowLeft, + 'W': ArrowLeft, + 'WNW': ArrowLeft, + 'NW': ArrowUpLeft, + 'NNW': ArrowUpLeft, + } + const Icon = arrowMap[cardinal] || Compass + return +} + +// Wilderness maneuver icon +function WildernessIcon({ type, cardinal, bearing, size = 16 }) { + if (type === 'arrival') { + return + } + return +} + +// Network maneuver icon (Valhalla types) function ManeuverIcon({ type }) { const size = 16 const props = { size, strokeWidth: 1.5 } @@ -40,10 +104,55 @@ function ManeuverIcon({ type }) { } } +/** + * Add transport mode prefix to network maneuver instruction. + * "Drive east on..." for auto, "Walk south on..." for foot, "Ride north on..." for mtb + */ +function formatNetworkInstruction(instruction, mode) { + if (!instruction) return '' + + // Get verb based on mode + const modeVerbs = { + 'auto': 'Drive', + 'foot': 'Walk', + 'pedestrian': 'Walk', + 'mtb': 'Ride', + 'bicycle': 'Ride', + 'atv': 'Drive', + 'vehicle': 'Drive', + } + const verb = modeVerbs[mode] || 'Go' + + // Check if instruction starts with a direction verb we should replace + const startsWithVerbs = [ + 'Turn left', 'Turn right', 'Bear left', 'Bear right', + 'Keep left', 'Keep right', 'Continue', 'Head', 'Go', + 'Proceed', 'Make a', 'Take a', 'Start', 'Merge', 'Exit' + ] + + for (const v of startsWithVerbs) { + if (instruction.startsWith(v)) { + // Already has a verb, return as-is (Valhalla instructions are already good) + return instruction + } + } + + // If instruction starts with direction (north, south, etc.), prepend verb + const directions = ['north', 'south', 'east', 'west', 'onto', 'on '] + for (const dir of directions) { + if (instruction.toLowerCase().startsWith(dir)) { + return `${verb} ${instruction}` + } + } + + return instruction +} + export default function ManeuverList() { const routeResult = useStore((s) => s.routeResult) const routeLoading = useStore((s) => s.routeLoading) const routeError = useStore((s) => s.routeError) + const routeMode = useStore((s) => s.routeMode) if (routeLoading) { return ( @@ -77,8 +186,25 @@ export default function ManeuverList() { if (!routeResult?.summary) return null const summary = routeResult.summary - const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') - const maneuvers = networkFeature?.properties?.maneuvers || [] + const features = routeResult.route?.features || [] + const networkMode = summary.network_mode || routeMode || 'foot' + + // Extract maneuvers from each segment type + const wildernessStartFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'start' + ) + const networkFeature = features.find(f => f.properties?.segment_type === 'network') + const wildernessEndFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'end' + ) + + const wildernessStartManeuvers = wildernessStartFeature?.properties?.maneuvers || [] + const networkManeuvers = networkFeature?.properties?.maneuvers || [] + const wildernessEndManeuvers = wildernessEndFeature?.properties?.maneuvers || [] + + const hasManeuvers = wildernessStartManeuvers.length > 0 || + networkManeuvers.length > 0 || + wildernessEndManeuvers.length > 0 return (
@@ -88,7 +214,7 @@ export default function ManeuverList() { style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }} > - {formatDistKm(summary.total_distance_km)} + {formatDistance(null, summary.total_distance_km)} {formatTimeMin(summary.total_effort_minutes)} @@ -102,7 +228,7 @@ export default function ManeuverList() { Wilderness - {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)}
)} @@ -111,7 +237,7 @@ export default function ManeuverList() { Road/Trail - {formatDistKm(summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)}
)} @@ -136,27 +262,80 @@ export default function ManeuverList() { )} {/* Turn-by-turn directions */} - {maneuvers.length > 0 && ( + {hasManeuvers && (
Directions
- {maneuvers.map((man, i) => ( -
- - - -
-

- {man.instruction} -

-

- {formatDistKm(man.distance_km)} -

+ + {/* Wilderness start maneuvers */} + {wildernessStartManeuvers.length > 0 && ( + <> +
+ Wilderness — On Foot
-
- ))} + {wildernessStartManeuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+
+
+ ))} + + )} + + {/* Network maneuvers */} + {networkManeuvers.length > 0 && ( + <> + {wildernessStartManeuvers.length > 0 && ( +
+ Road/Trail +
+ )} + {networkManeuvers.map((man, i) => ( +
+ + + +
+

+ {formatNetworkInstruction(man.instruction, networkMode)} +

+

+ {formatDistance(null, man.distance_km)} +

+
+
+ ))} + + )} + + {/* Wilderness end maneuvers */} + {wildernessEndManeuvers.length > 0 && ( + <> +
+ Wilderness — On Foot +
+ {wildernessEndManeuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

+
+
+ ))} + + )}
)}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 00b7bf5..c77994b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1443,6 +1443,7 @@ const MapView = forwardRef(function MapView(_, ref) { const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) const directionsMode = useStore((s) => s.directionsMode) const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -2001,34 +2002,30 @@ const MapView = forwardRef(function MapView(_, ref) { return } - // Handle directions mode — click fills the active field - const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() - if (directionsMode && activeDirectionsField) { + // Handle explicit pick-from-map mode for route inputs + const { pickingRouteField, setRouteStart, setRouteEnd, clearPickingRouteField } = useStore.getState() + if (pickingRouteField) { const { lng, lat } = e.lngLat + map.getCanvas().style.cursor = '' // Reverse geocode for name fetchReverse(lat, lng).then((place) => { const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) const location = { lat, lon: lng, name, source: "map_click" } - if (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) - } else if (activeDirectionsField.startsWith("stop-")) { - // Handle intermediate stops - would need more logic - setActiveDirectionsField(null) } + clearPickingRouteField() }).catch(() => { const name = lat.toFixed(5) + ", " + lng.toFixed(5) const location = { lat, lon: lng, name, source: "map_click" } - if (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) } + clearPickingRouteField() }) return } @@ -2253,6 +2250,7 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current(polygonGeometry) } + console.log('[TRACE-CLICK] Feature click setSelectedPlace:', { featureLat, featureLon, clickLat: lat, clickLng: lng, name: props.name }) store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2284,6 +2282,7 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) + console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 98e9f16..b89c661 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,5 +1,5 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } from 'lucide-react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react' import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' @@ -8,6 +8,7 @@ import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' import DirectionsPanel from './DirectionsPanel' +import PlaceDetail from './PlaceDetail' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -34,6 +35,8 @@ export default function Panel({ onClearRoute }) { const routeLoading = useStore((s) => s.routeLoading) const setRouteMode = useStore((s) => s.setRouteMode) const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) const clearRoute = useStore((s) => s.clearRoute) const sheetState = useStore((s) => s.sheetState) const setSheetState = useStore((s) => s.setSheetState) @@ -89,29 +92,20 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + // Show side panel place card when building route (either mode) and place is selected + const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace + const routesContent = directionsMode ? ( - <> - { - setDirectionsMode(false) - onClearRoute?.() - }} /> - {/* Show place card below directions when clicking map during routing */} - {selectedPlace && ( -
- -
- )} - + // Directions mode: just the directions panel, place card is shown in side panel + { + setDirectionsMode(false) + onClearRoute?.() + }} /> ) : ( <> - {showPreviewCard && selectedPlace && ( + {showPreviewCard && selectedPlace && !showRouteSection && (
- - {routeStart?.name || 'Right-click to set start'} + + {routeStart?.name || 'Click pin to pick start'} +
- - {routeEnd?.name || 'Right-click to set destination'} + + {routeEnd?.name || 'Click pin to pick destination'} +
@@ -263,19 +273,85 @@ export default function Panel({ onClearRoute }) {
) + // Side panel for place card during directions mode (desktop only) + const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+ {/* Use PlaceCard in compact preview mode */} + +
+ ) + + // Mobile overlay for place card during directions mode + const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+
+ +
+
+ ) + if (!isMobile) { return ( -
- {header} - {content} -
+ <> +
+ {header} + {content} +
+ {sidePlaceCardPanel} + ) } @@ -308,9 +384,10 @@ export default function Panel({ onClearRoute }) {
{sheetState !== 'collapsed' && ( -
+
{header} {content} + {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 3afa72b..84ecb6b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -476,6 +476,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const handleDirections = () => { + console.log('[TRACE-DIRECTIONS] PlaceCard handleDirections, place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) // No toast - empty origin slot is the visual prompt startDirections(place) } diff --git a/src/store.js b/src/store.js index 4ea2839..8b98599 100644 --- a/src/store.js +++ b/src/store.js @@ -72,6 +72,10 @@ export const useStore = create((set, get) => ({ // This is the SINGLE routing function for everything computeRoute: async () => { const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + console.log('[TRACE-ROUTE] computeRoute called with:', { + startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, + endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name + }) // Need both endpoints to route if (!routeStart || !routeEnd) return @@ -175,6 +179,7 @@ export const useStore = create((set, get) => ({ // Master startDirections - enters directions mode with destination pre-filled startDirections: (place) => { + console.log('[TRACE-STORE] startDirections received place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) const { geoPermission, userLocation, clearRoute } = get() clearRoute() @@ -229,7 +234,8 @@ export const useStore = create((set, get) => ({ panelOpen: true, autocompleteOpen: false, directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) + pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -243,6 +249,8 @@ export const useStore = create((set, get) => ({ setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), + setPickingRouteField: (field) => set({ pickingRouteField: field }), + clearPickingRouteField: () => set({ pickingRouteField: null }), setTheme: (theme) => set({ theme }), setThemeOverride: (override) => { set({ themeOverride: override }) From 2345334bc71db886748213fce6dbfb0ad2776fca Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 08:23:38 +0000 Subject: [PATCH 07/10] feat: wire up radial menu directions and multi-stop add button - Radial menu "From here" now sets origin and opens directions panel - Radial menu "To here" now sets destination, opens directions panel, and uses GPS as origin fallback when available - DirectionsPanel "Add stop" button now creates intermediate stops - Stops array initialized from routeStart/routeEnd when adding stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 39 ++++++++++++++++++++++++++++-- src/components/MapView.jsx | 25 +++++++++++++------ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 794cad6..b485636 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -74,8 +74,43 @@ export default function DirectionsPanel({ onClose }) { } const handleAddStop = () => { - // For now, show a message - multi-stop UI is complex - // TODO: Implement full multi-stop UI + // Build stops array from current route endpoints if not already populated + let newStops = [...stops] + + // If stops is empty but we have endpoints, initialize from routeStart/routeEnd + if (newStops.length === 0) { + if (routeStart) { + newStops.push({ + id: crypto.randomUUID(), + lat: routeStart.lat, + lon: routeStart.lon, + name: routeStart.name || "Start", + }) + } + if (routeEnd) { + newStops.push({ + id: crypto.randomUUID(), + lat: routeEnd.lat, + lon: routeEnd.lon, + name: routeEnd.name || "Destination", + }) + } + } + + // Create placeholder intermediate stop + const newStop = { + id: crypto.randomUUID(), + lat: null, + lon: null, + name: "", + } + + // Insert before destination (last position), or at end if no destination + const insertIdx = Math.max(0, newStops.length - 1) + newStops.splice(insertIdx, 0, newStop) + + // Update stops array - reorderStops triggers UI update + reorderStops(newStops) } // Check if route has wilderness segments diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index c77994b..0f13fd1 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1672,13 +1672,24 @@ const MapView = forwardRef(function MapView(_, ref) { lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), } - const { routeStart, setRouteEnd, computeRoute } = useStore.getState() + const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() + setRouteEnd(place) + setDirectionsMode(true) + if (routeStart) { computeRoute() - } else { - toast("Set starting point first") + } else if (geoPermission === "granted" && userLocation) { + // Use GPS as origin fallback + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + computeRoute() } + // If no origin and no GPS, directions panel opens and origin field auto-focuses }, }, { @@ -1692,16 +1703,16 @@ const MapView = forwardRef(function MapView(_, ref) { lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), } - const { clearRoute, setRouteStart, routeEnd, computeRoute } = useStore.getState() + const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() clearRoute() clearRouteDisplay(mapInstance.current) setRouteStart(place) - // If we already have a destination, compute route immediately + setDirectionsMode(true) + if (routeEnd) { computeRoute() - } else { - toast("Now tap destination") } + // If no destination, directions panel opens and destination field auto-focuses }, }, { From 79413014a5cd2fbd6631af4c1298cc04a8f9176a Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 14:59:31 +0000 Subject: [PATCH 08/10] fix: separate stops[] from routeStart/routeEnd for multi-stop routing - stops[] now contains ONLY intermediate waypoints - routeStart and routeEnd are separate sources of truth - addIntermediateStop() adds empty placeholder to stops[] - updateStop() and removeStop() manage intermediate waypoints - computeRoute() chains sequential 2-point routes for multi-stop - DirectionsPanel renders: origin -> stops.map() -> destination - Each intermediate stop has remove button (Trash2 icon) Test scenarios verified: - Origin + destination routes normally (no stops involved) - Add Stop creates empty input between origin and destination - Setting intermediate location triggers route recalculation - Multiple stops can be added sequentially - Removing a stop recalculates route without it - Clear all returns to empty state Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 638 ++++++++++++++--------------- src/store.js | 609 +++++++++++++-------------- 2 files changed, 615 insertions(+), 632 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index b485636..2900e75 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,334 +1,304 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" -import { useStore } from "../store" -import LocationInput from "./LocationInput" -import ManeuverList from "./ManeuverList" - -const TRAVEL_MODES = [ - { id: "auto", label: "Drive", Icon: Car }, - { id: "foot", label: "Foot", Icon: Footprints }, - { id: "mtb", label: "MTB", Icon: Bike }, - { id: "atv", label: "ATV", Icon: Car }, - { id: "vehicle", label: "4x4", Icon: Car }, -] - -const BOUNDARY_MODES = [ - { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, - { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, - { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, -] - -export default function DirectionsPanel({ onClose }) { - const routeStart = useStore((s) => s.routeStart) - const routeEnd = useStore((s) => s.routeEnd) - const routeMode = useStore((s) => s.routeMode) - const boundaryMode = useStore((s) => s.boundaryMode) - const routeResult = useStore((s) => s.routeResult) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - const stops = useStore((s) => s.stops) - const userLocation = useStore((s) => s.userLocation) - const geoPermission = useStore((s) => s.geoPermission) - - const setRouteStart = useStore((s) => s.setRouteStart) - const setRouteEnd = useStore((s) => s.setRouteEnd) - const setRouteMode = useStore((s) => s.setRouteMode) - const setBoundaryMode = useStore((s) => s.setBoundaryMode) - const computeRoute = useStore((s) => s.computeRoute) - const clearRoute = useStore((s) => s.clearRoute) - const setDirectionsMode = useStore((s) => s.setDirectionsMode) - const addStop = useStore((s) => s.addStop) - const removeStop = useStore((s) => s.removeStop) - const reorderStops = useStore((s) => s.reorderStops) - - // Auto-fill origin with GPS if available and origin is empty - useEffect(() => { - if (!routeStart && geoPermission === "granted" && userLocation) { - setRouteStart({ - lat: userLocation.lat, - lon: userLocation.lon, - name: "Your location", - source: "gps", - }) - } - }, [routeStart, geoPermission, userLocation, setRouteStart]) - - // Auto-compute route when both endpoints are set - useEffect(() => { - if (routeStart && routeEnd) { - computeRoute() - } - }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - - const handleClose = () => { - clearRoute() - setDirectionsMode(false) - onClose?.() - } - - const handleAddStop = () => { - // Build stops array from current route endpoints if not already populated - let newStops = [...stops] - - // If stops is empty but we have endpoints, initialize from routeStart/routeEnd - if (newStops.length === 0) { - if (routeStart) { - newStops.push({ - id: crypto.randomUUID(), - lat: routeStart.lat, - lon: routeStart.lon, - name: routeStart.name || "Start", - }) - } - if (routeEnd) { - newStops.push({ - id: crypto.randomUUID(), - lat: routeEnd.lat, - lon: routeEnd.lon, - name: routeEnd.name || "Destination", - }) - } - } - - // Create placeholder intermediate stop - const newStop = { - id: crypto.randomUUID(), - lat: null, - lon: null, - name: "", - } - - // Insert before destination (last position), or at end if no destination - const insertIdx = Math.max(0, newStops.length - 1) - newStops.splice(insertIdx, 0, newStop) - - // Update stops array - reorderStops triggers UI update - reorderStops(newStops) - } - - // Check if route has wilderness segments - const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 - - // Multi-stop support: show intermediate stops from the stops array - const intermediateStops = stops.slice(1, -1) - - return ( -
- {/* Header */} -
- - Directions - - -
- - {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - - - {/* Swap button - positioned between inputs */} - - - {/* Intermediate stops (for multi-stop routes) */} - {intermediateStops.map((stop, idx) => ( -
- { - if (place) { - const newStops = [...stops] - newStops[idx + 1] = { ...newStops[idx + 1], ...place } - reorderStops(newStops) - } else { - removeStop(stop.id) - } - }} - placeholder="Stop" - icon="stop" - fieldId={`stop-${idx}`} - /> -
- ))} - - {/* Destination */} - - - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 10 && ( - - )} -
- - {/* Travel mode selector */} -
- {TRAVEL_MODES.map((m) => { - const active = routeMode === m.id - return ( - - ) - })} -
- - {/* Boundary mode selector (only for non-auto modes) */} - {routeMode !== "auto" && ( -
- {BOUNDARY_MODES.map((m) => { - const active = boundaryMode === m.id - return ( - - ) - })} -
- )} - - {/* Loading indicator */} - {routeLoading && ( -
-
- - Finding route... - -
- )} - - {/* Error message - friendly text, no "offroute" */} - {routeError && ( -
- {routeError.includes("No route") || routeError.includes("not found") - ? "No route found. Try a different start point or mode." - : routeError.includes("entry point") - ? "No roads found nearby — try Foot mode for trails." - : routeError} -
- )} - - {/* Route legend - only shown when route has wilderness segment */} - {routeResult && hasWilderness && !routeLoading && ( -
-
- - - - Wilderness (on foot) -
-
- - - - Road/Trail -
-
- )} - - {/* Route summary and maneuvers */} - {routeResult && !routeLoading && ( -
- -
- )} - - {/* Hint when waiting for input */} - {!routeStart && !routeEnd && !routeLoading && ( -
-

- Enter addresses, paste coordinates, or click the map -

-
- )} -
- ) -} +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addIntermediateStop = useStore((s) => s.addIntermediateStop) + const updateStop = useStore((s) => s.updateStop) + const removeStop = useStore((s) => s.removeStop) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Simply add a new empty intermediate stop + addIntermediateStop() + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Intermediate stops - rendered between origin and destination */} + {stops.map((stop, idx) => ( +
+
+ { + if (place) { + updateStop(stop.id, place) + } + }} + placeholder={`Stop ${idx + 1}`} + icon="stop" + fieldId={`stop-${idx}`} + autoFocus={stop.lat == null} + /> +
+ +
+ ))} + + {/* Swap button - positioned between origin and destination (or after stops) */} + + + {/* Destination */} + + + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message - friendly text, no "offroute" */} + {routeError && ( +
+ {routeError.includes("No route") || routeError.includes("not found") + ? "No route found. Try a different start point or mode." + : routeError.includes("entry point") + ? "No roads found nearby — try Foot mode for trails." + : routeError} +
+ )} + + {/* Route legend - only shown when route has wilderness segment */} + {routeResult && hasWilderness && !routeLoading && ( +
+
+ + + + Wilderness (on foot) +
+
+ + + + Road/Trail +
+
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

+ Enter addresses, paste coordinates, or click the map +

+
+ )} +
+ ) +} diff --git a/src/store.js b/src/store.js index 8b98599..474163f 100644 --- a/src/store.js +++ b/src/store.js @@ -1,298 +1,311 @@ -import { create } from 'zustand' -import { requestOffroute, requestOptimizedRoute } from './api' - -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 }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Unified Route State ── - // Single routing system - all routes go through /api/offroute - routeStart: null, // { lat, lon, name } - routeEnd: null, // { lat, lon, name } - routeMode: "auto", // foot | mtb | atv | vehicle - boundaryMode: "strict", // strict | pragmatic | emergency - routeResult: null, // Response from /api/offroute - routeLoading: false, - routeError: null, - - // Map display callback - set by MapView - _updateRouteDisplay: null, - _clearRouteDisplay: null, - setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), - - setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), - setRouteEnd: (place) => set({ routeEnd: place }), - setRouteResult: (result) => set({ routeResult: result, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, routeResult: null }), - - // Mode/boundary setters that trigger recalculation - setRouteMode: (mode) => { - set({ routeMode: mode }) - get().computeRoute() - }, - setBoundaryMode: (mode) => { - set({ boundaryMode: mode }) - get().computeRoute() - }, - - clearRoute: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ - routeStart: null, - routeEnd: null, - routeResult: null, - routeError: null, - stops: [], - route: null - }) - }, - - // ── UNIFIED ROUTING TRIGGER ── - // This is the SINGLE routing function for everything - computeRoute: async () => { - const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() - console.log('[TRACE-ROUTE] computeRoute called with:', { - startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, - endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name - }) - - // Need both endpoints to route - if (!routeStart || !routeEnd) return - - set({ routeLoading: true, routeError: null }) - - try { - const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) - - if (data.status === "ok" && data.route) { - set({ routeResult: data, routeError: null }) - if (_updateRouteDisplay) _updateRouteDisplay(data.route) - } else { - set({ routeError: data.message || data.error || "No route found", routeResult: null }) - } - } catch (e) { - set({ routeError: e.message, routeResult: null }) - } finally { - set({ routeLoading: false }) - } - }, - - // ── Stop list (master compatibility) ── - stops: [], - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - route: null, // Legacy Valhalla response (for 3+ stop optimization) - - addStop: (stop) => { - const { stops, routeMode, _updateRouteDisplay } = get() - if (stops.length >= 10) return false - const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] - set({ stops: newStops }) - - // Route logic depends on stop count - if (newStops.length === 1) { - // Single stop = origin, waiting for second - const origin = newStops[0] - set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) - } else if (newStops.length === 2) { - // Two stops = use offroute (handles on-road and wilderness) - const origin = newStops[0] - const dest = newStops[1] - set({ - routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, - routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } - }) - get().computeRoute() - } else { - // 3+ stops = use Valhalla multi-stop optimization - set({ routeLoading: true, routeError: null }) - const locations = newStops.map((s) => ({ lat: s.lat, lon: s.lon })) - const costing = routeMode === "auto" ? "auto" : routeMode === "foot" ? "pedestrian" : routeMode === "mtb" ? "bicycle" : "auto" - requestOptimizedRoute(locations, costing) - .then((data) => { - if (data.trip) { - set({ route: data.trip, routeError: null }) - // Update display via legacy route handler if available - if (_updateRouteDisplay && data.trip) { - // Multi-stop uses legacy route format, need to convert or use separate handler - } - } - }) - .catch((e) => set({ routeError: e.message })) - .finally(() => set({ routeLoading: false })) - } - - return true - }, - - removeStop: (id) => { - const { stops } = get() - const newStops = stops.filter((s) => s.id !== id) - set({ stops: newStops }) - if (newStops.length === 0) { - get().clearRoute() - } else if (newStops.length === 1) { - // Back to single stop - const origin = newStops[0] - set({ - routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, - routeEnd: null, - routeResult: null - }) - } - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => { - const { _clearRouteDisplay } = get() - if (_clearRouteDisplay) _clearRouteDisplay() - set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) - }, - - setStops: (stops) => set({ stops }), - - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - // Master startDirections - enters directions mode with destination pre-filled - startDirections: (place) => { - console.log('[TRACE-STORE] startDirections received place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) - const { geoPermission, userLocation, clearRoute } = get() - clearRoute() - - // Set destination from the clicked place - const destination = { - lat: place.lat, - lon: place.lon, - name: place.name, - source: place.source, - matchCode: place.matchCode, - } - - // Set origin from GPS if available - let origin = null - if (geoPermission === 'granted' && userLocation) { - origin = { - lat: userLocation.lat, - lon: userLocation.lon, - name: 'Your location', - source: 'gps', - } - } - - set({ - routeEnd: destination, - routeStart: origin, - directionsMode: true, - activeDirectionsField: origin ? null : 'origin', // Focus origin if empty - selectedPlace: null, - }) - }, - - // Legacy route setter (for 3+ stop Valhalla optimization) - setRoute: (route) => set({ route, routeError: null }), - setRouteError: (err) => set({ routeError: err, route: null }), - - // ── Place detail ── - selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } - clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) - pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), - setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), - setPickingRouteField: (field) => set({ pickingRouteField: field }), - clearPickingRouteField: () => set({ pickingRouteField: null }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.routeResult - const hasRoutePoints = !!s.routeStart || !!s.routeEnd - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasRoutePoints) return "ROUTING" - return "IDLE" - }) -} +import { create } from "zustand" +import { requestOffroute } from "./api" + +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 }), + + // ── Geolocation ── + userLocation: null, // { lat, lon } + geoPermission: "prompt", // "prompt" | "granted" | "denied" + + setUserLocation: (loc) => set({ userLocation: loc }), + setGeoPermission: (p) => set({ geoPermission: p }), + + // ── Map viewport (for search bias) ── + mapCenter: null, // { lat, lon, zoom } + setMapCenter: (center) => set({ mapCenter: center }), + + // ── Unified Route State ── + // routeStart = origin (source of truth) + // routeEnd = destination (source of truth) + // stops[] = ONLY intermediate waypoints (not origin/destination) + routeStart: null, // { lat, lon, name } + routeEnd: null, // { lat, lon, name } + stops: [], // Intermediate waypoints only: [{ id, lat, lon, name }, ...] + routeMode: "auto", // foot | mtb | atv | vehicle + boundaryMode: "strict", // strict | pragmatic | emergency + routeResult: null, // Response from /api/offroute + routeLoading: false, + routeError: null, + + // Map display callback - set by MapView + _updateRouteDisplay: null, + _clearRouteDisplay: null, + setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), + + setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), + setRouteEnd: (place) => set({ routeEnd: place }), + setRouteResult: (result) => set({ routeResult: result, routeError: null }), + setRouteLoading: (loading) => set({ routeLoading: loading }), + setRouteError: (err) => set({ routeError: err, routeResult: null }), + + // Mode/boundary setters that trigger recalculation + setRouteMode: (mode) => { + set({ routeMode: mode }) + get().computeRoute() + }, + setBoundaryMode: (mode) => { + set({ boundaryMode: mode }) + get().computeRoute() + }, + + clearRoute: () => { + const { _clearRouteDisplay } = get() + if (_clearRouteDisplay) _clearRouteDisplay() + set({ + routeStart: null, + routeEnd: null, + stops: [], + routeResult: null, + routeError: null, + }) + }, + + // ── INTERMEDIATE STOPS MANAGEMENT ── + // stops[] contains ONLY intermediate waypoints, not origin/destination + + addIntermediateStop: () => { + const { stops } = get() + if (stops.length >= 8) return false // Max 8 intermediate stops + const newStop = { + id: crypto.randomUUID(), + lat: null, + lon: null, + name: "", + } + set({ stops: [...stops, newStop] }) + return true + }, + + updateStop: (id, place) => { + const { stops } = get() + const newStops = stops.map((s) => + s.id === id ? { ...s, lat: place.lat, lon: place.lon, name: place.name } : s + ) + set({ stops: newStops }) + // Trigger route recalculation if all waypoints have coordinates + get().computeRoute() + }, + + removeStop: (id) => { + const { stops } = get() + const newStops = stops.filter((s) => s.id !== id) + set({ stops: newStops }) + // Recalculate route without this stop + get().computeRoute() + }, + + setStops: (stops) => set({ stops }), + + // ── UNIFIED ROUTING TRIGGER ── + // Handles both 2-point and multi-point routing + computeRoute: async () => { + const { routeStart, routeEnd, stops, routeMode, boundaryMode, _updateRouteDisplay } = get() + + // Need both endpoints to route + if (!routeStart || !routeEnd) return + + // Filter out incomplete stops (no coordinates yet) + const validStops = stops.filter((s) => s.lat != null && s.lon != null) + + // Build full waypoint list: [origin, ...intermediates, destination] + const waypoints = [ + routeStart, + ...validStops, + routeEnd, + ] + + console.log("[TRACE-ROUTE] computeRoute with waypoints:", waypoints.length, waypoints.map(w => w.name)) + + set({ routeLoading: true, routeError: null }) + + try { + if (waypoints.length === 2) { + // Simple 2-point routing + const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) + if (data.status === "ok" && data.route) { + set({ routeResult: data, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(data.route) + } else { + set({ routeError: data.message || data.error || "No route found", routeResult: null }) + } + } else { + // Multi-point routing: chain sequential 2-point routes and merge + const segments = [] + let totalDistanceKm = 0 + let totalEffortMinutes = 0 + let allFeatures = [] + + for (let i = 0; i < waypoints.length - 1; i++) { + const from = waypoints[i] + const to = waypoints[i + 1] + const segmentData = await requestOffroute(from, to, routeMode, boundaryMode) + + if (segmentData.status !== "ok" || !segmentData.route) { + throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint")) + } + + segments.push(segmentData) + + // Accumulate totals + if (segmentData.summary) { + totalDistanceKm += segmentData.summary.total_distance_km || 0 + totalEffortMinutes += segmentData.summary.total_effort_minutes || 0 + } + + // Collect features + if (segmentData.route?.features) { + allFeatures.push(...segmentData.route.features) + } + } + + // Build merged result + const mergedResult = { + status: "ok", + summary: { + total_distance_km: totalDistanceKm, + total_effort_minutes: totalEffortMinutes, + waypoint_count: waypoints.length, + }, + route: { + type: "FeatureCollection", + features: allFeatures, + }, + } + + set({ routeResult: mergedResult, routeError: null }) + if (_updateRouteDisplay) _updateRouteDisplay(mergedResult.route) + } + } catch (e) { + set({ routeError: e.message, routeResult: null }) + } finally { + set({ routeLoading: false }) + } + }, + + // ── Legacy compatibility ── + gpsOrigin: true, + pendingDestination: null, + setGpsOrigin: (val) => set({ gpsOrigin: val }), + setPendingDestination: (place) => set({ pendingDestination: place }), + clearPendingDestination: () => set({ pendingDestination: null }), + + // Master startDirections - enters directions mode with destination pre-filled + startDirections: (place) => { + console.log("[TRACE-STORE] startDirections received place:", { lat: place?.lat, lon: place?.lon, name: place?.name }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, + } + + let origin = null + if (geoPermission === "granted" && userLocation) { + origin = { + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + } + } + + set({ + routeEnd: destination, + routeStart: origin, + directionsMode: true, + activeDirectionsField: origin ? null : "origin", + selectedPlace: null, + }) + }, + + // ── Place detail ── + selectedPlace: null, + clickMarker: null, + + setSelectedPlace: (place) => set({ selectedPlace: place }), + updateBoundary: null, + setUpdateBoundary: (fn) => set({ updateBoundary: fn }), + clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), + setClickMarker: (marker) => set({ clickMarker: marker }), + clearClickMarker: () => set({ clickMarker: null }), + + // ── UI state ── + sheetState: "half", + panelOpen: true, + autocompleteOpen: false, + directionsMode: false, + activeDirectionsField: null, + pickingRouteField: null, + theme: "dark", + themeOverride: null, + viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map", + + setSheetState: (s) => set({ sheetState: s }), + setViewMode: (mode) => { + set({ viewMode: mode }) + localStorage.setItem("navi-view-mode", mode) + }, + setPanelOpen: (open) => set({ panelOpen: open }), + setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? "origin" : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), + setPickingRouteField: (field) => set({ pickingRouteField: field }), + clearPickingRouteField: () => set({ pickingRouteField: null }), + setTheme: (theme) => set({ theme }), + setThemeOverride: (override) => { + set({ themeOverride: override }) + if (override) { + localStorage.setItem("navi-theme-override", override) + } else { + localStorage.removeItem("navi-theme-override") + } + }, + + // ── Auth state ── + auth: { authenticated: false, username: null, loaded: false }, + setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), + + // ── Contacts ── + contacts: [], + contactsLoaded: false, + activeTab: "routes", + editingContact: null, + pickingLocationFor: null, + + setContacts: (c) => set({ contacts: c, contactsLoaded: true }), + setActiveTab: (tab) => set({ activeTab: tab }), + setEditingContact: (c) => set({ editingContact: c }), + clearEditingContact: () => set({ editingContact: null }), + setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), + clearPickingLocationFor: () => set({ pickingLocationFor: null }), +})) + +// ── Panel state selector ── +export const usePanelState = () => { + return useStore((s) => { + const hasPreview = !!s.selectedPlace + const hasRoute = !!s.routeResult + const hasRoutePoints = !!s.routeStart || !!s.routeEnd + + if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" + if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" + if (hasPreview) return "PREVIEW" + if (hasRoute) return "ROUTE_CALCULATED" + if (hasRoutePoints) return "ROUTING" + return "IDLE" + }) +} From 0942b10b270652a0f850a6a9b61a33db3eed6c87 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:14:08 +0000 Subject: [PATCH 09/10] fix: swap button layout and add stop reorder buttons - Swap button now inline on origin row (not absolute positioned) - Swap button no longer overlaps intermediate stop controls - Added up/down chevron buttons on each intermediate stop row - Reordering stops triggers route recalculation - Destination row has spacer to align with origin row Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 123 ++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 38 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 2900e75..44c0f98 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,5 @@ import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" import { useStore } from "../store" import LocationInput from "./LocationInput" import ManeuverList from "./ManeuverList" @@ -40,6 +40,7 @@ export default function DirectionsPanel({ onClose }) { const addIntermediateStop = useStore((s) => s.addIntermediateStop) const updateStop = useStore((s) => s.updateStop) const removeStop = useStore((s) => s.removeStop) + const setStops = useStore((s) => s.setStops) // Auto-fill origin with GPS if available and origin is empty useEffect(() => { @@ -74,10 +75,29 @@ export default function DirectionsPanel({ onClose }) { } const handleAddStop = () => { - // Simply add a new empty intermediate stop addIntermediateStop() } + const handleMoveStopUp = (idx) => { + if (idx === 0) return + const newStops = [...stops] + const temp = newStops[idx] + newStops[idx] = newStops[idx - 1] + newStops[idx - 1] = temp + setStops(newStops) + computeRoute() + } + + const handleMoveStopDown = (idx) => { + if (idx >= stops.length - 1) return + const newStops = [...stops] + const temp = newStops[idx] + newStops[idx] = newStops[idx + 1] + newStops[idx + 1] = temp + setStops(newStops) + computeRoute() + } + // Check if route has wilderness segments const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 @@ -97,21 +117,37 @@ export default function DirectionsPanel({ onClose }) {
- {/* Origin/Destination inputs with swap button */} -
- {/* Origin */} - + {/* Origin/Destination inputs */} +
+ {/* Origin row with swap button on right */} +
+
+ +
+ {/* Swap button - only on origin row, swaps origin and destination */} + +
{/* Intermediate stops - rendered between origin and destination */} {stops.map((stop, idx) => ( -
+
+ {/* Reorder buttons */} +
+ + +
+ {/* Remove button */}
))} - {/* Swap button - positioned between origin and destination (or after stops) */} - - - {/* Destination */} - + {/* Destination row */} +
+
+ +
+ {/* Spacer to align with origin row swap button */} +
+
{/* Add stop button - only show when route exists */} {routeStart && routeEnd && stops.length < 8 && ( From bc453ff375a79a57795ce4496008e13d3c4440f9 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 15:40:12 +0000 Subject: [PATCH 10/10] feat: drag-and-drop stop reordering and fix radial add-stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - addIntermediateStop() now accepts optional place parameter - Radial menu add-stop wedge uses addIntermediateStop with coordinates - Replaced up/down chevron buttons with @dnd-kit drag-and-drop - All rows (origin, stops, destination) can be reordered by dragging - GripVertical drag handle on left of each row - On drag end: first item → origin, last → destination, middle → stops Co-Authored-By: Claude Opus 4.5 --- src/components/DirectionsPanel.jsx | 320 +++++++++++++++++------------ src/components/MapView.jsx | 18 +- src/store.js | 11 +- 3 files changed, 208 insertions(+), 141 deletions(-) diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx index 44c0f98..a01f1c9 100644 --- a/src/components/DirectionsPanel.jsx +++ b/src/components/DirectionsPanel.jsx @@ -1,5 +1,8 @@ -import { useEffect } from "react" -import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, ChevronUp, ChevronDown } from "lucide-react" +import { useEffect, useMemo } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react" +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { useStore } from "../store" import LocationInput from "./LocationInput" import ManeuverList from "./ManeuverList" @@ -18,6 +21,40 @@ const BOUNDARY_MODES = [ { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, ] +// Sortable row component +function SortableRow({ id, children }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 1, + } + + return ( +
+ {/* Drag handle */} + + {children} +
+ ) +} + export default function DirectionsPanel({ onClose }) { const routeStart = useStore((s) => s.routeStart) const routeEnd = useStore((s) => s.routeEnd) @@ -42,6 +79,36 @@ export default function DirectionsPanel({ onClose }) { const removeStop = useStore((s) => s.removeStop) const setStops = useStore((s) => s.setStops) + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // Build unified list for drag-and-drop: origin + stops + destination + // Each item has: { id, type, data } + const unifiedList = useMemo(() => { + const items = [] + if (routeStart) { + items.push({ id: "origin", type: "origin", data: routeStart }) + } + stops.forEach((stop) => { + items.push({ id: stop.id, type: "stop", data: stop }) + }) + if (routeEnd) { + items.push({ id: "destination", type: "destination", data: routeEnd }) + } + return items + }, [routeStart, stops, routeEnd]) + + const itemIds = useMemo(() => unifiedList.map((item) => item.id), [unifiedList]) + // Auto-fill origin with GPS if available and origin is empty useEffect(() => { if (!routeStart && geoPermission === "granted" && userLocation) { @@ -61,13 +128,6 @@ export default function DirectionsPanel({ onClose }) { } }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) - const handleSwap = () => { - const tempStart = routeStart - const tempEnd = routeEnd - setRouteStart(tempEnd) - setRouteEnd(tempStart) - } - const handleClose = () => { clearRoute() setDirectionsMode(false) @@ -78,24 +138,56 @@ export default function DirectionsPanel({ onClose }) { addIntermediateStop() } - const handleMoveStopUp = (idx) => { - if (idx === 0) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx - 1] - newStops[idx - 1] = temp - setStops(newStops) - computeRoute() - } + // Handle drag end - reorder the unified list + const handleDragEnd = (event) => { + const { active, over } = event + if (!over || active.id === over.id) return - const handleMoveStopDown = (idx) => { - if (idx >= stops.length - 1) return - const newStops = [...stops] - const temp = newStops[idx] - newStops[idx] = newStops[idx + 1] - newStops[idx + 1] = temp + const oldIndex = unifiedList.findIndex((item) => item.id === active.id) + const newIndex = unifiedList.findIndex((item) => item.id === over.id) + + if (oldIndex === -1 || newIndex === -1) return + + // Reorder the unified list + const reordered = arrayMove(unifiedList, oldIndex, newIndex) + + // Extract new origin, stops, and destination from reordered list + // First item becomes origin, last becomes destination, middle are stops + if (reordered.length === 0) return + + const newOriginItem = reordered[0] + const newDestItem = reordered.length > 1 ? reordered[reordered.length - 1] : null + const newStopItems = reordered.length > 2 ? reordered.slice(1, -1) : [] + + // Convert items to proper format + const newOrigin = newOriginItem.data ? { + lat: newOriginItem.data.lat, + lon: newOriginItem.data.lon, + name: newOriginItem.data.name, + source: newOriginItem.data.source, + } : null + + const newDest = newDestItem?.data ? { + lat: newDestItem.data.lat, + lon: newDestItem.data.lon, + name: newDestItem.data.name, + source: newDestItem.data.source, + } : null + + const newStops = newStopItems.map((item) => ({ + id: item.id === "origin" || item.id === "destination" ? crypto.randomUUID() : item.id, + lat: item.data?.lat ?? null, + lon: item.data?.lon ?? null, + name: item.data?.name ?? "", + })) + + // Update state + setRouteStart(newOrigin) + setRouteEnd(newDest) setStops(newStops) - computeRoute() + + // Trigger route recalculation + setTimeout(() => computeRoute(), 0) } // Check if route has wilderness segments @@ -117,113 +209,87 @@ export default function DirectionsPanel({ onClose }) {
- {/* Origin/Destination inputs */} -
- {/* Origin row with swap button on right */} -
-
- -
- {/* Swap button - only on origin row, swaps origin and destination */} - -
+ {/* Drag-and-drop location list */} + + +
+ {unifiedList.map((item, idx) => ( + +
+ {item.type === "origin" && ( + + )} + {item.type === "destination" && ( + + )} + {item.type === "stop" && ( + { + if (place) { + updateStop(item.id, place) + } + }} + placeholder={`Stop ${idx}`} + icon="stop" + fieldId={`stop-${item.id}`} + autoFocus={item.data.lat == null} + /> + )} +
+ {/* Remove button for intermediate stops only */} + {item.type === "stop" && ( + + )} + {/* Spacer for origin/destination to align with stops that have remove button */} + {item.type !== "stop" && ( +
+ )} + + ))} - {/* Intermediate stops - rendered between origin and destination */} - {stops.map((stop, idx) => ( -
-
- { - if (place) { - updateStop(stop.id, place) - } + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( +
- {/* Reorder buttons */} -
- - -
- {/* Remove button */} - + )}
- ))} - - {/* Destination row */} -
-
- -
- {/* Spacer to align with origin row swap button */} -
-
- - {/* Add stop button - only show when route exists */} - {routeStart && routeEnd && stops.length < 8 && ( - - )} -
+ + {/* Travel mode selector */}
diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0f13fd1..6f6d26b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1721,22 +1721,20 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop } = useStore.getState() + const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") + const success = addIntermediateStop(place) + if (success) { + // If we have both origin and destination, recalculate route + if (routeStart && routeEnd) { + computeRoute() } + } else { + toast("Maximum 8 intermediate stops reached") } }, }, diff --git a/src/store.js b/src/store.js index 474163f..069be9f 100644 --- a/src/store.js +++ b/src/store.js @@ -73,14 +73,17 @@ export const useStore = create((set, get) => ({ // ── INTERMEDIATE STOPS MANAGEMENT ── // stops[] contains ONLY intermediate waypoints, not origin/destination - addIntermediateStop: () => { + // Add intermediate stop - can be called with or without place + // With place: creates pre-filled stop (from radial menu) + // Without place: creates empty placeholder (from Add Stop button) + addIntermediateStop: (place) => { const { stops } = get() if (stops.length >= 8) return false // Max 8 intermediate stops const newStop = { id: crypto.randomUUID(), - lat: null, - lon: null, - name: "", + lat: place?.lat ?? null, + lon: place?.lon ?? null, + name: place?.name ?? "", } set({ stops: [...stops, newStop] }) return true