minotor 11.1.2 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.cspell.json +7 -1
  2. package/CHANGELOG.md +3 -3
  3. package/README.md +111 -86
  4. package/dist/cli/perf.d.ts +57 -18
  5. package/dist/cli.mjs +1371 -342
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/parser.cjs.js +57 -4
  8. package/dist/parser.cjs.js.map +1 -1
  9. package/dist/parser.esm.js +57 -4
  10. package/dist/parser.esm.js.map +1 -1
  11. package/dist/router.cjs.js +1 -1
  12. package/dist/router.cjs.js.map +1 -1
  13. package/dist/router.d.ts +5 -5
  14. package/dist/router.esm.js +1 -1
  15. package/dist/router.esm.js.map +1 -1
  16. package/dist/router.umd.js +1 -1
  17. package/dist/router.umd.js.map +1 -1
  18. package/dist/routing/__tests__/access.test.d.ts +1 -0
  19. package/dist/routing/__tests__/plainRouter.test.d.ts +1 -0
  20. package/dist/routing/__tests__/rangeResult.test.d.ts +1 -0
  21. package/dist/routing/__tests__/rangeRouter.test.d.ts +1 -0
  22. package/dist/routing/__tests__/rangeState.test.d.ts +1 -0
  23. package/dist/routing/__tests__/raptor.test.d.ts +1 -0
  24. package/dist/routing/__tests__/state.test.d.ts +1 -0
  25. package/dist/routing/access.d.ts +55 -0
  26. package/dist/routing/plainRouter.d.ts +21 -0
  27. package/dist/routing/plotter.d.ts +9 -0
  28. package/dist/routing/query.d.ts +132 -13
  29. package/dist/routing/rangeResult.d.ts +155 -0
  30. package/dist/routing/rangeRouter.d.ts +24 -0
  31. package/dist/routing/rangeState.d.ts +83 -0
  32. package/dist/routing/raptor.d.ts +96 -0
  33. package/dist/routing/result.d.ts +27 -7
  34. package/dist/routing/route.d.ts +5 -21
  35. package/dist/routing/router.d.ts +20 -91
  36. package/dist/routing/state.d.ts +92 -17
  37. package/dist/timetable/route.d.ts +8 -0
  38. package/dist/timetable/timetable.d.ts +17 -1
  39. package/package.json +1 -1
  40. package/src/__e2e__/benchmark.json +18 -0
  41. package/src/__e2e__/router.test.ts +461 -127
  42. package/src/cli/minotor.ts +39 -3
  43. package/src/cli/perf.ts +324 -60
  44. package/src/cli/repl.ts +96 -41
  45. package/src/router.ts +11 -3
  46. package/src/routing/__tests__/access.test.ts +294 -0
  47. package/src/routing/__tests__/plainRouter.test.ts +1633 -0
  48. package/src/routing/__tests__/plotter.test.ts +8 -8
  49. package/src/routing/__tests__/rangeResult.test.ts +273 -0
  50. package/src/routing/__tests__/rangeRouter.test.ts +472 -0
  51. package/src/routing/__tests__/rangeState.test.ts +246 -0
  52. package/src/routing/__tests__/raptor.test.ts +366 -0
  53. package/src/routing/__tests__/result.test.ts +27 -27
  54. package/src/routing/__tests__/route.test.ts +28 -0
  55. package/src/routing/__tests__/router.test.ts +75 -1587
  56. package/src/routing/__tests__/state.test.ts +78 -0
  57. package/src/routing/access.ts +144 -0
  58. package/src/routing/plainRouter.ts +60 -0
  59. package/src/routing/plotter.ts +53 -6
  60. package/src/routing/query.ts +116 -13
  61. package/src/routing/rangeResult.ts +292 -0
  62. package/src/routing/rangeRouter.ts +167 -0
  63. package/src/routing/rangeState.ts +150 -0
  64. package/src/routing/raptor.ts +416 -0
  65. package/src/routing/result.ts +68 -26
  66. package/src/routing/route.ts +15 -53
  67. package/src/routing/router.ts +40 -480
  68. package/src/routing/state.ts +191 -32
  69. package/src/timetable/__tests__/timetable.test.ts +373 -0
  70. package/src/timetable/route.ts +16 -4
  71. package/src/timetable/timetable.ts +54 -1
