terra-route 0.0.12 → 0.0.13
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/README.md +5 -5
- package/instructions.md +13 -0
- package/package.json +2 -1
- package/src/terra-route.compare.spec.ts +81 -0
- package/src/terra-route.spec.ts +576 -0
- package/src/terra-route.ts +369 -152
package/README.md
CHANGED
|
@@ -60,16 +60,16 @@ npm run benchmark
|
|
|
60
60
|
Here is an example output of a benchmark run for routing:
|
|
61
61
|
|
|
62
62
|
<pre>
|
|
63
|
-
Terra Route
|
|
64
|
-
GeoJSON Path Finder
|
|
65
|
-
ngraph.graph
|
|
63
|
+
Terra Route | █████ 151ms
|
|
64
|
+
GeoJSON Path Finder | ██████████████████ 571ms
|
|
65
|
+
ngraph.graph | ██████████████████████████████████████████████████ 1564ms
|
|
66
66
|
</pre>
|
|
67
67
|
|
|
68
|
-
Using default Haversine distance, Terra Route is approximately
|
|
68
|
+
Using default Haversine distance, Terra Route is approximately 3.75x faster than GeoJSON Path Finder with Haversine distance for A -> B path finding. If you pass in the CheapRuler distance metric (you can use the exposed `createCheapRuler` function), it is approximately x8 faster than GeoJSON Path Finder with Haversine distance.
|
|
69
69
|
|
|
70
70
|
For initialisation of the network, Terra Route is approximately 10x faster with Haversine than GeoJSON Path Finder. Terra Draw splits out instantiating the Class of the library from the actual graph building, which is done via `buildRouteGraph`. This allows you to defer graph creation to an appropriate time.
|
|
71
71
|
|
|
72
|
-
Terra Route uses an [A*
|
|
72
|
+
Terra Route uses an [bi-directional A* algorithm for pathfinding](https://en.wikipedia.org/wiki/A*_search_algorithm) and by default uses a [four-ary heap](https://en.wikipedia.org/wiki/D-ary_heap) for the underlying priority queue, although this is configurable.
|
|
73
73
|
|
|
74
74
|
## Limitations
|
|
75
75
|
|
package/instructions.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Here we want to improve the performance by a consistently significant amount for the getRoute method TerraRoute class.
|
|
2
|
+
|
|
3
|
+
We could achieve this via data structures or algorithmic changes to the class. Look into the academic literature of Shortest Path Algorithms to get good suggestions for what would yield speedups. Also think about how this would work most effectively in the JavaScript language.
|
|
4
|
+
|
|
5
|
+
You can run tests with:
|
|
6
|
+
|
|
7
|
+
npm run test
|
|
8
|
+
|
|
9
|
+
You can assume unit tests are already passing, and do not need to run them until changes are made. You should not need to make any changes to the unit test files (.spec.ts files). In addition to unit tests, you can run a benchmark with the following command:
|
|
10
|
+
|
|
11
|
+
npm run benchmark:loop
|
|
12
|
+
|
|
13
|
+
This will run the benchmark tool 8 times. Run this first to get a comparison for future changes. You can assume the unit test
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terra-route",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"description": "A library for routing along GeoJSON LineString networks",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"docs": "typedoc",
|
|
7
7
|
"docs:serve": "serve ./docs",
|
|
8
8
|
"test": "jest --silent=false",
|
|
9
9
|
"benchmark": "tsx benchmark/benchmark.ts",
|
|
10
|
+
"benchmark:loop": "for i in {1..8}; do npm run -s benchmark | awk '/GRAPH ROUTING PERFORMANCE/{p=1} p && /Terra Route took/{print $0; p=0}'; done",
|
|
10
11
|
"build": "microbundle",
|
|
11
12
|
"watch": "microbundle --watch --format modern",
|
|
12
13
|
"unused": "knip",
|
|
@@ -87,5 +87,86 @@ describe("TerraRoute", () => {
|
|
|
87
87
|
}
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
it('matches route created from GeoJSON Path Finder when network is expanded in two halves', () => {
|
|
91
|
+
const network = JSON.parse(readFileSync('src/data/network.geojson', 'utf-8')) as FeatureCollection<LineString>;
|
|
92
|
+
|
|
93
|
+
const mid = Math.floor(network.features.length / 2);
|
|
94
|
+
const firstHalf: FeatureCollection<LineString> = {
|
|
95
|
+
type: 'FeatureCollection',
|
|
96
|
+
features: network.features.slice(0, mid)
|
|
97
|
+
};
|
|
98
|
+
const secondHalf: FeatureCollection<LineString> = {
|
|
99
|
+
type: 'FeatureCollection',
|
|
100
|
+
features: network.features.slice(mid)
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const pairs: [Position, Position][] = [];
|
|
104
|
+
|
|
105
|
+
// Use the full network to create representative pairs across the whole graph
|
|
106
|
+
for (let i = 0, j = network.features.length - 1; i < j; i++, j--) {
|
|
107
|
+
const coordsA = network.features[i].geometry.coordinates;
|
|
108
|
+
const coordsB = network.features[j].geometry.coordinates;
|
|
109
|
+
|
|
110
|
+
const midA = Math.floor(coordsA.length / 2);
|
|
111
|
+
const midB = Math.floor(coordsB.length / 2);
|
|
112
|
+
|
|
113
|
+
pairs.push([
|
|
114
|
+
coordsA[midA] as Position,
|
|
115
|
+
coordsB[midB] as Position
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 0, j = network.features.length - 1; i < j; i++, j--) {
|
|
120
|
+
const coordsA = network.features[i].geometry.coordinates;
|
|
121
|
+
const coordsB = network.features[j].geometry.coordinates;
|
|
122
|
+
|
|
123
|
+
pairs.push([
|
|
124
|
+
coordsA[0] as Position,
|
|
125
|
+
coordsB[coordsB.length - 1] as Position
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const terraRoute = new TerraRoute();
|
|
130
|
+
terraRoute.buildRouteGraph(firstHalf);
|
|
131
|
+
terraRoute.expandRouteGraph(secondHalf);
|
|
132
|
+
|
|
133
|
+
const pathFinder = new PathFinder(network as FeatureCollection<LineString>, {
|
|
134
|
+
// Mimic points having to be identical
|
|
135
|
+
tolerance: 0.000000000000000000001,
|
|
136
|
+
weight: (a, b) => haversineDistance(a, b)
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
140
|
+
const startIsEnd = pairs[i][0][0] === pairs[i][1][0] && pairs[i][0][1] === pairs[i][1][1]
|
|
141
|
+
if (startIsEnd) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const start = createPointFeature(pairs[i][0]);
|
|
146
|
+
const end = createPointFeature(pairs[i][1]);
|
|
147
|
+
|
|
148
|
+
const route = pathFinder.findPath(start, end);
|
|
149
|
+
expect(route).not.toBeNull();
|
|
150
|
+
|
|
151
|
+
// Route not found
|
|
152
|
+
if (!route || route.path.length <= 1) {
|
|
153
|
+
const routeFromTerraRoute = terraRoute.getRoute(start, end);
|
|
154
|
+
expect(routeFromTerraRoute).toBeNull();
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const routeFromPathFinder = pathToGeoJSON(route);
|
|
159
|
+
expect(routeFromPathFinder).toBeDefined();
|
|
160
|
+
|
|
161
|
+
const routeFromTerraRoute = terraRoute.getRoute(start, end);
|
|
162
|
+
expect(routeFromTerraRoute).not.toBeNull();
|
|
163
|
+
|
|
164
|
+
const pathFinderLength = routeLength(routeFromPathFinder!);
|
|
165
|
+
const terraDrawLength = routeLength(routeFromTerraRoute!);
|
|
166
|
+
|
|
167
|
+
expect(pathFinderLength).toBeGreaterThanOrEqual(terraDrawLength);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
90
171
|
})
|
|
91
172
|
});
|
package/src/terra-route.spec.ts
CHANGED
|
@@ -52,6 +52,276 @@ describe("TerraRoute", () => {
|
|
|
52
52
|
});
|
|
53
53
|
})
|
|
54
54
|
|
|
55
|
+
describe('expandRouteGraph', () => {
|
|
56
|
+
it('throws an error if the network is not built', () => {
|
|
57
|
+
const network = createFeatureCollection([
|
|
58
|
+
createLineStringFeature([
|
|
59
|
+
[0, 0],
|
|
60
|
+
[1, 0],
|
|
61
|
+
]),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
expect(() => routeFinder.expandRouteGraph(network)).toThrow("Network not built. Please call buildRouteGraph(network) first.");
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('merges a new network so routing works across both', () => {
|
|
68
|
+
// First network: 0,0 -> 1,0
|
|
69
|
+
const networkA = createFeatureCollection([
|
|
70
|
+
createLineStringFeature([
|
|
71
|
+
[0, 0],
|
|
72
|
+
[1, 0],
|
|
73
|
+
]),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
// Second network connects to it and extends: 1,0 -> 2,0
|
|
77
|
+
const networkB = createFeatureCollection([
|
|
78
|
+
createLineStringFeature([
|
|
79
|
+
[1, 0],
|
|
80
|
+
[2, 0],
|
|
81
|
+
]),
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
routeFinder.buildRouteGraph(networkA);
|
|
85
|
+
|
|
86
|
+
const start = createPointFeature([0, 0]);
|
|
87
|
+
const end = createPointFeature([2, 0]);
|
|
88
|
+
|
|
89
|
+
// Not routable yet
|
|
90
|
+
expect(routeFinder.getRoute(start, end)).toBeNull();
|
|
91
|
+
|
|
92
|
+
routeFinder.expandRouteGraph(networkB);
|
|
93
|
+
const result = routeFinder.getRoute(start, end);
|
|
94
|
+
|
|
95
|
+
expect(result).not.toBeNull();
|
|
96
|
+
expect(result!.geometry.coordinates).toEqual([
|
|
97
|
+
[0, 0],
|
|
98
|
+
[1, 0],
|
|
99
|
+
[2, 0],
|
|
100
|
+
]);
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('preserves existing routes after expansion', () => {
|
|
104
|
+
const networkA = createFeatureCollection([
|
|
105
|
+
createLineStringFeature([
|
|
106
|
+
[0, 0],
|
|
107
|
+
[1, 0],
|
|
108
|
+
[2, 0],
|
|
109
|
+
]),
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const networkB = createFeatureCollection([
|
|
113
|
+
createLineStringFeature([
|
|
114
|
+
[2, 0],
|
|
115
|
+
[2, 1],
|
|
116
|
+
]),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
routeFinder.buildRouteGraph(networkA);
|
|
120
|
+
|
|
121
|
+
const start = createPointFeature([0, 0]);
|
|
122
|
+
const mid = createPointFeature([2, 0]);
|
|
123
|
+
|
|
124
|
+
const before = routeFinder.getRoute(start, mid);
|
|
125
|
+
expect(before).not.toBeNull();
|
|
126
|
+
expect(before!.geometry.coordinates).toEqual([
|
|
127
|
+
[0, 0],
|
|
128
|
+
[1, 0],
|
|
129
|
+
[2, 0],
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
routeFinder.expandRouteGraph(networkB);
|
|
133
|
+
|
|
134
|
+
const after = routeFinder.getRoute(start, mid);
|
|
135
|
+
expect(after).not.toBeNull();
|
|
136
|
+
expect(after!.geometry.coordinates).toEqual([
|
|
137
|
+
[0, 0],
|
|
138
|
+
[1, 0],
|
|
139
|
+
[2, 0],
|
|
140
|
+
]);
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('is a no-op when expanding with an empty network', () => {
|
|
144
|
+
const networkA = createFeatureCollection([
|
|
145
|
+
createLineStringFeature([
|
|
146
|
+
[0, 0],
|
|
147
|
+
[1, 0],
|
|
148
|
+
[2, 0],
|
|
149
|
+
]),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const emptyNetwork = createFeatureCollection([]);
|
|
153
|
+
|
|
154
|
+
routeFinder.buildRouteGraph(networkA);
|
|
155
|
+
|
|
156
|
+
const start = createPointFeature([0, 0]);
|
|
157
|
+
const end = createPointFeature([2, 0]);
|
|
158
|
+
|
|
159
|
+
const before = routeFinder.getRoute(start, end);
|
|
160
|
+
expect(before).not.toBeNull();
|
|
161
|
+
expect(before!.geometry.coordinates).toEqual([
|
|
162
|
+
[0, 0],
|
|
163
|
+
[1, 0],
|
|
164
|
+
[2, 0],
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
routeFinder.expandRouteGraph(emptyNetwork);
|
|
168
|
+
|
|
169
|
+
const after = routeFinder.getRoute(start, end);
|
|
170
|
+
expect(after).not.toBeNull();
|
|
171
|
+
expect(after!.geometry.coordinates).toEqual([
|
|
172
|
+
[0, 0],
|
|
173
|
+
[1, 0],
|
|
174
|
+
[2, 0],
|
|
175
|
+
]);
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('does not create a route if the expanded network is still disconnected', () => {
|
|
179
|
+
const networkA = createFeatureCollection([
|
|
180
|
+
createLineStringFeature([
|
|
181
|
+
[0, 0],
|
|
182
|
+
[1, 0],
|
|
183
|
+
]),
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
// Disconnected component
|
|
187
|
+
const networkB = createFeatureCollection([
|
|
188
|
+
createLineStringFeature([
|
|
189
|
+
[10, 0],
|
|
190
|
+
[11, 0],
|
|
191
|
+
]),
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
routeFinder.buildRouteGraph(networkA);
|
|
195
|
+
|
|
196
|
+
const start = createPointFeature([0, 0]);
|
|
197
|
+
const end = createPointFeature([11, 0]);
|
|
198
|
+
|
|
199
|
+
// Still not routable after expansion (no connecting edge)
|
|
200
|
+
routeFinder.expandRouteGraph(networkB);
|
|
201
|
+
expect(routeFinder.getRoute(start, end)).toBeNull();
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('allows routing that traverses only newly-added nodes after expansion', () => {
|
|
205
|
+
const networkA = createFeatureCollection([
|
|
206
|
+
createLineStringFeature([
|
|
207
|
+
[0, 0],
|
|
208
|
+
[1, 0],
|
|
209
|
+
]),
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
// Adds a detour: 1,0 -> 1,1 -> 2,1 -> 2,0
|
|
213
|
+
const networkB = createFeatureCollection([
|
|
214
|
+
createLineStringFeature([
|
|
215
|
+
[1, 0],
|
|
216
|
+
[1, 1],
|
|
217
|
+
]),
|
|
218
|
+
createLineStringFeature([
|
|
219
|
+
[1, 1],
|
|
220
|
+
[2, 1],
|
|
221
|
+
]),
|
|
222
|
+
createLineStringFeature([
|
|
223
|
+
[2, 1],
|
|
224
|
+
[2, 0],
|
|
225
|
+
]),
|
|
226
|
+
]);
|
|
227
|
+
|
|
228
|
+
routeFinder.buildRouteGraph(networkA);
|
|
229
|
+
routeFinder.expandRouteGraph(networkB);
|
|
230
|
+
|
|
231
|
+
const start = createPointFeature([0, 0]);
|
|
232
|
+
const end = createPointFeature([2, 0]);
|
|
233
|
+
|
|
234
|
+
const result = routeFinder.getRoute(start, end);
|
|
235
|
+
expect(result).not.toBeNull();
|
|
236
|
+
expect(result!.geometry.coordinates).toEqual([
|
|
237
|
+
[0, 0],
|
|
238
|
+
[1, 0],
|
|
239
|
+
[1, 1],
|
|
240
|
+
[2, 1],
|
|
241
|
+
[2, 0],
|
|
242
|
+
]);
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('supports multiple sequential expansions', () => {
|
|
246
|
+
const networkA = createFeatureCollection([
|
|
247
|
+
createLineStringFeature([
|
|
248
|
+
[0, 0],
|
|
249
|
+
[1, 0],
|
|
250
|
+
]),
|
|
251
|
+
]);
|
|
252
|
+
const networkB = createFeatureCollection([
|
|
253
|
+
createLineStringFeature([
|
|
254
|
+
[1, 0],
|
|
255
|
+
[2, 0],
|
|
256
|
+
]),
|
|
257
|
+
]);
|
|
258
|
+
const networkC = createFeatureCollection([
|
|
259
|
+
createLineStringFeature([
|
|
260
|
+
[2, 0],
|
|
261
|
+
[3, 0],
|
|
262
|
+
]),
|
|
263
|
+
]);
|
|
264
|
+
|
|
265
|
+
routeFinder.buildRouteGraph(networkA);
|
|
266
|
+
routeFinder.expandRouteGraph(networkB);
|
|
267
|
+
routeFinder.expandRouteGraph(networkC);
|
|
268
|
+
|
|
269
|
+
const start = createPointFeature([0, 0]);
|
|
270
|
+
const end = createPointFeature([3, 0]);
|
|
271
|
+
|
|
272
|
+
const result = routeFinder.getRoute(start, end);
|
|
273
|
+
expect(result).not.toBeNull();
|
|
274
|
+
expect(result!.geometry.coordinates).toEqual([
|
|
275
|
+
[0, 0],
|
|
276
|
+
[1, 0],
|
|
277
|
+
[2, 0],
|
|
278
|
+
[3, 0],
|
|
279
|
+
]);
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('continues to behave correctly if the same network is expanded in again', () => {
|
|
283
|
+
const networkA = createFeatureCollection([
|
|
284
|
+
createLineStringFeature([
|
|
285
|
+
[0, 0],
|
|
286
|
+
[1, 0],
|
|
287
|
+
]),
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
// networkB connects and extends
|
|
291
|
+
const networkB = createFeatureCollection([
|
|
292
|
+
createLineStringFeature([
|
|
293
|
+
[1, 0],
|
|
294
|
+
[2, 0],
|
|
295
|
+
]),
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
routeFinder.buildRouteGraph(networkA);
|
|
299
|
+
routeFinder.expandRouteGraph(networkB);
|
|
300
|
+
|
|
301
|
+
const start = createPointFeature([0, 0]);
|
|
302
|
+
const end = createPointFeature([2, 0]);
|
|
303
|
+
|
|
304
|
+
const first = routeFinder.getRoute(start, end);
|
|
305
|
+
expect(first).not.toBeNull();
|
|
306
|
+
expect(first!.geometry.coordinates).toEqual([
|
|
307
|
+
[0, 0],
|
|
308
|
+
[1, 0],
|
|
309
|
+
[2, 0],
|
|
310
|
+
]);
|
|
311
|
+
|
|
312
|
+
// Expand with the exact same network again.
|
|
313
|
+
routeFinder.expandRouteGraph(networkB);
|
|
314
|
+
|
|
315
|
+
const second = routeFinder.getRoute(start, end);
|
|
316
|
+
expect(second).not.toBeNull();
|
|
317
|
+
expect(second!.geometry.coordinates).toEqual([
|
|
318
|
+
[0, 0],
|
|
319
|
+
[1, 0],
|
|
320
|
+
[2, 0],
|
|
321
|
+
]);
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
55
325
|
describe('getRoute', () => {
|
|
56
326
|
it('throws an error if the network is not built', () => {
|
|
57
327
|
const start = createPointFeature([0, 0]);
|
|
@@ -60,6 +330,110 @@ describe("TerraRoute", () => {
|
|
|
60
330
|
expect(() => routeFinder.getRoute(start, end)).toThrow("Network not built. Please call buildRouteGraph(network) first.");
|
|
61
331
|
})
|
|
62
332
|
|
|
333
|
+
it("returns null when the start equals the end", () => {
|
|
334
|
+
const network = createFeatureCollection([
|
|
335
|
+
createLineStringFeature([
|
|
336
|
+
[0, 0],
|
|
337
|
+
[1, 0],
|
|
338
|
+
]),
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
routeFinder.buildRouteGraph(network);
|
|
342
|
+
|
|
343
|
+
const point = createPointFeature([0, 0]);
|
|
344
|
+
expect(routeFinder.getRoute(point, point)).toBeNull();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("returns null when the start and end are not connected (even if both points are new)", () => {
|
|
348
|
+
const network = createFeatureCollection([
|
|
349
|
+
// Component A
|
|
350
|
+
createLineStringFeature([
|
|
351
|
+
[0, 0],
|
|
352
|
+
[1, 0],
|
|
353
|
+
]),
|
|
354
|
+
// Component B
|
|
355
|
+
createLineStringFeature([
|
|
356
|
+
[10, 0],
|
|
357
|
+
[11, 0],
|
|
358
|
+
]),
|
|
359
|
+
]);
|
|
360
|
+
|
|
361
|
+
routeFinder.buildRouteGraph(network);
|
|
362
|
+
|
|
363
|
+
// Use points not present in the network.
|
|
364
|
+
const start = createPointFeature([100, 100]);
|
|
365
|
+
const end = createPointFeature([101, 101]);
|
|
366
|
+
|
|
367
|
+
expect(routeFinder.getRoute(start, end)).toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("returns null when both points are outside the network", () => {
|
|
371
|
+
const network = createFeatureCollection([
|
|
372
|
+
createLineStringFeature([
|
|
373
|
+
[0, 0],
|
|
374
|
+
[1, 0],
|
|
375
|
+
]),
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
routeFinder.buildRouteGraph(network);
|
|
379
|
+
|
|
380
|
+
// Both points are outside the network; the implementation should handle this gracefully.
|
|
381
|
+
const start = createPointFeature([2, 0]);
|
|
382
|
+
const end = createPointFeature([3, 0]);
|
|
383
|
+
|
|
384
|
+
// Still disconnected, so route is null.
|
|
385
|
+
expect(routeFinder.getRoute(start, end)).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("can route between two points added at runtime when the link is provided", () => {
|
|
389
|
+
const network = createFeatureCollection([
|
|
390
|
+
// Base network doesn't matter; we will force a mode that relies on runtime-added links.
|
|
391
|
+
createLineStringFeature([
|
|
392
|
+
[0, 0],
|
|
393
|
+
[1, 0],
|
|
394
|
+
]),
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
routeFinder.buildRouteGraph(network);
|
|
398
|
+
|
|
399
|
+
// Force a mode that relies on the runtime adjacency list.
|
|
400
|
+
type TerraRouteInternals = {
|
|
401
|
+
csrOffsets: Int32Array | null;
|
|
402
|
+
csrIndices: Int32Array | null;
|
|
403
|
+
csrDistances: Float64Array | null;
|
|
404
|
+
csrNodeCount: number;
|
|
405
|
+
coordinateIndexMap: Map<number, Map<number, number>>;
|
|
406
|
+
adjacencyList: Array<Array<{ node: number; distance: number }>>;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const internal = routeFinder as unknown as TerraRouteInternals;
|
|
410
|
+
internal.csrOffsets = null;
|
|
411
|
+
internal.csrIndices = null;
|
|
412
|
+
internal.csrDistances = null;
|
|
413
|
+
internal.csrNodeCount = 0;
|
|
414
|
+
|
|
415
|
+
// Create two new points and then connect them via a runtime-provided link.
|
|
416
|
+
const start = createPointFeature([5, 5]);
|
|
417
|
+
const end = createPointFeature([6, 5]);
|
|
418
|
+
|
|
419
|
+
// First call creates internal nodes (still no route).
|
|
420
|
+
expect(routeFinder.getRoute(start, end)).toBeNull();
|
|
421
|
+
|
|
422
|
+
const startIndex = internal.coordinateIndexMap.get(5)!.get(5)!;
|
|
423
|
+
const endIndex = internal.coordinateIndexMap.get(6)!.get(5)!;
|
|
424
|
+
|
|
425
|
+
// Add a link both ways so routing can traverse.
|
|
426
|
+
internal.adjacencyList[startIndex].push({ node: endIndex, distance: 1 });
|
|
427
|
+
internal.adjacencyList[endIndex].push({ node: startIndex, distance: 1 });
|
|
428
|
+
|
|
429
|
+
const route = routeFinder.getRoute(start, end);
|
|
430
|
+
expect(route).not.toBeNull();
|
|
431
|
+
expect(route!.geometry.coordinates).toEqual([
|
|
432
|
+
[5, 5],
|
|
433
|
+
[6, 5],
|
|
434
|
+
]);
|
|
435
|
+
});
|
|
436
|
+
|
|
63
437
|
it("finds the correct shortest route in a simple connected graph", () => {
|
|
64
438
|
const network = createFeatureCollection([
|
|
65
439
|
createLineStringFeature([
|
|
@@ -148,6 +522,208 @@ describe("TerraRoute", () => {
|
|
|
148
522
|
expect(result!.geometry.coordinates.length).toBeGreaterThanOrEqual(2);
|
|
149
523
|
});
|
|
150
524
|
|
|
525
|
+
it("supports a custom heap implementation", () => {
|
|
526
|
+
// Minimal heap that satisfies the expected interface (extracts minimum key).
|
|
527
|
+
class SimpleMinHeap {
|
|
528
|
+
private items: Array<{ key: number; value: number }> = [];
|
|
529
|
+
insert(key: number, value: number) {
|
|
530
|
+
this.items.push({ key, value });
|
|
531
|
+
}
|
|
532
|
+
extractMin() {
|
|
533
|
+
if (this.items.length === 0) return null;
|
|
534
|
+
let minIndex = 0;
|
|
535
|
+
for (let i = 1; i < this.items.length; i++) {
|
|
536
|
+
if (this.items[i].key < this.items[minIndex].key) minIndex = i;
|
|
537
|
+
}
|
|
538
|
+
return this.items.splice(minIndex, 1)[0].value;
|
|
539
|
+
}
|
|
540
|
+
size() {
|
|
541
|
+
return this.items.length;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const network = createFeatureCollection([
|
|
546
|
+
createLineStringFeature([
|
|
547
|
+
[0, 0],
|
|
548
|
+
[1, 0],
|
|
549
|
+
[2, 0],
|
|
550
|
+
]),
|
|
551
|
+
createLineStringFeature([
|
|
552
|
+
[2, 0],
|
|
553
|
+
[2, 1],
|
|
554
|
+
]),
|
|
555
|
+
]);
|
|
556
|
+
|
|
557
|
+
const heapConstructor = SimpleMinHeap as unknown as new () => {
|
|
558
|
+
insert: (key: number, value: number) => void;
|
|
559
|
+
extractMin: () => number | null;
|
|
560
|
+
size: () => number;
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
const rf = new TerraRoute({ heap: heapConstructor });
|
|
564
|
+
rf.buildRouteGraph(network);
|
|
565
|
+
|
|
566
|
+
const start = createPointFeature([0, 0]);
|
|
567
|
+
const end = createPointFeature([2, 1]);
|
|
568
|
+
|
|
569
|
+
const result = rf.getRoute(start, end);
|
|
570
|
+
expect(result).not.toBeNull();
|
|
571
|
+
expect(result!.geometry.coordinates).toEqual([
|
|
572
|
+
[0, 0],
|
|
573
|
+
[1, 0],
|
|
574
|
+
[2, 0],
|
|
575
|
+
[2, 1],
|
|
576
|
+
]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("prefers the shortest route even when there are many alternatives", () => {
|
|
580
|
+
// A short corridor from start to end, plus lots of distracting branches.
|
|
581
|
+
const corridor: number[][] = [
|
|
582
|
+
[0, 0],
|
|
583
|
+
[1, 0],
|
|
584
|
+
[2, 0],
|
|
585
|
+
[3, 0],
|
|
586
|
+
[4, 0],
|
|
587
|
+
[5, 0],
|
|
588
|
+
[6, 0],
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
const features = [createLineStringFeature(corridor)];
|
|
592
|
+
|
|
593
|
+
// Add many branches off the early corridor nodes to enlarge the frontier.
|
|
594
|
+
// These branches don't lead to the end.
|
|
595
|
+
for (let i = 0; i <= 3; i++) {
|
|
596
|
+
for (let b = 1; b <= 20; b++) {
|
|
597
|
+
features.push(createLineStringFeature([
|
|
598
|
+
[i, 0],
|
|
599
|
+
[i, b],
|
|
600
|
+
]));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Add a couple of loops to create multiple improvements for some nodes.
|
|
605
|
+
features.push(createLineStringFeature([
|
|
606
|
+
[1, 0],
|
|
607
|
+
[1, 1],
|
|
608
|
+
[2, 0],
|
|
609
|
+
]));
|
|
610
|
+
features.push(createLineStringFeature([
|
|
611
|
+
[2, 0],
|
|
612
|
+
[2, 1],
|
|
613
|
+
[3, 0],
|
|
614
|
+
]));
|
|
615
|
+
|
|
616
|
+
const network = createFeatureCollection(features);
|
|
617
|
+
routeFinder.buildRouteGraph(network);
|
|
618
|
+
|
|
619
|
+
const start = createPointFeature([0, 0]);
|
|
620
|
+
const end = createPointFeature([6, 0]);
|
|
621
|
+
|
|
622
|
+
const result = routeFinder.getRoute(start, end);
|
|
623
|
+
expect(result).not.toBeNull();
|
|
624
|
+
expect(getReasonIfLineStringInvalid(result)).toBe(undefined);
|
|
625
|
+
expect(startAndEndAreCorrect(result!, start, end)).toBe(true);
|
|
626
|
+
expect(result!.geometry.coordinates).toEqual(corridor);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("returns a route across imbalanced branching", () => {
|
|
630
|
+
// Start side: large star (many edges) feeding into a single corridor.
|
|
631
|
+
// End side: small local neighborhood. This encourages the reverse side to expand at least once.
|
|
632
|
+
|
|
633
|
+
const features = [] as ReturnType<typeof createLineStringFeature>[];
|
|
634
|
+
|
|
635
|
+
// Core corridor: (0,0) -> (1,0) -> (2,0) -> (3,0)
|
|
636
|
+
features.push(createLineStringFeature([
|
|
637
|
+
[0, 0],
|
|
638
|
+
[1, 0],
|
|
639
|
+
[2, 0],
|
|
640
|
+
[3, 0],
|
|
641
|
+
]));
|
|
642
|
+
|
|
643
|
+
// Heavy fan-out at the start node: many leaves that don't help reach the end.
|
|
644
|
+
for (let i = 1; i <= 60; i++) {
|
|
645
|
+
features.push(createLineStringFeature([
|
|
646
|
+
[0, 0],
|
|
647
|
+
[-i, i],
|
|
648
|
+
]));
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Light branching near the end.
|
|
652
|
+
features.push(createLineStringFeature([
|
|
653
|
+
[3, 0],
|
|
654
|
+
[3, 1],
|
|
655
|
+
]));
|
|
656
|
+
|
|
657
|
+
const network = createFeatureCollection(features);
|
|
658
|
+
routeFinder.buildRouteGraph(network);
|
|
659
|
+
|
|
660
|
+
const start = createPointFeature([0, 0]);
|
|
661
|
+
const end = createPointFeature([3, 0]);
|
|
662
|
+
|
|
663
|
+
const result = routeFinder.getRoute(start, end);
|
|
664
|
+
expect(result).not.toBeNull();
|
|
665
|
+
expect(result!.geometry.coordinates).toEqual([
|
|
666
|
+
[0, 0],
|
|
667
|
+
[1, 0],
|
|
668
|
+
[2, 0],
|
|
669
|
+
[3, 0],
|
|
670
|
+
]);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("returns null when only one of the points can reach the network", () => {
|
|
674
|
+
const network = createFeatureCollection([
|
|
675
|
+
createLineStringFeature([
|
|
676
|
+
[0, 0],
|
|
677
|
+
[1, 0],
|
|
678
|
+
[2, 0],
|
|
679
|
+
]),
|
|
680
|
+
]);
|
|
681
|
+
|
|
682
|
+
routeFinder.buildRouteGraph(network);
|
|
683
|
+
|
|
684
|
+
const start = createPointFeature([10, 10]);
|
|
685
|
+
const end = createPointFeature([2, 0]);
|
|
686
|
+
|
|
687
|
+
const result = routeFinder.getRoute(start, end);
|
|
688
|
+
expect(result).toBeNull();
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
it("returns a route after being called multiple times", () => {
|
|
692
|
+
const network = createFeatureCollection([
|
|
693
|
+
createLineStringFeature([
|
|
694
|
+
[0, 0],
|
|
695
|
+
[1, 0],
|
|
696
|
+
[2, 0],
|
|
697
|
+
]),
|
|
698
|
+
createLineStringFeature([
|
|
699
|
+
[2, 0],
|
|
700
|
+
[3, 0],
|
|
701
|
+
]),
|
|
702
|
+
]);
|
|
703
|
+
|
|
704
|
+
routeFinder.buildRouteGraph(network);
|
|
705
|
+
|
|
706
|
+
const start1 = createPointFeature([0, 0]);
|
|
707
|
+
const end1 = createPointFeature([3, 0]);
|
|
708
|
+
const first = routeFinder.getRoute(start1, end1);
|
|
709
|
+
expect(first).not.toBeNull();
|
|
710
|
+
expect(first!.geometry.coordinates).toEqual([
|
|
711
|
+
[0, 0],
|
|
712
|
+
[1, 0],
|
|
713
|
+
[2, 0],
|
|
714
|
+
[3, 0],
|
|
715
|
+
]);
|
|
716
|
+
|
|
717
|
+
const start2 = createPointFeature([1, 0]);
|
|
718
|
+
const end2 = createPointFeature([2, 0]);
|
|
719
|
+
const second = routeFinder.getRoute(start2, end2);
|
|
720
|
+
expect(second).not.toBeNull();
|
|
721
|
+
expect(second!.geometry.coordinates).toEqual([
|
|
722
|
+
[1, 0],
|
|
723
|
+
[2, 0],
|
|
724
|
+
]);
|
|
725
|
+
});
|
|
726
|
+
|
|
151
727
|
it("can route across intersecting segments", () => {
|
|
152
728
|
const network = createFeatureCollection([
|
|
153
729
|
createLineStringFeature([
|
package/src/terra-route.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { FourAryHeap } from "./heap/four-ary-heap";
|
|
|
6
6
|
|
|
7
7
|
interface Router {
|
|
8
8
|
buildRouteGraph(network: FeatureCollection<LineString>): void;
|
|
9
|
+
expandRouteGraph(network: FeatureCollection<LineString>): void;
|
|
9
10
|
getRoute(start: Feature<Point>, end: Feature<Point>): Feature<LineString> | null;
|
|
10
11
|
}
|
|
11
12
|
|
|
@@ -31,6 +32,11 @@ class TerraRoute implements Router {
|
|
|
31
32
|
private cameFromScratch: Int32Array | null = null; // Predecessor per node for path reconstruction
|
|
32
33
|
private visitedScratch: Uint8Array | null = null; // Visited set to avoid reprocessing
|
|
33
34
|
private hScratch: Float64Array | null = null; // Per-node heuristic cache for the current query (lazy compute)
|
|
35
|
+
// Reverse-direction scratch for bidirectional search
|
|
36
|
+
private gScoreRevScratch: Float64Array | null = null; // gScore per node from the end
|
|
37
|
+
private cameFromRevScratch: Int32Array | null = null; // Successor per node (next step toward the end)
|
|
38
|
+
private visitedRevScratch: Uint8Array | null = null; // Visited set for reverse search
|
|
39
|
+
private hRevScratch: Float64Array | null = null; // Heuristic cache for reverse direction per query
|
|
34
40
|
private scratchCapacity = 0; // Current capacity of scratch arrays
|
|
35
41
|
|
|
36
42
|
constructor(options?: {
|
|
@@ -63,52 +69,15 @@ class TerraRoute implements Router {
|
|
|
63
69
|
const coordsLocal = this.coordinates; // Local alias for coordinates array
|
|
64
70
|
const measureDistance = this.distanceMeasurement; // Local alias for distance function
|
|
65
71
|
|
|
66
|
-
const features = network.features; // All LineString features
|
|
67
|
-
|
|
68
72
|
// Pass 1: assign indices and count degrees per node
|
|
69
73
|
const degree: number[] = []; // Dynamic degree array; grows as nodes are discovered
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
for (let i = 0, len = lineCoords.length - 1; i < len; i++) { // Iterate segment pairs
|
|
75
|
-
const a = lineCoords[i]; // Segment start coord
|
|
76
|
-
const b = lineCoords[i + 1]; // Segment end coord
|
|
77
|
-
|
|
78
|
-
const lngA = a[0], latA = a[1]; // Keys for A
|
|
79
|
-
const lngB = b[0], latB = b[1]; // Keys for B
|
|
80
|
-
|
|
81
|
-
// Index A
|
|
82
|
-
let latMapA = coordIndexMapLocal.get(lngA);
|
|
83
|
-
if (latMapA === undefined) {
|
|
84
|
-
latMapA = new Map<number, number>();
|
|
85
|
-
coordIndexMapLocal.set(lngA, latMapA);
|
|
86
|
-
}
|
|
87
|
-
let indexA = latMapA.get(latA);
|
|
88
|
-
if (indexA === undefined) {
|
|
89
|
-
indexA = coordsLocal.length;
|
|
90
|
-
coordsLocal.push(a);
|
|
91
|
-
latMapA.set(latA, indexA);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Index B
|
|
95
|
-
let latMapB = coordIndexMapLocal.get(lngB);
|
|
96
|
-
if (latMapB === undefined) {
|
|
97
|
-
latMapB = new Map<number, number>();
|
|
98
|
-
coordIndexMapLocal.set(lngB, latMapB);
|
|
99
|
-
}
|
|
100
|
-
let indexB = latMapB.get(latB);
|
|
101
|
-
if (indexB === undefined) {
|
|
102
|
-
indexB = coordsLocal.length;
|
|
103
|
-
coordsLocal.push(b);
|
|
104
|
-
latMapB.set(latB, indexB);
|
|
105
|
-
}
|
|
74
|
+
this.forEachSegment(network, (a, b) => {
|
|
75
|
+
const indexA = this.indexCoordinate(a, coordIndexMapLocal, coordsLocal);
|
|
76
|
+
const indexB = this.indexCoordinate(b, coordIndexMapLocal, coordsLocal);
|
|
106
77
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
111
|
-
}
|
|
78
|
+
degree[indexA] = (degree[indexA] ?? 0) + 1;
|
|
79
|
+
degree[indexB] = (degree[indexB] ?? 0) + 1;
|
|
80
|
+
});
|
|
112
81
|
|
|
113
82
|
// Build CSR arrays from degree counts
|
|
114
83
|
const nodeCount = this.coordinates.length; // Total nodes discovered
|
|
@@ -124,32 +93,20 @@ class TerraRoute implements Router {
|
|
|
124
93
|
|
|
125
94
|
// Pass 2: fill CSR arrays using a write cursor per node
|
|
126
95
|
const cursor = offsets.slice(); // Current write positions per node
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const segmentDistance = measureDistance(a, b); // Edge weight once
|
|
142
|
-
|
|
143
|
-
// Write A → B
|
|
144
|
-
let pos = cursor[indexA]++;
|
|
145
|
-
indices[pos] = indexB;
|
|
146
|
-
distances[pos] = segmentDistance;
|
|
147
|
-
// Write B → A
|
|
148
|
-
pos = cursor[indexB]++;
|
|
149
|
-
indices[pos] = indexA;
|
|
150
|
-
distances[pos] = segmentDistance;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
96
|
+
this.forEachSegment(network, (a, b) => {
|
|
97
|
+
// Read back indices (guaranteed to exist from pass 1)
|
|
98
|
+
const indexA = this.coordinateIndexMap.get(a[0])!.get(a[1])!;
|
|
99
|
+
const indexB = this.coordinateIndexMap.get(b[0])!.get(b[1])!;
|
|
100
|
+
|
|
101
|
+
const segmentDistance = measureDistance(a, b); // Edge weight once
|
|
102
|
+
|
|
103
|
+
let pos = cursor[indexA]++;
|
|
104
|
+
indices[pos] = indexB;
|
|
105
|
+
distances[pos] = segmentDistance;
|
|
106
|
+
pos = cursor[indexB]++;
|
|
107
|
+
indices[pos] = indexA;
|
|
108
|
+
distances[pos] = segmentDistance;
|
|
109
|
+
});
|
|
153
110
|
|
|
154
111
|
// Commit CSR to instance
|
|
155
112
|
this.csrOffsets = offsets;
|
|
@@ -160,6 +117,122 @@ class TerraRoute implements Router {
|
|
|
160
117
|
this.adjacencyList = new Array(nodeCount);
|
|
161
118
|
}
|
|
162
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Expands (merges) the existing graph with an additional LineString FeatureCollection.
|
|
122
|
+
*/
|
|
123
|
+
public expandRouteGraph(network: FeatureCollection<LineString>): void {
|
|
124
|
+
if (this.network === null) {
|
|
125
|
+
throw new Error("Network not built. Please call buildRouteGraph(network) first.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Merge the feature arrays for reference/debugging. We avoid copying properties deeply.
|
|
129
|
+
this.network = {
|
|
130
|
+
type: "FeatureCollection",
|
|
131
|
+
features: [...this.network.features, ...network.features],
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const coordIndexMapLocal = this.coordinateIndexMap;
|
|
135
|
+
const coordsLocal = this.coordinates;
|
|
136
|
+
const measureDistance = this.distanceMeasurement;
|
|
137
|
+
const adj = this.adjacencyList;
|
|
138
|
+
|
|
139
|
+
// Ensure we have adjacency arrays for any existing CSR-only nodes.
|
|
140
|
+
// (During buildRouteGraph, adjacencyList is sized but entries are undefined.)
|
|
141
|
+
for (let i = 0; i < adj.length; i++) {
|
|
142
|
+
if (adj[i] === undefined) adj[i] = [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Add new edges into the adjacency list (sparse), then rebuild CSR from adjacency.
|
|
146
|
+
this.forEachSegment(network, (a, b) => {
|
|
147
|
+
const indexA = this.indexCoordinate(a, coordIndexMapLocal, coordsLocal, (idx) => { adj[idx] = []; });
|
|
148
|
+
const indexB = this.indexCoordinate(b, coordIndexMapLocal, coordsLocal, (idx) => { adj[idx] = []; });
|
|
149
|
+
|
|
150
|
+
const segmentDistance = measureDistance(a, b);
|
|
151
|
+
|
|
152
|
+
adj[indexA].push({ node: indexB, distance: segmentDistance });
|
|
153
|
+
adj[indexB].push({ node: indexA, distance: segmentDistance });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.rebuildCsrFromAdjacency();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Rebuild CSR arrays for the full node set, using:
|
|
161
|
+
* - Existing CSR edges (from the last build/expand)
|
|
162
|
+
* - Any additional edges stored in `adjacencyList`
|
|
163
|
+
*/
|
|
164
|
+
private rebuildCsrFromAdjacency(): void {
|
|
165
|
+
const nodeCount = this.coordinates.length;
|
|
166
|
+
const adj = this.adjacencyList;
|
|
167
|
+
|
|
168
|
+
// Compute degree using CSR degree + adjacency degree
|
|
169
|
+
const degree = new Int32Array(nodeCount);
|
|
170
|
+
|
|
171
|
+
if (this.csrOffsets && this.csrIndices && this.csrDistances) {
|
|
172
|
+
const csrOffsets = this.csrOffsets;
|
|
173
|
+
const covered = Math.min(this.csrNodeCount, nodeCount);
|
|
174
|
+
for (let i = 0; i < covered; i++) {
|
|
175
|
+
degree[i] += (csrOffsets[i + 1] - csrOffsets[i]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
180
|
+
const neighbors = adj[i];
|
|
181
|
+
if (neighbors && neighbors.length) degree[i] += neighbors.length;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const offsets = new Int32Array(nodeCount + 1);
|
|
185
|
+
for (let i = 0; i < nodeCount; i++) {
|
|
186
|
+
offsets[i + 1] = offsets[i] + degree[i];
|
|
187
|
+
}
|
|
188
|
+
const totalEdges = offsets[nodeCount];
|
|
189
|
+
const indices = new Int32Array(totalEdges);
|
|
190
|
+
const distances = new Float64Array(totalEdges);
|
|
191
|
+
const cursor = offsets.slice();
|
|
192
|
+
|
|
193
|
+
// Copy existing CSR edges first
|
|
194
|
+
if (this.csrOffsets && this.csrIndices && this.csrDistances) {
|
|
195
|
+
const csrOffsets = this.csrOffsets;
|
|
196
|
+
const csrIndices = this.csrIndices;
|
|
197
|
+
const csrDistances = this.csrDistances;
|
|
198
|
+
const covered = Math.min(this.csrNodeCount, nodeCount);
|
|
199
|
+
for (let n = 0; n < covered; n++) {
|
|
200
|
+
const startOff = csrOffsets[n];
|
|
201
|
+
const endOff = csrOffsets[n + 1];
|
|
202
|
+
let pos = cursor[n];
|
|
203
|
+
for (let i = startOff; i < endOff; i++) {
|
|
204
|
+
indices[pos] = csrIndices[i];
|
|
205
|
+
distances[pos] = csrDistances[i];
|
|
206
|
+
pos++;
|
|
207
|
+
}
|
|
208
|
+
cursor[n] = pos;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Append adjacency edges
|
|
213
|
+
for (let n = 0; n < nodeCount; n++) {
|
|
214
|
+
const neighbors = adj[n];
|
|
215
|
+
if (!neighbors || neighbors.length === 0) continue;
|
|
216
|
+
let pos = cursor[n];
|
|
217
|
+
for (let i = 0, len = neighbors.length; i < len; i++) {
|
|
218
|
+
const nb = neighbors[i];
|
|
219
|
+
indices[pos] = nb.node;
|
|
220
|
+
distances[pos] = nb.distance;
|
|
221
|
+
pos++;
|
|
222
|
+
}
|
|
223
|
+
cursor[n] = pos;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Commit and reset adjacency (we've absorbed edges into CSR)
|
|
227
|
+
this.csrOffsets = offsets;
|
|
228
|
+
this.csrIndices = indices;
|
|
229
|
+
this.csrDistances = distances;
|
|
230
|
+
this.csrNodeCount = nodeCount;
|
|
231
|
+
|
|
232
|
+
// Keep adjacency list for *future* dynamic additions, but clear existing edges to avoid duplication.
|
|
233
|
+
this.adjacencyList = new Array(nodeCount);
|
|
234
|
+
}
|
|
235
|
+
|
|
163
236
|
/**
|
|
164
237
|
* Computes the shortest route between two points in the network using the A* algorithm.
|
|
165
238
|
*
|
|
@@ -189,112 +262,202 @@ class TerraRoute implements Router {
|
|
|
189
262
|
// Local aliases
|
|
190
263
|
const coords = this.coordinates; // Alias to coordinates array
|
|
191
264
|
const adj = this.adjacencyList; // Alias to sparse adjacency list (for dynamic nodes)
|
|
192
|
-
const measureDistance = this.distanceMeasurement; // Alias to distance function
|
|
193
265
|
|
|
194
266
|
// Ensure and init scratch buffers
|
|
195
267
|
const nodeCount = coords.length; // Current number of nodes (may be >= csrNodeCount if new nodes added)
|
|
196
268
|
this.ensureScratch(nodeCount); // Allocate scratch arrays if needed
|
|
197
269
|
|
|
198
270
|
// Non-null after ensure
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
271
|
+
const gF = this.gScoreScratch!; // forward gScore (from start)
|
|
272
|
+
const gR = this.gScoreRevScratch!; // reverse gScore (from end)
|
|
273
|
+
const prevF = this.cameFromScratch!; // predecessor in forward search
|
|
274
|
+
const nextR = this.cameFromRevScratch!; // successor in reverse search (toward end)
|
|
275
|
+
const visF = this.visitedScratch!;
|
|
276
|
+
const visR = this.visitedRevScratch!;
|
|
277
|
+
const hF = this.hScratch!;
|
|
278
|
+
const hR = this.hRevScratch!;
|
|
279
|
+
|
|
280
|
+
gF.fill(Number.POSITIVE_INFINITY, 0, nodeCount);
|
|
281
|
+
gR.fill(Number.POSITIVE_INFINITY, 0, nodeCount);
|
|
282
|
+
prevF.fill(-1, 0, nodeCount);
|
|
283
|
+
nextR.fill(-1, 0, nodeCount);
|
|
284
|
+
visF.fill(0, 0, nodeCount);
|
|
285
|
+
visR.fill(0, 0, nodeCount);
|
|
286
|
+
hF.fill(-1, 0, nodeCount);
|
|
287
|
+
hR.fill(-1, 0, nodeCount);
|
|
288
|
+
|
|
289
|
+
const openF = new this.heapConstructor();
|
|
290
|
+
const openR = new this.heapConstructor();
|
|
291
|
+
|
|
292
|
+
const startCoord = coords[startIndex];
|
|
293
|
+
const endCoord = coords[endIndex];
|
|
294
|
+
|
|
295
|
+
gF[startIndex] = 0;
|
|
296
|
+
gR[endIndex] = 0;
|
|
297
|
+
|
|
298
|
+
// Bidirectional Dijkstra (A* with zero heuristic). This keeps correctness simple and matches the
|
|
299
|
+
// reference pathfinder while still saving work by meeting in the middle.
|
|
300
|
+
openF.insert(0, startIndex);
|
|
301
|
+
openR.insert(0, endIndex);
|
|
302
|
+
|
|
303
|
+
// Best meeting point found so far
|
|
304
|
+
let bestPathCost = Number.POSITIVE_INFINITY;
|
|
305
|
+
let meetingNode = -1;
|
|
306
|
+
|
|
307
|
+
// Main bidirectional loop: expand alternately.
|
|
308
|
+
// Without a heap peek, a safe and effective stopping rule is based on the last extracted keys:
|
|
309
|
+
// once min_g_forward + min_g_reverse >= bestPathCost, no shorter path can still be found.
|
|
310
|
+
let lastExtractedGForward = 0;
|
|
311
|
+
let lastExtractedGReverse = 0;
|
|
312
|
+
while (openF.size() > 0 && openR.size() > 0) {
|
|
313
|
+
if (meetingNode >= 0 && (lastExtractedGForward + lastExtractedGReverse) >= bestPathCost) {
|
|
231
314
|
break;
|
|
232
315
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
cameFrom[nbNode] = current; // Track predecessor
|
|
250
|
-
// A* priority = g + h (cache h per node for this query)
|
|
251
|
-
let hVal = hCache[nbNode];
|
|
252
|
-
if (hVal < 0) { hVal = measureDistance(coords[nbNode], endCoord); hCache[nbNode] = hVal; }
|
|
253
|
-
openSet.insert(tentativeG + hVal, nbNode); // Push/update neighbor into open set
|
|
316
|
+
|
|
317
|
+
// Expand one step from each side, prioritizing the smaller frontier.
|
|
318
|
+
const expandForward = openF.size() <= openR.size();
|
|
319
|
+
|
|
320
|
+
if (expandForward) {
|
|
321
|
+
const current = openF.extractMin()!;
|
|
322
|
+
if (visF[current] !== 0) continue;
|
|
323
|
+
lastExtractedGForward = gF[current];
|
|
324
|
+
visF[current] = 1;
|
|
325
|
+
|
|
326
|
+
// If reverse has finalized this node, we have a candidate meeting.
|
|
327
|
+
if (visR[current] !== 0) {
|
|
328
|
+
const total = gF[current] + gR[current];
|
|
329
|
+
if (total < bestPathCost) {
|
|
330
|
+
bestPathCost = total;
|
|
331
|
+
meetingNode = current;
|
|
254
332
|
}
|
|
255
333
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
334
|
+
|
|
335
|
+
// Relax neighbors and push newly improved ones
|
|
336
|
+
if (this.csrOffsets && current < this.csrNodeCount) {
|
|
337
|
+
const csrOffsets = this.csrOffsets!;
|
|
338
|
+
const csrIndices = this.csrIndices!;
|
|
339
|
+
const csrDistances = this.csrDistances!;
|
|
340
|
+
for (let i = csrOffsets[current], endOff = csrOffsets[current + 1]; i < endOff; i++) {
|
|
341
|
+
const nbNode = csrIndices[i];
|
|
342
|
+
const tentativeG = gF[current] + csrDistances[i];
|
|
343
|
+
if (tentativeG < gF[nbNode]) {
|
|
344
|
+
gF[nbNode] = tentativeG;
|
|
345
|
+
prevF[nbNode] = current;
|
|
346
|
+
const otherG = gR[nbNode];
|
|
347
|
+
if (otherG !== Number.POSITIVE_INFINITY) {
|
|
348
|
+
const total = tentativeG + otherG;
|
|
349
|
+
if (total < bestPathCost) { bestPathCost = total; meetingNode = nbNode; }
|
|
350
|
+
}
|
|
351
|
+
openF.insert(tentativeG, nbNode);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
} else {
|
|
355
|
+
const neighbors = adj[current];
|
|
356
|
+
if (neighbors && neighbors.length) {
|
|
357
|
+
for (let i = 0, n = neighbors.length; i < n; i++) {
|
|
358
|
+
const nb = neighbors[i];
|
|
359
|
+
const nbNode = nb.node;
|
|
360
|
+
const tentativeG = gF[current] + nb.distance;
|
|
361
|
+
if (tentativeG < gF[nbNode]) {
|
|
362
|
+
gF[nbNode] = tentativeG;
|
|
363
|
+
prevF[nbNode] = current;
|
|
364
|
+
const otherG = gR[nbNode];
|
|
365
|
+
if (otherG !== Number.POSITIVE_INFINITY) {
|
|
366
|
+
const total = tentativeG + otherG;
|
|
367
|
+
if (total < bestPathCost) { bestPathCost = total; meetingNode = nbNode; }
|
|
368
|
+
}
|
|
369
|
+
openF.insert(tentativeG, nbNode);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
const current = openR.extractMin()!;
|
|
376
|
+
if (visR[current] !== 0) continue;
|
|
377
|
+
lastExtractedGReverse = gR[current];
|
|
378
|
+
visR[current] = 1;
|
|
379
|
+
|
|
380
|
+
if (visF[current] !== 0) {
|
|
381
|
+
const total = gF[current] + gR[current];
|
|
382
|
+
if (total < bestPathCost) {
|
|
383
|
+
bestPathCost = total;
|
|
384
|
+
meetingNode = current;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Reverse direction: same neighbor iteration because graph is undirected.
|
|
389
|
+
// Store successor pointer (next step toward end) i.e. nextR[neighbor] = current.
|
|
390
|
+
if (this.csrOffsets && current < this.csrNodeCount) {
|
|
391
|
+
const csrOffsets = this.csrOffsets!;
|
|
392
|
+
const csrIndices = this.csrIndices!;
|
|
393
|
+
const csrDistances = this.csrDistances!;
|
|
394
|
+
for (let i = csrOffsets[current], endOff = csrOffsets[current + 1]; i < endOff; i++) {
|
|
395
|
+
const nbNode = csrIndices[i];
|
|
396
|
+
const tentativeG = gR[current] + csrDistances[i];
|
|
397
|
+
if (tentativeG < gR[nbNode]) {
|
|
398
|
+
gR[nbNode] = tentativeG;
|
|
399
|
+
nextR[nbNode] = current;
|
|
400
|
+
const otherG = gF[nbNode];
|
|
401
|
+
if (otherG !== Number.POSITIVE_INFINITY) {
|
|
402
|
+
const total = tentativeG + otherG;
|
|
403
|
+
if (total < bestPathCost) { bestPathCost = total; meetingNode = nbNode; }
|
|
404
|
+
}
|
|
405
|
+
openR.insert(tentativeG, nbNode);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const neighbors = adj[current];
|
|
410
|
+
if (neighbors && neighbors.length) {
|
|
411
|
+
for (let i = 0, n = neighbors.length; i < n; i++) {
|
|
412
|
+
const nb = neighbors[i];
|
|
413
|
+
const nbNode = nb.node;
|
|
414
|
+
const tentativeG = gR[current] + nb.distance;
|
|
415
|
+
if (tentativeG < gR[nbNode]) {
|
|
416
|
+
gR[nbNode] = tentativeG;
|
|
417
|
+
nextR[nbNode] = current;
|
|
418
|
+
const otherG = gF[nbNode];
|
|
419
|
+
if (otherG !== Number.POSITIVE_INFINITY) {
|
|
420
|
+
const total = tentativeG + otherG;
|
|
421
|
+
if (total < bestPathCost) { bestPathCost = total; meetingNode = nbNode; }
|
|
422
|
+
}
|
|
423
|
+
openR.insert(tentativeG, nbNode);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
275
426
|
}
|
|
276
427
|
}
|
|
277
428
|
}
|
|
278
429
|
}
|
|
279
430
|
|
|
280
|
-
|
|
281
|
-
if (cameFrom[endIndex] < 0) {
|
|
431
|
+
if (meetingNode < 0) {
|
|
282
432
|
return null;
|
|
283
433
|
}
|
|
284
434
|
|
|
285
|
-
// Reconstruct path
|
|
435
|
+
// Reconstruct path: start -> meeting using prevF, then meeting -> end using nextR
|
|
286
436
|
const path: Position[] = [];
|
|
287
|
-
|
|
288
|
-
|
|
437
|
+
|
|
438
|
+
// Walk back from meeting to start, collecting nodes
|
|
439
|
+
let cur = meetingNode;
|
|
440
|
+
while (cur !== startIndex && cur >= 0) {
|
|
289
441
|
path.push(coords[cur]);
|
|
290
|
-
cur =
|
|
442
|
+
cur = prevF[cur];
|
|
443
|
+
}
|
|
444
|
+
if (cur !== startIndex) {
|
|
445
|
+
// Forward tree doesn't connect start to meeting (shouldn't happen if meeting is valid)
|
|
446
|
+
return null;
|
|
291
447
|
}
|
|
292
|
-
// Include start coordinate
|
|
293
448
|
path.push(coords[startIndex]);
|
|
294
|
-
|
|
295
|
-
// Reverse to get start→end order
|
|
296
449
|
path.reverse();
|
|
297
450
|
|
|
451
|
+
// Walk from meeting to end (skip meeting node because it's already included)
|
|
452
|
+
cur = meetingNode;
|
|
453
|
+
while (cur !== endIndex) {
|
|
454
|
+
cur = nextR[cur];
|
|
455
|
+
if (cur < 0) {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
path.push(coords[cur]);
|
|
459
|
+
}
|
|
460
|
+
|
|
298
461
|
return {
|
|
299
462
|
type: "Feature",
|
|
300
463
|
geometry: { type: "LineString", coordinates: path },
|
|
@@ -353,7 +516,11 @@ class TerraRoute implements Router {
|
|
|
353
516
|
&& this.gScoreScratch
|
|
354
517
|
&& this.cameFromScratch
|
|
355
518
|
&& this.visitedScratch
|
|
356
|
-
&& this.hScratch
|
|
519
|
+
&& this.hScratch
|
|
520
|
+
&& this.gScoreRevScratch
|
|
521
|
+
&& this.cameFromRevScratch
|
|
522
|
+
&& this.visitedRevScratch
|
|
523
|
+
&& this.hRevScratch;
|
|
357
524
|
|
|
358
525
|
if (ifAlreadyBigEnough) {
|
|
359
526
|
return; // Nothing to do
|
|
@@ -363,8 +530,58 @@ class TerraRoute implements Router {
|
|
|
363
530
|
this.cameFromScratch = new Int32Array(capacity);
|
|
364
531
|
this.visitedScratch = new Uint8Array(capacity);
|
|
365
532
|
this.hScratch = new Float64Array(capacity);
|
|
533
|
+
this.gScoreRevScratch = new Float64Array(capacity);
|
|
534
|
+
this.cameFromRevScratch = new Int32Array(capacity);
|
|
535
|
+
this.visitedRevScratch = new Uint8Array(capacity);
|
|
536
|
+
this.hRevScratch = new Float64Array(capacity);
|
|
366
537
|
this.scratchCapacity = capacity;
|
|
367
538
|
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
// Iterate all consecutive segment pairs in a LineString FeatureCollection.
|
|
542
|
+
// Kept as a simple loop helper to avoid repeating nested iteration logic.
|
|
543
|
+
private forEachSegment(
|
|
544
|
+
network: FeatureCollection<LineString>,
|
|
545
|
+
fn: (a: Position, b: Position) => void,
|
|
546
|
+
): void {
|
|
547
|
+
const features = network.features;
|
|
548
|
+
for (let f = 0, fLen = features.length; f < fLen; f++) {
|
|
549
|
+
const lineCoords = features[f].geometry.coordinates;
|
|
550
|
+
for (let i = 0, len = lineCoords.length - 1; i < len; i++) {
|
|
551
|
+
// GeoJSON coordinates are compatible with Position (number[]), and the project assumes [lng,lat]
|
|
552
|
+
fn(lineCoords[i] as Position, lineCoords[i + 1] as Position);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Hot-path coordinate indexer used by both build/expand.
|
|
558
|
+
// Accepts explicit maps/arrays so callers can hoist them once.
|
|
559
|
+
private indexCoordinate(
|
|
560
|
+
coord: Position,
|
|
561
|
+
coordIndexMapLocal: Map<number, Map<number, number>>,
|
|
562
|
+
coordsLocal: Position[],
|
|
563
|
+
onNewIndex?: (index: number) => void,
|
|
564
|
+
): number {
|
|
565
|
+
const lng = coord[0];
|
|
566
|
+
const lat = coord[1];
|
|
567
|
+
|
|
568
|
+
let latMap = coordIndexMapLocal.get(lng);
|
|
569
|
+
if (latMap === undefined) {
|
|
570
|
+
latMap = new Map<number, number>();
|
|
571
|
+
coordIndexMapLocal.set(lng, latMap);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let idx = latMap.get(lat);
|
|
575
|
+
if (idx === undefined) {
|
|
576
|
+
idx = coordsLocal.length;
|
|
577
|
+
coordsLocal.push(coord);
|
|
578
|
+
latMap.set(lat, idx);
|
|
579
|
+
if (onNewIndex) onNewIndex(idx);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return idx;
|
|
583
|
+
}
|
|
584
|
+
|
|
368
585
|
}
|
|
369
586
|
|
|
370
587
|
export { TerraRoute, createCheapRuler, haversineDistance }
|