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
|
||||
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`.
|
||||
- **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:**
|
||||
(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,
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
ScanLine,
|
||||
Server,
|
||||
Smartphone,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
|
@ -102,16 +98,6 @@ export function CoordinatorSettingsCard() {
|
|||
};
|
||||
|
||||
const meta = STATUS_META[status];
|
||||
const StatusIcon =
|
||||
status === 'checking'
|
||||
? Loader2
|
||||
: status === 'online'
|
||||
? CheckCircle2
|
||||
: status === 'stale'
|
||||
? AlertTriangle
|
||||
: status === 'idle'
|
||||
? Activity
|
||||
: XCircle;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
skateTime,
|
||||
type TravelMode,
|
||||
type RouteResult,
|
||||
type TransitItinerary,
|
||||
} from '@/lib/routing';
|
||||
|
||||
// ─── 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 {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -228,12 +242,29 @@ export function RouteDialog({
|
|||
const [error, setError] = 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). */
|
||||
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]);
|
||||
if (!map) return;
|
||||
const coords = selectedTransit
|
||||
? 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
|
||||
// once-only voice/haptic announcements.
|
||||
|
|
@ -611,6 +642,7 @@ export function RouteDialog({
|
|||
if (cancelled) return;
|
||||
setCachedAge(null);
|
||||
setRoute(result);
|
||||
setTransitIndex(0);
|
||||
if (mode !== 'transit') saveRoute(dest, mode, result);
|
||||
|
||||
const map = mapRef.current;
|
||||
|
|
@ -619,24 +651,10 @@ export function RouteDialog({
|
|||
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 {
|
||||
// Transit legs are drawn by the effect just below, so switching
|
||||
// between alternatives doesn't re-query the transit service.
|
||||
if (!result.transit) {
|
||||
const group = L.layerGroup().addTo(map);
|
||||
L.polyline(result.coordinates, {
|
||||
color: '#ccff00',
|
||||
weight: 5,
|
||||
|
|
@ -644,11 +662,10 @@ export function RouteDialog({
|
|||
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, animate: false });
|
||||
routeLayersRef.current = group;
|
||||
if (result.coordinates.length > 0) {
|
||||
map.fitBounds(L.latLngBounds(result.coordinates), { padding: [48, 48], maxZoom: 16, animate: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -705,6 +722,35 @@ export function RouteDialog({
|
|||
};
|
||||
}, [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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* 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" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : route?.transit ? (
|
||||
) : mode === 'transit' && selectedTransit ? (
|
||||
<div className="w-full space-y-2">
|
||||
<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="text-lg font-black tabular-nums">
|
||||
{formatClock(route.transit.departISO)} → {formatClock(route.transit.arriveISO)}
|
||||
{formatClock(selectedTransit.departISO)} → {formatClock(selectedTransit.arriveISO)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDuration(route.transit.durationS)} ·{' '}
|
||||
{route.transit.transfers === 0
|
||||
{formatDuration(selectedTransit.durationS)} ·{' '}
|
||||
{selectedTransit.transfers === 0
|
||||
? '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" />}
|
||||
</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">
|
||||
{route.transit.legs
|
||||
{selectedTransit.legs
|
||||
.filter((l) => l.mode !== 'WALK')
|
||||
.map((l, i) => (
|
||||
<span
|
||||
|
|
@ -1062,6 +1109,120 @@ export function RouteDialog({
|
|||
</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 */}
|
||||
|
||||
{/* Follow mode: live turns + voice, with the screen kept awake. */}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { ArrowLeftRight, Handshake, BadgeCheck, CheckCircle2, XCircle, Undo2 } from 'lucide-react';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
|
|
|
|||
|
|
@ -63,8 +63,13 @@ export interface RouteResult {
|
|||
* distance/time are a driving approximation for the chosen mode.
|
||||
*/
|
||||
approximate?: boolean;
|
||||
/** Present only for transit routes — the chosen public-transport itinerary. */
|
||||
/** Present only for transit routes — the selected public-transport itinerary. */
|
||||
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, …). */
|
||||
|
|
@ -73,8 +78,21 @@ export interface TransitLeg {
|
|||
mode: string;
|
||||
/** Short line name when the leg is on a vehicle (e.g. "U9", "S7"). */
|
||||
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;
|
||||
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;
|
||||
endISO?: string;
|
||||
/** Decoded [lat, lng] path for this leg. */
|
||||
|
|
@ -327,11 +345,15 @@ interface MotisLeg {
|
|||
mode?: string;
|
||||
routeShortName?: string;
|
||||
routeLongName?: string;
|
||||
agencyName?: string;
|
||||
headsign?: string;
|
||||
realTime?: boolean;
|
||||
distance?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
from?: { name?: string };
|
||||
to?: { name?: string };
|
||||
from?: { name?: string; track?: string };
|
||||
to?: { name?: string; track?: string };
|
||||
intermediateStops?: unknown[];
|
||||
legGeometry?: { points?: string; precision?: number };
|
||||
}
|
||||
interface MotisItinerary {
|
||||
|
|
@ -364,34 +386,50 @@ export async function fetchTransitRoute(
|
|||
|
||||
const data = (await res.json()) as { itineraries?: MotisItinerary[] };
|
||||
const itineraries = Array.isArray(data.itineraries) ? data.itineraries : [];
|
||||
const it = itineraries[0];
|
||||
if (!it) throw new Error('No transit route found');
|
||||
if (itineraries.length === 0) throw new Error('No transit route found');
|
||||
|
||||
const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({
|
||||
mode: (leg.mode ?? '').toUpperCase(),
|
||||
line: leg.routeShortName || leg.routeLongName,
|
||||
fromName: leg.from?.name,
|
||||
toName: leg.to?.name,
|
||||
startISO: leg.startTime,
|
||||
endISO: leg.endTime,
|
||||
coordinates:
|
||||
leg.legGeometry?.points
|
||||
? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7)
|
||||
: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
coordinates: legs.flatMap((l) => l.coordinates),
|
||||
distanceM: (it.legs ?? []).reduce((sum, l) => sum + (l.distance ?? 0), 0),
|
||||
durationS: it.duration ?? 0,
|
||||
mode: 'transit',
|
||||
transit: {
|
||||
const mapItinerary = (it: MotisItinerary): TransitItinerary => {
|
||||
const legs: TransitLeg[] = (it.legs ?? []).map((leg) => ({
|
||||
mode: (leg.mode ?? '').toUpperCase(),
|
||||
line: leg.routeShortName || leg.routeLongName,
|
||||
headsign: leg.headsign,
|
||||
agencyName: leg.agencyName,
|
||||
fromName: leg.from?.name,
|
||||
toName: leg.to?.name,
|
||||
fromTrack: leg.from?.track,
|
||||
toTrack: leg.to?.track,
|
||||
stops:
|
||||
Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0
|
||||
? leg.intermediateStops.length + 1
|
||||
: undefined,
|
||||
realTime: leg.realTime === true,
|
||||
distanceM: leg.distance,
|
||||
startISO: leg.startTime,
|
||||
endISO: leg.endTime,
|
||||
coordinates:
|
||||
leg.legGeometry?.points
|
||||
? decodePolyline(leg.legGeometry.points, leg.legGeometry.precision ?? 7)
|
||||
: [],
|
||||
}));
|
||||
return {
|
||||
departISO: it.startTime ?? '',
|
||||
arriveISO: it.endTime ?? '',
|
||||
durationS: it.duration ?? 0,
|
||||
transfers: it.transfers ?? 0,
|
||||
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