diff --git a/src/components/DirectionsPanel.jsx b/src/components/DirectionsPanel.jsx new file mode 100644 index 0000000..d2f7db4 --- /dev/null +++ b/src/components/DirectionsPanel.jsx @@ -0,0 +1,263 @@ +import { useEffect } from "react" +import { ArrowUpDown, Plus, X, Footprints, Bike, Car, Shield, AlertTriangle, Zap } from "lucide-react" +import { useStore } from "../store" +import LocationInput from "./LocationInput" +import ManeuverList from "./ManeuverList" + +const TRAVEL_MODES = [ + { id: "auto", label: "Drive", Icon: Car }, + { id: "foot", label: "Foot", Icon: Footprints }, + { id: "mtb", label: "MTB", Icon: Bike }, + { id: "atv", label: "ATV", Icon: Car }, + { id: "vehicle", label: "4x4", Icon: Car }, +] + +const BOUNDARY_MODES = [ + { id: "strict", label: "Strict", Icon: Shield, title: "Avoid barriers" }, + { id: "pragmatic", label: "Cross", Icon: AlertTriangle, title: "Cross with penalty" }, + { id: "emergency", label: "Ignore", Icon: Zap, title: "Ignore barriers" }, +] + +export default function DirectionsPanel({ onClose }) { + const routeStart = useStore((s) => s.routeStart) + const routeEnd = useStore((s) => s.routeEnd) + const routeMode = useStore((s) => s.routeMode) + const boundaryMode = useStore((s) => s.boundaryMode) + const routeResult = useStore((s) => s.routeResult) + const routeLoading = useStore((s) => s.routeLoading) + const routeError = useStore((s) => s.routeError) + const stops = useStore((s) => s.stops) + const userLocation = useStore((s) => s.userLocation) + const geoPermission = useStore((s) => s.geoPermission) + + const setRouteStart = useStore((s) => s.setRouteStart) + const setRouteEnd = useStore((s) => s.setRouteEnd) + const setRouteMode = useStore((s) => s.setRouteMode) + const setBoundaryMode = useStore((s) => s.setBoundaryMode) + const computeRoute = useStore((s) => s.computeRoute) + const clearRoute = useStore((s) => s.clearRoute) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) + const addStop = useStore((s) => s.addStop) + const removeStop = useStore((s) => s.removeStop) + const reorderStops = useStore((s) => s.reorderStops) + + // Auto-fill origin with GPS if available and origin is empty + useEffect(() => { + if (!routeStart && geoPermission === "granted" && userLocation) { + setRouteStart({ + lat: userLocation.lat, + lon: userLocation.lon, + name: "Your location", + source: "gps", + }) + } + }, [routeStart, geoPermission, userLocation, setRouteStart]) + + // Auto-compute route when both endpoints are set + useEffect(() => { + if (routeStart && routeEnd) { + computeRoute() + } + }, [routeStart?.lat, routeStart?.lon, routeEnd?.lat, routeEnd?.lon]) + + const handleSwap = () => { + const tempStart = routeStart + const tempEnd = routeEnd + setRouteStart(tempEnd) + setRouteEnd(tempStart) + } + + const handleClose = () => { + clearRoute() + setDirectionsMode(false) + onClose?.() + } + + const handleAddStop = () => { + // Insert a stop between origin and destination + // For now, this adds to the stops array + // The UI will show intermediate stops + } + + // Multi-stop support: show intermediate stops from the stops array + const intermediateStops = stops.slice(1, -1) // Everything except first and last + + return ( +
+ {/* Header */} +
+ + Directions + + +
+ + {/* Origin/Destination inputs with swap button */} +
+ {/* Origin */} + + + {/* Swap button - positioned between inputs */} + + + {/* Intermediate stops (for multi-stop routes) */} + {intermediateStops.map((stop, idx) => ( +
+ { + if (place) { + const newStops = [...stops] + newStops[idx + 1] = { ...newStops[idx + 1], ...place } + reorderStops(newStops) + } else { + removeStop(stop.id) + } + }} + placeholder="Stop" + icon="stop" + fieldId={`stop-${idx}`} + /> +
+ ))} + + {/* Destination */} + + + {/* Add stop button */} + {routeStart && routeEnd && stops.length < 10 && ( + + )} +
+ + {/* Travel mode selector */} +
+ {TRAVEL_MODES.map((m) => { + const active = routeMode === m.id + return ( + + ) + })} +
+ + {/* Boundary mode selector (only for non-auto modes) */} + {routeMode !== "auto" && ( +
+ {BOUNDARY_MODES.map((m) => { + const active = boundaryMode === m.id + return ( + + ) + })} +
+ )} + + {/* Loading indicator */} + {routeLoading && ( +
+
+ + Finding route... + +
+ )} + + {/* Error message */} + {routeError && ( +
+ {routeError} +
+ )} + + {/* Route summary and maneuvers */} + {routeResult && !routeLoading && ( +
+ +
+ )} + + {/* Hint when waiting for input */} + {!routeStart && !routeEnd && !routeLoading && ( +
+

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

+
+ )} +
+ ) +} diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx new file mode 100644 index 0000000..a15b1bb --- /dev/null +++ b/src/components/LocationInput.jsx @@ -0,0 +1,301 @@ +import { useRef, useEffect, useCallback, useState } from "react" +import { MapPin, Crosshair, X, Navigation2, User, Star, Coffee, Fuel, ShoppingBag, Hotel, Building2 } from "lucide-react" +import { useStore } from "../store" +import { searchGeocode } from "../api" +import { buildAddress } from "../utils/place" +import { hasFeature } from "../config" + +/** Parse coordinate input like "42.35, -114.30" */ +function parseCoordinates(input) { + if (!input) return null + const pattern = /^(-?\d+\.?\d*)\s*[,\s]\s*(-?\d+\.?\d*)$/ + const match = input.trim().match(pattern) + if (!match) return null + const lat = parseFloat(match[1]) + const lon = parseFloat(match[2]) + if (isNaN(lat) || isNaN(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) return null + return { lat, lon } +} + +function CategoryIcon({ result, size = 14 }) { + const type = result.type || "" + const source = result.source || "" + if (result._isContact) return + if (source === "nickname") return + if (type === "coordinates") return + if (type === "locality" || type === "city") return + const osmVal = result.raw?.osm_value || "" + if (osmVal.includes("cafe") || osmVal.includes("coffee")) return + if (osmVal.includes("fuel") || osmVal.includes("gas")) return + if (osmVal.includes("shop") || osmVal.includes("supermarket")) return + if (osmVal.includes("hotel") || osmVal.includes("motel")) return + return +} + +export default function LocationInput({ + value, // { lat, lon, name } or null + onChange, // (place) => void + placeholder, + icon, // "origin" | "destination" | "stop" + fieldId, // unique id for this field (for map click targeting) + onFocus, // () => void + autoFocus, +}) { + const inputRef = useRef(null) + const [query, setQuery] = useState(value?.name || "") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(-1) + const debounceRef = useRef(null) + const abortRef = useRef(null) + + const contacts = useStore((s) => s.contacts) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) + const setActiveDirectionsField = useStore((s) => s.setActiveDirectionsField) + + // Sync display value when external value changes + useEffect(() => { + if (value?.name && value.name !== query) { + setQuery(value.name) + } else if (!value && query && !open) { + // Value cleared externally + setQuery("") + } + }, [value?.name, value?.lat, value?.lon]) + + const doSearch = useCallback(async (q) => { + if (abortRef.current) abortRef.current.abort() + + if (!q.trim()) { + setResults([]) + setOpen(false) + setLoading(false) + return + } + + // Check coordinates first + const coords = parseCoordinates(q) + if (coords) { + const coordResult = { + lat: coords.lat, + lon: coords.lon, + name: coords.lat.toFixed(5) + ", " + coords.lon.toFixed(5), + address: "Coordinates", + type: "coordinates", + source: "coordinates", + match_code: null, + raw: {}, + } + setResults([coordResult]) + setOpen(true) + setLoading(false) + return + } + + // Contact matches + let contactResults = [] + if (hasFeature("has_contacts") && contacts.length > 0) { + const lower = q.trim().toLowerCase() + contactResults = contacts + .filter((c) => + (c.label || "").toLowerCase().startsWith(lower) || + (c.name || "").toLowerCase().startsWith(lower) || + (c.call_sign || "").toLowerCase().startsWith(lower) + ) + .slice(0, 3) + .map((c) => ({ + lat: c.lat, + lon: c.lon, + name: c.label, + address: c.address || c.name || "", + type: "contact", + source: "contacts", + match_code: null, + raw: { contact: c }, + _isContact: true, + })) + } + + const ctrl = new AbortController() + abortRef.current = ctrl + setLoading(true) + + try { + const data = await searchGeocode(q.trim(), 5, ctrl.signal) + const combined = [...contactResults, ...(data.results || [])] + setResults(combined) + setOpen(combined.length > 0) + setActiveIndex(-1) + } catch (e) { + if (e.name !== "AbortError") { + if (contactResults.length > 0) { + setResults(contactResults) + setOpen(true) + } else { + setResults([]) + setOpen(false) + } + } + } finally { + setLoading(false) + } + }, [contacts]) + + const handleChange = (e) => { + const val = e.target.value + setQuery(val) + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => doSearch(val), 150) + } + + const handleClear = () => { + setQuery("") + setResults([]) + setOpen(false) + onChange(null) + inputRef.current?.focus() + } + + const selectResult = (result) => { + onChange({ + lat: result.lat, + lon: result.lon, + name: result.name, + source: result.source, + matchCode: result.match_code, + }) + setQuery(result.name) + setResults([]) + setOpen(false) + setActiveIndex(-1) + } + + const handleKeyDown = (e) => { + if (!open || results.length === 0) { + if (e.key === "Escape") setOpen(false) + return + } + switch (e.key) { + case "ArrowDown": + e.preventDefault() + setActiveIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + case "ArrowUp": + e.preventDefault() + setActiveIndex((prev) => Math.max(prev - 1, -1)) + break + case "Enter": + e.preventDefault() + if (activeIndex >= 0 && activeIndex < results.length) { + selectResult(results[activeIndex]) + } + break + case "Escape": + e.preventDefault() + setOpen(false) + setActiveIndex(-1) + break + } + } + + const handleFocus = () => { + setActiveDirectionsField(fieldId) + if (results.length > 0) setOpen(true) + onFocus?.() + } + + const handleBlur = () => { + // Delay to allow click on dropdown + setTimeout(() => setOpen(false), 150) + } + + const isActive = activeDirectionsField === fieldId + + const iconColor = icon === "origin" ? "#22c55e" : icon === "destination" ? "#ef4444" : "var(--text-tertiary)" + + return ( +
+
+ {icon === "origin" ? ( + + ) : ( + + )} + + {loading ? ( +
+ ) : query ? ( + + ) : null} +
+ + {open && results.length > 0 && ( +
    + {results.map((r, i) => { + const isPoi = r.type === "poi" && r.raw?.name + const isContact = r._isContact + const primary = isContact ? r.name : isPoi ? r.raw.name : r.name + const secondary = isContact ? (r.address || "") : isPoi ? buildAddress(r) : null + return ( +
  • selectResult(r)} + onMouseEnter={() => setActiveIndex(i)} + > +
    + + + + + {primary} + +
    + {secondary && ( +
    + {secondary} +
    + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/components/MapView.jsx b/src/components/MapView.jsx index ca9b5c5..a46eac5 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1441,6 +1441,8 @@ const MapView = forwardRef(function MapView(_, ref) { const pickingLocationFor = useStore((s) => s.pickingLocationFor) const setEditingContact = useStore((s) => s.setEditingContact) const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor) + const directionsMode = useStore((s) => s.directionsMode) + const activeDirectionsField = useStore((s) => s.activeDirectionsField) // Zoom level indicator state const [zoomLevel, setZoomLevel] = useState(10) @@ -1999,7 +2001,37 @@ const MapView = forwardRef(function MapView(_, ref) { return } - + // Handle directions mode — click fills the active field + const { directionsMode, activeDirectionsField, setRouteStart, setRouteEnd, setActiveDirectionsField } = useStore.getState() + if (directionsMode && activeDirectionsField) { + const { lng, lat } = e.lngLat + // Reverse geocode for name + fetchReverse(lat, lng).then((place) => { + const name = place?.name || lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } else if (activeDirectionsField.startsWith("stop-")) { + // Handle intermediate stops - would need more logic + setActiveDirectionsField(null) + } + }).catch(() => { + const name = lat.toFixed(5) + ", " + lng.toFixed(5) + const location = { lat, lon: lng, name, source: "map_click" } + if (activeDirectionsField === "origin") { + setRouteStart(location) + setActiveDirectionsField("destination") + } else if (activeDirectionsField === "destination") { + setRouteEnd(location) + setActiveDirectionsField(null) + } + }) + return + } const store = useStore.getState() const marker = store.clickMarker @@ -2694,6 +2726,22 @@ const MapView = forwardRef(function MapView(_, ref) { } }, [pickingLocationFor]) + // Handle directions mode cursor + useEffect(() => { + const map = mapInstance.current + if (!map) return + if (directionsMode && activeDirectionsField) { + map.getCanvas().style.cursor = 'crosshair' + } else if (!measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + return () => { + if (map && !measuringRef.current.active && !pickingLocationFor) { + map.getCanvas().style.cursor = '' + } + } + }, [directionsMode, activeDirectionsField]) + // ESC key handler for location pick mode useEffect(() => { const handleKeyDown = (e) => { diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index efc9b5a..a708734 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -7,6 +7,7 @@ import SearchBar from './SearchBar' import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' +import DirectionsPanel from './DirectionsPanel' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -39,6 +40,8 @@ export default function Panel({ onClearRoute }) { const activeTab = useStore((s) => s.activeTab) const auth = useStore((s) => s.auth) const setActiveTab = useStore((s) => s.setActiveTab) + const directionsMode = useStore((s) => s.directionsMode) + const setDirectionsMode = useStore((s) => s.setDirectionsMode) const panelState = usePanelState() @@ -86,7 +89,12 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints - const routesContent = ( + const routesContent = directionsMode ? ( + { + setDirectionsMode(false) + onClearRoute?.() + }} /> + ) : ( <> diff --git a/src/store.js b/src/store.js index a4039dc..4ea2839 100644 --- a/src/store.js +++ b/src/store.js @@ -173,23 +173,38 @@ export const useStore = create((set, get) => ({ setPendingDestination: (place) => set({ pendingDestination: place }), clearPendingDestination: () => set({ pendingDestination: null }), - // Master startDirections - restored verbatim + // Master startDirections - enters directions mode with destination pre-filled startDirections: (place) => { - const { geoPermission, stops, addStop, clearStops } = get() - if (geoPermission === 'granted') { - clearStops() - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ gpsOrigin: true, selectedPlace: null }) - } else if (stops.length > 0) { - const origin = stops[0] - clearStops() - addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode }) - addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode }) - set({ selectedPlace: null }) - } else { - // GPS denied, no stops: set pendingDestination only; origin-picker will add both - set({ pendingDestination: place, selectedPlace: null }) + const { geoPermission, userLocation, clearRoute } = get() + clearRoute() + + // Set destination from the clicked place + const destination = { + lat: place.lat, + lon: place.lon, + name: place.name, + source: place.source, + matchCode: place.matchCode, } + + // Set origin from GPS if available + let origin = null + if (geoPermission === 'granted' && userLocation) { + origin = { + lat: userLocation.lat, + lon: userLocation.lon, + name: 'Your location', + source: 'gps', + } + } + + set({ + routeEnd: destination, + routeStart: origin, + directionsMode: true, + activeDirectionsField: origin ? null : 'origin', // Focus origin if empty + selectedPlace: null, + }) }, // Legacy route setter (for 3+ stop Valhalla optimization) @@ -213,6 +228,8 @@ export const useStore = create((set, get) => ({ sheetState: 'half', // 'collapsed' | 'half' | 'full' panelOpen: true, autocompleteOpen: false, + directionsMode: false, // true when directions panel is active + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied) themeOverride: null, // null | 'dark' | 'light' (manual override, persisted) viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid' @@ -224,6 +241,8 @@ export const useStore = create((set, get) => ({ }, setPanelOpen: (open) => set({ panelOpen: open }), setAutocompleteOpen: (open) => set({ autocompleteOpen: open }), + setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }), + setActiveDirectionsField: (field) => set({ activeDirectionsField: field }), setTheme: (theme) => set({ theme }), setThemeOverride: (override) => { set({ themeOverride: override })