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 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 | ██████ 186ms
64
- GeoJSON Path Finder | ██████████████████ 566ms
65
- ngraph.graph | ██████████████████████████████████████████████████ 1577ms
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 3x 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.
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* algorthm 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.
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
 
@@ -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.12",
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
  });
@@ -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([
@@ -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
- for (let f = 0, fLen = features.length; f < fLen; f++) { // Iterate features
71
- const feature = features[f]; // Current feature
72
- const lineCoords = feature.geometry.coordinates; // Coordinates for this LineString
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
- // Count degree for both directions
108
- degree[indexA] = (degree[indexA] ?? 0) + 1;
109
- degree[indexB] = (degree[indexB] ?? 0) + 1;
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
- for (let f = 0, fLen = features.length; f < fLen; f++) {
128
- const feature = features[f];
129
- const lineCoords = feature.geometry.coordinates;
130
- for (let i = 0, len = lineCoords.length - 1; i < len; i++) {
131
- const a = lineCoords[i];
132
- const b = lineCoords[i + 1];
133
-
134
- const lngA = a[0], latA = a[1];
135
- const lngB = b[0], latB = b[1];
136
-
137
- // Read back indices (guaranteed to exist from pass 1)
138
- const indexA = this.coordinateIndexMap.get(lngA)!.get(latA)!;
139
- const indexB = this.coordinateIndexMap.get(lngB)!.get(latB)!;
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 gScore = this.gScoreScratch!; // gScore pointer
200
- const cameFrom = this.cameFromScratch!; // cameFrom pointer
201
- const visited = this.visitedScratch!; // visited pointer
202
- const hCache = this.hScratch!; // heuristic cache pointer
203
-
204
- // Reset only the used range for speed
205
- gScore.fill(Number.POSITIVE_INFINITY, 0, nodeCount); // Init gScore to +∞
206
- cameFrom.fill(-1, 0, nodeCount); // Init predecessors to -1 (unknown)
207
- visited.fill(0, 0, nodeCount); // Init visited flags to 0
208
- hCache.fill(-1, 0, nodeCount); // Init heuristic cache with sentinel (-1 means unknown)
209
-
210
- // Create min-heap (priority queue)
211
- const openSet = new this.heapConstructor();
212
-
213
- // Precompute heuristic for start to prime the queue cost
214
- const endCoord = coords[endIndex]; // Cache end coordinate for heuristic
215
- let hStart = hCache[startIndex];
216
-
217
- if (hStart < 0) {
218
- hStart = measureDistance(coords[startIndex], endCoord);
219
- hCache[startIndex] = hStart;
220
- }
221
-
222
- openSet.insert(hStart, startIndex); // Insert start with f = g(0) + h(start)
223
- gScore[startIndex] = 0; // g(start) = 0
224
-
225
- while (openSet.size() > 0) { // Main A* loop until queue empty
226
- const current = openSet.extractMin()!; // Pop node with smallest f
227
- if (visited[current] !== 0) { // Skip if already finalized
228
- continue;
229
- }
230
- if (current === endIndex) { // Early exit if reached goal
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
- visited[current] = 1; // Mark as visited
234
-
235
- // Prefer CSR neighbors if available for this node, fall back to sparse list for dynamically added nodes
236
-
237
- if (this.csrOffsets && current < this.csrNodeCount) { // Use CSR fast path
238
- const csrOffsets = this.csrOffsets!; // Local CSR offsets (non-null here)
239
- const csrIndices = this.csrIndices!; // Local CSR neighbors
240
- const csrDistances = this.csrDistances!; // Local CSR weights
241
- const startOff = csrOffsets[current]; // Row start for current
242
- const endOff = csrOffsets[current + 1]; // Row end for current
243
-
244
- for (let i = startOff; i < endOff; i++) { // Iterate neighbors in CSR slice
245
- const nbNode = csrIndices[i]; // Neighbor node id
246
- const tentativeG = gScore[current] + csrDistances[i]; // g' = g(current) + w(current, nb)
247
- if (tentativeG < gScore[nbNode]) { // Relaxation check
248
- gScore[nbNode] = tentativeG; // Update best g
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
- // Fallback: use sparse adjacency (only for nodes added after CSR build)
258
- else {
259
-
260
- const neighbors = adj[current]; // Neighbor list for current
261
- if (!neighbors || neighbors.length === 0) continue; // No neighbors
262
- for (let i = 0, n = neighbors.length; i < n; i++) { // Iterate neighbors
263
- const nb = neighbors[i]; // Neighbor entry
264
- const nbNode = nb.node; // Neighbor id
265
-
266
- const tentativeG = gScore[current] + nb.distance; // g' via current
267
- if (tentativeG < gScore[nbNode]) { // Relax if better
268
- gScore[nbNode] = tentativeG; // Update best g
269
- cameFrom[nbNode] = current; // Track predecessor
270
-
271
- // A* priority = g + h (cached)
272
- let hVal = hCache[nbNode];
273
- if (hVal < 0) { hVal = measureDistance(coords[nbNode], endCoord); hCache[nbNode] = hVal; }
274
- openSet.insert(tentativeG + hVal, nbNode); // Enqueue neighbor
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
- // If goal was never reached/relaxed, return null
281
- if (cameFrom[endIndex] < 0) {
431
+ if (meetingNode < 0) {
282
432
  return null;
283
433
  }
284
434
 
285
- // Reconstruct path (push + reverse to avoid O(n^2) unshift)
435
+ // Reconstruct path: start -> meeting using prevF, then meeting -> end using nextR
286
436
  const path: Position[] = [];
287
- let cur = endIndex;
288
- while (cur !== startIndex) {
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 = cameFrom[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 }