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.
@@ -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
- private buildNetworkGraph(): void {
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
- for (const neighbor of this.adjacencyList.get(current) || []) {
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
+ }