Use extId station lookup and clamp walk durations

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.
This commit is contained in:
2026-05-18 12:15:13 +02:00
parent ce1fa4972c
commit 44fb492759
4 changed files with 54 additions and 25 deletions
@@ -69,6 +69,12 @@ jest.mock('../hooks/useOriginStationWalk', () => ({
jest.mock('../services/api', () => ({ jest.mock('../services/api', () => ({
api: { api: {
findStationByExtId: jest.fn().mockResolvedValue({
name: 'Mödling Bahnhof',
extId: '1231701',
lat: 48.085,
lng: 16.296,
}),
searchJourneys: jest.fn().mockResolvedValue([ searchJourneys: jest.fn().mockResolvedValue([
{ {
id: 'journey-1', id: 'journey-1',
@@ -24,9 +24,9 @@ export function useOriginStationWalk(origin: Station | null) {
if (origin?.lat == null || origin.lng == null) return; if (origin?.lat == null || origin.lng == null) return;
try { try {
const nearest = await api.findNearestStationByCoords(origin.lat, origin.lng); const selectedStation = await api.findStationByExtId(origin.extId);
if (!isMounted) return; if (!isMounted) return;
setStation(nearest); setStation(selectedStation);
} catch (err) { } catch (err) {
if (!isMounted) return; if (!isMounted) return;
setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed'); setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed');
+5 -2
View File
@@ -2,6 +2,8 @@ import type { WalkRoute, WalkStep } from "@timetoleave/core";
import { OSRM_URL } from "./constants"; import { OSRM_URL } from "./constants";
import { ApiClient } from "./api-service"; import { ApiClient } from "./api-service";
const WALKING_METERS_PER_SECOND = 1.25;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// OSRM response types (internal, not exported) // OSRM response types (internal, not exported)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -76,16 +78,17 @@ export class WalkRoutingClient {
if (res.code !== "Ok" || !res.routes.length) return null; if (res.code !== "Ok" || !res.routes.length) return null;
const route = res.routes[0]; const route = res.routes[0];
const walkingDuration = Math.ceil(route.distance / WALKING_METERS_PER_SECOND);
const steps: WalkStep[] = (route.legs[0]?.steps ?? []).map((s) => ({ const steps: WalkStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
name: s.name, name: s.name,
distance: s.distance, distance: s.distance,
duration: s.duration, duration: Math.max(s.duration, Math.ceil(s.distance / WALKING_METERS_PER_SECOND)),
instruction: stepInstruction(s), instruction: stepInstruction(s),
})); }));
return { return {
distance: route.distance, distance: route.distance,
duration: route.duration, duration: Math.max(route.duration, walkingDuration),
steps, steps,
}; };
} }
+41 -21
View File
@@ -69,6 +69,24 @@ function normalizeHafasCoordinate(value: number | undefined): number | undefined
return Math.abs(value) > 1000 ? value / 1e6 : value; return Math.abs(value) > 1000 ? value / 1e6 : value;
} }
type HafasLocationWithCoords = Station & {
type: string;
lon?: number;
crd?: {
x?: number;
y?: number;
};
};
function toStation(location: HafasLocationWithCoords): Station {
return {
name: location.name,
extId: location.extId,
lat: normalizeHafasCoordinate(location.lat ?? location.crd?.y),
lng: normalizeHafasCoordinate(location.lng ?? location.lon ?? location.crd?.x),
};
}
/** /**
* Client for the TimeToLeave server API. Proxy requests to HAFAS, OSRM, * Client for the TimeToLeave server API. Proxy requests to HAFAS, OSRM,
* Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app. * Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app.
@@ -226,7 +244,7 @@ export class ApiClient {
async searchStation(query: string): Promise<Station[]> { async searchStation(query: string): Promise<Station[]> {
const result = await this.hafasRequest<{ const result = await this.hafasRequest<{
svcResL?: Array<{ res?: { match?: { locL?: Array<{ type: string; name: string; extId: string }> } } }>; svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
}>({ }>({
svcReqL: [ svcReqL: [
{ {
@@ -238,7 +256,23 @@ export class ApiClient {
const locL = result?.svcResL?.[0]?.res?.match?.locL ?? []; const locL = result?.svcResL?.[0]?.res?.match?.locL ?? [];
return locL return locL
.filter((l) => l.type === "S") .filter((l) => l.type === "S")
.map((l) => ({ name: l.name, extId: l.extId })); .map(toStation);
}
async findStationByExtId(extId: string): Promise<Station | null> {
const result = await this.hafasRequest<{
svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
}>({
svcReqL: [
{
meth: "LocMatch",
req: { input: { loc: { extId, type: "S" }, maxLoc: 1, field: "S" } },
},
],
});
const station = result?.svcResL?.[0]?.res?.match?.locL?.find((location) => location.type === "S");
return station ? toStation(station) : null;
} }
/** /**
@@ -247,17 +281,8 @@ export class ApiClient {
* is unavailable or the base URL is empty. * is unavailable or the base URL is empty.
*/ */
async findNearestStationByCoords(lat: number, lng: number): Promise<Station | null> { async findNearestStationByCoords(lat: number, lng: number): Promise<Station | null> {
interface HafasLocation extends Station {
type: string;
lon?: number;
crd?: {
x?: number;
y?: number;
};
}
const result = await this.hafasRequest<{ const result = await this.hafasRequest<{
svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>; svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
}>({ }>({
svcReqL: [ svcReqL: [
{ {
@@ -279,15 +304,15 @@ export class ApiClient {
], ],
}); });
const stations: HafasLocation[] = result?.svcResL?.[0]?.res?.match?.locL ?? []; const stations: HafasLocationWithCoords[] = result?.svcResL?.[0]?.res?.match?.locL ?? [];
if (stations.length === 0) return null; if (stations.length === 0) return null;
// Filter to only "S" (station) type results, then pick the closest // Filter to only "S" (station) type results, then pick the closest
const stationResults = stations.filter((s: HafasLocation) => s.type === "S"); const stationResults = stations.filter((s: HafasLocationWithCoords) => s.type === "S");
if (stationResults.length === 0) return null; if (stationResults.length === 0) return null;
// Find the closest station by Euclidean distance // Find the closest station by Euclidean distance
const closest = stationResults.reduce((best: HafasLocation, candidate: HafasLocation) => { const closest = stationResults.reduce((best: HafasLocationWithCoords, candidate: HafasLocationWithCoords) => {
const bestLat = normalizeHafasCoordinate(best.lat ?? best.crd?.y) ?? lat; const bestLat = normalizeHafasCoordinate(best.lat ?? best.crd?.y) ?? lat;
const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon ?? best.crd?.x) ?? lng; const bestLng = normalizeHafasCoordinate(best.lng ?? best.lon ?? best.crd?.x) ?? lng;
const candidateLat = normalizeHafasCoordinate(candidate.lat ?? candidate.crd?.y) ?? lat; const candidateLat = normalizeHafasCoordinate(candidate.lat ?? candidate.crd?.y) ?? lat;
@@ -297,12 +322,7 @@ export class ApiClient {
return candDist < bestDist ? candidate : best; return candDist < bestDist ? candidate : best;
}, stationResults[0]); }, stationResults[0]);
return { return toStation(closest);
name: closest.name,
extId: closest.extId,
lat: normalizeHafasCoordinate(closest.lat ?? closest.crd?.y),
lng: normalizeHafasCoordinate(closest.lng ?? closest.lon ?? closest.crd?.x),
};
} }
async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> { async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> {