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"
+ })
+}