minotor 7.0.1 → 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 (59) 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 +1268 -290
  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 +519 -95
  9. package/dist/parser.cjs.js.map +1 -1
  10. package/dist/parser.esm.js +519 -95
  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 -209
  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/time.ts +23 -9
  58. package/src/timetable/timetable.ts +46 -9
  59. package/src/timetable/tripId.ts +29 -0
@@ -6,7 +6,7 @@ import {
6
6
  TransferType,
7
7
  } from '../timetable/timetable.js';
8
8
  import { GtfsStopsMap } from './stops.js';
9
- import { TripId } from './trips.js';
9
+ import { GtfsTripId } from './trips.js';
10
10
  import { parseCsv } from './utils.js';
11
11
 
12
12
  export type GtfsTransferType =
@@ -19,11 +19,19 @@ export type GtfsTransferType =
19
19
 
20
20
  export type TransfersMap = Map<StopId, Transfer[]>;
21
21
 
22
+ export type GtfsTripBoarding = {
23
+ fromTrip: GtfsTripId;
24
+ toTrip: GtfsTripId;
25
+ hopOnStop: StopId;
26
+ };
27
+
28
+ export type TripContinuationsMap = Map<StopId, GtfsTripBoarding[]>;
29
+
22
30
  export type TransferEntry = {
23
31
  from_stop_id?: SourceStopId;
24
32
  to_stop_id?: SourceStopId;
25
- from_trip_id?: TripId;
26
- to_trip_id?: TripId;
33
+ from_trip_id?: GtfsTripId;
34
+ to_trip_id?: GtfsTripId;
27
35
  from_route_id?: ServiceRouteId;
28
36
  to_route_id?: ServiceRouteId;
29
37
  transfer_type: GtfsTransferType;
@@ -39,9 +47,12 @@ export type TransferEntry = {
39
47
  export const parseTransfers = async (
40
48
  transfersStream: NodeJS.ReadableStream,
41
49
  stopsMap: GtfsStopsMap,
42
- ): Promise<TransfersMap> => {
50
+ ): Promise<{
51
+ transfers: TransfersMap;
52
+ tripContinuations: TripContinuationsMap;
53
+ }> => {
43
54
  const transfers: TransfersMap = new Map();
44
-
55
+ const tripContinuations: TripContinuationsMap = new Map();
45
56
  for await (const rawLine of parseCsv(transfersStream, [
46
57
  'transfer_type',
47
58
  'min_transfer_time',
@@ -54,33 +65,62 @@ export const parseTransfers = async (
54
65
  ) {
55
66
  continue;
56
67
  }
68
+ if (!transferEntry.from_stop_id || !transferEntry.to_stop_id) {
69
+ console.warn(`Missing transfer origin or destination stop.`);
70
+ continue;
71
+ }
72
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
73
+ const fromStop = stopsMap.get(transferEntry.from_stop_id)!;
74
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
75
+ const toStop = stopsMap.get(transferEntry.to_stop_id)!;
76
+
77
+ if (transferEntry.transfer_type === 4) {
78
+ if (
79
+ transferEntry.from_trip_id === undefined ||
80
+ transferEntry.from_trip_id === '' ||
81
+ transferEntry.to_trip_id === undefined ||
82
+ transferEntry.to_trip_id === ''
83
+ ) {
84
+ console.warn(
85
+ `Unsupported in-seat transfer, missing from_trip_id and/or to_trip_id.`,
86
+ );
87
+ continue;
88
+ }
89
+ const tripBoardingEntry: GtfsTripBoarding = {
90
+ fromTrip: transferEntry.from_trip_id,
91
+ toTrip: transferEntry.to_trip_id,
92
+ hopOnStop: toStop.id,
93
+ };
94
+ const existingBoardings = tripContinuations.get(fromStop.id);
95
+ if (existingBoardings) {
96
+ existingBoardings.push(tripBoardingEntry);
97
+ } else {
98
+ tripContinuations.set(fromStop.id, [tripBoardingEntry]);
99
+ }
100
+ continue;
101
+ }
57
102
  if (transferEntry.from_trip_id && transferEntry.to_trip_id) {
58
103
  console.warn(
59
- `Unsupported transfer between trips ${transferEntry.from_trip_id} and ${transferEntry.to_trip_id}.`,
104
+ `Unsupported transfer of type ${transferEntry.transfer_type} between trips ${transferEntry.from_trip_id} and ${transferEntry.to_trip_id}.`,
60
105
  );
61
106
  continue;
62
107
  }
63
108
  if (transferEntry.from_route_id && transferEntry.to_route_id) {
64
109
  console.warn(
65
- `Unsupported transfer between routes ${transferEntry.from_route_id} and ${transferEntry.to_route_id}.`,
110
+ `Unsupported transfer of type ${transferEntry.transfer_type} between routes ${transferEntry.from_route_id} and ${transferEntry.to_route_id}.`,
66
111
  );
67
112
  continue;
68
113
  }
69
- if (!transferEntry.from_stop_id || !transferEntry.to_stop_id) {
70
- console.warn(`Missing transfer origin or destination stop.`);
71
- continue;
72
- }
73
- if (transferEntry.transfer_type === 2 && !transferEntry.min_transfer_time) {
114
+
115
+ if (
116
+ transferEntry.transfer_type === 2 &&
117
+ transferEntry.min_transfer_time === undefined
118
+ ) {
74
119
  console.info(
75
120
  `Missing minimum transfer time between ${transferEntry.from_stop_id} and ${transferEntry.to_stop_id}.`,
76
121
  );
77
122
  }
78
123
 
79
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
80
- const fromStop = stopsMap.get(transferEntry.from_stop_id)!;
81
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
82
- const toStop = stopsMap.get(transferEntry.to_stop_id)!;
83
-
84
124
  const transfer: Transfer = {
85
125
  destination: toStop.id,
86
126
  type: parseGtfsTransferType(transferEntry.transfer_type),
@@ -93,7 +133,10 @@ export const parseTransfers = async (
93
133
  fromStopTransfers.push(transfer);
94
134
  transfers.set(fromStop.id, fromStopTransfers);
95
135
  }
96
- return transfers;
136
+ return {
137
+ transfers,
138
+ tripContinuations,
139
+ };
97
140
  };
98
141
 
99
142
  const parseGtfsTransferType = (
package/src/gtfs/trips.ts CHANGED
@@ -6,27 +6,35 @@ import {
6
6
  NOT_AVAILABLE,
7
7
  REGULAR,
8
8
  Route,
9
+ RouteId,
10
+ TripRouteIndex,
9
11
  } from '../timetable/route.js';
10
12
  import {
11
13
  ServiceRoute,
12
14
  ServiceRouteId,
13
15
  StopAdjacency,
14
16
  } from '../timetable/timetable.js';
17
+ import { encode } from '../timetable/tripId.js';
15
18
  import { GtfsRouteId, GtfsRoutesMap } from './routes.js';
16
19
  import { ServiceId, ServiceIds } from './services.js';
17
20
  import { GtfsStopsMap } from './stops.js';
18
21
  import { GtfsTime, toTime } from './time.js';
19
- import { TransfersMap } from './transfers.js';
22
+ import { TransfersMap, TripContinuationsMap } from './transfers.js';
20
23
  import { hashIds, parseCsv } from './utils.js';
21
24
 
22
- export type TripId = string;
25
+ export type GtfsTripId = string;
23
26
 
24
- export type TripIdsMap = Map<TripId, GtfsRouteId>;
27
+ export type GtfsTripIdsMap = Map<GtfsTripId, GtfsRouteId>;
28
+
29
+ export type TripsMapping = Map<
30
+ GtfsTripId,
31
+ { routeId: RouteId; tripRouteIndex: TripRouteIndex }
32
+ >;
25
33
 
26
34
  type TripEntry = {
27
35
  route_id: GtfsRouteId;
28
36
  service_id: ServiceId;
29
- trip_id: TripId;
37
+ trip_id: GtfsTripId;
30
38
  };
31
39
 
32
40
  export type GtfsPickupDropOffType =
@@ -37,7 +45,7 @@ export type GtfsPickupDropOffType =
37
45
  | '3'; // Must coordinate with driver
38
46
 
39
47
  type StopTimeEntry = {
40
- trip_id: TripId;
48
+ trip_id: GtfsTripId;
41
49
  arrival_time?: GtfsTime;
42
50
  departure_time?: GtfsTime;
43
51
  stop_id: SourceStopId;
@@ -55,6 +63,7 @@ type RouteBuilder = {
55
63
  serviceRouteId: ServiceRouteId;
56
64
  stops: StopId[];
57
65
  trips: Array<{
66
+ gtfsTripId: GtfsTripId;
58
67
  firstDeparture: number;
59
68
  arrivalTimes: number[];
60
69
  departureTimes: number[];
@@ -109,7 +118,9 @@ export const encodePickUpDropOffTypes = (
109
118
  /**
110
119
  * Sorts trips by departure time and creates optimized typed arrays
111
120
  */
112
- const finalizeRouteFromBuilder = (builder: RouteBuilder): SerializedRoute => {
121
+ const finalizeRouteFromBuilder = (
122
+ builder: RouteBuilder,
123
+ ): [SerializedRoute, GtfsTripId[]] => {
113
124
  builder.trips.sort((a, b) => a.firstDeparture - b.firstDeparture);
114
125
 
115
126
  const stopsCount = builder.stops.length;
@@ -119,11 +130,13 @@ const finalizeRouteFromBuilder = (builder: RouteBuilder): SerializedRoute => {
119
130
  const allPickUpTypes: SerializedPickUpDropOffType[] = [];
120
131
  const allDropOffTypes: SerializedPickUpDropOffType[] = [];
121
132
 
133
+ const gtfsTripIds = [];
122
134
  for (let tripIndex = 0; tripIndex < tripsCount; tripIndex++) {
123
135
  const trip = builder.trips[tripIndex];
124
136
  if (!trip) {
125
137
  throw new Error(`Missing trip data at index ${tripIndex}`);
126
138
  }
139
+ gtfsTripIds.push(trip.gtfsTripId);
127
140
  const baseIndex = tripIndex * stopsCount * 2;
128
141
 
129
142
  for (let stopIndex = 0; stopIndex < stopsCount; stopIndex++) {
@@ -155,12 +168,15 @@ const finalizeRouteFromBuilder = (builder: RouteBuilder): SerializedRoute => {
155
168
  allPickUpTypes,
156
169
  allDropOffTypes,
157
170
  );
158
- return {
159
- serviceRouteId: builder.serviceRouteId,
160
- stops: stopsArray,
161
- stopTimes: stopTimesArray,
162
- pickUpDropOffTypes: pickUpDropOffTypesArray,
163
- };
171
+ return [
172
+ {
173
+ serviceRouteId: builder.serviceRouteId,
174
+ stops: stopsArray,
175
+ stopTimes: stopTimesArray,
176
+ pickUpDropOffTypes: pickUpDropOffTypesArray,
177
+ },
178
+ gtfsTripIds,
179
+ ];
164
180
  };
165
181
 
166
182
  /**
@@ -175,8 +191,8 @@ export const parseTrips = async (
175
191
  tripsStream: NodeJS.ReadableStream,
176
192
  serviceIds: ServiceIds,
177
193
  validGtfsRoutes: GtfsRoutesMap,
178
- ): Promise<TripIdsMap> => {
179
- const trips: TripIdsMap = new Map();
194
+ ): Promise<GtfsTripIdsMap> => {
195
+ const trips: GtfsTripIdsMap = new Map();
180
196
  for await (const rawLine of parseCsv(tripsStream, ['stop_sequence'])) {
181
197
  const line = rawLine as TripEntry;
182
198
  if (!serviceIds.has(line.service_id)) {
@@ -193,16 +209,19 @@ export const parseTrips = async (
193
209
  };
194
210
 
195
211
  export const buildStopsAdjacencyStructure = (
212
+ tripsMapping: TripsMapping,
196
213
  serviceRoutes: ServiceRoute[],
197
214
  routes: Route[],
198
215
  transfersMap: TransfersMap,
216
+ tripContinuationsMap: TripContinuationsMap,
199
217
  nbStops: number,
200
218
  activeStops: Set<StopId>,
201
219
  ): StopAdjacency[] => {
202
- // TODO somehow works when it's a map
203
220
  const stopsAdjacency = new Array<StopAdjacency>(nbStops);
204
221
  for (let i = 0; i < nbStops; i++) {
205
- stopsAdjacency[i] = { routes: [], transfers: [] };
222
+ stopsAdjacency[i] = {
223
+ routes: [],
224
+ };
206
225
  }
207
226
  for (let index = 0; index < routes.length; index++) {
208
227
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -229,12 +248,51 @@ export const buildStopsAdjacencyStructure = (
229
248
  const transfer = transfers[i]!;
230
249
  if (activeStops.has(stop) || activeStops.has(transfer.destination)) {
231
250
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
232
- stopsAdjacency[stop]!.transfers.push(transfer);
251
+ const stopAdj = stopsAdjacency[stop]!;
252
+ if (!stopAdj.transfers) {
253
+ stopAdj.transfers = [];
254
+ }
255
+ stopAdj.transfers.push(transfer);
233
256
  activeStops.add(transfer.destination);
234
257
  activeStops.add(stop);
235
258
  }
236
259
  }
237
260
  }
261
+ for (const [stop, tripContinuations] of tripContinuationsMap) {
262
+ for (let i = 0; i < tripContinuations.length; i++) {
263
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
264
+ const tripContinuation = tripContinuations[i]!;
265
+ if (
266
+ activeStops.has(stop) ||
267
+ activeStops.has(tripContinuation.hopOnStop)
268
+ ) {
269
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
270
+ const stopAdj = stopsAdjacency[stop]!;
271
+ if (!stopAdj.tripContinuations) {
272
+ stopAdj.tripContinuations = new Map();
273
+ }
274
+ const originTrip = tripsMapping.get(tripContinuation.fromTrip);
275
+ const destinationTrip = tripsMapping.get(tripContinuation.toTrip);
276
+ if (destinationTrip === undefined || originTrip === undefined) {
277
+ continue;
278
+ }
279
+ const tripBoarding = {
280
+ hopOnStop: tripContinuation.hopOnStop,
281
+ routeId: destinationTrip.routeId,
282
+ tripIndex: destinationTrip.tripRouteIndex,
283
+ };
284
+ const tripId = encode(originTrip.routeId, originTrip.tripRouteIndex);
285
+ const existingContinuations = stopAdj.tripContinuations.get(tripId);
286
+ if (existingContinuations) {
287
+ existingContinuations.push(tripBoarding);
288
+ } else {
289
+ stopAdj.tripContinuations.set(tripId, [tripBoarding]);
290
+ }
291
+ activeStops.add(tripContinuation.hopOnStop);
292
+ activeStops.add(stop);
293
+ }
294
+ }
295
+ }
238
296
  return stopsAdjacency;
239
297
  };
240
298
 
@@ -250,16 +308,17 @@ export const buildStopsAdjacencyStructure = (
250
308
  export const parseStopTimes = async (
251
309
  stopTimesStream: NodeJS.ReadableStream,
252
310
  stopsMap: GtfsStopsMap,
253
- activeTripIds: TripIdsMap,
311
+ activeTripIds: GtfsTripIdsMap,
254
312
  activeStopIds: Set<StopId>,
255
313
  ): Promise<{
256
314
  routes: Route[];
257
315
  serviceRoutesMap: Map<GtfsRouteId, ServiceRouteId>;
316
+ tripsMapping: TripsMapping;
258
317
  }> => {
259
318
  /**
260
319
  * Adds a trip to the appropriate route builder
261
320
  */
262
- const addTrip = (currentTripId: TripId) => {
321
+ const addTrip = (currentTripId: GtfsTripId) => {
263
322
  const gtfsRouteId = activeTripIds.get(currentTripId);
264
323
 
265
324
  if (!gtfsRouteId || stops.length === 0) {
@@ -304,6 +363,7 @@ export const parseStopTimes = async (
304
363
 
305
364
  routeBuilder.trips.push({
306
365
  firstDeparture,
366
+ gtfsTripId: currentTripId,
307
367
  arrivalTimes: arrivalTimes,
308
368
  departureTimes: departureTimes,
309
369
  pickUpTypes: pickUpTypes,
@@ -330,7 +390,7 @@ export const parseStopTimes = async (
330
390
  let departureTimes: number[] = [];
331
391
  let pickUpTypes: SerializedPickUpDropOffType[] = [];
332
392
  let dropOffTypes: SerializedPickUpDropOffType[] = [];
333
- let currentTripId: TripId | undefined = undefined;
393
+ let currentTripId: GtfsTripId | undefined = undefined;
334
394
 
335
395
  for await (const rawLine of parseCsv(stopTimesStream, ['stop_sequence'])) {
336
396
  const line = rawLine as StopTimeEntry;
@@ -347,6 +407,9 @@ export const parseStopTimes = async (
347
407
  continue;
348
408
  }
349
409
  if (line.pickup_type === '1' && line.drop_off_type === '1') {
410
+ // Warning: could potentially lead to issues if there is an in-seat transfer
411
+ // at this stop - it can be not boardable nor alightable but still useful for an in-seat transfer.
412
+ // This doesn't seem to happen in practice for now so keeping this condition to save memory.
350
413
  continue;
351
414
  }
352
415
  if (currentTripId && line.trip_id !== currentTripId && stops.length > 0) {
@@ -382,18 +445,30 @@ export const parseStopTimes = async (
382
445
  }
383
446
 
384
447
  const routesAdjacency: Route[] = [];
448
+ const tripsMapping = new Map<
449
+ GtfsTripId,
450
+ { routeId: RouteId; tripRouteIndex: TripRouteIndex }
451
+ >();
385
452
  for (const [, routeBuilder] of routeBuilders) {
386
- const routeData = finalizeRouteFromBuilder(routeBuilder);
453
+ const [routeData, gtfsTripIds] = finalizeRouteFromBuilder(routeBuilder);
454
+ const routeId = routesAdjacency.length;
387
455
  routesAdjacency.push(
388
456
  new Route(
457
+ routeId,
389
458
  routeData.stopTimes,
390
459
  routeData.pickUpDropOffTypes,
391
460
  routeData.stops,
392
461
  routeData.serviceRouteId,
393
462
  ),
394
463
  );
464
+ gtfsTripIds.forEach((tripId, index) => {
465
+ tripsMapping.set(tripId, {
466
+ routeId,
467
+ tripRouteIndex: index,
468
+ });
469
+ });
395
470
  }
396
- return { routes: routesAdjacency, serviceRoutesMap };
471
+ return { routes: routesAdjacency, serviceRoutesMap, tripsMapping };
397
472
  };
398
473
 
399
474
  const parsePickupDropOffType = (
package/src/router.ts CHANGED
@@ -3,7 +3,7 @@ import { Query } from './routing/query.js';
3
3
  import { Result } from './routing/result.js';
4
4
  import type { Leg, Transfer, VehicleLeg } from './routing/route.js';
5
5
  import { Route } from './routing/route.js';
6
- import type { ReachingTime } from './routing/router.js';
6
+ import type { Arrival } from './routing/router.js';
7
7
  import { Router } from './routing/router.js';
8
8
  import type { LocationType, SourceStopId, StopId } from './stops/stops.js';
9
9
  import type { Stop } from './stops/stops.js';
@@ -30,9 +30,9 @@ export {
30
30
  };
31
31
 
32
32
  export type {
33
+ Arrival,
33
34
  Leg,
34
35
  LocationType,
35
- ReachingTime,
36
36
  RouteType,
37
37
  ServiceRouteInfo,
38
38
  SourceStopId,
@@ -0,0 +1,230 @@
1
+ import assert from 'node:assert';
2
+ import { describe, it } from 'node:test';
3
+
4
+ import { Timetable } from '../../router.js';
5
+ import { Stop, StopId } from '../../stops/stops.js';
6
+ import { StopsIndex } from '../../stops/stopsIndex.js';
7
+ import { Route } from '../../timetable/route.js';
8
+ import { Time } from '../../timetable/time.js';
9
+ import { ServiceRoute, StopAdjacency } from '../../timetable/timetable.js';
10
+ import { Plotter } from '../plotter.js';
11
+ import { Query } from '../query.js';
12
+ import { Result } from '../result.js';
13
+ import { RoutingEdge } from '../router.js';
14
+
15
+ describe('Plotter', () => {
16
+ const stop1: Stop = {
17
+ id: 0,
18
+ sourceStopId: 'stop1',
19
+ name: 'Lausanne',
20
+ children: [],
21
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
22
+ };
23
+
24
+ const stop2: Stop = {
25
+ id: 1,
26
+ sourceStopId: 'stop2',
27
+ name: 'Fribourg',
28
+ children: [],
29
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
30
+ };
31
+
32
+ const stopsAdjacency: StopAdjacency[] = [{ routes: [0] }, { routes: [0] }];
33
+
34
+ const routesAdjacency = [
35
+ Route.of({
36
+ id: 0,
37
+ serviceRouteId: 0,
38
+ trips: [
39
+ {
40
+ stops: [
41
+ {
42
+ id: 0,
43
+ arrivalTime: Time.fromString('08:00:00'),
44
+ departureTime: Time.fromString('08:05:00'),
45
+ },
46
+ {
47
+ id: 1,
48
+ arrivalTime: Time.fromString('08:30:00'),
49
+ departureTime: Time.fromString('08:35:00'),
50
+ },
51
+ ],
52
+ },
53
+ ],
54
+ }),
55
+ ];
56
+
57
+ const routes: ServiceRoute[] = [
58
+ {
59
+ type: 'RAIL',
60
+ name: 'IC 1',
61
+ routes: [0],
62
+ },
63
+ ];
64
+
65
+ const mockStopsIndex = new StopsIndex([stop1, stop2]);
66
+ const mockTimetable = new Timetable(stopsAdjacency, routesAdjacency, routes);
67
+ const mockQuery = new Query.Builder()
68
+ .from('stop1')
69
+ .to(new Set(['stop2']))
70
+ .departureTime(Time.fromHMS(8, 0, 0))
71
+ .build();
72
+
73
+ describe('plotDotGraph', () => {
74
+ it('should generate valid DOT graph structure', () => {
75
+ const result = new Result(
76
+ mockQuery,
77
+ {
78
+ earliestArrivals: new Map(),
79
+ graph: [],
80
+ destinations: [],
81
+ },
82
+ mockStopsIndex,
83
+ mockTimetable,
84
+ );
85
+
86
+ const plotter = new Plotter(result);
87
+ const dotGraph = plotter.plotDotGraph();
88
+
89
+ assert(dotGraph.includes('digraph RoutingGraph {'));
90
+ assert(dotGraph.includes('// Stations'));
91
+ assert(dotGraph.includes('// Edges'));
92
+ assert(dotGraph.endsWith('}'));
93
+ });
94
+
95
+ it('should include station nodes', () => {
96
+ const graph: Map<StopId, RoutingEdge>[] = [
97
+ new Map([[0, { arrival: Time.fromHMS(8, 0, 0) }]]),
98
+ ];
99
+
100
+ const result = new Result(
101
+ mockQuery,
102
+ {
103
+ earliestArrivals: new Map(),
104
+ graph,
105
+ destinations: [0],
106
+ },
107
+ mockStopsIndex,
108
+ mockTimetable,
109
+ );
110
+
111
+ const plotter = new Plotter(result);
112
+ const dotGraph = plotter.plotDotGraph();
113
+
114
+ assert(dotGraph.includes('"s_0"'));
115
+ assert(dotGraph.includes('Lausanne'));
116
+ assert(dotGraph.includes('shape=box'));
117
+ });
118
+
119
+ it('should handle empty graph gracefully', () => {
120
+ const result = new Result(
121
+ mockQuery,
122
+ {
123
+ earliestArrivals: new Map(),
124
+ graph: [],
125
+ destinations: [],
126
+ },
127
+ mockStopsIndex,
128
+ mockTimetable,
129
+ );
130
+
131
+ const plotter = new Plotter(result);
132
+ const dotGraph = plotter.plotDotGraph();
133
+
134
+ assert(dotGraph.includes('digraph RoutingGraph {'));
135
+ assert(dotGraph.endsWith('}'));
136
+ });
137
+
138
+ it('should escape special characters', () => {
139
+ const specialStop: Stop = {
140
+ id: 2,
141
+ sourceStopId: 'test"stop\\with\nlines\rand\ttabs',
142
+ name: 'Station "Test"\nWith\\Special\rChars\tAndTabs',
143
+ lat: 0,
144
+ lon: 0,
145
+ children: [],
146
+ parent: undefined,
147
+ locationType: 'SIMPLE_STOP_OR_PLATFORM',
148
+ };
149
+
150
+ const specialStopsIndex = new StopsIndex([stop1, stop2, specialStop]);
151
+ const graph: Map<StopId, RoutingEdge>[] = [
152
+ new Map([[2, { arrival: Time.fromHMS(8, 0, 0) }]]),
153
+ ];
154
+
155
+ const result = new Result(
156
+ mockQuery,
157
+ {
158
+ earliestArrivals: new Map(),
159
+ graph,
160
+ destinations: [2],
161
+ },
162
+ specialStopsIndex,
163
+ mockTimetable,
164
+ );
165
+
166
+ const plotter = new Plotter(result);
167
+ const dotGraph = plotter.plotDotGraph();
168
+
169
+ // Check that special characters are properly escaped in the station label
170
+ assert(
171
+ dotGraph.includes(
172
+ 'Station \\"Test\\"\\nWith\\\\Special\\rChars\\tAndTabs\\n2',
173
+ ),
174
+ 'Station name should have properly escaped special characters',
175
+ );
176
+ });
177
+
178
+ it('should use correct colors', () => {
179
+ const graph: Map<StopId, RoutingEdge>[] = [
180
+ new Map([[0, { arrival: Time.fromHMS(8, 0, 0) }]]),
181
+ new Map([
182
+ [
183
+ 1,
184
+ {
185
+ from: 0,
186
+ to: 1,
187
+ arrival: Time.fromHMS(8, 30, 0),
188
+ routeId: 0,
189
+ tripIndex: 0,
190
+ },
191
+ ],
192
+ ]),
193
+ new Map([
194
+ [
195
+ 1,
196
+ {
197
+ from: 0,
198
+ to: 1,
199
+ arrival: Time.fromHMS(8, 45, 0),
200
+ type: 'WALKING',
201
+ minTransferTime: Time.fromHMS(0, 5, 0),
202
+ },
203
+ ],
204
+ ]),
205
+ ];
206
+
207
+ const result = new Result(
208
+ mockQuery,
209
+ {
210
+ earliestArrivals: new Map(),
211
+ graph,
212
+ destinations: [1],
213
+ },
214
+ mockStopsIndex,
215
+ mockTimetable,
216
+ );
217
+
218
+ const plotter = new Plotter(result);
219
+ const dotGraph = plotter.plotDotGraph();
220
+ assert(
221
+ dotGraph.includes('color="#60a5fa"'),
222
+ 'Round 1 should use blue color (#60a5fa)',
223
+ );
224
+ assert(
225
+ dotGraph.includes('color="#ff9800"'),
226
+ 'Round 2 should use orange color (#ff9800)',
227
+ );
228
+ });
229
+ });
230
+ });