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 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-09 14:59:31 +00:00
commit 79413014a5
2 changed files with 615 additions and 632 deletions

View file

@ -1,334 +1,304 @@
import { useEffect } from "react" import { useEffect } from "react"
import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2 } from "lucide-react"
import { useStore } from "../store" import { useStore } from "../store"
import LocationInput from "./LocationInput" import LocationInput from "./LocationInput"
import ManeuverList from "./ManeuverList" import ManeuverList from "./ManeuverList"
const TRAVEL_MODES = [ const TRAVEL_MODES = [
{ id: "auto", label: "Drive", Icon: Car }, { id: "auto", label: "Drive", Icon: Car },
{ id: "foot", label: "Foot", Icon: Footprints }, { id: "foot", label: "Foot", Icon: Footprints },
{ id: "mtb", label: "MTB", Icon: Bike }, { id: "mtb", label: "MTB", Icon: Bike },
{ id: "atv", label: "ATV", Icon: Car }, { id: "atv", label: "ATV", Icon: Car },
{ id: "vehicle", label: "4x4", Icon: Car }, { id: "vehicle", label: "4x4", Icon: Car },
] ]
const BOUNDARY_MODES = [ const BOUNDARY_MODES = [
{ id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" },
{ id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" },
{ id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" },
] ]
export default function DirectionsPanel({ onClose }) { export default function DirectionsPanel({ onClose }) {
const routeStart = useStore((s) => s.routeStart) const routeStart = useStore((s) => s.routeStart)
const routeEnd = useStore((s) => s.routeEnd) const routeEnd = useStore((s) => s.routeEnd)
const routeMode = useStore((s) => s.routeMode) const routeMode = useStore((s) => s.routeMode)
const boundaryMode = useStore((s) => s.boundaryMode) const boundaryMode = useStore((s) => s.boundaryMode)
const routeResult = useStore((s) => s.routeResult) const routeResult = useStore((s) => s.routeResult)
const routeLoading = useStore((s) => s.routeLoading) const routeLoading = useStore((s) => s.routeLoading)
const routeError = useStore((s) => s.routeError) const routeError = useStore((s) => s.routeError)
const stops = useStore((s) => s.stops) const stops = useStore((s) => s.stops)
const userLocation = useStore((s) => s.userLocation) const userLocation = useStore((s) => s.userLocation)
const geoPermission = useStore((s) => s.geoPermission) const geoPermission = useStore((s) => s.geoPermission)
const setRouteStart = useStore((s) => s.setRouteStart) const setRouteStart = useStore((s) => s.setRouteStart)
const setRouteEnd = useStore((s) => s.setRouteEnd) const setRouteEnd = useStore((s) => s.setRouteEnd)
const setRouteMode = useStore((s) => s.setRouteMode) const setRouteMode = useStore((s) => s.setRouteMode)
const setBoundaryMode = useStore((s) => s.setBoundaryMode) const setBoundaryMode = useStore((s) => s.setBoundaryMode)
const computeRoute = useStore((s) => s.computeRoute) const computeRoute = useStore((s) => s.computeRoute)
const clearRoute = useStore((s) => s.clearRoute) const clearRoute = useStore((s) => s.clearRoute)
const setDirectionsMode = useStore((s) => s.setDirectionsMode) const setDirectionsMode = useStore((s) => s.setDirectionsMode)
const addStop = useStore((s) => s.addStop) const addIntermediateStop = useStore((s) => s.addIntermediateStop)
const removeStop = useStore((s) => s.removeStop) const updateStop = useStore((s) => s.updateStop)
const reorderStops = useStore((s) => s.reorderStops) const removeStop = useStore((s) => s.removeStop)
// Auto-fill origin with GPS if available and origin is empty // Auto-fill origin with GPS if available and origin is empty
useEffect(() => { useEffect(() => {
if (!routeStart && geoPermission === "granted" && userLocation) { if (!routeStart && geoPermission === "granted" && userLocation) {
setRouteStart({ setRouteStart({
lat: userLocation.lat, lat: userLocation.lat,
lon: userLocation.lon, lon: userLocation.lon,
name: "Your location", name: "Your location",
source: "gps", source: "gps",
}) })
} }
}, [routeStart, geoPermission, userLocation, setRouteStart]) }, [routeStart, geoPermission, userLocation, setRouteStart])
// Auto-compute route when both endpoints are set // Auto-compute route when both endpoints are set
useEffect(() => { useEffect(() => {
if (routeStart && routeEnd) { if (routeStart && routeEnd) {
computeRoute() computeRoute()
} }
}, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon])
const handleSwap = () => { const handleSwap = () => {
const tempStart = routeStart const tempStart = routeStart
const tempEnd = routeEnd const tempEnd = routeEnd
setRouteStart(tempEnd) setRouteStart(tempEnd)
setRouteEnd(tempStart) setRouteEnd(tempStart)
} }
const handleClose = () => { const handleClose = () => {
clearRoute() clearRoute()
setDirectionsMode(false) setDirectionsMode(false)
onClose?.() onClose?.()
} }
const handleAddStop = () => { const handleAddStop = () => {
// Build stops array from current route endpoints if not already populated // Simply add a new empty intermediate stop
let newStops = [...stops] addIntermediateStop()
}
// If stops is empty but we have endpoints, initialize from routeStart/routeEnd
if (newStops.length === 0) { // Check if route has wilderness segments
if (routeStart) { const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0
newStops.push({
id: crypto.randomUUID(), return (
lat: routeStart.lat, <div className="flex flex-col gap-3">
lon: routeStart.lon, {/* Header */}
name: routeStart.name || "Start", <div className="flex items-center justify-between">
}) <span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
} Directions
if (routeEnd) { </span>
newStops.push({ <button
id: crypto.randomUUID(), onClick={handleClose}
lat: routeEnd.lat, className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors"
lon: routeEnd.lon, title="Close directions"
name: routeEnd.name || "Destination", >
}) <X size={18} style={{ color: "var(--text-tertiary)" }} />
} </button>
} </div>
// Create placeholder intermediate stop {/* Origin/Destination inputs with swap button */}
const newStop = { <div className="relative flex flex-col gap-2">
id: crypto.randomUUID(), {/* Origin */}
lat: null, <LocationInput
lon: null, value={routeStart}
name: "", onChange={setRouteStart}
} placeholder={geoPermission === "granted" ? "Your location" : "Choose starting point"}
icon="origin"
// Insert before destination (last position), or at end if no destination fieldId="origin"
const insertIdx = Math.max(0, newStops.length - 1) autoFocus={!routeStart}
newStops.splice(insertIdx, 0, newStop) />
// Update stops array - reorderStops triggers UI update {/* Intermediate stops - rendered between origin and destination */}
reorderStops(newStops) {stops.map((stop, idx) => (
} <div key={stop.id} className="relative flex items-center gap-1">
<div className="flex-1">
// Check if route has wilderness segments <LocationInput
const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 value={stop.lat != null ? { lat: stop.lat, lon: stop.lon, name: stop.name } : null}
onChange={(place) => {
// Multi-stop support: show intermediate stops from the stops array if (place) {
const intermediateStops = stops.slice(1, -1) updateStop(stop.id, place)
}
return ( }}
<div className="flex flex-col gap-3"> placeholder={`Stop ${idx + 1}`}
{/* Header */} icon="stop"
<div className="flex items-center justify-between"> fieldId={`stop-${idx}`}
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}> autoFocus={stop.lat == null}
Directions />
</span> </div>
<button <button
onClick={handleClose} onClick={() => removeStop(stop.id)}
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors" className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors shrink-0"
title="Close directions" title="Remove stop"
> >
<X size={18} style={{ color: "var(--text-tertiary)" }} /> <Trash2 size={14} style={{ color: "var(--text-tertiary)" }} />
</button> </button>
</div> </div>
))}
{/* Origin/Destination inputs with swap button */}
<div className="relative flex flex-col gap-2"> {/* Swap button - positioned between origin and destination (or after stops) */}
{/* Origin */} <button
<LocationInput onClick={handleSwap}
value={routeStart} className="absolute right-2 z-10 p-1.5 rounded-full transition-colors"
onChange={setRouteStart} style={{
placeholder={geoPermission === "granted" ? "Your location" : "Choose starting point"} background: "var(--bg-raised)",
icon="origin" border: "1px solid var(--border)",
fieldId="origin" top: stops.length === 0 ? "50%" : "calc(50% - 8px)",
autoFocus={!routeStart} transform: "translateY(-50%)",
/> }}
title="Swap origin and destination"
{/* Swap button - positioned between inputs */} >
<button <ArrowUpDown size={14} style={{ color: "var(--text-secondary)" }} />
onClick={handleSwap} </button>
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-full transition-colors"
style={{ {/* Destination */}
background: "var(--bg-raised)", <LocationInput
border: "1px solid var(--border)", value={routeEnd}
}} onChange={setRouteEnd}
title="Swap origin and destination" placeholder="Choose destination"
> icon="destination"
<ArrowUpDown size={14} style={{ color: "var(--text-secondary)" }} /> fieldId="destination"
</button> autoFocus={routeStart && !routeEnd}
/>
{/* Intermediate stops (for multi-stop routes) */}
{intermediateStops.map((stop, idx) => ( {/* Add stop button - only show when route exists */}
<div key={stop.id} className="relative"> {routeStart && routeEnd && stops.length < 8 && (
<LocationInput <button
value={{ lat: stop.lat, lon: stop.lon, name: stop.name }} onClick={handleAddStop}
onChange={(place) => { className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors"
if (place) { style={{
const newStops = [...stops] background: "var(--bg-overlay)",
newStops[idx + 1] = { ...newStops[idx + 1], ...place } color: "var(--text-secondary)",
reorderStops(newStops) border: "1px dashed var(--border)",
} else { }}
removeStop(stop.id) >
} <Plus size={14} />
}} <span>Add stop</span>
placeholder="Stop" </button>
icon="stop" )}
fieldId={`stop-${idx}`} </div>
/>
</div> {/* Travel mode selector */}
))} <div className="flex gap-1">
{TRAVEL_MODES.map((m) => {
{/* Destination */} const active = routeMode === m.id
<LocationInput return (
value={routeEnd} <button
onChange={setRouteEnd} key={m.id}
placeholder="Choose destination" onClick={() => setRouteMode(m.id)}
icon="destination" className="flex-1 flex items-center justify-center gap-1 py-2 text-xs rounded-lg transition-colors"
fieldId="destination" style={{
autoFocus={routeStart && !routeEnd} background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
/> color: active ? "var(--accent)" : "var(--text-tertiary)",
}}
{/* Add stop button - only show when route exists */} title={m.label}
{routeStart && routeEnd && stops.length < 10 && ( >
<button <m.Icon size={16} />
onClick={handleAddStop} <span className="hidden sm:inline">{m.label}</span>
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors" </button>
style={{ )
background: "var(--bg-overlay)", })}
color: "var(--text-secondary)", </div>
border: "1px dashed var(--border)",
}} {/* Boundary mode selector (only for non-auto modes) */}
> {routeMode !== "auto" && (
<Plus size={14} /> <div className="flex gap-1">
<span>Add stop</span> {BOUNDARY_MODES.map((m) => {
</button> const active = boundaryMode === m.id
)} return (
</div> <button
key={m.id}
{/* Travel mode selector */} onClick={() => setBoundaryMode(m.id)}
<div className="flex gap-1"> className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg transition-colors"
{TRAVEL_MODES.map((m) => { style={{
const active = routeMode === m.id background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
return ( color: active ? "var(--accent)" : "var(--text-tertiary)",
<button }}
key={m.id} title={m.title}
onClick={() => setRouteMode(m.id)} >
className="flex-1 flex items-center justify-center gap-1 py-2 text-xs rounded-lg transition-colors" <m.Icon size={14} />
style={{ <span className="hidden sm:inline">{m.label}</span>
background: active ? "var(--accent-muted)" : "var(--bg-overlay)", </button>
color: active ? "var(--accent)" : "var(--text-tertiary)", )
}} })}
title={m.label} </div>
> )}
<m.Icon size={16} />
<span className="hidden sm:inline">{m.label}</span> {/* Loading indicator */}
</button> {routeLoading && (
) <div className="flex items-center justify-center gap-2 py-3">
})} <div
</div> className="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: "var(--accent)", borderTopColor: "transparent" }}
{/* Boundary mode selector (only for non-auto modes) */} />
{routeMode !== "auto" && ( <span className="text-sm" style={{ color: "var(--text-secondary)" }}>
<div className="flex gap-1"> Finding route...
{BOUNDARY_MODES.map((m) => { </span>
const active = boundaryMode === m.id </div>
return ( )}
<button
key={m.id} {/* Error message - friendly text, no "offroute" */}
onClick={() => setBoundaryMode(m.id)} {routeError && (
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg transition-colors" <div
style={{ className="px-3 py-2 rounded-lg text-sm"
background: active ? "var(--accent-muted)" : "var(--bg-overlay)", style={{
color: active ? "var(--accent)" : "var(--text-tertiary)", background: "var(--error-bg, rgba(239, 68, 68, 0.1))",
}} color: "var(--error, #ef4444)",
title={m.title} }}
> >
<m.Icon size={14} /> {routeError.includes("No route") || routeError.includes("not found")
<span className="hidden sm:inline">{m.label}</span> ? "No route found. Try a different start point or mode."
</button> : routeError.includes("entry point")
) ? "No roads found nearby — try Foot mode for trails."
})} : routeError}
</div> </div>
)} )}
{/* Loading indicator */} {/* Route legend - only shown when route has wilderness segment */}
{routeLoading && ( {routeResult && hasWilderness && !routeLoading && (
<div className="flex items-center justify-center gap-2 py-3"> <div
<div className="flex items-center gap-4 px-3 py-2 rounded-lg text-xs"
className="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin" style={{ background: "var(--bg-overlay)" }}
style={{ borderColor: "var(--accent)", borderTopColor: "transparent" }} >
/> <div className="flex items-center gap-1.5">
<span className="text-sm" style={{ color: "var(--text-secondary)" }}> <svg width="24" height="2" style={{ overflow: "visible" }}>
Finding route... <line
</span> x1="0" y1="1" x2="24" y2="1"
</div> stroke="#f97316"
)} strokeWidth="3"
strokeDasharray="4,3"
{/* Error message - friendly text, no "offroute" */} />
{routeError && ( </svg>
<div <span style={{ color: "var(--text-secondary)" }}>Wilderness (on foot)</span>
className="px-3 py-2 rounded-lg text-sm" </div>
style={{ <div className="flex items-center gap-1.5">
background: "var(--error-bg, rgba(239, 68, 68, 0.1))", <svg width="24" height="2" style={{ overflow: "visible" }}>
color: "var(--error, #ef4444)", <line
}} x1="0" y1="1" x2="24" y2="1"
> stroke="#3b82f6"
{routeError.includes("No route") || routeError.includes("not found") strokeWidth="3"
? "No route found. Try a different start point or mode." />
: routeError.includes("entry point") </svg>
? "No roads found nearby — try Foot mode for trails." <span style={{ color: "var(--text-secondary)" }}>Road/Trail</span>
: routeError} </div>
</div> </div>
)} )}
{/* Route legend - only shown when route has wilderness segment */} {/* Route summary and maneuvers */}
{routeResult && hasWilderness && !routeLoading && ( {routeResult && !routeLoading && (
<div <div className="border-t pt-3" style={{ borderColor: "var(--border)" }}>
className="flex items-center gap-4 px-3 py-2 rounded-lg text-xs" <ManeuverList />
style={{ background: "var(--bg-overlay)" }} </div>
> )}
<div className="flex items-center gap-1.5">
<svg width="24" height="2" style={{ overflow: "visible" }}> {/* Hint when waiting for input */}
<line {!routeStart && !routeEnd && !routeLoading && (
x1="0" y1="1" x2="24" y2="1" <div className="text-center py-4">
stroke="#f97316" <p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
strokeWidth="3" Enter addresses, paste coordinates, or click the map
strokeDasharray="4,3" </p>
/> </div>
</svg> )}
<span style={{ color: "var(--text-secondary)" }}>Wilderness (on foot)</span> </div>
</div> )
<div className="flex items-center gap-1.5"> }
<svg width="24" height="2" style={{ overflow: "visible" }}>
<line
x1="0" y1="1" x2="24" y2="1"
stroke="#3b82f6"
strokeWidth="3"
/>
</svg>
<span style={{ color: "var(--text-secondary)" }}>Road/Trail</span>
</div>
</div>
)}
{/* Route summary and maneuvers */}
{routeResult && !routeLoading && (
<div className="border-t pt-3" style={{ borderColor: "var(--border)" }}>
<ManeuverList />
</div>
)}
{/* Hint when waiting for input */}
{!routeStart && !routeEnd && !routeLoading && (
<div className="text-center py-4">
<p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
Enter addresses, paste coordinates, or click the map
</p>
</div>
)}
</div>
)
}

View file

@ -1,298 +1,311 @@
import { create } from 'zustand' import { create } from "zustand"
import { requestOffroute, requestOptimizedRoute } from './api' import { requestOffroute } from "./api"
export const useStore = create((set, get) => ({ export const useStore = create((set, get) => ({
// ── Search state ── // ── Search state ──
query: '', query: "",
results: [], results: [],
searchLoading: false, searchLoading: false,
abortController: null, abortController: null,
setQuery: (query) => set({ query }), setQuery: (query) => set({ query }),
setResults: (results) => set({ results }), setResults: (results) => set({ results }),
setSearchLoading: (loading) => set({ searchLoading: loading }), setSearchLoading: (loading) => set({ searchLoading: loading }),
setAbortController: (ctrl) => set({ abortController: ctrl }), setAbortController: (ctrl) => set({ abortController: ctrl }),
// ── Geolocation ── // ── Geolocation ──
userLocation: null, // { lat, lon } userLocation: null, // { lat, lon }
geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' geoPermission: "prompt", // "prompt" | "granted" | "denied"
setUserLocation: (loc) => set({ userLocation: loc }), setUserLocation: (loc) => set({ userLocation: loc }),
setGeoPermission: (p) => set({ geoPermission: p }), setGeoPermission: (p) => set({ geoPermission: p }),
// ── Map viewport (for search bias) ── // ── Map viewport (for search bias) ──
mapCenter: null, // { lat, lon, zoom } mapCenter: null, // { lat, lon, zoom }
setMapCenter: (center) => set({ mapCenter: center }), setMapCenter: (center) => set({ mapCenter: center }),
// ── Unified Route State ── // ── Unified Route State ──
// Single routing system - all routes go through /api/offroute // routeStart = origin (source of truth)
routeStart: null, // { lat, lon, name } // routeEnd = destination (source of truth)
routeEnd: null, // { lat, lon, name } // stops[] = ONLY intermediate waypoints (not origin/destination)
routeMode: "auto", // foot | mtb | atv | vehicle routeStart: null, // { lat, lon, name }
boundaryMode: "strict", // strict | pragmatic | emergency routeEnd: null, // { lat, lon, name }
routeResult: null, // Response from /api/offroute stops: [], // Intermediate waypoints only: [{ id, lat, lon, name }, ...]
routeLoading: false, routeMode: "auto", // foot | mtb | atv | vehicle
routeError: null, boundaryMode: "strict", // strict | pragmatic | emergency
routeResult: null, // Response from /api/offroute
// Map display callback - set by MapView routeLoading: false,
_updateRouteDisplay: null, routeError: null,
_clearRouteDisplay: null,
setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }), // Map display callback - set by MapView
_updateRouteDisplay: null,
setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }), _clearRouteDisplay: null,
setRouteEnd: (place) => set({ routeEnd: place }), setRouteDisplayCallbacks: (update, clear) => set({ _updateRouteDisplay: update, _clearRouteDisplay: clear }),
setRouteResult: (result) => set({ routeResult: result, routeError: null }),
setRouteLoading: (loading) => set({ routeLoading: loading }), setRouteStart: (place) => set({ routeStart: place, routeResult: null, routeError: null }),
setRouteError: (err) => set({ routeError: err, routeResult: null }), setRouteEnd: (place) => set({ routeEnd: place }),
setRouteResult: (result) => set({ routeResult: result, routeError: null }),
// Mode/boundary setters that trigger recalculation setRouteLoading: (loading) => set({ routeLoading: loading }),
setRouteMode: (mode) => { setRouteError: (err) => set({ routeError: err, routeResult: null }),
set({ routeMode: mode })
get().computeRoute() // Mode/boundary setters that trigger recalculation
}, setRouteMode: (mode) => {
setBoundaryMode: (mode) => { set({ routeMode: mode })
set({ boundaryMode: mode }) get().computeRoute()
get().computeRoute() },
}, setBoundaryMode: (mode) => {
set({ boundaryMode: mode })
clearRoute: () => { get().computeRoute()
const { _clearRouteDisplay } = get() },
if (_clearRouteDisplay) _clearRouteDisplay()
set({ clearRoute: () => {
routeStart: null, const { _clearRouteDisplay } = get()
routeEnd: null, if (_clearRouteDisplay) _clearRouteDisplay()
routeResult: null, set({
routeError: null, routeStart: null,
stops: [], routeEnd: null,
route: null stops: [],
}) routeResult: null,
}, routeError: null,
})
// ── UNIFIED ROUTING TRIGGER ── },
// This is the SINGLE routing function for everything
computeRoute: async () => { // ── INTERMEDIATE STOPS MANAGEMENT ──
const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() // stops[] contains ONLY intermediate waypoints, not origin/destination
console.log('[TRACE-ROUTE] computeRoute called with:', {
startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, addIntermediateStop: () => {
endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name const { stops } = get()
}) if (stops.length >= 8) return false // Max 8 intermediate stops
const newStop = {
// Need both endpoints to route id: crypto.randomUUID(),
if (!routeStart || !routeEnd) return lat: null,
lon: null,
set({ routeLoading: true, routeError: null }) name: "",
}
try { set({ stops: [...stops, newStop] })
const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode) return true
},
if (data.status === "ok" && data.route) {
set({ routeResult: data, routeError: null }) updateStop: (id, place) => {
if (_updateRouteDisplay) _updateRouteDisplay(data.route) const { stops } = get()
} else { const newStops = stops.map((s) =>
set({ routeError: data.message || data.error || "No route found", routeResult: null }) s.id === id ? { ...s, lat: place.lat, lon: place.lon, name: place.name } : s
} )
} catch (e) { set({ stops: newStops })
set({ routeError: e.message, routeResult: null }) // Trigger route recalculation if all waypoints have coordinates
} finally { get().computeRoute()
set({ routeLoading: false }) },
}
}, removeStop: (id) => {
const { stops } = get()
// ── Stop list (master compatibility) ── const newStops = stops.filter((s) => s.id !== id)
stops: [], set({ stops: newStops })
gpsOrigin: true, // whether GPS should be used as origin when available // Recalculate route without this stop
pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) get().computeRoute()
route: null, // Legacy Valhalla response (for 3+ stop optimization) },
addStop: (stop) => { setStops: (stops) => set({ stops }),
const { stops, routeMode, _updateRouteDisplay } = get()
if (stops.length >= 10) return false // ── UNIFIED ROUTING TRIGGER ──
const newStops = [...stops, { ...stop, id: crypto.randomUUID() }] // Handles both 2-point and multi-point routing
set({ stops: newStops }) computeRoute: async () => {
const { routeStart, routeEnd, stops, routeMode, boundaryMode, _updateRouteDisplay } = get()
// Route logic depends on stop count
if (newStops.length === 1) { // Need both endpoints to route
// Single stop = origin, waiting for second if (!routeStart || !routeEnd) return
const origin = newStops[0]
set({ routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name } }) // Filter out incomplete stops (no coordinates yet)
} else if (newStops.length === 2) { const validStops = stops.filter((s) => s.lat != null && s.lon != null)
// Two stops = use offroute (handles on-road and wilderness)
const origin = newStops[0] // Build full waypoint list: [origin, ...intermediates, destination]
const dest = newStops[1] const waypoints = [
set({ routeStart,
routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, ...validStops,
routeEnd: { lat: dest.lat, lon: dest.lon, name: dest.name } routeEnd,
}) ]
get().computeRoute()
} else { console.log("[TRACE-ROUTE] computeRoute with waypoints:", waypoints.length, waypoints.map(w => w.name))
// 3+ stops = use Valhalla multi-stop optimization
set({ routeLoading: true, routeError: null }) 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" try {
requestOptimizedRoute(locations, costing) if (waypoints.length === 2) {
.then((data) => { // Simple 2-point routing
if (data.trip) { const data = await requestOffroute(routeStart, routeEnd, routeMode, boundaryMode)
set({ route: data.trip, routeError: null }) if (data.status === "ok" && data.route) {
// Update display via legacy route handler if available set({ routeResult: data, routeError: null })
if (_updateRouteDisplay && data.trip) { if (_updateRouteDisplay) _updateRouteDisplay(data.route)
// Multi-stop uses legacy route format, need to convert or use separate handler } else {
} set({ routeError: data.message || data.error || "No route found", routeResult: null })
} }
}) } else {
.catch((e) => set({ routeError: e.message })) // Multi-point routing: chain sequential 2-point routes and merge
.finally(() => set({ routeLoading: false })) const segments = []
} let totalDistanceKm = 0
let totalEffortMinutes = 0
return true let allFeatures = []
},
for (let i = 0; i < waypoints.length - 1; i++) {
removeStop: (id) => { const from = waypoints[i]
const { stops } = get() const to = waypoints[i + 1]
const newStops = stops.filter((s) => s.id !== id) const segmentData = await requestOffroute(from, to, routeMode, boundaryMode)
set({ stops: newStops })
if (newStops.length === 0) { if (segmentData.status !== "ok" || !segmentData.route) {
get().clearRoute() throw new Error("No route found between " + (from.name || "waypoint") + " and " + (to.name || "waypoint"))
} else if (newStops.length === 1) { }
// Back to single stop
const origin = newStops[0] segments.push(segmentData)
set({
routeStart: { lat: origin.lat, lon: origin.lon, name: origin.name }, // Accumulate totals
routeEnd: null, if (segmentData.summary) {
routeResult: null totalDistanceKm += segmentData.summary.total_distance_km || 0
}) totalEffortMinutes += segmentData.summary.total_effort_minutes || 0
} }
},
// Collect features
reorderStops: (newStops) => set({ stops: newStops }), if (segmentData.route?.features) {
allFeatures.push(...segmentData.route.features)
clearStops: () => { }
const { _clearRouteDisplay } = get() }
if (_clearRouteDisplay) _clearRouteDisplay()
set({ stops: [], routeStart: null, routeEnd: null, routeResult: null, routeError: null }) // Build merged result
}, const mergedResult = {
status: "ok",
setStops: (stops) => set({ stops }), summary: {
total_distance_km: totalDistanceKm,
setGpsOrigin: (val) => set({ gpsOrigin: val }), total_effort_minutes: totalEffortMinutes,
setPendingDestination: (place) => set({ pendingDestination: place }), waypoint_count: waypoints.length,
clearPendingDestination: () => set({ pendingDestination: null }), },
route: {
// Master startDirections - enters directions mode with destination pre-filled type: "FeatureCollection",
startDirections: (place) => { features: allFeatures,
console.log('[TRACE-STORE] startDirections received place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) },
const { geoPermission, userLocation, clearRoute } = get() }
clearRoute()
set({ routeResult: mergedResult, routeError: null })
// Set destination from the clicked place if (_updateRouteDisplay) _updateRouteDisplay(mergedResult.route)
const destination = { }
lat: place.lat, } catch (e) {
lon: place.lon, set({ routeError: e.message, routeResult: null })
name: place.name, } finally {
source: place.source, set({ routeLoading: false })
matchCode: place.matchCode, }
} },
// Set origin from GPS if available // ── Legacy compatibility ──
let origin = null gpsOrigin: true,
if (geoPermission === 'granted' && userLocation) { pendingDestination: null,
origin = { setGpsOrigin: (val) => set({ gpsOrigin: val }),
lat: userLocation.lat, setPendingDestination: (place) => set({ pendingDestination: place }),
lon: userLocation.lon, clearPendingDestination: () => set({ pendingDestination: null }),
name: 'Your location',
source: 'gps', // 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()
set({ clearRoute()
routeEnd: destination,
routeStart: origin, const destination = {
directionsMode: true, lat: place.lat,
activeDirectionsField: origin ? null : 'origin', // Focus origin if empty lon: place.lon,
selectedPlace: null, name: place.name,
}) source: place.source,
}, matchCode: place.matchCode,
}
// Legacy route setter (for 3+ stop Valhalla optimization)
setRoute: (route) => set({ route, routeError: null }), let origin = null
setRouteError: (err) => set({ routeError: err, route: null }), if (geoPermission === "granted" && userLocation) {
origin = {
// ── Place detail ── lat: userLocation.lat,
selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } lon: userLocation.lon,
clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection name: "Your location",
source: "gps",
setSelectedPlace: (place) => set({ selectedPlace: place }), }
}
// Boundary rendering function - set by MapView, called by PlaceCard
updateBoundary: null, set({
setUpdateBoundary: (fn) => set({ updateBoundary: fn }), routeEnd: destination,
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), routeStart: origin,
setClickMarker: (marker) => set({ clickMarker: marker }), directionsMode: true,
clearClickMarker: () => set({ clickMarker: null }), activeDirectionsField: origin ? null : "origin",
selectedPlace: null,
// ── UI state ── })
sheetState: 'half', // 'collapsed' | 'half' | 'full' },
panelOpen: true,
autocompleteOpen: false, // ── Place detail ──
directionsMode: false, // true when directions panel is active selectedPlace: null,
activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) clickMarker: null,
pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode)
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) setSelectedPlace: (place) => set({ selectedPlace: place }),
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) updateBoundary: null,
viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' setUpdateBoundary: (fn) => set({ updateBoundary: fn }),
clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }),
setSheetState: (s) => set({ sheetState: s }), setClickMarker: (marker) => set({ clickMarker: marker }),
setViewMode: (mode) => { clearClickMarker: () => set({ clickMarker: null }),
set({ viewMode: mode })
localStorage.setItem('navi-view-mode', mode) // ── UI state ──
}, sheetState: "half",
setPanelOpen: (open) => set({ panelOpen: open }), panelOpen: true,
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), autocompleteOpen: false,
setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), directionsMode: false,
setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), activeDirectionsField: null,
setPickingRouteField: (field) => set({ pickingRouteField: field }), pickingRouteField: null,
clearPickingRouteField: () => set({ pickingRouteField: null }), theme: "dark",
setTheme: (theme) => set({ theme }), themeOverride: null,
setThemeOverride: (override) => { viewMode: (typeof localStorage !== "undefined" && localStorage.getItem("navi-view-mode")) || "map",
set({ themeOverride: override })
if (override) { setSheetState: (s) => set({ sheetState: s }),
localStorage.setItem('navi-theme-override', override) setViewMode: (mode) => {
} else { set({ viewMode: mode })
localStorage.removeItem('navi-theme-override') localStorage.setItem("navi-view-mode", mode)
} },
}, setPanelOpen: (open) => set({ panelOpen: open }),
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
// ── Auth state ── setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? "origin" : null }),
auth: { authenticated: false, username: null, loaded: false }, setActiveDirectionsField: (field) => set({ activeDirectionsField: field }),
setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), setPickingRouteField: (field) => set({ pickingRouteField: field }),
clearPickingRouteField: () => set({ pickingRouteField: null }),
// ── Contacts ── setTheme: (theme) => set({ theme }),
contacts: [], setThemeOverride: (override) => {
contactsLoaded: false, set({ themeOverride: override })
activeTab: 'routes', // 'routes' | 'contacts' if (override) {
editingContact: null, // null=closed, {}=new, {id:N}=edit localStorage.setItem("navi-theme-override", override)
pickingLocationFor: null, // form data while user picks location on map } else {
localStorage.removeItem("navi-theme-override")
setContacts: (c) => set({ contacts: c, contactsLoaded: true }), }
setActiveTab: (tab) => set({ activeTab: tab }), },
setEditingContact: (c) => set({ editingContact: c }),
clearEditingContact: () => set({ editingContact: null }), // ── Auth state ──
setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), auth: { authenticated: false, username: null, loaded: false },
clearPickingLocationFor: () => set({ pickingLocationFor: null }), setAuth: (auth) => set({ auth: { ...auth, loaded: true } }),
}))
// ── Contacts ──
// ── Panel state selector ── contacts: [],
// Returns string state, prioritizing preview to allow it alongside any route state contactsLoaded: false,
export const usePanelState = () => { activeTab: "routes",
return useStore((s) => { editingContact: null,
const hasPreview = !!s.selectedPlace pickingLocationFor: null,
const hasRoute = !!s.routeResult
const hasRoutePoints = !!s.routeStart || !!s.routeEnd setContacts: (c) => set({ contacts: c, contactsLoaded: true }),
setActiveTab: (tab) => set({ activeTab: tab }),
if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" setEditingContact: (c) => set({ editingContact: c }),
if (hasPreview && hasRoutePoints) return "PREVIEW_ROUTING" clearEditingContact: () => set({ editingContact: null }),
if (hasPreview) return "PREVIEW" setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }),
if (hasRoute) return "ROUTE_CALCULATED" clearPickingLocationFor: () => set({ pickingLocationFor: null }),
if (hasRoutePoints) return "ROUTING" }))
return "IDLE"
}) // ── 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"
})
}