@@ -18,15 +18,43 @@ import {
1818 USER_AGENT_TRANSIT ,
1919} from "@openmapx/core" ;
2020import { 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
2332const 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
3159function 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
300459export async function getTrip ( tripId : string ) : Promise < VehicleJourney | null > {
0 commit comments