import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import L from 'leaflet'; import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair, Volume2, VolumeX, Check, Search } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { DestinationSearch } from '@/components/DestinationSearch'; import { cn } from '@/lib/utils'; import { getCurrentPosition, watchPosition, GeolocationError, type GeoCoords, type GeoWatch, } from '@/lib/geolocation'; import { getLastLocation } from '@/lib/lastLocation'; import { saveRoute, loadRoute, formatAge } from '@/lib/routeCache'; import { createCachedTileLayer } from '@/lib/cachedTileLayer'; import { calculateDistance } from '@/lib/sortOptions'; import { fetchRoute, fetchTransitRoute, formatClock, formatDistance, formatDuration, TRAVEL_MODES, TRANSIT_ATTRIBUTION, ROUTE_ERR_BUSY, ROUTE_ERR_NO_ROUTE, skateTime, type TravelMode, type RouteResult, type RouteStep, type TransitPlanOptions, } from '@/lib/routing'; import { TRANSIT_MODE_GROUPS, legDurationS, transitLegColor, transitModeEmoji, transitPrimaryEmoji, } from '@/lib/transitUi'; import { StopDepartures } from '@/components/StopDepartures'; import { useTranslation } from '@/contexts/language'; /** Travel-mode labels live in `lib/routing`; map them to translation keys at render. */ const MODE_LABEL_KEYS: Record = { foot: 'routeUi.mode.foot', bike: 'routeUi.mode.bike', moto: 'routeUi.mode.moto', car: 'routeUi.mode.car', transit: 'routeUi.mode.transit', }; /** Transit mode-group labels live in `lib/transitUi`; translate at render. */ const TRANSIT_GROUP_LABEL_KEYS: Record = { bus: 'routeUi.group.bus', tram: 'routeUi.group.tram', metro: 'routeUi.group.metro', train: 'routeUi.group.train', ferry: 'routeUi.group.ferry', }; /** Minimal translation-function shape. */ type Translate = (key: string, params?: Record) => string; /** Maneuver tokens live in `lib/routing`; map them to literal translation keys. */ const MANEUVER_LABEL_KEYS: Record = { headOut: 'routeUi.maneuver.headOut', arrive: 'routeUi.maneuver.arrive', continueStraight: 'routeUi.maneuver.continueStraight', bearLeft: 'routeUi.maneuver.bearLeft', bearRight: 'routeUi.maneuver.bearRight', turnLeft: 'routeUi.maneuver.turnLeft', turnRight: 'routeUi.maneuver.turnRight', sharpLeft: 'routeUi.maneuver.sharpLeft', sharpRight: 'routeUi.maneuver.sharpRight', turn: 'routeUi.maneuver.turn', keepLeft: 'routeUi.maneuver.keepLeft', keepRight: 'routeUi.maneuver.keepRight', takeFork: 'routeUi.maneuver.takeFork', merge: 'routeUi.maneuver.merge', roundabout: 'routeUi.maneuver.roundabout', takeRamp: 'routeUi.maneuver.takeRamp', takeExit: 'routeUi.maneuver.takeExit', }; /** * Translate a route step. New steps carry a maneuver token + optional road * name (feed data, appended but never translated); legacy cached steps only * have the English `instruction`. */ function stepInstruction(step: RouteStep, t: Translate): string { if (!step.label) return step.instruction; const key = MANEUVER_LABEL_KEYS[step.label]; if (!key) return step.instruction; const base = t(key); return step.road ? t('routeUi.maneuverOnto', { instruction: base, road: step.road }) : base; } /** A stop name that opens the departure board, when the feed gave a stop id. */ function StopButton({ id, name, onOpen, }: { id?: string; name?: string; onOpen: (id: string, name: string) => void; }) { if (!id || !name) return <>{name ?? ''}; return ( ); } interface RouteDialogProps { open: boolean; onOpenChange: (open: boolean) => void; /** Destination coordinates. */ destination: { lat: number; lng: number } | null; /** Destination label shown in the header. */ destinationLabel?: string; /** Called when the in-dialog search changes the destination, so the parent * can keep its own target in sync (avoids a stale re-seed on reopen). */ onDestinationChange?: (pick: { lat: number; lng: number; label: string }) => void; } /** * In-app routing dialog. * * Shows a self-contained Leaflet map with a route drawn from the user's current * location to a destination, with a foot/bike/moto/car mode toggle. No external * maps app is opened — the route comes from the keyless public OSRM server and * is drawn here. See src/lib/routing.ts for the honest scope notes. */ /** The average speed the routing engine assumed for this mode, e.g. "21". */ function speedValue(distanceM: number, durationS: number): string | undefined { if (!(distanceM > 0) || !(durationS > 0)) return undefined; const kmh = distanceM / 1000 / (durationS / 3600); if (!Number.isFinite(kmh) || kmh <= 0) return undefined; return kmh < 10 ? kmh.toFixed(1) : String(Math.round(kmh)); } /** Local clock time you'd arrive if you set off now. */ function etaClock(durationS: number): string { return new Date(Date.now() + durationS * 1000).toLocaleTimeString(dateLocaleCode(), { hour: '2-digit', minute: '2-digit', }); } export function RouteDialog({ open, onOpenChange, destination, destinationLabel, onDestinationChange, }: RouteDialogProps) { const { t } = useTranslation(); // The destination is owned HERE so the in-dialog search can change it without // the parent re-opening the dialog. Seeded from the prop whenever the dialog // OPENS (or the parent's target actually changes) — not on every parent // render, which would revert a search the user just made. const [dest, setDest] = useState(destination); const [destLabel, setDestLabel] = useState(destinationLabel ?? ''); const [searchOpen, setSearchOpen] = useState(false); const lastPropKeyRef = useRef(''); // The map's initial centre; read by the init effect without making it a dep // (deps on the destination would tear down + rebuild the whole Leaflet map). const destRef = useRef(destination); destRef.current = destination; useEffect(() => { if (!open) { lastPropKeyRef.current = ''; return; } const key = destination ? `${destination.lat},${destination.lng}` : ''; if (key === lastPropKeyRef.current) return; lastPropKeyRef.current = key; setDest(destination); setDestLabel(destinationLabel ?? ''); }, [open, destination, destinationLabel]); // The map container is inside Radix Dialog content, which mounts AFTER this // component's first effect pass. A plain useRef is therefore still null when // the init effect runs, and — because the effect's deps never changed again — // the map was NEVER created (empty dialog, no route line). Holding the node in // state makes the effect re-run the moment it actually mounts. const [mapDiv, setMapDiv] = useState(null); const mapRef = useRef(null); const routeLayersRef = useRef(null); const originMarkerRef = useRef(null); const destMarkerRef = useRef(null); const [origin, setOrigin] = useState<{ lat: number; lng: number } | null>(null); // Remember the last mode used — a cyclist or driver shouldn't be reset to // "Skate / Walk" every time they open Directions. const [mode, setMode] = useState(() => { try { const saved = localStorage.getItem('skatespots:route-mode') as TravelMode | null; return saved && TRAVEL_MODES.some((m) => m.mode === saved) ? saved : 'foot'; } catch { return 'foot'; } }); const chooseMode = useCallback((next: TravelMode) => { setMode(next); try { localStorage.setItem('skatespots:route-mode', next); } catch { // ignore (private mode / quota) } }, []); /** Which step the user tapped (highlights + pans the map). */ const [activeStep, setActiveStep] = useState(null); // ── Start point: my location, or a point the user picks on the map ───────── const [startMode, setStartMode] = useState<'me' | 'picked'>('me'); const [picked, setPicked] = useState(null); const [pickingStart, setPickingStart] = useState(false); // ── Follow mode (explicit; owns a GPS watcher) ───────────────────────────── const [following, setFollowing] = useState(false); const [live, setLive] = useState(null); const [followError, setFollowError] = useState(null); const watchRef = useRef(null); // ── Voice guidance, wake lock, arrival (phone navigation) ────────────────── const [voiceOn, setVoiceOn] = useState(() => { try { return localStorage.getItem('skatespots:route-voice') !== 'off'; } catch { return true; } }); const [arrived, setArrived] = useState(false); /** stepIndex → set of announced distance bands (150 m / 40 m). */ const announcedRef = useRef>>(new Map()); const arrivedRef = useRef(false); const wakeLockRef = useRef<{ release: () => Promise } | null>(null); /** Route shown while offline came from the cache (with its age). */ const [cachedAge, setCachedAge] = useState(null); /** Where the route starts: a picked point wins, else the device location. */ const effectiveOrigin = useMemo( () => (startMode === 'picked' && picked ? picked : origin), [startMode, picked, origin], ); /** Where to draw the "you are here" dot — live position while following. */ const markerPos = useMemo( () => (following && live ? live : effectiveOrigin), [following, live, effectiveOrigin], ); // Keep the map-click handler current without rebuilding the map. const pickingStartRef = useRef(false); pickingStartRef.current = pickingStart; const [route, setRoute] = useState(null); const [locating, setLocating] = useState(false); const [routing, setRouting] = useState(false); const [error, setError] = useState(null); const [locError, setLocError] = useState(null); // Which transit alternative is selected (index into route.transitOptions). // Reset to 0 whenever a new transit route is fetched. const [transitIndex, setTransitIndex] = useState(0); const selectedTransit = useMemo(() => { if (mode !== 'transit') return null; const opts = route?.transitOptions; if (opts && opts.length > 0) return opts[Math.min(transitIndex, opts.length - 1)] ?? null; return route?.transit ?? null; }, [mode, route, transitIndex]); // All alternatives (falling back to a single option for older results). const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []); // Transit plan controls: leave-now vs depart-at/arrive-by, and a mode filter. const [transitTimeMode, setTransitTimeMode] = useState<'now' | 'depart' | 'arrive'>('now'); const [transitTimeValue, setTransitTimeValue] = useState(''); const [transitModeFilter, setTransitModeFilter] = useState([]); const [stopBoard, setStopBoard] = useState<{ id: string; name: string } | null>(null); const selectedTransitModes = useMemo( () => TRANSIT_MODE_GROUPS.filter((g) => transitModeFilter.includes(g.key)).flatMap((g) => [...g.modes]), [transitModeFilter], ); const toggleModeGroup = (key: string) => setTransitModeFilter((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key])); /** Re-fit the map to the whole route (after tapping a step pans it away). */ const fitRoute = useCallback(() => { const map = mapRef.current; if (!map) return; const coords = selectedTransit ? selectedTransit.legs.flatMap((l) => l.coordinates) : route?.coordinates; if (!coords || coords.length < 2) return; map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16 }); }, [route, selectedTransit]); // A new route (mode/origin change) invalidates the tapped-step focus and the // once-only voice/haptic announcements. useEffect(() => { setActiveStep(null); announcedRef.current.clear(); arrivedRef.current = false; setArrived(false); }, [route]); // ── Voice guidance (device TTS — free, offline, no server, no permission) ─── const speak = useCallback( (text: string) => { if (!voiceOn) return; if (typeof window === 'undefined' || !('speechSynthesis' in window)) return; try { const synth = window.speechSynthesis; const utter = new SpeechSynthesisUtterance(text); utter.rate = 1; const lang = (navigator.language || 'en').slice(0, 2).toLowerCase(); const voices = synth.getVoices(); // Prefer an on-device voice in the user's language (offline, no network). const voice = voices.find((v) => v.localService && v.lang.toLowerCase().startsWith(lang)) ?? voices.find((v) => v.lang.toLowerCase().startsWith(lang)); if (voice) { utter.voice = voice; utter.lang = voice.lang; } else { utter.lang = lang; } synth.cancel(); // never let prompts queue up and lag behind you synth.speak(utter); } catch { // No TTS — haptics still guide. } }, [voiceOn], ); const toggleVoice = useCallback(() => { setVoiceOn((on) => { const next = !on; try { localStorage.setItem('skatespots:route-voice', next ? 'on' : 'off'); } catch { // ignore } if (!next && typeof window !== 'undefined' && 'speechSynthesis' in window) { window.speechSynthesis.cancel(); } return next; }); }, []); /** Short haptic pulse (Android). Silent no-op where unsupported. */ const buzz = useCallback((pattern: number | number[]) => { try { if (typeof navigator.vibrate === 'function') navigator.vibrate(pattern); } catch { // ignore } }, []); // ── Wake lock: keep the screen on while navigating ────────────────────────── const acquireWakeLock = useCallback(async () => { const navWithWakeLock = navigator as Navigator & { wakeLock?: { request: (type: 'screen') => Promise<{ release: () => Promise }> }; }; try { const sentinel = await navWithWakeLock.wakeLock?.request('screen'); if (sentinel) wakeLockRef.current = sentinel; } catch { // Unsupported / denied — navigation still works, the screen may sleep. } }, []); const releaseWakeLock = useCallback(() => { try { void wakeLockRef.current?.release(); } catch { // ignore } wakeLockRef.current = null; }, []); // ── Follow mode ──────────────────────────────────────────────────────────── // Explicit only: the user taps Start, we watch their position, we keep the // screen awake, and we stop the watcher the moment they stop or the dialog // closes. Voice + haptics guide; voice is the point — you can't watch a // screen while skating. const stopFollowing = useCallback(() => { watchRef.current?.stop(); watchRef.current = null; setFollowing(false); releaseWakeLock(); }, [releaseWakeLock]); const startFollowing = useCallback(() => { if (watchRef.current) return; setFollowError(null); setArrived(false); arrivedRef.current = false; announcedRef.current.clear(); setFollowing(true); void acquireWakeLock(); // Spoken from the Start tap (a user gesture) — this also unlocks TTS. speak(t('routeUi.voiceStart')); watchRef.current = watchPosition( (coords) => { setLive(coords); setFollowError(null); }, (err) => { // Only a real permission denial stops following. A timeout / temporary // loss is normal (tunnels, indoors) and the watcher recovers by itself, // so we keep running and just say we're waiting. if (err.kind === 'denied') { setFollowError(t('routeUi.errPermissionDenied')); stopFollowing(); } else { setFollowError(t('routeUi.errWaitingGps')); } }, ); }, [stopFollowing, acquireWakeLock, speak, t]); // Never leave the GPS running: stop on close and on unmount. useEffect(() => { if (!open && watchRef.current) stopFollowing(); }, [open, stopFollowing]); useEffect( () => () => { watchRef.current?.stop(); watchRef.current = null; releaseWakeLock(); }, [releaseWakeLock], ); // Browsers drop the wake lock whenever the page is hidden. Re-acquire it when // the user comes back to a navigation that is still running. useEffect(() => { if (!following) return; const onVisibility = () => { if (document.visibilityState === 'visible') void acquireWakeLock(); }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); }, [following, acquireWakeLock]); // While following, keep the live dot centred — but only once you've actually // moved, and with a gentle animation, so GPS jitter doesn't twitch the map. const lastPanRef = useRef<{ lat: number; lng: number } | null>(null); useEffect(() => { if (!following || !live) return; const map = mapRef.current; if (!map) return; const last = lastPanRef.current; if (last && calculateDistance(last.lat, last.lng, live.lat, live.lng) * 1000 < 6) return; lastPanRef.current = { lat: live.lat, lng: live.lng }; map.panTo([live.lat, live.lng], { animate: true, duration: 0.6, easeLinearity: 0.5 }); }, [following, live]); /** Live guidance: distance left, and which step you're on. */ const nav = useMemo(() => { if (!live || !dest) return null; const toDestKm = calculateDistance(live.lat, live.lng, dest.lat, dest.lng); let bestI = -1; let bestKm = Infinity; (route?.steps ?? []).forEach((s, i) => { if (!s.lat && !s.lng) return; const km = calculateDistance(live.lat, live.lng, s.lat, s.lng); if (km < bestKm) { bestKm = km; bestI = i; } }); return { toDestKm, stepIndex: bestI, stepKm: bestKm }; }, [live, dest, route]); const currentStepIndex = following && nav && nav.stepIndex >= 0 ? nav.stepIndex : activeStep; // ── Voice + haptics + arrival while following ─────────────────────────────── useEffect(() => { if (!following || !nav || !route) return; // Arrival: within ~30 m of the destination. if (nav.toDestKm < 0.03) { if (!arrivedRef.current) { arrivedRef.current = true; speak(t('routeUi.voiceArrived')); buzz([120, 80, 120]); setArrived(true); stopFollowing(); } return; } const idx = nav.stepIndex; const km = nav.stepKm; if (idx < 0 || !Number.isFinite(km)) return; const steps = route.steps ?? []; const bands = announcedRef.current.get(idx) ?? new Set(); if (km <= 0.15 && km > 0.04 && !bands.has(150)) { // ~150 m out — a heads-up so you know what's coming. bands.add(150); announcedRef.current.set(idx, bands); const metres = Math.max(50, Math.round((km * 1000) / 10) * 10); speak(t('routeUi.voiceIn', { metres, instruction: steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinue'), })); buzz(60); } else if (km <= 0.04 && !bands.has(40)) { // ~40 m out — the turn itself. bands.add(40); announcedRef.current.set(idx, bands); speak(steps[idx] ? stepInstruction(steps[idx], t) : t('routeUi.voiceContinueCaps')); buzz(140); } }, [following, nav, route, speak, buzz, stopFollowing, t]); // ─── Get the user's location when the dialog opens ───────────────────────── const locate = useCallback(async () => { setLocating(true); setLocError(null); try { const pos = await getCurrentPosition(); setOrigin(pos); } catch (err) { const msg = err instanceof GeolocationError ? err.kind === 'denied' ? t('routeUi.errPermissionDeniedLong') : t('routeUi.errNoLocation') : t('routeUi.errNoLocationShort'); setLocError(msg); } finally { setLocating(false); } }, [t]); useEffect(() => { if (!open) return; // Seed from the last known fix so a route can be drawn immediately, then // refine with a live reading. This is what makes opening Directions feel // instant instead of hanging on "Finding your location…". const cached = getLastLocation(); const fresh = !!cached && Date.now() - cached.at < 2 * 60 * 1000; if (!origin && cached) setOrigin({ lat: cached.lat, lng: cached.lng }); // Only ask for a live GPS fix when the cached one is missing or stale. A // recent fix means the route can draw instantly with no "Finding your // location…" wait (and no needless location request on every open). if (!fresh) locate(); // Re-run only when the dialog opens (locate is stable). // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); // Reset transient state when closed useEffect(() => { if (!open) { setRoute(null); setError(null); setPickingStart(false); setCachedAge(null); } }, [open]); // ─── Initialize the Leaflet map once the dialog content is mounted ───────── useEffect(() => { const initial = destRef.current; if (!open || !mapDiv || mapRef.current || !initial) return; const map = L.map(mapDiv, { center: [initial.lat, initial.lng], zoom: 14, scrollWheelZoom: true, attributionControl: false, minZoom: 2, }); createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '', subdomains: 'abc', maxZoom: 19, noWrap: true, className: 'skatespots-dark-tiles', }).addTo(map); // Metric scale bar — genuinely useful on a route map (how long is that line?). L.control.scale({ imperial: false, position: 'topleft' }).addTo(map); // Destination marker const destIcon = L.divIcon({ className: 'route-dest-icon', html: `
`, iconSize: [32, 32], iconAnchor: [16, 30], }); destMarkerRef.current = L.marker([initial.lat, initial.lng], { icon: destIcon, }).addTo(map); mapRef.current = map; // "Choose on map" — the next tap sets the start point. const onMapClick = (e: L.LeafletMouseEvent) => { if (!pickingStartRef.current) return; setPicked({ lat: e.latlng.lat, lng: e.latlng.lng }); setStartMode('picked'); setPickingStart(false); }; map.on('click', onMapClick); // The dialog animates in (a brief scale), so the container is still // settling at first paint. Nudge Leaflet's size once now and again after the // animation ends — otherwise the map renders at the wrong size for a frame // and visibly jumps (the "flash" people see when they tap Directions). const t1 = setTimeout(() => map.invalidateSize(), 120); const t2 = setTimeout(() => map.invalidateSize(), 340); return () => { clearTimeout(t1); clearTimeout(t2); map.off('click', onMapClick); map.remove(); mapRef.current = null; routeLayersRef.current = null; originMarkerRef.current = null; destMarkerRef.current = null; }; }, [open, mapDiv]); // Move the destination marker when the in-dialog search changes the target. // The map itself is NOT rebuilt — the route effect below re-fits the view. useEffect(() => { if (dest && destMarkerRef.current) { destMarkerRef.current.setLatLng([dest.lat, dest.lng]); } }, [dest]); // ─── Draw / update the "from" marker (device location, picked point, live) ── useEffect(() => { const map = mapRef.current; if (!map || !markerPos) return; if (originMarkerRef.current) { originMarkerRef.current.setLatLng([markerPos.lat, markerPos.lng]); } else { const userIcon = L.divIcon({ className: 'route-origin-icon', html: `
`, iconSize: [18, 18], iconAnchor: [9, 9], }); originMarkerRef.current = L.marker([markerPos.lat, markerPos.lng], { icon: userIcon, }).addTo(map); } }, [markerPos]); // ─── Fetch + draw the route whenever origin / mode changes ───────────────── useEffect(() => { if (!open || !effectiveOrigin || !dest) return; const controller = new AbortController(); let cancelled = false; (async () => { setRouting(true); setError(null); try { const planOpts: TransitPlanOptions = {}; if (mode === 'transit') { if (transitTimeMode !== 'now' && transitTimeValue) { const when = new Date(transitTimeValue); if (!Number.isNaN(when.getTime())) { planOpts.time = when.toISOString(); planOpts.arriveBy = transitTimeMode === 'arrive'; } } if (selectedTransitModes.length > 0) planOpts.transitModes = selectedTransitModes; } const result = mode === 'transit' ? await fetchTransitRoute(effectiveOrigin, dest, controller.signal, planOpts) : await fetchRoute(effectiveOrigin, dest, mode, controller.signal); if (cancelled) return; setCachedAge(null); setRoute(result); setTransitIndex(0); if (mode !== 'transit') saveRoute(effectiveOrigin, dest, mode, result); const map = mapRef.current; if (map) { if (routeLayersRef.current) { routeLayersRef.current.remove(); routeLayersRef.current = null; } // Transit legs are drawn by the effect just below, so switching // between alternatives doesn't re-query the transit service. if (!result.transit) { const group = L.layerGroup().addTo(map); L.polyline(result.coordinates, { color: '#ccff00', weight: 5, opacity: 0.9, lineJoin: 'round', lineCap: 'round', }).addTo(group); routeLayersRef.current = group; if (result.coordinates.length > 0) { map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false }); } } } } catch (err) { if (cancelled) return; if (err instanceof DOMException && err.name === 'AbortError') return; const msg = err instanceof Error ? err.message : ''; if (mode === 'transit') { setError( msg === 'No transit route found' ? selectedTransitModes.length > 0 || transitTimeMode !== 'now' ? t('routeUi.errTransitNoRouteFiltered') : t('routeUi.errTransitNoRoute') : t('routeUi.errTransitUnreachable'), ); } else if (msg === ROUTE_ERR_BUSY) { setError(t('routeUi.errRoutingBusy')); } else if (msg === ROUTE_ERR_NO_ROUTE) { setError(t('routeUi.errNoRouteMode')); } else { setError(t('routeUi.errRoutingUnreachable')); } // Offline / server down: fall back to the last route we saved for this // destination + mode, clearly labelled, instead of a dead dialog. if (mode !== 'transit') { const hit = loadRoute(effectiveOrigin, dest, mode); if (hit) { setRoute(hit.route); setCachedAge(hit.ageMs); setError(null); const map = mapRef.current; if (map && hit.route.coordinates.length > 1) { if (routeLayersRef.current) routeLayersRef.current.remove(); const group = L.layerGroup().addTo(map); L.polyline(hit.route.coordinates, { color: '#ccff00', weight: 5, opacity: 0.7, dashArray: '8 8', lineJoin: 'round', lineCap: 'round', }).addTo(group); routeLayersRef.current = group; map.fitBounds(L.latLngBounds(hit.route.coordinates), { padding: [48, 48], maxZoom: 16, animate: false }); } } } } finally { if (!cancelled) setRouting(false); } })(); return () => { cancelled = true; controller.abort(); }; }, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes, t]); // ─── Draw the selected transit itinerary (redraws when an alternative is // chosen — no re-query). Walking legs dashed grey, vehicles solid. ────── useEffect(() => { const map = mapRef.current; if (!map || !selectedTransit) return; if (routeLayersRef.current) { routeLayersRef.current.remove(); routeLayersRef.current = null; } const group = L.layerGroup().addTo(map); for (const leg of selectedTransit.legs) { if (leg.coordinates.length < 2) continue; const isWalk = leg.mode === 'WALK'; L.polyline(leg.coordinates, { color: isWalk ? '#9ca3af' : transitLegColor(leg.mode), weight: isWalk ? 3 : 5, opacity: isWalk ? 0.7 : 0.9, dashArray: isWalk ? '4 6' : undefined, lineJoin: 'round', lineCap: 'round', }).addTo(group); } routeLayersRef.current = group; const coords = selectedTransit.legs.flatMap((l) => l.coordinates); if (coords.length > 0) { map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16, animate: false }); } }, [selectedTransit]); const destLabelText = destLabel || t('routeUi.destination'); const speed = route ? speedValue(route.distanceM, route.durationS) : undefined; return ( <> {/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM order: the map sits directly under the header (bigger — it's the point of the screen), the mode/vehicle row moves BELOW it, and the details scroll with the Start/Stop bar pinned at the bottom. */} {t('routeUi.routeTo', { label: destLabelText })} {/* Copy kept out of the visible chrome so the route screen stays clean; the same promise now lives on /privacy → "What leaves your device" → Directions. sr-only keeps the dialog accessible. */} {t('routeUi.description')} {/* Map. The height lives on the WRAPPER: the global `.leaflet-container { height: 100% }` override beats a `h-64` on the map element itself, and against a height-less parent that resolved to 0 — a fully invisible map with no route line. */}
{/* Destination search — overlays the map while open. Mounted only then, so opening Directions never pays for these queries. */} {searchOpen && (
{ setDest({ lat: pick.lat, lng: pick.lng }); setDestLabel(pick.label); onDestinationChange?.(pick); setSearchOpen(false); }} onClose={() => setSearchOpen(false)} />
)} {/* Overlays */} {/* Blocking overlay only when we have nothing to show yet. With a cached origin we keep the map visible and refine quietly. */} {((locating && !origin) || (routing && !route)) && (
{locating && !origin ? t('routeUi.findingLocation') : t('routeUi.plottingRoute')}
)} {/* Bottom-left stack: offline badge + "refining location" chip. Kept out of the top-left so it never covers Leaflet's zoom control. */} {(cachedAge !== null || (locating && origin)) && (
{cachedAge !== null && (
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge, t) })}
)} {locating && origin && (
{t('routeUi.refiningLocation')}
)}
)} {/* "Choose on map" hint */} {pickingStart && (
{t('routeUi.tapMapStart')}
)} {/* Follow mode: next turn + distance left */} {following && nav && (

{nav.stepIndex >= 0 ? t('routeUi.nextStep', { n: nav.stepIndex + 1 }) : t('routeUi.next')}

{nav.stepIndex >= 0 ? route?.steps?.[nav.stepIndex] ? stepInstruction(route.steps[nav.stepIndex], t) : t('routeUi.followTheLine') : route?.steps?.[0] ? stepInstruction(route.steps[0], t) : t('routeUi.followTheLine')}

{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm) ? `${formatDistance(nav.stepKm)} ${t('routeUi.toTheTurn')} · ` : ''} {formatDistance(nav.toDestKm)} {t('routeUi.toGo')}

)} {followError && (
{followError}
)} {locError && !origin && (

{locError}

)} {/* Re-fit the whole route — handy after tapping a step pans the map. */} {route && route.coordinates.length > 1 && ( )}
{/* Mode toggle (vehicles) — sits BELOW the map (order-3). */}
{TRAVEL_MODES.map((m) => ( ))}
{/* Start point — default is where you are; you can pick another on the map */}
{t('routeUi.from')}
{/* Scrollable details */}
{/* Summary bar */}
{error ? (
{error}
) : mode === 'transit' && selectedTransit ? (
{transitPrimaryEmoji(selectedTransit)}
{formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
{formatDuration(selectedTransit.durationS)} ·{' '} {selectedTransit.transfers === 0 ? t('routeUi.direct') : selectedTransit.transfers === 1 ? t('routeUi.transferOne') : t('routeUi.transfers', { count: selectedTransit.transfers })} {options.length > 1 && ` · ${t('routeUi.options', { count: options.length })}`} {routing && }
{selectedTransit.legs.some((l) => l.mode !== 'WALK') && (
{selectedTransit.legs .filter((l) => l.mode !== 'WALK') .map((l, i) => ( {transitModeEmoji(l.mode)} {l.line ?? l.mode} ))}
)}

{TRANSIT_ATTRIBUTION}

) : route ? (
{/* Compact summary — a single line instead of three ~90px tiles, so the map above keeps the vertical space. */}
{formatDistance(route.distanceM / 1000)} {formatDuration(route.durationS)} {t('routeUi.ride')} {t('routeUi.arrive')} {etaClock(route.durationS)}

{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji} {route.steps && route.steps.length > 1 ? t('routeUi.stepsCount', { count: route.steps.length }) : t('routeUi.direct')} {mode === 'foot' ? · {skateTime(route.distanceM / 1000, t)} : speed && ( · {t('routeUi.speedAvg', { speed })} )} {TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( · {t('routeUi.usesDrivingRoads')} )} {route.approximate && · {t('routeUi.drivingEstimate')}} {routing && · {t('routeUi.updating')}}

) : (
{origin ? t('routeUi.pickMode') : t('routeUi.waitingLocation')}
)}
{/* Turn-by-turn orientation — the planning list. While following, the map and the live NEXT banner take over (so the list steps out of the way). */} {!following && mode !== 'transit' && route?.steps && route.steps.length > 1 && (

{t('routeUi.routeSteps')} {route.steps.length}

{mode === 'foot' && (

{t('routeUi.walkingRoute')}

)}
    {route.steps.map((s, i) => (
  1. ))}

{t('routeUi.planningView')}

)} {/* Transit itinerary: alternatives + a real leg-by-leg breakdown — all from the same MOTIS response (nothing extra is fetched). */} {!following && mode === 'transit' && selectedTransit && (
{/* When: leave now · depart at · arrive by */}
{(['now', 'depart', 'arrive'] as const).map((m) => ( ))} {transitTimeMode !== 'now' && ( setTransitTimeValue(e.target.value)} aria-label={transitTimeMode === 'depart' ? t('routeUi.departureTime') : t('routeUi.arrivalTime')} className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white" /> )}
{/* Only: filter the modes (nothing selected = all) */}
{t('routeUi.only')} {TRANSIT_MODE_GROUPS.map((g) => { const on = transitModeFilter.includes(g.key); return ( ); })} {transitModeFilter.length > 0 && ( )}
{options.length > 1 && (
{options.map((opt, i) => { const active = i === transitIndex; const modes = [...new Set( opt.legs .filter((l) => l.mode !== 'WALK' && l.mode !== 'BIKE') .map((l) => transitModeEmoji(l.mode)), )]; return ( ); })}
)}

{t('routeUi.itinerary')} {selectedTransit.legs.length}

    {selectedTransit.legs.map((leg, i) => { const isFoot = leg.mode === 'WALK' || leg.mode === 'BIKE'; const dur = legDurationS(leg); const startClock = leg.startISO ? formatClock(leg.startISO) : '—'; const endClock = leg.endISO ? formatClock(leg.endISO) : '—'; return (
  1. {transitModeEmoji(leg.mode)}
    {!isFoot && ( {leg.line ?? leg.mode} )} {isFoot ? leg.mode === 'BIKE' ? t('routeUi.rideVerb') : t('routeUi.walk') : leg.headsign ? `→ ${leg.headsign}` : (leg.line ?? leg.mode)} {dur > 0 && ( {formatDuration(dur)} )} {leg.realTime && ( {t('routeUi.live')} )}
    {isFoot ? ( <> {leg.toName ? ( <> {t('routeUi.toStop')}{' '} setStopBoard({ id, name })} /> ) : leg.mode === 'BIKE' ? t('routeUi.rideLower') : t('routeUi.walkLower')} {leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''} ) : ( <> setStopBoard({ id, name })} /> {leg.fromTrack ? ` · ${t('routeUi.track', { track: leg.fromTrack })}` : ''} {startClock} {' → '} setStopBoard({ id, name })} /> {leg.toTrack ? ` · ${t('routeUi.track', { track: leg.toTrack })}` : ''} {endClock} {typeof leg.stops === 'number' && leg.stops > 0 ? ` · ${leg.stops === 1 ? t('routeUi.stopOne') : t('routeUi.stopsCount', { count: leg.stops })}` : ''} {leg.agencyName ? ` · ${leg.agencyName}` : ''} {leg.agencyFareUrl ? ( <> {' · '} {t('routeUi.tickets')} ) : ''} )}
  2. ); })}
)}
{/* /scrollable details */} {/* Follow mode: live turns + voice, with the screen kept awake. */} {route && !route.transit && (
{arrived ? ( ) : following ? ( ) : ( )}

{arrived ? t('routeUi.arrived') : following ? voiceOn ? t('routeUi.voiceOnNote') : t('routeUi.voiceOffNote') : t('routeUi.followNote')}

)}
{/* Departure board — its own dialog, kept OUTSIDE the route dialog so the two focus traps don't fight. */} { if (!o) setStopBoard(null); }} stopId={stopBoard?.id ?? null} stopName={stopBoard?.name ?? ''} /> ); }