feat(transit): depart/arrive time picker, mode filters, and stop boards
Builds out the rest of the transit UX from data Transitous actually provides. - Time picker: Leave now / Depart at / Arrive by (MOTIS time + arriveBy). - Mode filter: Bus/Tram/Metro/Train/Ferry groups -> MOTIS transitModes. - Stop departure boards: tap a stop name -> MOTIS stoptimes, with live and cancelled badges and a feed-provided platform hint. - Fares: MOTIS carries NO prices (verified live: debugOutput.fares=0, agencyFareUrl empty), so a 'tickets' link is shown ONLY when the feed supplies an operator fare URL - never an invented price. New src/lib/transitUi.ts holds the mode vocabulary (emoji/colour/groups) so the itinerary and the board cannot drift; StopDepartures.tsx is its own dialog. Docs: AGENTS.md transit note.
This commit is contained in:
parent
126eb38330
commit
5fff51c814
5 changed files with 503 additions and 67 deletions
20
AGENTS.md
20
AGENTS.md
|
|
@ -351,9 +351,23 @@ session with **no other memory of this project**. So:
|
|||
`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.
|
||||
the app is fine, but a Node/curl probe must set a `User-Agent`.
|
||||
**Time picker + mode filter + stop boards (2026-10-01):** `fetchTransitRoute`
|
||||
takes `{ time, arriveBy, transitModes }` (MOTIS `time`/`arriveBy`/`transitModes`);
|
||||
the dialog exposes **Leave now · Depart at · Arrive by** (a `datetime-local`
|
||||
input) and an **Only** row of mode toggles (nothing selected = all modes).
|
||||
Mode groups live in **`src/lib/transitUi.ts`** (`TRANSIT_MODE_GROUPS`) with the
|
||||
shared mode→emoji/colour/label helpers — same module the board uses, so the
|
||||
vocabulary can't drift. Tapping a **stop name** in the itinerary opens
|
||||
**`StopDepartures.tsx`** (`fetchStopDepartures` → MOTIS `/api/v1/stoptimes`,
|
||||
`n=10`), showing clock, mode, line, headsign, a feed-provided platform hint
|
||||
(`place.description`, e.g. "S-Bahnsteig Gleis 3"), and **live/cancelled**
|
||||
badges. It is rendered *outside* the route dialog (a nested focus trap would
|
||||
fight). ⚠️ **Fares do not exist in this data — do NOT invent prices:**
|
||||
verified live that the plan response has no fare field (`debugOutput.fares: 0`,
|
||||
`agencyFareUrl: ""`). The only honest fare UI is a `tickets ↗` link to
|
||||
`agencyFareUrl` **when the feed supplies one** (usually empty). Still absent:
|
||||
nothing planned.
|
||||
- ⚠️ **`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,
|
||||
|
|
|
|||
|
|
@ -35,62 +35,37 @@ import {
|
|||
skateTime,
|
||||
type TravelMode,
|
||||
type RouteResult,
|
||||
type TransitItinerary,
|
||||
type TransitPlanOptions,
|
||||
} from '@/lib/routing';
|
||||
import {
|
||||
TRANSIT_MODE_GROUPS,
|
||||
legDurationS,
|
||||
transitLegColor,
|
||||
transitModeEmoji,
|
||||
transitPrimaryEmoji,
|
||||
} from '@/lib/transitUi';
|
||||
import { StopDepartures } from '@/components/StopDepartures';
|
||||
|
||||
// ─── 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 '🚇';
|
||||
}
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** A stop name that opens the departure board, when the feed gave a stop id. */
|
||||
function StopButton({
|
||||
id,
|
||||
name,
|
||||
onOpen,
|
||||
}: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
onOpen: (id: string, name: string) => void;
|
||||
}) {
|
||||
if (!id || !name) return <>{name ?? ''}</>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(id, name)}
|
||||
className="underline decoration-dotted underline-offset-2 transition-colors hover:text-foreground"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface RouteDialogProps {
|
||||
|
|
@ -255,6 +230,21 @@ export function RouteDialog({
|
|||
// All alternatives (falling back to a single option for older results).
|
||||
const options = route?.transitOptions ?? (route?.transit ? [route.transit] : []);
|
||||
|
||||
// Transit plan controls: leave-now vs depart-at/arrive-by, and a mode filter.
|
||||
const [transitTimeMode, setTransitTimeMode] = useState<'now' | 'depart' | 'arrive'>('now');
|
||||
const [transitTimeValue, setTransitTimeValue] = useState('');
|
||||
const [transitModeFilter, setTransitModeFilter] = useState<string[]>([]);
|
||||
const [stopBoard, setStopBoard] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const selectedTransitModes = useMemo(
|
||||
() =>
|
||||
TRANSIT_MODE_GROUPS.filter((g) => transitModeFilter.includes(g.key)).flatMap((g) => [...g.modes]),
|
||||
[transitModeFilter],
|
||||
);
|
||||
|
||||
const toggleModeGroup = (key: string) =>
|
||||
setTransitModeFilter((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
|
||||
/** Re-fit the map to the whole route (after tapping a step pans it away). */
|
||||
const fitRoute = useCallback(() => {
|
||||
const map = mapRef.current;
|
||||
|
|
@ -636,8 +626,19 @@ export function RouteDialog({
|
|||
setRouting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const planOpts: TransitPlanOptions = {};
|
||||
if (mode === 'transit') {
|
||||
if (transitTimeMode !== 'now' && transitTimeValue) {
|
||||
const when = new Date(transitTimeValue);
|
||||
if (!Number.isNaN(when.getTime())) {
|
||||
planOpts.time = when.toISOString();
|
||||
planOpts.arriveBy = transitTimeMode === 'arrive';
|
||||
}
|
||||
}
|
||||
if (selectedTransitModes.length > 0) planOpts.transitModes = selectedTransitModes;
|
||||
}
|
||||
const result = mode === 'transit'
|
||||
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal)
|
||||
? await fetchTransitRoute(effectiveOrigin, dest, controller.signal, planOpts)
|
||||
: await fetchRoute(effectiveOrigin, dest, mode, controller.signal);
|
||||
if (cancelled) return;
|
||||
setCachedAge(null);
|
||||
|
|
@ -675,7 +676,9 @@ export function RouteDialog({
|
|||
if (mode === 'transit') {
|
||||
setError(
|
||||
msg === 'No transit route found'
|
||||
? 'No public-transport route found here — this area may not publish open timetables.'
|
||||
? selectedTransitModes.length > 0 || transitTimeMode !== 'now'
|
||||
? 'No route for that time or transport mode — try another time, or include more modes.'
|
||||
: '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) {
|
||||
|
|
@ -720,7 +723,7 @@ export function RouteDialog({
|
|||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, effectiveOrigin, dest, mode]);
|
||||
}, [open, effectiveOrigin, dest, mode, transitTimeMode, transitTimeValue, selectedTransitModes]);
|
||||
|
||||
// ─── Draw the selected transit itinerary (redraws when an alternative is
|
||||
// chosen — no re-query). Walking legs dashed grey, vehicles solid. ──────
|
||||
|
|
@ -752,6 +755,7 @@ export function RouteDialog({
|
|||
}, [selectedTransit]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* App-like shell. VISUAL ORDER is set with flex `order` utilities, not DOM
|
||||
order: the map sits directly under the header (bigger — it's the point
|
||||
|
|
@ -1113,6 +1117,69 @@ export function RouteDialog({
|
|||
from the same MOTIS response (nothing extra is fetched). */}
|
||||
{!following && mode === 'transit' && selectedTransit && (
|
||||
<div className="border-t bg-muted/20">
|
||||
{/* When: leave now · depart at · arrive by */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-3">
|
||||
{(['now', 'depart', 'arrive'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setTransitTimeMode(m)}
|
||||
aria-pressed={transitTimeMode === m}
|
||||
className={cn(
|
||||
'min-h-0 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-colors',
|
||||
transitTimeMode === m
|
||||
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
|
||||
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||||
)}
|
||||
>
|
||||
{m === 'now' ? 'Leave now' : m === 'depart' ? 'Depart at' : 'Arrive by'}
|
||||
</button>
|
||||
))}
|
||||
{transitTimeMode !== 'now' && (
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={transitTimeValue}
|
||||
onChange={(e) => setTransitTimeValue(e.target.value)}
|
||||
aria-label={transitTimeMode === 'depart' ? 'Departure time' : 'Arrival time'}
|
||||
className="min-h-0 rounded-lg border border-white/15 bg-white/[0.04] px-2 py-1 text-[11px] text-white"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Only: filter the modes (nothing selected = all) */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-4 pt-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-muted-foreground">Only</span>
|
||||
{TRANSIT_MODE_GROUPS.map((g) => {
|
||||
const on = transitModeFilter.includes(g.key);
|
||||
return (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
onClick={() => toggleModeGroup(g.key)}
|
||||
aria-pressed={on}
|
||||
className={cn(
|
||||
'flex min-h-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold transition-colors',
|
||||
on
|
||||
? 'border-[#ccff00]/60 bg-[#ccff00]/10 text-white'
|
||||
: 'border-white/10 text-gray-400 hover:border-white/25',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{g.emoji}</span>
|
||||
{g.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{transitModeFilter.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTransitModeFilter([])}
|
||||
className="min-h-0 rounded-full px-2 py-0.5 text-[10px] font-bold text-muted-foreground underline decoration-dotted hover:text-foreground"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{options.length > 1 && (
|
||||
<div className="flex gap-1.5 overflow-x-auto px-4 pt-3">
|
||||
{options.map((opt, i) => {
|
||||
|
|
@ -1200,18 +1267,50 @@ export function RouteDialog({
|
|||
<div className="text-[10px] text-muted-foreground">
|
||||
{isFoot ? (
|
||||
<>
|
||||
{leg.toName ? `to ${leg.toName}` : leg.mode === 'BIKE' ? 'ride' : 'walk'}
|
||||
{leg.toName ? (
|
||||
<>
|
||||
to{' '}
|
||||
<StopButton
|
||||
id={leg.toStopId}
|
||||
name={leg.toName}
|
||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||
/>
|
||||
</>
|
||||
) : leg.mode === 'BIKE' ? 'ride' : 'walk'}
|
||||
{leg.distanceM ? ` · ${formatDistance(leg.distanceM / 1000)}` : ''}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{leg.fromName}{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
||||
<StopButton
|
||||
id={leg.fromStopId}
|
||||
name={leg.fromName}
|
||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||
/>
|
||||
{leg.fromTrack ? ` · track ${leg.fromTrack}` : ''} {startClock}
|
||||
{' → '}
|
||||
{leg.toName}{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
|
||||
<StopButton
|
||||
id={leg.toStopId}
|
||||
name={leg.toName}
|
||||
onOpen={(id, name) => setStopBoard({ id, name })}
|
||||
/>
|
||||
{leg.toTrack ? ` · track ${leg.toTrack}` : ''} {endClock}
|
||||
{typeof leg.stops === 'number' && leg.stops > 0
|
||||
? ` · ${leg.stops} stop${leg.stops > 1 ? 's' : ''}`
|
||||
: ''}
|
||||
{leg.agencyName ? ` · ${leg.agencyName}` : ''}
|
||||
{leg.agencyFareUrl ? (
|
||||
<>
|
||||
{' · '}
|
||||
<a
|
||||
href={leg.agencyFareUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-foreground"
|
||||
>
|
||||
tickets ↗
|
||||
</a>
|
||||
</>
|
||||
) : ''}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1285,5 +1384,15 @@ export function RouteDialog({
|
|||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Departure board — its own dialog, kept OUTSIDE the route dialog so the
|
||||
two focus traps don't fight. */}
|
||||
<StopDepartures
|
||||
open={!!stopBoard}
|
||||
onOpenChange={(o) => { if (!o) setStopBoard(null); }}
|
||||
stopId={stopBoard?.id ?? null}
|
||||
stopName={stopBoard?.name ?? ''}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
142
src/components/StopDepartures.tsx
Normal file
142
src/components/StopDepartures.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
fetchStopDepartures,
|
||||
formatClock,
|
||||
TRANSIT_ATTRIBUTION,
|
||||
type StopDeparture,
|
||||
} from '@/lib/routing';
|
||||
import { transitLegColor, transitModeEmoji } from '@/lib/transitUi';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StopDeparturesProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
stopId: string | null;
|
||||
stopName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Next departures from a stop — the board behind a tap on a stop in the
|
||||
* itinerary. Live data (MOTIS `realTime`) is badged; the board is real
|
||||
* schedule data, never invented, and says so when nothing comes back.
|
||||
*/
|
||||
export function StopDepartures({ open, onOpenChange, stopId, stopName }: StopDeparturesProps) {
|
||||
const [rows, setRows] = useState<StopDeparture[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !stopId) return;
|
||||
const controller = new AbortController();
|
||||
let cancelled = false;
|
||||
setRows(null);
|
||||
setError(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const departures = await fetchStopDepartures(stopId, { n: 10 }, controller.signal);
|
||||
if (!cancelled) setRows(departures);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setError("Couldn't reach the transit service. Check your connection and try again.");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, stopId, reloadKey]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80dvh] w-[calc(100%-2rem)] max-w-md flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b px-4 py-3">
|
||||
<DialogTitle className="text-base">
|
||||
<span className="block text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
Departures
|
||||
</span>
|
||||
<span className="block truncate">{stopName}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Upcoming departures from {stopName}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<div className="p-4 text-sm text-amber-500">{error}</div>
|
||||
) : !rows ? (
|
||||
<div className="flex items-center justify-center gap-2 p-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Loading departures…
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
No upcoming departures found at this stop.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{rows.map((d, i) => (
|
||||
<li key={`${d.line}-${d.departISO}-${i}`} className="flex items-center gap-2.5 px-4 py-2.5">
|
||||
<span className="w-12 shrink-0 text-right text-sm font-black tabular-nums text-white">
|
||||
{d.departISO ? formatClock(d.departISO) : '—'}
|
||||
</span>
|
||||
<span aria-hidden className="text-lg leading-none">{transitModeEmoji(d.mode)}</span>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center rounded-md px-1.5 py-0.5 text-[11px] font-black text-black"
|
||||
style={{ background: transitLegColor(d.mode) }}
|
||||
>
|
||||
{d.line ?? d.mode}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate text-xs font-bold text-white',
|
||||
d.cancelled && 'line-through opacity-60',
|
||||
)}
|
||||
>
|
||||
{d.headsign ?? '—'}
|
||||
</span>
|
||||
{d.platform && (
|
||||
<span className="block truncate text-[10px] text-muted-foreground">{d.platform}</span>
|
||||
)}
|
||||
</span>
|
||||
{d.realTime && !d.cancelled && (
|
||||
<span className="shrink-0 rounded bg-[#00ff88]/15 px-1 text-[9px] font-black uppercase text-[#00ff88]">
|
||||
live
|
||||
</span>
|
||||
)}
|
||||
{d.cancelled && (
|
||||
<span className="shrink-0 rounded bg-red-500/15 px-1 text-[9px] font-black uppercase text-red-400">
|
||||
cancelled
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||
<p className="text-[10px] text-muted-foreground/70">{TRANSIT_ATTRIBUTION}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadKey((k) => k + 1)}
|
||||
className="flex min-h-0 items-center gap-1 rounded-full border border-white/10 px-2 py-1 text-[10px] font-bold uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -82,11 +82,17 @@ export interface TransitLeg {
|
|||
headsign?: string;
|
||||
/** Operator name (e.g. "BVG"), when the feed provides it. */
|
||||
agencyName?: string;
|
||||
/** Operator ticket/fare page, when the feed provides one. Often absent —
|
||||
* MOTIS carries no prices, so we never invent one (see below). */
|
||||
agencyFareUrl?: string;
|
||||
fromName?: string;
|
||||
toName?: string;
|
||||
/** Boarding / alighting platform or track, when the feed provides it. */
|
||||
fromTrack?: string;
|
||||
toTrack?: string;
|
||||
/** Exact MOTIS stop ids — used to open the departure board for a stop. */
|
||||
fromStopId?: string;
|
||||
toStopId?: string;
|
||||
/** Rough "N stops" cue (intermediate stops + the alighting stop). */
|
||||
stops?: number;
|
||||
/** True when the leg carries live/realtime data (vs. a pure timetable). */
|
||||
|
|
@ -108,6 +114,28 @@ export interface TransitItinerary {
|
|||
legs: TransitLeg[];
|
||||
}
|
||||
|
||||
/** Options for a transit plan (time picker + mode filter). */
|
||||
export interface TransitPlanOptions {
|
||||
/** ISO departure time — or the latest arrival time when `arriveBy`. */
|
||||
time?: string;
|
||||
/** When true, `time` is the latest acceptable arrival instead of departure. */
|
||||
arriveBy?: boolean;
|
||||
/** MOTIS mode whitelist, e.g. `['BUS','TRAM']`. Empty = all modes. */
|
||||
transitModes?: string[];
|
||||
}
|
||||
|
||||
/** One upcoming departure at a stop (the stop departure board). */
|
||||
export interface StopDeparture {
|
||||
mode: string;
|
||||
line?: string;
|
||||
headsign?: string;
|
||||
/** Feed-provided platform hint (e.g. "S-Bahnsteig Gleis 3"), if any. */
|
||||
platform?: string;
|
||||
departISO?: string;
|
||||
realTime?: boolean;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
/** Attribution required by Transitous / OpenStreetMap. */
|
||||
export const TRANSIT_ATTRIBUTION = 'Transit: Transitous · OpenStreetMap contributors';
|
||||
|
||||
|
|
@ -304,7 +332,7 @@ export async function fetchRoute(
|
|||
// ─── Public transport (Transitous / MOTIS) ───────────────────────────────────
|
||||
|
||||
const TRANSITOUS_PLAN = 'https://api.transitous.org/api/v1/plan';
|
||||
|
||||
const TRANSITOUS_STOPTIMES = 'https://api.transitous.org/api/v1/stoptimes';
|
||||
/**
|
||||
* Decode an encoded polyline (Google/OSRM format) into [lat, lng] pairs.
|
||||
* MOTIS emits these with `precision: 7` (1e7). Standard decoder — kept local
|
||||
|
|
@ -346,16 +374,26 @@ interface MotisLeg {
|
|||
routeShortName?: string;
|
||||
routeLongName?: string;
|
||||
agencyName?: string;
|
||||
agencyFareUrl?: string;
|
||||
headsign?: string;
|
||||
realTime?: boolean;
|
||||
distance?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
from?: { name?: string; track?: string };
|
||||
to?: { name?: string; track?: string };
|
||||
from?: { name?: string; track?: string; stopId?: string };
|
||||
to?: { name?: string; track?: string; stopId?: string };
|
||||
intermediateStops?: unknown[];
|
||||
legGeometry?: { points?: string; precision?: number };
|
||||
}
|
||||
interface MotisStopTime {
|
||||
mode?: string;
|
||||
routeShortName?: string;
|
||||
routeLongName?: string;
|
||||
headsign?: string;
|
||||
realTime?: boolean;
|
||||
cancelled?: boolean;
|
||||
place?: { departure?: string; cancelled?: boolean; description?: string };
|
||||
}
|
||||
interface MotisItinerary {
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
|
|
@ -373,14 +411,19 @@ export async function fetchTransitRoute(
|
|||
from: { lat: number; lng: number },
|
||||
to: { lat: number; lng: number },
|
||||
signal?: AbortSignal,
|
||||
opts: TransitPlanOptions = {},
|
||||
): Promise<RouteResult> {
|
||||
const params = new URLSearchParams({
|
||||
fromPlace: `${from.lat},${from.lng}`,
|
||||
toPlace: `${to.lat},${to.lng}`,
|
||||
numItineraries: '4',
|
||||
// ISO in UTC (JS always emits the Gregorian calendar — unlike the shell).
|
||||
time: new Date().toISOString(),
|
||||
time: opts.time ?? new Date().toISOString(),
|
||||
});
|
||||
if (opts.arriveBy) params.set('arriveBy', 'true');
|
||||
if (opts.transitModes && opts.transitModes.length > 0) {
|
||||
params.set('transitModes', opts.transitModes.join(','));
|
||||
}
|
||||
const res = await fetch(`${TRANSITOUS_PLAN}?${params.toString()}`, { signal });
|
||||
if (!res.ok) throw new Error(`Transit service returned ${res.status}`);
|
||||
|
||||
|
|
@ -394,10 +437,13 @@ export async function fetchTransitRoute(
|
|||
line: leg.routeShortName || leg.routeLongName,
|
||||
headsign: leg.headsign,
|
||||
agencyName: leg.agencyName,
|
||||
agencyFareUrl: leg.agencyFareUrl,
|
||||
fromName: leg.from?.name,
|
||||
toName: leg.to?.name,
|
||||
fromTrack: leg.from?.track,
|
||||
toTrack: leg.to?.track,
|
||||
fromStopId: leg.from?.stopId,
|
||||
toStopId: leg.to?.stopId,
|
||||
stops:
|
||||
Array.isArray(leg.intermediateStops) && leg.intermediateStops.length > 0
|
||||
? leg.intermediateStops.length + 1
|
||||
|
|
@ -433,6 +479,36 @@ export async function fetchTransitRoute(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upcoming departures from a stop (Transitous/MOTIS `stoptimes`) — the
|
||||
* departure board behind a tap on a stop in the itinerary. Throws on a non-OK
|
||||
* response so the caller can show an honest "can't reach transit" state.
|
||||
*/
|
||||
export async function fetchStopDepartures(
|
||||
stopId: string,
|
||||
opts: { n?: number; time?: string } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<StopDeparture[]> {
|
||||
const params = new URLSearchParams({
|
||||
stopId,
|
||||
n: String(opts.n ?? 8),
|
||||
time: opts.time ?? new Date().toISOString(),
|
||||
});
|
||||
const res = await fetch(`${TRANSITOUS_STOPTIMES}?${params.toString()}`, { signal });
|
||||
if (!res.ok) throw new Error(`Transit service returned ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as { stopTimes?: MotisStopTime[] };
|
||||
return (data.stopTimes ?? []).map((s) => ({
|
||||
mode: (s.mode ?? '').toUpperCase(),
|
||||
line: s.routeShortName || s.routeLongName,
|
||||
headsign: s.headsign,
|
||||
platform: s.place?.description,
|
||||
departISO: s.place?.departure,
|
||||
realTime: s.realTime === true,
|
||||
cancelled: s.cancelled === true || s.place?.cancelled === true,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Format an ISO timestamp as a local clock time, e.g. "08:39". */
|
||||
export function formatClock(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
|
|
|
|||
95
src/lib/transitUi.ts
Normal file
95
src/lib/transitUi.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* Transit presentation helpers
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Shared by RouteDialog (the itinerary) and StopDepartures (the departure
|
||||
* board), so the mode → icon/colour vocabulary lives in exactly one place.
|
||||
* Modes are MOTIS's own `mode` values.
|
||||
*/
|
||||
|
||||
import type { TransitItinerary, TransitLeg } from './routing';
|
||||
|
||||
/** Line colour for a transit leg, keyed by the MOTIS mode. */
|
||||
export function transitLegColor(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'BUS':
|
||||
case 'COACH': 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. */
|
||||
export function transitModeEmoji(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'BUS':
|
||||
case 'COACH': 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 '🚇';
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading emoji for a whole itinerary — its first vehicle leg, else walking. */
|
||||
export 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). */
|
||||
export function legDurationS(leg: TransitLeg): 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transit mode picker's groups, each mapping to MOTIS `transitModes`
|
||||
* values. ⚠️ MOTIS classifies a German S-Bahn as `METRO` (verified live), so it
|
||||
* lands under "Metro", not "Train" — that is the feed's own classification.
|
||||
*/
|
||||
export const TRANSIT_MODE_GROUPS: ReadonlyArray<{
|
||||
key: string;
|
||||
label: string;
|
||||
emoji: string;
|
||||
modes: readonly string[];
|
||||
}> = [
|
||||
{ key: 'bus', label: 'Bus', emoji: '🚌', modes: ['BUS', 'COACH'] },
|
||||
{ key: 'tram', label: 'Tram', emoji: '🚊', modes: ['TRAM'] },
|
||||
{ key: 'metro', label: 'Metro', emoji: '🚇', modes: ['SUBWAY', 'METRO'] },
|
||||
{
|
||||
key: 'train',
|
||||
label: 'Train',
|
||||
emoji: '🚆',
|
||||
modes: [
|
||||
'RAIL',
|
||||
'REGIONAL_RAIL',
|
||||
'REGIONAL_FAST_RAIL',
|
||||
'HIGHSPEED_RAIL',
|
||||
'LONG_DISTANCE',
|
||||
'NIGHT_RAIL',
|
||||
'SUBURBAN',
|
||||
],
|
||||
},
|
||||
{ key: 'ferry', label: 'Ferry', emoji: '⛴️', modes: ['FERRY'] },
|
||||
];
|
||||
Loading…
Reference in a new issue