/** * 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 { 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 { 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 { 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 { 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; }