SkateSpots/src/lib/transitUi.ts
SkateSpots 5fff51c814 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.
2026-10-01 11:14:34 +07:00

95 lines
3.1 KiB
TypeScript

/**
* 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'] },
];