Skip to content

Commit 439eb33

Browse files
committed
fix: db vendo per-leg geometries & polylines
1 parent 0aaf2fb commit 439eb33

7 files changed

Lines changed: 313 additions & 29 deletions

File tree

apps/web/src/components/map/layers/TransitItineraryLayer.tsx

Lines changed: 86 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import { MODE_COLORS, useDirectionsStore } from "@openmapx/core";
4-
import { useEffect } from "react";
3+
import type { GeoJSONLineString } from "@integrations/transit/types";
4+
import { API_ENDPOINTS, apiClient, MODE_COLORS, useDirectionsStore } from "@openmapx/core";
5+
import { useEffect, useRef, useState } from "react";
56
import { useMap } from "@/lib/MapContext";
67
import { PRIMARY_BLUE_HEX } from "@/lib/theme";
78

@@ -15,6 +16,52 @@ export function TransitItineraryLayer() {
1516
const { mapRef, mapReady, styleVersion, fitBounds } = useMap();
1617
const { mode, transitItineraries, activeItineraryIndex } = useDirectionsStore();
1718

19+
// Refined per-leg geometries fetched lazily from /leg-geometry after the
20+
// itinerary is selected. Keyed by tripId; replaces stopovers geometry when available.
21+
const [legGeometries, setLegGeometries] = useState<Record<string, GeoJSONLineString>>({});
22+
// Track the fetch generation so stale responses from a previous itinerary are discarded.
23+
const fetchGenRef = useRef(0);
24+
// Track which (itinerary list, index) pair has already had bounds fitted.
25+
// Keying on the list reference catches new searches that reset the index back
26+
// to 0, which a bare index comparison would miss.
27+
const fittedRef = useRef<{ list: typeof transitItineraries; index: number } | null>(null);
28+
29+
useEffect(() => {
30+
if (mode !== "transit") {
31+
setLegGeometries({});
32+
return;
33+
}
34+
const itinerary = transitItineraries[activeItineraryIndex];
35+
if (!itinerary) return;
36+
37+
const gen = ++fetchGenRef.current;
38+
setLegGeometries({});
39+
40+
const transitLegs = itinerary.legs.filter((leg) => leg.tripId && leg.mode !== "walking");
41+
if (transitLegs.length === 0) return;
42+
43+
void Promise.allSettled(
44+
transitLegs.map(async (leg) => {
45+
if (!leg.tripId) return;
46+
const { tripId } = leg;
47+
try {
48+
const params: Record<string, string> = { trip_id: tripId };
49+
if (leg.from.stopId) params.from_stop_id = leg.from.stopId;
50+
if (leg.to.stopId) params.to_stop_id = leg.to.stopId;
51+
const geo = await apiClient.get<GeoJSONLineString>(
52+
API_ENDPOINTS.transitLegGeometry,
53+
params,
54+
);
55+
if (fetchGenRef.current === gen && geo?.coordinates?.length >= 2) {
56+
setLegGeometries((prev) => ({ ...prev, [tripId]: geo }));
57+
}
58+
} catch {
59+
// geometry not available for this leg — stopovers geometry is used
60+
}
61+
}),
62+
);
63+
}, [mode, transitItineraries, activeItineraryIndex]);
64+
1865
useEffect(() => {
1966
void styleVersion;
2067
const map = mapRef.current;
@@ -38,19 +85,20 @@ export function TransitItineraryLayer() {
3885

3986
cleanup();
4087

41-
// Build line features for each leg
88+
// Build line features for each leg; use refined trip geometry when available
4289
const lineFeatures = itinerary.legs.map((leg, i) => {
4390
const isWalk = leg.mode === "walking";
4491
const color = isWalk
4592
? "#757575"
4693
: leg.route?.color
4794
? `#${leg.route.color.replace("#", "")}`
4895
: (MODE_COLORS[leg.mode] ?? PRIMARY_BLUE_HEX);
96+
const geometry = (leg.tripId ? legGeometries[leg.tripId] : undefined) ?? leg.geometry;
4997

5098
return {
5199
type: "Feature" as const,
52100
properties: { isWalk, color, index: i },
53-
geometry: leg.geometry,
101+
geometry,
54102
};
55103
});
56104

@@ -126,27 +174,44 @@ export function TransitItineraryLayer() {
126174
},
127175
});
128176

129-
// Fit bounds to itinerary
130-
const allCoords: [number, number][] = [];
131-
for (const leg of itinerary.legs) {
132-
for (const coord of leg.geometry.coordinates) {
133-
allCoords.push(coord);
177+
// Fit bounds only when the itinerary set or selected index changes, not on
178+
// every legGeometries update. Comparing the list reference catches new
179+
// searches that reset the index to 0 (a bare index check would miss them).
180+
const alreadyFitted =
181+
fittedRef.current?.list === transitItineraries &&
182+
fittedRef.current?.index === activeItineraryIndex;
183+
if (!alreadyFitted) {
184+
fittedRef.current = { list: transitItineraries, index: activeItineraryIndex };
185+
const allCoords: [number, number][] = [];
186+
for (const leg of itinerary.legs) {
187+
for (const coord of leg.geometry.coordinates) {
188+
allCoords.push(coord);
189+
}
190+
}
191+
if (allCoords.length >= 2) {
192+
const lngs = allCoords.map((c) => c[0]);
193+
const lats = allCoords.map((c) => c[1]);
194+
fitBounds(
195+
[
196+
[Math.min(...lngs), Math.min(...lats)],
197+
[Math.max(...lngs), Math.max(...lats)],
198+
],
199+
80,
200+
);
134201
}
135-
}
136-
if (allCoords.length >= 2) {
137-
const lngs = allCoords.map((c) => c[0]);
138-
const lats = allCoords.map((c) => c[1]);
139-
fitBounds(
140-
[
141-
[Math.min(...lngs), Math.min(...lats)],
142-
[Math.max(...lngs), Math.max(...lats)],
143-
],
144-
80,
145-
);
146202
}
147203

148204
return cleanup;
149-
}, [mapRef, mapReady, styleVersion, mode, transitItineraries, activeItineraryIndex, fitBounds]);
205+
}, [
206+
mapRef,
207+
mapReady,
208+
styleVersion,
209+
mode,
210+
transitItineraries,
211+
activeItineraryIndex,
212+
fitBounds,
213+
legGeometries,
214+
]);
150215

