minotor 7.0.2 → 8.0.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 (58) hide show
  1. package/.cspell.json +11 -1
  2. package/CHANGELOG.md +8 -3
  3. package/README.md +26 -24
  4. package/dist/cli.mjs +1243 -267
  5. package/dist/cli.mjs.map +1 -1
  6. package/dist/gtfs/transfers.d.ts +13 -4
  7. package/dist/gtfs/trips.d.ts +12 -7
  8. package/dist/parser.cjs.js +494 -71
  9. package/dist/parser.cjs.js.map +1 -1
  10. package/dist/parser.esm.js +494 -71
  11. package/dist/parser.esm.js.map +1 -1
  12. package/dist/router.cjs.js +1 -1
  13. package/dist/router.cjs.js.map +1 -1
  14. package/dist/router.d.ts +2 -2
  15. package/dist/router.esm.js +1 -1
  16. package/dist/router.esm.js.map +1 -1
  17. package/dist/router.umd.js +1 -1
  18. package/dist/router.umd.js.map +1 -1
  19. package/dist/routing/__tests__/plotter.test.d.ts +1 -0
  20. package/dist/routing/plotter.d.ts +42 -3
  21. package/dist/routing/result.d.ts +23 -7
  22. package/dist/routing/route.d.ts +2 -0
  23. package/dist/routing/router.d.ts +78 -19
  24. package/dist/timetable/__tests__/tripId.test.d.ts +1 -0
  25. package/dist/timetable/io.d.ts +4 -2
  26. package/dist/timetable/proto/timetable.d.ts +13 -1
  27. package/dist/timetable/route.d.ts +41 -8
  28. package/dist/timetable/timetable.d.ts +18 -3
  29. package/dist/timetable/tripId.d.ts +15 -0
  30. package/package.json +1 -1
  31. package/src/__e2e__/router.test.ts +114 -105
  32. package/src/__e2e__/timetable/stops.bin +2 -2
  33. package/src/__e2e__/timetable/timetable.bin +2 -2
  34. package/src/cli/repl.ts +259 -1
  35. package/src/gtfs/__tests__/transfers.test.ts +468 -12
  36. package/src/gtfs/__tests__/trips.test.ts +350 -28
  37. package/src/gtfs/parser.ts +16 -4
  38. package/src/gtfs/transfers.ts +61 -18
  39. package/src/gtfs/trips.ts +97 -22
  40. package/src/router.ts +2 -2
  41. package/src/routing/__tests__/plotter.test.ts +230 -0
  42. package/src/routing/__tests__/result.test.ts +486 -125
  43. package/src/routing/__tests__/route.test.ts +7 -3
  44. package/src/routing/__tests__/router.test.ts +378 -172
  45. package/src/routing/plotter.ts +279 -48
  46. package/src/routing/result.ts +114 -34
  47. package/src/routing/route.ts +0 -3
  48. package/src/routing/router.ts +332 -211
  49. package/src/timetable/__tests__/io.test.ts +33 -1
  50. package/src/timetable/__tests__/route.test.ts +10 -3
  51. package/src/timetable/__tests__/timetable.test.ts +225 -57
  52. package/src/timetable/__tests__/tripId.test.ts +27 -0
  53. package/src/timetable/io.ts +71 -10
  54. package/src/timetable/proto/timetable.proto +14 -2
  55. package/src/timetable/proto/timetable.ts +218 -20
  56. package/src/timetable/route.ts +152 -19
  57. package/src/timetable/timetable.ts +45 -6
  58. package/src/timetable/tripId.ts +29 -0