@@ -0,0 +1,416 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
+ import { StopId } from '../stops/stops.js';
3
+ import {
4
+ NOT_AVAILABLE,
5
+ Route,
6
+ StopRouteIndex,
7
+ TripRouteIndex,
8
+ } from '../timetable/route.js';
9
+ import { Duration, DURATION_ZERO, Time } from '../timetable/time.js';
10
+ import { Timetable, TripStop } from '../timetable/timetable.js';
11
+ import { QueryOptions } from './query.js';
12
+ import { RoutingEdge, TransferEdge, VehicleEdge } from './state.js';
13
+
14
+ /**
15
+ * Common interface for all variants of RAPTOR routing.
16
+ */
17
+ export interface IRaptorState {
18
+ /** Origin stop IDs for this run. */
19
+ readonly origins: StopId[];
20
+
21
+ /** Per-round routing graph; `graph[round][stop]` is the best edge used to reach `stop`. */
22
+ readonly graph: (RoutingEdge | undefined)[][];
23
+
24
+ /** Per-run earliest arrival at a stop. Used for boarding decisions. */
25
+ arrivalTime(stop: StopId): Time;
26
+
27
+ /**
28
+ * Tightest known upper bound on the arrival time at `stop` in `round`.
29
+ */
30
+ improvementBound(round: number, stop: StopId): Time;
31
+
32
+ /**
33
+ * Best known arrival time at any destination.
34
+ */
35
+ readonly destinationBest: Time;
36
+
37
+ /** Returns `true` if `stop` is one of the query's destination stops. */
38
+ isDestination(stop: StopId): boolean;
39
+
40
+ /**
41
+ * Records a new arrival at `stop`, updating all relevant state.
42
+ *
43
+ * In Range RAPTOR mode this also updates the cross-run shared labels.
44
+ */
45
+ updateArrival(stop: StopId, time: Time, round: number): void;
46
+
47
+ /**
48
+ * Propagates labels from round `k-1` into round `k` before routes are scanned.
49
+ * No-op in standard RAPTOR mode.
50
+ */
51
+ initRound(round: number): void;
52
+ }
53
+
54
+ type TripContinuation = TripStop & {
55
+ previousEdge: VehicleEdge;
56
+ };
57
+
58
+ type Round = number;
59
+
60
+ /**
61
+ * Encapsulates the core RAPTOR algorithm, operating on a {@link Timetable} and
62
+ * an {@link IRaptorState} provided by the caller.
63
+ *
64
+ * @see https://www.microsoft.com/en-us/research/wp-content/uploads/2012/01/raptor_alenex.pdf
65
+ */
66
+ export class Raptor {
67
+ private readonly timetable: Timetable;
68
+
69
+ constructor(timetable: Timetable) {
70
+ this.timetable = timetable;
71
+ }
72
+
73
+ run(options: QueryOptions, state: IRaptorState): void {
74
+ const markedStops = new Set<StopId>(state.origins);
75
+
76
+ for (let round = 1; round <= options.maxTransfers + 1; round++) {
77
+ state.initRound(round);
78
+
79
+ const edgesAtCurrentRound = state.graph[round]!;
80
+ const reachableRoutes = this.timetable.findReachableRoutes(
81
+ markedStops,
82
+ options.transportModes,
83
+ );
84
+ markedStops.clear();
85
+
86
+ for (const [route, hopOnStopIndex] of reachableRoutes) {
87
+ for (const stop of this.scanRoute(
88
+ route,
89
+ hopOnStopIndex,
90
+ round,
91
+ state,
92
+ options,
93
+ )) {
94
+ markedStops.add(stop);
95
+ }
96
+ }
97
+
98
+ let continuations = this.findTripContinuations(
99
+ markedStops,
100
+ edgesAtCurrentRound,
101
+ );
102
+ const stopsFromContinuations = new Set<StopId>();
103
+ while (continuations.length > 0) {
104
+ stopsFromContinuations.clear();
105
+ for (const continuation of continuations) {
106
+ const route = this.timetable.getRoute(continuation.routeId)!;
107
+ for (const stop of this.scanRouteContinuation(
108
+ route,
109
+ continuation.stopIndex,
110
+ round,
111
+ state,
112
+ continuation,
113
+ )) {
114
+ stopsFromContinuations.add(stop);
115
+ markedStops.add(stop);
116
+ }
117
+ }
118
+ continuations = this.findTripContinuations(
119
+ stopsFromContinuations,
120
+ edgesAtCurrentRound,
121
+ );
122
+ }
123
+
124
+ for (const stop of this.considerTransfers(
125
+ options,
126
+ round,
127
+ markedStops,
128
+ state,
129
+ )) {
130
+ markedStops.add(stop);
131
+ }
132
+
133
+ if (markedStops.size === 0) break;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Finds trip continuations for the given marked stops and edges at the current round.
139
+ * @param markedStops The set of marked stops.
140
+ * @param edgesAtCurrentRound The array of edges at the current round, indexed by stop ID.
141
+ * @returns An array of trip continuations.
142
+ */
143
+ private findTripContinuations(
144
+ markedStops: Set<StopId>,
145
+ edgesAtCurrentRound: (RoutingEdge | undefined)[],
146
+ ): TripContinuation[] {
147
+ const continuations: TripContinuation[] = [];
148
+ for (const stopId of markedStops) {
149
+ const arrival = edgesAtCurrentRound[stopId];
150
+ if (!arrival || !('routeId' in arrival)) continue;
151
+
152
+ const continuousTrips = this.timetable.getContinuousTrips(
153
+ arrival.hopOffStopIndex,
154
+ arrival.routeId,
155
+ arrival.tripIndex,
156
+ );
157
+ for (const trip of continuousTrips) {
158
+ continuations.push({
159
+ routeId: trip.routeId,
160
+ stopIndex: trip.stopIndex,
161
+ tripIndex: trip.tripIndex,
162
+ previousEdge: arrival,
163
+ });
164
+ }
165
+ }
166
+ return continuations;
167
+ }
168
+
169
+ /**
170
+ * Scans a route for an in-seat trip continuation.
171
+ *
172
+ * The boarded trip and entry stop are fixed, so there is no need to probe for
173
+ * earlier boardings.
174
+ *
175
+ * @param route The route to scan
176
+ * @param hopOnStopIndex The stop index where the continuation begins
177
+ * @param round The current RAPTOR round
178
+ * @param routingState Current routing state
179
+ * @param tripContinuation The in-seat continuation descriptor
180
+ * @param shared Optional shared state for Range RAPTOR mode
181
+ */
182
+ private scanRouteContinuation(
183
+ route: Route,
184
+ hopOnStopIndex: StopRouteIndex,
185
+ round: Round,
186
+ state: IRaptorState,
187
+ tripContinuation: TripContinuation,
188
+ ): Set<StopId> {
189
+ const newlyMarkedStops = new Set<StopId>();
190
+ const edgesAtCurrentRound = state.graph[round]!;
191
+
192
+ const nbStops = route.getNbStops();
193
+ const routeId = route.id;
194
+ const tripIndex = tripContinuation.tripIndex;
195
+ const tripStopOffset = route.tripStopOffset(tripIndex);
196
+ const previousEdge = tripContinuation.previousEdge;
197
+
198
+ for (
199
+ let currentStopIndex = hopOnStopIndex;
200
+ currentStopIndex < nbStops;
201
+ currentStopIndex++
202
+ ) {
203
+ const currentStop: StopId = route.stops[currentStopIndex]!;
204
+ const arrivalTime = route.arrivalAtOffset(
205
+ currentStopIndex,
206
+ tripStopOffset,
207
+ );
208
+ const dropOffType = route.dropOffTypeAtOffset(
209
+ currentStopIndex,
210
+ tripStopOffset,
211
+ );
212
+
213
+ if (
214
+ dropOffType !== NOT_AVAILABLE &&
215
+ arrivalTime < state.improvementBound(round, currentStop) &&
216
+ arrivalTime < state.destinationBest
217
+ ) {
218
+ edgesAtCurrentRound[currentStop] = {
219
+ routeId,
220
+ stopIndex: hopOnStopIndex,
221
+ tripIndex,
222
+ arrival: arrivalTime,
223
+ hopOffStopIndex: currentStopIndex,
224
+ continuationOf: previousEdge,
225
+ };
226
+ state.updateArrival(currentStop, arrivalTime, round);
227
+ newlyMarkedStops.add(currentStop);
228
+ }
229
+ }
230
+ return newlyMarkedStops;
231
+ }
232
+
233
+ /**
234
+ * Scans a route using the standard RAPTOR boarding logic.
235
+ *
236
+ * Iterates through all stops from the hop-on point, maintaining the current
237
+ * best trip and improving arrival times when possible. At each marked stop it
238
+ * also checks whether an earlier (or first) trip can be boarded, upgrading the
239
+ * active trip when one is found.
240
+ *
241
+ * @param route The route to scan
242
+ * @param hopOnStopIndex The stop index where passengers can first board
243
+ * @param round The current RAPTOR round
244
+ * @param state Current routing state
245
+ * @param options Query options (minTransferTime, etc.)
246
+ */
247
+ private scanRoute(
248
+ route: Route,
249
+ hopOnStopIndex: StopRouteIndex,
250
+ round: Round,
251
+ state: IRaptorState,
252
+ options: QueryOptions,
253
+ ): Set<StopId> {
254
+ const newlyMarkedStops = new Set<StopId>();
255
+ const edgesAtCurrentRound = state.graph[round]!;
256
+ const edgesAtPreviousRound = state.graph[round - 1]!;
257
+
258
+ const nbStops = route.getNbStops();
259
+ const routeId = route.id;
260
+ let activeTripIndex: TripRouteIndex | undefined;
261
+ let activeTripBoardStopIndex = hopOnStopIndex;
262
+ // tripStopOffset = activeTripIndex * nbStops, precomputed when the trip changes.
263
+ let activeTripStopOffset = 0;
264
+
265
+ for (
266
+ let currentStopIndex = hopOnStopIndex;
267
+ currentStopIndex < nbStops;
268
+ currentStopIndex++
269
+ ) {
270
+ const currentStop: StopId = route.stops[currentStopIndex]!;
271
+
272
+ // If on a trip, check whether alighting here improves the global best.
273
+ if (activeTripIndex !== undefined) {
274
+ const arrivalTime = route.arrivalAtOffset(
275
+ currentStopIndex,
276
+ activeTripStopOffset,
277
+ );
278
+ const dropOffType = route.dropOffTypeAtOffset(
279
+ currentStopIndex,
280
+ activeTripStopOffset,
281
+ );
282
+
283
+ if (
284
+ dropOffType !== NOT_AVAILABLE &&
285
+ arrivalTime < state.improvementBound(round, currentStop) &&
286
+ arrivalTime < state.destinationBest
287
+ ) {
288
+ edgesAtCurrentRound[currentStop] = {
289
+ routeId,
290
+ stopIndex: activeTripBoardStopIndex,
291
+ tripIndex: activeTripIndex,
292
+ arrival: arrivalTime,
293
+ hopOffStopIndex: currentStopIndex,
294
+ };
295
+ state.updateArrival(currentStop, arrivalTime, round);
296
+ newlyMarkedStops.add(currentStop);
297
+ }
298
+ }
299
+
300
+ // Check whether we can board an earlier (or first) trip at this stop.
301
+ const previousEdge = edgesAtPreviousRound[currentStop];
302
+ const earliestArrivalOnPreviousRound = previousEdge?.arrival;
303
+ if (
304
+ earliestArrivalOnPreviousRound !== undefined &&
305
+ (activeTripIndex === undefined ||
306
+ earliestArrivalOnPreviousRound <=
307
+ route.departureAtOffset(currentStopIndex, activeTripStopOffset))
308
+ ) {
309
+ const earliestTrip = route.findEarliestTrip(
310
+ currentStopIndex,
311
+ earliestArrivalOnPreviousRound,
312
+ activeTripIndex,
313
+ );
314
+ if (earliestTrip === undefined) {
315
+ continue;
316
+ }
317
+
318
+ const fromTripStop =
319
+ previousEdge && 'routeId' in previousEdge
320
+ ? {
321
+ stopIndex: previousEdge.hopOffStopIndex,
322
+ routeId: previousEdge.routeId,
323
+ tripIndex: previousEdge.tripIndex,
324
+ }
325
+ : undefined;
326
+ const firstBoardableTrip = this.timetable.findFirstBoardableTrip(
327
+ currentStopIndex,
328
+ route,
329
+ earliestTrip,
330
+ earliestArrivalOnPreviousRound,
331
+ activeTripIndex,
332
+ fromTripStop,
333
+ options.minTransferTime,
334
+ );
335
+
336
+ if (firstBoardableTrip !== undefined) {
337
+ // At round 1, enforce maxInitialWaitingTime: skip boarding if the
338
+ // traveler would have to wait longer than the allowed threshold at
339
+ // the first boarding stop.
340
+ const exceedsInitialWait =
341
+ round === 1 &&
342
+ options.maxInitialWaitingTime !== undefined &&
343
+ route.departureFrom(currentStopIndex, firstBoardableTrip) -
344
+ earliestArrivalOnPreviousRound >
345
+ options.maxInitialWaitingTime;
346
+
347
+ if (!exceedsInitialWait) {
348
+ activeTripIndex = firstBoardableTrip;
349
+ activeTripBoardStopIndex = currentStopIndex;
350
+ activeTripStopOffset = route.tripStopOffset(firstBoardableTrip);
351
+ }
352
+ }
353
+ }
354
+ }
355
+ return newlyMarkedStops;
356
+ }
357
+
358
+ /**
359
+ * Processes all currently marked stops to find available transfers
360
+ * and determines if using these transfers would result in earlier arrival times
361
+ * at destination stops. It handles different transfer types including in-seat
362
+ * transfers and walking transfers with appropriate minimum transfer times.
363
+ *
364
+ * @param options Query options (minTransferTime, etc.)
365
+ * @param round The current round number in the RAPTOR algorithm
366
+ * @param markedStops The set of currently marked stops
367
+ * @param state Current routing state
368
+ */
369
+ private considerTransfers(
370
+ options: QueryOptions,
371
+ round: number,
372
+ markedStops: Set<StopId>,
373
+ state: IRaptorState,
374
+ ): Set<StopId> {
375
+ const newlyMarkedStops = new Set<StopId>();
376
+ const arrivalsAtCurrentRound = state.graph[round]!;
377
+ for (const stop of markedStops) {
378
+ const currentArrival = arrivalsAtCurrentRound[stop];
379
+ // Skip transfers if the last leg was also a transfer
380
+ if (!currentArrival || 'type' in currentArrival) continue;
381
+ const transfers = this.timetable.getTransfers(stop);
382
+ for (const transfer of transfers) {
383
+ let transferTime: Duration;
384
+ if (transfer.minTransferTime) {
385
+ transferTime = transfer.minTransferTime;
386
+ } else if (transfer.type === 'IN_SEAT') {
387
+ transferTime = DURATION_ZERO;
388
+ } else {
389
+ transferTime = options.minTransferTime;
390
+ }
391
+ const arrivalAfterTransfer = currentArrival.arrival + transferTime;
392
+
393
+ if (
394
+ arrivalAfterTransfer <
395
+ state.improvementBound(round, transfer.destination) &&
396
+ arrivalAfterTransfer < state.destinationBest
397
+ ) {
398
+ arrivalsAtCurrentRound[transfer.destination] = {
399
+ arrival: arrivalAfterTransfer,
400
+ from: stop,
401
+ to: transfer.destination,
402
+ minTransferTime: transferTime || undefined,
403
+ type: transfer.type,
404
+ } as TransferEdge;
405
+ state.updateArrival(
406
+ transfer.destination,
407
+ arrivalAfterTransfer,
408
+ round,
409
+ );
410
+ newlyMarkedStops.add(transfer.destination);
411
+ }
412
+ }
413
+ }
414
+ return newlyMarkedStops;
415
+ }
416
+ }
@@ -3,9 +3,9 @@ import { SourceStopId, StopId } from '../stops/stops.js';
3
3
  import { StopsIndex } from '../stops/stopsIndex.js';
4
4
  import { RawPickUpDropOffType } from '../timetable/route.js';
5
5
  import { Time } from '../timetable/time.js';
6
- import { Query } from './query.js';
7
- import { Leg, Route, Transfer, VehicleLeg } from './route.js';
6
+ import { Access, Leg, Route, Transfer, VehicleLeg } from './route.js';
8
7
  import { Arrival, RoutingState, TransferEdge, VehicleEdge } from './router.js';
8
+ import { AccessEdge } from './state.js';
9
9
 
10
10
  /**
11
11
  * Details about the pickup and drop-off modalities at each stop in each trip of a route.
@@ -42,26 +42,49 @@ const toPickupDropOffType = (
42
42
  };
43
43
 
44
44
  export class Result {
45
- private readonly query: Query;
45
+ private readonly destinations: ReadonlySet<StopId>;
46
46
  public readonly routingState: RoutingState;
47
47
  public readonly stopsIndex: StopsIndex;
48
48
  public readonly timetable: Timetable;
49
49
 
50
50
  constructor(
51
- query: Query,
51
+ destinations: ReadonlySet<StopId>,
52
52
  routingState: RoutingState,
53
53
  stopsIndex: StopsIndex,
54
54
  timetable: Timetable,
55
55
  ) {
56
- this.query = query;
56
+ this.destinations = destinations;
57
57
  this.routingState = routingState;
58
58
  this.stopsIndex = stopsIndex;
59
59
  this.timetable = timetable;
60
60
  }
61
61
 
62
+ /**
63
+ * Expands a target stop or stop set to all equivalent concrete stop IDs.
64
+ *
65
+ * When `to` is omitted, defaults to the resolved destinations stored on this
66
+ * result.
67
+ *
68
+ * Equivalent stops are expanded here so destination handling has a single
69
+ * source of truth shared by route reconstruction and arrival lookups.
70
+ */
71
+ private expandDestinations(to?: StopId | Set<StopId>): Set<StopId> {
72
+ const targets: Iterable<StopId> =
73
+ to instanceof Set ? to : to !== undefined ? [to] : this.destinations;
74
+
75
+ const expanded = new Set<StopId>();
76
+ for (const target of targets) {
77
+ for (const equivalentStop of this.stopsIndex.equivalentStops(target)) {
78
+ expanded.add(equivalentStop.id);
79
+ }
80
+ }
81
+ return expanded;
82
+ }
83
+
62
84
  /**
63
85
  * Reconstructs the best route to a stop by SourceStopId.
64
- * (to any stop reachable in less time / transfers than the destination(s) of the query)
86
+ * (to any stop reachable in less time / transfers than this result's
87
+ * destination set)
65
88
  *
66
89
  * @param to The destination stop by SourceStopId.
67
90
  * @returns a route to the destination stop if it exists.
@@ -83,33 +106,30 @@ export class Result {
83
106
 
84
107
  /**
85
108
  * Reconstructs the best route to a stop.
86
- * (to any stop reachable in less time / transfers than the destination(s) of the query)
109
+ * (to any stop reachable in less time / transfers than this result's
110
+ * destination set)
87
111
  *
88
- * @param to The destination stop. Defaults to the destination of the original query.
112
+ * @param to The destination stop. Defaults to this result's resolved
113
+ * destinations.
89
114
  * @returns a route to the destination stop if it exists.
90
115
  */
91
116
  bestRoute(to?: StopId | Set<StopId>): Route | undefined {
92
- const destinationIterable: Iterable<StopId> =
93
- to instanceof Set ? to : to ? [to] : this.query.to;
117
+ const destinationStops = this.expandDestinations(to);
94
118
 
95
119
  // Find the fastest-reached destination across all equivalent stops.
96
120
  let fastestDestination: StopId | undefined = undefined;
97
121
  let fastestArrivalTime: Time | undefined = undefined;
98
122
  let fastestLegNumber: number | undefined = undefined;
99
- for (const sourceDestination of destinationIterable) {
100
- const equivalentStops =
101
- this.stopsIndex.equivalentStops(sourceDestination);
102
- for (const destination of equivalentStops) {
103
- const arrivalData = this.routingState.getArrival(destination.id);
104
- if (
105
- arrivalData !== undefined &&
106
- (fastestArrivalTime === undefined ||
107
- arrivalData.arrival < fastestArrivalTime)
108
- ) {
109
- fastestDestination = destination.id;
110
- fastestArrivalTime = arrivalData.arrival;
111
- fastestLegNumber = arrivalData.legNumber;
112
- }
123
+ for (const destination of destinationStops) {
124
+ const arrivalData = this.routingState.getArrival(destination);
125
+ if (
126
+ arrivalData !== undefined &&
127
+ (fastestArrivalTime === undefined ||
128
+ arrivalData.arrival < fastestArrivalTime)
129
+ ) {
130
+ fastestDestination = destination;
131
+ fastestArrivalTime = arrivalData.arrival;
132
+ fastestLegNumber = arrivalData.legNumber;
113
133
  }
114
134
  }
115
135
  if (fastestDestination === undefined || fastestLegNumber === undefined) {
@@ -121,9 +141,11 @@ export class Result {
121
141
  let currentStop = fastestDestination;
122
142
  let round = fastestLegNumber;
123
143
  let previousVehicleEdge: VehicleEdge | undefined;
124
- while (round > 0) {
144
+
145
+ while (round >= 0) {
125
146
  const edge = this.routingState.graph[round]?.[currentStop];
126
147
  if (!edge) {
148
+ if (round === 0) break;
127
149
  throw new Error(
128
150
  `No edge arriving at stop ${currentStop} at round ${round}`,
129
151
  );
@@ -175,6 +197,9 @@ export class Result {
175
197
  } else if ('type' in edge) {
176
198
  leg = this.buildTransferLeg(edge);
177
199
  previousVehicleEdge = undefined;
200
+ } else if ('duration' in edge) {
201
+ leg = this.buildAccessLeg(edge);
202
+ previousVehicleEdge = undefined;
178
203
  } else {
179
204
  break;
180
205
  }
@@ -250,6 +275,22 @@ export class Result {
250
275
  };
251
276
  }
252
277
 
278
+ /**
279
+ * Builds a transfer leg from a transfer edge.
280
+ *
281
+ * @param edge Transfer edge representing a walking connection between stops
282
+ * @returns A transfer leg with from/to stops and transfer details
283
+ */
284
+ private buildAccessLeg(edge: AccessEdge): Access {
285
+ return {
286
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
287
+ from: this.stopsIndex.findStopById(edge.from)!,
288
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
289
+ to: this.stopsIndex.findStopById(edge.to)!,
290
+ duration: edge.duration,
291
+ };
292
+ }
293
+
253
294
  /**
254
295
  * Builds a guaranteed transfer leg between two consecutive vehicle legs.
255
296
  *
@@ -278,7 +319,8 @@ export class Result {
278
319
  }
279
320
 
280
321
  /**
281
- * Returns the arrival time at any stop reachable in less time / transfers than the destination(s) of the query)
322
+ * Returns the arrival time at any stop reachable in less time / transfers
323
+ * than this result's destination set.
282
324
  *
283
325
  * @param stop The target stop for which to return the arrival time.
284
326
  * @param maxTransfers The optional maximum number of transfers allowed.
@@ -1,4 +1,4 @@
1
- import { Stop, StopId } from '../stops/stops.js';
1
+ import { Stop } from '../stops/stops.js';
2
2
  import {
3
3
  Duration,
4
4
  DURATION_ZERO,
@@ -9,21 +9,6 @@ import {
9
9
  } from '../timetable/time.js';
10
10
  import { ServiceRouteInfo, TransferType } from '../timetable/timetable.js';
11
11
 
12
- export type JsonLeg = {
13
- from: StopId;
14
- to: StopId;
15
- } & (
16
- | {
17
- departure: string;
18
- arrival: string;
19
- route: ServiceRouteInfo;
20
- }
21
- | {
22
- type: TransferType;
23
- minTransferTime?: number;
24
- }
25
- );
26
-
27
12
  export type PickUpDropOffType =
28
13
  | 'REGULAR'
29
14
  | 'NOT_AVAILABLE'
@@ -35,6 +20,10 @@ export type BaseLeg = {
35
20
  to: Stop;
36
21
  };
37
22
 
23
+ export type Access = BaseLeg & {
24
+ duration: Duration;
25
+ };
26
+
38
27
  export type Transfer = BaseLeg & {
39
28
  minTransferTime?: Duration;
40
29
  type: TransferType;
@@ -48,7 +37,7 @@ export type VehicleLeg = BaseLeg & {
48
37
  dropOffType: PickUpDropOffType;
49
38
  };
50
39
 
51
- export type Leg = Transfer | VehicleLeg;
40
+ export type Leg = Transfer | Access | VehicleLeg;
52
41
 
53
42
  /**
54
43
  * Represents a resolved route consisting of multiple legs,
@@ -68,15 +57,15 @@ export class Route {
68
57
  * @throws If no vehicle leg is found in the route.
69
58
  */
70
59
  departureTime(): Time {
71
- let cumulativeTransferTime: Duration = DURATION_ZERO;
60
+ let cumulativeAccessTime: Duration = DURATION_ZERO;
72
61
  for (let i = 0; i < this.legs.length; i++) {
73
62
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
74
63
  const leg = this.legs[i]!;
75
64
  if ('departureTime' in leg) {
76
- return leg.departureTime - cumulativeTransferTime;
65
+ return leg.departureTime - cumulativeAccessTime;
77
66
  }
78
- if ('minTransferTime' in leg && leg.minTransferTime) {
79
- cumulativeTransferTime += leg.minTransferTime;
67
+ if ('duration' in leg && leg.duration) {
68
+ cumulativeAccessTime += leg.duration;
80
69
  }
81
70
  }
82
71
  throw new Error('No vehicle leg found in route');
@@ -132,6 +121,10 @@ export class Route {
132
121
  'type' in leg && !('route' in leg)
133
122
  ? `Transfer: ${leg.type}${leg.minTransferTime ? `, Minimum Transfer Time: ${durationToString(leg.minTransferTime)}` : ''}`
134
123
  : '';
124
+ const accessDetails =
125
+ 'duration' in leg
126
+ ? `Access duration: ${durationToString(leg.duration)}`
127
+ : '';
135
128
  const travelDetails =
136
129
  'route' in leg && 'departureTime' in leg && 'arrivalTime' in leg
137
130
  ? `Route: ${leg.route.type} ${leg.route.name}, Departure: ${timeToString(leg.departureTime)}, Arrival: ${timeToString(leg.arrivalTime)}`
@@ -142,6 +135,7 @@ export class Route {
142
135
  ` ${fromStop}`,
143
136
  ` ${toStop}`,
144
137
  transferDetails ? ` ${transferDetails}` : '',
138
+ accessDetails ? ` ${accessDetails}` : '',
145
139
  travelDetails ? ` ${travelDetails}` : '',
146
140
  ]
147
141
  .filter((line) => line.trim() !== '')
@@ -149,36 +143,4 @@ export class Route {
149
143
  })
150
144
  .join('\n');
151
145
  }
152
-
153
- /**
154
- * Generates a concise JSON representation of the route.
155
- * This is particularly useful for generating regression tests
156
- * to verify the correctness of route calculations.
157
- *
158
- * @returns A JSON representation of the route.
159
- */
160
- asJson(): JsonLeg[] {
161
- const jsonLegs: JsonLeg[] = this.legs.map((leg: Leg) => {
162
- if ('route' in leg) {
163
- return {
164
- from: leg.from.id,
165
- to: leg.to.id,
166
- departure: timeToString(leg.departureTime),
167
- arrival: timeToString(leg.arrivalTime),
168
- route: leg.route,
169
- };
170
- } else {
171
- return {
172
- from: leg.from.id,
173
- to: leg.to.id,
174
- type: leg.type,
175
- ...(leg.minTransferTime !== undefined && {
176
- minTransferTime: leg.minTransferTime,
177
- }),
178
- };
179
- }
180
- });
181
-
182
- return jsonLegs;
183
- }
184
146
  }