151216
return null;
152217
}

integrations/transit-db-vendo/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,7 @@ export function setup(ctx: IntegrationContext): void {
3434
async getVehicleJourney(tripId: string) {
3535
return dbVendo.getTrip(tripId);
3636
},
37+
getLegGeometry: (tripId: string, fromStopId?: string, toStopId?: string) =>
38+
dbVendo.getLegGeometry(tripId, fromStopId, toStopId),
3739
});
3840
}

integrations/transit-db-vendo/provider.ts

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,43 @@ import {
1818
USER_AGENT_TRANSIT,
1919
} from "@openmapx/core";
2020
import { createClient } from "db-vendo-client";
21-
import { profile as dbProfile } from "db-vendo-client/p/db/index.js";
21+
// dbnav uses app.services-bahn.de — documented as more stable than the db
22+
// profile (app.vendo.noncd.db.de, "possibly shut off soon" per readme).
23+
// @ts-expect-error — no type declarations for the dbnav profile sub-path
24+
import { profile as dbnavProfile } from "db-vendo-client/p/dbnav/index.js";
25+
// @ts-expect-error — no type declarations for the dbweb profile sub-path
26+
import { profile as dbwebProfile } from "db-vendo-client/p/dbweb/index.js";
27+
// @ts-expect-error — no type declarations for throttle/retry sub-paths
28+
import { withRetrying } from "db-vendo-client/retry.js";
29+
// @ts-expect-error — no type declarations for throttle/retry sub-paths
30+
import { withThrottling } from "db-vendo-client/throttle.js";
2231

2332
const PREFIX = "db:";
33+
const UA = process.env.DB_USER_AGENT ?? USER_AGENT_TRANSIT;
2434

