feat(transit): real itineraries — alternatives, leg-by-leg, mode badges
Transit showed one cryptic summary line and threw away 3 of 4 itineraries. Surface what the MOTIS/Transitous response already contains. routing.ts: TransitLeg gains headsign, agencyName, from/to track, stop count, realTime and distance; RouteResult gains transitOptions (all itineraries, fastest first). fetchTransitRoute maps every itinerary (the API returns more than numItineraries). RouteDialog: - Alternatives row — tap to switch; a dedicated effect redraws the map from the selected itinerary, so switching never re-queries the service. - Leg-by-leg itinerary: coloured line badge, headsign, board/alight stop + clock times, platform track, N stops, operator, and a 'live' badge only when the feed is realtime. - Summary now reflects the selected option and shows the option count. - 'Whole route' re-fit follows the selection. Also clears the 2 pre-existing eslint warnings (unused StatusIcon/nip19) so the documented zero-warning baseline holds. Docs: AGENTS.md transit-detail note (incl. Transitous 403s a generic UA).
This commit is contained in:
parent
b6cc7797e6
commit
52ed2f89bd
5 changed files with 274 additions and 74 deletions
16
AGENTS.md
16
AGENTS.md
|
|
@ -338,6 +338,22 @@ session with **no other memory of this project**. So:
|
||||||
**< 2 min old**; the blocking "Finding your location…" overlay shows only when
|
**< 2 min old**; the blocking "Finding your location…" overlay shows only when
|
||||||
there is no seed (with a seed it refines silently via a corner chip). The map
|
there is no seed (with a seed it refines silently via a corner chip). The map
|
||||||
"Near Me" button also flips the sort to `proximity`.
|
"Near Me" button also flips the sort to `proximity`.
|
||||||
|
- **Transit detail (2026-10-01):** Transitous returns **more itineraries than
|
||||||
|
requested** (`numItineraries: 4` → we saw 9), so `fetchTransitRoute` now maps
|
||||||
|
them ALL into `RouteResult.transitOptions` (`transit` = index 0). `RouteDialog`
|
||||||
|
shows alternatives (tap to switch — a separate effect redraws the map, **no
|
||||||
|
re-query**) and a **leg-by-leg itinerary**: coloured line badge, headsign
|
||||||
|
(`→ S Strausberg Nord`), board/alight stop + clock times, platform `track`
|
||||||
|
when present, `N stops` (`intermediateStops.length + 1`), operator
|
||||||
|
(`agencyName`), and a **live** badge only when `realTime` is true. Mode →
|
||||||
|
icon/colour is MOTIS's own `mode` (`BUS` 🚌, `TRAM` 🚊, `METRO`/`SUBWAY` 🚇,
|
||||||
|
`RAIL*`/`REGIONAL_RAIL`… 🚆, `FERRY` ⛴️). ⚠️ MOTIS labels a German S-Bahn
|
||||||
|
`METRO` (verified live) — that's the feed's own classification; don't
|
||||||
|
"correct" it heuristically. ⚠️ Transitous **403s a generic User-Agent**
|
||||||
|
("Generic user-agent headers are not allowed") — the browser sends its own so
|
||||||
|
the app is fine, but a Node/curl probe must set a `User-Agent`. Still absent:
|
||||||
|
depart/arrive-by time picker, per-mode filters (bus/train), stop departure
|
||||||
|
boards, fares.
|
||||||
- ⚠️ **`RouteDialog` had a total-failure bug (fixed 2026-09-27), don't regress:**
|
- ⚠️ **`RouteDialog` had a total-failure bug (fixed 2026-09-27), don't regress:**
|
||||||
(1) its Leaflet map is inside Radix Dialog content, which mounts AFTER the
|
(1) its Leaflet map is inside Radix Dialog content, which mounts AFTER the
|
||||||
component's first effect pass, so a `mapDivRef.current` guard was null,
|
component's first effect pass, so a `mapDivRef.current` guard was null,
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,14 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle2,
|
|
||||||
Copy,
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
|
||||||
QrCode,
|
QrCode,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
Server,
|
Server,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Trash2,
|
Trash2,
|
||||||
XCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
@ -102,16 +98,6 @@ export function CoordinatorSettingsCard() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const meta = STATUS_META[status];
|
const meta = STATUS_META[status];
|
||||||
const StatusIcon =
|
|
||||||
status === 'checking'
|
|
||||||
? Loader2
|
|
||||||
: status === 'online'
|
|
||||||
? CheckCircle2
|
|
||||||
: status === 'stale'
|
|
||||||
? AlertTriangle
|
|
||||||
: status === 'idle'
|
|
||||||
? Activity
|
|
||||||
: XCircle;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
skateTime,
|
skateTime,
|
||||||
type TravelMode,
|
type TravelMode,
|
||||||
type RouteResult,
|
type RouteResult,
|
||||||
|
type TransitItinerary,
|
||||||
} from '@/lib/routing';
|
} from '@/lib/routing';
|
||||||
|
|
||||||
// ─── Transit presentation helpers ────────────────────────────────────────────
|
// ─── Transit presentation helpers ────────────────────────────────────────────
|
||||||
|
|
@ -79,6 +80,19 @@ function transitModeEmoji(mode: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Leading emoji for a whole itinerary — its first vehicle leg, else walking. */
|
||||||
|
function transitPrimaryEmoji(it: TransitItinerary): string {
|
||||||
|
const vehicle = it.legs.find((l) => l.mode !== 'WALK' && l.mode !== 'BIKE');
|
||||||
|
return transitModeEmoji(vehicle?.mode ?? 'WALK');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds between a leg's start and end (0 when either time is missing). */
|
||||||
|
function legDurationS(leg: TransitItinerary['legs'][number]): number {
|
||||||
|
if (!leg.startISO || !leg.endISO) return 0;
|
||||||
|
const ms = Date.parse(leg.endISO) - Date.parse(leg.startISO);
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? Math.round(ms / 1000) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
interface RouteDialogProps {
|
interface RouteDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
|
@ -228,12 +242,29 @@ export function RouteDialog({
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [locError, setLocError] = useState<string | null>(null);
|
const [locError, setLocError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Which transit alternative is selected (index into route.transitOptions).
|
||||||
|
// Reset to 0 whenever a new transit route is fetched.
|
||||||
|
const [transitIndex, setTransitIndex] = useState(0);
|
||||||
|
const selectedTransit = useMemo(() => {
|
||||||
|
if (mode !== 'transit') return null;
|
||||||
|
const opts = route?.transitOptions;
|
||||||
|
if (opts && opts.length > 0) return opts[Math.min(transitIndex, opts.length - 1)] ?? null;
|
||||||
|
return route?.transit ?? null;
|
||||||
|
}, [mode, route, transitIndex]);
|
||||||
|
|
||||||
|
// All alternatives (falling back to a single option for older results).
|
||||||
|
const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []);
|
||||||
|
|
||||||
/** Re-fit the map to the whole route (after tapping a step pans it away). */
|
/** Re-fit the map to the whole route (after tapping a step pans it away). */
|
||||||
const fitRoute = useCallback(() => {
|
const fitRoute = useCallback(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map || !route || route.coordinates.length < 2) return;
|
if (!map) return;
|
||||||
map.fitBounds(L.latLngBounds(route.coordinates), { padding: [48, 48], maxZoom: 16 });
|
const coords = selectedTransit
|
||||||
}, [route]);
|
? selectedTransit.legs.flatMap((l) => l.coordinates)
|
||||||
|
: route?.coordinates;
|
||||||
|
if (!coords || coords.length < 2) return;
|
||||||
|
map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16 });
|
||||||
|
}, [route, selectedTransit]);
|
||||||
|
|
||||||
// A new route (mode/origin change) invalidates the tapped-step focus and the
|
// A new route (mode/origin change) invalidates the tapped-step focus and the
|
||||||
// once-only voice/haptic announcements.
|
// once-only voice/haptic announcements.
|
||||||
|
|
@ -611,6 +642,7 @@ export function RouteDialog({
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setCachedAge(null);
|
setCachedAge(null);
|
||||||
setRoute(result);
|
setRoute(result);
|
||||||
|
setTransitIndex(0);
|
||||||
if (mode !== 'transit') saveRoute(dest, mode, result);
|
if (mode !== 'transit') saveRoute(dest, mode, result);
|
||||||
|
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
|
|
@ -619,24 +651,10 @@ export function RouteDialog({
|
||||||
routeLayersRef.current.remove();
|
routeLayersRef.current.remove();
|
||||||
routeLayersRef.current = null;
|
routeLayersRef.current = null;
|
||||||
}
|
}
|
||||||
const group = L.layerGroup().addTo(map);
|
// Transit legs are drawn by the effect just below, so switching
|
||||||
|
// between alternatives doesn't re-query the transit service.
|
||||||
if (result.transit) {
|
if (!result.transit) {
|
||||||
// Public transport: draw each leg — walking dashed grey, transit
|
const group = L.layerGroup().addTo(map);
|
||||||
// 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, {
|
L.polyline(result.coordinates, {
|
||||||
color: '#ccff00',
|
color: '#ccff00',
|
||||||
weight: 5,
|
weight: 5,
|
||||||
|
|
@ -644,11 +662,10 @@ export function RouteDialog({
|
||||||
lineJoin: 'round',
|
lineJoin: 'round',
|
||||||
lineCap: 'round',
|
lineCap: 'round',
|
||||||
}).addTo(group);
|
}).addTo(group);
|
||||||
}
|
routeLayersRef.current = group;
|
||||||
|
if (result.coordinates.length > 0) {
|
||||||
routeLayersRef.current = group;
|
map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
|
||||||
if (result.coordinates.length > 0) {
|
}
|
||||||
map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -705,6 +722,35 @@ export function RouteDialog({
|
||||||
};
|
};
|
||||||
}, [open, effectiveOrigin, dest, mode]);
|
}, [open, effectiveOrigin, dest, mode]);
|
||||||
|
|
||||||
|
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
||||||
|
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !selectedTransit) return;
|
||||||
|
if (routeLayersRef.current) {
|
||||||
|
routeLayersRef.current.remove();
|
||||||
|
routeLayersRef.current = null;
|
||||||
|
}
|
||||||
|
const group = L.layerGroup().addTo(map);
|
||||||
|
for (const leg of selectedTransit.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);
|
||||||
|
}
|
||||||
|
routeLayersRef.current = group;
|
||||||
|
const coords = selectedTransit.legs.flatMap((l) => l.coordinates);
|
||||||
|
if (coords.length > 0) {
|
||||||
|
map.fitBounds(L.latLngBounds(coords), { padding: [48, 48], maxZoom: 16, animate: false });
|
||||||
|
}
|
||||||
|
}, [selectedTransit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
{/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM
|
{/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM
|
||||||
|
|
@ -921,26 +967,27 @@ export function RouteDialog({
|
||||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
) : route?.transit ? (
|
) : mode === 'transit' && selectedTransit ? (
|
||||||
<div className="w-full space-y-2">
|
<div className="w-full space-y-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-2xl leading-none">🚇</div>
|
<div className="text-2xl leading-none">{transitPrimaryEmoji(selectedTransit)}</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-lg font-black tabular-nums">
|
<div className="text-lg font-black tabular-nums">
|
||||||
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)}
|
{formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDuration(route.transit.durationS)} ·{' '}
|
{formatDuration(selectedTransit.durationS)} ·{' '}
|
||||||
{route.transit.transfers === 0
|
{selectedTransit.transfers === 0
|
||||||
? 'direct'
|
? 'direct'
|
||||||
: `${route.transit.transfers} transfer${route.transit.transfers > 1 ? 's' : ''}`}
|
: `${selectedTransit.transfers} transfer${selectedTransit.transfers > 1 ? 's' : ''}`}
|
||||||
|
{options.length > 1 && ` · ${options.length} options`}
|
||||||
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
|
{routing && <Loader2 className="ml-1 inline h-3 w-3 animate-spin" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{route.transit.legs.some((l) => l.mode !== 'WALK') && (
|
{selectedTransit.legs.some((l) => l.mode !== 'WALK') && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{route.transit.legs
|
{selectedTransit.legs
|
||||||
.filter((l) => l.mode !== 'WALK')
|
.filter((l) => l.mode !== 'WALK')
|
||||||
.map((l, i) => (
|
.map((l, i) => (
|
||||||
<span
|
<span
|
||||||
|
|
@ -1062,6 +1109,120 @@ export function RouteDialog({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Transit itinerary: alternatives + a real leg-by-leg breakdown — all
|
||||||
|
from the same MOTIS response (nothing extra is fetched). */}
|
||||||
|
{!following && mode === 'transit' && selectedTransit && (
|
||||||
|
<div className="border-t bg-muted/20">
|
||||||
|
{options.length > 1 && (
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
|
||||||
|
{options.map((opt, i) => {
|
||||||
|
const active = i === transitIndex;
|
||||||
|
const modes = [...new Set(
|
||||||
|
opt.legs
|
||||||
|
.filter((l) => l.mode !== 'WALK' && l.mode !== 'BIKE')
|
||||||
|
.map((l) => transitModeEmoji(l.mode)),
|
||||||
|
)];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${opt.departISO}-${i}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTransitIndex(i)}
|
||||||
|
className={cn(
|
||||||
|
// `min-h-0` beats the app-wide 44px mobile touch-target rule.
|
||||||
|
'flex min-h-0 shrink-0 flex-col items-start gap-0.5 rounded-xl border px-2.5 py-1.5 text-left transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-[#ccff00]/60 bg-[#ccff00]/10'
|
||||||
|
: 'border-white/10 bg-white/[0.03] hover:border-white/25',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-black tabular-nums text-white">
|
||||||
|
{formatClock(opt.departISO)}–{formatClock(opt.arriveISO)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
<span aria-hidden>{modes.join('')}</span>
|
||||||
|
{formatDuration(opt.durationS)} ·{' '}
|
||||||
|
{opt.transfers === 0
|
||||||
|
? 'direct'
|
||||||
|
: `${opt.transfers} transfer${opt.transfers > 1 ? 's' : ''}`}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="flex items-center gap-2 px-4 pt-3 text-[10px] font-black uppercase tracking-widest text-muted-foreground">
|
||||||
|
Itinerary
|
||||||
|
<span className="rounded-full bg-white/10 px-1.5 py-0.5 text-[9px] tabular-nums">
|
||||||
|
{selectedTransit.legs.length}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<ol className="max-h-72 space-y-1 overflow-y-auto px-4 pb-2 pt-1.5">
|
||||||
|
{selectedTransit.legs.map((leg, i) => {
|
||||||
|
const isFoot = leg.mode === 'WALK' || leg.mode === 'BIKE';
|
||||||
|
const dur = legDurationS(leg);
|
||||||
|
const startClock = leg.startISO ? formatClock(leg.startISO) : '—';
|
||||||
|
const endClock = leg.endISO ? formatClock(leg.endISO) : '—';
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={`${leg.mode}-${leg.line}-${i}`}
|
||||||
|
className="flex min-h-0 items-start gap-2 rounded-lg px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="mt-px w-5 shrink-0 text-center text-base leading-none" aria-hidden>
|
||||||
|
{transitModeEmoji(leg.mode)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
|
||||||
|
{!isFoot && (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center rounded-md px-1.5 py-0.5 text-[11px] font-black text-black"
|
||||||
|
style={{ background: transitLegColor(leg.mode) }}
|
||||||
|
>
|
||||||
|
{leg.line ?? leg.mode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-bold text-white">
|
||||||
|
{isFoot
|
||||||
|
? leg.mode === 'BIKE' ? 'Ride' : 'Walk'
|
||||||
|
: leg.headsign
|
||||||
|
? `→ ${leg.headsign}`
|
||||||
|
: (leg.line ?? leg.mode)}
|
||||||
|
</span>
|
||||||
|
{dur > 0 && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">{formatDuration(dur)}</span>
|
||||||
|
)}
|
||||||
|
{leg.realTime && (
|
||||||
|
<span className="rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||||
|
live
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground">
|
||||||
|
{isFoot ? (
|
||||||
|
<>
|
||||||
|
{leg.toName ? `to ${leg.toName}` : leg.mode === 'BIKE' ? 'ride' : 'walk'}
|
||||||
|
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{leg.fromName}{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
||||||
|
{' → '}
|
||||||
|
{leg.toName}{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
|
||||||
|
{typeof leg.stops === 'number' && leg.stops > 0
|
||||||
|
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
|
||||||
|
: ''}
|
||||||
|
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>{/* /scrollable details */}
|
</div>{/* /scrollable details */}
|
||||||
|
|
||||||
{/* Follow mode: live turns + voice, with the screen kept awake. */}
|
{/* Follow mode: live turns + voice, with the screen kept awake. */}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react';
|
import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
|
||||||
|
|
@ -63,8 +63,13 @@ export interface RouteResult {
|
||||||
* distance/time are a driving approximation for the chosen mode.
|
* distance/time are a driving approximation for the chosen mode.
|
||||||
*/
|
*/
|
||||||
approximate?: boolean;
|
approximate?: boolean;
|
||||||
/** Present only for transit routes — the chosen public-transport itinerary. */
|
/** Present only for transit routes — the selected public-transport itinerary. */
|
||||||
transit?: TransitItinerary;
|
transit?: TransitItinerary;
|
||||||
|
/**
|
||||||
|
* Present only for transit routes — every planned option, fastest first.
|
||||||
|
* `transit` duplicates index 0. The dialog lets the user pick another.
|
||||||
|
*/
|
||||||
|
transitOptions?: TransitItinerary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One leg of a public-transport itinerary (walk, bus, subway, rail, …). */
|
/** One leg of a public-transport itinerary (walk, bus, subway, rail, …). */
|
||||||
|
|
@ -73,8 +78,21 @@ export interface TransitLeg {
|
||||||
mode: string;
|
mode: string;
|
||||||
/** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */
|
/** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */
|
||||||
line?: string;
|
line?: string;
|
||||||
|
/** Headsign / direction of travel, when the feed provides it. */
|
||||||
|
headsign?: string;
|
||||||
|
/** Operator name (e.g. "BVG"), when the feed provides it. */
|
||||||
|
agencyName?: string;
|
||||||
fromName?: string;
|
fromName?: string;
|
||||||
toName?: string;
|
toName?: string;
|
||||||
|
/** Boarding / alighting platform or track, when the feed provides it. */
|
||||||
|
fromTrack?: string;
|
||||||
|
toTrack?: string;
|
||||||
|
/** Rough "N stops" cue (intermediate stops + the alighting stop). */
|
||||||
|
stops?: number;
|
||||||
|
/** True when the leg carries live/realtime data (vs. a pure timetable). */
|
||||||
|
realTime?: boolean;
|
||||||
|
/** Length of this leg in metres. */
|
||||||
|
distanceM?: number;
|
||||||
startISO?: string;
|
startISO?: string;
|
||||||
endISO?: string;
|
endISO?: string;
|
||||||
/** Decoded [lat, lng] path for this leg. */
|
/** Decoded [lat, lng] path for this leg. */
|
||||||
|
|
@ -327,11 +345,15 @@ interface MotisLeg {
|
||||||
mode?: string;
|
mode?: string;
|
||||||
routeShortName?: string;
|
routeShortName?: string;
|
||||||
routeLongName?: string;
|
routeLongName?: string;
|
||||||
|
agencyName?: string;
|
||||||
|
headsign?: string;
|
||||||
|
realTime?: boolean;
|
||||||
distance?: number;
|
distance?: number;
|
||||||
startTime?: string;
|
startTime?: string;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
from?: { name?: string };
|
from?: { name?: string; track?: string };
|
||||||
to?: { name?: string };
|
to?: { name?: string; track?: string };
|
||||||
|
intermediateStops?: unknown[];
|
||||||
legGeometry?: { points?: string; precision?: number };
|
legGeometry?: { points?: string; precision?: number };
|
||||||
}
|
}
|
||||||
interface MotisItinerary {
|
interface MotisItinerary {
|
||||||
|
|
@ -364,34 +386,50 @@ export async function fetchTransitRoute(
|
||||||
|
|
||||||
const data = (await res.json()) as { itineraries?: MotisItinerary[] };
|
const data = (await res.json()) as { itineraries?: MotisItinerary[] };
|
||||||
const itineraries = Array.isArray(data.itineraries) ? data.itineraries : [];
|
const itineraries = Array.isArray(data.itineraries) ? data.itineraries : [];
|
||||||
const it = itineraries[0];
|
if (itineraries.length === 0) throw new Error('No transit route found');
|
||||||
if (!it) throw new Error('No transit route found');
|
|
||||||
|
|
||||||
const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({
|
const mapItinerary = (it: MotisItinerary): TransitItinerary => {
|
||||||
mode: (leg.mode ?? '').toUpperCase(),
|
const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({
|
||||||
line: leg.routeShortName || leg.routeLongName,
|
mode: (leg.mode ?? '').toUpperCase(),
|
||||||
fromName: leg.from?.name,
|
line: leg.routeShortName || leg.routeLongName,
|
||||||
toName: leg.to?.name,
|
headsign: leg.headsign,
|
||||||
startISO: leg.startTime,
|
agencyName: leg.agencyName,
|
||||||
endISO: leg.endTime,
|
fromName: leg.from?.name,
|
||||||
coordinates:
|
toName: leg.to?.name,
|
||||||
leg.legGeometry?.points
|
fromTrack: leg.from?.track,
|
||||||
? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7)
|
toTrack: leg.to?.track,
|
||||||
: [],
|
stops:
|
||||||
}));
|
Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0
|
||||||
|
? leg.intermediateStops.length + 1
|
||||||
return {
|
: undefined,
|
||||||
coordinates: legs.flatMap((l) => l.coordinates),
|
realTime: leg.realTime === true,
|
||||||
distanceM: (it.legs ?? []).reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
distanceM: leg.distance,
|
||||||
durationS: it.duration ?? 0,
|
startISO: leg.startTime,
|
||||||
mode: 'transit',
|
endISO: leg.endTime,
|
||||||
transit: {
|
coordinates:
|
||||||
|
leg.legGeometry?.points
|
||||||
|
? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7)
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
return {
|
||||||
departISO: it.startTime ?? '',
|
departISO: it.startTime ?? '',
|
||||||
arriveISO: it.endTime ?? '',
|
arriveISO: it.endTime ?? '',
|
||||||
durationS: it.duration ?? 0,
|
durationS: it.duration ?? 0,
|
||||||
transfers: it.transfers ?? 0,
|
transfers: it.transfers ?? 0,
|
||||||
legs,
|
legs,
|
||||||
},
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = itineraries.map(mapItinerary);
|
||||||
|
const first = options[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
coordinates: first.legs.flatMap((l) => l.coordinates),
|
||||||
|
distanceM: (itineraries[0].legs ?? []).reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||||
|
durationS: first.durationS,
|
||||||
|
mode: 'transit',
|
||||||
|
transit: first,
|
||||||
|
transitOptions: options,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue