SkateSpots/src/components/RouteDialog.tsx
SkateSpots 4474cc26e9 feat(directions): bigger map + app-like layout and richer route details
- Map is now the hero: h-[38vh] mobile / 400px desktop (was 256), with a metric
  Leaflet scale control.
- New layout: flex column with header/modes/From/map pinned, the details as the
  ONLY scroll region, and Start/Stop pinned at the bottom so it's always
  reachable. Previously the dialog was a single overflow-hidden box, so once the
  map grew everything below the fold (Start, steps) was unreachable.
- Summary rebuilt as stat tiles (distance / time / arrive) plus a detail line:
  step count, honest 'uses driving roads'/'driving estimate' notes, and the
  average speed the engine assumed ('21 km/h avg'; foot keeps the skate cruise
  estimate). Steps header shows the count; step list scrolls taller (max-h-72).
- Wider dialog on desktop (sm:max-w-xl).

Verified in-app: tiles + 9/8/5/5 steps per mode, follow banner still tracks
movement, offline chip still appears, map 371px -> 321px with the new layout.
2026-09-27 19:42:06 +07:00

857 lines
34 KiB
TypeScript

import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import L from 'leaflet';
import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed, Play, Square, Crosshair } 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 { 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 (
<div className="rounded-xl border border-white/8 bg-white/[0.03] px-2 py-2.5 text-center">
<p className="text-lg font-black leading-tight tabular-nums text-white">{value}</p>
<p className="mt-0.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</p>
{sub && <p className="mt-0.5 text-[10px] leading-tight text-muted-foreground/70">{sub}</p>}
</div>
);
}
/** 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<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);
/** 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);
/** 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.
useEffect(() => {
setActiveStep(null);
}, [route]);
// ── Follow mode ────────────────────────────────────────────────────────────
// Explicit only: the user taps Start, we watch their position, and we stop the
// watcher the moment they stop or the dialog closes. No voice guidance.
const stopFollowing = useCallback(() => {
watchRef.current?.stop();
watchRef.current = null;
setFollowing(false);
}, []);
const startFollowing = useCallback(() => {
if (watchRef.current) return;
setFollowError(null);
setFollowing(true);
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]);
// 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;
},
[],
);
// 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;
// ─── 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,
});
L.tileLayer('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: `<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([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: `<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 || !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 (
<Dialog open={open} onOpenChange={onOpenChange}>
{/* App-like shell: header + modes + map are pinned, the details in the
middle scroll, and the Start/Stop bar stays reachable 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="shrink-0 p-4 pb-3">
<DialogTitle className="flex items-center gap-2 text-lg">
<Navigation className="h-5 w-5 text-[#ccff00]" />
Route to {destinationLabel}
</DialogTitle>
<DialogDescription className="text-xs">
{mode === 'transit'
? 'Public transport via Transitous (open timetables + OpenStreetMap)'
: 'In-app route · OpenStreetMap routing — nothing leaves to Google or Apple Maps'}
</DialogDescription>
</DialogHeader>
{/* Mode toggle */}
<div className="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">{m.label}</span>
</button>
))}
</div>
{/* Start point — default is where you are; you can pick another on the map */}
<div className="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">
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',
)}
>
My location
</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 ? 'Tap the map…' : 'Choose on map'}
</button>
</div>
{/* 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="relative h-[38vh] min-h-[220px] shrink-0 sm:h-[400px]">
<div
ref={setMapDiv}
className="h-full w-full bg-muted"
aria-label="Route map"
/>
{/* 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 ? 'Finding your location…' : 'Plotting your route…'}
</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">
Offline · saved route {formatAge(cachedAge)}
</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" />
Refining location…
</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">
Tap the map to set your start point
</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-[10px] font-black uppercase tracking-widest text-[#ccff00]">
Next{nav.stepIndex >= 0 ? ` · step ${nav.stepIndex + 1}` : ''}
</p>
<p className="truncate text-sm font-bold text-white">
{nav.stepIndex >= 0
? route?.steps?.[nav.stepIndex]?.instruction ?? 'Follow the line'
: route?.steps?.[0]?.instruction ?? 'Follow the line'}
</p>
<p className="text-[11px] text-gray-400">
{nav.stepIndex >= 0 && Number.isFinite(nav.stepKm)
? `${formatDistance(nav.stepKm)} to the turn · `
: ''}
{formatDistance(nav.toDestKm)} to go
</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" />
Try again
</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="Fit the whole route on the map"
>
<LocateFixed className="h-3.5 w-3.5" />
Whole route
</button>
)}
</div>
{/* Scrollable details */}
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Summary bar */}
<div className="p-4 border-t bg-muted/30 min-h-[64px] flex items-center">
{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>
) : route?.transit ? (
<div className="w-full space-y-2">
<div className="flex items-center gap-3">
<div className="text-2xl leading-none">🚇</div>
<div className="min-w-0">
<div className="text-lg font-black tabular-nums">
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)}
</div>
<div className="text-xs text-muted-foreground">
{formatDuration(route.transit.durationS)} ·{' '}
{route.transit.transfers === 0
? 'direct'
: `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`}
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
</div>
</div>
</div>
{route.transit.legs.some((l) => l.mode !== 'WALK') && (
<div className="flex flex-wrap gap-1">
{route.transit.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-2">
<div className="grid grid-cols-3 gap-2">
<StatTile label="Distance" value={formatDistance(route.distanceM / 1000)} />
<StatTile
label="Time"
value={formatDuration(route.durationS)}
sub={
mode === 'foot'
? `${skateTime(route.distanceM / 1000)} on a board`
: speedLabel(route.distanceM, route.durationS)
}
/>
<StatTile label="Arrive" value={etaClock(route.durationS)} />
</div>
<p className="flex flex-wrap items-center gap-x-1.5 text-[10px] text-muted-foreground">
<span className="text-base leading-none">
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</span>
<span>
{route.steps && route.steps.length > 1
? `${route.steps.length} steps`
: 'direct'}
</span>
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span>· uses driving roads</span>
)}
{route.approximate && <span>· driving estimate</span>}
{routing && <span>· updating…</span>}
</p>
</div>
) : (
<div className="text-sm text-muted-foreground">
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
</div>
)}
</div>
{/* Turn-by-turn orientation — a static list. Honestly NOT live navigation. */}
{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">
Route steps
<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">walking route — quicker on a board</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">{s.instruction}</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">
Orientation only — no live turn-by-turn or voice guidance yet.
</p>
</div>
)}
</div>{/* /scrollable details */}
{/* Follow mode: explicit, and honest that there's no voice guidance. */}
{route && !route.transit && (
<div className="flex items-center gap-3 border-t bg-muted/30 px-4 py-3">
{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" />
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" />
Start
</Button>
)}
<p className="min-w-0 flex-1 text-[10px] leading-snug text-muted-foreground">
{following
? 'Following your position and the next turn. No voice guidance.'
: 'Follows your position and shows the next turn as you roll.'}
</p>
</div>
)}
</DialogContent>
</Dialog>
);
}