terra-route 0.0.7 → 0.0.9

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.
@@ -3,33 +3,29 @@ import { haversineDistance } from "./distance/haversine";
3
3
  import { createCheapRuler } from "./distance/cheap-ruler";
4
4
  import { MinHeap } from "./heap/min-heap";
5
5
  import { HeapConstructor } from "./heap/heap";
6
+ import { LineStringGraph } from "./graph/graph";
6
7
 
7
- /**
8
- * TerraRoute is a routing utility for finding the shortest path
9
- * between two geographic points over a given GeoJSON LineString network.
10
- *
11
- * The class builds an internal graph structure based on the provided network,
12
- * then applies A* algorithm to compute the shortest route.
13
- */
14
- class TerraRoute {
15
- private network: FeatureCollection<LineString> | undefined;
16
- private distanceMeasurement: (positionA: Position, positionB: Position) => number;
17
- private adjacencyList: Map<number, Array<{ node: number; distance: number }>> = new Map();
18
- private coords: Position[] = []
19
- private coordMap: Map<number, Map<number, number>> = new Map();
20
- private heap: HeapConstructor;
8
+ interface Router {
9
+ buildRouteGraph(network: FeatureCollection<LineString>): void;
10
+ getRoute(start: Feature<Point>, end: Feature<Point>): Feature<LineString> | null;
11
+ }
12
+
13
+ class TerraRoute implements Router {
14
+ private network: FeatureCollection<LineString> | null = null;
15
+ private distanceMeasurement: (a: Position, b: Position) => number;
16
+ private heapConstructor: HeapConstructor;
17
+
18
+ // Map from longitude (map from latitude index)
19
+ private coordinateIndexMap: Map<number, Map<number, number>> = new Map();
20
+ private coordinates: Position[] = [];
21
+ private adjacencyList: Array<Array<{ node: number; distance: number }>> = [];
21
22
 
22
- /**
23
- * Creates a new instance of TerraRoute.
24
- *
25
- * @param distanceMeasurement - Optional custom distance measurement function (defaults to haversine distance).
26
- */
27
23
  constructor(options?: {
28
- distanceMeasurement?: (positionA: Position, positionB: Position) => number,
29
- heap?: HeapConstructor
24
+ distanceMeasurement?: (a: Position, b: Position) => number;
25
+ heap?: HeapConstructor;
30
26
  }) {
31
- this.heap = options?.heap ? options.heap : MinHeap
32
- this.distanceMeasurement = options?.distanceMeasurement ? options.distanceMeasurement : haversineDistance;
27
+ this.distanceMeasurement = options?.distanceMeasurement ?? haversineDistance;
28
+ this.heapConstructor = options?.heap ?? MinHeap;
33
29
  }
34
30
 
35
31
  /**
@@ -39,48 +35,59 @@ class TerraRoute {
39
35
  * @param coord - A GeoJSON Position array representing [longitude, latitude].
40
36
  * @returns A unique numeric index for the coordinate.
41
37
  */
42
- private coordinateIndex(coord: Position): number {
43
- const [lng, lat] = coord;
44
- if (!this.coordMap.has(lng)) this.coordMap.set(lng, new Map());
45
-
46
- const latMap = this.coordMap.get(lng)!;
47
- if (latMap.has(lat)) {
48
- return latMap.get(lat)!;
49
- }
38
+ public buildRouteGraph(network: FeatureCollection<LineString>): void {
39
+ this.network = network;
50
40
 
51
- const index = this.coords.length;
52
- this.coords.push(coord);
53
- latMap.set(lat, index);
41
+ // Reset everything
42
+ this.coordinateIndexMap = new Map();
43
+ this.coordinates = [];
44
+ this.adjacencyList = [];
45
+
46
+ // Hoist to locals for speed
47
+ const coordIndexMapLocal = this.coordinateIndexMap;
48
+ const coordsLocal = this.coordinates;
49
+ const adjListLocal = this.adjacencyList;
50
+ const measureDistance = this.distanceMeasurement;
51
+
52
+ for (const feature of network.features) {
53
+ const lineCoords = feature.geometry.coordinates;
54
+
55
+ for (let i = 0; i < lineCoords.length - 1; i++) {
56
+ const [lngA, latA] = lineCoords[i];
57
+ const [lngB, latB] = lineCoords[i + 1];
58
+
59
+ // get or assign index for A
60
+ let latMapA = coordIndexMapLocal.get(lngA);
61
+ if (!latMapA) {
62
+ latMapA = new Map<number, number>();
63
+ coordIndexMapLocal.set(lngA, latMapA);
64
+ }
65
+ let indexA = latMapA.get(latA);
66
+ if (indexA === undefined) {
67
+ indexA = coordsLocal.length;
68
+ coordsLocal.push(lineCoords[i]);
69
+ latMapA.set(latA, indexA);
70
+ adjListLocal[indexA] = [];
71
+ }
54
72
 
55
- return index;
56
- }
73
+ // get or assign index for B
74
+ let latMapB = coordIndexMapLocal.get(lngB);
75
+ if (!latMapB) {
76
+ latMapB = new Map<number, number>();
77
+ coordIndexMapLocal.set(lngB, latMapB);
78
+ }
79
+ let indexB = latMapB.get(latB);
80
+ if (indexB === undefined) {
81
+ indexB = coordsLocal.length;
82
+ coordsLocal.push(lineCoords[i + 1]);
83
+ latMapB.set(latB, indexB);
84
+ adjListLocal[indexB] = [];
85
+ }
57
86
 
58
- /**
59
- * Builds the internal graph representation (adjacency list) from the input network.
60
- * Each LineString segment is translated into graph edges with associated distances.
61
- * Assumes that the network is a connected graph of LineStrings with shared coordinates. Calling this
62
- * method with a new network overwrite any existing network and reset all internal data structures.
63
- *
64
- * @param network - A GeoJSON FeatureCollection of LineStrings representing the road network.
65
- */
66
- public buildRouteGraph(network: FeatureCollection<LineString>): void {
67
- this.network = network;
68
- this.adjacencyList = new Map();
69
- this.coords = [];
70
- this.coordMap = new Map();
71
-
72
- for (const feature of this.network.features) {
73
- const coords = feature.geometry.coordinates;
74
- for (let i = 0; i < coords.length - 1; i++) {
75
- const aIndex = this.coordinateIndex(coords[i]);
76
- const bIndex = this.coordinateIndex(coords[i + 1]);
77
- const distance = this.distanceMeasurement(coords[i], coords[i + 1]);
78
-
79
- if (!this.adjacencyList.has(aIndex)) this.adjacencyList.set(aIndex, []);
80
- if (!this.adjacencyList.has(bIndex)) this.adjacencyList.set(bIndex, []);
81
-
82
- this.adjacencyList.get(aIndex)!.push({ node: bIndex, distance });
83
- this.adjacencyList.get(bIndex)!.push({ node: aIndex, distance });
87
+ // record the bidirectional edge
88
+ const segmentDistance = measureDistance(lineCoords[i], lineCoords[i + 1]);
89
+ adjListLocal[indexA].push({ node: indexB, distance: segmentDistance });
90
+ adjListLocal[indexB].push({ node: indexA, distance: segmentDistance });
84
91
  }
85
92
  }
86
93
  }
@@ -102,64 +109,60 @@ class TerraRoute {
102
109
  throw new Error("Network not built. Please call buildRouteGraph(network) first.");
103
110
  }
104
111
 
105
- const startIndex = this.coordinateIndex(start.geometry.coordinates);
106
- const endIndex = this.coordinateIndex(end.geometry.coordinates);
112
+ // ensure start/end are in the index maps
113
+ const startIndex = this.getOrCreateIndex(start.geometry.coordinates);
114
+ const endIndex = this.getOrCreateIndex(end.geometry.coordinates);
107
115
 
108
116
  if (startIndex === endIndex) {
109
117
  return null;
110
118
  }
111
119
 
112
- const openSet = new this.heap();
120
+ const openSet = new this.heapConstructor();
113
121
  openSet.insert(0, startIndex);
114
122
 
115
- const cameFrom = new Map<number, number>();
116
- const gScore = new Map<number, number>([[startIndex, 0]]);
117
- const visited = new Set<number>();
123
+ const nodeCount = this.coordinates.length;
124
+ const gScore = new Array<number>(nodeCount).fill(Infinity);
125
+ const cameFrom = new Array<number>(nodeCount).fill(-1);
126
+ const visited = new Array<boolean>(nodeCount).fill(false);
127
+
128
+ gScore[startIndex] = 0;
118
129
 
119
130
  while (openSet.size() > 0) {
120
- // Extract the node with the smallest fScore
121
131
  const current = openSet.extractMin()!;
122
-
123
- // If we've reached the end node, we're done
132
+ if (visited[current]) {
133
+ continue;
134
+ }
124
135
  if (current === endIndex) {
125
136
  break;
126
137
  }
127
-
128
- visited.add(current);
129
-
130
- // Explore neighbors
131
- for (const neighbor of this.adjacencyList.get(current) || []) {
132
- // Tentative cost from start to this neighbor
133
- const tentativeG = (gScore.get(current) ?? Infinity) + neighbor.distance;
134
-
135
- // If this path to neighbor is better, record it
136
- if (tentativeG < (gScore.get(neighbor.node) ?? Infinity)) {
137
- cameFrom.set(neighbor.node, current);
138
- gScore.set(neighbor.node, tentativeG);
139
-
140
- // Calculate fScore: gScore + heuristic distance to the end
141
- const fScore =
142
- tentativeG +
143
- this.distanceMeasurement(this.coords[neighbor.node], this.coords[endIndex]);
144
-
145
- openSet.insert(fScore, neighbor.node);
138
+ visited[current] = true;
139
+
140
+ for (const neighbor of this.adjacencyList[current] || []) {
141
+ const tentativeG = gScore[current] + neighbor.distance;
142
+ if (tentativeG < gScore[neighbor.node]) {
143
+ gScore[neighbor.node] = tentativeG;
144
+ cameFrom[neighbor.node] = current;
145
+ const heuristic = this.distanceMeasurement(
146
+ this.coordinates[neighbor.node],
147
+ this.coordinates[endIndex]
148
+ );
149
+ openSet.insert(tentativeG + heuristic, neighbor.node);
146
150
  }
147
151
  }
148
152
  }
149
153
 
150
- // If we never set a path to the end node, there's no route
151
- if (!cameFrom.has(endIndex)) {
154
+ if (cameFrom[endIndex] < 0) {
152
155
  return null;
153
156
  }
154
157
 
155
- // Reconstruct the path from end node to start node
158
+ // Reconstruct path
156
159
  const path: Position[] = [];
157
- let node = endIndex;
158
-
159
- while (node !== undefined) {
160
- path.unshift(this.coords[node]);
161
- node = cameFrom.get(node)!;
160
+ let current = endIndex;
161
+ while (current !== startIndex) {
162
+ path.unshift(this.coordinates[current]);
163
+ current = cameFrom[current];
162
164
  }
165
+ path.unshift(this.coordinates[startIndex]);
163
166
 
164
167
  return {
165
168
  type: "Feature",
@@ -168,6 +171,26 @@ class TerraRoute {
168
171
  };
169
172
  }
170
173
 
174
+ /**
175
+ * Helper to index start/end in getRoute.
176
+ */
177
+ private getOrCreateIndex(coord: Position): number {
178
+ const [lng, lat] = coord;
179
+ let latMap = this.coordinateIndexMap.get(lng);
180
+ if (!latMap) {
181
+ latMap = new Map<number, number>();
182
+ this.coordinateIndexMap.set(lng, latMap);
183
+ }
184
+ let index = latMap.get(lat);
185
+ if (index === undefined) {
186
+ index = this.coordinates.length;
187
+ this.coordinates.push(coord);
188
+ latMap.set(lat, index);
189
+ // ensure adjacencyList covers this new node
190
+ this.adjacencyList[index] = [];
191
+ }
192
+ return index;
193
+ }
171
194
  }
172
195
 
173
- export { TerraRoute, createCheapRuler, haversineDistance }
196
+ export { TerraRoute, createCheapRuler, haversineDistance, LineStringGraph }
@@ -0,0 +1,39 @@
1
+ import { Position, Feature, Point, LineString, FeatureCollection } from "geojson";
2
+
3
+ /**
4
+ * Creates a GeoJSON Point feature from a coordinate.
5
+ * @param coord - A coordinate in the form of [longitude, latitude]
6
+ * @returns A GeoJSON Feature<Point> object
7
+ */
8
+ export const createPointFeature = (coord: Position): Feature<Point> => ({
9
+ type: "Feature",
10
+ geometry: {
11
+ type: "Point",
12
+ coordinates: coord,
13
+ },
14
+ properties: {},
15
+ });
16
+
17
+ /**
18
+ * Creates a GeoJSON LineString feature from an array of coordinates.
19
+ * @param coordinates - An array of coordinates in the form of [longitude, latitude]
20
+ * @returns A GeoJSON Feature<LineString> object
21
+ */
22
+ export const createLineStringFeature = (coordinates: Position[]): Feature<LineString> => ({
23
+ type: "Feature",
24
+ geometry: {
25
+ type: "LineString",
26
+ coordinates,
27
+ },
28
+ properties: {},
29
+ });
30
+
31
+ /**
32
+ * Creates a GeoJSON FeatureCollection from an array of features.
33
+ * @param features - An array of GeoJSON Feature<LineString> objects
34
+ * @returns A GeoJSON FeatureCollection object
35
+ */
36
+ export const createFeatureCollection = (features: Feature<LineString>[]): FeatureCollection<LineString> => ({
37
+ type: "FeatureCollection",
38
+ features,
39
+ });
@@ -1,43 +1,13 @@
1
- import { Position, Feature, Point, LineString, FeatureCollection } from "geojson";
2
- import { haversineDistance } from "../terra-route";
3
-
4
- export const createPointFeature = (coord: Position): Feature<Point> => ({
5
- type: "Feature",
6
- geometry: {
7
- type: "Point",
8
- coordinates: coord,
9
- },
10
- properties: {},
11
- });
12
-
13
- export const createFeatureCollection = (features: Feature<LineString>[]): FeatureCollection<LineString> => ({
14
- type: "FeatureCollection",
15
- features,
16
- });
17
-
18
- export const createLineStringFeature = (coordinates: Position[]): Feature<LineString> => ({
19
- type: "Feature",
20
- geometry: {
21
- type: "LineString",
22
- coordinates,
23
- },
24
- properties: {},
25
- });
26
-
27
- export function routeLength(
28
- line: Feature<LineString>,
29
-
30
- ) {
31
- const lineCoords = line.geometry.coordinates;
32
-
33
- // Calculate the total route distance
34
- let routeDistance = 0;
35
- for (let i = 0; i < lineCoords.length - 1; i++) {
36
- routeDistance += haversineDistance(lineCoords[i], lineCoords[i + 1]);
37
- }
38
- return routeDistance
39
- }
1
+ import { FeatureCollection, LineString, Feature, Position } from "geojson";
40
2
 
3
+ /**
4
+ * Generates a grid of LineStrings with n x n nodes, spaced by the given spacing.
5
+ * Each node is connected to its right and upward neighbors, and diagonals are included.
6
+ *
7
+ * @param n - Number of nodes in each dimension (n x n grid)
8
+ * @param spacing - Distance between consecutive nodes
9
+ * @returns FeatureCollection of LineStrings representing the grid
10
+ */
41
11
  export function generateGridWithDiagonals(n: number, spacing: number): FeatureCollection<LineString> {
42
12
  const features: Feature<LineString>[] = [];
43
13
 
@@ -182,38 +152,6 @@ export function generateStarPolygon(
182
152
  };
183
153
  }
184
154
 
185
-
186
- /**
187
- * Extracts unique coordinates from a FeatureCollection of LineStrings.
188
- *
189
- * @param collection - A GeoJSON FeatureCollection of LineStrings
190
- * @returns An array of unique Position coordinates
191
- */
192
- export function getUniqueCoordinatesFromLineStrings(
193
- collection: FeatureCollection<LineString>
194
- ): Position[] {
195
- const seen = new Set<string>();
196
- const unique: Position[] = [];
197
-
198
- for (const feature of collection.features) {
199
- if (feature.geometry.type !== "LineString") {
200
- continue;
201
- }
202
-
203
- for (const coord of feature.geometry.coordinates) {
204
- const key = `${coord[0]},${coord[1]}`;
205
-
206
- if (!seen.has(key)) {
207
- seen.add(key);
208
- unique.push(coord);
209
- }
210
- }
211
- }
212
-
213
- return unique;
214
- }
215
-
216
-
217
155
  /**
218
156
  * Generate a spatial n-depth tree as a FeatureCollection<LineString>.
219
157
  *
@@ -357,120 +295,3 @@ export function generateConcentricRings(
357
295
  features,
358
296
  };
359
297
  }
360
-
361
-
362
- /**
363
- * Validates a GeoJSON Feature<LineString> route.
364
- *
365
- * @param route - The GeoJSON feature to validate
366
- * @returns A boolean indicating if it is a valid LineString route
367
- */
368
- export function getReasonIfLineStringInvalid(
369
- route: Feature<LineString> | null | undefined
370
- ): string | undefined {
371
- // 1. Must exist
372
- if (!route) {
373
- return 'No feature';
374
- }
375
-
376
- // 2. Must be a Feature
377
- if (route.type !== "Feature") {
378
- return 'Not a Feature';
379
- }
380
-
381
- // 3. Must have a geometry of type LineString
382
- if (!route.geometry || route.geometry.type !== "LineString") {
383
- return 'Not a LineString';
384
- }
385
-
386
- // 4. Coordinates must be an array with length >= 2
387
- const coords = route.geometry.coordinates;
388
- if (!Array.isArray(coords) || coords.length < 2) {
389
- return `Not enough coordinates: ${coords.length} (${coords})`;
390
- }
391
-
392
- const seen = new Set<string>();
393
-
394
- // 5. Validate each coordinate is a valid Position
395
- // (At minimum, [number, number] or [number, number, number])
396
- for (const position of coords) {
397
- if (!Array.isArray(position)) {
398
- return 'Not a Position; not an array';
399
- }
400
-
401
- // Check numeric values, ignoring optional altitude
402
- if (
403
- position.length < 2 ||
404
- typeof position[0] !== "number" ||
405
- typeof position[1] !== "number"
406
- ) {
407
- return 'Not a Position; elements are not a numbers';
408
- }
409
-
410
- // 6. Check for duplicates
411
- const key = `${position[0]},${position[1]}`;
412
- if (seen.has(key)) {
413
- return `Duplicate coordinate: ${key}`;
414
- }
415
- seen.add(key);
416
- }
417
- }
418
-
419
- /**
420
- * Checks if the start and end coordinates of a LineString match the given start and end points.
421
- *
422
- * @param line - The LineString feature to check
423
- * @param start - The start point feature
424
- * @param end - The end point feature
425
- * @return True if the start and end coordinates match, false otherwise
426
- * */
427
- export function startAndEndAreCorrect(line: Feature<LineString>, start: Feature<Point>, end: Feature<Point>) {
428
- const lineCoords = line.geometry.coordinates;
429
- const startCoords = start.geometry.coordinates;
430
- const endCoords = end.geometry.coordinates;
431
-
432
- // Check if the first coordinate of the LineString matches the start point
433
- const startMatches = lineCoords[0][0] === startCoords[0] && lineCoords[0][1] === startCoords[1];
434
-
435
- // Check if the last coordinate of the LineString matches the end point
436
- const endMatches = lineCoords[lineCoords.length - 1][0] === endCoords[0] && lineCoords[lineCoords.length - 1][1] === endCoords[1];
437
-
438
- return startMatches && endMatches;
439
- }
440
-
441
- export function routeIsLongerThanDirectPath(line: Feature<LineString>, start: Feature<Point>, end: Feature<Point>) {
442
- const lineCoords = line.geometry.coordinates;
443
- const startCoords = start.geometry.coordinates;
444
- const endCoords = end.geometry.coordinates;
445
-
446
- if (lineCoords.length <= 2) {
447
- return true;
448
- }
449
-
450
- // Calculate the direct distance between the start and end points
451
- const directDistance = haversineDistance(startCoords, endCoords);
452
-
453
- // Calculate the route distance
454
- let routeDistance = 0;
455
- for (let i = 0; i < lineCoords.length - 1; i++) {
456
- routeDistance += haversineDistance(lineCoords[i], lineCoords[i + 1]);
457
- }
458
-
459
- // If the route distance is 0, it means the start and end points are the same
460
- if (routeDistance === 0) {
461
- return true;
462
- }
463
-
464
- if (routeDistance < directDistance) {
465
-
466
- // Check if the route distance is very close to the direct distance
467
- const absoluteDifference = Math.abs(routeDistance - directDistance);
468
- if (absoluteDifference < 0.000000000001) {
469
- return true;
470
- }
471
-
472
- return false;
473
- }
474
-
475
- return true
476
- }