- labelsUi.* (221+ keys): spot categories/features/types, access, shelter, event types, gear categories/conditions, price/listings, tours, crews, member-visibility, tag categories, spot status. New src/lib/labelKeys.ts holds the shared value->key maps (one source; the five duplicated EVENT_TYPE_KEYS copies now import it). spotStatus.statusTimeAgo() uses the locale date shim. - formatPrice() now takes an optional t so FREE/Trade/Make Offer translate. - Route helpers: skateTime/distanceReadout/maneuver descriptors/formatAge translated (routeUi.*), consumers updated. - Sasquatch: PRO_TIERS labels/badges + summarizeDraft titles/lines/ctas translated (sasquatchUi.*). - Lab *_LABELS/_DESCRIPTIONS maps remain English (documented out of scope).
601 lines
22 KiB
TypeScript
601 lines
22 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.
|
|
*/
|
|
|
|
import { dateLocaleCode } from '@/lib/dateFnsI18n';
|
|
|
|
/** Travel modes offered in the UI. */
|
|
export type TravelMode = 'foot' | 'bike' | 'moto' | 'car' | 'transit';
|
|
|
|
/**
|
|
* Minimal shape of the translation function, declared locally so pure lib
|
|
* helpers can accept `t` without importing the React context.
|
|
*/
|
|
export type TranslateFn = (key: string, params?: Record<string, string | number>) => string;
|
|
|
|
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". English fallback
|
|
* used for routes cached before `label` existed. */
|
|
instruction: string;
|
|
/** Maneuver token (see `maneuverDescriptor`). The UI maps it to a literal
|
|
* translation key; absent on legacy cached steps. */
|
|
label?: string;
|
|
/** Road/way name to append ("… onto Sukhumvit Rd") — feed data, never
|
|
* translated. Only set for maneuvers that take a road. */
|
|
road?: string;
|
|
/** Length of this step in metres. */
|
|
distanceM: number;
|
|
/** Where the manoeuvre happens. */
|
|
lat: number;
|
|
lng: number;
|
|
}
|
|
|
|
/** The app-owned maneuver token + English fallback, from OSRM's terse codes. */
|
|
function maneuverDescriptor(type: string, modifier: string): { token: string | null; en: string } {
|
|
const t = type.toLowerCase();
|
|
const m = modifier.toLowerCase();
|
|
const pick = (token: string, en: string) => ({ token, en });
|
|
if (t === 'depart') return pick('headOut', 'Head out');
|
|
if (t === 'arrive') return pick('arrive', 'Arrive at the spot');
|
|
if (t === 'new name') return pick('continueStraight', 'Continue');
|
|
if (t === 'continue') {
|
|
return m.includes('left')
|
|
? pick('bearLeft', 'Bear left')
|
|
: m.includes('right')
|
|
? pick('bearRight', 'Bear right')
|
|
: pick('continueStraight', 'Continue');
|
|
}
|
|
if (t === 'turn') {
|
|
if (m.includes('sharp left')) return pick('sharpLeft', 'Sharp left');
|
|
if (m.includes('sharp right')) return pick('sharpRight', 'Sharp right');
|
|
if (m.includes('slight left')) return pick('bearLeft', 'Bear left');
|
|
if (m.includes('slight right')) return pick('bearRight', 'Bear right');
|
|
if (m.includes('left')) return pick('turnLeft', 'Turn left');
|
|
if (m.includes('right')) return pick('turnRight', 'Turn right');
|
|
return pick('turn', 'Turn');
|
|
}
|
|
if (t === 'fork') {
|
|
return m.includes('left')
|
|
? pick('keepLeft', 'Keep left')
|
|
: m.includes('right')
|
|
? pick('keepRight', 'Keep right')
|
|
: pick('takeFork', 'Take the fork');
|
|
}
|
|
if (t === 'merge') return pick('merge', 'Merge');
|
|
if (t === 'roundabout' || t === 'rotary') return pick('roundabout', 'Take the roundabout');
|
|
if (t === 'end of road') {
|
|
return m.includes('left') ? pick('turnLeft', 'Turn left') : pick('turnRight', 'Turn right');
|
|
}
|
|
if (t === 'on ramp') return pick('takeRamp', 'Take the ramp');
|
|
if (t === 'off ramp') return pick('takeExit', 'Take the exit');
|
|
// Unknown OSRM code — keep it raw (feed-ish data, not app copy).
|
|
return { token: null, en: 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 { token, en } = maneuverDescriptor(s.maneuver?.type ?? 'continue', s.maneuver?.modifier ?? '');
|
|
const road = (s.name ?? '').trim();
|
|
const loc = s.maneuver?.location;
|
|
const takesRoad = token !== null && token !== 'arrive' && !!road;
|
|
steps.push({
|
|
instruction: takesRoad ? `${en} onto ${road}` : en,
|
|
label: token ?? undefined,
|
|
road: takesRoad ? road : undefined,
|
|
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(dateLocaleCode(), { 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.
|
|
* `t` is passed in by the caller (this lib has no React context).
|
|
*/
|
|
export function skateTime(km: number, t: TranslateFn): 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 t('routeUi.skateRightHere');
|
|
if (mins < 60) return t('routeUi.skateMin', { count: mins });
|
|
const h = (mins / 60).toFixed(1);
|
|
return t('routeUi.skateHours', { hours: h });
|
|
}
|
|
|
|
/**
|
|
* 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, t: TranslateFn): string {
|
|
const d = formatDistance(km);
|
|
const time = skateTime(km, t);
|
|
return time ? `${d} · ${time}` : d;
|
|
}
|