SkateSpots/src/lib/routing.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

566 lines
21 KiB
TypeScript

/**
* In-app routing — OSRM client + skate-friendly distance/time formatting.
* ─────────────────────────────────────────────────────────────────────────────
* We deliberately do NOT hand off to Google/Apple Maps. Instead we fetch a route
* from the keyless, account-free public OSRM server and draw it on our own
* Leaflet map. This keeps navigation in-app, private, and dependency-light.
*
* SCOPE (on purpose):
* • We return a static route line + distance + duration summary.
* • We do NOT do live turn-by-turn, voice, or rerouting — that's a huge native
* effort and out of scope for a lean PWA.
*
* MODES:
* foot / bike / car are real OSRM profiles. "moto" (motorbike) isn't a real
* OSRM profile, so we route it as `car` (driving roads) and label it honestly.
*
* TRANSIT (public transport) is routed separately against **Transitous** — a
* free, keyless, community-run worldwide transit router (MOTIS 2 API). It has
* open CORS, so the browser calls it directly. It sends the origin/destination
* to api.transitous.org (privacy note: unlike OSRM-mode routing, this one call
* is not in-app only). Coverage depends on which operators publish open data;
* where none is published we report that honestly instead of faking a route.
*/
/** Travel modes offered in the UI. */
export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit';
export interface TravelModeMeta {
mode: TravelMode;
/** OSRM routing profile actually used (undefined for transit). */
profile?: 'foot' | 'bike' | 'car';
label: string;
emoji: string;
/** True when we're being honest that the profile is an approximation. */
approximate?: boolean;
}
export const TRAVEL_MODES: TravelModeMeta[] = [
{ mode: 'foot', profile: 'foot', label: 'Skate / Walk', emoji: '🛹' },
{ mode: 'bike', profile: 'bike', label: 'Bike', emoji: '🚲' },
{ mode: 'moto', profile: 'car', label: 'Moto', emoji: '🏍️', approximate: true },
{ mode: 'car', profile: 'car', label: 'Car', emoji: '🚗' },
{ mode: 'transit', label: 'Transit', emoji: '🚇' },
];
export function travelModeMeta(mode: TravelMode): TravelModeMeta {
return TRAVEL_MODES.find((m) => m.mode === mode) ?? TRAVEL_MODES[0];
}
export interface RouteResult {
/** Decoded [lat, lng] path suitable for a Leaflet polyline. */
coordinates: Array<[number, number]>;
/** Straight-line-ish route distance in meters. */
distanceM: number;
/** Estimated travel time in seconds (from OSRM for the profile). */
durationS: number;
/** Which mode the route was requested for. */
mode: TravelMode;
/** Turn-by-turn instructions (orientation aid; not live navigation). */
steps?: RouteStep[];
/**
* True when the numbers came from the car-only fallback server, so the
* distance/time are a driving approximation for the chosen mode.
*/
approximate?: boolean;
/** 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, …). */
export interface TransitLeg {
/** MOTIS mode, uppercased: WALK, BUS, TRAM, SUBWAY, METRO, RAIL, … */
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;
/** 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). */
realTime?: boolean;
/** Length of this leg in metres. */
distanceM?: number;
startISO?: string;
endISO?: string;
/** Decoded [lat, lng] path for this leg. */
coordinates: Array<[number, number]>;
}
/** A public-transport itinerary summary returned by the transit router. */
export interface TransitItinerary {
departISO: string;
arriveISO: string;
durationS: number;
transfers: number;
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';
/**
* Real per-profile OSRM endpoints (FOSSGIS / OpenStreetMap Germany).
*
* ⚠️ The old `router.project-osrm.org` demo server hosts ONE dataset (car) and
* **ignores the profile segment of the URL** — foot/bike/car all came back with
* the same distance and time, so the mode toggle looked broken and the times
* were dishonest. FOSSGIS runs a separate engine per profile, so foot really is
* a walking route and bike a cycling one (they differ in distance AND time).
* It sends `Access-Control-Allow-Origin: *`, so we can call it directly.
*/
const OSRM_PROFILE_BASE: Record<'foot' | 'bike' | 'car', string> = {
foot: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
bike: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
car: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
};
/** Last-resort fallback: the demo server (car-only, hence approximate). */
const OSRM_FALLBACK_BASE = 'https://router.project-osrm.org/route/v1';
const CORS_PROXY = 'https://proxy.shakespeare.diy/?url=';
interface OsrmStep {
distance?: number;
name?: string;
maneuver?: { type?: string; modifier?: string; location?: [number, number] };
}
interface OsrmLeg {
steps?: OsrmStep[];
}
interface OsrmResponse {
code: string;
routes?: Array<{
distance: number;
duration: number;
geometry: { coordinates: Array<[number, number]> }; // [lng, lat]
legs?: OsrmLeg[];
}>;
}
/** One turn-by-turn instruction (used for orientation, not live navigation). */
export interface RouteStep {
/** Human instruction, e.g. "Turn left onto Sukhumvit Rd". */
instruction: string;
/** Length of this step in metres. */
distanceM: number;
/** Where the manoeuvre happens. */
lat: number;
lng: number;
}
/** Turn OSRM's terse maneuver codes into something readable. */
function maneuverLabel(type: string, modifier: string): string {
const t = type.toLowerCase();
const m = modifier.toLowerCase();
if (t === 'depart') return 'Head out';
if (t === 'arrive') return 'Arrive at the spot';
if (t === 'new name') return 'Continue';
if (t === 'continue') return m.includes('left') ? 'Bear left' : m.includes('right') ? 'Bear right' : 'Continue';
if (t === 'turn') {
if (m.includes('sharp left')) return 'Sharp left';
if (m.includes('sharp right')) return 'Sharp right';
if (m.includes('slight left')) return 'Bear left';
if (m.includes('slight right')) return 'Bear right';
if (m.includes('left')) return 'Turn left';
if (m.includes('right')) return 'Turn right';
return 'Turn';
}
if (t === 'fork') return m.includes('left') ? 'Keep left' : m.includes('right') ? 'Keep right' : 'Take the fork';
if (t === 'merge') return 'Merge';
if (t === 'roundabout' || t === 'rotary') return 'Take the roundabout';
if (t === 'end of road') return m.includes('left') ? 'Turn left' : 'Turn right';
if (t === 'on ramp') return 'Take the ramp';
if (t === 'off ramp') return 'Take the exit';
return t.replace(/\b\w/g, (c) => c.toUpperCase());
}
function parseSteps(data: OsrmResponse): RouteStep[] {
const route = data.routes?.[0];
if (!route?.legs) return [];
const steps: RouteStep[] = [];
for (const leg of route.legs) {
for (const s of leg.steps ?? []) {
const label = maneuverLabel(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
const road = (s.name ?? '').trim();
const loc = s.maneuver?.location;
steps.push({
instruction: road && !label.startsWith('Arrive') ? `${label} onto ${road}` : label,
distanceM: s.distance ?? 0,
lat: loc?.[1] ?? 0,
lng: loc?.[0] ?? 0,
});
}
}
return steps;
}
/** Error message constants the UI branches on (kept in sync with RouteDialog). */
export const ROUTE_ERR_NO_ROUTE = 'No route found';
export const ROUTE_ERR_BUSY = 'Routing service busy';
function isPoint(p: { lat?: number; lng?: number } | null | undefined): boolean {
return !!p && Number.isFinite(p.lat) && Number.isFinite(p.lng);
}
/**
* Call OSRM and parse the JSON.
*
* OSRM sends `Access-Control-Allow-Origin: *`, so we call it DIRECTLY first —
* one less third-party hop (faster, and the origin/destination never pass
* through a third party). Only if the direct call fails do we retry through the
* CORS proxy (some networks/WebViews block the direct request).
*/
async function fetchOsrm(url: string, signal?: AbortSignal): Promise<OsrmResponse> {
try {
const res = await fetch(url, { signal });
if (res.ok) return (await res.json()) as OsrmResponse;
if (res.status === 429) throw new Error(ROUTE_ERR_BUSY);
// Any other non-OK (400/404/500…) is worth one proxy retry.
} catch (e) {
if ((e as Error)?.name === 'AbortError') throw e;
if ((e as Error)?.message === ROUTE_ERR_BUSY) throw e;
// fall through to the proxy
}
const proxied = `${CORS_PROXY}${encodeURIComponent(url)}`;
const res = await fetch(proxied, { signal });
if (res.status === 429) throw new Error(ROUTE_ERR_BUSY);
if (!res.ok) throw new Error(`Routing service returned ${res.status}`);
return (await res.json()) as OsrmResponse;
}
/**
* Fetch a route between two points for a given travel mode using OSRM.
* Throws `ROUTE_ERR_BUSY`, `ROUTE_ERR_NO_ROUTE`, or a network error.
*/
export async function fetchRoute(
from: { lat: number; lng: number },
to: { lat: number; lng: number },
mode: TravelMode,
signal?: AbortSignal,
): Promise<RouteResult> {
const { profile } = travelModeMeta(mode);
if (!profile) throw new Error('Not a road routing mode'); // transit uses fetchTransitRoute
// Never send NaN/undefined to OSRM — that surfaces as a confusing "no route".
if (!isPoint(from) || !isPoint(to)) throw new Error(ROUTE_ERR_NO_ROUTE);
// OSRM wants lon,lat;lon,lat
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`;
const query = `overview=full&geometries=geojson&steps=true`;
// 1) The real per-profile server (foot ≠ bike ≠ car distances and times).
let data: OsrmResponse | null = null;
let approximate = false;
try {
data = await fetchOsrm(`${OSRM_PROFILE_BASE[profile]}/${coords}?${query}`, signal);
if (data.code !== 'Ok') data = null;
} catch (e) {
if ((e as Error)?.name === 'AbortError') throw e;
}
// 2) Fallback: the demo server, which is CAR-ONLY — flag the result as
// approximate so we can say so plainly rather than passing driving
// numbers off as a walking/cycling route.
if (!data) {
data = await fetchOsrm(`${OSRM_FALLBACK_BASE}/driving/${coords}?${query}`, signal);
approximate = profile !== 'car';
}
if (data.code === 'TooManyRequests') throw new Error(ROUTE_ERR_BUSY);
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error(ROUTE_ERR_NO_ROUTE);
}
const route = data.routes[0];
// GeoJSON is [lng, lat] → Leaflet wants [lat, lng]
const coordinates = route.geometry.coordinates.map(
([lng, lat]) => [lat, lng] as [number, number],
);
return {
coordinates,
distanceM: route.distance,
durationS: route.duration,
mode,
steps: parseSteps(data),
approximate: approximate || undefined,
};
}
// ─── 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
* so we don't add a dependency for one function.
*/
function decodePolyline(encoded: string, precision = 7): Array<[number, number]> {
const factor = 10 ** precision;
const coordinates: Array<[number, number]> = [];
let index = 0;
let lat = 0;
let lng = 0;
while (index < encoded.length) {
let shift = 0;
let result = 0;
let byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lat += result & 1 ? ~(result >> 1) : result >> 1;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lng += result & 1 ? ~(result >> 1) : result >> 1;
coordinates.push([lat / factor, lng / factor]);
}
return coordinates;
}
interface MotisLeg {
mode?: string;
routeShortName?: string;
routeLongName?: string;
agencyName?: string;
agencyFareUrl?: string;
headsign?: string;
realTime?: boolean;
distance?: number;
startTime?: string;
endTime?: 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;
duration?: number;
transfers?: number;
legs?: MotisLeg[];
}
/**
* Fetch a public-transport itinerary from Transitous (MOTIS 2 API). Keyless and
* free; throws when no itinerary exists (e.g. no open timetable for the area)
* so the caller can say so plainly rather than inventing a route.
*/
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: 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}`);
const data = (await res.json()) as { itineraries?: MotisItinerary[] };
const itineraries = Array.isArray(data.itineraries) ? data.itineraries : [];
if (itineraries.length === 0) throw new Error('No transit route found');
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,
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
: 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,
};
}
/**
* 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);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// ─── Formatting helpers ──────────────────────────────────────────────────────
/**
* Format a distance in kilometers, human-friendly.
* e.g. 0.34 → "340 m", 3.4 → "3.4 km", 12 → "12 km"
*/
export function formatDistance(km: number): string {
if (!isFinite(km) || km < 0) return '—';
if (km < 1) return `${Math.round(km * 1000)} m`;
if (km < 10) return `${km.toFixed(1)} km`;
return `${Math.round(km)} km`;
}
/** Format a duration in seconds → "12 min", "1 h 5 min". */
export function formatDuration(seconds: number): string {
if (!isFinite(seconds) || seconds < 0) return '—';
const mins = Math.round(seconds / 60);
if (mins < 1) return '<1 min';
if (mins < 60) return `${mins} min`;
const h = Math.floor(mins / 60);
const m = mins % 60;
return m > 0 ? `${h} h ${m} min` : `${h} h`;
}
/**
* Skaters think in time, not distance. Estimate a cruising time from a
* straight-line distance (km) at a relaxed ~10 km/h push, and phrase it in a
* skate-native way. No routing call needed — pure formula, works offline.
*/
export function skateTime(km: number): string {
if (!isFinite(km) || km < 0) return '';
const CRUISE_KMH = 10; // relaxed cruising pace
const mins = Math.round((km / CRUISE_KMH) * 60);
if (mins < 1) return 'right here';
if (mins < 60) return `~${mins} min cruise`;
const h = (mins / 60).toFixed(1);
return `~${h} h cruise`;
}
/**
* Compact one-line readout for cards/popups given a distance in km.
* e.g. "1.2 km · ~7 min cruise"
*/
export function distanceReadout(km: number): string {
const d = formatDistance(km);
const t = skateTime(km);
return t ? `${d} · ${t}` : d;
}