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 (
);
}