SkateSpots/src/components/RouteDialog.tsx
SkateSpots 7e48884eb9 feat(map,i18n): single-copy world clamp + regional flags + pt-PT
- Map: min zoom = smallest integer zoom whose 256*2^z world covers the
  viewport (no grey bands, no duplicated continents). Horizontal wrap is
  restored (worldCopyJump, no longitude maxBounds) so Hawaii/Pacific are
  reachable by dragging; vertical panning stays clamped.
- Flags: real SVGs for Catalonia, Basque Country, Galicia and England
  (no Unicode region flags exist; English is not the UK). Portuguese now has
  Brasil (pt) and Portugal (pt-PT).
- pt-PT shares the pt dictionary with a European drift for the visible chrome
  (Guardar, Palavra-passe, Autocarro, Utilizador, ...).
- check-i18n covers pt-PT: 24 codes at 100%; tsc/tests/build green.
2026-10-26 16:09:21 +07:00

1469 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<TravelMode, string> = {
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<string, string> = {
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, string | number>) => string;
/** Maneuver tokens live in `lib/routing`; map them to literal translation keys. */
const MANEUVER_LABEL_KEYS: Record<string, string> = {
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 (
<button
type="button"
onClick={() => onOpen(id, name)}
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground"
>
{name}
</button>
);
}
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<string>('');
// 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<HTMLDivElement | null>(null);
const mapRef = useRef<L.Map | null>(null);
const routeLayersRef = useRef<L.LayerGroup | null>(null);
const originMarkerRef = useRef<L.Marker | null>(null);
const destMarkerRef = useRef<L.Marker | null>(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<TravelMode>(() => {
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<number | null>(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<GeoCoords | null>(null);
const [pickingStart, setPickingStart] = useState(false);
// ── Follow mode (explicit; owns a GPS watcher) ─────────────────────────────
const [following, setFollowing] = useState(false);
const [live, setLive] = useState<GeoCoords | null>(null);
const [followError, setFollowError] = useState<string | null>(null);
const watchRef = useRef<GeoWatch | null>(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<Map<number, Set<number>>>(new Map());
const arrivedRef = useRef(false);
const wakeLockRef = useRef<{ release: () => Promise<void> } | null>(null);
/** Route shown while offline came from the cache (with its age). */
const [cachedAge, setCachedAge] = useState<number | null>(null);
/** Where the route starts: a picked point wins, else the device location. */
const effectiveOrigin = useMemo<GeoCoords | null>(
() => (startMode === 'picked' && picked ? picked : origin),
[startMode, picked, origin],
);
/** Where to draw the "you are here" dot — live position while following. */
const markerPos = useMemo<GeoCoords | null>(
() => (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<RouteResult | null>(null);
const [locating, setLocating] = useState(false);
const [routing, setRouting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [locError, setLocError] = useState<string | null>(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<string[]>([]);
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<void> }> };
};
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<number>();
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: `<div style="width:32px;height:32px;background:linear-gradient(135deg,#ff0040,#ff6600);border-radius:50% 50% 50% 0;transform:rotate(-45deg);box-shadow:0 4px 12px rgba(255,0,64,0.5);border:3px solid rgba(255,255,255,0.9);"></div>`,
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: `<div style="width:18px;height:18px;background:#00b3ff;border-radius:50%;box-shadow:0 0 0 6px rgba(0,179,255,0.25),0 2px 8px rgba(0,0,0,0.4);border:3px solid #fff;"></div>`,
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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
{/* 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. */}
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-xl">
<DialogHeader className="order-1 shrink-0 pl-4 pr-12 pt-4 pb-3">
<DialogTitle className="sr-only">{t('routeUi.routeTo', { label: destLabelText })}</DialogTitle>
<button
type="button"
onClick={() => setSearchOpen(true)}
className="flex w-full items-center gap-3 rounded-xl border border-white/10 bg-white/[0.04] px-3 py-2 text-left transition-colors hover:border-[#ccff00]/40"
>
<Navigation className="h-5 w-5 shrink-0 text-[#ccff00]" />
<span className="min-w-0 flex-1">
<span className="block text-[10px] font-bold uppercase tracking-widest text-gray-500">
{t('routeUi.routeToLabel')}
</span>
<span className="block truncate text-base font-bold text-white">{destLabelText}</span>
</span>
<span className="flex shrink-0 items-center gap-1.5 rounded-full border border-white/10 px-2.5 py-1 text-[10px] font-bold uppercase tracking-wider text-gray-300">
<Search className="h-3 w-3" /> {t('routeUi.search')}
</span>
</button>
{/* 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. */}
<DialogDescription className="sr-only">
{t('routeUi.description')}
</DialogDescription>
</DialogHeader>
{/* 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. */}
<div className={cn('order-2 relative shrink-0 bg-[#0b0b0c]', following ? 'h-[54vh] min-h-[320px] sm:h-[60vh]' : 'h-[34vh] min-h-[220px] sm:h-[400px]')}>
<div
ref={setMapDiv}
className="h-full w-full bg-[#0b0b0c]"
aria-label={t('routeUi.routeMapAria')}
/>
{/* Destination search — overlays the map while open. Mounted only
then, so opening Directions never pays for these queries. */}
{searchOpen && (
<div className="absolute inset-0 z-[1500] bg-background/95 backdrop-blur-md">
<DestinationSearch
near={dest ?? effectiveOrigin}
onSelect={(pick) => {
setDest({ lat: pick.lat, lng: pick.lng });
setDestLabel(pick.label);
onDestinationChange?.(pick);
setSearchOpen(false);
}}
onClose={() => setSearchOpen(false)}
/>
</div>
)}
{/* 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)) && (
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-sm">
<div className="flex items-center gap-2 text-sm font-medium">
<Loader2 className="h-4 w-4 animate-spin text-[#ccff00]" />
{locating && !origin ? t('routeUi.findingLocation') : t('routeUi.plottingRoute')}
</div>
</div>
)}
{/* 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)) && (
<div className="pointer-events-none absolute bottom-2 left-2 flex flex-col items-start gap-1">
{cachedAge !== null && (
<div className="rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-amber-300 backdrop-blur-sm">
{t('routeUi.offlineSavedRoute', { age: formatAge(cachedAge, t) })}
</div>
)}
{locating && origin && (
<div className="flex items-center gap-1.5 rounded-full bg-background/80 px-2 py-1 text-[10px] font-medium text-muted-foreground backdrop-blur-sm">
<Loader2 className="h-3 w-3 animate-spin" />
{t('routeUi.refiningLocation')}
</div>
)}
</div>
)}
{/* "Choose on map" hint */}
{pickingStart && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/80 px-3 py-2 text-center text-[11px] font-bold text-[#ccff00] backdrop-blur-sm">
{t('routeUi.tapMapStart')}
</div>
)}
{/* Follow mode: next turn + distance left */}
{following && nav && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 backdrop-blur-sm">
<p className="text-[11px] font-black uppercase tracking-widest text-[#ccff00]">
{nav.stepIndex >= 0 ? t('routeUi.nextStep', { n: nav.stepIndex + 1 }) : t('routeUi.next')}
</p>
<p className="truncate text-base font-black text-white">
{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')}
</p>
<p className="text-xs text-gray-400">
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
? `${formatDistance(nav.stepKm)} ${t('routeUi.toTheTurn')} · `
: ''}
{formatDistance(nav.toDestKm)} {t('routeUi.toGo')}
</p>
</div>
)}
{followError && (
<div className="pointer-events-none absolute inset-x-2 top-2 rounded-xl bg-black/85 px-3 py-2 text-center text-[11px] font-bold text-amber-300 backdrop-blur-sm">
{followError}
</div>
)}
{locError && !origin && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 p-6">
<div className="text-center space-y-3 max-w-xs">
<MapPin className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">{locError}</p>
<Button size="sm" variant="outline" onClick={locate} className="gap-2">
<LocateFixed className="h-4 w-4" />
{t('routeUi.tryAgain')}
</Button>
</div>
</div>
)}
{/* Re-fit the whole route — handy after tapping a step pans the map. */}
{route && route.coordinates.length > 1 && (
<button
type="button"
onClick={fitRoute}
className="absolute bottom-2 right-2 flex items-center gap-1 rounded-lg border border-white/10 bg-background/85 px-2 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground shadow backdrop-blur-sm transition-colors hover:text-foreground"
aria-label={t('routeUi.fitRouteAria')}
>
<LocateFixed className="h-3.5 w-3.5" />
{t('routeUi.wholeRoute')}
</button>
)}
</div>
{/* Mode toggle (vehicles) — sits BELOW the map (order-3). */}
<div className="order-3 flex shrink-0 gap-1.5 px-4 pb-3">
{TRAVEL_MODES.map((m) => (
<button
key={m.mode}
type="button"
onClick={() => 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}
>
<span className="text-lg leading-none">{m.emoji}</span>
<span className="text-[10px] leading-tight text-center">{t(MODE_LABEL_KEYS[m.mode])}</span>
</button>
))}
</div>
{/* Start point — default is where you are; you can pick another on the map */}
<div className="order-4 flex shrink-0 flex-wrap items-center gap-1.5 px-4 pb-3">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{t('routeUi.from')}
</span>
<button
type="button"
onClick={() => {
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',
)}
>
{t('routeUi.myLocation')}
</button>
<button
type="button"
onClick={() => 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',
)}
>
<Crosshair className="h-3 w-3" />
{pickingStart ? t('routeUi.tapTheMap') : t('routeUi.chooseOnMap')}
</button>
</div>
{/* Scrollable details */}
<div className="order-5 min-h-0 flex-1 overflow-y-auto">
{/* Summary bar */}
<div className="flex items-center border-t bg-muted/30 px-4 py-2.5">
{error ? (
<div className="flex items-center gap-2 text-sm text-amber-500">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : mode === 'transit' && selectedTransit ? (
<div className="w-full space-y-2">
<div className="flex items-center gap-3">
<div className="text-2xl leading-none">{transitPrimaryEmoji(selectedTransit)}</div>
<div className="min-w-0">
<div className="text-lg font-black tabular-nums">
{formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
</div>
<div className="text-xs text-muted-foreground">
{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 && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
</div>
</div>
</div>
{selectedTransit.legs.some((l) => l.mode !== 'WALK') && (
<div className="flex flex-wrap gap-1">
{selectedTransit.legs
.filter((l) => l.mode !== 'WALK')
.map((l, i) => (
<span
key={`${l.mode}-${l.line}-${i}`}
className="inline-flex items-center gap-1 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] font-bold text-gray-300"
>
<span aria-hidden>{transitModeEmoji(l.mode)}</span>
{l.line ?? l.mode}
</span>
))}
</div>
)}
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
</div>
) : route ? (
<div className="w-full space-y-1.5">
{/* Compact summary — a single line instead of three ~90px tiles,
so the map above keeps the vertical space. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 leading-none">
<span className="text-base font-black tabular-nums text-white">
{formatDistance(route.distanceM / 1000)}
</span>
<span aria-hidden className="h-3.5 w-px bg-white/15" />
<span className="flex items-baseline gap-1.5">
<span className="text-base font-black tabular-nums text-white">
{formatDuration(route.durationS)}
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t('routeUi.ride')}
</span>
</span>
<span aria-hidden className="h-3.5 w-px bg-white/15" />
<span className="flex items-baseline gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t('routeUi.arrive')}
</span>
<span className="text-base font-black tabular-nums text-white">
{etaClock(route.durationS)}
</span>
</span>
</div>
<p className="flex flex-wrap items-center gap-x-1.5 text-[10px] text-muted-foreground">
<span className="text-sm leading-none">
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</span>
<span>
{route.steps && route.steps.length > 1
? t('routeUi.stepsCount', { count: route.steps.length })
: t('routeUi.direct')}
</span>
{mode === 'foot'
? <span>· {skateTime(route.distanceM / 1000, t)}</span>
: speed && (
<span>· {t('routeUi.speedAvg', { speed })}</span>
)}
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span>· {t('routeUi.usesDrivingRoads')}</span>
)}
{route.approximate && <span>· {t('routeUi.drivingEstimate')}</span>}
{routing && <span>· {t('routeUi.updating')}</span>}
</p>
</div>
) : (
<div className="text-sm text-muted-foreground">
{origin ? t('routeUi.pickMode') : t('routeUi.waitingLocation')}
</div>
)}
</div>
{/* 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 && (
<div className="border-t bg-muted/20">
<div className="flex flex-wrap items-baseline justify-between gap-x-2 px-4 pt-3">
<p className="flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{t('routeUi.routeSteps')}
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{route.steps.length}
</span>
</p>
{mode === 'foot' && (
<p className="text-[10px] text-muted-foreground/70">{t('routeUi.walkingRoute')}</p>
)}
</div>
<ol className="max-h-72 space-y-1.5 overflow-y-auto px-4 pb-2 pt-1.5">
{route.steps.map((s, i) => (
<li key={`${i}-${s.instruction}`}>
<button
type="button"
onClick={() => {
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',
)}
>
<span className="mt-px w-4 shrink-0 text-right font-mono text-[10px] text-muted-foreground/60">
{i + 1}
</span>
<span className="min-w-0 flex-1 leading-snug text-gray-300">{stepInstruction(s, t)}</span>
{s.distanceM >= 10 && (
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{formatDistance(s.distanceM / 1000)}
</span>
)}
</button>
</li>
))}
</ol>
<p className="px-4 pb-3 text-[10px] text-muted-foreground/60">
{t('routeUi.planningView')}
</p>
</div>
)}
{/* Transit itinerary: alternatives + a real leg-by-leg breakdown — all
from the same MOTIS response (nothing extra is fetched). */}
{!following && mode === 'transit' && selectedTransit && (
<div className="border-t bg-muted/20">
{/* When: leave now · depart at · arrive by */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-3">
{(['now', 'depart', 'arrive'] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setTransitTimeMode(m)}
aria-pressed={transitTimeMode === m}
className={cn(
'min-h-0 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
transitTimeMode === m
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
: 'border-white/10 text-gray-400 hover:border-white/25',
)}
>
{m === 'now' ? t('routeUi.leaveNow') : m === 'depart' ? t('routeUi.departAt') : t('routeUi.arriveBy')}
</button>
))}
{transitTimeMode !== 'now' && (
<input
type="datetime-local"
value={transitTimeValue}
onChange={(e) => 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"
/>
)}
</div>
{/* Only: filter the modes (nothing selected = all) */}
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">{t('routeUi.only')}</span>
{TRANSIT_MODE_GROUPS.map((g) => {
const on = transitModeFilter.includes(g.key);
return (
<button
key={g.key}
type="button"
onClick={() => toggleModeGroup(g.key)}
aria-pressed={on}
className={cn(
'flex min-h-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold transition-colors',
on
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
: 'border-white/10 text-gray-400 hover:border-white/25',
)}
>
<span aria-hidden>{g.emoji}</span>
{t(TRANSIT_GROUP_LABEL_KEYS[g.key] ?? g.label)}
</button>
);
})}
{transitModeFilter.length > 0 && (
<button
type="button"
onClick={() => setTransitModeFilter([])}
className="min-h-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-muted-foreground underline decoration-dotted hover:text-foreground"
>
{t('routeUi.clear')}
</button>
)}
</div>
{options.length > 1 && (
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
{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 (
<button
key={`${opt.departISO}-${i}`}
type="button"
onClick={() => setTransitIndex(i)}
className={cn(
// `min-h-0` beats the app-wide 44px mobile touch-target rule.
'flex min-h-0 shrink-0 flex-col items-start gap-0.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors',
active
? 'border-[#ccff00]/60 bg-[#ccff00]/10'
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
)}
>
<span className="text-xs font-black tabular-nums text-white">
{formatClock(opt.departISO)}–{formatClock(opt.arriveISO)}
</span>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span aria-hidden>{modes.join('')}</span>
{formatDuration(opt.durationS)} ·{' '}
{opt.transfers === 0
? t('routeUi.direct')
: opt.transfers === 1
? t('routeUi.transferOne')
: t('routeUi.transfers', { count: opt.transfers })}
</span>
</button>
);
})}
</div>
)}
<p className="flex items-center gap-2 px-4 pt-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
{t('routeUi.itinerary')}
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
{selectedTransit.legs.length}
</span>
</p>
<ol className="max-h-72 space-y-1 overflow-y-auto px-4 pb-2 pt-1.5">
{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 (
<li
key={`${leg.mode}-${leg.line}-${i}`}
className="flex min-h-0 items-start gap-2 rounded-lg px-2 py-1.5"
>
<span className="mt-px w-5 shrink-0 text-center text-base leading-none" aria-hidden>
{transitModeEmoji(leg.mode)}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
{!isFoot && (
<span
className="inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-black text-black"
style={{ background: transitLegColor(leg.mode) }}
>
{leg.line ?? leg.mode}
</span>
)}
<span className="text-xs font-bold text-white">
{isFoot
? leg.mode === 'BIKE' ? t('routeUi.rideVerb') : t('routeUi.walk')
: leg.headsign
? `→ ${leg.headsign}`
: (leg.line ?? leg.mode)}
</span>
{dur > 0 && (
<span className="text-[10px] text-muted-foreground">{formatDuration(dur)}</span>
)}
{leg.realTime && (
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
{t('routeUi.live')}
</span>
)}
</div>
<div className="text-[10px] text-muted-foreground">
{isFoot ? (
<>
{leg.toName ? (
<>
{t('routeUi.toStop')}{' '}
<StopButton
id={leg.toStopId}
name={leg.toName}
onOpen={(id, name) => setStopBoard({ id, name })}
/>
</>
) : leg.mode === 'BIKE' ? t('routeUi.rideLower') : t('routeUi.walkLower')}
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
</>
) : (
<>
<StopButton
id={leg.fromStopId}
name={leg.fromName}
onOpen={(id, name) => setStopBoard({ id, name })}
/>
{leg.fromTrack ? ` · ${t('routeUi.track', { track: leg.fromTrack })}` : ''} {startClock}
{' → '}
<StopButton
id={leg.toStopId}
name={leg.toName}
onOpen={(id, name) => 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 ? (
<>
{' · '}
<a
href={leg.agencyFareUrl}
target="_blank"
rel="noopener noreferrer"
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
>
{t('routeUi.tickets')}
</a>
</>
) : ''}
</>
)}
</div>
</div>
</li>
);
})}
</ol>
</div>
)}
</div>{/* /scrollable details */}
{/* Follow mode: live turns + voice, with the screen kept awake. */}
{route && !route.transit && (
<div className="order-6 flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
{arrived ? (
<Button
size="sm"
onClick={() => onOpenChange(false)}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Check className="h-3.5 w-3.5" />
{t('routeUi.done')}
</Button>
) : following ? (
<Button
size="sm"
variant="outline"
onClick={stopFollowing}
className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10"
>
<Square className="h-3.5 w-3.5" />
{t('routeUi.stop')}
</Button>
) : (
<Button
size="sm"
onClick={startFollowing}
className="gap-2 bg-[#ccff00] font-bold text-black hover:bg-[#ccff00]/90"
>
<Play className="h-3.5 w-3.5" />
{t('routeUi.start')}
</Button>
)}
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
{arrived
? t('routeUi.arrived')
: following
? voiceOn
? t('routeUi.voiceOnNote')
: t('routeUi.voiceOffNote')
: t('routeUi.followNote')}
</p>
<button
type="button"
onClick={toggleVoice}
aria-pressed={voiceOn}
aria-label={voiceOn ? t('routeUi.voiceOffAria') : t('routeUi.voiceOnAria')}
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-full border transition-colors',
voiceOn
? 'border-[#ccff00]/50 bg-[#ccff00]/15 text-[#ccff00]'
: 'border-white/10 text-muted-foreground hover:text-foreground',
)}
>
{voiceOn ? <Volume2 className="h-4 w-4" /> : <VolumeX className="h-4 w-4" />}
</button>
</div>
)}
</DialogContent>
</Dialog>
{/* Departure board — its own dialog, kept OUTSIDE the route dialog so the
two focus traps don't fight. */}
<StopDepartures
open={!!stopBoard}
onOpenChange={(o) => { if (!o) setStopBoard(null); }}
stopId={stopBoard?.id ?? null}
stopName={stopBoard?.name ?? ''}
/>
</>
);
}