- 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>
156 lines
5.6 KiB
TypeScript
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;
|
|
}
|