minotor 11.1.2 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cspell.json +7 -1
- package/CHANGELOG.md +3 -3
- package/README.md +111 -86
- package/dist/cli/perf.d.ts +57 -18
- package/dist/cli.mjs +1371 -342
- package/dist/cli.mjs.map +1 -1
- package/dist/parser.cjs.js +57 -4
- package/dist/parser.cjs.js.map +1 -1
- package/dist/parser.esm.js +57 -4
- package/dist/parser.esm.js.map +1 -1
- package/dist/router.cjs.js +1 -1
- package/dist/router.cjs.js.map +1 -1
- package/dist/router.d.ts +5 -5
- package/dist/router.esm.js +1 -1
- package/dist/router.esm.js.map +1 -1
- package/dist/router.umd.js +1 -1
- package/dist/router.umd.js.map +1 -1
- package/dist/routing/__tests__/access.test.d.ts +1 -0
- package/dist/routing/__tests__/plainRouter.test.d.ts +1 -0
- package/dist/routing/__tests__/rangeResult.test.d.ts +1 -0
- package/dist/routing/__tests__/rangeRouter.test.d.ts +1 -0
- package/dist/routing/__tests__/rangeState.test.d.ts +1 -0
- package/dist/routing/__tests__/raptor.test.d.ts +1 -0
- package/dist/routing/__tests__/state.test.d.ts +1 -0
- package/dist/routing/access.d.ts +55 -0
- package/dist/routing/plainRouter.d.ts +21 -0
- package/dist/routing/plotter.d.ts +9 -0
- package/dist/routing/query.d.ts +132 -13
- package/dist/routing/rangeResult.d.ts +155 -0
- package/dist/routing/rangeRouter.d.ts +24 -0
- package/dist/routing/rangeState.d.ts +83 -0
- package/dist/routing/raptor.d.ts +96 -0
- package/dist/routing/result.d.ts +27 -7
- package/dist/routing/route.d.ts +5 -21
- package/dist/routing/router.d.ts +20 -91
- package/dist/routing/state.d.ts +92 -17
- package/dist/timetable/route.d.ts +8 -0
- package/dist/timetable/timetable.d.ts +17 -1
- package/package.json +1 -1
- package/src/__e2e__/benchmark.json +18 -0
- package/src/__e2e__/router.test.ts +461 -127
- package/src/cli/minotor.ts +39 -3
- package/src/cli/perf.ts +324 -60
- package/src/cli/repl.ts +96 -41
- package/src/router.ts +11 -3
- package/src/routing/__tests__/access.test.ts +294 -0
- package/src/routing/__tests__/plainRouter.test.ts +1633 -0
- package/src/routing/__tests__/plotter.test.ts +8 -8
- package/src/routing/__tests__/rangeResult.test.ts +273 -0
- package/src/routing/__tests__/rangeRouter.test.ts +472 -0
- package/src/routing/__tests__/rangeState.test.ts +246 -0
- package/src/routing/__tests__/raptor.test.ts +366 -0
- package/src/routing/__tests__/result.test.ts +27 -27
- package/src/routing/__tests__/route.test.ts +28 -0
- package/src/routing/__tests__/router.test.ts +75 -1587
- package/src/routing/__tests__/state.test.ts +78 -0
- package/src/routing/access.ts +144 -0
- package/src/routing/plainRouter.ts +60 -0
- package/src/routing/plotter.ts +53 -6
- package/src/routing/query.ts +116 -13
- package/src/routing/rangeResult.ts +292 -0
- package/src/routing/rangeRouter.ts +167 -0
- package/src/routing/rangeState.ts +150 -0
- package/src/routing/raptor.ts +416 -0
- package/src/routing/result.ts +68 -26
- package/src/routing/route.ts +15 -53
- package/src/routing/router.ts +40 -480
- package/src/routing/state.ts +191 -32
- package/src/timetable/__tests__/timetable.test.ts +373 -0
- package/src/timetable/route.ts +16 -4
- package/src/timetable/timetable.ts +54 -1
package/src/cli/repl.ts
CHANGED
|
@@ -2,7 +2,7 @@ import repl from 'node:repl';
|
|
|
2
2
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
|
|
5
|
-
import { Query, Router, StopsIndex, Timetable } from '../router.js';
|
|
5
|
+
import { Query, RangeQuery, Router, StopsIndex, Timetable } from '../router.js';
|
|
6
6
|
import type { Stop } from '../stops/stops.js';
|
|
7
7
|
import {
|
|
8
8
|
MUST_COORDINATE_WITH_DRIVER,
|
|
@@ -50,31 +50,52 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
|
|
|
50
50
|
},
|
|
51
51
|
});
|
|
52
52
|
replServer.defineCommand('route', {
|
|
53
|
-
help: 'Find a route using .route from <
|
|
53
|
+
help: 'Find a route using .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
|
|
54
54
|
action(routeQuery: string) {
|
|
55
55
|
this.clearBufferedCommand();
|
|
56
56
|
const parts = routeQuery.split(' ').filter(Boolean);
|
|
57
|
-
|
|
58
|
-
const maxTransfers =
|
|
59
|
-
withTransfersIndex !== -1 && parts[withTransfersIndex + 1] !== undefined
|
|
60
|
-
? parseInt(parts[withTransfersIndex + 1] as string)
|
|
61
|
-
: 4;
|
|
62
|
-
const atTime = parts
|
|
63
|
-
.slice(
|
|
64
|
-
withTransfersIndex === -1
|
|
65
|
-
? parts.indexOf('at') + 1
|
|
66
|
-
: parts.indexOf('at') + 1,
|
|
67
|
-
withTransfersIndex === -1 ? parts.length : withTransfersIndex,
|
|
68
|
-
)
|
|
69
|
-
.join(' ');
|
|
57
|
+
|
|
70
58
|
const fromIndex = parts.indexOf('from');
|
|
71
59
|
const toIndex = parts.indexOf('to');
|
|
60
|
+
const atIndex = parts.indexOf('at');
|
|
61
|
+
const beforeIndex = parts.indexOf('before');
|
|
62
|
+
const withIndex = parts.indexOf('with');
|
|
63
|
+
|
|
64
|
+
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
|
+
);
|
|
68
|
+
this.displayPrompt();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
72
|
const fromId = parts.slice(fromIndex + 1, toIndex).join(' ');
|
|
73
|
-
const toId = parts.slice(toIndex + 1,
|
|
73
|
+
const toId = parts.slice(toIndex + 1, atIndex).join(' ');
|
|
74
|
+
|
|
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;
|
|
82
|
+
const atTime = parts.slice(atIndex + 1, atTimeEnd).join(' ');
|
|
83
|
+
|
|
84
|
+
// beforeTime is only present when the 'before' keyword appears.
|
|
85
|
+
const beforeTimeEnd = withIndex !== -1 ? withIndex : parts.length;
|
|
86
|
+
const beforeTime =
|
|
87
|
+
beforeIndex !== -1
|
|
88
|
+
? parts.slice(beforeIndex + 1, beforeTimeEnd).join(' ')
|
|
89
|
+
: undefined;
|
|
90
|
+
|
|
91
|
+
const maxTransfers =
|
|
92
|
+
withIndex !== -1 && parts[withIndex + 1] !== undefined
|
|
93
|
+
? parseInt(parts[withIndex + 1] as string)
|
|
94
|
+
: 4;
|
|
74
95
|
|
|
75
96
|
if (!fromId || !toId || !atTime) {
|
|
76
97
|
console.log(
|
|
77
|
-
'Usage: .route from <
|
|
98
|
+
'Usage: .route from <stop> to <stop> at <HH:mm> [before <HH:mm>] [with <N> transfers]',
|
|
78
99
|
);
|
|
79
100
|
this.displayPrompt();
|
|
80
101
|
return;
|
|
@@ -105,34 +126,68 @@ export const startRepl = (stopsPath: string, timetablePath: string) => {
|
|
|
105
126
|
return;
|
|
106
127
|
}
|
|
107
128
|
|
|
108
|
-
const departureTime = timeFromString(atTime);
|
|
109
|
-
|
|
110
129
|
try {
|
|
111
|
-
const
|
|
112
|
-
.from(fromStop.id)
|
|
113
|
-
.to(toStop.id)
|
|
114
|
-
.departureTime(departureTime)
|
|
115
|
-
.maxTransfers(maxTransfers)
|
|
116
|
-
.build();
|
|
117
|
-
|
|
130
|
+
const departureTime = timeFromString(atTime);
|
|
118
131
|
const router = new Router(timetable, stopsIndex);
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
if (beforeTime !== undefined) {
|
|
133
|
+
const lastDepartureTime = timeFromString(beforeTime);
|
|
134
|
+
const query = new RangeQuery.Builder()
|
|
135
|
+
.from(fromStop.id)
|
|
136
|
+
.to(toStop.id)
|
|
137
|
+
.departureTime(departureTime)
|
|
138
|
+
.lastDepartureTime(lastDepartureTime)
|
|
139
|
+
.maxTransfers(maxTransfers)
|
|
140
|
+
.build();
|
|
141
|
+
|
|
142
|
+
const result = router.rangeRoute(query);
|
|
143
|
+
|
|
144
|
+
if (result.size === 0) {
|
|
145
|
+
console.log(
|
|
146
|
+
`No journeys found from ${fromStop.name} to ${toStop.name} ` +
|
|
147
|
+
`between ${atTime} and ${beforeTime}.`,
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
console.log(
|
|
151
|
+
`Found ${result.size} Pareto-optimal journey${result.size === 1 ? '' : 's'} ` +
|
|
152
|
+
`from ${fromStop.name} to ${toStop.name} ` +
|
|
153
|
+
`(window ${atTime}–${beforeTime}):`,
|
|
154
|
+
);
|
|
155
|
+
const routes = result.getRoutes();
|
|
156
|
+
routes.forEach((route, index) => {
|
|
157
|
+
const journeyNumber = index + 1;
|
|
158
|
+
console.log(`\nJourney ${journeyNumber}:`);
|
|
159
|
+
console.log(route.toString());
|
|
160
|
+
});
|
|
161
|
+
}
|
|
124
162
|
} else {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
const query = new Query.Builder()
|
|
164
|
+
.from(fromStop.id)
|
|
165
|
+
.to(toStop.id)
|
|
166
|
+
.departureTime(departureTime)
|
|
167
|
+
.maxTransfers(maxTransfers)
|
|
168
|
+
.build();
|
|
169
|
+
|
|
170
|
+
const result = router.route(query);
|
|
171
|
+
const arrivalTime = result.arrivalAt(toStop.id);
|
|
172
|
+
|
|
173
|
+
if (arrivalTime === undefined) {
|
|
174
|
+
console.log(`Destination not reachable`);
|
|
175
|
+
} else {
|
|
176
|
+
const transfers = Math.max(0, arrivalTime.legNumber - 1);
|
|
177
|
+
console.log(
|
|
178
|
+
`Arriving to ${toStop.name} at ${timeToString(arrivalTime.arrival)} ` +
|
|
179
|
+
`with ${transfers} transfer${transfers === 1 ? '' : 's'} ` +
|
|
180
|
+
`from ${fromStop.name}.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
130
183
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
184
|
+
const bestRoute = result.bestRoute(toStop.id);
|
|
185
|
+
if (bestRoute) {
|
|
186
|
+
console.log(`Found route from ${fromStop.name} to ${toStop.name}:`);
|
|
187
|
+
console.log(bestRoute.toString());
|
|
188
|
+
} else {
|
|
189
|
+
console.log('No route found');
|
|
190
|
+
}
|
|
136
191
|
}
|
|
137
192
|
} catch (error) {
|
|
138
193
|
console.log('Error querying route:', error);
|
package/src/router.ts
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Plotter } from './routing/plotter.js';
|
|
2
|
-
import { Query } from './routing/query.js';
|
|
2
|
+
import { Query, RangeQuery } 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 {
|
|
7
|
-
|
|
6
|
+
import type {
|
|
7
|
+
Arrival,
|
|
8
|
+
ArrivalWithDuration,
|
|
9
|
+
ParetoRun,
|
|
10
|
+
} from './routing/router.js';
|
|
11
|
+
import { RangeResult, Router } from './routing/router.js';
|
|
8
12
|
import type { LocationType, SourceStopId, StopId } from './stops/stops.js';
|
|
9
13
|
import type { Stop } from './stops/stops.js';
|
|
10
14
|
import { StopsIndex } from './stops/stopsIndex.js';
|
|
@@ -20,6 +24,8 @@ export {
|
|
|
20
24
|
Duration,
|
|
21
25
|
Plotter,
|
|
22
26
|
Query,
|
|
27
|
+
RangeQuery,
|
|
28
|
+
RangeResult,
|
|
23
29
|
Result,
|
|
24
30
|
Route,
|
|
25
31
|
Router,
|
|
@@ -30,8 +36,10 @@ export {
|
|
|
30
36
|
|
|
31
37
|
export type {
|
|
32
38
|
Arrival,
|
|
39
|
+
ArrivalWithDuration,
|
|
33
40
|
Leg,
|
|
34
41
|
LocationType,
|
|
42
|
+
ParetoRun,
|
|
35
43
|
RouteType,
|
|
36
44
|
ServiceRouteInfo,
|
|
37
45
|
SourceStopId,
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
|
|
5
|
+
import { Stop } from '../../stops/stops.js';
|
|
6
|
+
import { StopsIndex } from '../../stops/stopsIndex.js';
|
|
7
|
+
import { Route } from '../../timetable/route.js';
|
|
8
|
+
import { timeFromHM } from '../../timetable/time.js';
|
|
9
|
+
import {
|
|
10
|
+
ServiceRoute,
|
|
11
|
+
StopAdjacency,
|
|
12
|
+
Timetable,
|
|
13
|
+
} from '../../timetable/timetable.js';
|
|
14
|
+
import { AccessFinder, AccessPoint } from '../access.js';
|
|
15
|
+
|
|
16
|
+
// Timetable: stop 0 is an isolated origin; stop 1 is a boarding stop on route 0;
|
|
17
|
+
// stop 2 is a second stop served by route 0.
|
|
18
|
+
// Stop 0 has a REQUIRES_MINIMAL_TIME transfer to stop 1 (5 min)
|
|
19
|
+
// and a GUARANTEED transfer to stop 2 (must be ignored).
|
|
20
|
+
const stopsAdjacency: StopAdjacency[] = [
|
|
21
|
+
{
|
|
22
|
+
transfers: [
|
|
23
|
+
{ destination: 1, type: 'REQUIRES_MINIMAL_TIME', minTransferTime: 5 },
|
|
24
|
+
{ destination: 2, type: 'GUARANTEED' },
|
|
25
|
+
],
|
|
26
|
+
routes: [],
|
|
27
|
+
},
|
|
28
|
+
{ routes: [0] },
|
|
29
|
+
{ routes: [0] },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const routesAdjacency = [
|
|
33
|
+
Route.of({
|
|
34
|
+
id: 0,
|
|
35
|
+
serviceRouteId: 0,
|
|
36
|
+
trips: [
|
|
37
|
+
{
|
|
38
|
+
stops: [
|
|
39
|
+
{
|
|
40
|
+
id: 1,
|
|
41
|
+
arrivalTime: timeFromHM(8, 10),
|
|
42
|
+
departureTime: timeFromHM(8, 10),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 2,
|
|
46
|
+
arrivalTime: timeFromHM(8, 20),
|
|
47
|
+
departureTime: timeFromHM(8, 20),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
stops: [
|
|
53
|
+
{
|
|
54
|
+
id: 1,
|
|
55
|
+
arrivalTime: timeFromHM(8, 40),
|
|
56
|
+
departureTime: timeFromHM(8, 40),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 2,
|
|
60
|
+
arrivalTime: timeFromHM(8, 50),
|
|
61
|
+
departureTime: timeFromHM(8, 50),
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
}),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const serviceRoutes: ServiceRoute[] = [
|
|
70
|
+
{ type: 'BUS', name: 'Line 1', routes: [0] },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const stops: Stop[] = [
|
|
74
|
+
{
|
|
75
|
+
id: 0,
|
|
76
|
+
sourceStopId: 'A',
|
|
77
|
+
name: 'Stop A',
|
|
78
|
+
lat: 0,
|
|
79
|
+
lon: 0,
|
|
80
|
+
children: [],
|
|
81
|
+
locationType: 'SIMPLE_STOP_OR_PLATFORM',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 1,
|
|
85
|
+
sourceStopId: 'B',
|
|
86
|
+
name: 'Stop B',
|
|
87
|
+
lat: 0,
|
|
88
|
+
lon: 0,
|
|
89
|
+
children: [],
|
|
90
|
+
locationType: 'SIMPLE_STOP_OR_PLATFORM',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 2,
|
|
94
|
+
sourceStopId: 'C',
|
|
95
|
+
name: 'Stop C',
|
|
96
|
+
lat: 0,
|
|
97
|
+
lon: 0,
|
|
98
|
+
children: [],
|
|
99
|
+
locationType: 'SIMPLE_STOP_OR_PLATFORM',
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const timetable = new Timetable(stopsAdjacency, routesAdjacency, serviceRoutes);
|
|
104
|
+
const stopsIndex = new StopsIndex(stops);
|
|
105
|
+
const finder = new AccessFinder(timetable, stopsIndex);
|
|
106
|
+
|
|
107
|
+
describe('AccessFinder', () => {
|
|
108
|
+
describe('collectAccessPaths', () => {
|
|
109
|
+
it('returns the origin itself as a zero-cost access path', () => {
|
|
110
|
+
const paths = finder.collectAccessPaths(0, 2);
|
|
111
|
+
const selfPath = paths.find((p) => p.toStopId === 0);
|
|
112
|
+
assert(selfPath);
|
|
113
|
+
assert.strictEqual(selfPath.duration, 0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('includes REQUIRES_MINIMAL_TIME transfers with the specified duration', () => {
|
|
117
|
+
const paths = finder.collectAccessPaths(0, 2);
|
|
118
|
+
const walkPath = paths.find((p) => p.toStopId === 1);
|
|
119
|
+
assert(walkPath);
|
|
120
|
+
assert.strictEqual(walkPath.duration, 5);
|
|
121
|
+
assert.strictEqual(walkPath.fromStopId, 0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('uses the fallback transfer time when the timetable specifies none', () => {
|
|
125
|
+
// Temporarily use a timetable where the transfer has no minTransferTime.
|
|
126
|
+
const adj: StopAdjacency[] = [
|
|
127
|
+
{
|
|
128
|
+
transfers: [{ destination: 1, type: 'REQUIRES_MINIMAL_TIME' }],
|
|
129
|
+
routes: [],
|
|
130
|
+
},
|
|
131
|
+
{ routes: [0] },
|
|
132
|
+
{ routes: [0] },
|
|
133
|
+
];
|
|
134
|
+
const localTimetable = new Timetable(adj, routesAdjacency, serviceRoutes);
|
|
135
|
+
const localFinder = new AccessFinder(localTimetable, stopsIndex);
|
|
136
|
+
const paths = localFinder.collectAccessPaths(0, 3); // fallback = 3 min
|
|
137
|
+
const walkPath = paths.find((p) => p.toStopId === 1);
|
|
138
|
+
assert(walkPath);
|
|
139
|
+
assert.strictEqual(walkPath.duration, 3);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('does not include GUARANTEED transfers', () => {
|
|
143
|
+
const paths = finder.collectAccessPaths(0, 2);
|
|
144
|
+
const guaranteedPath = paths.find((p) => p.toStopId === 2);
|
|
145
|
+
assert.strictEqual(guaranteedPath, undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps the shortest walk when multiple equivalent origins can reach the same stop', () => {
|
|
149
|
+
// Parent stop 3 with two children: stop 4 (8-min walk to stop 1)
|
|
150
|
+
// and stop 5 (3-min walk to stop 1).
|
|
151
|
+
const adj: StopAdjacency[] = [
|
|
152
|
+
{ routes: [] },
|
|
153
|
+
{ routes: [0] },
|
|
154
|
+
{ routes: [0] },
|
|
155
|
+
{ routes: [] },
|
|
156
|
+
{
|
|
157
|
+
transfers: [
|
|
158
|
+
{
|
|
159
|
+
destination: 1,
|
|
160
|
+
type: 'REQUIRES_MINIMAL_TIME',
|
|
161
|
+
minTransferTime: 8,
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
routes: [],
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
transfers: [
|
|
168
|
+
{
|
|
169
|
+
destination: 1,
|
|
170
|
+
type: 'REQUIRES_MINIMAL_TIME',
|
|
171
|
+
minTransferTime: 3,
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
routes: [],
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
const extraStops: Stop[] = [
|
|
178
|
+
...stops,
|
|
179
|
+
{
|
|
180
|
+
id: 3,
|
|
181
|
+
sourceStopId: 'parent',
|
|
182
|
+
name: 'Parent',
|
|
183
|
+
lat: 0,
|
|
184
|
+
lon: 0,
|
|
185
|
+
children: [4, 5],
|
|
186
|
+
locationType: 'STATION',
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
id: 4,
|
|
190
|
+
sourceStopId: 'child1',
|
|
191
|
+
name: 'Child 1',
|
|
192
|
+
lat: 0,
|
|
193
|
+
lon: 0,
|
|
194
|
+
children: [],
|
|
195
|
+
parent: 3,
|
|
196
|
+
locationType: 'SIMPLE_STOP_OR_PLATFORM',
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
id: 5,
|
|
200
|
+
sourceStopId: 'child2',
|
|
201
|
+
name: 'Child 2',
|
|
202
|
+
lat: 0,
|
|
203
|
+
lon: 0,
|
|
204
|
+
children: [],
|
|
205
|
+
parent: 3,
|
|
206
|
+
locationType: 'SIMPLE_STOP_OR_PLATFORM',
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
const localTimetable = new Timetable(adj, routesAdjacency, serviceRoutes);
|
|
210
|
+
const localStopsIndex = new StopsIndex(extraStops);
|
|
211
|
+
const localFinder = new AccessFinder(localTimetable, localStopsIndex);
|
|
212
|
+
|
|
213
|
+
// Origin is child stop 4; equivalentStops(4) = [4, 5] (siblings).
|
|
214
|
+
const paths = localFinder.collectAccessPaths(4, 2);
|
|
215
|
+
const walkToStop1 = paths.find((p) => p.toStopId === 1);
|
|
216
|
+
assert(walkToStop1);
|
|
217
|
+
assert.strictEqual(walkToStop1.duration, 3); // shorter walk wins
|
|
218
|
+
assert.strictEqual(walkToStop1.fromStopId, 5);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('collectDepartureTimes', () => {
|
|
223
|
+
it('returns slots sorted latest-departure-first', () => {
|
|
224
|
+
const paths: AccessPoint[] = [
|
|
225
|
+
{ fromStopId: 0, toStopId: 1, duration: 0 },
|
|
226
|
+
];
|
|
227
|
+
const slots = finder.collectDepartureTimes(
|
|
228
|
+
paths,
|
|
229
|
+
timeFromHM(8, 0),
|
|
230
|
+
timeFromHM(8, 45),
|
|
231
|
+
);
|
|
232
|
+
assert.strictEqual(slots.length, 2);
|
|
233
|
+
assert.strictEqual(slots[0]?.depTime, timeFromHM(8, 40));
|
|
234
|
+
assert.strictEqual(slots[1]?.depTime, timeFromHM(8, 10));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('excludes trips departing outside the query window', () => {
|
|
238
|
+
const paths: AccessPoint[] = [
|
|
239
|
+
{ fromStopId: 0, toStopId: 1, duration: 0 },
|
|
240
|
+
];
|
|
241
|
+
const slots = finder.collectDepartureTimes(
|
|
242
|
+
paths,
|
|
243
|
+
timeFromHM(8, 0),
|
|
244
|
+
timeFromHM(8, 30),
|
|
245
|
+
);
|
|
246
|
+
assert.strictEqual(slots.length, 1);
|
|
247
|
+
assert.strictEqual(slots[0]?.depTime, timeFromHM(8, 10));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('returns empty when no trip falls within the window', () => {
|
|
251
|
+
const paths: AccessPoint[] = [
|
|
252
|
+
{ fromStopId: 0, toStopId: 1, duration: 0 },
|
|
253
|
+
];
|
|
254
|
+
const slots = finder.collectDepartureTimes(
|
|
255
|
+
paths,
|
|
256
|
+
timeFromHM(9, 0),
|
|
257
|
+
timeFromHM(10, 0),
|
|
258
|
+
);
|
|
259
|
+
assert.strictEqual(slots.length, 0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('zero-duration paths to stops with routes generate slots at exact trip departure times', () => {
|
|
263
|
+
// The self-path (stop 0, duration 0) has no routes in this timetable,
|
|
264
|
+
// so it contributes no slots. The walk path (stop 1, duration 5) has
|
|
265
|
+
// trips at 08:10 and 08:40; with the window ending at 08:05 only the
|
|
266
|
+
// 08:10 trip is reachable, producing a single slot at 08:05 (= 08:10 - 5).
|
|
267
|
+
const equivalentPath: AccessPoint = {
|
|
268
|
+
fromStopId: 0,
|
|
269
|
+
toStopId: 0,
|
|
270
|
+
duration: 0,
|
|
271
|
+
};
|
|
272
|
+
const walkPath: AccessPoint = {
|
|
273
|
+
fromStopId: 0,
|
|
274
|
+
toStopId: 1,
|
|
275
|
+
duration: 5,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const slots = finder.collectDepartureTimes(
|
|
279
|
+
[equivalentPath, walkPath],
|
|
280
|
+
timeFromHM(8, 0),
|
|
281
|
+
timeFromHM(8, 5),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
assert.strictEqual(slots.length, 1);
|
|
285
|
+
assert.strictEqual(slots[0]!.depTime, timeFromHM(8, 5));
|
|
286
|
+
const legIds = slots[0]!.legs.map((l) => l.toStopId);
|
|
287
|
+
assert(legIds.includes(1), 'walk path must appear in its own slot');
|
|
288
|
+
assert(
|
|
289
|
+
!legIds.includes(0),
|
|
290
|
+
'equivalent path without routes must not appear in a slot it did not generate',
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
});
|