35+
// Primary client for journey planning, departures, stops, etc.
36+
// dbnav quota: 60 req/min → throttle to 1/s. Retry up to 3× on transient
37+
// failures (network timeouts, 5xx); HafasErrors are never retried.
2538
// biome-ignore lint/suspicious/noExplicitAny: external untyped package
26-
const client: any = createClient(dbProfile, process.env.DB_USER_AGENT ?? USER_AGENT_TRANSIT, {
39+
const client: any = createClient(withRetrying(withThrottling(dbnavProfile, 1, 1000)), UA, {
2740
enrichStations: true,
2841
});
2942

43+
// Secondary client for polyline geometry via /reiseloesung/fahrt?poly=true.
44+
// dbweb is "aggressively blocked" per docs, so we:
45+
// • throttle to 1 req/2 s (very conservative)
46+
// • retry up to 2× with a short backoff (3 s, 6 s) so the UI doesn't stall
47+
// • randomize the User-Agent to reduce fingerprinting-based blocking
48+
// biome-ignore lint/suspicious/noExplicitAny: external untyped package
49+
const dbwebClient: any = createClient(
50+
withRetrying(withThrottling({ ...dbwebProfile, randomizeUserAgent: true }, 1, 2000), {
51+
retries: 2,
52+
minTimeout: 3_000,
53+
factor: 2,
54+
}),
55+
UA,
56+
);
57+
3058
// biome-ignore lint/suspicious/noExplicitAny: external API response
3159
function normalizeStop(s: any): TransitStop {
3260
return {
@@ -175,9 +203,23 @@ function legToTripLeg(leg: any): TripLeg {
175203
[toLng, toLat],
176204
],
177205
};
178-
const polyFeature = leg.polyline?.features?.[0]?.geometry;
179-
if (polyFeature?.type === "LineString" && Array.isArray(polyFeature.coordinates)) {
180-
geometry = polyFeature as GeoJSONLineString;
206+
if (Array.isArray(leg.stopovers) && leg.stopovers.length >= 2) {
207+
// db-vendo-client does not support polylines in journeys(); use stopover
208+
// coordinates as a multi-point fallback so the route passes through real
209+
// intermediate stations instead of drawing a single straight line.
210+
const coords: [number, number][] = [];
211+
for (const s of leg.stopovers) {
212+
// biome-ignore lint/suspicious/noExplicitAny: external API response
213+
const sv = s as any;
214+
const lat = sv.stop?.location?.latitude ?? sv.location?.latitude;
215+
const lng = sv.stop?.location?.longitude ?? sv.location?.longitude;
216+
if (typeof lat === "number" && typeof lng === "number") {
217+
coords.push([lng, lat]);
218+
}
219+
}
220+
if (coords.length >= 2) {
221+
geometry = { type: "LineString", coordinates: coords };
222+
}
181223
}
182224

183225
// stopovers includes origin + destination; subtract 2 for intermediate count
@@ -237,8 +279,11 @@ export async function planJourney(
237279
results: numItineraries ?? 3,
238280
stopovers: true,
239281
remarks: true,
240-
// Note: polylines are NOT supported in journeys() for db-vendo-client;
241-
// only in refreshJourney() and trip(). Geometry falls back to straight lines.
282+
// polylines are not supported in journeys() for db-vendo-client; only
283+
// refreshJourney() accepts the option, but the DB vendo API no longer
284+
// returns polylineGroup data there either. We use stopover coordinates
285+
// as a multi-point fallback (see legToTripLeg), and refine per-leg via
286+
// getLegGeometry() when the user selects an itinerary.
242287
},
243288
);
244289
if (!data.journeys?.length) return null;
@@ -295,6 +340,120 @@ export async function planJourney(
295340
}
296341
}
297342

343+
// Leg Geometry
344+
345+
/** Return the index in `coords` closest to `[lng, lat]` (squared equirectangular distance). */
346+
function closestIndex(coords: [number, number][], lng: number, lat: number): number {
347+
let best = 0;
348+
let bestDist = Infinity;
349+
for (let i = 0; i < coords.length; i++) {
350+
const d = (coords[i][0] - lng) ** 2 + (coords[i][1] - lat) ** 2;
351+
if (d < bestDist) {
352+
bestDist = d;
353+
best = i;
354+
}
355+
}
356+
return best;
357+
}
358+
359+
/**
360+
* Fetch the exact track shape for a single transit leg.
361+
*
362+
* Uses the bahn.de web client whose /reiseloesung/fahrt endpoint supports
363+
* poly=true and returns the full polyline as a FeatureCollection of Points.
364+
* The result is clipped to the user's boarding/alighting stops. Falls back to
365+
* stopover coordinates if the polyline is not available.
366+
*/
367+
export async function getLegGeometry(
368+
tripId: string,
369+
fromStopId?: string,
370+
toStopId?: string,
371+
): Promise<GeoJSONLineString | null> {
372+
const rawId = stripPrefix(tripId);
373+
const rawFrom = fromStopId ? stripPrefix(fromStopId) : undefined;
374+
const rawTo = toStopId ? stripPrefix(toStopId) : undefined;
375+
try {
376+
// Use the bahn.de web client: its /reiseloesung/fahrt endpoint accepts
377+
// poly=true and returns the full track polyline as a FeatureCollection of
378+
// Point features. The HAFAS trip IDs from the db profile are compatible.
379+
// biome-ignore lint/suspicious/noExplicitAny: external API response
380+
const data: any = await dbwebClient.trip(rawId, { stopovers: true, polyline: true });
381+
const trip = data.trip ?? data;
382+
383+
// The polyline comes back as a GeoJSON FeatureCollection of Point features.
384+
// Convert to a LineString and clip to the boarding / alighting stops.
385+
// biome-ignore lint/suspicious/noExplicitAny: external API response
386+
const stopovers: any[] = trip.stopovers ?? [];
387+
const polyCollection = trip.polyline;
388+
389+
if (
390+
polyCollection?.type === "FeatureCollection" &&
391+
Array.isArray(polyCollection.features) &&
392+
polyCollection.features.length >= 2
393+
) {
394+
const allCoords: [number, number][] = [];
395+
// biome-ignore lint/suspicious/noExplicitAny: external API response
396+
for (const f of polyCollection.features as any[]) {
397+
if (f.geometry?.type === "Point" && Array.isArray(f.geometry.coordinates)) {
398+
allCoords.push(f.geometry.coordinates as [number, number]);
399+
}
400+
}
401+
402+
if (allCoords.length >= 2) {
403+
// Clip to the user's leg using the boarding/alighting stop coordinates
404+
// as anchor points (find the nearest polyline point to each station).
405+
const fromStop = rawFrom ? stopovers.find((s) => s.stop?.id === rawFrom) : null;
406+
const toStop = rawTo ? stopovers.find((s) => s.stop?.id === rawTo) : null;
407+
408+
let startIdx = 0;
409+
let endIdx = allCoords.length - 1;
410+
411+
if (fromStop?.stop?.location) {
412+
startIdx = closestIndex(
413+
allCoords,
414+
fromStop.stop.location.longitude,
415+
fromStop.stop.location.latitude,
416+
);
417+
}
418+
if (toStop?.stop?.location) {
419+
endIdx = closestIndex(
420+
allCoords,
421+
toStop.stop.location.longitude,
422+
toStop.stop.location.latitude,
423+
);
424+
}
425+
426+
// Ensure correct order (trains always run origin→destination in the polyline)
427+
if (startIdx > endIdx) [startIdx, endIdx] = [endIdx, startIdx];
428+
429+
const clipped = allCoords.slice(startIdx, endIdx + 1);
430+
if (clipped.length >= 2) {
431+
return { type: "LineString", coordinates: clipped };
432+
}
433+
}
434+
}
435+
436+
// Fallback: use stopover coordinates clipped to the leg's stop range.
437+
let fromIdx = rawFrom ? stopovers.findIndex((s) => s.stop?.id === rawFrom) : -1;
438+
let toIdx = rawTo ? stopovers.findIndex((s) => s.stop?.id === rawTo) : -1;
439+
if (fromIdx === -1) fromIdx = 0;
440+
if (toIdx === -1) toIdx = stopovers.length - 1;
441+
442+
const slice = stopovers.slice(fromIdx, toIdx + 1);
443+
const coords: [number, number][] = [];
444+
for (const s of slice) {
445+
const lat = s.stop?.location?.latitude;
446+
const lng = s.stop?.location?.longitude;
447+
if (typeof lat === "number" && typeof lng === "number") {
448+
coords.push([lng, lat]);
449+
}
450+
}
451+
return coords.length >= 2 ? { type: "LineString", coordinates: coords } : null;
452+
} catch {
453+
return null;
454+
}
455+
}
456+
298457
// Trip Detail
299458

300459
export async function getTrip(tripId: string): Promise<VehicleJourney | null> {

0 commit comments

Comments
 (0)