terra-route 0.0.2 → 0.0.4
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 +26 -3
- package/dist/distance/cheap-ruler.d.ts +14 -0
- package/dist/terra-route.cjs +1 -1
- package/dist/terra-route.cjs.map +1 -1
- package/dist/terra-route.d.ts +12 -5
- package/dist/terra-route.modern.js +1 -1
- package/dist/terra-route.modern.js.map +1 -1
- package/dist/terra-route.module.js +1 -1
- package/dist/terra-route.module.js.map +1 -1
- package/dist/terra-route.umd.js +1 -1
- package/dist/terra-route.umd.js.map +1 -1
- package/package.json +105 -105
- package/src/min-heap.ts +37 -25
- package/src/terra-route.spec.ts +247 -14
- package/src/terra-route.ts +27 -16
- package/src/test-utils/test-utils.ts +369 -0
package/src/terra-route.ts
CHANGED
|
@@ -11,30 +11,21 @@ import { createCheapRuler } from "./distance/cheap-ruler";
|
|
|
11
11
|
* then applies A* algorithm to compute the shortest route.
|
|
12
12
|
*/
|
|
13
13
|
class TerraRoute {
|
|
14
|
-
private network: FeatureCollection<LineString
|
|
14
|
+
private network: FeatureCollection<LineString> | undefined;
|
|
15
15
|
private distanceMeasurement: (positionA: Position, positionB: Position) => number;
|
|
16
|
-
private adjacencyList: Map<number, Array<{ node: number; distance: number }
|
|
17
|
-
private coords: Position[]
|
|
18
|
-
private coordMap: Map<number, Map<number, number
|
|
16
|
+
private adjacencyList: Map<number, Array<{ node: number; distance: number }>> = new Map();
|
|
17
|
+
private coords: Position[] = []
|
|
18
|
+
private coordMap: Map<number, Map<number, number>> = new Map();
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Creates a new instance of TerraRoute.
|
|
22
22
|
*
|
|
23
|
-
* @param network - A GeoJSON FeatureCollection of LineStrings representing the route network.
|
|
24
23
|
* @param distanceMeasurement - Optional custom distance measurement function (defaults to haversine distance).
|
|
25
24
|
*/
|
|
26
25
|
constructor(
|
|
27
|
-
network: FeatureCollection<LineString>,
|
|
28
26
|
distanceMeasurement?: (positionA: Position, positionB: Position) => number
|
|
29
27
|
) {
|
|
30
|
-
this.network = network;
|
|
31
|
-
this.adjacencyList = new Map();
|
|
32
|
-
this.coords = [];
|
|
33
|
-
this.coordMap = new Map();
|
|
34
28
|
this.distanceMeasurement = distanceMeasurement ? distanceMeasurement : haversineDistance;
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
this.buildNetworkGraph();
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
/**
|
|
@@ -63,9 +54,17 @@ class TerraRoute {
|
|
|
63
54
|
/**
|
|
64
55
|
* Builds the internal graph representation (adjacency list) from the input network.
|
|
65
56
|
* Each LineString segment is translated into bidirectional graph edges with associated distances.
|
|
66
|
-
* Assumes that the network is a connected graph of LineStrings with shared coordinates.
|
|
57
|
+
* Assumes that the network is a connected graph of LineStrings with shared coordinates. Calling this
|
|
58
|
+
* method with a new network overwrite any existing network and reset all internal data structures.
|
|
59
|
+
*
|
|
60
|
+
* @param network - A GeoJSON FeatureCollection of LineStrings representing the road network.
|
|
67
61
|
*/
|
|
68
|
-
|
|
62
|
+
public buildRouteGraph(network: FeatureCollection<LineString>): void {
|
|
63
|
+
this.network = network;
|
|
64
|
+
this.adjacencyList = new Map();
|
|
65
|
+
this.coords = [];
|
|
66
|
+
this.coordMap = new Map();
|
|
67
|
+
|
|
69
68
|
for (const feature of this.network.features) {
|
|
70
69
|
const coords = feature.geometry.coordinates;
|
|
71
70
|
for (let i = 0; i < coords.length - 1; i++) {
|
|
@@ -88,11 +87,21 @@ class TerraRoute {
|
|
|
88
87
|
* @param start - A GeoJSON Point Feature representing the start location.
|
|
89
88
|
* @param end - A GeoJSON Point Feature representing the end location.
|
|
90
89
|
* @returns A GeoJSON LineString Feature representing the shortest path, or null if no path is found.
|
|
90
|
+
*
|
|
91
|
+
* @throws Error if the network has not been built yet with buildRouteGraph(network).
|
|
91
92
|
*/
|
|
92
93
|
public getRoute(start: Feature<Point>, end: Feature<Point>): Feature<LineString> | null {
|
|
94
|
+
if (!this.network) {
|
|
95
|
+
throw new Error("Network not built. Please call buildNetworkGraph(network) first.");
|
|
96
|
+
}
|
|
97
|
+
|
|
93
98
|
const startIdx = this.coordinateIndex(start.geometry.coordinates);
|
|
94
99
|
const endIdx = this.coordinateIndex(end.geometry.coordinates);
|
|
95
100
|
|
|
101
|
+
if (startIdx === endIdx) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
96
105
|
const openSet = new MinHeap();
|
|
97
106
|
openSet.insert(0, startIdx);
|
|
98
107
|
const cameFrom = new Map<number, number>();
|
|
@@ -115,7 +124,9 @@ class TerraRoute {
|
|
|
115
124
|
};
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
|
|
127
|
+
const neighbors = this.adjacencyList.get(current) || [];
|
|
128
|
+
|
|
129
|
+
for (const neighbor of neighbors) {
|
|
119
130
|
const tentativeGScore = (gScore.get(current) ?? Infinity) + neighbor.distance;
|
|
120
131
|
if (tentativeGScore < (gScore.get(neighbor.node) ?? Infinity)) {
|
|
121
132
|
cameFrom.set(neighbor.node, current);
|
|
@@ -22,3 +22,372 @@ export const createLineStringFeature = (coordinates: Position[]): Feature<LineSt
|
|
|
22
22
|
},
|
|
23
23
|
properties: {},
|
|
24
24
|
});
|
|
25
|
+
|
|
26
|
+
export function generateGridWithDiagonals(n: number, spacing: number): FeatureCollection<LineString> {
|
|
27
|
+
const features: Feature<LineString>[] = [];
|
|
28
|
+
|
|
29
|
+
const coord = (x: number, y: number): Position => [x * spacing, y * spacing];
|
|
30
|
+
|
|
31
|
+
for (let y = 0; y < n; y++) {
|
|
32
|
+
for (let x = 0; x < n; x++) {
|
|
33
|
+
// Horizontal edge (to the right)
|
|
34
|
+
if (x < n - 1) {
|
|
35
|
+
features.push({
|
|
36
|
+
type: "Feature",
|
|
37
|
+
geometry: {
|
|
38
|
+
type: "LineString",
|
|
39
|
+
coordinates: [
|
|
40
|
+
coord(x, y),
|
|
41
|
+
coord(x + 1, y)
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
properties: {}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Vertical edge (upward)
|
|
49
|
+
if (y < n - 1) {
|
|
50
|
+
features.push({
|
|
51
|
+
type: "Feature",
|
|
52
|
+
geometry: {
|
|
53
|
+
type: "LineString",
|
|
54
|
+
coordinates: [
|
|
55
|
+
coord(x, y),
|
|
56
|
+
coord(x, y + 1)
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
properties: {}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Diagonal bottom-left to top-right
|
|
64
|
+
if (x < n - 1 && y < n - 1) {
|
|
65
|
+
features.push({
|
|
66
|
+
type: "Feature",
|
|
67
|
+
geometry: {
|
|
68
|
+
type: "LineString",
|
|
69
|
+
coordinates: [
|
|
70
|
+
coord(x, y),
|
|
71
|
+
coord(x + 1, y + 1)
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
properties: {}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Diagonal bottom-right to top-left
|
|
79
|
+
if (x > 0 && y < n - 1) {
|
|
80
|
+
features.push({
|
|
81
|
+
type: "Feature",
|
|
82
|
+
geometry: {
|
|
83
|
+
type: "LineString",
|
|
84
|
+
coordinates: [
|
|
85
|
+
coord(x, y),
|
|
86
|
+
coord(x - 1, y + 1)
|
|
87
|
+
]
|
|
88
|
+
},
|
|
89
|
+
properties: {}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
type: "FeatureCollection",
|
|
97
|
+
features
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generate a star-like polygon with n vertices.
|
|
103
|
+
* If connectAll is true, connects every vertex to every other (complete graph).
|
|
104
|
+
* If false, connects only the outer ring to form a polygon perimeter.
|
|
105
|
+
*
|
|
106
|
+
* @param n - Number of vertices (>= 3)
|
|
107
|
+
* @param radius - Radius in degrees for placing vertices in a circle
|
|
108
|
+
* @param center - Center of the polygon [lng, lat]
|
|
109
|
+
* @param connectAll - If true, connects every pair of vertices. If false, only connects the outer ring.
|
|
110
|
+
* @returns FeatureCollection of LineStrings
|
|
111
|
+
*/
|
|
112
|
+
export function generateStarPolygon(
|
|
113
|
+
n: number,
|
|
114
|
+
radius: number = 0.01,
|
|
115
|
+
center: Position = [0, 0],
|
|
116
|
+
connectAll: boolean = true
|
|
117
|
+
): FeatureCollection<LineString> {
|
|
118
|
+
if (n < 3) {
|
|
119
|
+
throw new Error("Star polygon requires at least 3 vertices.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const angleStep = (2 * Math.PI) / n;
|
|
123
|
+
const vertices: Position[] = [];
|
|
124
|
+
|
|
125
|
+
// Generate points in a circle
|
|
126
|
+
for (let i = 0; i < n; i++) {
|
|
127
|
+
const angle = i * angleStep;
|
|
128
|
+
const x = center[0] + radius * Math.cos(angle);
|
|
129
|
+
const y = center[1] + radius * Math.sin(angle);
|
|
130
|
+
vertices.push([x, y]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const features: Feature<LineString>[] = [];
|
|
134
|
+
|
|
135
|
+
if (connectAll) {
|
|
136
|
+
// Connect every vertex to every other vertex
|
|
137
|
+
for (let i = 0; i < n; i++) {
|
|
138
|
+
for (let j = i + 1; j < n; j++) {
|
|
139
|
+
features.push({
|
|
140
|
+
type: "Feature",
|
|
141
|
+
geometry: {
|
|
142
|
+
type: "LineString",
|
|
143
|
+
coordinates: [vertices[i], vertices[j]],
|
|
144
|
+
},
|
|
145
|
+
properties: {},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
// Connect outer ring only
|
|
151
|
+
for (let i = 0; i < n; i++) {
|
|
152
|
+
const next = (i + 1) % n;
|
|
153
|
+
features.push({
|
|
154
|
+
type: "Feature",
|
|
155
|
+
geometry: {
|
|
156
|
+
type: "LineString",
|
|
157
|
+
coordinates: [vertices[i], vertices[next]],
|
|
158
|
+
},
|
|
159
|
+
properties: {},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
type: "FeatureCollection",
|
|
166
|
+
features,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extracts unique coordinates from a FeatureCollection of LineStrings.
|
|
173
|
+
*
|
|
174
|
+
* @param collection - A GeoJSON FeatureCollection of LineStrings
|
|
175
|
+
* @returns An array of unique Position coordinates
|
|
176
|
+
*/
|
|
177
|
+
export function getUniqueCoordinatesFromLineStrings(
|
|
178
|
+
collection: FeatureCollection<LineString>
|
|
179
|
+
): Position[] {
|
|
180
|
+
const seen = new Set<string>();
|
|
181
|
+
const unique: Position[] = [];
|
|
182
|
+
|
|
183
|
+
for (const feature of collection.features) {
|
|
184
|
+
if (feature.geometry.type !== "LineString") {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const coord of feature.geometry.coordinates) {
|
|
189
|
+
const key = `${coord[0]},${coord[1]}`;
|
|
190
|
+
|
|
191
|
+
if (!seen.has(key)) {
|
|
192
|
+
seen.add(key);
|
|
193
|
+
unique.push(coord);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return unique;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Generate a spatial n-depth tree as a FeatureCollection<LineString>.
|
|
204
|
+
*
|
|
205
|
+
* @param depth - Number of depth levels (>= 1)
|
|
206
|
+
* @param branchingFactor - Number of children per node
|
|
207
|
+
* @param root - Root position [lng, lat]
|
|
208
|
+
* @param length - Distance between each parent and child
|
|
209
|
+
* @returns FeatureCollection of LineStrings representing the tree
|
|
210
|
+
*/
|
|
211
|
+
export function generateTreeFeatureCollection(
|
|
212
|
+
depth: number,
|
|
213
|
+
branchingFactor: number,
|
|
214
|
+
root: Position = [0, 0],
|
|
215
|
+
length: number = 0.01
|
|
216
|
+
): FeatureCollection<LineString> {
|
|
217
|
+
if (depth < 1) {
|
|
218
|
+
throw new Error("Tree must have at least depth 1.");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const features: Feature<LineString>[] = [];
|
|
222
|
+
|
|
223
|
+
interface TreeNode {
|
|
224
|
+
position: Position;
|
|
225
|
+
level: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const nodes: TreeNode[] = [{ position: root, level: 0 }];
|
|
229
|
+
|
|
230
|
+
const RAD = Math.PI / 180;
|
|
231
|
+
|
|
232
|
+
for (let level = 0; level < depth; level++) {
|
|
233
|
+
const newNodes: TreeNode[] = [];
|
|
234
|
+
|
|
235
|
+
for (const node of nodes.filter(n => n.level === level)) {
|
|
236
|
+
const angleStart = -90 - ((branchingFactor - 1) * 20) / 2;
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < branchingFactor; i++) {
|
|
239
|
+
const angle = angleStart + i * 20; // spread branches 20 degrees apart
|
|
240
|
+
const radians = angle * RAD;
|
|
241
|
+
|
|
242
|
+
const dx = length * Math.cos(radians);
|
|
243
|
+
const dy = length * Math.sin(radians);
|
|
244
|
+
|
|
245
|
+
const child: Position = [node.position[0] + dx, node.position[1] + dy];
|
|
246
|
+
|
|
247
|
+
features.push({
|
|
248
|
+
type: "Feature",
|
|
249
|
+
geometry: {
|
|
250
|
+
type: "LineString",
|
|
251
|
+
coordinates: [node.position, child],
|
|
252
|
+
},
|
|
253
|
+
properties: {},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
newNodes.push({ position: child, level: level + 1 });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
nodes.push(...newNodes);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
type: "FeatureCollection",
|
|
265
|
+
features,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Generates a connected graph of concentric rings, each ring fully connected
|
|
271
|
+
* around itself and connected radially to the next ring.
|
|
272
|
+
*
|
|
273
|
+
* @param numRings - Number of concentric rings
|
|
274
|
+
* @param pointsPerRing - How many points (nodes) on each ring
|
|
275
|
+
* @param spacing - Distance between consecutive rings
|
|
276
|
+
* @param center - [lng, lat] center of the rings
|
|
277
|
+
* @returns A FeatureCollection of LineStrings for the rings + radial connections
|
|
278
|
+
*/
|
|
279
|
+
export function generateConcentricRings(
|
|
280
|
+
numRings: number,
|
|
281
|
+
pointsPerRing: number,
|
|
282
|
+
spacing: number,
|
|
283
|
+
center: Position = [0, 0]
|
|
284
|
+
): FeatureCollection<LineString> {
|
|
285
|
+
// Holds all the ring coordinates: ringPoints[i][j] => coordinate
|
|
286
|
+
const ringPoints: Position[][] = [];
|
|
287
|
+
|
|
288
|
+
// Create ring points
|
|
289
|
+
for (let i = 0; i < numRings; i++) {
|
|
290
|
+
const ringRadius = (i + 1) * spacing;
|
|
291
|
+
const ring: Position[] = [];
|
|
292
|
+
|
|
293
|
+
for (let j = 0; j < pointsPerRing; j++) {
|
|
294
|
+
const angle = (2 * Math.PI * j) / pointsPerRing;
|
|
295
|
+
const x = center[0] + ringRadius * Math.cos(angle);
|
|
296
|
+
const y = center[1] + ringRadius * Math.sin(angle);
|
|
297
|
+
ring.push([x, y]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
ringPoints.push(ring);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Build the graph as a collection of LineStrings
|
|
304
|
+
const features: Feature<LineString>[] = [];
|
|
305
|
+
|
|
306
|
+
// 1. Add each ring as a closed loop
|
|
307
|
+
for (let i = 0; i < numRings; i++) {
|
|
308
|
+
const coords = ringPoints[i];
|
|
309
|
+
// Close the ring by appending the first point again
|
|
310
|
+
const ringWithClosure = [...coords, coords[0]];
|
|
311
|
+
|
|
312
|
+
features.push({
|
|
313
|
+
type: "Feature",
|
|
314
|
+
properties: {},
|
|
315
|
+
geometry: {
|
|
316
|
+
type: "LineString",
|
|
317
|
+
coordinates: ringWithClosure,
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// 2. Connect rings radially
|
|
323
|
+
// (i.e., ring i node j to ring i+1 node j)
|
|
324
|
+
for (let i = 0; i < numRings - 1; i++) {
|
|
325
|
+
for (let j = 0; j < pointsPerRing; j++) {
|
|
326
|
+
const start = ringPoints[i][j];
|
|
327
|
+
const end = ringPoints[i + 1][j];
|
|
328
|
+
|
|
329
|
+
features.push({
|
|
330
|
+
type: "Feature",
|
|
331
|
+
properties: {},
|
|
332
|
+
geometry: {
|
|
333
|
+
type: "LineString",
|
|
334
|
+
coordinates: [start, end],
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
type: "FeatureCollection",
|
|
342
|
+
features,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Validates a GeoJSON Feature<LineString> route.
|
|
349
|
+
*
|
|
350
|
+
* @param route - The GeoJSON feature to validate
|
|
351
|
+
* @returns A boolean indicating if it is a valid LineString route
|
|
352
|
+
*/
|
|
353
|
+
export function getReasonIfLineStringInvalid(
|
|
354
|
+
route: Feature<LineString> | null | undefined
|
|
355
|
+
): string | undefined {
|
|
356
|
+
// 1. Must exist
|
|
357
|
+
if (!route) {
|
|
358
|
+
return 'No feature';
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// 2. Must be a Feature
|
|
362
|
+
if (route.type !== "Feature") {
|
|
363
|
+
return 'Not a Feature';
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 3. Must have a geometry of type LineString
|
|
367
|
+
if (!route.geometry || route.geometry.type !== "LineString") {
|
|
368
|
+
return 'Not a LineString';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 4. Coordinates must be an array with length >= 2
|
|
372
|
+
const coords = route.geometry.coordinates;
|
|
373
|
+
if (!Array.isArray(coords) || coords.length < 2) {
|
|
374
|
+
return `Not enough coordinates: ${coords.length} (${coords})`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// 5. Validate each coordinate is a valid Position
|
|
378
|
+
// (At minimum, [number, number] or [number, number, number])
|
|
379
|
+
for (const position of coords) {
|
|
380
|
+
if (!Array.isArray(position)) {
|
|
381
|
+
return 'Not a Position; not an array';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check numeric values, ignoring optional altitude
|
|
385
|
+
if (
|
|
386
|
+
position.length < 2 ||
|
|
387
|
+
typeof position[0] !== "number" ||
|
|
388
|
+
typeof position[1] !== "number"
|
|
389
|
+
) {
|
|
390
|
+
return 'Not a Position; elements are not a numbers';
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|