diff --git a/src/App.jsx b/src/App.jsx index 0d02c8f..3bdea6e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useCallback } from 'react' import { useStore } from './store' import { useTheme } from './hooks/useTheme' -import { requestRoute, fetchAuthState } from './api' -import { decodePolyline } from './utils/decode' +import { fetchAuthState } from './api' import MapView from './components/MapView' import Panel from './components/Panel' @@ -12,20 +11,10 @@ import LocateButton from './components/LocateButton' export default function App() { const mapViewRef = useRef(null) - const routeDebounceRef = useRef(null) // Initialize theme system useTheme() - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) - const setRoute = useStore((s) => s.setRoute) - const setRouteLoading = useStore((s) => s.setRouteLoading) - const setRouteError = useStore((s) => s.setRouteError) - const clearRoute = useStore((s) => s.clearRoute) const setAuth = useStore((s) => s.setAuth) // Initialize auth state on app load (single fetch, no polling) @@ -33,70 +22,18 @@ export default function App() { fetchAuthState().then(setAuth) }, [setAuth]) - // Fetch route when stops, mode, gpsOrigin, or geoPermission change (debounced 500ms) - useEffect(() => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - - routeDebounceRef.current = setTimeout(async () => { - const { userLocation } = useStore.getState() - - let effective = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (gpsOrigin && geoPermission === 'granted' && userLocation) { - effective = [{ lat: userLocation.lat, lon: userLocation.lon }, ...effective] - } - - if (effective.length < 2) { - clearRoute() - return - } - - setRouteLoading(true) - - try { - const data = await requestRoute(effective, mode) - if (data.trip) { - setRoute(data.trip) - } else { - setRouteError('No route returned') - } - } catch (e) { - setRouteError(e.message || 'Route request failed') - } finally { - setRouteLoading(false) - } - }, 500) - - return () => { - if (routeDebounceRef.current) clearTimeout(routeDebounceRef.current) - } - }, [stops, mode, gpsOrigin, geoPermission, clearRoute, setRoute, setRouteLoading, setRouteError]) - - // Handle maneuver click - const handleManeuverClick = useCallback( - (maneuver) => { - if (!route || !route.legs) return - - const legIdx = maneuver._legIndex || 0 - const leg = route.legs[legIdx] - if (!leg || !leg.shape) return - - const coords = decodePolyline(leg.shape, 6) - const idx = maneuver.begin_shape_index - if (idx >= 0 && idx < coords.length) { - const [lng, lat] = coords[idx] - mapViewRef.current?.flyTo(lat, lng, 15) - } - }, - [route] - ) + // Handle clear route from panel + const handleClearRoute = useCallback(() => { + mapViewRef.current?.clearRoute?.() + }, []) return (
- - + + - + {/* Bottom-right map controls */}
diff --git a/src/api.js b/src/api.js index fe8fd02..bed21ec 100644 --- a/src/api.js +++ b/src/api.js @@ -321,3 +321,70 @@ export async function fetchAuthState() { return { authenticated: false, username: null } } } + +// ── Offroute API ── + +const OFFROUTE_URL = "/api/offroute" +const MVUM_URL = "/api/mvum" + +/** + * Request an offroute route from the pathfinder API. + * @param {object} start - { lat, lon } + * @param {object} end - { lat, lon } + * @param {string} mode - foot | mtb | atv | vehicle + * @param {string} boundaryMode - strict | pragmatic | emergency + * @returns {Promise} Offroute response with GeoJSON route + */ +export async function requestOffroute(start, end, mode = "foot", boundaryMode = "strict") { + const body = { + start: [start.lat, start.lon], + end: [end.lat, end.lon], + mode, + boundary_mode: boundaryMode, + } + console.log('[TRACE-API] requestOffroute body:', JSON.stringify(body)) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 120000) // 2 min timeout for complex routes + + try { + const resp = await fetch(OFFROUTE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + + if (!resp.ok) { + const errBody = await resp.json().catch(() => ({})) + throw new Error(errBody.message || 'Could not find a route. Try a different start point or mode.') + } + + return resp.json() + } finally { + clearTimeout(timeout) + } +} + +/** + * Fetch MVUM (Motor Vehicle Use Map) info for a location. + * @param {number} lat + * @param {number} lon + * @param {number} radius - Search radius in meters + * @returns {Promise} MVUM feature info or null + */ +export async function fetchMvumInfo(lat, lon, radius = 500) { + try { + const params = new URLSearchParams({ + lat: String(lat), + lon: String(lon), + radius: String(radius), + }) + const resp = await fetch(`${MVUM_URL}?${params}`, { signal: AbortSignal.timeout(5000) }) + if (!resp.ok) return null + const data = await resp.json() + return data.feature || null + } catch { + return null + } +} diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx new file mode 100644 index 0000000..a01f1c9 --- /dev/null +++ b/src/components/DirectionsPanel.jsx @@ -0,0 +1,417 @@ +import { useEffect, useMemo } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap, Trash2, GripVertical } from "lucide-react" +import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core" +import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +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" }, +] + +// Sortable row component +function SortableRow({ id, children }) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 1000 : 1, + } + + return ( +
+ {/* Drag handle */} + + {children} +
+ ) +} + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + 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) + const setStops = useStore((s) => s.setStops) + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // Build unified list for drag-and-drop: origin + stops + destination + // Each item has: { id, type, data } + const unifiedList = useMemo(() => { + const items = [] + if (routeStart) { + items.push({ id: "origin", type: "origin", data: routeStart }) + } + stops.forEach((stop) => { + items.push({ id: stop.id, type: "stop", data: stop }) + }) + if (routeEnd) { + items.push({ id: "destination", type: "destination", data: routeEnd }) + } + return items + }, [routeStart, stops, routeEnd]) + + const itemIds = useMemo(() => unifiedList.map((item) => item.id), [unifiedList]) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + 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 handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + addIntermediateStop() + } + + // Handle drag end - reorder the unified list + const handleDragEnd = (event) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = unifiedList.findIndex((item) => item.id === active.id) + const newIndex = unifiedList.findIndex((item) => item.id === over.id) + + if (oldIndex === -1 || newIndex === -1) return + + // Reorder the unified list + const reordered = arrayMove(unifiedList, oldIndex, newIndex) + + // Extract new origin, stops, and destination from reordered list + // First item becomes origin, last becomes destination, middle are stops + if (reordered.length === 0) return + + const newOriginItem = reordered[0] + const newDestItem = reordered.length > 1 ? reordered[reordered.length - 1] : null + const newStopItems = reordered.length > 2 ? reordered.slice(1, -1) : [] + + // Convert items to proper format + const newOrigin = newOriginItem.data ? { + lat: newOriginItem.data.lat, + lon: newOriginItem.data.lon, + name: newOriginItem.data.name, + source: newOriginItem.data.source, + } : null + + const newDest = newDestItem?.data ? { + lat: newDestItem.data.lat, + lon: newDestItem.data.lon, + name: newDestItem.data.name, + source: newDestItem.data.source, + } : null + + const newStops = newStopItems.map((item) => ({ + id: item.id === "origin" || item.id === "destination" ? crypto.randomUUID() : item.id, + lat: item.data?.lat ?? null, + lon: item.data?.lon ?? null, + name: item.data?.name ?? "", + })) + + // Update state + setRouteStart(newOrigin) + setRouteEnd(newDest) + setStops(newStops) + + // Trigger route recalculation + setTimeout(() => computeRoute(), 0) + } + + // Check if route has wilderness segments + const hasWilderness = routeResult?.summary?.wilderness_distance_km > 0 + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Drag-and-drop location list */} + + +
+ {unifiedList.map((item, idx) => ( + +
+ {item.type === "origin" && ( + + )} + {item.type === "destination" && ( + + )} + {item.type === "stop" && ( + { + if (place) { + updateStop(item.id, place) + } + }} + placeholder={`Stop ${idx}`} + icon="stop" + fieldId={`stop-${item.id}`} + autoFocus={item.data.lat == null} + /> + )} +
+ {/* Remove button for intermediate stops only */} + {item.type === "stop" && ( + + )} + {/* Spacer for origin/destination to align with stops that have remove button */} + {item.type !== "stop" && ( +
+ )} + + ))} + + {/* Add stop button - only show when route exists */} + {routeStart && routeEnd && stops.length < 8 && ( + + )} +
+ + + + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message - friendly text, no "offroute" */} + {routeError && ( +
+ {routeError.includes("No route") || routeError.includes("not found") + ? "No route found. Try a different start point or mode." + : routeError.includes("entry point") + ? "No roads found nearby — try Foot mode for trails." + : routeError} +
+ )} + + {/* Route legend - only shown when route has wilderness segment */} + {routeResult && hasWilderness && !routeLoading && ( +
+
+ + + + Wilderness (on foot) +
+
+ + + + Road/Trail +
+
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

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

+
+ )} +
+ ) +} diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx new file mode 100644 index 0000000..eb0a204 --- /dev/null +++ b/src/components/LocationInput.jsx @@ -0,0 +1,321 @@ +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2, Target } from "lucide-react" +import toast from "react-hot-toast" +import { useStore } from "../store" +import { searchGeocode } from "../api" +import { buildAddress } from "../utils/place" +import { hasFeature } from "../config" + +/** Parse coordinate input like "42.35, -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = input.trim().match(pattern) + if (!match) return null + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null + return { lat, lon } +} + +function CategoryIcon({ result, size = 14 }) { + const type = result.type || "" + const source = result.source || "" + if (result._isContact) return + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(false) + return + } + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, -1)) + break + case "Enter": + e.preventDefault() + if (activeIndex >= 0 && activeIndex < results.length) { + selectResult(results[activeIndex]) + } + break + case "Escape": + e.preventDefault() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) // For styling only, not map clicks + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handlePickFromMap = () => { + setPickingRouteField(fieldId) + toast("Click map to set location", { icon: "🎯", duration: 3000 }) + inputRef.current?.blur() // Unfocus input so user focuses on map + } + + const isPicking = pickingRouteField === fieldId + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {/* Pick from map button */} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/ManeuverList.jsx b/src/components/ManeuverList.jsx index d869b66..44d1ffc 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,140 +1,343 @@ -import { - MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, - MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation -} from 'lucide-react' -import { useStore } from '../store' - -function formatTime(seconds) { - if (seconds < 60) return `${Math.round(seconds)}s` - if (seconds < 3600) return `${Math.round(seconds / 60)} min` - const h = Math.floor(seconds / 3600) - const m = Math.round((seconds % 3600) / 60) - return m > 0 ? `${h}h ${m}m` : `${h}h` -} - -function formatDist(miles) { - if (miles < 0.1) return `${Math.round(miles * 5280)} ft` - return `${miles.toFixed(1)} mi` -} - -function ManeuverIcon({ type }) { - const size = 16 - const props = { size, strokeWidth: 1.5 } - switch (type) { - case 0: return - case 1: return - case 2: return - case 3: return - case 4: case 5: return - case 6: return - case 7: return - case 8: return - case 9: return - case 10: case 11: case 12: return - case 15: case 16: return - case 24: return - case 25: return - case 26: return - default: return - } -} - -export default function ManeuverList({ onManeuverClick }) { - const route = useStore((s) => s.route) - const routeLoading = useStore((s) => s.routeLoading) - const routeError = useStore((s) => s.routeError) - - if (routeLoading) { - return ( -
-
- - Calculating route... - -
- ) - } - - if (routeError) { - return ( -
- {routeError} -
- ) - } - - if (!route || !route.legs) return null - - const totalTime = route.summary?.time || 0 - const totalDist = route.summary?.length || 0 - - const allManeuvers = [] - let timeRemaining = totalTime - - for (let legIdx = 0; legIdx < route.legs.length; legIdx++) { - const leg = route.legs[legIdx] - for (const man of leg.maneuvers || []) { - allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining }) - timeRemaining -= man.time || 0 - } - } - - return ( -
- {/* Route summary */} -
- - {formatDist(totalDist)} - - - {formatTime(totalTime)} - -
- - {/* Maneuver steps */} -
- {allManeuvers.map((man, i) => ( - - ))} -
-
- ) -} +import { + MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, + MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, + Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, + ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin +} from 'lucide-react' +import { useStore } from '../store' + +/** + * Format distance with commas for feet, one decimal for miles. + * Under 1 mile: "2,640 ft" + * 1+ miles: "1.3 mi" + */ +function formatDistance(distanceM, distanceKm) { + let meters = null + if (distanceM !== undefined && distanceM !== null) { + meters = distanceM + } else if (distanceKm !== undefined && distanceKm !== null) { + meters = distanceKm * 1000 + } + + if (meters === null) return '' + + const miles = meters / 1609.34 + if (miles < 1) { + const feet = Math.round(meters * 3.28084) + return feet.toLocaleString() + ' ft' + } + return miles.toFixed(1) + ' mi' +} + +function formatTimeMin(minutes) { + if (minutes < 60) return Math.round(minutes) + ' min' + const h = Math.floor(minutes / 60) + const m = Math.round(minutes % 60) + return m > 0 ? h + 'h ' + m + 'm' : h + 'h' +} + +// Compass arrow icon based on cardinal direction with rotation +function CompassIcon({ cardinal, bearing, size = 16 }) { + // Use bearing to rotate arrow, or fall back to cardinal-based icon + if (bearing !== undefined && bearing !== null) { + return ( + + ) + } + + const props = { size, strokeWidth: 2 } + const arrowMap = { + 'N': ArrowUp, + 'NNE': ArrowUpRight, + 'NE': ArrowUpRight, + 'ENE': ArrowRight, + 'E': ArrowRight, + 'ESE': ArrowRight, + 'SE': ArrowDownRight, + 'SSE': ArrowDownRight, + 'S': ArrowDown, + 'SSW': ArrowDownLeft, + 'SW': ArrowDownLeft, + 'WSW': ArrowLeft, + 'W': ArrowLeft, + 'WNW': ArrowLeft, + 'NW': ArrowUpLeft, + 'NNW': ArrowUpLeft, + } + const Icon = arrowMap[cardinal] || Compass + return +} + +// Wilderness maneuver icon +function WildernessIcon({ type, cardinal, bearing, size = 16 }) { + if (type === 'arrival') { + return + } + return +} + +// Network maneuver icon (Valhalla types) +function ManeuverIcon({ type }) { + const size = 16 + const props = { size, strokeWidth: 1.5 } + switch (type) { + case 0: return + case 1: return + case 2: return + case 3: return + case 4: case 5: return + case 6: return + case 7: return + case 8: return + case 9: return + case 10: case 11: case 12: return + case 15: case 16: return + case 24: return + case 25: return + case 26: return + default: return + } +} + +/** + * Add transport mode prefix to network maneuver instruction. + * "Drive east on..." for auto, "Walk south on..." for foot, "Ride north on..." for mtb + */ +function formatNetworkInstruction(instruction, mode) { + if (!instruction) return '' + + // Get verb based on mode + const modeVerbs = { + 'auto': 'Drive', + 'foot': 'Walk', + 'pedestrian': 'Walk', + 'mtb': 'Ride', + 'bicycle': 'Ride', + 'atv': 'Drive', + 'vehicle': 'Drive', + } + const verb = modeVerbs[mode] || 'Go' + + // Check if instruction starts with a direction verb we should replace + const startsWithVerbs = [ + 'Turn left', 'Turn right', 'Bear left', 'Bear right', + 'Keep left', 'Keep right', 'Continue', 'Head', 'Go', + 'Proceed', 'Make a', 'Take a', 'Start', 'Merge', 'Exit' + ] + + for (const v of startsWithVerbs) { + if (instruction.startsWith(v)) { + // Already has a verb, return as-is (Valhalla instructions are already good) + return instruction + } + } + + // If instruction starts with direction (north, south, etc.), prepend verb + const directions = ['north', 'south', 'east', 'west', 'onto', 'on '] + for (const dir of directions) { + if (instruction.toLowerCase().startsWith(dir)) { + return `${verb} ${instruction}` + } + } + + return instruction +} + +export default function ManeuverList() { + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const routeMode = useStore((s) => s.routeMode) + + if (routeLoading) { + return ( +
+
+ + Calculating route... + +
+ ) + } + + if (routeError) { + return ( +
+ {routeError} +
+ ) + } + + if (!routeResult?.summary) return null + + const summary = routeResult.summary + const features = routeResult.route?.features || [] + const networkMode = summary.network_mode || routeMode || 'foot' + + // Extract maneuvers from each segment type + const wildernessStartFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'start' + ) + const networkFeature = features.find(f => f.properties?.segment_type === 'network') + const wildernessEndFeature = features.find(f => + f.properties?.segment_type === 'wilderness' && f.properties?.segment_position === 'end' + ) + + const wildernessStartManeuvers = wildernessStartFeature?.properties?.maneuvers || [] + const networkManeuvers = networkFeature?.properties?.maneuvers || [] + const wildernessEndManeuvers = wildernessEndFeature?.properties?.maneuvers || [] + + const hasManeuvers = wildernessStartManeuvers.length > 0 || + networkManeuvers.length > 0 || + wildernessEndManeuvers.length > 0 + + return ( +
+ {/* Total summary */} +
+ + {formatDistance(null, summary.total_distance_km)} + + + {formatTimeMin(summary.total_effort_minutes)} + +
+ + {/* Segment breakdown */} +
+ {summary.wilderness_distance_km > 0 && ( +
+ + Wilderness + + {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + +
+ )} + {summary.network_distance_km > 0 && ( +
+ + Road/Trail + + {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + +
+ )} +
+ + {/* Warnings */} + {(summary.barrier_crossings > 0 || summary.mvum_closed_crossings > 0) && ( +
+ {summary.barrier_crossings > 0 && ( +
+ + {summary.barrier_crossings} barrier crossing{summary.barrier_crossings > 1 ? 's' : ''} +
+ )} + {summary.mvum_closed_crossings > 0 && ( +
+ + {summary.mvum_closed_crossings} MVUM closure{summary.mvum_closed_crossings > 1 ? 's' : ''} +
+ )} +
+ )} + + {/* Turn-by-turn directions */} + {hasManeuvers && ( +
+
Directions
+ + {/* Wilderness start maneuvers */} + {wildernessStartManeuvers.length > 0 && ( + <> +
+ Wilderness — On Foot +
+ {wildernessStartManeuvers.map((man, i) => ( +
+ + + +
+

+ {man.instruction} +

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

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

+

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

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

+ {man.instruction} +

+
+
+ ))} + + )} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index 0ede093..6f6d26b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -6,9 +6,9 @@ import { layers, namedTheme } from 'protomaps-themes-base' import { getTheme, getThemeSprite, getOverlayConfig } from '../themes/registry' import { useStore } from '../store' import { decodePolyline } from '../utils/decode' -import { fetchReverse } from '../api' +import { fetchReverse, requestOffroute } from '../api' import { getConfig, hasFeature } from '../config' -import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Plus, Star, Ruler, X } from 'lucide-react' +import { MapPin, Navigation, ArrowUpRight, ArrowDownLeft, Star, Ruler, X, Trash2, Plus } from 'lucide-react' import RadialMenu from './RadialMenu' import useContextMenu from '../hooks/useContextMenu' import toast from 'react-hot-toast' @@ -27,6 +27,10 @@ const BOUNDARY_SOURCE = 'boundary-source' const BOUNDARY_LAYER = 'boundary-layer' const STATE_BOUNDARIES_LAYER = 'state-boundaries-z4-z7' const ROUTE_LAYER_PREFIX = 'route-layer-' +const OFFROUTE_SOURCE = 'offroute-source' +const OFFROUTE_WILDERNESS_LAYER = 'offroute-wilderness' +const OFFROUTE_NETWORK_LAYER = 'offroute-network' +const OFFROUTE_MARKERS_LAYER = 'offroute-markers' const HILLSHADE_SOURCE = 'hillshade-dem' const HILLSHADE_LAYER = 'hillshade-layer' const TRAFFIC_SOURCE = 'traffic-tiles' @@ -1122,6 +1126,7 @@ function isProtectedLayer(id) { return id.startsWith('public-lands') || id.startsWith('boundary') || id.startsWith('route') || + id.startsWith('offroute') || id.startsWith('measure') || id.startsWith('contour') || id.startsWith('usfs') || @@ -1327,6 +1332,83 @@ function removeStateBoundaries(map) { } } + +/** Clear offroute display layers */ +function clearRouteDisplay(map) { + if (!map) return + if (map.getLayer(OFFROUTE_WILDERNESS_LAYER)) map.removeLayer(OFFROUTE_WILDERNESS_LAYER) + if (map.getLayer(OFFROUTE_NETWORK_LAYER)) map.removeLayer(OFFROUTE_NETWORK_LAYER) + if (map.getLayer(OFFROUTE_MARKERS_LAYER)) map.removeLayer(OFFROUTE_MARKERS_LAYER) + if (map.getSource(OFFROUTE_SOURCE)) map.removeSource(OFFROUTE_SOURCE) +} + +/** Update offroute display with route GeoJSON */ +function updateRouteDisplay(map, routeGeojson) { + if (!map || !routeGeojson) return + + // Clear existing layers + clearRouteDisplay(map) + + // Add source with route features + map.addSource(OFFROUTE_SOURCE, { + type: "geojson", + data: routeGeojson, + }) + + // Find first symbol layer for proper z-ordering + let beforeId = undefined + for (const layer of map.getStyle().layers) { + if (layer.type === "symbol") { + beforeId = layer.id + break + } + } + + // Wilderness segment - dashed orange line + map.addLayer({ + id: OFFROUTE_WILDERNESS_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "wilderness"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#f97316", // orange-500 + "line-width": 4, + "line-opacity": 0.9, + "line-dasharray": [8, 4], + }, + }, beforeId) + + // Network segment - solid blue line + map.addLayer({ + id: OFFROUTE_NETWORK_LAYER, + type: "line", + source: OFFROUTE_SOURCE, + filter: ["==", ["get", "segment_type"], "network"], + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": "#3b82f6", // blue-500 + "line-width": 5, + "line-opacity": 0.85, + }, + }, beforeId) + + // Fit bounds to route + const features = routeGeojson.features || [] + const allCoords = features + .filter(f => f.geometry?.coordinates) + .flatMap(f => f.geometry.coordinates) + + if (allCoords.length > 0) { + const bounds = allCoords.reduce( + (b, c) => b.extend(c), + new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) + ) + const leftPad = 420 + map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) + } +} + const MapView = forwardRef(function MapView(_, ref) { const mapRef = useRef(null) const mapInstance = useRef(null) @@ -1348,20 +1430,20 @@ const MapView = forwardRef(function MapView(_, ref) { const measuringRef = useRef({ active: false, points: [] }) const measureLabelsRef = useRef([]) // HTML label elements - const stops = useStore((s) => s.stops) - const route = useStore((s) => s.route) const theme = useStore((s) => s.theme) const selectedPlace = useStore((s) => s.selectedPlace) const clickMarker = useStore((s) => s.clickMarker) const setClickMarker = useStore((s) => s.setClickMarker) const clearClickMarker = useStore((s) => s.clearClickMarker) - const gpsOrigin = useStore((s) => s.gpsOrigin) const geoPermission = useStore((s) => s.geoPermission) const setSheetState = useStore((s) => s.setSheetState) const setMapCenter = useStore((s) => s.setMapCenter) const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) + const directionsMode = useStore((s) => s.directionsMode) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const pickingRouteField = useStore((s) => s.pickingRouteField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1580,7 +1662,7 @@ const MapView = forwardRef(function MapView(_, ref) { const radialWedges = [ { - id: "directions-to", + id: "to-here", label: "To here", icon: ArrowDownLeft, onSelect: () => { @@ -1589,29 +1671,48 @@ const MapView = forwardRef(function MapView(_, ref) { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - useStore.getState().startDirections(place) + const { routeStart, setRouteStart, setRouteEnd, computeRoute, setDirectionsMode, geoPermission, userLocation } = useStore.getState() + + setRouteEnd(place) + setDirectionsMode(true) + + if (routeStart) { + computeRoute() + } else if (geoPermission === "granted" && userLocation) { + // Use GPS as origin fallback + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + computeRoute() + } + // If no origin and no GPS, directions panel opens and origin field auto-focuses }, }, { - id: "directions-from", + id: "from-here", label: "From here", icon: ArrowUpRight, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { clearStops, addStop } = useStore.getState() - clearStops() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - addStop(place) - useStore.setState({ gpsOrigin: false }) + const { clearRoute, setRouteStart, routeEnd, computeRoute, setDirectionsMode } = useStore.getState() + clearRoute() + clearRouteDisplay(mapInstance.current) + setRouteStart(place) + setDirectionsMode(true) + + if (routeEnd) { + computeRoute() + } + // If no destination, directions panel opens and destination field auto-focuses }, }, { @@ -1620,25 +1721,33 @@ const MapView = forwardRef(function MapView(_, ref) { icon: Plus, onSelect: () => { setRadialMenu((m) => ({ ...m, open: false })) - const { stops, addStop, clearStops } = useStore.getState() + const { addIntermediateStop, computeRoute, routeStart, routeEnd } = useStore.getState() const place = { lat: radialMenu.lat, lon: radialMenu.lon, name: radialMenu.centerLabel || radialMenu.lat.toFixed(5) + ", " + radialMenu.lon.toFixed(5), - source: "radial_menu", - matchCode: null, } - if (stops.length === 0) { - addStop(place) - useStore.setState({ gpsOrigin: false }) - } else { - const success = addStop(place) - if (!success) { - toast("Maximum 10 stops reached") + const success = addIntermediateStop(place) + if (success) { + // If we have both origin and destination, recalculate route + if (routeStart && routeEnd) { + computeRoute() } + } else { + toast("Maximum 8 intermediate stops reached") } }, }, + { + id: "clear-route", + label: "Clear", + icon: Trash2, + onSelect: () => { + setRadialMenu((m) => ({ ...m, open: false })) + useStore.getState().clearRoute() + clearRouteDisplay(mapInstance.current) + }, + }, { id: "save-place", label: "Save", @@ -1805,6 +1914,14 @@ const MapView = forwardRef(function MapView(_, ref) { updateSatellitePaint(map, currentThemeRef.current) }, + // Clear offroute route from map + clearRoute() { + const map = mapInstance.current + if (!map) return + clearRouteDisplay(map) + useStore.getState().clearRoute() + }, + })) // Initialize map @@ -1894,7 +2011,33 @@ const MapView = forwardRef(function MapView(_, ref) { return } - + // Handle explicit pick-from-map mode for route inputs + const { pickingRouteField, setRouteStart, setRouteEnd, clearPickingRouteField } = useStore.getState() + if (pickingRouteField) { + const { lng, lat } = e.lngLat + map.getCanvas().style.cursor = '' + // Reverse geocode for name + fetchReverse(lat, lng).then((place) => { + const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (pickingRouteField === "origin") { + setRouteStart(location) + } else if (pickingRouteField === "destination") { + setRouteEnd(location) + } + clearPickingRouteField() + }).catch(() => { + const name = lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (pickingRouteField === "origin") { + setRouteStart(location) + } else if (pickingRouteField === "destination") { + setRouteEnd(location) + } + clearPickingRouteField() + }) + return + } const store = useStore.getState() const marker = store.clickMarker @@ -2062,12 +2205,16 @@ const MapView = forwardRef(function MapView(_, ref) { const props = labelFeature.properties const geom = labelFeature.geometry - // Get feature coordinates (Point geometry) - let featureLat = lat - let featureLon = lng + // CRITICAL: Always use CLICK coordinates for routing (lat, lng from e.lngLat) + // Feature coordinates are only for display/fetching details + let featureLat = lat // Click coordinate - used for routing + let featureLon = lng // Click coordinate - used for routing + let displayLat = lat // May be updated to feature coords for display + let displayLon = lng if (geom && geom.type === 'Point' && geom.coordinates) { - featureLon = geom.coordinates[0] - featureLat = geom.coordinates[1] + // Store feature's canonical coords separately - NOT for routing + displayLon = geom.coordinates[0] + displayLat = geom.coordinates[1] } // FIX A: For park-type features, also query polygon layers to get boundary geometry @@ -2112,6 +2259,7 @@ const MapView = forwardRef(function MapView(_, ref) { updateBoundaryRef.current(polygonGeometry) } + console.log('[TRACE-CLICK] Feature click setSelectedPlace:', { featureLat, featureLon, clickLat: lat, clickLng: lng, name: props.name }) store.setSelectedPlace({ lat: featureLat, lon: featureLon, @@ -2143,6 +2291,7 @@ const MapView = forwardRef(function MapView(_, ref) { circleRadiusPx: MARKER_RADIUS_PX, }) + console.log('[TRACE-CLICK] Reticle click setSelectedPlace:', { lat, lng }) store.setSelectedPlace({ lat, lon: lng, @@ -2304,6 +2453,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'] @@ -2464,10 +2619,8 @@ const MapView = forwardRef(function MapView(_, ref) { originalPaintValues = {} // Restore view - map.jumpTo({ center, zoom, bearing, pitch }) - // Re-render route if exists - const currentRoute = useStore.getState().route - if (currentRoute) updateRoute(map, currentRoute) + const currentRoute = useStore.getState().routeResult + if (currentRoute?.route) updateRouteDisplay(map, currentRoute.route) }) }, [theme]) @@ -2560,168 +2713,6 @@ const MapView = forwardRef(function MapView(_, ref) { return () => document.removeEventListener('keydown', handleKeyDown) }, [selectedPlace]) - // Update route polyline when route changes - useEffect(() => { - const map = mapInstance.current - if (!map) return - if (!map.isStyleLoaded()) { - const handler = () => updateRoute(map, route) - map.once('idle', handler) - return () => map.off('idle', handler) - } - updateRoute(map, route) - }, [route]) - - function updateRoute(map, routeData) { - if (!map) return - - // Remove old route layers - const style = map.getStyle() - if (style) { - for (const layer of style.layers) { - if (layer.id.startsWith(ROUTE_LAYER_PREFIX)) { - map.removeLayer(layer.id) - } - } - } - - if (!routeData || !routeData.legs) { - if (map.getSource(ROUTE_SOURCE)) { - map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] }) - } - return - } - - const features = [] - for (let i = 0; i < routeData.legs.length; i++) { - const leg = routeData.legs[i] - if (!leg.shape) continue - const coords = decodePolyline(leg.shape, 6) - features.push({ - type: 'Feature', - properties: { legIndex: i }, - geometry: { type: 'LineString', coordinates: coords }, - }) - } - - const source = map.getSource(ROUTE_SOURCE) - if (source) { - source.setData({ type: 'FeatureCollection', features }) - } else { - map.addSource(ROUTE_SOURCE, { - type: 'geojson', - data: { type: 'FeatureCollection', features }, - }) - } - - // Use CSS variable for route color (read computed value) - const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim() - - for (let i = 0; i < features.length; i++) { - const layerId = `${ROUTE_LAYER_PREFIX}${i}` - if (!map.getLayer(layerId)) { - map.addLayer({ - id: layerId, - type: 'line', - source: ROUTE_SOURCE, - filter: ['==', ['get', 'legIndex'], i], - layout: { 'line-join': 'round', 'line-cap': 'round' }, - paint: { - 'line-color': routeColor || '#7a9a6b', - 'line-width': 5, - 'line-opacity': 0.85, - }, - }) - } - } - - // Fit bounds to route - if (features.length > 0) { - const allCoords = features.flatMap((f) => f.geometry.coordinates) - const bounds = allCoords.reduce( - (b, c) => b.extend(c), - new maplibregl.LngLatBounds(allCoords[0], allCoords[0]) - ) - // Single-panel: no floating detail - const leftPad = 420 // 360px panel + margin - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } }) - } - } - - // Update stop markers when stops change - useEffect(() => { - const map = mapInstance.current - if (!map) return - - // Remove old markers - for (const m of markersRef.current) m.remove() - markersRef.current = [] - if (popupRef.current) { - popupRef.current.remove() - popupRef.current = null - } - - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const indexOffset = hasGpsOrigin ? 1 : 0 - - stops.forEach((stop, i) => { - const displayIndex = i + indexOffset - const effectiveTotal = stops.length + indexOffset - - let pinClass = 'navi-pin navi-pin--intermediate' - if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin' - else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination' - - const label = String.fromCharCode(65 + Math.min(displayIndex, 25)) - - const el = document.createElement('div') - el.className = pinClass - el.textContent = label - - el.addEventListener('click', (e) => { - e.stopPropagation() - // Flag so the map-level click handler doesn't fire - pinClickedRef.current = true - if (popupRef.current) popupRef.current.remove() - const popup = new maplibregl.Popup({ offset: 20, closeButton: true }) - .setLngLat([stop.lon, stop.lat]) - .setHTML( - `
- ${stop.name} -
-
` - ) - .addTo(map) - - popup.getElement().querySelector(`#remove-stop-${stop.id}`)?.addEventListener('click', () => { - useStore.getState().removeStop(stop.id) - popup.remove() - }) - popupRef.current = popup - }) - - const marker = new maplibregl.Marker({ element: el }) - .setLngLat([stop.lon, stop.lat]) - .addTo(map) - - markersRef.current.push(marker) - }) - - // If stops but no route yet, fit to stops - if (stops.length > 0 && !route) { - if (stops.length === 1) { - map.flyTo({ center: [stops[0].lon, stops[0].lat], zoom: 13 }) - } else { - const bounds = stops.reduce( - (b, s) => b.extend([s.lon, s.lat]), - new maplibregl.LngLatBounds([stops[0].lon, stops[0].lat], [stops[0].lon, stops[0].lat]) - ) - map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 420, right: 60 } }) - } - } - }, [stops, route, gpsOrigin, geoPermission]) - - // ESC key handler for measurement mode useEffect(() => { const handleKeyDown = (e) => { @@ -2747,6 +2738,22 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) + // Handle directions mode cursor + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (directionsMode && activeDirectionsField) { + map.getCanvas().style.cursor = 'crosshair' + } else if (!measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + return () => { + if (map && !measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + } + }, [directionsMode, activeDirectionsField]) + // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 2799a89..b89c661 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,53 +1,60 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut } from 'lucide-react' +import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin, Target } from 'lucide-react' import ThemePicker from './ThemePicker' import { useStore, usePanelState } from '../store' import { hasFeature } from '../config' import SearchBar from './SearchBar' -import StopList from './StopList' -import ModeSelector from './ModeSelector' import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' -import { requestOptimizedRoute } from '../api' +import DirectionsPanel from './DirectionsPanel' +import PlaceDetail from './PlaceDetail' -export default function Panel({ onManeuverClick }) { +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 pendingDestination = useStore((s) => s.pendingDestination) const clearSelectedPlace = useStore((s) => s.clearSelectedPlace) - const clearPendingDestination = useStore((s) => s.clearPendingDestination) - const stops = useStore((s) => s.stops) - const mode = useStore((s) => s.mode) - const route = useStore((s) => s.route) + const 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 setStops = useStore((s) => s.setStops) - const setRoute = useStore((s) => s.setRoute) - const setRouteError = useStore((s) => s.setRouteError) - const setRouteLoading = useStore((s) => s.setRouteLoading) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const pickingRouteField = useStore((s) => s.pickingRouteField) + const setPickingRouteField = useStore((s) => s.setPickingRouteField) + const clearRoute = useStore((s) => s.clearRoute) const sheetState = useStore((s) => s.sheetState) const setSheetState = useStore((s) => s.setSheetState) - const theme = useStore((s) => s.theme) - const themeOverride = useStore((s) => s.themeOverride) - const setThemeOverride = useStore((s) => s.setThemeOverride) - const gpsOrigin = useStore((s) => s.gpsOrigin) - const geoPermission = useStore((s) => s.geoPermission) const activeTab = useStore((s) => s.activeTab) const auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) + const directionsMode = useStore((s) => s.directionsMode) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) const panelState = usePanelState() const [isMobile, setIsMobile] = useState(false) - const [optimizing, setOptimizing] = useState(false) const sheetRef = useRef(null) const dragStartY = useRef(0) const dragStartState = useRef('half') - // Show contacts tab only if feature enabled AND user is authenticated const showContacts = hasFeature('has_contacts') && auth.authenticated - // Responsive detection useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() @@ -55,61 +62,9 @@ export default function Panel({ onManeuverClick }) { return () => window.removeEventListener('resize', check) }, []) - // Auth handlers const handleLogin = () => { window.location.href = '/outpost.goauthentik.io/start?rd=%2F' } const handleLogout = () => { window.location.href = 'https://auth.echo6.co/if/flow/default-invalidation-flow/?next=https://navi.echo6.co/' } - // Optimize stops - const hasGpsOrigin = gpsOrigin && geoPermission === 'granted' - const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0) - - const handleOptimize = useCallback(async () => { - if (effectiveCount < 3 || optimizing) return - setOptimizing(true) - try { - const { userLocation } = useStore.getState() - let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon })) - if (hasGpsOrigin && userLocation) { - locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations] - } - const data = await requestOptimizedRoute(locations, mode) - if (data.trip) { - const wpOrder = hasGpsOrigin && userLocation - ? (data.trip.locations || []).slice(1) - : data.trip.locations - if (wpOrder && wpOrder.length === stops.length) { - const reordered = wpOrder.map((wp) => { - let closest = stops[0] - let minDist = Infinity - for (const s of stops) { - const d = Math.abs(s.lat - wp.lat) + Math.abs(s.lon - wp.lon) - if (d < minDist) { - minDist = d - closest = s - } - } - return closest - }) - const seen = new Set() - const unique = reordered.filter((s) => { - if (seen.has(s.id)) return false - seen.add(s.id) - return true - }) - if (unique.length === stops.length) { - setStops(unique) - } - } - setRoute(data.trip) - } - } catch (e) { - setRouteError(e.message) - } finally { - setOptimizing(false) - } - }, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError]) - - // Mobile sheet drag handling const handleTouchStart = useCallback((e) => { dragStartY.current = e.touches[0].clientY dragStartState.current = sheetState @@ -127,21 +82,30 @@ export default function Panel({ onManeuverClick }) { } }, [setSheetState]) - const showOptimize = effectiveCount >= 3 + const handleClearRoute = () => { + clearRoute() + onClearRoute?.() + } - // Determine what to show based on panel state const showPreviewCard = panelState.startsWith('PREVIEW') - const showRouteSection = ['ROUTING', 'ROUTE_CALCULATED', 'PREVIEW_ROUTING', 'PREVIEW_CALCULATED'].includes(panelState) || !!pendingDestination - const showManeuvers = panelState === 'ROUTE_CALCULATED' || panelState === 'PREVIEW_CALCULATED' - const showEmptyState = panelState === 'IDLE' && !pendingDestination + const hasRoutePoints = routeStart || routeEnd + const showRouteSection = hasRoutePoints || routeResult || routeLoading + const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - // Routes tab content - now state-driven - const routesContent = ( + // Show side panel place card when building route (either mode) and place is selected + const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace + + const routesContent = directionsMode ? ( + // Directions mode: just the directions panel, place card is shown in side panel + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + ) : ( <> - {/* Preview card when place is selected */} - {showPreviewCard && selectedPlace && ( + {showPreviewCard && selectedPlace && !showRouteSection && (
)} - {/* Route section with stops */} {showRouteSection && ( - <> -
- -
- -
- - {showOptimize && ( - - )} - {pendingDestination && stops.length === 0 && ( - - )} -
- - )} - - {/* Maneuvers when route is calculated */} - {showManeuvers && (route || routeLoading || routeError) && (
- +
+ + Route + + +
+ +
+
+ + + {routeStart?.name || 'Click pin to pick start'} + + +
+
+ + + {routeEnd?.name || 'Click pin to pick destination'} + + +
+
+ +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {routeMode !== 'auto' && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + +
)} - {/* Empty state */} {showEmptyState && (

Search or tap the map to explore

@@ -203,13 +223,13 @@ export default function Panel({ onManeuverClick }) { {showContacts && (
) - // Desktop: side panel (now 360px to accommodate PlaceCard) + // Side panel for place card during directions mode (desktop only) + const sidePlaceCardPanel = showSidePlaceCard && !isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+ {/* Use PlaceCard in compact preview mode */} + +
+ ) + + // Mobile overlay for place card during directions mode + const mobilePlaceCardOverlay = showSidePlaceCard && isMobile && ( +
+
+ + {selectedPlace?.name || 'Place Info'} + + +
+
+ +
+
+ ) + if (!isMobile) { return ( -
- {header} - {content} -
+ <> +
+ {header} + {content} +
+ {sidePlaceCardPanel} + ) } - // Mobile: bottom sheet const sheetHeights = { collapsed: 'h-12', half: 'h-[45vh]', @@ -280,13 +364,12 @@ export default function Panel({ onManeuverClick }) { return (
- {/* Drag handle */}
{sheetState !== 'collapsed' && ( -
+
{header} {content} + {mobilePlaceCardOverlay}
)}
diff --git a/src/components/PlaceCard.jsx b/src/components/PlaceCard.jsx index 3afa72b..84ecb6b 100644 --- a/src/components/PlaceCard.jsx +++ b/src/components/PlaceCard.jsx @@ -476,6 +476,7 @@ export function PlaceCard({ place, variant = "preview", expanded = true, onToggl const savedContact = contacts.find((c) => c.lat === place.lat && c.lon === place.lon) const handleDirections = () => { + console.log('[TRACE-DIRECTIONS] PlaceCard handleDirections, place:', { lat: place?.lat, lon: place?.lon, name: place?.name }) // No toast - empty origin slot is the visual prompt startDirections(place) } diff --git a/src/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 6b7f30d..069be9f 100644 --- a/src/store.js +++ b/src/store.js @@ -1,155 +1,314 @@ -import { create } from 'zustand' - -export const useStore = create((set, get) => ({ - // ── Search state ── - query: '', - results: [], - searchLoading: false, - abortController: null, - - setQuery: (query) => set({ query }), - setResults: (results) => set({ results }), - setSearchLoading: (loading) => set({ searchLoading: loading }), - setAbortController: (ctrl) => set({ abortController: ctrl }), - - // ── Stop list ── - stops: [], - // Each stop: { id, lat, lon, name, source, matchCode, isOrigin } - - addStop: (stop) => { - const { stops } = get() - if (stops.length >= 10) return false - set({ stops: [...stops, { ...stop, id: crypto.randomUUID() }] }) - return true - }, - - removeStop: (id) => { - set({ stops: get().stops.filter((s) => s.id !== id) }) - }, - - reorderStops: (newStops) => set({ stops: newStops }), - - clearStops: () => set({ stops: [] }), - - setStops: (stops) => set({ stops }), - - // ── Geolocation ── - userLocation: null, // { lat, lon } - geoPermission: 'prompt', // 'prompt' | 'granted' | 'denied' - - setUserLocation: (loc) => set({ userLocation: loc }), - setGeoPermission: (p) => set({ geoPermission: p }), - - // ── Map viewport (for search bias) ── - mapCenter: null, // { lat, lon, zoom } - setMapCenter: (center) => set({ mapCenter: center }), - - // ── Mode ── - mode: 'auto', // 'auto' | 'pedestrian' | 'bicycle' - setMode: (mode) => set({ mode }), - - // ── Route ── - route: null, // Valhalla response (trip object) - routeLoading: false, - routeError: null, - - setRoute: (route) => set({ route, routeError: null }), - setRouteLoading: (loading) => set({ routeLoading: loading }), - setRouteError: (err) => set({ routeError: err, route: null }), - clearRoute: () => set({ route: null, routeError: null }), - - // ── Place detail ── - selectedPlace: null, // { lat, lon, name, address, type, source, matchCode, raw, mode?, featureId?, featureLayer?, wikidata? } - clickMarker: null, // { lat, lon, circleRadiusPx } — visual marker for two-click selection - gpsOrigin: true, // whether GPS should be used as origin when available - pendingDestination: null, // place waiting for a starting point (GPS-denied Directions flow) - - setSelectedPlace: (place) => set({ selectedPlace: place }), - - // Boundary rendering function - set by MapView, called by PlaceCard - updateBoundary: null, - setUpdateBoundary: (fn) => set({ updateBoundary: fn }), - clearSelectedPlace: () => set({ selectedPlace: null, clickMarker: null }), - setClickMarker: (marker) => set({ clickMarker: marker }), - clearClickMarker: () => set({ clickMarker: null }), - setGpsOrigin: (val) => set({ gpsOrigin: val }), - setPendingDestination: (place) => set({ pendingDestination: place }), - clearPendingDestination: () => set({ pendingDestination: null }), - - startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) - } - }, - - // ── UI state ── - sheetState: 'half', // 'collapsed' | 'half' | 'full' - panelOpen: true, - autocompleteOpen: false, - theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) - themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) - viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' - - setSheetState: (s) => set({ sheetState: s }), - setViewMode: (mode) => { - set({ viewMode: mode }) - localStorage.setItem('navi-view-mode', mode) - }, - setPanelOpen: (open) => set({ panelOpen: open }), - setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), - setTheme: (theme) => set({ theme }), - setThemeOverride: (override) => { - set({ themeOverride: override }) - if (override) { - localStorage.setItem('navi-theme-override', override) - } else { - localStorage.removeItem('navi-theme-override') - } - }, - // ── Auth state ── - auth: { authenticated: false, username: null, loaded: false }, - setAuth: (auth) => set({ auth: { ...auth, loaded: true } }), - - // ── Contacts ── - contacts: [], - contactsLoaded: false, - activeTab: 'routes', // 'routes' | 'contacts' - editingContact: null, // null=closed, {}=new, {id:N}=edit - pickingLocationFor: null, // form data while user picks location on map - - setContacts: (c) => set({ contacts: c, contactsLoaded: true }), - setActiveTab: (tab) => set({ activeTab: tab }), - setEditingContact: (c) => set({ editingContact: c }), - clearEditingContact: () => set({ editingContact: null }), - setPickingLocationFor: (formData) => set({ pickingLocationFor: formData }), - clearPickingLocationFor: () => set({ pickingLocationFor: null }), -})) - -// ── Panel state selector ── -// Returns string state, prioritizing preview to allow it alongside any route state -export const usePanelState = () => { - return useStore((s) => { - const hasPreview = !!s.selectedPlace - const hasRoute = !!s.route - const hasStops = s.stops.length >= 1 - - if (hasPreview && hasRoute) return "PREVIEW_CALCULATED" - if (hasPreview && hasStops) return "PREVIEW_ROUTING" - if (hasPreview) return "PREVIEW" - if (hasRoute) return "ROUTE_CALCULATED" - if (hasStops) return "ROUTING" - return "IDLE" - }) -} +import { create } from "zustand" +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 + + // Add intermediate stop - can be called with or without place + // With place: creates pre-filled stop (from radial menu) + // Without place: creates empty placeholder (from Add Stop button) + addIntermediateStop: (place) => { + const { stops } = get() + if (stops.length >= 8) return false // Max 8 intermediate stops + const newStop = { + id: crypto.randomUUID(), + lat: place?.lat ?? null, + lon: place?.lon ?? null, + name: place?.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" + }) +}