From 816ea8dd1f84380968892cb5d7239dee8449eea2 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 9 May 2026 06:09:14 +0000 Subject: [PATCH] feat: wilderness maneuvers, pick-from-map, distance formatting, place card panel - Wilderness maneuvers render with compass arrows and cardinal directions - Network maneuvers prefixed with transport mode (Drive/Walk/Ride) - Distances under 1 mile show feet with commas - Pick-from-map mode replaces auto-fill-on-focus (crosshair + toast) - ESC cancels pick mode - Place card slides out right during active routing - Removed debug toasts Co-Authored-By: Claude Opus 4.5 --- src/api.js | 1 + src/components/LocationInput.jsx | 622 ++++++++++++++++--------------- src/components/ManeuverList.jsx | 233 ++++++++++-- src/components/MapView.jsx | 27 +- src/components/Panel.jsx | 147 ++++++-- src/components/PlaceCard.jsx | 1 + src/store.js | 10 +- 7 files changed, 663 insertions(+), 378 deletions(-) diff --git a/src/api.js b/src/api.js index 47d5861..bed21ec 100644 --- a/src/api.js +++ b/src/api.js @@ -342,6 +342,7 @@ export async function requestOffroute(start, end, mode = "foot", boundaryMode = 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 diff --git a/src/components/LocationInput.jsx b/src/components/LocationInput.jsx index a15b1bb..eb0a204 100644 --- a/src/components/LocationInput.jsx +++ b/src/components/LocationInput.jsx @@ -1,301 +1,321 @@ -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} -
    - )} -
  • - ) - })} -
