minotor 1.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 (131) hide show
  1. package/.cspell.json +43 -0
  2. package/.czrc +3 -0
  3. package/.editorconfig +10 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.md +32 -0
  5. package/.github/ISSUE_TEMPLATE/config.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +29 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +4 -0
  8. package/.github/workflows/minotor.yml +85 -0
  9. package/.prettierrc +7 -0
  10. package/.releaserc.json +27 -0
  11. package/CHANGELOG.md +6 -0
  12. package/LICENSE +21 -0
  13. package/README.md +166 -0
  14. package/dist/bundle.cjs.js +16507 -0
  15. package/dist/bundle.cjs.js.map +1 -0
  16. package/dist/bundle.esm.js +16496 -0
  17. package/dist/bundle.esm.js.map +1 -0
  18. package/dist/bundle.umd.js +2 -0
  19. package/dist/bundle.umd.js.map +1 -0
  20. package/dist/cli/__tests__/minotor.test.d.ts +1 -0
  21. package/dist/cli/minotor.d.ts +5 -0
  22. package/dist/cli/repl.d.ts +1 -0
  23. package/dist/cli/utils.d.ts +3 -0
  24. package/dist/cli.mjs +20504 -0
  25. package/dist/cli.mjs.map +1 -0
  26. package/dist/gtfs/__tests__/parser.test.d.ts +1 -0
  27. package/dist/gtfs/__tests__/routes.test.d.ts +1 -0
  28. package/dist/gtfs/__tests__/services.test.d.ts +1 -0
  29. package/dist/gtfs/__tests__/stops.test.d.ts +1 -0
  30. package/dist/gtfs/__tests__/time.test.d.ts +1 -0
  31. package/dist/gtfs/__tests__/transfers.test.d.ts +1 -0
  32. package/dist/gtfs/__tests__/trips.test.d.ts +1 -0
  33. package/dist/gtfs/__tests__/utils.test.d.ts +1 -0
  34. package/dist/gtfs/parser.d.ts +34 -0
  35. package/dist/gtfs/profiles/__tests__/ch.test.d.ts +1 -0
  36. package/dist/gtfs/profiles/ch.d.ts +2 -0
  37. package/dist/gtfs/profiles/standard.d.ts +2 -0
  38. package/dist/gtfs/routes.d.ts +11 -0
  39. package/dist/gtfs/services.d.ts +19 -0
  40. package/dist/gtfs/stops.d.ts +20 -0
  41. package/dist/gtfs/time.d.ts +17 -0
  42. package/dist/gtfs/transfers.d.ts +22 -0
  43. package/dist/gtfs/trips.d.ts +26 -0
  44. package/dist/gtfs/utils.d.ts +21 -0
  45. package/dist/index.d.ts +11 -0
  46. package/dist/routing/__tests__/router.test.d.ts +1 -0
  47. package/dist/routing/plotter.d.ts +11 -0
  48. package/dist/routing/query.d.ts +35 -0
  49. package/dist/routing/result.d.ts +28 -0
  50. package/dist/routing/route.d.ts +25 -0
  51. package/dist/routing/router.d.ts +33 -0
  52. package/dist/stops/__tests__/io.test.d.ts +1 -0
  53. package/dist/stops/__tests__/stopFinder.test.d.ts +1 -0
  54. package/dist/stops/i18n.d.ts +10 -0
  55. package/dist/stops/io.d.ts +4 -0
  56. package/dist/stops/proto/stops.d.ts +53 -0
  57. package/dist/stops/stops.d.ts +16 -0
  58. package/dist/stops/stopsIndex.d.ts +52 -0
  59. package/dist/timetable/__tests__/io.test.d.ts +1 -0
  60. package/dist/timetable/__tests__/timetable.test.d.ts +1 -0
  61. package/dist/timetable/duration.d.ts +51 -0
  62. package/dist/timetable/io.d.ts +8 -0
  63. package/dist/timetable/proto/timetable.d.ts +122 -0
  64. package/dist/timetable/time.d.ts +98 -0
  65. package/dist/timetable/timetable.d.ts +82 -0
  66. package/dist/umdIndex.d.ts +9 -0
  67. package/eslint.config.mjs +52 -0
  68. package/package.json +109 -0
  69. package/rollup.config.js +44 -0
  70. package/src/cli/__tests__/minotor.test.ts +23 -0
  71. package/src/cli/minotor.ts +112 -0
  72. package/src/cli/repl.ts +200 -0
  73. package/src/cli/utils.ts +36 -0
  74. package/src/gtfs/__tests__/parser.test.ts +591 -0
  75. package/src/gtfs/__tests__/resources/sample-feed/agency.txt +2 -0
  76. package/src/gtfs/__tests__/resources/sample-feed/calendar.txt +3 -0
  77. package/src/gtfs/__tests__/resources/sample-feed/calendar_dates.txt +2 -0
  78. package/src/gtfs/__tests__/resources/sample-feed/fare_attributes.txt +3 -0
  79. package/src/gtfs/__tests__/resources/sample-feed/fare_rules.txt +5 -0
  80. package/src/gtfs/__tests__/resources/sample-feed/frequencies.txt +12 -0
  81. package/src/gtfs/__tests__/resources/sample-feed/routes.txt +6 -0
  82. package/src/gtfs/__tests__/resources/sample-feed/sample-feed.zip +0 -0
  83. package/src/gtfs/__tests__/resources/sample-feed/shapes.txt +1 -0
  84. package/src/gtfs/__tests__/resources/sample-feed/stop_times.txt +34 -0
  85. package/src/gtfs/__tests__/resources/sample-feed/stops.txt +10 -0
  86. package/src/gtfs/__tests__/resources/sample-feed/trips.txt +13 -0
  87. package/src/gtfs/__tests__/resources/sample-feed.zip +0 -0
  88. package/src/gtfs/__tests__/routes.test.ts +63 -0
  89. package/src/gtfs/__tests__/services.test.ts +209 -0
  90. package/src/gtfs/__tests__/stops.test.ts +177 -0
  91. package/src/gtfs/__tests__/time.test.ts +27 -0
  92. package/src/gtfs/__tests__/transfers.test.ts +117 -0
  93. package/src/gtfs/__tests__/trips.test.ts +463 -0
  94. package/src/gtfs/__tests__/utils.test.ts +13 -0
  95. package/src/gtfs/parser.ts +154 -0
  96. package/src/gtfs/profiles/__tests__/ch.test.ts +43 -0
  97. package/src/gtfs/profiles/ch.ts +70 -0
  98. package/src/gtfs/profiles/standard.ts +39 -0
  99. package/src/gtfs/routes.ts +48 -0
  100. package/src/gtfs/services.ts +98 -0
  101. package/src/gtfs/stops.ts +112 -0
  102. package/src/gtfs/time.ts +33 -0
  103. package/src/gtfs/transfers.ts +102 -0
  104. package/src/gtfs/trips.ts +228 -0
  105. package/src/gtfs/utils.ts +42 -0
  106. package/src/index.ts +28 -0
  107. package/src/routing/__tests__/router.test.ts +760 -0
  108. package/src/routing/plotter.ts +70 -0
  109. package/src/routing/query.ts +74 -0
  110. package/src/routing/result.ts +108 -0
  111. package/src/routing/route.ts +94 -0
  112. package/src/routing/router.ts +262 -0
  113. package/src/stops/__tests__/io.test.ts +43 -0
  114. package/src/stops/__tests__/stopFinder.test.ts +185 -0
  115. package/src/stops/i18n.ts +40 -0
  116. package/src/stops/io.ts +94 -0
  117. package/src/stops/proto/stops.proto +26 -0
  118. package/src/stops/proto/stops.ts +445 -0
  119. package/src/stops/stops.ts +24 -0
  120. package/src/stops/stopsIndex.ts +151 -0
  121. package/src/timetable/__tests__/io.test.ts +175 -0
  122. package/src/timetable/__tests__/timetable.test.ts +180 -0
  123. package/src/timetable/duration.ts +85 -0
  124. package/src/timetable/io.ts +265 -0
  125. package/src/timetable/proto/timetable.proto +76 -0
  126. package/src/timetable/proto/timetable.ts +1304 -0
  127. package/src/timetable/time.ts +192 -0
  128. package/src/timetable/timetable.ts +286 -0
  129. package/src/umdIndex.ts +14 -0
  130. package/tsconfig.build.json +4 -0
  131. package/tsconfig.json +21 -0
