terra-route 0.0.8 → 0.0.10

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 (58) hide show
  1. package/README.md +39 -58
  2. package/assets/logo-dark-mode.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/dist/graph/graph.d.ts +114 -0
  5. package/dist/graph/methods/bounding-box.d.ts +13 -0
  6. package/dist/graph/methods/connected.d.ts +9 -0
  7. package/dist/graph/methods/duplicates.d.ts +7 -0
  8. package/dist/graph/methods/leaf.d.ts +12 -0
  9. package/dist/graph/methods/nodes.d.ts +17 -0
  10. package/dist/graph/methods/spatial-index/geokdbush.d.ts +3 -0
  11. package/dist/graph/methods/spatial-index/kdbush.d.ts +16 -0
  12. package/dist/graph/methods/spatial-index/tinyqueue.d.ts +11 -0
  13. package/dist/graph/methods/unify.d.ts +2 -0
  14. package/dist/graph/methods/unique-segments.d.ts +5 -0
  15. package/dist/graph/methods/unique.d.ts +5 -0
  16. package/dist/terra-route.cjs +1 -1
  17. package/dist/terra-route.cjs.map +1 -1
  18. package/dist/terra-route.d.ts +2 -1
  19. package/dist/terra-route.modern.js +1 -1
  20. package/dist/terra-route.modern.js.map +1 -1
  21. package/dist/terra-route.module.js +1 -1
  22. package/dist/terra-route.module.js.map +1 -1
  23. package/dist/terra-route.umd.js +1 -1
  24. package/dist/terra-route.umd.js.map +1 -1
  25. package/dist/test-utils/utils.d.ts +50 -0
  26. package/jest.config.js +1 -0
  27. package/package.json +7 -3
  28. package/src/data/network-5-cc.geojson +822 -0
  29. package/src/data/network.geojson +21910 -820
  30. package/src/distance/haversine.ts +2 -0
  31. package/src/graph/graph.spec.ts +238 -0
  32. package/src/graph/graph.ts +212 -0
  33. package/src/graph/methods/bounding-box.spec.ts +199 -0
  34. package/src/graph/methods/bounding-box.ts +85 -0
  35. package/src/graph/methods/connected.spec.ts +219 -0
  36. package/src/graph/methods/connected.ts +168 -0
  37. package/src/graph/methods/duplicates.spec.ts +161 -0
  38. package/src/graph/methods/duplicates.ts +117 -0
  39. package/src/graph/methods/leaf.spec.ts +224 -0
  40. package/src/graph/methods/leaf.ts +88 -0
  41. package/src/graph/methods/nodes.spec.ts +317 -0
  42. package/src/graph/methods/nodes.ts +77 -0
  43. package/src/graph/methods/spatial-index/geokdbush.spec.ts +86 -0
  44. package/src/graph/methods/spatial-index/geokdbush.ts +189 -0
  45. package/src/graph/methods/spatial-index/kdbush.spec.ts +67 -0
  46. package/src/graph/methods/spatial-index/kdbush.ts +189 -0
  47. package/src/graph/methods/spatial-index/tinyqueue.spec.ts +51 -0
  48. package/src/graph/methods/spatial-index/tinyqueue.ts +108 -0
  49. package/src/graph/methods/unify.spec.ts +475 -0
  50. package/src/graph/methods/unify.ts +132 -0
  51. package/src/graph/methods/unique.spec.ts +65 -0
  52. package/src/graph/methods/unique.ts +69 -0
  53. package/src/terra-route.compare.spec.ts +3 -1
  54. package/src/terra-route.spec.ts +12 -1
  55. package/src/terra-route.ts +2 -1
  56. package/src/test-utils/create.ts +39 -0
  57. package/src/test-utils/{test-utils.ts → generate-network.ts} +9 -188
  58. package/src/test-utils/utils.ts +228 -0
