/** * 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 (subway/public transport) is intentionally NOT here: there is no free, * keyless, worldwide transit routing API. It needs per-city GTFS data and would * be empty in most cities — a worse experience than omitting it. */ /** Travel modes offered in the UI. */ export type TravelMode = 'foot' | 'bike' | 'moto' | 'car'; export interface TravelModeMeta { mode: TravelMode; /** OSRM routing profile actually used. */ 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: '🚗' }, ]; 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; } const OSRM_BASE = 'https://router.project-osrm.org/route/v1'; const CORS_PROXY = 'https://proxy.shakespeare.diy/?url='; interface OsrmResponse { code: string; routes?: Array<{ distance: number; duration: number; geometry: { coordinates: Array<[number, number]> }; // [lng, lat] }>; } /** * Fetch a route between two points for a given travel mode using OSRM. * Throws on network error or when OSRM returns no route. */ export async function fetchRoute( from: { lat: number; lng: number }, to: { lat: number; lng: number }, mode: TravelMode, signal?: AbortSignal, ): Promise { const { profile } = travelModeMeta(mode); // OSRM wants lon,lat;lon,lat const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`; const target = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson`; const url = `${CORS_PROXY}${encodeURIComponent(target)}`; const res = await fetch(url, { signal }); if (!res.ok) { throw new Error(`Routing service returned ${res.status}`); } const data = (await res.json()) as OsrmResponse; if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) { throw new Error('No route found'); } 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, }; } // ─── 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; }