minotor 11.2.1 → 11.2.3

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.
@@ -5,6 +5,14 @@ export type QueryOptions = {
5
5
  maxTransfers: number;
6
6
  minTransferTime: Duration;
7
7
  transportModes: Set<RouteType>;
8
+ /**
9
+ * Maximum total journey duration (in minutes) from the query departure time.
10
+ *
11
+ * When set, arrivals after `departureTime + maxDuration` are skipped. The
12
+ * duration includes initial access, waiting time, transit legs, and transfers.
13
+ * Undefined means no limit.
14
+ */
15
+ maxDuration?: Duration;
8
16
  /**
9
17
  * Maximum time (in minutes) the traveler is willing to wait at the first
10
18
  * boarding stop before the first transit vehicle departs.
@@ -61,6 +69,12 @@ export declare class Query {
61
69
  * Restricts routing to the given transport modes.
62
70
  */
63
71
  transportModes(transportModes: Set<RouteType>): /*elided*/ any;
72
+ /**
73
+ * Sets the maximum total journey duration (in minutes) from the query
74
+ * departure time. The limit includes initial access, waiting time, transit
75
+ * legs, and transfers.
76
+ */
77
+ maxDuration(maxDuration: Duration): /*elided*/ any;
64
78
  /**
65
79
  * Sets the maximum time (in minutes) the traveler is willing to wait at
66
80
  * the first boarding stop before the first transit vehicle departs.
@@ -162,6 +176,12 @@ export declare class RangeQuery extends Query {
162
176
  * Restricts routing to the given transport modes.
163
177
  */
164
178
  transportModes(transportModes: Set<RouteType>): /*elided*/ any;
179
+ /**
180
+ * Sets the maximum total journey duration (in minutes) from the query
181
+ * departure time. The limit includes initial access, waiting time, transit
182
+ * legs, and transfers.
183
+ */
184
+ maxDuration(maxDuration: Duration): /*elided*/ any;
165
185
  /**
166
186
  * Sets the maximum time (in minutes) the traveler is willing to wait at
167
187
  * the first boarding stop before the first transit vehicle departs.
@@ -68,8 +68,9 @@ export declare class RangeRaptorState implements IRaptorState {
68
68
  * Always at least as tight as the per-run `destinationBest`.
69
69
  */
70
70
  get destinationBest(): Time;
71
+ get maxArrivalTime(): Time;
71
72
  isDestination(stop: StopId): boolean;
72
- /** Updates both the per-run state and the cross-run shared labels. */
73
+ /** Updates the per-run aggregate best when improved, and always considers the cross-run shared label. */
73
74
  updateArrival(stop: StopId, time: Time, round: number): void;
74
75
  /**
75
76
  * initialized round `k` from round `k-1`: τk(p) ← min(τk(p), τk-1(p)).
@@ -21,6 +21,8 @@ export interface IRaptorState {
21
21
  * Best known arrival time at any destination.
22
22
  */
23
23
  readonly destinationBest: Time;
24
+ /** Latest arrival time allowed by the current query/run. */
25
+ readonly maxArrivalTime: Time;
24
26
  /** Returns `true` if `stop` is one of the query's destination stops. */
25
27
  isDestination(stop: StopId): boolean;
26
28
  /**
@@ -76,13 +76,23 @@ export declare class RoutingState implements IRaptorState {
76
76
  * {@link updateArrival} so that destination pruning is always O(1).
77
77
  */
78
78
  private _destinationBest;
79
+ /**
80
+ * Maximum arrival time allowed for this run. Defaults to UNREACHED_TIME when
81
+ * the query has no maxDuration limit.
82
+ */
83
+ maxArrivalTime: Time;
84
+ /**
85
+ * Query-level maximum duration, retained so resetFor() can recompute the
86
+ * absolute max arrival time for each departure-time iteration.
87
+ */
88
+ private readonly maxDuration?;
79
89
  /**
80
90
  * Every stop that has received an arrival improvement during the current run,
81
91
  * in the order the improvements occurred. Used by {@link resetFor} to clear
82
92
  * only the touched entries instead of scanning the entire array.
83
93
  */
84
94
  private readonly reachedStops;
85
- constructor(departureTime: Time, destinations: StopId[], accessPaths: AccessPoint[], nbStops: number, maxRounds?: number);
95
+ constructor(departureTime: Time, destinations: StopId[], accessPaths: AccessPoint[], nbStops: number, maxRounds?: number, maxDuration?: Duration);
86
96
  /**
87
97
  * Seeds round-0 arrivals and {@link origins} from a set of access paths.
88
98
  * Called by the constructor and by {@link resetFor}.
@@ -2,7 +2,8 @@ import { StopId } from '../stops/stops.js';
2
2
  import { Route, RouteId, StopRouteIndex, TripRouteIndex } from './route.js';
3
3
  import { Duration, Time } from './time.js';
4
4
  import { TripStopId } from './tripStopId.js';
5
- export type TransferType = 'RECOMMENDED' | 'GUARANTEED' | 'REQUIRES_MINIMAL_TIME' | 'IN_SEAT';
5
+ export type TransferType = // TODO use number to represent that.
6
+ 'RECOMMENDED' | 'GUARANTEED' | 'REQUIRES_MINIMAL_TIME' | 'IN_SEAT';
6
7
  export type Transfer = {
7
8
  destination: StopId;
8
9
  type: TransferType;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minotor",
3
- "version": "11.2.1",
3
+ "version": "11.2.3",
4
4
  "description": "A lightweight client-side transit routing library.",
5
5
  "keywords": [
6
6
  "minotor",
package/src/cli/perf.ts CHANGED
@@ -127,7 +127,8 @@ export const loadQueriesFromJson = (
127
127
  .from(fromStop.id)
128
128
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
129
129
  .to(new Set(toStops.map((stop) => stop!.id)))
130
- .departureTime(timeFromString(serializedQuery.departureTime));
130
+ .departureTime(timeFromString(serializedQuery.departureTime))
131
+ .maxDuration(6 * 60);
131
132
 
132
133
  if (serializedQuery.maxTransfers !== undefined) {
133
134
  queryBuilder.maxTransfers(serializedQuery.maxTransfers);
@@ -180,7 +181,8 @@ export const loadRangeQueriesFromJson = (
180
181
  .to(new Set(toStops.map((stop) => stop!.id)))
181
182
  .departureTime(timeFromString(serializedQuery.departureTime))
182
183
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
183
- .lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!));
184
+ .lastDepartureTime(timeFromString(serializedQuery.lastDepartureTime!))
185
+ .maxDuration(6 * 60);
184
186
 
185
187
  if (serializedQuery.maxTransfers !== undefined) {
186
188
  queryBuilder.maxTransfers(serializedQuery.maxTransfers);
package/src/cli/repl.ts CHANGED
@@ -49,8 +49,11 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
49
49
  this.displayPrompt();
50
50
  },
51
51
  });
52
+ const routeSyntax =
53
+ '.route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers] [wait max <N> minutes]';
54
+
52
55
  replServer.defineCommand('route', {
53
- help: 'Find a route using .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
56
+ help: `Find a route using ${routeSyntax}`,
54
57
  action(routeQuery: string) {
55
58
  this.clearBufferedCommand();
56
59
  const parts = routeQuery.split(' ').filter(Boolean);
@@ -60,11 +63,13 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
60
63
  const atIndex = parts.indexOf('at');
61
64
  const beforeIndex = parts.indexOf('before');
62
65
  const withIndex = parts.indexOf('with');
66
+ const waitIndex = parts.indexOf('wait');
67
+ const routeClauseIndexes = [beforeIndex, withIndex, waitIndex].filter(
68
+ (index) => index !== -1,
69
+ );
63
70
 
64
71
  if (fromIndex === -1 || toIndex === -1 || atIndex === -1) {
65
- console.log(
66
- 'Usage: .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
67
- );
72
+ console.log(`Usage: ${routeSyntax}`);
68
73
  this.displayPrompt();
69
74
  return;
70
75
  }
@@ -72,17 +77,20 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
72
77
  const fromId = parts.slice(fromIndex + 1, toIndex).join(' ');
73
78
  const toId = parts.slice(toIndex + 1, atIndex).join(' ');
74
79
 
75
- // atTime ends at 'before', 'with', or the end of the input.
76
- const atTimeEnd =
77
- beforeIndex !== -1
78
- ? beforeIndex
79
- : withIndex !== -1
80
- ? withIndex
81
- : parts.length;
80
+ // atTime ends at 'before', 'with', 'wait', or the end of the input.
81
+ const atTimeEnd = Math.min(
82
+ ...routeClauseIndexes.filter((index) => index > atIndex),
83
+ parts.length,
84
+ );
82
85
  const atTime = parts.slice(atIndex + 1, atTimeEnd).join(' ');
83
86
 
84
87
  // beforeTime is only present when the 'before' keyword appears.
85
- const beforeTimeEnd = withIndex !== -1 ? withIndex : parts.length;
88
+ const beforeTimeEnd = Math.min(
89
+ ...[withIndex, waitIndex].filter(
90
+ (index) => index !== -1 && index > beforeIndex,
91
+ ),
92
+ parts.length,
93
+ );
86
94
  const beforeTime =
87
95
  beforeIndex !== -1
88
96
  ? parts.slice(beforeIndex + 1, beforeTimeEnd).join(' ')
@@ -92,11 +100,23 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
92
100
  withIndex !== -1 && parts[withIndex + 1] !== undefined
93
101
  ? parseInt(parts[withIndex + 1] as string)
94
102
  : 4;
95
-
96
- if (!fromId || !toId || !atTime) {
97
- console.log(
98
- 'Usage: .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
99
- );
103
+ const maxInitialWaitingTime =
104
+ waitIndex !== -1 &&
105
+ parts[waitIndex + 1] === 'max' &&
106
+ parts[waitIndex + 2] !== undefined
107
+ ? Number(parts[waitIndex + 2])
108
+ : undefined;
109
+ const waitUnit = parts[waitIndex + 3];
110
+ const hasInvalidWaitClause =
111
+ waitIndex !== -1 &&
112
+ (parts[waitIndex + 1] !== 'max' ||
113
+ maxInitialWaitingTime === undefined ||
114
+ !Number.isFinite(maxInitialWaitingTime) ||
115
+ maxInitialWaitingTime < 0 ||
116
+ (waitUnit !== 'minute' && waitUnit !== 'minutes'));
117
+
118
+ if (!fromId || !toId || !atTime || hasInvalidWaitClause) {
119
+ console.log(`Usage: ${routeSyntax}`);
100
120
  this.displayPrompt();
101
121
  return;
102
122
  }
@@ -131,13 +151,16 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
131
151
  const router = new Router(timetable, stopsIndex);
132
152
  if (beforeTime !== undefined) {
133
153
  const lastDepartureTime = timeFromString(beforeTime);
134
- const query = new RangeQuery.Builder()
154
+ const queryBuilder = new RangeQuery.Builder()
135
155
  .from(fromStop.id)
136
156
  .to(toStop.id)
137
157
  .departureTime(departureTime)
138
158
  .lastDepartureTime(lastDepartureTime)
139
- .maxTransfers(maxTransfers)
140
- .build();
159
+ .maxTransfers(maxTransfers);
160
+ if (maxInitialWaitingTime !== undefined) {
161
+ queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime);
162
+ }
163
+ const query = queryBuilder.build();
141
164
 
142
165
  const result = router.rangeRoute(query);
143
166
 
@@ -160,12 +183,15 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
160
183
  });
161
184
  }
162
185
  } else {
163
- const query = new Query.Builder()
186
+ const queryBuilder = new Query.Builder()
164
187
  .from(fromStop.id)
165
188
  .to(toStop.id)
166
189
  .departureTime(departureTime)
167
- .maxTransfers(maxTransfers)
168
- .build();
190
+ .maxTransfers(maxTransfers);
191
+ if (maxInitialWaitingTime !== undefined) {
192
+ queryBuilder.maxInitialWaitingTime(maxInitialWaitingTime);
193
+ }
194
+ const query = queryBuilder.build();
169
195
 
170
196
  const result = router.route(query);
171
197
  const arrivalTime = result.arrivalAt(toStop.id);
package/src/router.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Plotter } from './routing/plotter.js';
2
+ import type { QueryOptions, RangeQueryOptions } from './routing/query.js';
2
3
  import { Query, RangeQuery } from './routing/query.js';
3
4
  import { Result } from './routing/result.js';
4
5
  import type { Leg, Transfer, VehicleLeg } from './routing/route.js';
@@ -40,6 +41,8 @@ export type {
40
41
  Leg,
41
42
  LocationType,
42
43
  ParetoRun,
44
+ QueryOptions,
45
+ RangeQueryOptions,
43
46
  RouteType,
44
47
  ServiceRouteInfo,
45
48
  SourceStopId,
@@ -148,6 +148,28 @@ describe('PlainRouter', () => {
148
148
  // Route 0 arrives at stop3 at 08:35
149
149
  assert.strictEqual(timeToStop3?.arrival, timeFromHM(8, 35));
150
150
  });
151
+
152
+ it('should not return journeys arriving after maxDuration', () => {
153
+ const tooShort = new Query.Builder()
154
+ .from(0)
155
+ .to(2)
156
+ .departureTime(timeFromHM(8, 0))
157
+ .maxDuration(34)
158
+ .build();
159
+
160
+ assert.strictEqual(router.route(tooShort).bestRoute(), undefined);
161
+
162
+ const justEnough = new Query.Builder()
163
+ .from(0)
164
+ .to(2)
165
+ .departureTime(timeFromHM(8, 0))
166
+ .maxDuration(35)
167
+ .build();
168
+
169
+ const route = router.route(justEnough).bestRoute();
170
+ assert(route);
171
+ assert.strictEqual(route.arrivalTime(), timeFromHM(8, 35));
172
+ });
151
173
  });
152
174
 
153
175
  describe('with a route change', () => {
@@ -347,6 +347,28 @@ describe('RangeRouter', () => {
347
347
  assert.strictEqual(result.size, 0);
348
348
  assert.strictEqual(result.bestRoute(), undefined);
349
349
  });
350
+
351
+ it('filters runs whose arrivals exceed maxDuration for their departure slot', () => {
352
+ const tooShort = new RangeQuery.Builder()
353
+ .from(0)
354
+ .to(1)
355
+ .departureTime(timeFromHM(8, 0))
356
+ .lastDepartureTime(timeFromHM(8, 30))
357
+ .maxDuration(29)
358
+ .build();
359
+
360
+ assert.strictEqual(router.rangeRoute(tooShort).size, 0);
361
+
362
+ const justEnough = new RangeQuery.Builder()
363
+ .from(0)
364
+ .to(1)
365
+ .departureTime(timeFromHM(8, 0))
366
+ .lastDepartureTime(timeFromHM(8, 30))
367
+ .maxDuration(30)
368
+ .build();
369
+
370
+ assert.strictEqual(router.rangeRoute(justEnough).size, 2);
371
+ });
350
372
  });
351
373
 
352
374
  describe('same-stop query (origin equals destination)', () => {
@@ -229,6 +229,94 @@ describe('RangeRaptorState', () => {
229
229
  });
230
230
  });
231
231
 
232
+ describe('updateArrival aggregate overwrite behavior', () => {
233
+ it('does not overwrite the current run aggregate with a later arrival from a higher round', () => {
234
+ const state = new RangeRaptorState(
235
+ MAX_ROUNDS,
236
+ NB_STOPS,
237
+ timeFromHM(12, 0),
238
+ );
239
+ const run = RoutingState.fromTestData({
240
+ nbStops: NB_STOPS,
241
+ destinations: [2],
242
+ arrivals: [[2, timeFromHM(8, 30), 1]],
243
+ });
244
+ state.setCurrentRun(run);
245
+
246
+ state.updateArrival(2, timeFromHM(8, 45), 2);
247
+
248
+ assert.deepStrictEqual(run.getArrival(2), {
249
+ arrival: timeFromHM(8, 30),
250
+ legNumber: 1,
251
+ });
252
+ assert.strictEqual(state.roundLabels[2]![2], timeFromHM(8, 45));
253
+ });
254
+
255
+ it('does not overwrite the current run aggregate with an equal-time arrival using more legs', () => {
256
+ const state = new RangeRaptorState(
257
+ MAX_ROUNDS,
258
+ NB_STOPS,
259
+ timeFromHM(12, 0),
260
+ );
261
+ const run = RoutingState.fromTestData({
262
+ nbStops: NB_STOPS,
263
+ destinations: [2],
264
+ arrivals: [[2, timeFromHM(8, 30), 2]],
265
+ });
266
+ state.setCurrentRun(run);
267
+
268
+ state.updateArrival(2, timeFromHM(8, 30), 3);
269
+
270
+ assert.deepStrictEqual(run.getArrival(2), {
271
+ arrival: timeFromHM(8, 30),
272
+ legNumber: 2,
273
+ });
274
+ assert.strictEqual(state.roundLabels[3]![2], timeFromHM(8, 30));
275
+ });
276
+
277
+ it('prefers fewer legs for the current run aggregate when arrival time is equal', () => {
278
+ const state = new RangeRaptorState(
279
+ MAX_ROUNDS,
280
+ NB_STOPS,
281
+ timeFromHM(12, 0),
282
+ );
283
+ const run = RoutingState.fromTestData({
284
+ nbStops: NB_STOPS,
285
+ destinations: [2],
286
+ arrivals: [[2, timeFromHM(8, 30), 3]],
287
+ });
288
+ state.setCurrentRun(run);
289
+
290
+ state.updateArrival(2, timeFromHM(8, 30), 2);
291
+
292
+ assert.deepStrictEqual(run.getArrival(2), {
293
+ arrival: timeFromHM(8, 30),
294
+ legNumber: 2,
295
+ });
296
+ });
297
+
298
+ it('still updates the current run aggregate when the new arrival is earlier', () => {
299
+ const state = new RangeRaptorState(
300
+ MAX_ROUNDS,
301
+ NB_STOPS,
302
+ timeFromHM(12, 0),
303
+ );
304
+ const run = RoutingState.fromTestData({
305
+ nbStops: NB_STOPS,
306
+ destinations: [2],
307
+ arrivals: [[2, timeFromHM(8, 45), 1]],
308
+ });
309
+ state.setCurrentRun(run);
310
+
311
+ state.updateArrival(2, timeFromHM(8, 30), 3);
312
+
313
+ assert.deepStrictEqual(run.getArrival(2), {
314
+ arrival: timeFromHM(8, 30),
315
+ legNumber: 3,
316
+ });
317
+ });
318
+ });
319
+
232
320
  describe('isDestination', () => {
233
321
  it('delegates to the current run', () => {
234
322
  const state = new RangeRaptorState(
@@ -249,6 +249,148 @@ describe('Raptor', () => {
249
249
  });
250
250
  });
251
251
 
252
+ describe('maxDuration', () => {
253
+ it('filters vehicle arrivals after the maxDuration cutoff and allows the exact boundary', () => {
254
+ const timetable = new Timetable(
255
+ stopsAdjacency,
256
+ [route0, route1],
257
+ serviceRoutes,
258
+ );
259
+ const raptor = new Raptor(timetable);
260
+ const tooShortOptions: QueryOptions = {
261
+ maxTransfers: 5,
262
+ minTransferTime: 2,
263
+ transportModes: ALL_TRANSPORT_MODES,
264
+ maxDuration: 29,
265
+ };
266
+
267
+ const tooShort = new RoutingState(
268
+ timeFromHM(8, 0),
269
+ [1],
270
+ [{ fromStopId: 0, toStopId: 0, duration: 0 }],
271
+ NB_STOPS,
272
+ tooShortOptions.maxTransfers + 1,
273
+ tooShortOptions.maxDuration,
274
+ );
275
+ raptor.run(tooShortOptions, tooShort);
276
+ assert.strictEqual(tooShort.getArrival(1), undefined);
277
+
278
+ const justEnoughOptions: QueryOptions = {
279
+ maxTransfers: 5,
280
+ minTransferTime: 2,
281
+ transportModes: ALL_TRANSPORT_MODES,
282
+ maxDuration: 30,
283
+ };
284
+ const justEnough = new RoutingState(
285
+ timeFromHM(8, 0),
286
+ [1],
287
+ [{ fromStopId: 0, toStopId: 0, duration: 0 }],
288
+ NB_STOPS,
289
+ justEnoughOptions.maxTransfers + 1,
290
+ justEnoughOptions.maxDuration,
291
+ );
292
+ raptor.run(justEnoughOptions, justEnough);
293
+ assert.strictEqual(justEnough.getArrival(1)?.arrival, timeFromHM(8, 30));
294
+ });
295
+
296
+ it('keeps intermediate stops reachable while filtering later vehicle arrivals', () => {
297
+ const timetable = new Timetable(
298
+ stopsAdjacency,
299
+ [route0, route1],
300
+ serviceRoutes,
301
+ );
302
+ const options: QueryOptions = {
303
+ maxTransfers: 5,
304
+ minTransferTime: 2,
305
+ transportModes: ALL_TRANSPORT_MODES,
306
+ maxDuration: 45,
307
+ };
308
+ const state = new RoutingState(
309
+ timeFromHM(8, 0),
310
+ [2],
311
+ [{ fromStopId: 0, toStopId: 0, duration: 0 }],
312
+ NB_STOPS,
313
+ options.maxTransfers + 1,
314
+ options.maxDuration,
315
+ );
316
+ const raptor = new Raptor(timetable);
317
+ raptor.run(options, state);
318
+ assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30));
319
+ assert.strictEqual(state.getArrival(2), undefined);
320
+ });
321
+
322
+ it('filters timed walking transfers after the maxDuration cutoff', () => {
323
+ const timetable = new Timetable(
324
+ stopsAdjacencyWithTransfer,
325
+ [route0],
326
+ [serviceRoutes[0]!],
327
+ );
328
+ const raptor = new Raptor(timetable);
329
+ const tooShortOptions: QueryOptions = {
330
+ maxTransfers: 5,
331
+ minTransferTime: 2,
332
+ transportModes: ALL_TRANSPORT_MODES,
333
+ maxDuration: 34,
334
+ };
335
+
336
+ const tooShort = new RoutingState(
337
+ timeFromHM(8, 0),
338
+ [2],
339
+ [{ fromStopId: 0, toStopId: 0, duration: 0 }],
340
+ NB_STOPS,
341
+ tooShortOptions.maxTransfers + 1,
342
+ tooShortOptions.maxDuration,
343
+ );
344
+ raptor.run(tooShortOptions, tooShort);
345
+ assert.strictEqual(tooShort.getArrival(1)?.arrival, timeFromHM(8, 30));
346
+ assert.strictEqual(tooShort.getArrival(2), undefined);
347
+
348
+ const justEnoughOptions: QueryOptions = {
349
+ maxTransfers: 5,
350
+ minTransferTime: 2,
351
+ transportModes: ALL_TRANSPORT_MODES,
352
+ maxDuration: 35,
353
+ };
354
+ const justEnough = new RoutingState(
355
+ timeFromHM(8, 0),
356
+ [2],
357
+ [{ fromStopId: 0, toStopId: 0, duration: 0 }],
358
+ NB_STOPS,
359
+ justEnoughOptions.maxTransfers + 1,
360
+ justEnoughOptions.maxDuration,
361
+ );
362
+ raptor.run(justEnoughOptions, justEnough);
363
+ assert.strictEqual(justEnough.getArrival(2)?.arrival, timeFromHM(8, 35));
364
+ });
365
+
366
+ it('filters in-seat continuation arrivals after the maxDuration cutoff', () => {
367
+ const timetable = new Timetable(
368
+ stopsAdjacency,
369
+ [route0, route1],
370
+ serviceRoutes,
371
+ tripContinuations,
372
+ );
373
+ const options: QueryOptions = {
374
+ maxTransfers: 5,
375
+ minTransferTime: 2,
376
+ transportModes: ALL_TRANSPORT_MODES,
377
+ maxDuration: 45,
378
+ };
379
+ const state = new RoutingState(
380
+ timeFromHM(8, 0),
381
+ [2],
382
+ [{ fromStopId: 0, toStopId: 0, duration: 0 }],
383
+ NB_STOPS,
384
+ options.maxTransfers + 1,
385
+ options.maxDuration,
386
+ );
387
+ const raptor = new Raptor(timetable);
388
+ raptor.run(options, state);
389
+ assert.strictEqual(state.getArrival(1)?.arrival, timeFromHM(8, 30));
390
+ assert.strictEqual(state.getArrival(2), undefined);
391
+ });
392
+ });
393
+
252
394
  describe('early termination', () => {
253
395
  it('exits when no trips are catchable', () => {
254
396
  // Departing at 09:00 — all trips have already left (route 0 at 08:10,
@@ -47,6 +47,7 @@ export class PlainRouter {
47
47
  accessLegs,
48
48
  this.timetable.nbStops(),
49
49
  query.options.maxTransfers + 1,
50
+ query.options.maxDuration,
50
51
  );
51
52
 
52
53
  this.raptor.run(query.options, routingState);
@@ -6,6 +6,14 @@ export type QueryOptions = {
6
6
  maxTransfers: number;
7
7
  minTransferTime: Duration;
8
8
  transportModes: Set<RouteType>;
9
+ /**
10
+ * Maximum total journey duration (in minutes) from the query departure time.
11
+ *
12
+ * When set, arrivals after `departureTime + maxDuration` are skipped. The
13
+ * duration includes initial access, waiting time, transit legs, and transfers.
14
+ * Undefined means no limit.
15
+ */
16
+ maxDuration?: Duration;
9
17
  /**
10
18
  * Maximum time (in minutes) the traveler is willing to wait at the first
11
19
  * boarding stop before the first transit vehicle departs.
@@ -98,6 +106,16 @@ export class Query {
98
106
  return this;
99
107
  }
100
108
 
109
+ /**
110
+ * Sets the maximum total journey duration (in minutes) from the query
111
+ * departure time. The limit includes initial access, waiting time, transit
112
+ * legs, and transfers.
113
+ */
114
+ maxDuration(maxDuration: Duration): this {
115
+ this.optionsValue.maxDuration = maxDuration;
116
+ return this;
117
+ }
118
+
101
119
  /**
102
120
  * Sets the maximum time (in minutes) the traveler is willing to wait at
103
121
  * the first boarding stop before the first transit vehicle departs.
@@ -92,6 +92,7 @@ export class RangeRouter {
92
92
  accessLegs,
93
93
  this.timetable.nbStops(),
94
94
  maxRounds,
95
+ query.options.maxDuration,
95
96
  );
96
97
  rangeState.setCurrentRun(routingState);
97
98
  this.raptor.run(
@@ -122,6 +123,7 @@ export class RangeRouter {
122
123
  legs,
123
124
  this.timetable.nbStops(),
124
125
  maxRounds,
126
+ query.options.maxDuration,
125
127
  );
126
128
  } else {
127
129
  routingState.resetFor(depTime, legs);
@@ -109,13 +109,27 @@ export class RangeRaptorState implements IRaptorState {
109
109
  return this._destinationBest;
110
110
  }
111
111
 
112
+ get maxArrivalTime(): Time {
113
+ return this.currentRun.maxArrivalTime;
114
+ }
115
+
112
116
  isDestination(stop: StopId): boolean {
113
117
  return this.currentRun.isDestination(stop);
114
118
  }
115
119
 
116
- /** Updates both the per-run state and the cross-run shared labels. */
120
+ /** Updates the per-run aggregate best when improved, and always considers the cross-run shared label. */
117
121
  updateArrival(stop: StopId, time: Time, round: number): void {
118
- this.currentRun.updateArrival(stop, time, round);
122
+ const currentRunArrival = this.currentRun.getArrival(stop);
123
+ const improvesCurrentRunAggregate =
124
+ currentRunArrival === undefined ||
125
+ time < currentRunArrival.arrival ||
126
+ (time === currentRunArrival.arrival &&
127
+ round < currentRunArrival.legNumber);
128
+
129
+ if (improvesCurrentRunAggregate) {
130
+ this.currentRun.updateArrival(stop, time, round);
131
+ }
132
+
119
133
  if (time < this.roundLabels[round]![stop]!) {
120
134
  this.roundLabels[round]![stop] = time;
121
135
  this.changedInRound[round]!.push(stop);