- 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.
857 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|