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 && (
-
-
- Add stop
-
- )}
-
-
- {/* Travel mode selector */}
-
- {TRAVEL_MODES.map((m) => {
- const active = routeMode === m.id
- return (
- setRouteMode(m.id)}
- className="flex-1 flex items-center justify-center gap-1 py-2 text-xs rounded-lg transition-colors"
- style={{
- background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
- color: active ? "var(--accent)" : "var(--text-tertiary)",
- }}
- title={m.label}
- >
-
- {m.label}
-
- )
- })}
-
-
- {/* Boundary mode selector (only for non-auto modes) */}
- {routeMode !== "auto" && (
-
- {BOUNDARY_MODES.map((m) => {
- const active = boundaryMode === m.id
- return (
- setBoundaryMode(m.id)}
- className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg transition-colors"
- style={{
- background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
- color: active ? "var(--accent)" : "var(--text-tertiary)",
- }}
- title={m.title}
- >
-
- {m.label}
-
- )
- })}
-
- )}
-
- {/* 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}
+ />
+
+
removeStop(stop.id)}
+ className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
+ title="Remove stop"
+ >
+
+
+
+ ))}
+
+ {/* Swap button - positioned between origin and destination (or after stops) */}
+
+
+
+
+ {/* Destination */}
+
+
+ {/* Add stop button - only show when route exists */}
+ {routeStart && routeEnd && stops.length < 8 && (
+
+
+ Add stop
+
+ )}
+
+
+ {/* Travel mode selector */}
+
+ {TRAVEL_MODES.map((m) => {
+ const active = routeMode === m.id
+ return (
+ setRouteMode(m.id)}
+ className="flex-1 flex items-center justify-center gap-1 py-2 text-xs rounded-lg transition-colors"
+ style={{
+ background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
+ color: active ? "var(--accent)" : "var(--text-tertiary)",
+ }}
+ title={m.label}
+ >
+
+ {m.label}
+
+ )
+ })}
+
+
+ {/* Boundary mode selector (only for non-auto modes) */}
+ {routeMode !== "auto" && (
+
+ {BOUNDARY_MODES.map((m) => {
+ const active = boundaryMode === m.id
+ return (
+ setBoundaryMode(m.id)}
+ className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg transition-colors"
+ style={{
+ background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
+ color: active ? "var(--accent)" : "var(--text-tertiary)",
+ }}
+ title={m.title}
+ >
+
+ {m.label}
+
+ )
+ })}
+
+ )}
+
+ {/* 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"
+ })
+}