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:
@@ -69,6 +69,12 @@ jest.mock('../hooks/useOriginStationWalk', () => ({
|
||||
|
||||
jest.mock('../services/api', () => ({
|
||||
api: {
|
||||
findStationByExtId: jest.fn().mockResolvedValue({
|
||||
name: 'Mödling Bahnhof',
|
||||
extId: '1231701',
|
||||
lat: 48.085,
|
||||
lng: 16.296,
|
||||
}),
|
||||
searchJourneys: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'journey-1',
|
||||
|
||||
@@ -24,9 +24,9 @@ export function useOriginStationWalk(origin: Station | null) {
|
||||
if (origin?.lat == null || origin.lng == null) return;
|
||||
|
||||
try {
|
||||
const nearest = await api.findNearestStationByCoords(origin.lat, origin.lng);
|
||||
const selectedStation = await api.findStationByExtId(origin.extId);
|
||||
if (!isMounted) return;
|
||||
setStation(nearest);
|
||||
setStation(selectedStation);
|
||||
} catch (err) {
|
||||
if (!isMounted) return;
|
||||
setLookupError(err instanceof Error ? err.message : 'Origin station lookup failed');
|
||||
|
||||
@@ -2,6 +2,8 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -76,16 +78,17 @@ export class WalkRoutingClient {
|
||||
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: s.duration,
|
||||
duration: Math.max(s.duration, Math.ceil(s.distance / WALKING_METERS_PER_SECOND)),
|
||||
instruction: stepInstruction(s),
|
||||
}));
|
||||
|
||||
return {
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
duration: Math.max(route.duration, walkingDuration),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,6 +69,24 @@ function normalizeHafasCoordinate(value: number | undefined): number | undefined
|
||||
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,
|
||||
* Nominatim, and WienerLinien backends. Set `baseUrl` to your deployed app.
|
||||
@@ -226,7 +244,7 @@ export class ApiClient {
|
||||
|
||||
async searchStation(query: string): Promise<Station[]> {
|
||||
const result = await this.hafasRequest<{
|
||||
svcResL?: Array<{ res?: { match?: { locL?: Array<{ type: string; name: string; extId: string }> } } }>;
|
||||
svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
|
||||
}>({
|
||||
svcReqL: [
|
||||
{
|
||||
@@ -238,7 +256,23 @@ export class ApiClient {
|
||||
const locL = result?.svcResL?.[0]?.res?.match?.locL ?? [];
|
||||
return locL
|
||||
.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.
|
||||
*/
|
||||
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<{
|
||||
svcResL?: Array<{ res?: { match?: { locL?: HafasLocation[] } } }>;
|
||||
svcResL?: Array<{ res?: { match?: { locL?: HafasLocationWithCoords[] } } }>;
|
||||
}>({
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 bestLng = normalizeHafasCoordinate(best.lng ?? best.lon ?? best.crd?.x) ?? lng;
|
||||
const candidateLat = normalizeHafasCoordinate(candidate.lat ?? candidate.crd?.y) ?? lat;
|
||||
@@ -297,12 +322,7 @@ export class ApiClient {
|
||||
return candDist < bestDist ? candidate : best;
|
||||
}, stationResults[0]);
|
||||
|
||||
return {
|
||||
name: closest.name,
|
||||
extId: closest.extId,
|
||||
lat: normalizeHafasCoordinate(closest.lat ?? closest.crd?.y),
|
||||
lng: normalizeHafasCoordinate(closest.lng ?? closest.lon ?? closest.crd?.x),
|
||||
};
|
||||
return toStation(closest);
|
||||
}
|
||||
|
||||
async monitorStops(stopIds: string[]): Promise<WienerLinienDeparture[]> {
|
||||
|
||||
Reference in New Issue
Block a user