feat: improve directions panel with route legend and place card below

- Add route legend showing wilderness (dashed orange) vs road (solid blue)
- Show place card below directions panel when clicking map during routing
- Clean up error messages to be user-friendly (no offroute text)
- Legend only appears when route has wilderness segments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-05-09 03:37:05 +00:00
commit 19a96cba5e
2 changed files with 316 additions and 267 deletions

View file

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

View file

@ -90,10 +90,23 @@ export default function Panel({ onClearRoute }) {
const showEmptyState = panelState === 'IDLE' && !hasRoutePoints const showEmptyState = panelState === 'IDLE' && !hasRoutePoints
const routesContent = directionsMode ? ( const routesContent = directionsMode ? (
<DirectionsPanel onClose={() => { <>
setDirectionsMode(false) <DirectionsPanel onClose={() => {
onClearRoute?.() setDirectionsMode(false)
}} /> onClearRoute?.()
}} />
{/* Show place card below directions when clicking map during routing */}
{selectedPlace && (
<div className="mt-3 border-t pt-3" style={{ borderColor: "var(--border)" }}>
<PlaceCard
place={selectedPlace}
variant="preview"
expanded={true}
onClose={clearSelectedPlace}
/>
</div>
)}
</>
) : ( ) : (
<> <>
<SearchBar /> <SearchBar />