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', () => ({
|
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');
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user