@@ -0,0 +1,475 @@
1
+ import { FeatureCollection, LineString } from 'geojson';
2
+ import { unifyCloseCoordinates } from './unify';
3
+ import { getReasonIfLineStringInvalid } from '../../test-utils/utils';
4
+ import { graphGetConnectedComponentCount } from './connected';
5
+ import { graphGetNodeAndEdgeCount } from './nodes';
6
+ import { createFeatureCollection, createLineStringFeature } from '../../test-utils/create';
7
+
8
+ describe('unifyCloseCoordinates', () => {
9
+ describe('for an empty feature collection', () => {
10
+ it('returns an empty feature collection', () => {
11
+ const input: FeatureCollection<LineString> = {
12
+ type: 'FeatureCollection',
13
+ features: []
14
+ };
15
+ const radiusMeters = 2; // meters
16
+ const output = unifyCloseCoordinates(input, radiusMeters);
17
+
18
+ expect(output).toEqual({
19
+ type: 'FeatureCollection',
20
+ features: []
21
+ });
22
+ });
23
+ });
24
+
25
+ describe('for a single linestring', () => {
26
+ it('does not modify coordinates', () => {
27
+ const input: FeatureCollection<LineString> = createFeatureCollection([
28
+ createLineStringFeature([
29
+ [0, 0],
30
+ [0.00001, 0.00001], // ~1.5m apart
31
+ [0.00002, 0.00002] // ~1.5m apart
32
+ ]),
33
+ ]);
34
+
35
+ const radiusMeters = 2; // meters
36
+ const output = unifyCloseCoordinates(input, radiusMeters);
37
+ const coords = output.features[0].geometry.coordinates;
38
+
39
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
40
+
41
+ expect(coords).toHaveLength(3); // Should unify to a single coordinate
42
+ expect(coords[0]).toEqual([0, 0]); // Both coordinates should unify to the first one
43
+ expect(coords[1]).toEqual([0.00001, 0.00001]); // Both coordinates should unify to the first one
44
+ expect(coords[2]).toEqual([0.00002, 0.00002]); // Both coordinates should unify to the first one
45
+
46
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
47
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 3, edgeCount: 2 });
48
+ });
49
+ });
50
+
51
+ describe('for two linestrings', () => {
52
+ it('does nothing if linestrings are already connected', () => {
53
+ const input: FeatureCollection<LineString> = createFeatureCollection([
54
+ createLineStringFeature([
55
+ [0, 0],
56
+ [0.00001, 0.00001] // ~1.5m apart
57
+ ]),
58
+ createLineStringFeature([
59
+ [0.00001, 0.00001],
60
+ [0.00002, 0.00002] // ~1.5m apart
61
+ ]),
62
+ ]);
63
+
64
+ const radiusMeters = 2; // meters
65
+ const output = unifyCloseCoordinates(input, radiusMeters);
66
+
67
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
68
+ const coords = output.features[0].geometry.coordinates;
69
+ expect(coords).toHaveLength(2);
70
+ expect(coords[0]).toEqual([0, 0]);
71
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
72
+
73
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
74
+ const coordsTwo = output.features[1].geometry.coordinates;
75
+ expect(coordsTwo).toHaveLength(2);
76
+ expect(coordsTwo[0]).toEqual([0.00001, 0.00001]);
77
+ expect(coordsTwo[1]).toEqual([0.00002, 0.00002]);
78
+
79
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
80
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 3, edgeCount: 2 });
81
+ });
82
+
83
+ it('unifies two nearby linestring coordinates if they are within tolerance', () => {
84
+ const input: FeatureCollection<LineString> = createFeatureCollection([
85
+ createLineStringFeature([
86
+ [0, 0],
87
+ [0.00001, 0.00001] // ~1.5m apart
88
+ ]),
89
+ createLineStringFeature([
90
+ [0.00002, 0.00002],
91
+ [0.00003, 0.00003] // ~1.5m apart
92
+ ]),
93
+ ]);
94
+
95
+ const radiusMeters = 2; // meters
96
+ const output = unifyCloseCoordinates(input, radiusMeters);
97
+
98
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
99
+ const coords = output.features[0].geometry.coordinates;
100
+ expect(coords).toHaveLength(2);
101
+ expect(coords[0]).toEqual([0, 0]);
102
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
103
+
104
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
105
+ const coordsTwo = output.features[1].geometry.coordinates;
106
+ expect(coordsTwo).toHaveLength(2);
107
+ expect(coordsTwo[0]).toEqual([0.00001, 0.00001]);
108
+ expect(coordsTwo[1]).toEqual([0.00003, 0.00003]);
109
+
110
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
111
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 3, edgeCount: 2 });
112
+ });
113
+
114
+ it('does not unify coordinates if they are not within tolerance', () => {
115
+ const input: FeatureCollection<LineString> = createFeatureCollection([
116
+ createLineStringFeature([
117
+ [0, 0],
118
+ [1, 1]
119
+ ]),
120
+ createLineStringFeature([
121
+ [10, 10],
122
+ [11, 11]
123
+ ]),
124
+ ]);
125
+
126
+ const radiusMeters = 2; // meters
127
+ const output = unifyCloseCoordinates(input, radiusMeters);
128
+
129
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
130
+ const coords = output.features[0].geometry.coordinates;
131
+ expect(coords).toHaveLength(2);
132
+ expect(coords[0]).toEqual([0, 0]);
133
+ expect(coords[1]).toEqual([1, 1]);
134
+
135
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
136
+ const coordsTwo = output.features[1].geometry.coordinates;
137
+ expect(coordsTwo).toHaveLength(2);
138
+ expect(coordsTwo[0]).toEqual([10, 10]);
139
+ expect(coordsTwo[1]).toEqual([11, 11]);
140
+
141
+ expect(graphGetConnectedComponentCount(output)).toBe(2);
142
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 4, edgeCount: 2 });
143
+ });
144
+
145
+ it('does unify both coordinates to corresponding coordinates in counterpart linestring if they are within tolerance', () => {
146
+ const input: FeatureCollection<LineString> = createFeatureCollection([
147
+ createLineStringFeature([
148
+ [0, 0],
149
+ [1, 1]
150
+ ]),
151
+ createLineStringFeature([
152
+ [0.000001, 0.000001],
153
+ [1.000001, 1.000001]
154
+ ]),
155
+ ]);
156
+
157
+ const radiusMeters = 2; // meters
158
+ const output = unifyCloseCoordinates(input, radiusMeters);
159
+
160
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
161
+ const coords = output.features[0].geometry.coordinates;
162
+ expect(coords).toHaveLength(2);
163
+ expect(coords[0]).toEqual([0, 0]);
164
+ expect(coords[1]).toEqual([1, 1]);
165
+
166
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
167
+ const coordsTwo = output.features[1].geometry.coordinates;
168
+ expect(coordsTwo).toHaveLength(2);
169
+ expect(coordsTwo[0]).toEqual([0, 0]);
170
+ expect(coordsTwo[1]).toEqual([1, 1]);
171
+
172
+ // Only one because lines are now identical and connected
173
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
174
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 2, edgeCount: 1 });
175
+ });
176
+ });
177
+
178
+ describe('for multiple linestrings', () => {
179
+ it('does nothing if linestrings already connected', () => {
180
+ const input: FeatureCollection<LineString> = createFeatureCollection([
181
+ createLineStringFeature([
182
+ [0, 0],
183
+ [0.00001, 0.00001] // ~1.5m apart
184
+ ]),
185
+ createLineStringFeature([
186
+ [0.00001, 0.00001],
187
+ [0.00002, 0.00002] // ~1.5m apart
188
+ ]),
189
+ createLineStringFeature([
190
+ [0.00002, 0.00002],
191
+ [0.00003, 0.00003] // ~1.5m apart
192
+ ]),
193
+ ]);
194
+
195
+ const radiusMeters = 2; // meters
196
+ const output = unifyCloseCoordinates(input, radiusMeters);
197
+
198
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
199
+ const coords = output.features[0].geometry.coordinates;
200
+ expect(coords).toHaveLength(2);
201
+ expect(coords[0]).toEqual([0, 0]);
202
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
203
+
204
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
205
+ const coordsTwo = output.features[1].geometry.coordinates;
206
+ expect(coordsTwo).toHaveLength(2);
207
+ expect(coordsTwo[0]).toEqual([0.00001, 0.00001]);
208
+ expect(coordsTwo[1]).toEqual([0.00002, 0.00002]);
209
+
210
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
211
+ const coordsThree = output.features[2].geometry.coordinates;
212
+ expect(coordsThree).toHaveLength(2);
213
+ expect(coordsThree[0]).toEqual([0.00002, 0.00002]);
214
+ expect(coordsThree[1]).toEqual([0.00003, 0.00003]);
215
+
216
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
217
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 4, edgeCount: 3 });
218
+ });
219
+
220
+ it('does not unify any linestrings coordinates if they are not within tolerance', () => {
221
+ const input: FeatureCollection<LineString> = createFeatureCollection([
222
+ createLineStringFeature([
223
+ [0, 0],
224
+ [0.00001, 0.00001] // ~1.5m apart
225
+ ]),
226
+ createLineStringFeature([
227
+ [0.00002, 0.00002],
228
+ [0.00003, 0.00003] // ~1.5m apart
229
+ ]),
230
+ createLineStringFeature([
231
+ [0.00004, 0.00004],
232
+ [0.00005, 0.00005] // ~1.5m apart
233
+ ]),
234
+ ]);
235
+
236
+ // Note the reduced radiusMeters
237
+ const radiusMeters = 1; // meters
238
+ const output = unifyCloseCoordinates(input, radiusMeters);
239
+
240
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
241
+ const coords = output.features[0].geometry.coordinates;
242
+ expect(coords).toHaveLength(2);
243
+ expect(coords[0]).toEqual([0, 0]);
244
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
245
+
246
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
247
+ const coordsTwo = output.features[1].geometry.coordinates;
248
+ expect(coordsTwo).toHaveLength(2);
249
+ expect(coordsTwo[0]).toEqual([0.00002, 0.00002]);
250
+ expect(coordsTwo[1]).toEqual([0.00003, 0.00003]);
251
+
252
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
253
+ const coordsThree = output.features[2].geometry.coordinates;
254
+ expect(coordsThree).toHaveLength(2);
255
+ expect(coordsThree[0]).toEqual([0.00004, 0.00004]);
256
+ expect(coordsThree[1]).toEqual([0.00005, 0.00005]);
257
+
258
+ expect(graphGetConnectedComponentCount(output)).toBe(3);
259
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 6, edgeCount: 3 });
260
+ });
261
+
262
+ it('unifies linestrings coordinates where they are within tolerance', () => {
263
+ const input: FeatureCollection<LineString> = createFeatureCollection([
264
+ createLineStringFeature([
265
+ [0, 0],
266
+ [0.00001, 0.00001] // ~1.5m apart
267
+ ]),
268
+ createLineStringFeature([
269
+ [0.00002, 0.00002],
270
+ [0.00003, 0.00003] // ~1.5m apart
271
+ ]),
272
+ createLineStringFeature([
273
+ [0.00004, 0.00004],
274
+ [0.00005, 0.00005] // ~1.5m apart
275
+ ]),
276
+ ]);
277
+
278
+ const radiusMeters = 2; // meters
279
+ const output = unifyCloseCoordinates(input, radiusMeters);
280
+
281
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
282
+ const coords = output.features[0].geometry.coordinates;
283
+ expect(coords).toHaveLength(2);
284
+ expect(coords[0]).toEqual([0, 0]);
285
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
286
+
287
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
288
+ const coordsTwo = output.features[1].geometry.coordinates;
289
+ expect(coordsTwo).toHaveLength(2);
290
+ expect(coordsTwo[0]).toEqual([0.00001, 0.00001]);
291
+ expect(coordsTwo[1]).toEqual([0.00003, 0.00003]);
292
+
293
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
294
+ const coordsThree = output.features[2].geometry.coordinates;
295
+ expect(coordsThree).toHaveLength(2);
296
+ expect(coordsThree[0]).toEqual([0.00003, 0.00003]);
297
+ expect(coordsThree[1]).toEqual([0.00005, 0.00005]);
298
+
299
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
300
+ expect(graphGetNodeAndEdgeCount(output)).toEqual({ nodeCount: 4, edgeCount: 3 });
301
+
302
+ });
303
+
304
+ it('unifies closest coordinate of linestrings if they are within tolerance and there are multiple options', () => {
305
+ const input: FeatureCollection<LineString> = createFeatureCollection([
306
+ createLineStringFeature([
307
+ [0, 0],
308
+ [0.00001, 0.00001]
309
+ ]),
310
+ createLineStringFeature([
311
+ [0.000011, 0.000011],
312
+ [0.00003, 0.00003]
313
+ ]),
314
+ createLineStringFeature([
315
+ [0.000012, 0.000012],
316
+ [0.00005, 0.00005]
317
+ ]),
318
+ ]);
319
+
320
+ const radiusMeters = 2; // meters
321
+ const output = unifyCloseCoordinates(input, radiusMeters);
322
+
323
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
324
+ const coords = output.features[0].geometry.coordinates;
325
+ expect(coords).toHaveLength(2);
326
+ expect(coords[0]).toEqual([0, 0]);
327
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
328
+
329
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
330
+ const coordsTwo = output.features[1].geometry.coordinates;
331
+ expect(coordsTwo).toHaveLength(2);
332
+ expect(coordsTwo[0]).toEqual([0.00001, 0.00001]);
333
+ expect(coordsTwo[1]).toEqual([0.00003, 0.00003]);
334
+
335
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
336
+ const coordsThree = output.features[2].geometry.coordinates;
337
+ expect(coordsThree).toHaveLength(2);
338
+ expect(coordsThree[0]).toEqual([0.00001, 0.00001]);
339
+ expect(coordsThree[1]).toEqual([0.00005, 0.00005]);
340
+
341
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
342
+ });
343
+
344
+ it('unifies first coordinate where they are within tolerance', () => {
345
+ const input: FeatureCollection<LineString> = createFeatureCollection([
346
+ createLineStringFeature([
347
+ [0, 0],
348
+ [0.00001, 0.00001]
349
+ ]),
350
+ // Going off in a different direction
351
+ createLineStringFeature([
352
+ [0.000001, 0.000001],
353
+ [-0.00001, -0.00001]
354
+ ]),
355
+ createLineStringFeature([
356
+ [0.0000011, 0.0000011],
357
+ [-0.00003, -0.00003]
358
+ ]),
359
+ ]);
360
+
361
+ const radiusMeters = 2; // meters
362
+ const output = unifyCloseCoordinates(input, radiusMeters);
363
+
364
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
365
+ const coords = output.features[0].geometry.coordinates;
366
+ expect(coords).toHaveLength(2);
367
+ expect(coords[0]).toEqual([0, 0]);
368
+ expect(coords[1]).toEqual([0.00001, 0.00001]);
369
+
370
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
371
+ const coordsTwo = output.features[1].geometry.coordinates;
372
+ expect(coordsTwo).toHaveLength(2);
373
+ expect(coordsTwo[0]).toEqual([0, 0]);
374
+ expect(coordsTwo[1]).toEqual([-0.00001, -0.00001]);
375
+
376
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
377
+ const coordsThree = output.features[2].geometry.coordinates;
378
+ expect(coordsThree).toHaveLength(2);
379
+ expect(coordsThree[0]).toEqual([0, 0]);
380
+ expect(coordsThree[1]).toEqual([-0.00003, -0.00003]);
381
+
382
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
383
+ });
384
+
385
+ it('unifies last coordinate where they are within tolerance', () => {
386
+ const input: FeatureCollection<LineString> = createFeatureCollection([
387
+ createLineStringFeature([
388
+ [-3, -3],
389
+ [0, 0]
390
+ ]),
391
+ createLineStringFeature([
392
+ [-1, -1],
393
+ [0.000001, 0.000001]
394
+ ]),
395
+ createLineStringFeature([
396
+ [-2, -2],
397
+ [0.0000011, 0.0000011]
398
+ ]),
399
+ ]);
400
+
401
+ const radiusMeters = 2; // meters
402
+ const output = unifyCloseCoordinates(input, radiusMeters);
403
+
404
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
405
+ const coords = output.features[0].geometry.coordinates;
406
+ expect(coords).toHaveLength(2);
407
+ expect(coords[0]).toEqual([-3, -3]);
408
+ expect(coords[1]).toEqual([0, 0]);
409
+
410
+
411
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
412
+ const coordsTwo = output.features[1].geometry.coordinates;
413
+ expect(coordsTwo).toHaveLength(2);
414
+
415
+ expect(coordsTwo[0]).toEqual([-1, -1]);
416
+ expect(coordsTwo[1]).toEqual([0, 0]);
417
+
418
+
419
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
420
+ const coordsThree = output.features[2].geometry.coordinates;
421
+ expect(coordsThree).toHaveLength(2);
422
+ expect(coordsThree[0]).toEqual([-2, -2]);
423
+ expect(coordsThree[1]).toEqual([0, 0]);
424
+
425
+
426
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
427
+ });
428
+
429
+ it('unifies middle coordinate where they are within tolerance', () => {
430
+ const input: FeatureCollection<LineString> = createFeatureCollection([
431
+ createLineStringFeature([
432
+ [-1, -1],
433
+ [0, 0],
434
+ [1, 1]
435
+ ]),
436
+ createLineStringFeature([
437
+ [-2, -2],
438
+ [0.000001, 0.000001],
439
+ [2, 2]
440
+ ]),
441
+ createLineStringFeature([
442
+ [-3, -3],
443
+ [0.0000011, 0.0000011],
444
+ [3, 3]
445
+ ]),
446
+ ]);
447
+
448
+ const radiusMeters = 2; // meters
449
+ const output = unifyCloseCoordinates(input, radiusMeters);
450
+
451
+ expect(getReasonIfLineStringInvalid(output.features[0])).toBeUndefined();
452
+ const coords = output.features[0].geometry.coordinates;
453
+ expect(coords).toHaveLength(3);
454
+ expect(coords[0]).toEqual([-1, -1]);
455
+ expect(coords[1]).toEqual([0, 0]);
456
+ expect(coords[2]).toEqual([1, 1]);
457
+
458
+ expect(getReasonIfLineStringInvalid(output.features[1])).toBeUndefined();
459
+ const coordsTwo = output.features[1].geometry.coordinates;
460
+ expect(coordsTwo).toHaveLength(3);
461
+ expect(coordsTwo[0]).toEqual([-2, -2]);
462
+ expect(coordsTwo[1]).toEqual([0, 0]);
463
+ expect(coordsTwo[2]).toEqual([2, 2]);
464
+
465
+ expect(getReasonIfLineStringInvalid(output.features[2])).toBeUndefined();
466
+ const coordsThree = output.features[2].geometry.coordinates;
467
+ expect(coordsThree).toHaveLength(3);
468
+ expect(coordsThree[0]).toEqual([-3, -3]);
469
+ expect(coordsThree[1]).toEqual([0, 0]);
470
+ expect(coordsThree[2]).toEqual([3, 3]);
471
+
472
+ expect(graphGetConnectedComponentCount(output)).toBe(1);
473
+ });
474
+ });
475
+ });
@@ -0,0 +1,132 @@
1
+ import { FeatureCollection, LineString, Position } from 'geojson';
2
+ import { haversineDistance } from '../../distance/haversine';
3
+ import { KDBush } from './spatial-index/kdbush';
4
+ import { around } from './spatial-index/geokdbush';
5
+
6
+ export function unifyCloseCoordinates(
7
+ featureCollection: FeatureCollection<LineString>,
8
+ radiusMeters: number
9
+ ): FeatureCollection<LineString> {
10
+ if (featureCollection.features.length < 2) {
11
+ return featureCollection; // No features to process
12
+ }
13
+
14
+ const globalSeen: Position[] = [];
15
+ const globalSeenSet = new Set<string>(); // Fast O(1) lookup for globalSeen
16
+ const mapping: Map<string, Position> = new Map();
17
+
18
+ // Build a one-time spatial index with all coordinates for efficient querying
19
+ const allCoords: Position[] = [];
20
+ const coordToGlobalIndex = new Map<string, number>();
21
+
22
+ // Collect all unique coordinates first
23
+ for (const feature of featureCollection.features) {
24
+ for (const coord of feature.geometry.coordinates) {
25
+ const key = `${coord[0]},${coord[1]}`; // Faster than join
26
+ if (!coordToGlobalIndex.has(key)) {
27
+ coordToGlobalIndex.set(key, allCoords.length);
28
+ allCoords.push(coord);
29
+ }
30
+ }
31
+ }
32
+
33
+ // Build spatial index once for all coordinates
34
+ let spatialIndex: KDBush = new KDBush(allCoords.length);
35
+ for (const coord of allCoords) {
36
+ spatialIndex.add(coord[0], coord[1]); // lng, lat
37
+ }
38
+ spatialIndex.finish();
39
+
40
+ function findOrRegisterCoordinate(
41
+ coordinate: Position,
42
+ exclude: Position[],
43
+ futureConflictCheck: Set<string>
44
+ ): Position {
45
+ let closest: Position | null = null;
46
+ let closestDistance = Infinity;
47
+
48
+ // if (globalSeen.length > 5) {
49
+ // Use spatial index for efficient querying
50
+ const radiusKm = radiusMeters / 1000; // Convert meters to kilometers
51
+ const candidateIndices = around(spatialIndex, coordinate[0], coordinate[1], Infinity, radiusKm);
52
+
53
+ for (const candidateIndex of candidateIndices) {
54
+ const candidateCoord = allCoords[candidateIndex];
55
+ const candidateKey = `${candidateCoord[0]},${candidateCoord[1]}`;
56
+
57
+ // Only consider coordinates that are in globalSeen (O(1) lookup)
58
+ if (!globalSeenSet.has(candidateKey)) {
59
+ continue;
60
+ }
61
+
62
+ if (exclude.includes(candidateCoord)) {
63
+ continue;
64
+ }
65
+
66
+ if (futureConflictCheck.has(candidateKey)) {
67
+ continue;
68
+ }
69
+
70
+ const distance = haversineDistance(candidateCoord, coordinate) * 1000; // Convert to meters
71
+
72
+ if (distance <= radiusMeters && distance < closestDistance) {
73
+ closest = candidateCoord;
74
+ closestDistance = distance;
75
+ }
76
+ }
77
+
78
+
79
+ if (closest !== null) {
80
+ return closest;
81
+ }
82
+
83
+ globalSeen.push(coordinate);
84
+ globalSeenSet.add(`${coordinate[0]},${coordinate[1]}`);
85
+ return coordinate;
86
+ }
87
+
88
+ const updatedFeatures = featureCollection.features.map((feature) => {
89
+ const localSeen: Position[] = [];
90
+ const updatedCoordinates: Position[] = [];
91
+ const usedKeys = new Set<string>();
92
+
93
+ for (const coordinate of feature.geometry.coordinates) {
94
+ const key = `${coordinate[0]},${coordinate[1]}`;
95
+
96
+ if (!mapping.has(key)) {
97
+ const unified = findOrRegisterCoordinate(coordinate, localSeen, usedKeys);
98
+ const unifiedKey = `${unified[0]},${unified[1]}`;
99
+
100
+ // Avoid inserting a coordinate if it's going to cause duplication later in this feature
101
+ if (usedKeys.has(unifiedKey)) {
102
+ mapping.set(key, coordinate);
103
+ } else {
104
+ mapping.set(key, unified);
105
+ }
106
+ }
107
+
108
+ const unifiedCoordinate = mapping.get(key)!;
109
+ const unifiedKey = `${unifiedCoordinate[0]},${unifiedCoordinate[1]}`;
110
+
111
+ if (!usedKeys.has(unifiedKey)) {
112
+ updatedCoordinates.push(unifiedCoordinate);
113
+ usedKeys.add(unifiedKey);
114
+ }
115
+
116
+ localSeen.push(coordinate);
117
+ }
118
+
119
+ return {
120
+ ...feature,
121
+ geometry: {
122
+ ...feature.geometry,
123
+ coordinates: updatedCoordinates
124
+ }
125
+ };
126
+ });
127
+
128
+ return {
129
+ ...featureCollection,
130
+ features: updatedFeatures
131
+ };
132
+ }
@@ -0,0 +1,65 @@
1
+ import { FeatureCollection, LineString } from 'geojson';
2
+ import { readFileSync } from 'fs';
3
+ import { graphGetUniqueSegments } from './unique';
4
+ import { graphGetNodeAndEdgeCount } from './nodes';
5
+ import { createFeatureCollection } from '../../test-utils/create';
6
+
7
+ describe('graphGetUniqueSegments', () => {
8
+ it('returns an empty feature collection for empty input', () => {
9
+ const input: FeatureCollection<LineString> = createFeatureCollection([]);
10
+ const output = graphGetUniqueSegments(input);
11
+ expect(output).toEqual(createFeatureCollection([]));
12
+ });
13
+
14
+ it('returns a single linestring for a single input linestring', () => {
15
+ const input: FeatureCollection<LineString> = createFeatureCollection([
16
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} }
17
+ ]);
18
+ const output = graphGetUniqueSegments(input);
19
+ expect(output.features.length).toBe(1);
20
+ expect(output.features[0].geometry.coordinates).toEqual([[0, 0], [1, 1]]);
21
+ });
22
+
23
+ it('returns two linestrings for two unconnected linestrings', () => {
24
+ const input: FeatureCollection<LineString> = createFeatureCollection([
25
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} },
26
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[2, 2], [3, 3]] }, properties: {} }
27
+ ]);
28
+ const output = graphGetUniqueSegments(input);
29
+ expect(output.features.length).toBe(2);
30
+ expect(output.features[0].geometry.coordinates).toEqual([[0, 0], [1, 1]]);
31
+ expect(output.features[1].geometry.coordinates).toEqual([[2, 2], [3, 3]]);
32
+ });
33
+
34
+ it('returns two linestrings for two connected linestrings', () => {
35
+ const input: FeatureCollection<LineString> = createFeatureCollection([
36
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} },
37
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[1, 1], [2, 2]] }, properties: {} }
38
+ ]);
39
+ const output = graphGetUniqueSegments(input);
40
+ expect(output.features.length).toBe(2);
41
+ expect(output.features[0].geometry.coordinates).toEqual([[0, 0], [1, 1]]);
42
+ expect(output.features[1].geometry.coordinates).toEqual([[1, 1], [2, 2]]);
43
+ });
44
+
45
+ it('returns two linestrings if one is a subsection of the other', () => {
46
+ const input: FeatureCollection<LineString> = createFeatureCollection([
47
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1]] }, properties: {} },
48
+ { type: 'Feature', geometry: { type: 'LineString', coordinates: [[0, 0], [1, 1], [2, 2]] }, properties: {} }
49
+ ]);
50
+ const output = graphGetUniqueSegments(input);
51
+ expect(output.features.length).toBe(2);
52
+ expect(output.features[0].geometry.coordinates).toEqual([[0, 0], [1, 1]]);
53
+ expect(output.features[1].geometry.coordinates).toEqual([[1, 1], [2, 2]]);
54
+ });
55
+
56
+ it('should not change the properties of the graph', () => {
57
+ const network = JSON.parse(readFileSync('src/data/network.geojson', 'utf-8')) as FeatureCollection<LineString>;
58
+
59
+ const networkAfter = graphGetUniqueSegments(network)
60
+
61
+ const afterNodeAndEdgeCount = graphGetNodeAndEdgeCount(networkAfter);
62
+ expect(afterNodeAndEdgeCount).toEqual(graphGetNodeAndEdgeCount(network));
63
+ expect(networkAfter.features.length).toEqual(afterNodeAndEdgeCount.edgeCount);
64
+ });
65
+ });