import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import L from 'leaflet';
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair, Volume2, VolumeX, Check } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
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,
} from '@/lib/routing';
// ─── Transit presentation helpers ────────────────────────────────────────────
/** Line colour for a transit leg, keyed by the MOTIS mode. */
function transitLegColor(mode: string): string {
switch (mode) {
case 'BUS': return '#22c55e';
case 'TRAM': return '#eab308';
case 'SUBWAY':
case 'METRO': return '#3b82f6';
case 'RAIL':
case 'REGIONAL_RAIL':
case 'REGIONAL_FAST_RAIL':
case 'LONG_DISTANCE':
case 'HIGHSPEED_RAIL':
case 'NIGHT_RAIL':
case 'SUBURBAN': return '#ef4444';
case 'FERRY': return '#06b6d4';
default: return '#ccff00';
}
}
/** Emoji for a transit leg's mode. */
function transitModeEmoji(mode: string): string {
switch (mode) {
case 'BUS': return '🚌';
case 'TRAM': return '🚊';
case 'SUBWAY':
case 'METRO': return '🚇';
case 'RAIL':
case 'REGIONAL_RAIL':
case 'REGIONAL_FAST_RAIL':
case 'LONG_DISTANCE':
case 'HIGHSPEED_RAIL':
case 'NIGHT_RAIL':
case 'SUBURBAN': return '🚆';
case 'FERRY': return '⛴️';
case 'WALK': return '🚶';
case 'BIKE': return '🚲';
default: 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;
}
/**
* 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.
*/
/** Big number + label tile used in the route summary. */
function StatTile({ label, value, sub }: { label: string; value: string; sub?: string }) {
return (
{value}
{label}
{sub &&
{sub}
}
);
}
/** The average speed the routing engine assumed for this mode, e.g. "21 km/h avg". */
function speedLabel(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) : Math.round(kmh)} km/h avg`;
}
/** Local clock time you'd arrive if you set off now. */
function etaClock(durationS: number): string {
return new Date(Date.now() + durationS * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
export function RouteDialog({
open,
onOpenChange,
destination,
destinationLabel = 'Destination',
}: RouteDialogProps) {
// 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);
/** Re-fit the map to the whole route (after tapping a step pans it away). */
const fitRoute = useCallback(() => {
const map = mapRef.current;
if (!map || !route || route.coordinates.length < 2) return;
map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 });
}, [route]);
// 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('Starting navigation');
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('Location permission denied.');
stopFollowing();
} else {
setFollowError('Waiting for a GPS fix…');
}
},
);
}, [stopFollowing, acquireWakeLock, speak]);
// 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.
useEffect(() => {
if (!following || !live) return;
mapRef.current?.panTo([live.lat, live.lng]);
}, [following, live]);
/** Live guidance: distance left, and which step you're on. */
const nav = useMemo(() => {
if (!live || !destination) return null;
const toDestKm = calculateDistance(live.lat, live.lng, destination.lat, destination.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, destination, 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("You've arrived");
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(`In ${metres} metres, ${steps[idx]?.instruction ?? 'continue'}`);
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]?.instruction ?? 'Continue');
buzz(140);
}
}, [following, nav, route, speak, buzz, stopFollowing]);
// ─── 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'
? 'Location permission denied. Enable location to route from where you are.'
: 'Could not get your location. Try again.'
: 'Could not get your location.';
setLocError(msg);
} finally {
setLocating(false);
}
}, []);
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(() => {
if (!open || !mapDiv || mapRef.current || !destination) return;
const map = L.map(mapDiv, {
center: [destination.lat, destination.lng],
zoom: 14,
scrollWheelZoom: true,
attributionControl: false,
minZoom: 2,
});
createCachedTileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '',
subdomains: 'abc',
maxZoom: 19,
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([destination.lat, destination.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);
setTimeout(() => map.invalidateSize(), 120);
return () => {
map.off('click', onMapClick);
map.remove();
mapRef.current = null;
routeLayersRef.current = null;
originMarkerRef.current = null;
destMarkerRef.current = null;
};
}, [open, mapDiv, destination]);
// ─── 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 || !destination) return;
const controller = new AbortController();
let cancelled = false;
(async () => {
setRouting(true);
setError(null);
try {
const result = mode === 'transit'
? await fetchTransitRoute(effectiveOrigin, destination, controller.signal)
: await fetchRoute(effectiveOrigin, destination, mode, controller.signal);
if (cancelled) return;
setCachedAge(null);
setRoute(result);
if (mode !== 'transit') saveRoute(destination, mode, result);
const map = mapRef.current;
if (map) {
if (routeLayersRef.current) {
routeLayersRef.current.remove();
routeLayersRef.current = null;
}
const group = L.layerGroup().addTo(map);
if (result.transit) {
// Public transport: draw each leg — walking dashed grey, transit
// legs solid and coloured by mode.
for (const leg of result.transit.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);
}
} else {
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 });
}
}
} 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'
? 'No public-transport route found here — this area may not publish open timetables.'
: "Couldn't reach the transit service. Check your connection and try again.",
);
} else if (msg === ROUTE_ERR_BUSY) {
setError('The routing service is busy right now — try again in a moment.');
} else if (msg === ROUTE_ERR_NO_ROUTE) {
setError('No route for this mode — the destination may be off the road network. Try another mode.');
} else {
setError("Couldn't reach the routing service. Check your connection and try again.");
}
// 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(destination, 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 });
}
}
}
} finally {
if (!cancelled) setRouting(false);
}
})();
return () => {
cancelled = true;
controller.abort();
};
}, [open, effectiveOrigin, destination, mode]);
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. */}
Route to {destinationLabel}
{mode === 'transit'
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
: 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'}
{/* Mode toggle (vehicles) — sits BELOW the map via order-3. */}
{TRAVEL_MODES.map((m) => (
chooseMode(m.mode)}
className={cn(
'flex-1 flex flex-col items-center gap-0.5 rounded-xl py-2 text-xs font-bold uppercase tracking-wide transition-all border-2',
mode === m.mode
? 'bg-[#ccff00]/15 border-[#ccff00] text-[#ccff00]'
: 'border-transparent bg-muted/50 text-muted-foreground hover:bg-muted',
)}
aria-pressed={mode === m.mode}
>
{m.emoji}
{m.label}
))}
{/* Start point — default is where you are; you can pick another on the map */}
From
{
setStartMode('me');
setPickingStart(false);
}}
className={cn(
'rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
startMode === 'me'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
My location
setPickingStart((v) => !v)}
className={cn(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
pickingStart || startMode === 'picked'
? 'border-[#ccff00]/60 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
{pickingStart ? 'Tap the map…' : 'Choose on map'}
{/* 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. */}
{/* 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 ? 'Finding your location…' : 'Plotting your route…'}
)}
{/* 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 && (
Offline · saved route {formatAge(cachedAge)}
)}
{locating && origin && (
Refining location…
)}
)}
{/* "Choose on map" hint */}
{pickingStart && (
Tap the map to set your start point
)}
{/* Follow mode: next turn + distance left */}
{following && nav && (
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
{nav.stepIndex >= 0
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
? `${formatDistance(nav.stepKm)} to the turn · `
: ''}
{formatDistance(nav.toDestKm)} to go
)}
{followError && (
{followError}
)}
{locError && !origin && (
)}
{/* Re-fit the whole route — handy after tapping a step pans the map. */}
{route && route.coordinates.length > 1 && (
Whole route
)}
{/* Scrollable details */}
{/* Summary bar */}
{error ? (
) : route?.transit ? (
🚇
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)}
{formatDuration(route.transit.durationS)} ·{' '}
{route.transit.transfers === 0
? 'direct'
: `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`}
{routing && }
{route.transit.legs.some((l) => l.mode !== 'WALK') && (
{route.transit.legs
.filter((l) => l.mode !== 'WALK')
.map((l, i) => (
{transitModeEmoji(l.mode)}
{l.line ?? l.mode}
))}
)}
{TRANSIT_ATTRIBUTION}
) : route ? (
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
{route.steps && route.steps.length > 1
? `${route.steps.length} steps`
: 'direct'}
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
· uses driving roads
)}
{route.approximate && · driving estimate }
{routing && · updating… }
) : (
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
)}
{/* 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 && (
Route steps
{route.steps.length}
{mode === 'foot' && (
walking route — quicker on a board
)}
{route.steps.map((s, i) => (
{
setActiveStep(i);
const map = mapRef.current;
if (map && s.lat && s.lng) map.panTo([s.lat, s.lng]);
}}
className={cn(
// `min-h-0` overrides the app-wide 44px mobile touch-target
// rule for `button`, which otherwise made every step row a
// giant 44px slab.
'flex min-h-0 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition-colors',
currentStepIndex === i ? 'bg-[#ccff00]/10 text-white' : 'hover:bg-white/5',
)}
>
{i + 1}
{s.instruction}
{s.distanceM >= 10 && (
{formatDistance(s.distanceM / 1000)}
)}
))}
Planning view — tap Start to follow with live turns, voice and arrival.
)}
{/* /scrollable details */}
{/* Follow mode: live turns + voice, with the screen kept awake. */}
{route && !route.transit && (
{arrived ? (
onOpenChange(false)}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
Done
) : following ? (
Stop
) : (
Start
)}
{arrived
? "You've arrived. 🛹"
: following
? voiceOn
? 'Screen stays on · voice on — turns and arrival are spoken.'
: 'Screen stays on · voice off.'
: 'Turns, voice and arrival as you roll. Keeps the screen on.'}
{voiceOn ? : }
)}
);
}