- )} -
- ) -} +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 a8b90b0..44d1ffc 100644 --- a/src/components/ManeuverList.jsx +++ b/src/components/ManeuverList.jsx @@ -1,13 +1,32 @@ import { MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft, MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw, - GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle + GitMerge, CornerRightDown, CornerRightUp, Navigation, Mountain, Map, AlertTriangle, + Compass, ArrowUp, ArrowUpRight, ArrowRight, ArrowDownRight, ArrowDown, + ArrowDownLeft, ArrowLeft, ArrowUpLeft, MapPin } from 'lucide-react' import { useStore } from '../store' -function formatDistKm(km) { - const miles = km * 0.621371 - if (miles < 0.1) return Math.round(miles * 5280) + ' ft' +/** + * 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' } @@ -18,6 +37,51 @@ function formatTimeMin(minutes) { 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 } @@ -40,10 +104,55 @@ function ManeuverIcon({ type }) { } } +/** + * 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 ( @@ -77,8 +186,25 @@ export default function ManeuverList() { if (!routeResult?.summary) return null const summary = routeResult.summary - const networkFeature = routeResult.route?.features?.find(f => f.properties?.segment_type === 'network') - const maneuvers = networkFeature?.properties?.maneuvers || [] + 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 (
@@ -88,7 +214,7 @@ export default function ManeuverList() { style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }} > - {formatDistKm(summary.total_distance_km)} + {formatDistance(null, summary.total_distance_km)} {formatTimeMin(summary.total_effort_minutes)} @@ -102,7 +228,7 @@ export default function ManeuverList() { Wilderness - {formatDistKm(summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)} + {formatDistance(null, summary.wilderness_distance_km)} / {formatTimeMin(summary.wilderness_effort_minutes)}
)} @@ -111,7 +237,7 @@ export default function ManeuverList() { Road/Trail - {formatDistKm(summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)} + {formatDistance(null, summary.network_distance_km)} / {formatTimeMin(summary.network_duration_minutes)}
)} @@ -136,27 +262,80 @@ export default function ManeuverList() { )} {/* Turn-by-turn directions */} - {maneuvers.length > 0 && ( + {hasManeuvers && (
Directions
- {maneuvers.map((man, i) => ( -
- - - -
-

- {man.instruction} -

-

- {formatDistKm(man.distance_km)} -

+ + {/* 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 00b7bf5..c77994b 100644 --- a/src/components/MapView.jsx +++ b/src/components/MapView.jsx @@ -1443,6 +1443,7 @@ const MapView = forwardRef(function MapView(_, ref) { 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) @@ -2001,34 +2002,30 @@ 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) { + // 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 (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) - } else if (activeDirectionsField.startsWith("stop-")) { - // Handle intermediate stops - would need more logic - setActiveDirectionsField(null) } + clearPickingRouteField() }).catch(() => { const name = lat.toFixed(5) + ", " + lng.toFixed(5) const location = { lat, lon: lng, name, source: "map_click" } - if (activeDirectionsField === "origin") { + if (pickingRouteField === "origin") { setRouteStart(location) - setActiveDirectionsField("destination") - } else if (activeDirectionsField === "destination") { + } else if (pickingRouteField === "destination") { setRouteEnd(location) - setActiveDirectionsField(null) } + clearPickingRouteField() }) return } @@ -2253,6 +2250,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, @@ -2284,6 +2282,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, diff --git a/src/components/Panel.jsx b/src/components/Panel.jsx index 98e9f16..b89c661 100644 --- a/src/components/Panel.jsx +++ b/src/components/Panel.jsx @@ -1,5 +1,5 @@ import { useRef, useCallback, useEffect, useState } from 'react' -import { LogIn, LogOut, Footprints, Bike, Car, Shield, AlertTriangle, Zap, X, MapPin } 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' @@ -8,6 +8,7 @@ import ManeuverList from './ManeuverList' import ContactList from './ContactList' import { PlaceCard } from './PlaceCard' import DirectionsPanel from './DirectionsPanel' +import PlaceDetail from './PlaceDetail' const TRAVEL_MODES = [ { id: 'auto', label: 'Drive', Icon: Car }, @@ -34,6 +35,8 @@ export default function Panel({ onClearRoute }) { const routeLoading = useStore((s) => s.routeLoading) 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) @@ -89,29 +92,20 @@ export default function Panel({ onClearRoute }) { const showRouteSection = hasRoutePoints || routeResult || routeLoading const showEmptyState = panelState === 'IDLE' && !hasRoutePoints + // Show side panel place card when building route (either mode) and place is selected + const showSidePlaceCard = (directionsMode || showRouteSection) && selectedPlace + const routesContent = directionsMode ? ( - <> - { - setDirectionsMode(false) - onClearRoute?.() - }} /> - {/* Show place card below directions when clicking map during routing */} - {selectedPlace && ( -
- -
- )} - + // Directions mode: just the directions panel, place card is shown in side panel + { + setDirectionsMode(false) + onClearRoute?.() + }} /> ) : ( <> - {showPreviewCard && selectedPlace && ( + {showPreviewCard && selectedPlace && !showRouteSection && (
- - {routeStart?.name || 'Right-click to set start'} + + {routeStart?.name || 'Click pin to pick start'} +
- - {routeEnd?.name || 'Right-click to set destination'} + + {routeEnd?.name || 'Click pin to pick destination'} +
@@ -263,19 +273,85 @@ export default function Panel({ onClearRoute }) {
) + // 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} + ) } @@ -308,9 +384,10 @@ export default function Panel({ onClearRoute }) { {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/store.js b/src/store.js index 4ea2839..8b98599 100644 --- a/src/store.js +++ b/src/store.js @@ -72,6 +72,10 @@ export const useStore = create((set, get) => ({ // This is the SINGLE routing function for everything computeRoute: async () => { const { routeStart, routeEnd, routeMode, boundaryMode, _updateRouteDisplay } = get() + console.log('[TRACE-ROUTE] computeRoute called with:', { + startLat: routeStart?.lat, startLon: routeStart?.lon, startName: routeStart?.name, + endLat: routeEnd?.lat, endLon: routeEnd?.lon, endName: routeEnd?.name + }) // Need both endpoints to route if (!routeStart || !routeEnd) return @@ -175,6 +179,7 @@ export const useStore = create((set, get) => ({ // 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() @@ -229,7 +234,8 @@ export const useStore = create((set, get) => ({ panelOpen: true, autocompleteOpen: false, directionsMode: false, // true when directions panel is active - activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for map click targeting) + activeDirectionsField: null, // 'origin' | 'destination' | 'stop-N' | null (for input focus styling) + pickingRouteField: null, // 'origin' | 'destination' | null (explicit pick-from-map mode) 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' @@ -243,6 +249,8 @@ export const useStore = create((set, get) => ({ 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 })