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 { 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); /** 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. 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, }); 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: header + modes + map are pinned, the details in the middle scroll, and the Start/Stop bar stays reachable 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 */}
{TRAVEL_MODES.map((m) => ( ))}
{/* Start point — default is where you are; you can pick another on the map */}
From
{/* 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 && (

{locError}

)} {/* Re-fit the whole route — handy after tapping a step pans the map. */} {route && route.coordinates.length > 1 && ( )}
{/* Scrollable details */}
{/* Summary bar */}
{error ? (
{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 — a static list. Honestly NOT live navigation. */} {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) => (
  1. ))}

Orientation only — no live turn-by-turn or voice guidance yet.

)}
{/* /scrollable details */} {/* Follow mode: explicit, and honest that there's no voice guidance. */} {route && !route.transit && (
{following ? ( ) : ( )}

{following ? 'Following your position and the next turn. No voice guidance.' : 'Follows your position and shows the next turn as you roll.'}

)}
); }