SkateSpots/src/lib/routing.ts
shakespeare.diy 35915557bb Add in-app routing (foot/bike/moto/car) via OSRM — no external maps handoff
- New src/lib/routing.ts: keyless OSRM client, travel modes, distance/skate-time formatters
- New RouteDialog: self-contained Leaflet map drawing the route from the user's
  location to a spot/shop/event, with a mode toggle and distance/time summary
- Wire the "Directions" buttons on Spot/Shop/Event detail pages to route in-app
  instead of opening Google/Apple Maps

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 10:17:06 +07:00

156 lines
5.6 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 (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<RouteResult> {
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;
}