mirror of
https://github.com/zvx-echo6/navi.git
synced 2026-05-20 22:54:42 +02:00
feat(navi): GPS origin + place detail panel + basic actions
Adds synthetic "Your location" stop A when GPS granted; place detail panel slides in on search result click with Directions / Add stop / Save (stub) / Share actions; elevation via Valhalla /height; react-hot-toast for feedback; pendingDestination state for GPS-denied Directions flow. Phase 3 Step 5 C1 of Navi. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6983e2655b
commit
02f2b25db3
18 changed files with 1207 additions and 274 deletions
33
src/components/GpsOriginItem.jsx
Normal file
33
src/components/GpsOriginItem.jsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/** Non-draggable "Your location" row at top of StopList when GPS is granted. */
|
||||
export default function GpsOriginItem() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded"
|
||||
style={{
|
||||
background: 'var(--bg-overlay)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{/* Spacer matching drag handle width */}
|
||||
<span className="w-[14px]" />
|
||||
|
||||
{/* ATAK chevron icon */}
|
||||
<span className="w-5 h-5 flex items-center justify-center shrink-0">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M8 1 L14 13 L8 10 L2 13 Z"
|
||||
fill="var(--accent)"
|
||||
stroke="var(--pin-stroke)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span className="flex-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Your location
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import {
|
||||
MoveRight, MoveUpRight, MoveDownRight, CornerUpRight, CornerUpLeft,
|
||||
MoveLeft, MoveUpLeft, MoveDownLeft, CircleDot, RotateCw,
|
||||
GitMerge, CornerRightDown, CornerRightUp, Navigation
|
||||
} from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
|
||||
/** Format seconds into human-friendly string */
|
||||
function formatTime(seconds) {
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)} min`
|
||||
|
|
@ -9,34 +13,30 @@ function formatTime(seconds) {
|
|||
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
||||
}
|
||||
|
||||
/** Format distance in miles */
|
||||
function formatDist(miles) {
|
||||
if (miles < 0.1) return `${Math.round(miles * 5280)} ft`
|
||||
return `${miles.toFixed(1)} mi`
|
||||
}
|
||||
|
||||
/** Get a maneuver type icon */
|
||||
function maneuverIcon(type) {
|
||||
function ManeuverIcon({ type }) {
|
||||
const size = 16
|
||||
const props = { size, strokeWidth: 1.5 }
|
||||
switch (type) {
|
||||
case 0: return '→' // straight
|
||||
case 1: return '↗' // slight right
|
||||
case 2: return '→' // right
|
||||
case 3: return '↘' // sharp right
|
||||
case 4: return '↩' // u-turn right
|
||||
case 5: return '↩' // u-turn left
|
||||
case 6: return '↙' // sharp left
|
||||
case 7: return '←' // left
|
||||
case 8: return '↖' // slight left
|
||||
case 9: return '●' // depart
|
||||
case 10: return '●' // arrive (straight)
|
||||
case 11: return '●' // arrive (right)
|
||||
case 12: return '●' // arrive (left)
|
||||
case 15: return '◎' // roundabout enter
|
||||
case 16: return '◎' // roundabout exit
|
||||
case 24: return '▲' // merge
|
||||
case 25: return '⤴' // on ramp
|
||||
case 26: return '⤵' // off ramp
|
||||
default: return '→'
|
||||
case 0: return <MoveRight {...props} />
|
||||
case 1: return <MoveUpRight {...props} />
|
||||
case 2: return <CornerUpRight {...props} />
|
||||
case 3: return <MoveDownRight {...props} />
|
||||
case 4: case 5: return <CornerUpLeft {...props} />
|
||||
case 6: return <MoveDownLeft {...props} />
|
||||
case 7: return <CornerUpLeft {...props} />
|
||||
case 8: return <MoveUpLeft {...props} />
|
||||
case 9: return <Navigation {...props} />
|
||||
case 10: case 11: case 12: return <CircleDot {...props} />
|
||||
case 15: case 16: return <RotateCw {...props} />
|
||||
case 24: return <GitMerge {...props} />
|
||||
case 25: return <CornerRightUp {...props} />
|
||||
case 26: return <CornerRightDown {...props} />
|
||||
default: return <MoveRight {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -48,15 +48,27 @@ export default function ManeuverList({ onManeuverClick }) {
|
|||
if (routeLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="w-5 h-5 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-2 text-sm text-gray-400">Calculating route...</span>
|
||||
<div
|
||||
className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin"
|
||||
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
|
||||
/>
|
||||
<span className="ml-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
Calculating route...
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (routeError) {
|
||||
return (
|
||||
<div className="px-3 py-2 bg-red-900/30 border border-red-700 rounded text-sm text-red-300">
|
||||
<div
|
||||
className="px-3 py-2 rounded text-sm"
|
||||
style={{
|
||||
background: 'color-mix(in srgb, var(--status-danger) 15%, transparent)',
|
||||
border: '1px solid var(--status-danger)',
|
||||
color: 'var(--status-danger)',
|
||||
}}
|
||||
>
|
||||
{routeError}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -64,22 +76,16 @@ export default function ManeuverList({ onManeuverClick }) {
|
|||
|
||||
if (!route || !route.legs) return null
|
||||
|
||||
// Compute total summary
|
||||
const totalTime = route.summary?.time || 0
|
||||
const totalDist = route.summary?.length || 0
|
||||
|
||||
// Flatten all maneuvers with cumulative time remaining
|
||||
const allManeuvers = []
|
||||
let timeRemaining = totalTime
|
||||
|
||||
for (let legIdx = 0; legIdx < route.legs.length; legIdx++) {
|
||||
const leg = route.legs[legIdx]
|
||||
for (const man of leg.maneuvers || []) {
|
||||
allManeuvers.push({
|
||||
...man,
|
||||
_legIndex: legIdx,
|
||||
timeRemaining,
|
||||
})
|
||||
allManeuvers.push({ ...man, _legIndex: legIdx, timeRemaining })
|
||||
timeRemaining -= man.time || 0
|
||||
}
|
||||
}
|
||||
|
|
@ -87,38 +93,42 @@ export default function ManeuverList({ onManeuverClick }) {
|
|||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Route summary */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-800/60 rounded mb-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-2 rounded mb-2"
|
||||
style={{ background: 'var(--bg-overlay)', border: '1px solid var(--border-subtle)' }}
|
||||
>
|
||||
<span className="font-mono text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
{formatDist(totalDist)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-300">
|
||||
<span className="font-mono text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{formatTime(totalTime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Maneuver steps */}
|
||||
<div className="flex flex-col divide-y divide-gray-700 max-h-[50vh] overflow-y-auto">
|
||||
<div className="flex flex-col max-h-[50vh] overflow-y-auto">
|
||||
{allManeuvers.map((man, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
if (man.begin_shape_index != null && onManeuverClick) {
|
||||
onManeuverClick(man)
|
||||
}
|
||||
if (man.begin_shape_index != null && onManeuverClick) onManeuverClick(man)
|
||||
}}
|
||||
className="flex items-start gap-2 px-2 py-2 text-left hover:bg-gray-800/60 transition-colors"
|
||||
className="flex items-start gap-2 px-2 py-2 text-left rounded transition-colors duration-75"
|
||||
style={{ '--hover-bg': 'var(--bg-overlay)' }}
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-overlay)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<span className="text-base w-6 text-center shrink-0 text-cyan-400">
|
||||
{maneuverIcon(man.type)}
|
||||
<span className="w-5 shrink-0 mt-0.5" style={{ color: 'var(--accent)' }}>
|
||||
<ManeuverIcon type={man.type} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-200 leading-tight">
|
||||
<p className="text-sm leading-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
{man.instruction || man.verbal_pre_transition_instruction || 'Continue'}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
<p className="font-mono text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{formatDist(man.length || 0)}
|
||||
{man.timeRemaining > 0 && (
|
||||
<span className="ml-2">{formatTime(man.timeRemaining)} remaining</span>
|
||||
<span className="ml-2">{formatTime(man.timeRemaining)} left</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useRef, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import { Protocol } from 'pmtiles'
|
||||
|
|
@ -8,17 +8,46 @@ import { decodePolyline } from '../utils/decode'
|
|||
|
||||
const ROUTE_SOURCE = 'route-source'
|
||||
const ROUTE_LAYER_PREFIX = 'route-layer-'
|
||||
const STOPS_SOURCE = 'stops-source'
|
||||
const STOPS_LAYER = 'stops-layer'
|
||||
|
||||
const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
||||
/** Build a full MapLibre style object for the given theme */
|
||||
function buildStyle(themeName) {
|
||||
return {
|
||||
version: 8,
|
||||
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: `https://protomaps.github.io/basemaps-assets/sprites/v4/${themeName}`,
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/na.pmtiles',
|
||||
attribution:
|
||||
'<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>',
|
||||
},
|
||||
},
|
||||
layers: layers('protomaps', namedTheme(themeName), { lang: 'en' }),
|
||||
}
|
||||
}
|
||||
|
||||
/** SVG for ATAK-style chevron pointing up (will be rotated via CSS) */
|
||||
const CHEVRON_SVG = `<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 1 L14 13 L8 10 L2 13 Z" fill="var(--accent)" stroke="var(--bg-raised)" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
const MapView = forwardRef(function MapView(_, ref) {
|
||||
const mapRef = useRef(null)
|
||||
const mapInstance = useRef(null)
|
||||
const markersRef = useRef([])
|
||||
const popupRef = useRef(null)
|
||||
const gpsMarkerRef = useRef(null)
|
||||
const previewMarkerRef = useRef(null)
|
||||
const watchIdRef = useRef(null)
|
||||
const currentThemeRef = useRef('dark')
|
||||
|
||||
const stops = useStore((s) => s.stops)
|
||||
const route = useStore((s) => s.route)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const selectedPlace = useStore((s) => s.selectedPlace)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
|
||||
// Expose map methods to parent
|
||||
|
|
@ -36,59 +65,56 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
const protocol = new Protocol()
|
||||
maplibregl.addProtocol('pmtiles', protocol.tile)
|
||||
|
||||
// Default center: Matt's home (Filer, ID) — updated by geolocation if permitted
|
||||
const DEFAULT_CENTER = [-114.6066, 42.5736]
|
||||
const DEFAULT_ZOOM = 10
|
||||
|
||||
const initialTheme = document.documentElement.getAttribute('data-theme') || 'dark'
|
||||
currentThemeRef.current = initialTheme
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: mapRef.current,
|
||||
style: {
|
||||
version: 8,
|
||||
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
|
||||
sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/dark',
|
||||
sources: {
|
||||
protomaps: {
|
||||
type: 'vector',
|
||||
url: 'pmtiles:///tiles/na.pmtiles',
|
||||
attribution:
|
||||
'<a href="https://protomaps.com">Protomaps</a> | <a href="https://openstreetmap.org">OSM</a>',
|
||||
},
|
||||
},
|
||||
layers: layers('protomaps', namedTheme('dark'), { lang: 'en' }),
|
||||
},
|
||||
style: buildStyle(initialTheme),
|
||||
center: DEFAULT_CENTER,
|
||||
zoom: DEFAULT_ZOOM,
|
||||
})
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
|
||||
// Request geolocation for initial view (not for routing — that's separate)
|
||||
// GPS tracking — creates chevron or dot marker
|
||||
if (navigator.geolocation) {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude } = pos.coords
|
||||
// Only fly to user location if no stops have been added yet
|
||||
if (useStore.getState().stops.length === 0) {
|
||||
map.flyTo({ center: [longitude, latitude], zoom: 12, duration: 1500 })
|
||||
}
|
||||
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
|
||||
useStore.getState().setGeoPermission('granted')
|
||||
createOrUpdateGpsMarker(map, latitude, longitude, null)
|
||||
},
|
||||
() => {
|
||||
useStore.getState().setGeoPermission('denied')
|
||||
},
|
||||
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
|
||||
)
|
||||
|
||||
// Watch for heading changes
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const { latitude, longitude, heading } = pos.coords
|
||||
useStore.getState().setUserLocation({ lat: latitude, lon: longitude })
|
||||
createOrUpdateGpsMarker(map, latitude, longitude, heading)
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||
)
|
||||
}
|
||||
|
||||
map.on('click', () => {
|
||||
// Mobile: collapse sheet when map is tapped
|
||||
if (window.innerWidth < 768) {
|
||||
setSheetState('collapsed')
|
||||
}
|
||||
if (window.innerWidth < 768) setSheetState('collapsed')
|
||||
useStore.getState().clearSelectedPlace()
|
||||
})
|
||||
|
||||
// Add empty route source on load
|
||||
map.on('load', () => {
|
||||
map.addSource(ROUTE_SOURCE, {
|
||||
type: 'geojson',
|
||||
|
|
@ -99,24 +125,116 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
mapInstance.current = map
|
||||
|
||||
return () => {
|
||||
if (watchIdRef.current != null) navigator.geolocation.clearWatch(watchIdRef.current)
|
||||
if (gpsMarkerRef.current) gpsMarkerRef.current.remove()
|
||||
maplibregl.removeProtocol('pmtiles')
|
||||
map.remove()
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
/** Create or update the GPS chevron/dot marker */
|
||||
function createOrUpdateGpsMarker(map, lat, lon, heading) {
|
||||
if (!gpsMarkerRef.current) {
|
||||
const el = document.createElement('div')
|
||||
if (heading != null && !isNaN(heading)) {
|
||||
el.className = 'navi-chevron'
|
||||
el.innerHTML = CHEVRON_SVG
|
||||
el.style.transform = `rotate(${heading}deg)`
|
||||
} else {
|
||||
el.className = 'navi-gps-dot'
|
||||
}
|
||||
gpsMarkerRef.current = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([lon, lat])
|
||||
.addTo(map)
|
||||
} else {
|
||||
gpsMarkerRef.current.setLngLat([lon, lat])
|
||||
const el = gpsMarkerRef.current.getElement()
|
||||
if (heading != null && !isNaN(heading)) {
|
||||
if (!el.classList.contains('navi-chevron')) {
|
||||
el.className = 'navi-chevron'
|
||||
el.innerHTML = CHEVRON_SVG
|
||||
}
|
||||
el.style.transform = `rotate(${heading}deg)`
|
||||
} else {
|
||||
if (!el.classList.contains('navi-gps-dot')) {
|
||||
el.className = 'navi-gps-dot'
|
||||
el.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swap map theme when store.theme changes
|
||||
useEffect(() => {
|
||||
const map = mapInstance.current
|
||||
if (!map || currentThemeRef.current === theme) return
|
||||
|
||||
currentThemeRef.current = theme
|
||||
const center = map.getCenter()
|
||||
const zoom = map.getZoom()
|
||||
const bearing = map.getBearing()
|
||||
const pitch = map.getPitch()
|
||||
|
||||
map.setStyle(buildStyle(theme), { diff: false })
|
||||
|
||||
// Re-add route source after style swap
|
||||
map.once('style.load', () => {
|
||||
map.addSource(ROUTE_SOURCE, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
})
|
||||
// Restore view
|
||||
map.jumpTo({ center, zoom, bearing, pitch })
|
||||
// Re-render route if exists
|
||||
const currentRoute = useStore.getState().route
|
||||
if (currentRoute) updateRoute(map, currentRoute)
|
||||
})
|
||||
}, [theme])
|
||||
|
||||
// Preview pin for selected place
|
||||
useEffect(() => {
|
||||
const map = mapInstance.current
|
||||
if (!map) return
|
||||
|
||||
// Remove old preview marker
|
||||
if (previewMarkerRef.current) {
|
||||
previewMarkerRef.current.remove()
|
||||
previewMarkerRef.current = null
|
||||
}
|
||||
|
||||
if (!selectedPlace) return
|
||||
|
||||
// Fly to selected place
|
||||
map.flyTo({ center: [selectedPlace.lon, selectedPlace.lat], zoom: 14, duration: 800 })
|
||||
|
||||
// Create preview marker
|
||||
const el = document.createElement('div')
|
||||
el.className = 'navi-pin-preview'
|
||||
previewMarkerRef.current = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([selectedPlace.lon, selectedPlace.lat])
|
||||
.addTo(map)
|
||||
|
||||
return () => {
|
||||
if (previewMarkerRef.current) {
|
||||
previewMarkerRef.current.remove()
|
||||
previewMarkerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [selectedPlace])
|
||||
|
||||
// Update route polyline when route changes
|
||||
useEffect(() => {
|
||||
const map = mapInstance.current
|
||||
if (!map || !map.isStyleLoaded()) {
|
||||
// Wait for style to load
|
||||
const handler = () => updateRoute(map)
|
||||
map?.on('load', handler)
|
||||
return () => map?.off('load', handler)
|
||||
if (!map) return
|
||||
if (!map.isStyleLoaded()) {
|
||||
const handler = () => updateRoute(map, route)
|
||||
map.once('load', handler)
|
||||
return () => map.off('load', handler)
|
||||
}
|
||||
updateRoute(map)
|
||||
updateRoute(map, route)
|
||||
}, [route])
|
||||
|
||||
function updateRoute(map) {
|
||||
function updateRoute(map, routeData) {
|
||||
if (!map) return
|
||||
|
||||
// Remove old route layers
|
||||
|
|
@ -129,19 +247,16 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
}
|
||||
}
|
||||
|
||||
if (!route || !route.legs) {
|
||||
if (!routeData || !routeData.legs) {
|
||||
if (map.getSource(ROUTE_SOURCE)) {
|
||||
map.getSource(ROUTE_SOURCE).setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Build GeoJSON features from route legs
|
||||
const features = []
|
||||
const legColors = ['#22d3ee', '#06b6d4', '#0891b2', '#0e7490', '#155e75']
|
||||
|
||||
for (let i = 0; i < route.legs.length; i++) {
|
||||
const leg = route.legs[i]
|
||||
for (let i = 0; i < routeData.legs.length; i++) {
|
||||
const leg = routeData.legs[i]
|
||||
if (!leg.shape) continue
|
||||
const coords = decodePolyline(leg.shape, 6)
|
||||
features.push({
|
||||
|
|
@ -161,7 +276,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
})
|
||||
}
|
||||
|
||||
// Add route layers (one per leg for color variation)
|
||||
// Use CSS variable for route color (read computed value)
|
||||
const routeColor = getComputedStyle(document.documentElement).getPropertyValue('--route-line').trim()
|
||||
|
||||
for (let i = 0; i < features.length; i++) {
|
||||
const layerId = `${ROUTE_LAYER_PREFIX}${i}`
|
||||
if (!map.getLayer(layerId)) {
|
||||
|
|
@ -170,12 +287,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
type: 'line',
|
||||
source: ROUTE_SOURCE,
|
||||
filter: ['==', ['get', 'legIndex'], i],
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round',
|
||||
},
|
||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||
paint: {
|
||||
'line-color': legColors[i % legColors.length],
|
||||
'line-color': routeColor || '#7a9a6b',
|
||||
'line-width': 5,
|
||||
'line-opacity': 0.85,
|
||||
},
|
||||
|
|
@ -190,7 +304,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
(b, c) => b.extend(c),
|
||||
new maplibregl.LngLatBounds(allCoords[0], allCoords[0])
|
||||
)
|
||||
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
|
||||
const hasDetail = useStore.getState().selectedPlace != null
|
||||
const leftPad = hasDetail ? 700 : 340
|
||||
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: leftPad, right: 60 } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -207,22 +323,21 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
popupRef.current = null
|
||||
}
|
||||
|
||||
stops.forEach((stop, i) => {
|
||||
let color = '#3b82f6' // blue
|
||||
if (i === 0) color = '#22c55e' // green
|
||||
else if (i === stops.length - 1 && stops.length > 1) color = '#ef4444' // red
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const indexOffset = hasGpsOrigin ? 1 : 0
|
||||
|
||||
const label = String.fromCharCode(65 + Math.min(i, 25))
|
||||
stops.forEach((stop, i) => {
|
||||
const displayIndex = i + indexOffset
|
||||
const effectiveTotal = stops.length + indexOffset
|
||||
|
||||
let pinClass = 'navi-pin navi-pin--intermediate'
|
||||
if (displayIndex === 0) pinClass = 'navi-pin navi-pin--origin'
|
||||
else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinClass = 'navi-pin navi-pin--destination'
|
||||
|
||||
const label = String.fromCharCode(65 + Math.min(displayIndex, 25))
|
||||
|
||||
const el = document.createElement('div')
|
||||
el.className = 'navi-marker'
|
||||
el.style.cssText = `
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: ${color}; border: 2px solid white;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: white; font-size: 12px; font-weight: bold;
|
||||
cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,0.4);
|
||||
`
|
||||
el.className = pinClass
|
||||
el.textContent = label
|
||||
|
||||
el.addEventListener('click', (e) => {
|
||||
|
|
@ -231,9 +346,9 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
const popup = new maplibregl.Popup({ offset: 20, closeButton: true })
|
||||
.setLngLat([stop.lon, stop.lat])
|
||||
.setHTML(
|
||||
`<div style="color:#fff;font-size:12px;max-width:200px">
|
||||
`<div style="font-size:12px;max-width:200px">
|
||||
<strong>${stop.name}</strong>
|
||||
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:#dc2626;border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
|
||||
<br/><button id="remove-stop-${stop.id}" style="margin-top:4px;padding:2px 8px;background:var(--status-danger);border:none;border-radius:4px;color:white;cursor:pointer;font-size:11px">Remove</button>
|
||||
</div>`
|
||||
)
|
||||
.addTo(map)
|
||||
|
|
@ -264,7 +379,7 @@ const MapView = forwardRef(function MapView({ onMapClick }, ref) {
|
|||
map.fitBounds(bounds, { padding: { top: 60, bottom: 60, left: 340, right: 60 } })
|
||||
}
|
||||
}
|
||||
}, [stops, route])
|
||||
}, [stops, route, gpsOrigin, geoPermission])
|
||||
|
||||
return <div ref={mapRef} className="w-full h-full" />
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { Car, Footprints, Bike } from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const MODES = [
|
||||
{ id: 'auto', label: 'Drive', icon: '🚗' },
|
||||
{ id: 'pedestrian', label: 'Walk', icon: '🚶' },
|
||||
{ id: 'bicycle', label: 'Bike', icon: '🚴' },
|
||||
{ id: 'auto', label: 'Drive', Icon: Car },
|
||||
{ id: 'pedestrian', label: 'Walk', Icon: Footprints },
|
||||
{ id: 'bicycle', label: 'Bike', Icon: Bike },
|
||||
]
|
||||
|
||||
export default function ModeSelector() {
|
||||
|
|
@ -11,23 +12,32 @@ export default function ModeSelector() {
|
|||
const setMode = useStore((s) => s.setMode)
|
||||
|
||||
return (
|
||||
<div className="flex gap-1" role="radiogroup" aria-label="Travel mode">
|
||||
{MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
role="radio"
|
||||
aria-checked={mode === m.id}
|
||||
onClick={() => setMode(m.id)}
|
||||
className={`flex-1 py-1.5 px-2 rounded text-xs font-medium transition-colors ${
|
||||
mode === m.id
|
||||
? 'bg-cyan-600 text-white'
|
||||
: 'bg-gray-700 text-gray-300 hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1">{m.icon}</span>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className="flex rounded-lg overflow-hidden"
|
||||
style={{ border: '1px solid var(--border)' }}
|
||||
role="radiogroup"
|
||||
aria-label="Travel mode"
|
||||
>
|
||||
{MODES.map((m) => {
|
||||
const active = mode === m.id
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => setMode(m.id)}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-2 text-xs font-medium transition-colors duration-100"
|
||||
style={{
|
||||
background: active ? 'var(--accent-muted)' : 'transparent',
|
||||
color: active ? 'var(--accent)' : 'var(--text-secondary)',
|
||||
borderRight: m.id !== 'bicycle' ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<m.Icon size={14} />
|
||||
{m.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useRef, useCallback, useEffect, useState } from 'react'
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
import SearchBar from './SearchBar'
|
||||
import StopList from './StopList'
|
||||
|
|
@ -18,6 +19,11 @@ export default function Panel({ onManeuverClick }) {
|
|||
const setRouteLoading = useStore((s) => s.setRouteLoading)
|
||||
const sheetState = useStore((s) => s.sheetState)
|
||||
const setSheetState = useStore((s) => s.setSheetState)
|
||||
const theme = useStore((s) => s.theme)
|
||||
const themeOverride = useStore((s) => s.themeOverride)
|
||||
const setThemeOverride = useStore((s) => s.setThemeOverride)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [optimizing, setOptimizing] = useState(false)
|
||||
|
|
@ -33,18 +39,32 @@ export default function Panel({ onManeuverClick }) {
|
|||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Theme toggle
|
||||
const toggleTheme = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeOverride(next)
|
||||
}
|
||||
|
||||
// Optimize stops
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const effectiveCount = stops.length + (hasGpsOrigin ? 1 : 0)
|
||||
|
||||
const handleOptimize = useCallback(async () => {
|
||||
if (stops.length < 3 || optimizing) return
|
||||
if (effectiveCount < 3 || optimizing) return
|
||||
setOptimizing(true)
|
||||
try {
|
||||
const locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
const { userLocation } = useStore.getState()
|
||||
let locations = stops.map((s) => ({ lat: s.lat, lon: s.lon }))
|
||||
if (hasGpsOrigin && userLocation) {
|
||||
locations = [{ lat: userLocation.lat, lon: userLocation.lon }, ...locations]
|
||||
}
|
||||
const data = await requestOptimizedRoute(locations, mode)
|
||||
if (data.trip) {
|
||||
// Reorder stops based on optimized waypoint order
|
||||
const wpOrder = data.trip.locations
|
||||
// If GPS origin was prepended, skip it from the result waypoints
|
||||
const wpOrder = hasGpsOrigin && userLocation
|
||||
? (data.trip.locations || []).slice(1)
|
||||
: data.trip.locations
|
||||
if (wpOrder && wpOrder.length === stops.length) {
|
||||
// Match optimized locations back to original stops by proximity
|
||||
const reordered = wpOrder.map((wp) => {
|
||||
let closest = stops[0]
|
||||
let minDist = Infinity
|
||||
|
|
@ -57,7 +77,6 @@ export default function Panel({ onManeuverClick }) {
|
|||
}
|
||||
return closest
|
||||
})
|
||||
// Deduplicate (in case of matching issues)
|
||||
const seen = new Set()
|
||||
const unique = reordered.filter((s) => {
|
||||
if (seen.has(s.id)) return false
|
||||
|
|
@ -75,7 +94,7 @@ export default function Panel({ onManeuverClick }) {
|
|||
} finally {
|
||||
setOptimizing(false)
|
||||
}
|
||||
}, [stops, mode, optimizing, setStops, setRoute, setRouteError])
|
||||
}, [stops, mode, optimizing, effectiveCount, hasGpsOrigin, setStops, setRoute, setRouteError])
|
||||
|
||||
// Mobile sheet drag handling
|
||||
const handleTouchStart = useCallback((e) => {
|
||||
|
|
@ -86,30 +105,25 @@ export default function Panel({ onManeuverClick }) {
|
|||
const handleTouchEnd = useCallback((e) => {
|
||||
const deltaY = e.changedTouches[0].clientY - dragStartY.current
|
||||
if (Math.abs(deltaY) < 30) return
|
||||
|
||||
if (deltaY < 0) {
|
||||
// Swipe up
|
||||
if (dragStartState.current === 'collapsed') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('full')
|
||||
} else {
|
||||
// Swipe down
|
||||
if (dragStartState.current === 'full') setSheetState('half')
|
||||
else if (dragStartState.current === 'half') setSheetState('collapsed')
|
||||
}
|
||||
}, [setSheetState])
|
||||
|
||||
const showOptimize = stops.length >= 3
|
||||
const showOptimize = effectiveCount >= 3
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
||||
{/* Stop list */}
|
||||
<div className="mt-3">
|
||||
<StopList />
|
||||
</div>
|
||||
|
||||
{/* Mode selector + optimize */}
|
||||
{stops.length >= 1 && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<ModeSelector />
|
||||
|
|
@ -117,7 +131,7 @@ export default function Panel({ onManeuverClick }) {
|
|||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={optimizing || routeLoading}
|
||||
className="w-full py-1.5 px-3 text-xs font-medium bg-yellow-700 hover:bg-yellow-600 text-white rounded disabled:opacity-50 transition-colors"
|
||||
className="navi-btn-secondary w-full"
|
||||
>
|
||||
{optimizing ? 'Optimizing...' : 'Optimize stop order'}
|
||||
</button>
|
||||
|
|
@ -125,28 +139,46 @@ export default function Panel({ onManeuverClick }) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Maneuver list */}
|
||||
{(route || routeLoading || routeError) && (
|
||||
<div className="mt-3">
|
||||
<ManeuverList onManeuverClick={onManeuverClick} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: Recents / saved places placeholder */}
|
||||
{stops.length === 0 && !route && (
|
||||
<div className="mt-6 text-center text-gray-600 text-xs">
|
||||
{/* TODO: Wire recents + favorites in a later phase */}
|
||||
<p>Recent places will appear here</p>
|
||||
<div className="mt-6 text-center text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<p>Search and add stops to build your route</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const header = (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h1 className="text-md font-semibold" style={{ color: 'var(--accent)' }}>Navi</h1>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-1.5 rounded"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Desktop: side panel
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className="absolute top-0 left-0 z-10 w-80 h-full bg-gray-900/95 backdrop-blur-sm border-r border-gray-700 overflow-y-auto p-4 flex flex-col">
|
||||
<h1 className="text-lg font-semibold text-cyan-400 mb-3">Navi</h1>
|
||||
<div
|
||||
className="absolute top-0 left-0 z-10 w-80 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -162,7 +194,11 @@ export default function Panel({ onManeuverClick }) {
|
|||
return (
|
||||
<div
|
||||
ref={sheetRef}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 bg-gray-900/95 backdrop-blur-sm border-t border-gray-700 rounded-t-2xl transition-all duration-300 ${sheetHeights[sheetState]}`}
|
||||
className={`absolute bottom-0 left-0 right-0 z-10 rounded-t-2xl transition-all duration-200 ${sheetHeights[sheetState]}`}
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
|
|
@ -175,11 +211,12 @@ export default function Panel({ onManeuverClick }) {
|
|||
else setSheetState('half')
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-600 rounded-full" />
|
||||
<div className="w-10 h-1 rounded-full" style={{ background: 'var(--border)' }} />
|
||||
</div>
|
||||
|
||||
{sheetState !== 'collapsed' && (
|
||||
<div className="px-4 pb-4 overflow-y-auto h-[calc(100%-2rem)]">
|
||||
{header}
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
236
src/components/PlaceDetail.jsx
Normal file
236
src/components/PlaceDetail.jsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Navigation, Plus, Bookmark, Share2 } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { fetchElevation } from '../api'
|
||||
|
||||
/** Meters to feet */
|
||||
const M_TO_FT = 3.28084
|
||||
|
||||
/** Build display address from raw result data */
|
||||
function buildAddress(place) {
|
||||
if (place.address) return place.address
|
||||
const raw = place.raw || {}
|
||||
const parts = [raw.street, raw.city, raw.state, raw.postcode].filter(Boolean)
|
||||
return parts.join(', ') || null
|
||||
}
|
||||
|
||||
export default function PlaceDetail() {
|
||||
const selectedPlace = useStore((s) => s.selectedPlace)
|
||||
const clearSelectedPlace = useStore((s) => s.clearSelectedPlace)
|
||||
const startDirections = useStore((s) => s.startDirections)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
|
||||
const [elevResult, setElevResult] = useState({ lat: null, lon: null, value: null })
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 768)
|
||||
check()
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// Fetch elevation when place changes
|
||||
const placeLat = selectedPlace?.lat
|
||||
const placeLon = selectedPlace?.lon
|
||||
useEffect(() => {
|
||||
if (placeLat == null || placeLon == null) return
|
||||
let cancelled = false
|
||||
fetchElevation(placeLat, placeLon).then((h) => {
|
||||
if (!cancelled) setElevResult({ lat: placeLat, lon: placeLon, value: h })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [placeLat, placeLon])
|
||||
|
||||
// Derive elevation/loading from comparing result to current place
|
||||
const elevLoading = placeLat != null && (elevResult.lat !== placeLat || elevResult.lon !== placeLon)
|
||||
const elevation = !elevLoading ? elevResult.value : null
|
||||
|
||||
if (!selectedPlace) return null
|
||||
|
||||
const address = buildAddress(selectedPlace)
|
||||
const elevFeet = elevation != null ? Math.round(elevation * M_TO_FT) : null
|
||||
const raw = selectedPlace.raw || {}
|
||||
|
||||
// Check if place is already in stops
|
||||
const existingStopIndex = stops.findIndex(
|
||||
(s) => Math.abs(s.lat - selectedPlace.lat) < 0.00001 && Math.abs(s.lon - selectedPlace.lon) < 0.00001
|
||||
)
|
||||
|
||||
const handleDirections = () => {
|
||||
startDirections(selectedPlace)
|
||||
if (geoPermission !== 'granted' && stops.length === 0) {
|
||||
toast('Set a starting point to get directions', { icon: '\u{1F4CD}' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddStop = () => {
|
||||
addStop({
|
||||
lat: selectedPlace.lat,
|
||||
lon: selectedPlace.lon,
|
||||
name: selectedPlace.name,
|
||||
source: selectedPlace.source,
|
||||
matchCode: selectedPlace.matchCode,
|
||||
})
|
||||
clearSelectedPlace()
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
toast('Saved places coming soon')
|
||||
}
|
||||
|
||||
const handleShare = () => {
|
||||
const text = [
|
||||
selectedPlace.name,
|
||||
address,
|
||||
`${selectedPlace.lat.toFixed(6)}, ${selectedPlace.lon.toFixed(6)}`,
|
||||
].filter(Boolean).join('\n')
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => toast('Copied to clipboard'),
|
||||
() => toast.error('Failed to copy')
|
||||
)
|
||||
}
|
||||
|
||||
const panelContent = (
|
||||
<>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={clearSelectedPlace}
|
||||
className="absolute top-3 right-3 p-1 rounded"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-label="Close detail panel"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
{/* Place name */}
|
||||
<div className="pr-8">
|
||||
<h2 className="text-md font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{selectedPlace.name}
|
||||
</h2>
|
||||
{selectedPlace.type && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{selectedPlace.type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{address && (
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Coordinates + elevation */}
|
||||
<div className="mt-3 font-mono text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span>{selectedPlace.lat.toFixed(6)}, {selectedPlace.lon.toFixed(6)}</span>
|
||||
<span className="mx-2">·</span>
|
||||
<span>
|
||||
{elevLoading ? '...' : elevFeet != null ? `${elevFeet.toLocaleString()} ft` : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Optional extras */}
|
||||
{(raw.opening_hours || raw.website || raw.phone) && (
|
||||
<div className="mt-3 flex flex-col gap-1 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
{raw.opening_hours && <span>{raw.opening_hours}</span>}
|
||||
{raw.website && (
|
||||
<a
|
||||
href={raw.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline truncate"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{raw.website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</a>
|
||||
)}
|
||||
{raw.phone && <span>{raw.phone}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="mt-auto pt-4 flex gap-2">
|
||||
<button
|
||||
onClick={handleDirections}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--accent)', color: 'var(--text-inverse)' }}
|
||||
>
|
||||
<Navigation size={13} />
|
||||
Directions
|
||||
</button>
|
||||
|
||||
{existingStopIndex >= 0 ? (
|
||||
<span
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
Added as stop {String.fromCharCode(65 + existingStopIndex)}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddStop}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-xs font-medium"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add stop
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||
aria-label="Save place"
|
||||
>
|
||||
<Bookmark size={14} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="p-2 rounded-lg"
|
||||
style={{ background: 'var(--tan-muted)', color: 'var(--tan)', border: '1px solid var(--border)' }}
|
||||
aria-label="Share place"
|
||||
>
|
||||
<Share2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
// Mobile: bottom overlay
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div
|
||||
className="navi-place-detail navi-place-detail-active fixed bottom-0 left-0 right-0 z-20 p-4 rounded-t-2xl flex flex-col"
|
||||
style={{
|
||||
background: 'var(--bg-raised)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
maxHeight: '50vh',
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop: side panel
|
||||
return (
|
||||
<div
|
||||
className="navi-place-detail navi-place-detail-active absolute top-0 z-10 h-full overflow-y-auto p-4 flex flex-col"
|
||||
style={{
|
||||
left: '20rem',
|
||||
width: '360px',
|
||||
background: 'var(--bg-raised)',
|
||||
borderRight: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +1,59 @@
|
|||
import { useRef, useEffect, useCallback, useState } from 'react'
|
||||
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { MapPin, Building2, Star, Crosshair, Coffee, Fuel, ShoppingBag, Hotel, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useStore } from '../store'
|
||||
import { searchGeocode } from '../api'
|
||||
|
||||
export default function SearchBar() {
|
||||
/** Get category icon based on result type/source */
|
||||
function CategoryIcon({ result }) {
|
||||
const type = result.type || ''
|
||||
const source = result.source || ''
|
||||
const size = 14
|
||||
|
||||
if (source === 'nickname') return <Star size={size} />
|
||||
if (type === 'coordinates') return <Crosshair size={size} />
|
||||
if (type === 'locality' || type === 'city') return <Building2 size={size} />
|
||||
|
||||
// POI subcategories from osm_value if available
|
||||
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} />
|
||||
}
|
||||
|
||||
const SearchBar = forwardRef(function SearchBar(_, ref) {
|
||||
const inputRef = useRef(null)
|
||||
const [activeIndex, setActiveIndex] = useState(-1)
|
||||
const debounceRef = useRef(null)
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}))
|
||||
|
||||
const query = useStore((s) => s.query)
|
||||
const results = useStore((s) => s.results)
|
||||
const searchLoading = useStore((s) => s.searchLoading)
|
||||
const autocompleteOpen = useStore((s) => s.autocompleteOpen)
|
||||
const stops = useStore((s) => s.stops)
|
||||
const pendingDestination = useStore((s) => s.pendingDestination)
|
||||
const setQuery = useStore((s) => s.setQuery)
|
||||
const setResults = useStore((s) => s.setResults)
|
||||
const setSearchLoading = useStore((s) => s.setSearchLoading)
|
||||
const setAbortController = useStore((s) => s.setAbortController)
|
||||
const setAutocompleteOpen = useStore((s) => s.setAutocompleteOpen)
|
||||
const addStop = useStore((s) => s.addStop)
|
||||
const setSelectedPlace = useStore((s) => s.setSelectedPlace)
|
||||
const clearPendingDestination = useStore((s) => s.clearPendingDestination)
|
||||
|
||||
// Focus on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (q) => {
|
||||
// Abort previous
|
||||
const prev = useStore.getState().abortController
|
||||
if (prev) prev.abort()
|
||||
|
||||
|
|
@ -61,19 +88,40 @@ export default function SearchBar() {
|
|||
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([])
|
||||
setAutocompleteOpen(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const selectResult = (result) => {
|
||||
addStop({
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
name: result.name,
|
||||
source: result.source,
|
||||
matchCode: result.match_code,
|
||||
})
|
||||
const { pendingDestination: pending } = useStore.getState()
|
||||
|
||||
if (pending) {
|
||||
// GPS-denied Directions flow: this result becomes the starting point
|
||||
addStop({ lat: result.lat, lon: result.lon, name: result.name, source: result.source, matchCode: result.match_code })
|
||||
addStop({ lat: pending.lat, lon: pending.lon, name: pending.name, source: pending.source, matchCode: pending.matchCode })
|
||||
clearPendingDestination()
|
||||
toast(`Routing from ${result.name} to ${pending.name}`, { icon: '\u{1F9ED}' })
|
||||
} else {
|
||||
// Normal flow: open PlaceDetail
|
||||
setSelectedPlace({
|
||||
lat: result.lat,
|
||||
lon: result.lon,
|
||||
name: result.name,
|
||||
address: result.address || null,
|
||||
type: result.type,
|
||||
source: result.source,
|
||||
matchCode: result.match_code,
|
||||
raw: result.raw || {},
|
||||
})
|
||||
}
|
||||
|
||||
setQuery('')
|
||||
setResults([])
|
||||
setAutocompleteOpen(false)
|
||||
|
|
@ -83,9 +131,7 @@ export default function SearchBar() {
|
|||
|
||||
const handleKeyDown = (e) => {
|
||||
if (!autocompleteOpen || results.length === 0) {
|
||||
if (e.key === 'Escape') {
|
||||
setAutocompleteOpen(false)
|
||||
}
|
||||
if (e.key === 'Escape') setAutocompleteOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -116,35 +162,51 @@ export default function SearchBar() {
|
|||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
|
||||
placeholder={atCap ? 'Max 10 stops reached' : 'Search for a place...'}
|
||||
disabled={atCap}
|
||||
className="w-full px-3 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-cyan-400 focus:ring-1 focus:ring-cyan-400 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
|
||||
aria-label="Search places"
|
||||
aria-expanded={autocompleteOpen}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
/>
|
||||
{searchLoading && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="w-4 h-4 border-2 border-cyan-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => results.length > 0 && setAutocompleteOpen(true)}
|
||||
placeholder={atCap ? 'Max 10 stops reached' : pendingDestination ? 'Starting point...' : 'Search for a place...'}
|
||||
disabled={atCap}
|
||||
className="navi-input w-full pr-8"
|
||||
aria-label="Search places"
|
||||
aria-expanded={autocompleteOpen}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
/>
|
||||
{/* Clear / Loading indicator */}
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
{searchLoading ? (
|
||||
<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)' }}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Autocomplete dropdown */}
|
||||
{autocompleteOpen && results.length > 0 && (
|
||||
<ul
|
||||
className="absolute z-50 mt-1 w-full bg-gray-800 border border-gray-600 rounded-lg shadow-lg overflow-hidden max-h-72 overflow-y-auto"
|
||||
className="absolute z-50 mt-1 w-full rounded-lg overflow-hidden max-h-72 overflow-y-auto"
|
||||
style={{
|
||||
background: 'var(--bg-overlay)',
|
||||
border: '1px solid var(--border)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{results.map((r, i) => (
|
||||
|
|
@ -152,27 +214,34 @@ export default function SearchBar() {
|
|||
key={`${r.lat}-${r.lon}-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
className={`px-3 py-2 cursor-pointer text-sm border-b border-gray-700 last:border-b-0 ${
|
||||
i === activeIndex
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-200 hover:bg-gray-700'
|
||||
}`}
|
||||
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 justify-between gap-2">
|
||||
<span className="truncate flex-1">{r.name}</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="shrink-0" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<CategoryIcon result={r} />
|
||||
</span>
|
||||
<span className="truncate flex-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{r.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{r.match_code?.housenumber === 'matched' && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-green-800 text-green-200 rounded font-medium">
|
||||
exact match
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded font-medium"
|
||||
style={{ background: 'var(--accent-muted)', color: 'var(--accent)' }}
|
||||
>
|
||||
exact
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-gray-500">{r.source}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-gray-400 mt-0.5">
|
||||
{r.type} · {r.confidence}
|
||||
<div className="text-[11px] mt-0.5 ml-6" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{r.type}{r.confidence && r.confidence !== 'high' ? ` \u00b7 ${r.confidence}` : ''}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -180,4 +249,6 @@ export default function SearchBar() {
|
|||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
export default SearchBar
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { X, GripVertical } from 'lucide-react'
|
||||
import { useStore } from '../store'
|
||||
|
||||
export default function StopItem({ stop, index, total }) {
|
||||
export default function StopItem({ stop, index, total, indexOffset = 0 }) {
|
||||
const removeStop = useStore((s) => s.removeStop)
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
|
|
@ -14,62 +15,62 @@ export default function StopItem({ stop, index, total }) {
|
|||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
|
||||
// Pin color logic
|
||||
let pinColor = 'bg-blue-500' // intermediate
|
||||
let pinLabel = String(index + 1)
|
||||
if (index === 0) {
|
||||
pinColor = 'bg-green-500'
|
||||
pinLabel = 'A'
|
||||
} else if (index === total - 1 && total > 1) {
|
||||
pinColor = 'bg-red-500'
|
||||
pinLabel = String.fromCharCode(65 + Math.min(index, 25)) // A-Z
|
||||
} else {
|
||||
pinLabel = String.fromCharCode(65 + Math.min(index, 25))
|
||||
}
|
||||
const displayIndex = index + indexOffset
|
||||
const effectiveTotal = total + indexOffset
|
||||
|
||||
// Pin color from tokens
|
||||
let pinVar = '--pin-intermediate'
|
||||
if (displayIndex === 0) pinVar = '--pin-origin'
|
||||
else if (displayIndex === effectiveTotal - 1 && effectiveTotal > 1) pinVar = '--pin-destination'
|
||||
|
||||
const pinLabel = String.fromCharCode(65 + Math.min(displayIndex, 25))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 py-1.5 px-2 bg-gray-800/60 rounded border border-gray-700 group"
|
||||
style={{
|
||||
...style,
|
||||
background: 'var(--bg-overlay)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
}}
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded group"
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-gray-500 hover:text-gray-300 touch-none"
|
||||
className="cursor-grab active:cursor-grabbing touch-none"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
|
||||
<circle cx="4" cy="2" r="1" />
|
||||
<circle cx="8" cy="2" r="1" />
|
||||
<circle cx="4" cy="6" r="1" />
|
||||
<circle cx="8" cy="6" r="1" />
|
||||
<circle cx="4" cy="10" r="1" />
|
||||
<circle cx="8" cy="10" r="1" />
|
||||
</svg>
|
||||
<GripVertical size={14} />
|
||||
</button>
|
||||
|
||||
{/* Pin indicator */}
|
||||
<span
|
||||
className={`${pinColor} text-white text-[10px] font-bold w-5 h-5 rounded-full flex items-center justify-center shrink-0`}
|
||||
className="text-[10px] font-semibold w-5 h-5 rounded-full flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
background: `var(${pinVar})`,
|
||||
color: '#fff',
|
||||
border: '1.5px solid var(--pin-stroke)',
|
||||
}}
|
||||
>
|
||||
{pinLabel}
|
||||
</span>
|
||||
|
||||
{/* Stop name */}
|
||||
<span className="flex-1 text-sm text-gray-200 truncate">{stop.name}</span>
|
||||
<span className="flex-1 text-sm truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{stop.name}
|
||||
</span>
|
||||
|
||||
{/* Remove button */}
|
||||
<button
|
||||
onClick={() => removeStop(stop.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 transition-opacity"
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
aria-label={`Remove stop ${stop.name}`}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -14,11 +14,17 @@ import {
|
|||
} from '@dnd-kit/sortable'
|
||||
import { useStore } from '../store'
|
||||
import StopItem from './StopItem'
|
||||
import GpsOriginItem from './GpsOriginItem'
|
||||
|
||||
export default function StopList() {
|
||||
const stops = useStore((s) => s.stops)
|
||||
const reorderStops = useStore((s) => s.reorderStops)
|
||||
const geoPermission = useStore((s) => s.geoPermission)
|
||||
const gpsOrigin = useStore((s) => s.gpsOrigin)
|
||||
const pendingDestination = useStore((s) => s.pendingDestination)
|
||||
|
||||
const hasGpsOrigin = gpsOrigin && geoPermission === 'granted'
|
||||
const indexOffset = hasGpsOrigin ? 1 : 0
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
|
|
@ -34,25 +40,34 @@ export default function StopList() {
|
|||
reorderStops(arrayMove(stops, oldIndex, newIndex))
|
||||
}
|
||||
|
||||
if (stops.length === 0) {
|
||||
if (stops.length === 0 && !hasGpsOrigin) {
|
||||
return (
|
||||
<div className="text-gray-500 text-xs px-2 py-3 text-center">
|
||||
{geoPermission === 'denied'
|
||||
? 'Add a starting point and destination above'
|
||||
: 'Search and add stops to build your route'}
|
||||
<div className="text-xs px-2 py-3 text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{pendingDestination
|
||||
? 'Search for a starting point above'
|
||||
: geoPermission === 'denied'
|
||||
? 'Add a starting point and destination above'
|
||||
: 'Search and add stops to build your route'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-col gap-1">
|
||||
{hasGpsOrigin && <GpsOriginItem />}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={stops.map((s) => s.id)} strategy={verticalListSortingStrategy}>
|
||||
{stops.map((stop, i) => (
|
||||
<StopItem key={stop.id} stop={stop} index={i} total={stops.length} />
|
||||
<StopItem
|
||||
key={stop.id}
|
||||
stop={stop}
|
||||
index={i}
|
||||
total={stops.length}
|
||||
indexOffset={indexOffset}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue