import { useEffect, useRef, useState, useCallback } from 'react'; import L from 'leaflet'; import { Loader2, MapPin, Navigation, AlertTriangle, LocateFixed } 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, GeolocationError } from '@/lib/geolocation'; import { fetchRoute, fetchTransitRoute, formatClock, formatDistance, formatDuration, TRAVEL_MODES, TRANSIT_ATTRIBUTION, type TravelMode, type RouteResult, } from '@/lib/routing'; import { buildDirectionsLinks, openExternal } from '@/lib/directions'; // ─── 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. */ export function RouteDialog({ open, onOpenChange, destination, destinationLabel = 'Destination', }: RouteDialogProps) { const mapDivRef = useRef(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); const [mode, setMode] = useState('foot'); 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); // ─── 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 && !origin && !locating && !locError) { locate(); } }, [open, origin, locating, locError, locate]); // Reset transient state when closed useEffect(() => { if (!open) { setRoute(null); setError(null); } }, [open]); // ─── Initialize the Leaflet map once the dialog content is mounted ───────── useEffect(() => { if (!open || !mapDivRef.current || mapRef.current || !destination) return; const map = L.map(mapDivRef.current, { 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); // 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; setTimeout(() => map.invalidateSize(), 120); return () => { map.remove(); mapRef.current = null; routeLayersRef.current = null; originMarkerRef.current = null; destMarkerRef.current = null; }; }, [open, destination]); // ─── Draw / update the origin marker when we have the user's location ────── useEffect(() => { const map = mapRef.current; if (!map || !origin) return; if (originMarkerRef.current) { originMarkerRef.current.setLatLng([origin.lat, origin.lng]); } else { const userIcon = L.divIcon({ className: 'route-origin-icon', html: `
`, iconSize: [18, 18], iconAnchor: [9, 9], }); originMarkerRef.current = L.marker([origin.lat, origin.lng], { icon: userIcon, }).addTo(map); } }, [origin]); // ─── Fetch + draw the route whenever origin / mode changes ───────────────── useEffect(() => { if (!open || !origin || !destination) return; const controller = new AbortController(); let cancelled = false; (async () => { setRouting(true); setError(null); try { const result = mode === 'transit' ? await fetchTransitRoute(origin, destination, controller.signal) : await fetchRoute(origin, destination, mode, controller.signal); if (cancelled) return; setRoute(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; setError( mode === 'transit' ? err instanceof Error && err.message === '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." : err instanceof Error && err.message === 'No route found' ? 'No route found for this mode. Try another mode.' : "Couldn't reach the routing service. Check your connection and try again.", ); } finally { if (!cancelled) setRouting(false); } })(); return () => { cancelled = true; controller.abort(); }; }, [open, origin, destination, mode]); return ( Route to {destinationLabel} {mode === 'transit' ? 'Public transport via Transitous (open timetables + OpenStreetMap)' : 'Routed in-app · nothing leaves to Google or Apple Maps'} {/* Mode toggle */}
{TRAVEL_MODES.map((m) => ( ))}
{/* Map */}
{/* Overlays */} {(locating || (routing && !route)) && (
{locating ? 'Finding your location…' : 'Plotting your route…'}
)} {locError && !origin && (

{locError}

)}
{/* 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}
{formatDistance(route.distanceM / 1000)}
{formatDuration(route.durationS)} {TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && ( · uses driving roads )}
{routing && ( )}
) : (
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
)}
{/* Open in maps — opt-in handoff to an external app (this shares the trip) */} {origin && destination && (
Open in {buildDirectionsLinks(origin, destination, mode, destinationLabel).map((link) => ( ))}
)}
); }