44fb492759
Add ApiClient.findStationByExtId and toStation helper; use it in useOriginStationWalk. Clamp OSRM route/step durations to a minimum walking speed (1.25 m/s) and update tests to mock the new API call.
110 lines
3.2 KiB
TypeScript
110 lines
3.2 KiB
TypeScript
import type { WalkRoute, WalkStep } from "@timetoleave/core";
|
|
import { OSRM_URL } from "./constants";
|
|
import { ApiClient } from "./api-service";
|
|
|
|
const WALKING_METERS_PER_SECOND = 1.25;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OSRM response types (internal, not exported)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface OsrmStep {
|
|
name: string;
|
|
distance: number;
|
|
duration: number;
|
|
maneuver: { instruction?: string; type: string; modifier?: string };
|
|
}
|
|
|
|
interface OsrmRoute {
|
|
distance: number;
|
|
duration: number;
|
|
legs: Array<{ steps: OsrmStep[] }>;
|
|
}
|
|
|
|
interface OsrmResponse {
|
|
code: string;
|
|
routes: OsrmRoute[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: Build human-readable instruction from OSRM step data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function stepInstruction(step: OsrmStep): string {
|
|
if (step.maneuver.instruction) return step.maneuver.instruction;
|
|
const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
|
|
return `${step.maneuver.type}${modifier}${step.name ? ` onto ${step.name}` : ""}`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WalkRoutingClient — Wraps the OSRM foot routing API
|
|
//
|
|
// OSRM routes are cached for 5 minutes. Walk routes between the same
|
|
// coordinates rarely change, and caching significantly reduces API load.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class WalkRoutingClient {
|
|
private client: ApiClient;
|
|
private readonly defaultTtlMs: number;
|
|
|
|
constructor(
|
|
baseUrl: string = OSRM_URL,
|
|
ttlMs: number = 5 * 60 * 1000, // 5 minutes — routes are very stable
|
|
) {
|
|
this.client = new ApiClient({
|
|
baseUrl,
|
|
defaultTimeoutMs: 10_000,
|
|
defaultTtlMs: ttlMs,
|
|
maxRetries: 3,
|
|
});
|
|
this.defaultTtlMs = ttlMs;
|
|
}
|
|
|
|
async getWalkRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<WalkRoute | null> {
|
|
const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
|
|
const path = `/route/v1/foot/${coords}`;
|
|
|
|
const cacheKey = `osrm:walk:${coords}`;
|
|
|
|
const res = await this.client.get<OsrmResponse>(
|
|
path,
|
|
{ overview: "false", steps: "true" },
|
|
{
|
|
cacheKey,
|
|
ttl: this.defaultTtlMs,
|
|
},
|
|
);
|
|
|
|
if (res.code !== "Ok" || !res.routes.length) return null;
|
|
|
|
const route = res.routes[0];
|
|
const walkingDuration = Math.ceil(route.distance / WALKING_METERS_PER_SECOND);
|
|
const steps: WalkStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
|
|
name: s.name,
|
|
distance: s.distance,
|
|
duration: Math.max(s.duration, Math.ceil(s.distance / WALKING_METERS_PER_SECOND)),
|
|
instruction: stepInstruction(s),
|
|
}));
|
|
|
|
return {
|
|
distance: route.distance,
|
|
duration: Math.max(route.duration, walkingDuration),
|
|
steps,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Expose cache statistics for debugging/monitoring.
|
|
*/
|
|
public cacheStats() {
|
|
return this.client.cacheStats();
|
|
}
|
|
|
|
/**
|
|
* Clear the cache (useful for testing or forced refresh).
|
|
*/
|
|
public clearCache() {
|
|
this.client.clearCache();
|
|
}
|
|
}
|