terra-route 0.0.13 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +6 -6
  3. package/dist/heap/four-ary-heap.d.ts +2 -0
  4. package/dist/terra-route.cjs +1 -1
  5. package/dist/terra-route.cjs.map +1 -1
  6. package/dist/terra-route.d.ts +25 -1
  7. package/dist/terra-route.modern.js +1 -1
  8. package/dist/terra-route.modern.js.map +1 -1
  9. package/dist/terra-route.module.js +1 -1
  10. package/dist/terra-route.module.js.map +1 -1
  11. package/dist/terra-route.umd.js +1 -1
  12. package/dist/terra-route.umd.js.map +1 -1
  13. package/fta.config.json +9 -0
  14. package/jest.config.js +1 -1
  15. package/package.json +35 -8
  16. package/scripts/bump.mjs +57 -0
  17. package/scripts/release.mjs +30 -0
  18. package/scripts/versionrc.cjs +26 -0
  19. package/src/distance/haversine.ts +13 -15
  20. package/src/heap/fibonacci-heap.ts +9 -0
  21. package/src/heap/four-ary-heap.ts +10 -0
  22. package/src/heap/heap.d.ts +5 -0
  23. package/src/heap/min-heap.ts +9 -0
  24. package/src/heap/pairing-heap.ts +5 -0
  25. package/src/terra-route.compare.spec.ts +4 -5
  26. package/src/terra-route.spec.ts +81 -0
  27. package/src/terra-route.ts +282 -196
  28. package/dist/fibonacci-heap.d.ts +0 -11
  29. package/dist/graph/graph.d.ts +0 -114
  30. package/dist/graph/methods/bounding-box.d.ts +0 -13
  31. package/dist/graph/methods/connected.d.ts +0 -9
  32. package/dist/graph/methods/duplicates.d.ts +0 -7
  33. package/dist/graph/methods/leaf.d.ts +0 -12
  34. package/dist/graph/methods/nodes.d.ts +0 -17
  35. package/dist/graph/methods/spatial-index/geokdbush.d.ts +0 -3
  36. package/dist/graph/methods/spatial-index/kdbush.d.ts +0 -16
  37. package/dist/graph/methods/spatial-index/tinyqueue.d.ts +0 -11
  38. package/dist/graph/methods/unify.d.ts +0 -2
  39. package/dist/graph/methods/unique-segments.d.ts +0 -5
  40. package/dist/graph/methods/unique.d.ts +0 -5
  41. package/dist/heap/min-heap.d.ts +0 -9
  42. package/dist/min-heap.d.ts +0 -8
  43. package/dist/test-utils/utils.d.ts +0 -50
  44. package/instructions.md +0 -13
@@ -836,6 +836,87 @@ describe("TerraRoute", () => {
836
836
  expect(result!.geometry.coordinates.at(-1)).toEqual([2, 0]);
837
837
  });
838
838
 
