SkateSpots/src/components/RouteDialog.tsx
SkateSpots b8757eccd0 feat(directions): every Directions button now uses the in-app router
- MapPage: replace the external 'Open with' menu (Google/OSM/Waze) with the
  shared in-app RouteDialog, so the map spot sheet routes natively (incl. the
  Transitous transit mode).
- FoodPage: replace the Google Maps handoff with RouteDialog.
- RouteDialog: remove the opt-in external 'Open in' handoff and delete
  src/lib/directions.ts — no external map apps anywhere (product decision).
2026-09-27 11:25:56 +07:00

420 lines
15 KiB
TypeScript

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';
// ─── 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<HTMLDivElement>(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);
const [mode, setMode] = useState<TravelMode>('foot');
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);
// ─── 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: `<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;
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: `<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([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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg p-0 gap-0 overflow-hidden">
<DialogHeader className="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)'
: 'Routed in-app · nothing leaves to Google or Apple Maps'}
</DialogDescription>
</DialogHeader>
{/* Mode toggle */}
<div className="px-4 pb-3 flex gap-1.5">
{TRAVEL_MODES.map((m) => (
<button
key={m.mode}
type="button"
onClick={() => setMode(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>
{/* Map */}
<div className="relative">
<div
ref={mapDivRef}
className="h-64 w-full bg-muted"
aria-label="Route map"
/>
{/* Overlays */}
{(locating || (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 ? 'Finding your location…' : 'Plotting your route…'}
</div>
</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>
)}
</div>
{/* 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="flex items-center justify-between w-full">
<div className="flex items-center gap-3">
<div className="text-2xl leading-none">
{TRAVEL_MODES.find((m) => m.mode === mode)?.emoji}
</div>
<div>
<div className="text-xl font-black tabular-nums">
{formatDistance(route.distanceM / 1000)}
</div>
<div className="text-xs text-muted-foreground">
{formatDuration(route.durationS)}
{TRAVEL_MODES.find((m) => m.mode === mode)?.approximate && (
<span className="ml-1">· uses driving roads</span>
)}
</div>
</div>
</div>
{routing && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
{origin ? 'Pick a mode to see your route.' : 'Waiting for your location…'}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}