terra-route 0.0.3 → 0.0.5
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/terra-route.cjs +1 -1
- package/dist/terra-route.cjs.map +1 -1
- package/dist/terra-route.d.ts +8 -4
- 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/eslint.config.mjs +54 -0
- package/package.json +88 -105
- package/src/min-heap.ts +37 -25
- package/src/terra-route.spec.ts +249 -14
- package/src/terra-route.ts +110 -55
- package/src/test-utils/test-utils.ts +369 -0
|
@@ -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
|
+
}
|