839
+
840
+ it("fails gracefully when a custom heap returns null unexpectedly", () => {
841
+ class NullingHeap {
842
+ private items: Array<{ key: number; value: number }> = [];
843
+
844
+ insert(key: number, value: number) {
845
+ this.items.push({ key, value });
846
+ }
847
+
848
+ extractMin() {
849
+ if (this.items.length === 0) {
850
+ return null;
851
+ }
852
+ return null;
853
+ }
854
+
855
+ size() {
856
+ return this.items.length;
857
+ }
858
+ }
859
+
860
+ const heapConstructor = NullingHeap as unknown as new () => {
861
+ insert: (key: number, value: number) => void;
862
+ extractMin: () => number | null;
863
+ size: () => number;
864
+ };
865
+
866
+ const network = createFeatureCollection([
867
+ createLineStringFeature([
868
+ [0, 0],
869
+ [1, 0],
870
+ [2, 0],
871
+ ]),
872
+ ]);
873
+
874
+ const rf = new TerraRoute({ heap: heapConstructor });
875
+
876
+ expect(() => rf.buildRouteGraph(network)).not.toThrow();
877
+
878
+ const result = rf.getRoute(
879
+ createPointFeature([0, 0]),
880
+ createPointFeature([2, 0]),
881
+ );
882
+
883
+ expect(result).toBeNull();
884
+ });
885
+
886
+
887
+ it("routes correctly when ALT landmarks are in another disconnected component", () => {
888
+ // Component A is listed first so ALT landmark selection starts here.
889
+ // Query is in component B, where landmark distances become Infinity and
890
+ // the ALT lower bound should safely degrade to 0 (Dijkstra behavior).
891
+ const network = createFeatureCollection([
892
+ createLineStringFeature([
893
+ [0, 0],
894
+ [1, 0],
895
+ [2, 0],
896
+ ]),
897
+ createLineStringFeature([
898
+ [100, 0],
899
+ [101, 0],
900
+ [102, 0],
901
+ ]),
902
+ ]);
903
+
904
+ routeFinder.buildRouteGraph(network);
905
+
906
+ const start = createPointFeature([100, 0]);
907
+ const end = createPointFeature([102, 0]);
908
+
909
+ const result = routeFinder.getRoute(start, end);
910
+
911
+ expect(result).not.toBeNull();
912
+ expect(result!.geometry.coordinates).toEqual([
913
+ [100, 0],
914
+ [101, 0],
915
+ [102, 0],
916
+ ]);
917
+ });
918
+
919
+
839
920
  describe('returns null if', () => {
840
921
  it("start and end are the same", () => {
841
922
  const network = createFeatureCollection([
@@ -26,19 +26,26 @@ class TerraRoute implements Router {
26
26
  private csrIndices: Int32Array | null = null; // Column indices: neighbor node IDs, length = totalEdges
27
27
  private csrDistances: Float64Array | null = null; // Edge weights aligned to csrIndices, length = totalEdges
28
28
  private csrNodeCount = 0; // Number of nodes captured in the CSR arrays
29
-
30
- // Reusable typed scratch buffers for A*
29
+ // ALT-style landmark heuristic data (query-time lower bounds via triangle inequality)
30
+ private landmarkNodeCount = 0;
31
+ private landmarkCount = 0;
32
+ private landmarkDistancesFlat: Float64Array | null = null; // Layout: [landmark0 distances..., landmark1 distances...]
33
+ private readonly maxLandmarks = 4;
34
+ private landmarksDirty = true;
35
+
36
+ // Reusable typed scratch buffers for shortest-path search
31
37
  private gScoreScratch: Float64Array | null = null; // gScore per node (cost from start)
32
38
  private cameFromScratch: Int32Array | null = null; // Predecessor per node for path reconstruction
33
39
  private visitedScratch: Uint8Array | null = null; // Visited set to avoid reprocessing
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
40
+ private heuristicScratch: Float64Array | null = null; // Cached h(node) per query for A*
41
+ private heuristicStampScratch: Uint32Array | null = null; // Query-stamp per node for heuristic cache validity
42
+ private heuristicQueryStamp = 1; // Monotonic stamp to avoid clearing heuristic cache each query
40
43
  private scratchCapacity = 0; // Current capacity of scratch arrays
41
44
 
45
+ // Reused open set to avoid heap allocations during repeated getRoute calls.
46
+ // (If a custom heap doesn't implement `clear()`, we fall back to constructing anew.)
47
+ private openForward: InstanceType<HeapConstructor> | null = null;
48
+
42
49
  constructor(options?: {
43
50
  distanceMeasurement?: (a: Position, b: Position) => number; // Optional distance function override
44
51
  heap?: HeapConstructor; // Optional heap implementation override
@@ -63,21 +70,40 @@ class TerraRoute implements Router {
63
70
  this.csrIndices = null;
64
71
  this.csrDistances = null;
65
72
  this.csrNodeCount = 0;
73
+ this.landmarkNodeCount = 0;
74
+ this.landmarkCount = 0;
75
+ this.landmarkDistancesFlat = null;
76
+ this.landmarksDirty = true;
66
77
 
67
78
  // Hoist to locals for speed (avoid repeated property lookups in hot loops)
68
79
  const coordIndexMapLocal = this.coordinateIndexMap; // Local alias for coord map
69
80
  const coordsLocal = this.coordinates; // Local alias for coordinates array
70
81
  const measureDistance = this.distanceMeasurement; // Local alias for distance function
71
82
 
72
- // Pass 1: assign indices and count degrees per node
83
+ // Assign indices, count degrees and capture edges in one pass
73
84
  const degree: number[] = []; // Dynamic degree array; grows as nodes are discovered
74
- this.forEachSegment(network, (a, b) => {
75
- const indexA = this.indexCoordinate(a, coordIndexMapLocal, coordsLocal);
76
- const indexB = this.indexCoordinate(b, coordIndexMapLocal, coordsLocal);
85
+ const edgeFrom: number[] = [];
86
+ const edgeTo: number[] = [];
87
+ const edgeDistance: number[] = [];
77
88
 
78
- degree[indexA] = (degree[indexA] ?? 0) + 1;
79
- degree[indexB] = (degree[indexB] ?? 0) + 1;
80
- });
89
+ const features = network.features;
90
+ for (let f = 0, featureLength = features.length; f < featureLength; f++) {
91
+ const lineCoords = features[f].geometry.coordinates;
92
+ for (let i = 0, segmentLength = lineCoords.length - 1; i < segmentLength; i++) {
93
+ const from = lineCoords[i] as Position;
94
+ const to = lineCoords[i + 1] as Position;
95
+
96
+ const indexA = this.indexCoordinate(from, coordIndexMapLocal, coordsLocal);
97
+ const indexB = this.indexCoordinate(to, coordIndexMapLocal, coordsLocal);
98
+
99
+ degree[indexA] = (degree[indexA] ?? 0) + 1;
100
+ degree[indexB] = (degree[indexB] ?? 0) + 1;
101
+
102
+ edgeFrom.push(indexA);
103
+ edgeTo.push(indexB);
104
+ edgeDistance.push(measureDistance(from, to));
105
+ }
106
+ }
81
107
 
82
108
  // Build CSR arrays from degree counts
83
109
  const nodeCount = this.coordinates.length; // Total nodes discovered
@@ -91,14 +117,12 @@ class TerraRoute implements Router {
91
117
  const indices = new Int32Array(totalEdges); // Neighbor indices array
92
118
  const distances = new Float64Array(totalEdges); // Distances array aligned to indices
93
119
 
94
- // Pass 2: fill CSR arrays using a write cursor per node
120
+ // Fill CSR arrays using a write cursor per node
95
121
  const cursor = offsets.slice(); // Current write positions per node
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
122
+ for (let i = 0, edgeLength = edgeFrom.length; i < edgeLength; i++) {
123
+ const indexA = edgeFrom[i];
124
+ const indexB = edgeTo[i];
125
+ const segmentDistance = edgeDistance[i];
102
126
 
103
127
  let pos = cursor[indexA]++;
104
128
  indices[pos] = indexB;
@@ -106,7 +130,7 @@ class TerraRoute implements Router {
106
130
  pos = cursor[indexB]++;
107
131
  indices[pos] = indexA;
108
132
  distances[pos] = segmentDistance;
109
- });
133
+ }
110
134
 
111
135
  // Commit CSR to instance
112
136
  this.csrOffsets = offsets;
@@ -228,6 +252,10 @@ class TerraRoute implements Router {
228
252
  this.csrIndices = indices;
229
253
  this.csrDistances = distances;
230
254
  this.csrNodeCount = nodeCount;
255
+ this.landmarksDirty = true;
256
+ this.landmarkNodeCount = 0;
257
+ this.landmarkCount = 0;
258
+ this.landmarkDistancesFlat = null;
231
259
 
232
260
  // Keep adjacency list for *future* dynamic additions, but clear existing edges to avoid duplication.
233
261
  this.adjacencyList = new Array(nodeCount);
@@ -260,204 +288,154 @@ class TerraRoute implements Router {
260
288
  }
261
289
 
262
290
  // Local aliases
263
- const coords = this.coordinates; // Alias to coordinates array
264
- const adj = this.adjacencyList; // Alias to sparse adjacency list (for dynamic nodes)
291
+ const coordinates = this.coordinates; // Alias to coordinates array
292
+ const adjacency = this.adjacencyList; // Alias to sparse adjacency list (for dynamic nodes)
293
+ const csrOffsets = this.csrOffsets;
294
+ const csrIndices = this.csrIndices;
295
+ const csrDistances = this.csrDistances;
296
+ const csrNodeCount = this.csrNodeCount;
297
+ const hasCsr = !!csrOffsets; // indices/distances should exist whenever offsets exist
298
+ const PositiveInfinity = Number.POSITIVE_INFINITY;
299
+ const measureDistance = this.distanceMeasurement;
300
+ const endCoordinates = end.geometry.coordinates;
301
+
302
+ this.ensureLandmarkHeuristicData();
303
+ const landmarkDistancesFlat = this.landmarkDistancesFlat;
304
+ const landmarkNodeCount = this.landmarkNodeCount;
305
+ const landmarkCount = this.landmarkCount;
265
306
 
266
307
  // Ensure and init scratch buffers
267
- const nodeCount = coords.length; // Current number of nodes (may be >= csrNodeCount if new nodes added)
308
+ const nodeCount = coordinates.length; // Current number of nodes (may be >= csrNodeCount if new nodes added)
268
309
  this.ensureScratch(nodeCount); // Allocate scratch arrays if needed
269
310
 
270
311
  // Non-null after ensure
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)
312
+ const gF = this.gScoreScratch!; // gScore from start
313
+ const prevF = this.cameFromScratch!; // predecessor for reconstruction
275
314
  const visF = this.visitedScratch!;
276
- const visR = this.visitedRevScratch!;
277
- const hF = this.hScratch!;
278
- const hR = this.hRevScratch!;
315
+ const heuristic = this.heuristicScratch!;
316
+ const heuristicStamp = this.heuristicStampScratch!;
279
317
 
280
- gF.fill(Number.POSITIVE_INFINITY, 0, nodeCount);
281
- gR.fill(Number.POSITIVE_INFINITY, 0, nodeCount);
318
+ gF.fill(PositiveInfinity, 0, nodeCount);
282
319
  prevF.fill(-1, 0, nodeCount);
283
- nextR.fill(-1, 0, nodeCount);
284
320
  visF.fill(0, 0, nodeCount);
285
- visR.fill(0, 0, nodeCount);
286
- hF.fill(-1, 0, nodeCount);
287
- hR.fill(-1, 0, nodeCount);
288
321
 
289
- const openF = new this.heapConstructor();
290
- const openR = new this.heapConstructor();
322
+ // Increment query stamp for heuristic cache validity; handle wraparound.
323
+ let queryStamp = (this.heuristicQueryStamp + 1) >>> 0;
324
+ if (queryStamp === 0) {
325
+ heuristicStamp.fill(0, 0, nodeCount);
326
+ queryStamp = 1;
327
+ }
328
+ this.heuristicQueryStamp = queryStamp;
291
329
 
292
- const startCoord = coords[startIndex];
293
- const endCoord = coords[endIndex];
330
+ const getHeuristic = (node: number): number => {
331
+ if (heuristicStamp[node] !== queryStamp) {
332
+ heuristicStamp[node] = queryStamp;
294
333
 
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) {
314
- break;
315
- }
334
+ if (landmarkDistancesFlat && landmarkCount > 0 && node < landmarkNodeCount && endIndex < landmarkNodeCount) {
335
+ let lowerBound = 0;
336
+ for (let l = 0, offset = 0; l < landmarkCount; l++, offset += landmarkNodeCount) {
337
+ const distanceToNode = landmarkDistancesFlat[offset + node];
338
+ const distanceToEnd = landmarkDistancesFlat[offset + endIndex];
316
339
 
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;
332
- }
333
- }
340
+ if (!Number.isFinite(distanceToNode) || !Number.isFinite(distanceToEnd)) {
341
+ continue;
342
+ }
334
343
 
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);
344
+ const landmarkLowerBound = distanceToEnd >= distanceToNode
345
+ ? distanceToEnd - distanceToNode
346
+ : distanceToNode - distanceToEnd;
347
+
348
+ if (landmarkLowerBound > lowerBound) {
349
+ lowerBound = landmarkLowerBound;
352
350
  }
353
351
  }
352
+ heuristic[node] = lowerBound;
354
353
  } 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
- }
354
+ heuristic[node] = measureDistance(coordinates[node], endCoordinates);
373
355
  }
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
- }
356
+ }
357
+ return heuristic[node];
358
+ };
359
+
360
+ // Prefer reusing heaps if supported.
361
+ const openFReuse = this.openForward ?? (this.openForward = new this.heapConstructor());
362
+ const openFAny = openFReuse as unknown as { clear?: () => void };
363
+ const canReuse = !!openFAny.clear;
364
+
365
+ const openF2 = canReuse ? openFReuse : new this.heapConstructor();
366
+
367
+ if (canReuse) {
368
+ openFAny.clear!();
369
+ }
370
+
371
+ gF[startIndex] = 0;
372
+ openF2.insert(getHeuristic(startIndex), startIndex);
373
+
374
+ while (openF2.size() > 0) {
375
+ const current = openF2.extractMin();
376
+ if (current === null) {
377
+ break;
378
+ }
379
+ if (visF[current] !== 0) {
380
+ continue;
381
+ }
382
+ if (current === endIndex) {
383
+ break;
384
+ }
385
+
386
+ visF[current] = 1;
387
+ const currentDistance = gF[current];
388
+
389
+ const isCsrNode = hasCsr && current < csrNodeCount;
390
+ if (!isCsrNode) {
391
+ const neighbors = adjacency[current];
392
+ if (!neighbors || neighbors.length === 0) {
393
+ continue;
386
394
  }
387
395
 
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
- }
396
+ for (let i = 0, n = neighbors.length; i < n; i++) {
397
+ const nb = neighbors[i];
398
+ const nbNode = nb.node;
399
+ const tentativeG = currentDistance + nb.distance;
400
+ if (tentativeG >= gF[nbNode]) {
401
+ continue;
426
402
  }
403
+
404
+ gF[nbNode] = tentativeG;
405
+ prevF[nbNode] = current;
406
+ openF2.insert(tentativeG + getHeuristic(nbNode), nbNode);
407
+ }
408
+ continue;
409
+ }
410
+
411
+ for (let i = csrOffsets![current], endOff = csrOffsets![current + 1]; i < endOff; i++) {
412
+ const nbNode = csrIndices![i];
413
+ const tentativeG = currentDistance + csrDistances![i];
414
+ if (tentativeG >= gF[nbNode]) {
415
+ continue;
427
416
  }
417
+
418
+ gF[nbNode] = tentativeG;
419
+ prevF[nbNode] = current;
420
+ openF2.insert(tentativeG + getHeuristic(nbNode), nbNode);
428
421
  }
429
422
  }
430
423
 
431
- if (meetingNode < 0) {
424
+ if (gF[endIndex] === PositiveInfinity) {
432
425
  return null;
433
426
  }
434
427
 
435
- // Reconstruct path: start -> meeting using prevF, then meeting -> end using nextR
428
+ // Reconstruct path from end back to start through predecessor links.
436
429
  const path: Position[] = [];
437
430
 
438
- // Walk back from meeting to start, collecting nodes
439
- let cur = meetingNode;
431
+ let cur = endIndex;
440
432
  while (cur !== startIndex && cur >= 0) {
441
- path.push(coords[cur]);
433
+ path.push(coordinates[cur]);
442
434
  cur = prevF[cur];
443
435
  }
444
- if (cur !== startIndex) {
445
- // Forward tree doesn't connect start to meeting (shouldn't happen if meeting is valid)
446
- return null;
447
- }
448
- path.push(coords[startIndex]);
436
+ path.push(coordinates[startIndex]);
449
437
  path.reverse();
450
438
 
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
-
461
439
  return {
462
440
  type: "Feature",
463
441
  geometry: { type: "LineString", coordinates: path },
@@ -465,6 +443,120 @@ class TerraRoute implements Router {
465
443
  };
466
444
  }
467
445
 
446
+ // Build ALT heuristic tables by running shortest-path trees from selected landmarks.
447
+ private buildLandmarkHeuristicData(): void {
448
+ const offsets = this.csrOffsets;
449
+ const indices = this.csrIndices;
450
+ const distances = this.csrDistances;
451
+ const nodeCount = this.csrNodeCount;
452
+
453
+ if (!offsets || !indices || !distances || nodeCount === 0) {
454
+ this.landmarkNodeCount = 0;
455
+ this.landmarkCount = 0;
456
+ this.landmarkDistancesFlat = null;
457
+ return;
458
+ }
459
+
460
+ const targetLandmarkCount = Math.min(this.maxLandmarks, nodeCount);
461
+ const selected = new Uint8Array(nodeCount);
462
+ const allDistances: Float64Array[] = [];
463
+
464
+ let source = 0;
465
+ for (let landmarkIndex = 0; landmarkIndex < targetLandmarkCount; landmarkIndex++) {
466
+ selected[source] = 1;
467
+
468
+ const computedDistances = this.computeShortestDistancesFrom(source, nodeCount, offsets, indices, distances);
469
+ allDistances.push(computedDistances);
470
+
471
+ let farthestDistance = -1;
472
+ let farthestIndex = -1;
473
+ for (let node = 0; node < nodeCount; node++) {
474
+ if (selected[node] !== 0) {
475
+ continue;
476
+ }
477
+
478
+ const distanceAtNode = computedDistances[node];
479
+ if (!Number.isFinite(distanceAtNode)) {
480
+ continue;
481
+ }
482
+
483
+ if (distanceAtNode > farthestDistance) {
484
+ farthestDistance = distanceAtNode;
485
+ farthestIndex = node;
486
+ }
487
+ }
488
+
489
+ if (farthestIndex < 0) {
490
+ break;
491
+ }
492
+ source = farthestIndex;
493
+ }
494
+
495
+ const landmarkCount = allDistances.length;
496
+
497
+ const flat = new Float64Array(landmarkCount * nodeCount);
498
+ for (let i = 0; i < landmarkCount; i++) {
499
+ flat.set(allDistances[i], i * nodeCount);
500
+ }
501
+
502
+ this.landmarkNodeCount = nodeCount;
503
+ this.landmarkCount = landmarkCount;
504
+ this.landmarkDistancesFlat = flat;
505
+ this.landmarksDirty = false;
506
+ }
507
+
508
+ // Build landmark tables only when needed by getRoute.
509
+ private ensureLandmarkHeuristicData(): void {
510
+ if (!this.landmarksDirty) {
511
+ return;
512
+ }
513
+ this.buildLandmarkHeuristicData();
514
+ }
515
+
516
+ // Dijkstra over CSR to compute all-pairs distances from one source node.
517
+ private computeShortestDistancesFrom(
518
+ source: number,
519
+ nodeCount: number,
520
+ offsets: Int32Array,
521
+ indices: Int32Array,
522
+ distances: Float64Array,
523
+ ): Float64Array {
524
+ const PositiveInfinity = Number.POSITIVE_INFINITY;
525
+ const bestDistance = new Float64Array(nodeCount);
526
+ const visited = new Uint8Array(nodeCount);
527
+ bestDistance.fill(PositiveInfinity);
528
+ bestDistance[source] = 0;
529
+
530
+ const openSet = new this.heapConstructor();
531
+ openSet.insert(0, source);
532
+
533
+ while (openSet.size() > 0) {
534
+ const current = openSet.extractMin();
535
+ if (current === null) {
536
+ break;
537
+ }
538
+ if (visited[current] !== 0) {
539
+ continue;
540
+ }
541
+
542
+ visited[current] = 1;
543
+ const currentDistance = bestDistance[current];
544
+
545
+ for (let i = offsets[current], endOffset = offsets[current + 1]; i < endOffset; i++) {
546
+ const neighbor = indices[i];
547
+ const tentativeDistance = currentDistance + distances[i];
548
+ if (tentativeDistance >= bestDistance[neighbor]) {
549
+ continue;
550
+ }
551
+
552
+ bestDistance[neighbor] = tentativeDistance;
553
+ openSet.insert(tentativeDistance, neighbor);
554
+ }
555
+ }
556
+
557
+ return bestDistance;
558
+ }
559
+
468
560
  /**
469
561
  * Helper to index start/end in getRoute.
470
562
  */
@@ -516,11 +608,8 @@ class TerraRoute implements Router {
516
608
  && this.gScoreScratch
517
609
  && this.cameFromScratch
518
610
  && this.visitedScratch
519
- && this.hScratch
520
- && this.gScoreRevScratch
521
- && this.cameFromRevScratch
522
- && this.visitedRevScratch
523
- && this.hRevScratch;
611
+ && this.heuristicScratch
612
+ && this.heuristicStampScratch;
524
613
 
525
614
  if (ifAlreadyBigEnough) {
526
615
  return; // Nothing to do
@@ -529,11 +618,8 @@ class TerraRoute implements Router {
529
618
  this.gScoreScratch = new Float64Array(capacity);
530
619
  this.cameFromScratch = new Int32Array(capacity);
531
620
  this.visitedScratch = new Uint8Array(capacity);
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);
621
+ this.heuristicScratch = new Float64Array(capacity);
622
+ this.heuristicStampScratch = new Uint32Array(capacity);
537
623
  this.scratchCapacity = capacity;
538
624
  }
539
625