@@ -58,7 +58,13 @@ describe('Route', () => {
58
58
  const stops = new Uint32Array([1001, 1002]);
59
59
  const serviceRouteId = 0;
60
60
 
61
- const route = new Route(stopTimes, pickUpDropOffTypes, stops, serviceRouteId);
61
+ const route = new Route(
62
+ 0,
63
+ stopTimes,
64
+ pickUpDropOffTypes,
65
+ stops,
66
+ serviceRouteId,
67
+ );
62
68
 
63
69
  describe('constructor', () => {
64
70
  it('should create a route with correct properties', () => {
@@ -68,6 +74,7 @@ describe('Route', () => {
68
74
 
69
75
  it('should handle empty route', () => {
70
76
  const emptyRoute = new Route(
77
+ 0,
71
78
  new Uint16Array([]),
72
79
  new Uint8Array([]),
73
80
  new Uint32Array([]),
@@ -104,14 +111,14 @@ describe('Route', () => {
104
111
  it('should throw error when stopA is not found', () => {
105
112
  assert.throws(
106
113
  () => route.isBefore(9999, 1002),
107
- /Stop index undefined not found in route 0/,
114
+ /Stop index not found for 9999 in route 0/,
108
115
  );
109
116
  });
110
117
 
111
118
  it('should throw error when stopB is not found', () => {
112
119
  assert.throws(
113
120
  () => route.isBefore(1001, 9999),
114
- /Stop index undefined not found in route 0/,
121
+ /Stop index not found for 9999 in route 0/,
115
122
  );
116
123
  });
117
124
  });
@@ -1,20 +1,21 @@
1
1
  import assert from 'node:assert';
2
2
  import { describe, it } from 'node:test';
3
3
 
4
- import { encodePickUpDropOffTypes } from '../../gtfs/trips.js';
5
4
  import { Duration } from '../duration.js';
6
- import { NOT_AVAILABLE, REGULAR, Route } from '../route.js';
5
+ import { NOT_AVAILABLE, Route } from '../route.js';
7
6
  import { Time } from '../time.js';
8
7
  import {
9
8
  RouteType,
10
9
  ServiceRoute,
11
10
  StopAdjacency,
12
11
  Timetable,
12
+ TripBoarding,
13
13
  } from '../timetable.js';
14
+ import { encode } from '../tripId.js';
14
15
 
15
16
  describe('Timetable', () => {
16
17
  const stopsAdjacency: StopAdjacency[] = [
17
- { transfers: [], routes: [] },
18
+ { routes: [] },
18
19
  {
19
20
  transfers: [{ destination: 2, type: 'RECOMMENDED' }],
20
21
  routes: [0, 1],
@@ -30,40 +31,65 @@ describe('Timetable', () => {
30
31
  routes: [1, 0],
31
32
  },
32
33
  {
33
- transfers: [],
34
34
  routes: [],
35
35
  },
36
36
  ];
37
37
 
38
- const route1 = new Route(
39
- new Uint16Array([
40
- Time.fromHMS(16, 40, 0).toMinutes(),
41
- Time.fromHMS(16, 50, 0).toMinutes(),
42
- Time.fromHMS(17, 20, 0).toMinutes(),
43
- Time.fromHMS(17, 30, 0).toMinutes(),
44
- Time.fromHMS(18, 0, 0).toMinutes(),
45
- Time.fromHMS(18, 10, 0).toMinutes(),
46
- Time.fromHMS(19, 0, 0).toMinutes(),
47
- Time.fromHMS(19, 10, 0).toMinutes(),
48
- ]),
49
- encodePickUpDropOffTypes(
50
- [REGULAR, NOT_AVAILABLE, REGULAR, REGULAR],
51
- [REGULAR, REGULAR, REGULAR, REGULAR],
52
- ),
53
- new Uint32Array([1, 2]),
54
- 0,
55
- );
56
- const route2 = new Route(
57
- new Uint16Array([
58
- Time.fromHMS(18, 20, 0).toMinutes(),
59
- Time.fromHMS(18, 30, 0).toMinutes(),
60
- Time.fromHMS(23, 20, 0).toMinutes(),
61
- Time.fromHMS(23, 30, 0).toMinutes(),
62
- ]),
63
- encodePickUpDropOffTypes([REGULAR, REGULAR], [REGULAR, REGULAR]),
64
- new Uint32Array([2, 1]),
65
- 1,
66
- );
38
+ const route1 = Route.of({
39
+ id: 0,
40
+ serviceRouteId: 0,
41
+ trips: [
42
+ {
43
+ stops: [
44
+ {
45
+ id: 1,
46
+ arrivalTime: Time.fromHMS(16, 40, 0),
47
+ departureTime: Time.fromHMS(16, 50, 0),
48
+ },
49
+ {
50
+ id: 2,
51
+ arrivalTime: Time.fromHMS(17, 20, 0),
52
+ departureTime: Time.fromHMS(17, 30, 0),
53
+ pickUpType: NOT_AVAILABLE,
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ stops: [
59
+ {
60
+ id: 1,
61
+ arrivalTime: Time.fromHMS(18, 0, 0),
62
+ departureTime: Time.fromHMS(18, 10, 0),
63
+ },
64
+ {
65
+ id: 2,
66
+ arrivalTime: Time.fromHMS(19, 0, 0),
67
+ departureTime: Time.fromHMS(19, 10, 0),
68
+ },
69
+ ],
70
+ },
71
+ ],
72
+ });
73
+ const route2 = Route.of({
74
+ id: 1,
75
+ serviceRouteId: 1,
76
+ trips: [
77
+ {
78
+ stops: [
79
+ {
80
+ id: 2,
81
+ arrivalTime: Time.fromHMS(18, 20, 0),
82
+ departureTime: Time.fromHMS(18, 30, 0),
83
+ },
84
+ {
85
+ id: 1,
86
+ arrivalTime: Time.fromHMS(19, 0, 0),
87
+ departureTime: Time.fromHMS(19, 10, 0),
88
+ },
89
+ ],
90
+ },
91
+ ],
92
+ });
67
93
  const routesAdjacency = [route1, route2];
68
94
  const routes: ServiceRoute[] = [
69
95
  { type: 'RAIL', name: 'Route 1', routes: [0] },
@@ -115,31 +141,173 @@ describe('Timetable', () => {
115
141
  const tripIndex = route.findEarliestTrip(2);
116
142
  assert.strictEqual(tripIndex, 1);
117
143
  });
118
- it('should find reachable routes from a set of stop IDs', () => {
119
- const fromStops = new Set([1]);
120
- const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
121
- assert.strictEqual(reachableRoutes.size, 2);
122
- assert.deepStrictEqual(
123
- reachableRoutes,
124
- new Map([
125
- [route1, 1],
126
- [route2, 1],
127
- ]),
128
- );
129
- });
144
+ describe('findReachableRoutes', () => {
145
+ it('should find reachable routes from a single stop', () => {
146
+ const fromStops = new Set([1]);
147
+ const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
148
+ assert.strictEqual(reachableRoutes.size, 2);
149
+ assert.deepStrictEqual(
150
+ reachableRoutes,
151
+ new Map([
152
+ [route1, 1],
153
+ [route2, 1],
154
+ ]),
155
+ );
156
+ });
130
157
 
131
- it('should find no reachable routes if starting from a non-existent stop', () => {
132
- const fromStops = new Set([3]);
133
- const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
134
- assert.strictEqual(reachableRoutes.size, 0);
135
- });
158
+ it('should find reachable routes from multiple stops', () => {
159
+ const fromStops = new Set([1, 2]);
160
+ const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
161
+ assert.strictEqual(reachableRoutes.size, 2);
162
+
163
+ assert.deepStrictEqual(
164
+ reachableRoutes,
165
+ new Map([
166
+ [route1, 1],
167
+ [route2, 2],
168
+ ]),
169
+ );
170
+ });
171
+
172
+ it('should find no reachable routes from stops with no routes', () => {
173
+ const fromStops = new Set([3]); // Stop 3 has no routes in sample timetable
174
+ const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
175
+ assert.strictEqual(reachableRoutes.size, 0);
176
+ assert.deepStrictEqual(reachableRoutes, new Map());
177
+ });
178
+
179
+ it('should find no reachable routes from empty stop set', () => {
180
+ const fromStops = new Set<number>();
181
+ const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
182
+ assert.strictEqual(reachableRoutes.size, 0);
183
+ assert.deepStrictEqual(reachableRoutes, new Map());
184
+ });
185
+
186
+ it('should find no reachable routes from non-existent stops', () => {
187
+ const fromStops = new Set([999, 1000]); // Non-existent stops
188
+ const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
189
+ assert.strictEqual(reachableRoutes.size, 0);
190
+ assert.deepStrictEqual(reachableRoutes, new Map());
191
+ });
192
+
193
+ it('should filter routes by transport modes correctly', () => {
194
+ const fromStops = new Set([1]);
195
+
196
+ const railRoutes = sampleTimetable.findReachableRoutes(
197
+ fromStops,
198
+ new Set<RouteType>(['RAIL']),
199
+ );
200
+ assert.strictEqual(railRoutes.size, 2);
201
+ assert.deepStrictEqual(
202
+ railRoutes,
203
+ new Map([
204
+ [route1, 1],
205
+ [route2, 1],
206
+ ]),
207
+ );
208
+
209
+ const busRoutes = sampleTimetable.findReachableRoutes(
210
+ fromStops,
211
+ new Set<RouteType>(['BUS']),
212
+ );
213
+ assert.strictEqual(busRoutes.size, 0);
214
+ assert.deepStrictEqual(busRoutes, new Map());
215
+
216
+ const multiModeRoutes = sampleTimetable.findReachableRoutes(
217
+ fromStops,
218
+ new Set<RouteType>(['RAIL', 'BUS', 'SUBWAY']),
219
+ );
220
+ assert.strictEqual(multiModeRoutes.size, 2);
221
+ });
222
+
223
+ it('should return earliest hop-on stop when route is accessible from multiple stops', () => {
224
+ // Create scenario where same route is accessible from multiple stops in the query
225
+ // route1 has stops [1, 2] in that order, so we need to test with those actual stops
226
+ const fromStops = new Set([1, 2]); // Both stops are on route1
227
+ const reachableRoutes = sampleTimetable.findReachableRoutes(fromStops);
228
+
229
+ // route1 should use stop 1 (earlier on the route than stop 2)
230
+ // route2 should use stop 2 (earlier on route2 which has [2, 1])
231
+ assert.strictEqual(reachableRoutes.size, 2);
232
+ assert.deepStrictEqual(
233
+ reachableRoutes,
234
+ new Map([
235
+ [route1, 1], // Stop 1 comes before stop 2 on route1
236
+ [route2, 2], // Stop 2 comes before stop 1 on route2
237
+ ]),
238
+ );
239
+ });
240
+
241
+ describe('getContinuousTrips', () => {
242
+ it('should return empty array when stop has no trip continuations', () => {
243
+ const continuousTrips = sampleTimetable.getContinuousTrips(1, 0, 0);
244
+ assert.deepStrictEqual(continuousTrips, []);
245
+ });
246
+
247
+ it('should return empty array when stop has trip continuations but not for the specified trip', () => {
248
+ // Create a timetable with trip continuations that don't match the query
249
+ const stopsWithContinuations: StopAdjacency[] = [
250
+ { routes: [] },
251
+ {
252
+ routes: [0, 1],
253
+ tripContinuations: new Map([
254
+ [encode(0, 1), [{ hopOnStop: 2, routeId: 1, tripIndex: 0 }]], // Different trip index
255
+ ]),
256
+ },
257
+ { routes: [1] },
258
+ ];
259
+
260
+ const timetableWithContinuations = new Timetable(
261
+ stopsWithContinuations,
262
+ routesAdjacency,
263
+ routes,
264
+ );
265
+
266
+ const continuousTrips = timetableWithContinuations.getContinuousTrips(
267
+ 1,
268
+ 0,
269
+ 0,
270
+ ); // Query trip index 0, but continuations are for trip index 1
271
+ assert.deepStrictEqual(continuousTrips, []);
272
+ });
273
+
274
+ it('should return trip continuations when they exist for the specified trip', () => {
275
+ const expectedContinuations: TripBoarding[] = [
276
+ { hopOnStop: 2, routeId: 1, tripIndex: 0 },
277
+ { hopOnStop: 2, routeId: 1, tripIndex: 1 },
278
+ ];
279
+
280
+ const stopsWithContinuations: StopAdjacency[] = [
281
+ { routes: [] },
282
+ {
283
+ routes: [0, 1],
284
+ tripContinuations: new Map([
285
+ [encode(0, 0), expectedContinuations], // Trip continuations for route 0, trip 0
286
+ ]),
287
+ },
288
+ { routes: [1] },
289
+ ];
290
+
291
+ const timetableWithContinuations = new Timetable(
292
+ stopsWithContinuations,
293
+ routesAdjacency,
294
+ routes,
295
+ );
296
+
297
+ const continuousTrips = timetableWithContinuations.getContinuousTrips(
298
+ 1,
299
+ 0,
300
+ 0,
301
+ );
302
+ assert.deepStrictEqual(continuousTrips, expectedContinuations);
303
+ });
136
304
 
137
- it('should find reachable routes filtered by transport modes', () => {
138
- const fromStops = new Set([1]);
139
- const reachableRoutes = sampleTimetable.findReachableRoutes(
140
- fromStops,
141
- new Set<RouteType>(['BUS']),
142
- );
143
- assert.strictEqual(reachableRoutes.size, 0);
305
+ it('should throw error when querying non-existent stop', () => {
306
+ assert.throws(
307
+ () => sampleTimetable.getContinuousTrips(999, 0, 0),
308
+ /Stop ID 999 not found/,
309
+ );
310
+ });
311
+ });
144
312
  });
145
313
  });
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { RouteId, TripRouteIndex } from '../route.js';
5
+ import { decode, encode } from '../tripId.js';
6
+
7
+ describe('tripId', () => {
8
+ it('should maintain identity for encode/decode round-trip', () => {
9
+ const testCases: [RouteId, TripRouteIndex][] = [
10
+ [0, 0],
11
+ [1, 1],
12
+ [1000, 500],
13
+ [65535, 32767],
14
+ [131071, 0],
15
+ [0, 32767],
16
+ [(1 << 17) - 1, (1 << 15) - 1], // Maximum values
17
+ ];
18
+
19
+ testCases.forEach(([routeId, tripIndex]) => {
20
+ const tripId = encode(routeId, tripIndex);
21
+ const [decodedRouteId, decodedTripIndex] = decode(tripId);
22
+
23
+ assert.strictEqual(decodedRouteId, routeId);
24
+ assert.strictEqual(decodedTripIndex, tripIndex);
25
+ });
26
+ });
27
+ });
@@ -5,6 +5,7 @@ import {
5
5
  ServiceRoute as ProtoServiceRoute,
6
6
  StopAdjacency as ProtoStopAdjacency,
7
7
  TransferType as ProtoTransferType,
8
+ TripContinuationEntry as ProtoTripContinuationEntry,
8
9
  } from './proto/timetable.js';
9
10
  import { Route } from './route.js';
10
11
  import {
@@ -14,6 +15,7 @@ import {
14
15
  StopAdjacency,
15
16
  Transfer,
16
17
  TransferType,
18
+ TripBoarding,
17
19
  } from './timetable.js';
18
20
 
19
21
  export type SerializedRoute = {
@@ -125,14 +127,19 @@ export const serializeStopsAdjacency = (
125
127
  ): ProtoStopAdjacency[] => {
126
128
  return stopsAdjacency.map((value) => {
127
129
  return {
128
- transfers: value.transfers.map((transfer) => ({
129
- destination: transfer.destination,
130
- type: serializeTransferType(transfer.type),
131
- ...(transfer.minTransferTime !== undefined && {
132
- minTransferTime: transfer.minTransferTime.toSeconds(),
133
- }),
134
- })),
130
+ transfers: value.transfers
131
+ ? value.transfers.map((transfer) => ({
132
+ destination: transfer.destination,
133
+ type: serializeTransferType(transfer.type),
134
+ ...(transfer.minTransferTime !== undefined && {
135
+ minTransferTime: transfer.minTransferTime.toSeconds(),
136
+ }),
137
+ }))
138
+ : [],
135
139
  routes: value.routes,
140
+ tripContinuations: value.tripContinuations
141
+ ? serializeTripContinuations(value.tripContinuations)
142
+ : [],
136
143
  };
137
144
  });
138
145
  };
@@ -190,10 +197,22 @@ export const deserializeStopsAdjacency = (
190
197
  transfers.push(newTransfer);
191
198
  }
192
199
 
193
- result.push({
194
- transfers: transfers,
200
+ const stopAdjacency: StopAdjacency = {
195
201
  routes: value.routes,
196
- });
202
+ };
203
+
204
+ if (transfers.length > 0) {
205
+ stopAdjacency.transfers = transfers;
206
+ }
207
+
208
+ const deserializedTripContinuations = deserializeTripContinuations(
209
+ value.tripContinuations,
210
+ );
211
+ if (deserializedTripContinuations.size > 0) {
212
+ stopAdjacency.tripContinuations = deserializedTripContinuations;
213
+ }
214
+
215
+ result.push(stopAdjacency);
197
216
  }
198
217
 
199
218
  return result;
@@ -210,6 +229,7 @@ export const deserializeRoutesAdjacency = (
210
229
  const stops = bytesToUint32Array(value.stops);
211
230
  routesAdjacency.push(
212
231
  new Route(
232
+ i,
213
233
  bytesToUint16Array(value.stopTimes),
214
234
  value.pickUpDropOffTypes,
215
235
  stops,
@@ -319,3 +339,44 @@ const serializeRouteType = (type: RouteType): ProtoRouteType => {
319
339
  return ProtoRouteType.MONORAIL;
320
340
  }
321
341
  };
342
+
343
+ export const serializeTripContinuations = (
344
+ tripContinuations: Map<number, TripBoarding[]>,
345
+ ): ProtoTripContinuationEntry[] => {
346
+ const result: ProtoTripContinuationEntry[] = [];
347
+
348
+ for (const [key, value] of tripContinuations.entries()) {
349
+ result.push({
350
+ key: key,
351
+ value: value.map((tripBoarding) => ({
352
+ hopOnStop: tripBoarding.hopOnStop,
353
+ routeId: tripBoarding.routeId,
354
+ tripIndex: tripBoarding.tripIndex,
355
+ })),
356
+ });
357
+ }
358
+
359
+ return result;
360
+ };
361
+
362
+ export const deserializeTripContinuations = (
363
+ protoTripContinuations: ProtoTripContinuationEntry[],
364
+ ): Map<number, TripBoarding[]> => {
365
+ const result = new Map<number, TripBoarding[]>();
366
+
367
+ for (let i = 0; i < protoTripContinuations.length; i++) {
368
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
369
+ const entry = protoTripContinuations[i]!;
370
+ const tripBoardings: TripBoarding[] = entry.value.map(
371
+ (protoTripBoarding) => ({
372
+ hopOnStop: protoTripBoarding.hopOnStop,
373
+ routeId: protoTripBoarding.routeId,
374
+ tripIndex: protoTripBoarding.tripIndex,
375
+ }),
376
+ );
377
+
378
+ result.set(entry.key, tripBoardings);
379
+ }
380
+
381
+ return result;
382
+ };
@@ -40,9 +40,21 @@ message Transfer {
40
40
  optional uint32 minTransferTime = 3;
41
41
  }
42
42
 
43
+ message TripBoarding {
44
+ uint32 hopOnStop = 1;
45
+ uint32 routeId = 2;
46
+ uint32 tripIndex = 3;
47
+ }
48
+
49
+ message TripContinuationEntry {
50
+ uint32 key = 1;
51
+ repeated TripBoarding value = 2;
52
+ }
53
+
43
54
  message StopAdjacency {
44
- repeated Transfer transfers = 1;
45
- repeated uint32 routes = 2;
55
+ repeated uint32 routes = 1;
56
+ repeated Transfer transfers = 2;
57
+ repeated TripContinuationEntry tripContinuations = 3;
46
58
  }
47
59
 
48
60
  enum RouteType {