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:
SkateSpots 2026-10-01 11:14:34 +07:00
parent 126eb38330
commit 5fff51c814
5 changed files with 503 additions and 67 deletions

View file

@ -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,

View file

@ -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 ?? ''}
/>
</>
);
}

View 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>
);
}

View file

@ -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
View 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'] },
];