@@ -0,0 +1,463 @@
1
+ import assert from 'node:assert';
2
+ import { Readable } from 'node:stream';
3
+ import { describe, it } from 'node:test';
4
+
5
+ import { Time } from '../../timetable/time.js';
6
+ import {
7
+ RoutesAdjacency,
8
+ ServiceRoutesMap,
9
+ } from '../../timetable/timetable.js';
10
+ import { ServiceIds } from '../services.js';
11
+ import { StopIds } from '../stops.js';
12
+ import { TransfersMap } from '../transfers.js';
13
+ import {
14
+ buildStopsAdjacencyStructure,
15
+ parseStopTimes,
16
+ parseTrips,
17
+ TripIdsMap,
18
+ } from '../trips.js';
19
+
20
+ describe('buildStopsAdjacencyStructure', () => {
21
+ it('should correctly build stops adjacency for valid routes and transfers', () => {
22
+ const validStops: StopIds = new Set(['stop1']);
23
+ const routesAdjacency: RoutesAdjacency = new Map([
24
+ [
25
+ 'routeA',
26
+ {
27
+ serviceRouteId: 'service1',
28
+ stops: ['stop1', 'stop2'],
29
+ stopIndices: new Map([
30
+ ['stop1', 0],
31
+ ['stop2', 1],
32
+ ]),
33
+ stopTimes: [],
34
+ },
35
+ ],
36
+ ]);
37
+ const transfersMap: TransfersMap = new Map([
38
+ ['stop1', [{ destination: 'stop2', type: 'RECOMMENDED' }]],
39
+ ]);
40
+
41
+ const stopsAdjacency = buildStopsAdjacencyStructure(
42
+ validStops,
43
+ routesAdjacency,
44
+ transfersMap,
45
+ );
46
+
47
+ assert.deepEqual(Array.from(stopsAdjacency.entries()), [
48
+ [
49
+ 'stop1',
50
+ {
51
+ routes: ['routeA'],
52
+ transfers: [],
53
+ },
54
+ ],
55
+ ]);
56
+ });
57
+
58
+ it('should ignore transfers to invalid stops', () => {
59
+ const validStops: StopIds = new Set(['stop1', 'stop2']);
60
+ const routesAdjacency: RoutesAdjacency = new Map([
61
+ [
62
+ 'routeA',
63
+ {
64
+ serviceRouteId: 'service1',
65
+ stops: ['stop1', 'stop2'],
66
+ stopIndices: new Map([
67
+ ['stop1', 0],
68
+ ['stop2', 1],
69
+ ]),
70
+ stopTimes: [],
71
+ },
72
+ ],
73
+ ]);
74
+ const transfersMap: TransfersMap = new Map([
75
+ ['stop1', [{ destination: 'stop3', type: 'RECOMMENDED' }]],
76
+ ]);
77
+
78
+ const stopsAdjacency = buildStopsAdjacencyStructure(
79
+ validStops,
80
+ routesAdjacency,
81
+ transfersMap,
82
+ );
83
+
84
+ assert.deepEqual(Array.from(stopsAdjacency.entries()), [
85
+ [
86
+ 'stop1',
87
+ {
88
+ routes: ['routeA'],
89
+ transfers: [],
90
+ },
91
+ ],
92
+ [
93
+ 'stop2',
94
+ {
95
+ routes: ['routeA'],
96
+ transfers: [],
97
+ },
98
+ ],
99
+ ]);
100
+ });
101
+ });
102
+ describe('GTFS trips parser', () => {
103
+ it('should correctly parse valid trips', async () => {
104
+ const mockedStream = new Readable();
105
+ mockedStream.push('route_id,service_id,trip_id\n');
106
+ mockedStream.push('"routeA","service1","trip1"\n');
107
+ mockedStream.push('"routeB","service2","trip2"\n');
108
+ mockedStream.push(null);
109
+
110
+ const validServiceIds: ServiceIds = new Set(['service1', 'service2']);
111
+ const validRouteIds: ServiceRoutesMap = new Map([
112
+ ['routeA', { type: 'BUS', name: 'B1' }],
113
+ ['routeB', { type: 'TRAM', name: 'T1' }],
114
+ ]);
115
+
116
+ const trips = await parseTrips(
117
+ mockedStream,
118
+ validServiceIds,
119
+ validRouteIds,
120
+ );
121
+ assert.deepEqual(
122
+ trips,
123
+ new Map([
124
+ ['trip1', 'routeA'],
125
+ ['trip2', 'routeB'],
126
+ ]),
127
+ );
128
+ });
129
+
130
+ it('should ignore trips with invalid service ids', async () => {
131
+ const mockedStream = new Readable();
132
+ mockedStream.push('route_id,service_id,trip_id\n');
133
+ mockedStream.push('"routeA","service1","trip1"\n');
134
+ mockedStream.push('"routeB","service3","trip2"\n');
135
+ mockedStream.push(null);
136
+
137
+ const validServiceIds: ServiceIds = new Set(['service1', 'service2']);
138
+ const validRouteIds: ServiceRoutesMap = new Map([
139
+ ['routeA', { type: 'BUS', name: 'B1' }],
140
+ ['routeB', { type: 'TRAM', name: 'T1' }],
141
+ ]);
142
+
143
+ const trips = await parseTrips(
144
+ mockedStream,
145
+ validServiceIds,
146
+ validRouteIds,
147
+ );
148
+ assert.deepEqual(trips, new Map([['trip1', 'routeA']]));
149
+ });
150
+
151
+ it('should ignore trips with invalid route ids', async () => {
152
+ const mockedStream = new Readable();
153
+ mockedStream.push('route_id,service_id,trip_id\n');
154
+ mockedStream.push('"routeA","service1","trip1"\n');
155
+ mockedStream.push('"routeC","service2","trip2"\n');
156
+ mockedStream.push(null);
157
+
158
+ const validServiceIds: ServiceIds = new Set(['service1', 'service2']);
159
+ const validRouteIds: ServiceRoutesMap = new Map([
160
+ ['routeA', { type: 'BUS', name: 'B1' }],
161
+ ['routeB', { type: 'TRAM', name: 'T1' }],
162
+ ]);
163
+
164
+ const trips = await parseTrips(
165
+ mockedStream,
166
+ validServiceIds,
167
+ validRouteIds,
168
+ );
169
+ assert.deepEqual(trips, new Map([['trip1', 'routeA']]));
170
+ });
171
+ });
172
+
173
+ describe('GTFS stop times parser', () => {
174
+ it('should correctly parse valid stop times', async () => {
175
+ const mockedStream = new Readable();
176
+ mockedStream.push(
177
+ 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type\n',
178
+ );
179
+ mockedStream.push('"tripA","08:00:00","08:05:00","stop1","1","0","0"\n');
180
+ mockedStream.push('"tripA","08:10:00","08:15:00","stop2","2","0","0"\n');
181
+ mockedStream.push(null);
182
+
183
+ const validTripIds: TripIdsMap = new Map([['tripA', 'routeA']]);
184
+ const validStopIds: StopIds = new Set(['stop1', 'stop2']);
185
+
186
+ const routes = await parseStopTimes(
187
+ mockedStream,
188
+ validTripIds,
189
+ validStopIds,
190
+ );
191
+ assert.deepEqual(
192
+ routes,
193
+ new Map([
194
+ [
195
+ 'routeA_1e0e4u3',
196
+ {
197
+ serviceRouteId: 'routeA',
198
+ stops: ['stop1', 'stop2'],
199
+ stopIndices: new Map([
200
+ ['stop1', 0],
201
+ ['stop2', 1],
202
+ ]),
203
+ stopTimes: [
204
+ {
205
+ arrival: Time.fromHMS(8, 0, 0),
206
+ departure: Time.fromHMS(8, 5, 0),
207
+ pickUpType: 'REGULAR',
208
+ dropOffType: 'REGULAR',
209
+ },
210
+ {
211
+ arrival: Time.fromHMS(8, 10, 0),
212
+ departure: Time.fromHMS(8, 15, 0),
213
+ pickUpType: 'REGULAR',
214
+ dropOffType: 'REGULAR',
215
+ },
216
+ ],
217
+ },
218
+ ],
219
+ ]),
220
+ );
221
+ });
222
+
223
+ it('should create same route for same GTFS route with same stops', async () => {
224
+ const mockedStream = new Readable();
225
+ mockedStream.push(
226
+ 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type\n',
227
+ );
228
+ mockedStream.push('"tripA","08:00:00","08:05:00","stop1","1","0","0"\n');
229
+ mockedStream.push('"tripA","08:10:00","08:15:00","stop2","2","0","0"\n');
230
+ mockedStream.push('"tripB","09:00:00","09:05:00","stop1","1","0","0"\n');
231
+ mockedStream.push('"tripB","09:10:00","09:15:00","stop2","2","0","0"\n');
232
+ mockedStream.push(null);
233
+
234
+ const validTripIds: TripIdsMap = new Map([
235
+ ['tripA', 'routeA'],
236
+ ['tripB', 'routeA'],
237
+ ]);
238
+ const validStopIds: StopIds = new Set(['stop1', 'stop2']);
239
+
240
+ const routes = await parseStopTimes(
241
+ mockedStream,
242
+ validTripIds,
243
+ validStopIds,
244
+ );
245
+ assert.deepEqual(
246
+ routes,
247
+ new Map([
248
+ [
249
+ 'routeA_1e0e4u3',
250
+ {
251
+ serviceRouteId: 'routeA',
252
+ stops: ['stop1', 'stop2'],
253
+ stopIndices: new Map([
254
+ ['stop1', 0],
255
+ ['stop2', 1],
256
+ ]),
257
+ stopTimes: [
258
+ {
259
+ arrival: Time.fromHMS(8, 0, 0),
260
+ departure: Time.fromHMS(8, 5, 0),
261
+ pickUpType: 'REGULAR',
262
+ dropOffType: 'REGULAR',
263
+ },
264
+ {
265
+ arrival: Time.fromHMS(8, 10, 0),
266
+ departure: Time.fromHMS(8, 15, 0),
267
+ pickUpType: 'REGULAR',
268
+ dropOffType: 'REGULAR',
269
+ },
270
+ {
271
+ arrival: Time.fromHMS(9, 0, 0),
272
+ departure: Time.fromHMS(9, 5, 0),
273
+ pickUpType: 'REGULAR',
274
+ dropOffType: 'REGULAR',
275
+ },
276
+ {
277
+ arrival: Time.fromHMS(9, 10, 0),
278
+ departure: Time.fromHMS(9, 15, 0),
279
+ pickUpType: 'REGULAR',
280
+ dropOffType: 'REGULAR',
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ ]),
286
+ );
287
+ });
288
+
289
+ it('should support unsorted trips within a route', async () => {
290
+ const mockedStream = new Readable();
291
+ mockedStream.push(
292
+ 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type\n',
293
+ );
294
+ mockedStream.push('"tripB","09:00:00","09:05:00","stop1","1","0","0"\n');
295
+ mockedStream.push('"tripB","09:10:00","09:15:00","stop2","2","0","0"\n');
296
+ mockedStream.push('"tripA","08:00:00","08:05:00","stop1","1","0","0"\n');
297
+ mockedStream.push('"tripA","08:10:00","08:15:00","stop2","2","0","0"\n');
298
+ mockedStream.push(null);
299
+
300
+ const validTripIds: TripIdsMap = new Map([
301
+ ['tripA', 'routeA'],
302
+ ['tripB', 'routeA'],
303
+ ]);
304
+ const validStopIds: StopIds = new Set(['stop1', 'stop2']);
305
+
306
+ const routes = await parseStopTimes(
307
+ mockedStream,
308
+ validTripIds,
309
+ validStopIds,
310
+ );
311
+ assert.deepEqual(
312
+ routes,
313
+ new Map([
314
+ [
315
+ 'routeA_1e0e4u3',
316
+ {
317
+ serviceRouteId: 'routeA',
318
+ stops: ['stop1', 'stop2'],
319
+ stopIndices: new Map([
320
+ ['stop1', 0],
321
+ ['stop2', 1],
322
+ ]),
323
+ stopTimes: [
324
+ {
325
+ arrival: Time.fromHMS(8, 0, 0),
326
+ departure: Time.fromHMS(8, 5, 0),
327
+ pickUpType: 'REGULAR',
328
+ dropOffType: 'REGULAR',
329
+ },
330
+ {
331
+ arrival: Time.fromHMS(8, 10, 0),
332
+ departure: Time.fromHMS(8, 15, 0),
333
+ pickUpType: 'REGULAR',
334
+ dropOffType: 'REGULAR',
335
+ },
336
+ {
337
+ arrival: Time.fromHMS(9, 0, 0),
338
+ departure: Time.fromHMS(9, 5, 0),
339
+ pickUpType: 'REGULAR',
340
+ dropOffType: 'REGULAR',
341
+ },
342
+ {
343
+ arrival: Time.fromHMS(9, 10, 0),
344
+ departure: Time.fromHMS(9, 15, 0),
345
+ pickUpType: 'REGULAR',
346
+ dropOffType: 'REGULAR',
347
+ },
348
+ ],
349
+ },
350
+ ],
351
+ ]),
352
+ );
353
+ });
354
+
355
+ it('should create distinct route for same GTFS route with different stops', async () => {
356
+ const mockedStream = new Readable();
357
+ mockedStream.push(
358
+ 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type\n',
359
+ );
360
+ mockedStream.push('"tripA","08:00:00","08:05:00","stop1","1","0","0"\n');
361
+ mockedStream.push('"tripA","08:10:00","08:15:00","stop2","2","0","0"\n');
362
+ mockedStream.push('"tripB","09:00:00","09:15:00","stop1","1","0","0"\n');
363
+ mockedStream.push(null);
364
+
365
+ const validTripIds: TripIdsMap = new Map([
366
+ ['tripA', 'routeA'],
367
+ ['tripB', 'routeA'],
368
+ ]);
369
+ const validStopIds: StopIds = new Set(['stop1', 'stop2']);
370
+
371
+ const routes = await parseStopTimes(
372
+ mockedStream,
373
+ validTripIds,
374
+ validStopIds,
375
+ );
376
+ assert.deepEqual(
377
+ routes,
378
+ new Map([
379
+ [
380
+ 'routeA_1e0e4u3',
381
+ {
382
+ serviceRouteId: 'routeA',
383
+ stops: ['stop1', 'stop2'],
384
+ stopIndices: new Map([
385
+ ['stop1', 0],
386
+ ['stop2', 1],
387
+ ]),
388
+ stopTimes: [
389
+ {
390
+ arrival: Time.fromHMS(8, 0, 0),
391
+ departure: Time.fromHMS(8, 5, 0),
392
+ pickUpType: 'REGULAR',
393
+ dropOffType: 'REGULAR',
394
+ },
395
+ {
396
+ arrival: Time.fromHMS(8, 10, 0),
397
+ departure: Time.fromHMS(8, 15, 0),
398
+ pickUpType: 'REGULAR',
399
+ dropOffType: 'REGULAR',
400
+ },
401
+ ],
402
+ },
403
+ ],
404
+ [
405
+ 'routeA_1tcrqn',
406
+ {
407
+ serviceRouteId: 'routeA',
408
+ stops: ['stop1'],
409
+ stopIndices: new Map([['stop1', 0]]),
410
+ stopTimes: [
411
+ {
412
+ arrival: Time.fromHMS(9, 0, 0),
413
+ departure: Time.fromHMS(9, 15, 0),
414
+ pickUpType: 'REGULAR',
415
+ dropOffType: 'REGULAR',
416
+ },
417
+ ],
418
+ },
419
+ ],
420
+ ]),
421
+ );
422
+ });
423
+
424
+ it('should throw an error for non-increasing stop sequences', async () => {
425
+ const mockedStream = new Readable();
426
+ mockedStream.push(
427
+ 'trip_id,arrival_time,departure_time,stop_id,stop_sequence,pickup_type,drop_off_type\n',
428
+ );
429
+ mockedStream.push('"tripA","08:00:00","08:05:00","stop1","2","0","0"\n');
430
+ mockedStream.push('"tripA","08:10:00","08:15:00","stop2","1","0","0"\n');
431
+ mockedStream.push(null);
432
+
433
+ const validTripIds: TripIdsMap = new Map([['tripA', 'routeA']]);
434
+ const validStopIds: StopIds = new Set(['stop1', 'stop2']);
435
+
436
+ const routes = await parseStopTimes(
437
+ mockedStream,
438
+ validTripIds,
439
+ validStopIds,
440
+ );
441
+ assert.deepEqual(
442
+ routes,
443
+ new Map([
444
+ [
445
+ 'routeA_1tcrqn',
446
+ {
447
+ serviceRouteId: 'routeA',
448
+ stops: ['stop1'],
449
+ stopIndices: new Map([['stop1', 0]]),
450
+ stopTimes: [
451
+ {
452
+ arrival: Time.fromHMS(8, 0, 0),
453
+ departure: Time.fromHMS(8, 5, 0),
454
+ pickUpType: 'REGULAR',
455
+ dropOffType: 'REGULAR',
456
+ },
457
+ ],
458
+ },
459
+ ],
460
+ ]),
461
+ );
462
+ });
463
+ });
@@ -0,0 +1,13 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { hash } from '../utils.js';
5
+
6
+ describe('Utility hash function', () => {
7
+ it('should be consistent for a given input', () => {
8
+ assert.equal(hash('stationA'), 'lswfbh');
9
+ });
10
+ it('should not collide with different input', () => {
11
+ assert.equal(hash('stationA') === hash('stationB'), false);
12
+ });
13
+ });
@@ -0,0 +1,154 @@
1
+ import log from 'loglevel';
2
+ import { DateTime } from 'luxon';
3
+ import StreamZip from 'node-stream-zip';
4
+
5
+ import { Platform } from '../stops/stops.js';
6
+ import { StopsIndex } from '../stops/stopsIndex.js';
7
+ import { RouteType, Timetable } from '../timetable/timetable.js';
8
+ import { standardProfile } from './profiles/standard.js';
9
+ import { parseRoutes } from './routes.js';
10
+ import { parseCalendar, parseCalendarDates, ServiceIds } from './services.js';
11
+ import { parseStops, StopEntry, StopIds } from './stops.js';
12
+ import { parseTransfers, TransfersMap } from './transfers.js';
13
+ import {
14
+ buildStopsAdjacencyStructure,
15
+ parseStopTimes,
16
+ parseTrips,
17
+ } from './trips.js';
18
+ import { Maybe } from './utils.js';
19
+
20
+ const CALENDAR_FILE = 'calendar.txt';
21
+ const CALENDAR_DATES_FILE = 'calendar_dates.txt';
22
+ const ROUTES_FILE = 'routes.txt';
23
+ const TRIPS_FILE = 'trips.txt';
24
+ const STOP_TIMES_FILE = 'stop_times.txt';
25
+ const STOPS_FILE = 'stops.txt';
26
+ const TRANSFERS_FILE = 'transfers.txt';
27
+
28
+ export type GtfsProfile = {
29
+ routeTypeParser: (routeType: number) => Maybe<RouteType>;
30
+ platformParser?: (stopEntry: StopEntry) => Maybe<Platform>;
31
+ };
32
+
33
+ export class GtfsParser {
34
+ private path: string;
35
+ private profile: GtfsProfile;
36
+
37
+ constructor(path: string, profile: GtfsProfile = standardProfile) {
38
+ // TODO: support input from multiple sources
39
+ this.path = path;
40
+ this.profile = profile;
41
+ }
42
+
43
+ /**
44
+ * Parses a GTFS feed to extract all the data relevant to a given day in a transit-planner friendly format.
45
+ *
46
+ * @param date The active date.
47
+ * @param gtfsPath A path to the zipped GTFS feed.
48
+ * @param gtfsProfile The GTFS profile configuration.
49
+ * @returns An object containing the timetable and stops map.
50
+ */
51
+ async parse(
52
+ date: Date,
53
+ ): Promise<{ timetable: Timetable; stopsIndex: StopsIndex }> {
54
+ const zip = new StreamZip.async({ file: this.path });
55
+ const entries = await zip.entries();
56
+ const datetime = DateTime.fromJSDate(date);
57
+
58
+ const validServiceIds: ServiceIds = new Set();
59
+ const validStopIds: StopIds = new Set();
60
+
61
+ if (entries[CALENDAR_FILE]) {
62
+ log.info(`Parsing ${CALENDAR_FILE}`);
63
+ const calendarStream = await zip.stream(CALENDAR_FILE);
64
+ await parseCalendar(calendarStream, validServiceIds, datetime);
65
+ log.info(`${validServiceIds.size} valid services.`);
66
+ }
67
+
68
+ if (entries[CALENDAR_DATES_FILE]) {
69
+ log.info(`Parsing ${CALENDAR_DATES_FILE}`);
70
+ const calendarDatesStream = await zip.stream(CALENDAR_DATES_FILE);
71
+ await parseCalendarDates(calendarDatesStream, validServiceIds, datetime);
72
+ log.info(`${validServiceIds.size} valid services.`);
73
+ }
74
+
75
+ log.info(`Parsing ${ROUTES_FILE}`);
76
+ const routesStream = await zip.stream(ROUTES_FILE);
77
+ const validGtfsRoutes = await parseRoutes(routesStream, this.profile);
78
+ log.info(`${validGtfsRoutes.size} valid GTFS routes.`);
79
+
80
+ log.info(`Parsing ${TRIPS_FILE}`);
81
+ const tripsStream = await zip.stream(TRIPS_FILE);
82
+ const trips = await parseTrips(
83
+ tripsStream,
84
+ validServiceIds,
85
+ validGtfsRoutes,
86
+ );
87
+ log.info(`${trips.size} valid trips.`);
88
+
89
+ let transfers = new Map() as TransfersMap;
90
+ if (entries[TRANSFERS_FILE]) {
91
+ log.info(`Parsing ${TRANSFERS_FILE}`);
92
+ const transfersStream = await zip.stream(TRANSFERS_FILE);
93
+ transfers = await parseTransfers(transfersStream);
94
+ log.info(`${transfers.size} valid transfers.`);
95
+ }
96
+
97
+ log.info(`Parsing ${STOP_TIMES_FILE}`);
98
+ const stopTimesStream = await zip.stream(STOP_TIMES_FILE);
99
+ const routesAdjacency = await parseStopTimes(
100
+ stopTimesStream,
101
+ trips,
102
+ validStopIds,
103
+ );
104
+ const stopsAdjacency = buildStopsAdjacencyStructure(
105
+ validStopIds,
106
+ routesAdjacency,
107
+ transfers,
108
+ );
109
+ log.info(`${routesAdjacency.size} valid unique routes.`);
110
+
111
+ log.info(`Parsing ${STOPS_FILE}`);
112
+ const stopsStream = await zip.stream(STOPS_FILE);
113
+ const stops = await parseStops(
114
+ stopsStream,
115
+ this.profile.platformParser,
116
+ validStopIds,
117
+ );
118
+ log.info(`${stops.size} valid stops.`);
119
+
120
+ await zip.close();
121
+
122
+ const timetable = new Timetable(
123
+ stopsAdjacency,
124
+ routesAdjacency,
125
+ validGtfsRoutes,
126
+ );
127
+
128
+ log.info(`Building stops index.`);
129
+ const stopsIndex = new StopsIndex(stops);
130
+
131
+ log.info('Parsing complete.');
132
+ return { timetable, stopsIndex };
133
+ }
134
+
135
+ /**
136
+ * Parses a GTFS feed to extract all stops.
137
+ *
138
+ * @param gtfsPath A path the zipped GTFS feed.
139
+ * @param gtfsProfile The GTFS profile configuration.
140
+ * @returns An object containing the timetable and stops map.
141
+ */
142
+ async parseStops(): Promise<StopsIndex> {
143
+ const zip = new StreamZip.async({ file: this.path });
144
+
145
+ log.info(`Parsing ${STOPS_FILE}`);
146
+ const stopsStream = await zip.stream(STOPS_FILE);
147
+ const stops = await parseStops(stopsStream, this.profile.platformParser);
148
+ log.info(`${stops.size} valid stops.`);
149
+
150
+ await zip.close();
151
+
152
+ return new StopsIndex(stops);
153
+ }
154
+ }
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { chGtfsProfile } from '../ch.js';
5
+
6
+ describe('The swiss GTFS feed parser', () => {
7
+ it('should extract the platform number from a stop entry', () => {
8
+ assert.ok(chGtfsProfile.platformParser);
9
+ assert.equal(
10
+ chGtfsProfile.platformParser({
11
+ stop_id: '8504100:0:1',
12
+ stop_name: 'Fribourg/Freiburg',
13
+ stop_lat: 46.8018210323626,
14
+ stop_lon: 7.14993389242926,
15
+ location_type: 1,
16
+ parent_station: 'Parent8504100',
17
+ }),
18
+ '1',
19
+ );
20
+ });
21
+ it('should not extract any platform number when not specified', () => {
22
+ assert.ok(chGtfsProfile.platformParser);
23
+ assert.equal(
24
+ chGtfsProfile.platformParser({
25
+ stop_id: 'Parent8587255',
26
+ stop_name: 'Fribourg, Tilleul/Cathédrale',
27
+ stop_lat: 46.8061375857565,
28
+ stop_lon: 7.16145029437328,
29
+ location_type: 1,
30
+ parent_station: '',
31
+ }),
32
+ undefined,
33
+ );
34
+ });
35
+ it('should convert the SBB route type to GTFS route type', () => {
36
+ assert.ok(chGtfsProfile.routeTypeParser);
37
+ assert.equal(chGtfsProfile.routeTypeParser(106), 'RAIL');
38
+ });
39
+ it('should not convert an unknown SBB route type', () => {
40
+ assert.ok(chGtfsProfile.routeTypeParser);
41
+ assert.equal(chGtfsProfile.routeTypeParser(716), undefined);
42
+ });
43
+ });