mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 14:44:51 +02:00
feat: add directions panel with editable origin/destination inputs
New UX for Get Directions: - DirectionsPanel component with two stacked input fields - LocationInput component with autocomplete, coordinate parsing - Swap button to flip origin/destination - Travel mode selector (Drive default, Foot, MTB, ATV, 4x4) - Boundary selector (only visible for non-Drive modes) - Map click fills active input field with crosshair cursor - Auto-route when both endpoints are filled - X button closes directions and returns to search view Store changes: - directionsMode state for panel switching - activeDirectionsField for map click targeting - startDirections now enters directions mode with destination pre-filled Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
09d68adf09
commit
7523ddd0a2
5 changed files with 656 additions and 17 deletions
263
src/components/DirectionsPanel.jsx
Normal file
263
src/components/DirectionsPanel.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
Directions
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-[var(--bg-overlay)] transition-colors"
|
||||||
|
title="Close directions"
|
||||||
|
>
|
||||||
|
<X size={18} style={{ color: "var(--text-tertiary)" }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Origin/Destination inputs with swap button */}
|
||||||
|
<div className="relative flex flex-col gap-2">
|
||||||
|
{/* Origin */}
|
||||||
|
<LocationInput
|
||||||
|
value={routeStart}
|
||||||
|
onChange={setRouteStart}
|
||||||
|
placeholder={geoPermission === "granted" ? "Your location" : "Choose starting point"}
|
||||||
|
icon="origin"
|
||||||
|
fieldId="origin"
|
||||||
|
autoFocus={!routeStart}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Swap button - positioned between inputs */}
|
||||||
|
<button
|
||||||
|
onClick={handleSwap}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 p-1.5 rounded-full transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-raised)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
title="Swap origin and destination"
|
||||||
|
>
|
||||||
|
<ArrowUpDown size={14} style={{ color: "var(--text-secondary)" }} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Intermediate stops (for multi-stop routes) */}
|
||||||
|
{intermediateStops.map((stop, idx) => (
|
||||||
|
<div key={stop.id} className="relative">
|
||||||
|
<LocationInput
|
||||||
|
value={{ lat: stop.lat, lon: stop.lon, name: stop.name }}
|
||||||
|
onChange={(place) => {
|
||||||
|
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}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Destination */}
|
||||||
|
<LocationInput
|
||||||
|
value={routeEnd}
|
||||||
|
onChange={setRouteEnd}
|
||||||
|
placeholder="Choose destination"
|
||||||
|
icon="destination"
|
||||||
|
fieldId="destination"
|
||||||
|
autoFocus={routeStart && !routeEnd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Add stop button */}
|
||||||
|
{routeStart && routeEnd && stops.length < 10 && (
|
||||||
|
<button
|
||||||
|
onClick={handleAddStop}
|
||||||
|
className="flex items-center justify-center gap-1.5 py-1.5 text-xs rounded-lg transition-colors"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-overlay)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
border: "1px dashed var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
<span>Add stop</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Travel mode selector */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TRAVEL_MODES.map((m) => {
|
||||||
|
const active = routeMode === m.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setRouteMode(m.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 py-2 text-xs rounded-lg transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
|
||||||
|
color: active ? "var(--accent)" : "var(--text-tertiary)",
|
||||||
|
}}
|
||||||
|
title={m.label}
|
||||||
|
>
|
||||||
|
<m.Icon size={16} />
|
||||||
|
<span className="hidden sm:inline">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Boundary mode selector (only for non-auto modes) */}
|
||||||
|
{routeMode !== "auto" && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{BOUNDARY_MODES.map((m) => {
|
||||||
|
const active = boundaryMode === m.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => setBoundaryMode(m.id)}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 py-1.5 text-xs rounded-lg transition-colors"
|
||||||
|
style={{
|
||||||
|
background: active ? "var(--accent-muted)" : "var(--bg-overlay)",
|
||||||
|
color: active ? "var(--accent)" : "var(--text-tertiary)",
|
||||||
|
}}
|
||||||
|
title={m.title}
|
||||||
|
>
|
||||||
|
<m.Icon size={14} />
|
||||||
|
<span className="hidden sm:inline">{m.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{routeLoading && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-3">
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 border-2 border-t-transparent rounded-full animate-spin"
|
||||||
|
style={{ borderColor: "var(--accent)", borderTopColor: "transparent" }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Finding route...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{routeError && (
|
||||||
|
<div
|
||||||
|
className="px-3 py-2 rounded-lg text-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--error-bg, rgba(239, 68, 68, 0.1))",
|
||||||
|
color: "var(--error, #ef4444)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{routeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Route summary and maneuvers */}
|
||||||
|
{routeResult && !routeLoading && (
|
||||||
|
<div className="border-t pt-3" style={{ borderColor: "var(--border)" }}>
|
||||||
|
<ManeuverList />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hint when waiting for input */}
|
||||||
|
{!routeStart && !routeEnd && !routeLoading && (
|
||||||
|
<div className="text-center py-4">
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-tertiary)" }}>
|
||||||
|
Enter addresses, paste coordinates, or click the map
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
301
src/components/LocationInput.jsx
Normal file
301
src/components/LocationInput.jsx
Normal file
|
|
@ -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 <User size={size} />
|
||||||
|
if (source === "nickname") return <Star size={size} />
|
||||||
|
if (type === "coordinates") return <Crosshair size={size} />
|
||||||
|
if (type === "locality" || type === "city") return <Building2 size={size} />
|
||||||
|
const osmVal = result.raw?.osm_value || ""
|
||||||
|
if (osmVal.includes("cafe") || osmVal.includes("coffee")) return <Coffee size={size} />
|
||||||
|
if (osmVal.includes("fuel") || osmVal.includes("gas")) return <Fuel size={size} />
|
||||||
|
if (osmVal.includes("shop") || osmVal.includes("supermarket")) return <ShoppingBag size={size} />
|
||||||
|
if (osmVal.includes("hotel") || osmVal.includes("motel")) return <Hotel size={size} />
|
||||||
|
return <MapPin size={size} />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-all"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-overlay)",
|
||||||
|
border: isActive ? "1px solid var(--accent)" : "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon === "origin" ? (
|
||||||
|
<Navigation2 size={16} style={{ color: iconColor, transform: "rotate(45deg)" }} />
|
||||||
|
) : (
|
||||||
|
<MapPin size={16} style={{ color: iconColor }} />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
className="flex-1 bg-transparent text-sm outline-none"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<div
|
||||||
|
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
|
||||||
|
style={{ borderColor: "var(--accent)", borderTopColor: "transparent" }}
|
||||||
|
/>
|
||||||
|
) : query ? (
|
||||||
|
<button onClick={handleClear} className="p-0.5" style={{ color: "var(--text-tertiary)" }}>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && results.length > 0 && (
|
||||||
|
<ul
|
||||||
|
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-48 overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-overlay)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
boxShadow: "var(--shadow-lg)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<li
|
||||||
|
key={`${r.lat}-${r.lon}-${i}`}
|
||||||
|
className="px-3 py-2 cursor-pointer text-sm"
|
||||||
|
style={{
|
||||||
|
background: i === activeIndex ? "var(--accent-muted)" : "transparent",
|
||||||
|
borderBottom: i < results.length - 1 ? "1px solid var(--border-subtle)" : "none",
|
||||||
|
}}
|
||||||
|
onClick={() => selectResult(r)}
|
||||||
|
onMouseEnter={() => setActiveIndex(i)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span style={{ color: isContact ? "var(--accent)" : "var(--text-tertiary)" }}>
|
||||||
|
<CategoryIcon result={r} />
|
||||||
|
</span>
|
||||||
|
<span className="truncate flex-1" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{primary}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{secondary && (
|
||||||
|
<div className="text-[11px] mt-0.5 ml-6 truncate" style={{ color: "var(--text-tertiary)" }}>
|
||||||
|
{secondary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1441,6 +1441,8 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
const pickingLocationFor = useStore((s) => s.pickingLocationFor)
|
const pickingLocationFor = useStore((s) => s.pickingLocationFor)
|
||||||
const setEditingContact = useStore((s) => s.setEditingContact)
|
const setEditingContact = useStore((s) => s.setEditingContact)
|
||||||
const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor)
|
const clearPickingLocationFor = useStore((s) => s.clearPickingLocationFor)
|
||||||
|
const directionsMode = useStore((s) => s.directionsMode)
|
||||||
|
const activeDirectionsField = useStore((s) => s.activeDirectionsField)
|
||||||
|
|
||||||
// Zoom level indicator state
|
// Zoom level indicator state
|
||||||
const [zoomLevel, setZoomLevel] = useState(10)
|
const [zoomLevel, setZoomLevel] = useState(10)
|
||||||
|
|
@ -1999,7 +2001,37 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
return
|
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 store = useStore.getState()
|
||||||
const marker = store.clickMarker
|
const marker = store.clickMarker
|
||||||
|
|
@ -2694,6 +2726,22 @@ const MapView = forwardRef(function MapView(_, ref) {
|
||||||
}
|
}
|
||||||
}, [pickingLocationFor])
|
}, [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
|
// ESC key handler for location pick mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import SearchBar from './SearchBar'
|
||||||
import ManeuverList from './ManeuverList'
|
import ManeuverList from './ManeuverList'
|
||||||
import ContactList from './ContactList'
|
import ContactList from './ContactList'
|
||||||
import { PlaceCard } from './PlaceCard'
|
import { PlaceCard } from './PlaceCard'
|
||||||
|
import DirectionsPanel from './DirectionsPanel'
|
||||||
|
|
||||||
const TRAVEL_MODES = [
|
const TRAVEL_MODES = [
|
||||||
{ id: 'auto', label: 'Drive', Icon: Car },
|
{ id: 'auto', label: 'Drive', Icon: Car },
|
||||||
|
|
@ -39,6 +40,8 @@ export default function Panel({ onClearRoute }) {
|
||||||
const activeTab = useStore((s) => s.activeTab)
|
const activeTab = useStore((s) => s.activeTab)
|
||||||
const auth = useStore((s) => s.auth)
|
const auth = useStore((s) => s.auth)
|
||||||
const setActiveTab = useStore((s) => s.setActiveTab)
|
const setActiveTab = useStore((s) => s.setActiveTab)
|
||||||
|
const directionsMode = useStore((s) => s.directionsMode)
|
||||||
|
const setDirectionsMode = useStore((s) => s.setDirectionsMode)
|
||||||
|
|
||||||
const panelState = usePanelState()
|
const panelState = usePanelState()
|
||||||
|
|
||||||
|
|
@ -86,7 +89,12 @@ export default function Panel({ onClearRoute }) {
|
||||||
const showRouteSection = hasRoutePoints || routeResult || routeLoading
|
const showRouteSection = hasRoutePoints || routeResult || routeLoading
|
||||||
const showEmptyState = panelState === 'IDLE' && !hasRoutePoints
|
const showEmptyState = panelState === 'IDLE' && !hasRoutePoints
|
||||||
|
|
||||||
const routesContent = (
|
const routesContent = directionsMode ? (
|
||||||
|
<DirectionsPanel onClose={() => {
|
||||||
|
setDirectionsMode(false)
|
||||||
|
onClearRoute?.()
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
|
|
||||||
|
|
|
||||||
49
src/store.js
49
src/store.js
|
|
@ -173,23 +173,38 @@ export const useStore = create((set, get) => ({
|
||||||
setPendingDestination: (place) => set({ pendingDestination: place }),
|
setPendingDestination: (place) => set({ pendingDestination: place }),
|
||||||
clearPendingDestination: () => set({ pendingDestination: null }),
|
clearPendingDestination: () => set({ pendingDestination: null }),
|
||||||
|
|
||||||
// Master startDirections - restored verbatim
|
// Master startDirections - enters directions mode with destination pre-filled
|
||||||
startDirections: (place) => {
|
startDirections: (place) => {
|
||||||
const { geoPermission, stops, addStop, clearStops } = get()
|
const { geoPermission, userLocation, clearRoute } = get()
|
||||||
if (geoPermission === 'granted') {
|
clearRoute()
|
||||||
clearStops()
|
|
||||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
// Set destination from the clicked place
|
||||||
set({ gpsOrigin: true, selectedPlace: null })
|
const destination = {
|
||||||
} else if (stops.length > 0) {
|
lat: place.lat,
|
||||||
const origin = stops[0]
|
lon: place.lon,
|
||||||
clearStops()
|
name: place.name,
|
||||||
addStop({ lat: origin.lat, lon: origin.lon, name: origin.name, source: origin.source, matchCode: origin.matchCode })
|
source: place.source,
|
||||||
addStop({ lat: place.lat, lon: place.lon, name: place.name, source: place.source, matchCode: place.matchCode })
|
matchCode: place.matchCode,
|
||||||
set({ selectedPlace: null })
|
|
||||||
} else {
|
|
||||||
// GPS denied, no stops: set pendingDestination only; origin-picker will add both
|
|
||||||
set({ pendingDestination: place, selectedPlace: null })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
// Legacy route setter (for 3+ stop Valhalla optimization)
|
||||||
|
|
@ -213,6 +228,8 @@ export const useStore = create((set, get) => ({
|
||||||
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
sheetState: 'half', // 'collapsed' | 'half' | 'full'
|
||||||
panelOpen: true,
|
panelOpen: true,
|
||||||
autocompleteOpen: false,
|
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)
|
theme: 'dark', // 'dark' | 'light' (resolved value — what's actually applied)
|
||||||
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
themeOverride: null, // null | 'dark' | 'light' (manual override, persisted)
|
||||||
viewMode: (typeof localStorage !== 'undefined' && localStorage.getItem('navi-view-mode')) || 'map', // 'map' | 'satellite' | 'hybrid'
|
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 }),
|
setPanelOpen: (open) => set({ panelOpen: open }),
|
||||||
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
setAutocompleteOpen: (open) => set({ autocompleteOpen: open }),
|
||||||
|
setDirectionsMode: (mode) => set({ directionsMode: mode, activeDirectionsField: mode ? 'origin' : null }),
|
||||||
|
setActiveDirectionsField: (field) => set({ activeDirectionsField: field }),
|
||||||
setTheme: (theme) => set({ theme }),
|
setTheme: (theme) => set({ theme }),
|
||||||
setThemeOverride: (override) => {
|
setThemeOverride: (override) => {
|
||||||
set({ themeOverride: override })
|
set({ themeOverride: override })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue