terra-draw-route-snap-mode 0.1.3 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terra-draw-route-snap-mode",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "A mode for Terra Draw to provide snapping to a route network",
5
5
  "scripts": {
6
6
  "docs": "typedoc",
@@ -60,7 +60,7 @@
60
60
  "knip": "5.30.2",
61
61
  "microbundle": "0.15.0",
62
62
  "serve": "^14.2.4",
63
- "terra-route": "^0.0.13",
63
+ "terra-route": "^0.0.14",
64
64
  "ts-jest": "^29.3.1",
65
65
  "ts-loader": "9.5.1",
66
66
  "tsx": "^4.19.3",
@@ -35,102 +35,423 @@ describe("Routing", () => {
35
35
  const mockRouteFinder = {
36
36
  getRoute: jest.fn().mockReturnValue(mockRoute),
37
37
  setNetwork: jest.fn(),
38
+ expandNetwork: jest.fn(),
38
39
  };
39
40
 
40
- it("should return null for empty network", () => {
41
- const routing = new Routing({
42
- network: {
41
+ beforeEach(() => {
42
+ jest.clearAllMocks();
43
+ })
44
+
45
+ describe("constructor", () => {
46
+ it("should not be affected if the original network is mutated after construction", () => {
47
+ const inputNetwork: FeatureCollection<LineString> = {
43
48
  type: "FeatureCollection",
44
49
  features: [
50
+ {
51
+ type: "Feature",
52
+ geometry: {
53
+ type: "LineString",
54
+ coordinates: [
55
+ [0, 0],
56
+ [1, 1],
57
+ ],
58
+ },
59
+ properties: {},
60
+ },
45
61
  ],
46
- }, routeFinder: mockRouteFinder
47
- });
62
+ };
63
+
64
+ const routing = new Routing({ network: inputNetwork, routeFinder: mockRouteFinder });
48
65
 
49
- const closest = routing.getClosestNetworkCoordinate([0, 0]);
66
+ // Mutate the original object after construction
67
+ inputNetwork.features.pop();
50
68
 
51
- expect(closest).toEqual(null);
69
+ // Routing should still behave as if the original feature exists
70
+ expect(routing.getClosestNetworkCoordinate([0, 0])).toEqual([0, 0]);
71
+ expect(routing.getClosestNetworkCoordinate([1, 1])).toEqual([1, 1]);
72
+
73
+ expect(routing.getRoute([0, 0], [1, 1])).toEqual({
74
+ type: "Feature",
75
+ geometry: {
76
+ type: "LineString",
77
+ coordinates: [
78
+ [0, 0],
79
+ [1, 1],
80
+ ],
81
+ },
82
+ properties: {},
83
+ });
84
+ });
52
85
  });
53
86
 
54
- it("should find the closest network coordinate with exact match", () => {
55
- const routing = new Routing({ network, routeFinder: mockRouteFinder });
87
+ describe("getClosestNetworkCoordinate", () => {
88
+ it("should return null for empty network", () => {
89
+ const routing = new Routing({
90
+ network: {
91
+ type: "FeatureCollection",
92
+ features: [
93
+ ],
94
+ }, routeFinder: mockRouteFinder
95
+ });
96
+
97
+ const closest = routing.getClosestNetworkCoordinate([0, 0]);
98
+
99
+ expect(closest).toEqual(null);
100
+ });
101
+
102
+ it("should find the closest network coordinate with exact match", () => {
103
+ const routing = new Routing({ network, routeFinder: mockRouteFinder });
104
+
105
+ const closest = routing.getClosestNetworkCoordinate([0, 0]);
106
+
107
+ expect(closest).toEqual([0, 0]);
108
+ });
56
109
 
57
- const closest = routing.getClosestNetworkCoordinate([0, 0]);
110
+ it("should find the closest network coordinate if not exact match", () => {
111
+ const routing = new Routing({ network, routeFinder: mockRouteFinder });
112
+
113
+ const closest = routing.getClosestNetworkCoordinate([0.1, 0.1]);
114
+
115
+ expect(closest).toEqual([0, 0]);
116
+ });
117
+
118
+ it("should still return a closest coordinate when a network repeats points", () => {
119
+ const networkWithDuplicates: FeatureCollection<LineString> = {
120
+ type: "FeatureCollection",
121
+ features: [
122
+ {
123
+ type: "Feature",
124
+ geometry: {
125
+ type: "LineString",
126
+ coordinates: [
127
+ [0, 0],
128
+ [1, 1],
129
+ [1, 1],
130
+ ],
131
+ },
132
+ properties: {},
133
+ },
134
+ ],
135
+ };
58
136
 
59
- expect(closest).toEqual([0, 0]);
137
+ const routing = new Routing({ network: networkWithDuplicates, routeFinder: mockRouteFinder });
138
+ expect(routing.getClosestNetworkCoordinate([1, 1])).toEqual([1, 1]);
139
+ });
60
140
  });
61
141
 
62
- it("should find the closest network coordinate if not exact match", () => {
63
- const routing = new Routing({ network, routeFinder: mockRouteFinder });
142
+ describe("getRoute", () => {
143
+ it("should return a route and cache it if enabled", () => {
144
+ const routing = new Routing({ network, routeFinder: mockRouteFinder, useCache: true });
145
+
146
+ const route = routing.getRoute([0, 0], [1, 1]);
147
+
148
+ expect(route).toEqual(mockRoute);
149
+ expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(1);
150
+
151
+ // Call again to hit cache
152
+ const cachedRoute = routing.getRoute([0, 0], [1, 1]);
153
+
154
+ expect(cachedRoute).toEqual(mockRoute);
155
+ expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(1); // Still 1 because cache used
156
+ });
157
+
158
+ it("should call the route finder again when the previous result was null", () => {
159
+ const nullRouteFinder = {
160
+ getRoute: jest.fn().mockReturnValue(null),
161
+ setNetwork: jest.fn(),
162
+ expandNetwork: jest.fn(),
163
+ };
64
164
 
65
- const closest = routing.getClosestNetworkCoordinate([0.1, 0.1]);
165
+ const routing = new Routing({ network, routeFinder: nullRouteFinder, useCache: true });
66
166
 
67
- expect(closest).toEqual([0, 0]);
167
+ expect(routing.getRoute([0, 0], [1, 1])).toBeNull();
168
+ expect(routing.getRoute([0, 0], [1, 1])).toBeNull();
169
+ expect(nullRouteFinder.getRoute).toHaveBeenCalledTimes(2);
170
+ });
171
+
172
+ it("should return a new route if cache is disabled", () => {
173
+ const routing = new Routing({ network, routeFinder: mockRouteFinder, useCache: false });
174
+
175
+ routing.getRoute([0, 0], [1, 1]);
176
+ routing.getRoute([0, 0], [1, 1]);
177
+
178
+ expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(2);
179
+ });
68
180
  });
69
181
 
70
- it("should return a route and cache it if enabled", () => {
71
- const routing = new Routing({ network, routeFinder: mockRouteFinder, useCache: true });
182
+ describe("setNetwork", () => {
183
+ it("should clear the route cache when the network is replaced", () => {
184
+ const routing = new Routing({ network, routeFinder: mockRouteFinder, useCache: true });
185
+
186
+ routing.getRoute([0, 0], [1, 1]);
187
+ expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(1);
188
+
189
+ // Warm cache
190
+ routing.getRoute([0, 0], [1, 1]);
191
+ expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(1);
72
192
 
73
- const route = routing.getRoute([0, 0], [1, 1]);
193
+ const newNetwork: FeatureCollection<LineString> = {
194
+ type: "FeatureCollection",
195
+ features: [
196
+ {
197
+ type: "Feature",
198
+ geometry: {
199
+ type: "LineString",
200
+ coordinates: [
201
+ [10, 10],
202
+ [11, 11],
203
+ ],
204
+ },
205
+ properties: {},
206
+ },
207
+ ],
208
+ };
209
+
210
+ routing.setNetwork(newNetwork);
211
+
212
+ // Cache should be cleared, so it calls through again.
213
+ routing.getRoute([0, 0], [1, 1]);
214
+ expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(2);
215
+ });
216
+
217
+ it("should not be affected if the original network is mutated after setNetwork", () => {
218
+ const terraRoute = new TerraRoute();
219
+ terraRoute.buildRouteGraph(network);
220
+
221
+ const routing = new Routing({
222
+ network,
223
+ routeFinder: {
224
+ getRoute: terraRoute.getRoute.bind(terraRoute),
225
+ setNetwork: terraRoute.buildRouteGraph.bind(terraRoute),
226
+ expandNetwork: terraRoute.expandRouteGraph.bind(terraRoute),
227
+ },
228
+ });
229
+
230
+ const replacementNetwork: FeatureCollection<LineString> = {
231
+ type: "FeatureCollection",
232
+ features: [
233
+ {
234
+ type: "Feature",
235
+ geometry: {
236
+ type: "LineString",
237
+ coordinates: [
238
+ [10, 10],
239
+ [11, 11],
240
+ ],
241
+ },
242
+ properties: {},
243
+ },
244
+ ],
245
+ };
74
246
 
75
- expect(route).toEqual(mockRoute);
76
- expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(1);
247
+ routing.setNetwork(replacementNetwork);
77
248
 
78
- // Call again to hit cache
79
- const cachedRoute = routing.getRoute([0, 0], [1, 1]);
249
+ // Mutate the original object after setNetwork
250
+ replacementNetwork.features.pop();
80
251
 
81
- expect(cachedRoute).toEqual(mockRoute);
82
- expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(1); // Still 1 because cache used
252
+ // Routing should still behave as if the replacement feature exists
253
+ expect(routing.getClosestNetworkCoordinate([10, 10])).toEqual([10, 10]);
254
+ expect(routing.getClosestNetworkCoordinate([11, 11])).toEqual([11, 11]);
255
+
256
+ expect(routing.getRoute([10, 10], [11, 11])).toEqual({
257
+ type: "Feature",
258
+ geometry: {
259
+ type: "LineString",
260
+ coordinates: [
261
+ [10, 10],
262
+ [11, 11],
263
+ ],
264
+ },
265
+ properties: {},
266
+ });
267
+
268
+ });
83
269
  });
84
270
 
85
- it("should return a new route if cache is disabled", () => {
86
- const routing = new Routing({ network, routeFinder: mockRouteFinder, useCache: false });
271
+ describe("expandRouteNetwork", () => {
272
+ it("should clear the route cache when the network is expanded", () => {
273
+ const routing = new Routing({
274
+ network,
275
+ routeFinder: mockRouteFinder,
276
+ useCache: true,
277
+ });
278
+
279
+ const additionalNetwork: FeatureCollection<LineString> = {
280
+ type: "FeatureCollection",
281
+ features: [
282
+ {
283
+ type: "Feature",
284
+ geometry: {
285
+ type: "LineString",
286
+ coordinates: [
287
+ [2, 2],
288
+ [3, 3],
289
+ ],
290
+ },
291
+ properties: {},
292
+ },
293
+ ],
294
+ };
295
+
296
+ const getRouteSpy = jest.spyOn(
297
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
+ (routing as any).routeFinder,
299
+ "getRoute"
300
+ );
301
+
302
+ routing.getRoute([0, 0], [1, 1]);
303
+ routing.getRoute([0, 0], [1, 1]);
304
+ expect(getRouteSpy).toHaveBeenCalledTimes(1);
305
+
306
+ routing.expandRouteNetwork(additionalNetwork);
307
+
308
+ // Cache should be cleared after expansion.
309
+ routing.getRoute([0, 0], [1, 1]);
310
+ expect(getRouteSpy).toHaveBeenCalledTimes(2);
311
+ });
312
+
313
+ it("should expand the route network and re-index points", () => {
314
+ const routing = new Routing({
315
+ network,
316
+ routeFinder: mockRouteFinder,
317
+ useCache: true,
318
+ });
87
319
 
88
- routing.getRoute([0, 0], [1, 1]);
89
- routing.getRoute([0, 0], [1, 1]);
320
+ expect(routing.getClosestNetworkCoordinate([2, 2])).toEqual([1, 1]);
90
321
 
91
- expect(mockRouteFinder.getRoute).toHaveBeenCalledTimes(2);
322
+ const additionalNetwork: FeatureCollection<LineString> = {
323
+ type: "FeatureCollection",
324
+ features: [
325
+ {
326
+ type: "Feature",
327
+ geometry: {
328
+ type: "LineString",
329
+ coordinates: [
330
+ [2, 2],
331
+ [3, 3],
332
+ ],
333
+ },
334
+ properties: {},
335
+ },
336
+ ],
337
+ };
338
+
339
+ const expandSpy = jest.spyOn(
340
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
341
+ (routing as any).routeFinder,
342
+ "expandNetwork"
343
+ );
344
+ routing.expandRouteNetwork(additionalNetwork);
345
+
346
+ expect(expandSpy).toHaveBeenCalledWith(additionalNetwork);
347
+
348
+ expect(routing.getClosestNetworkCoordinate([1, 1])).toEqual([1, 1]);
349
+ expect(routing.getClosestNetworkCoordinate([2, 2])).toEqual([2, 2]);
350
+ expect(routing.getClosestNetworkCoordinate([3, 3])).toEqual([3, 3]);
351
+
352
+ });
92
353
  });
93
354
 
94
- it('should update the network correctly with real a route finder', () => {
95
- const terraRoute = new TerraRoute();
96
- terraRoute.buildRouteGraph(network);
355
+ describe("integration (TerraRoute)", () => {
356
+ it('should update the network correctly with real a route finder', () => {
357
+ const terraRoute = new TerraRoute();
358
+ terraRoute.buildRouteGraph(network);
97
359
 
98
- const routing = new Routing({
99
- network, routeFinder: {
100
- getRoute: terraRoute.getRoute.bind(terraRoute),
101
- setNetwork: terraRoute.buildRouteGraph.bind(terraRoute)
102
- }
360
+ const routing = new Routing({
361
+ network, routeFinder: {
362
+ getRoute: terraRoute.getRoute.bind(terraRoute),
363
+ setNetwork: terraRoute.buildRouteGraph.bind(terraRoute),
364
+ expandNetwork: terraRoute.expandRouteGraph.bind(terraRoute)
365
+ }
366
+ });
367
+
368
+ jest.spyOn(routing, "setNetwork");
369
+
370
+ const existingRoute = routing.getRoute([0, 0], [1, 1]);
371
+ expect(existingRoute?.geometry.coordinates).toEqual([[0, 0], [1, 1]]);
372
+ expect(routing.getRoute([2, 2], [3, 3])).toBeNull();
373
+
374
+ const newNetwork: FeatureCollection<LineString> = {
375
+ type: "FeatureCollection",
376
+ features: [
377
+ {
378
+ type: "Feature",
379
+ geometry: {
380
+ type: "LineString",
381
+ coordinates: [
382
+ [2, 2],
383
+ [3, 3],
384
+ ],
385
+ },
386
+ properties: {},
387
+ },
388
+ ],
389
+ };
390
+
391
+ routing.setNetwork(newNetwork);
392
+
393
+ expect(routing.setNetwork).toHaveBeenCalledWith(newNetwork);
394
+
395
+ const newRoute = routing.getRoute([2, 2], [3, 3]);
396
+ expect(newRoute?.geometry.coordinates).toEqual([[2, 2], [3, 3]]);
397
+ expect(routing.getRoute([0, 0], [1, 1])).toBeNull();
103
398
  });
104
399
 
105
- jest.spyOn(routing, "setNetwork");
106
-
107
- const existingRoute = routing.getRoute([0, 0], [1, 1]);
108
- expect(existingRoute?.geometry.coordinates).toEqual([[0, 0], [1, 1]]);
109
- expect(routing.getRoute([2, 2], [3, 3])).toBeNull();
110
-
111
- const newNetwork: FeatureCollection<LineString> = {
112
- type: "FeatureCollection",
113
- features: [
114
- {
115
- type: "Feature",
116
- geometry: {
117
- type: "LineString",
118
- coordinates: [
119
- [2, 2],
120
- [3, 3],
121
- ],
400
+ it('should be able to route after expanding the network', () => {
401
+ const terraRoute = new TerraRoute();
402
+ terraRoute.buildRouteGraph(network);
403
+
404
+ const routing = new Routing({
405
+ network, routeFinder: {
406
+ getRoute: terraRoute.getRoute.bind(terraRoute),
407
+ setNetwork: terraRoute.buildRouteGraph.bind(terraRoute),
408
+ expandNetwork: terraRoute.expandRouteGraph.bind(terraRoute)
409
+ }
410
+ });
411
+
412
+ jest.spyOn(routing, "expandRouteNetwork");
413
+
414
+ const existingRoute = routing.getRoute([0, 0], [1, 1]);
415
+ expect(existingRoute?.geometry.coordinates).toEqual([[0, 0], [1, 1]]);
416
+ expect(routing.getRoute([2, 2], [3, 3])).toBeNull();
417
+
418
+ const newNetwork: FeatureCollection<LineString> = {
419
+ type: "FeatureCollection",
420
+ features: [
421
+ {
422
+ type: "Feature",
423
+ geometry: {
424
+ type: "LineString",
425
+ coordinates: [
426
+ [2, 2],
427
+ [3, 3],
428
+ ],
429
+ },
430
+ properties: {},
122
431
  },
123
- properties: {},
124
- },
125
- ],
126
- };
432
+ ],
433
+ };
434
+
435
+ routing.expandRouteNetwork(newNetwork);
127
436
 
128
- routing.setNetwork(newNetwork);
437
+ expect(routing.expandRouteNetwork).toHaveBeenCalledWith(newNetwork);
129
438
 
130
- expect(routing.setNetwork).toHaveBeenCalledWith(newNetwork);
439
+ const newRoute = routing.getRoute([2, 2], [3, 3]);
440
+ expect(newRoute?.geometry.coordinates).toEqual([[2, 2], [3, 3]]);
441
+ expect(routing.getRoute([0, 0], [1, 1])).toEqual({
442
+ type: "Feature",
443
+ geometry: {
444
+ type: "LineString",
445
+ coordinates: [
446
+ [0, 0],
447
+ [1, 1],
448
+ ],
449
+ },
450
+ properties: {},
451
+ });
131
452
 
132
- const newRoute = routing.getRoute([2, 2], [3, 3]);
133
- expect(newRoute?.geometry.coordinates).toEqual([[2, 2], [3, 3]]);
134
- expect(routing.getRoute([0, 0], [1, 1])).toBeNull();
453
+ // 1, 1 is not connected to 2, 2
454
+ expect(routing.getRoute([0, 0], [3, 3])).toBeNull();
455
+ });
135
456
  });
136
457
  });
package/src/routing.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  export type RouteFinder = {
12
12
  getRoute: (positionA: Feature<Point>, positionB: Feature<Point>) => Feature<LineString> | null
13
13
  setNetwork: (network: FeatureCollection<LineString>) => void
14
+ expandNetwork: (additionalNetwork: FeatureCollection<LineString>) => void
14
15
  }
15
16
 
16
17
  export interface RoutingInterface {
@@ -33,8 +34,8 @@ export class Routing implements RoutingInterface {
33
34
  network: FeatureCollection<LineString>, useCache?: boolean,
34
35
  routeFinder: RouteFinder
35
36
  }) {
36
- this.useCache = options.useCache || true;
37
- this.network = options.network;
37
+ this.useCache = options.useCache !== undefined ? options.useCache : true;
38
+ this.network = this.clone(options.network);
38
39
  this.routeFinder = options.routeFinder;
39
40
 
40
41
  this.initialise();
@@ -96,7 +97,7 @@ export class Routing implements RoutingInterface {
96
97
  * @param network The network to use
97
98
  */
98
99
  public setNetwork(network: FeatureCollection<LineString>) {
99
- this.network = network;
100
+ this.network = this.clone(network);
100
101
 
101
102
  // Ensure the network is updated correctly for the router finder
102
103
  this.routeFinder.setNetwork(network);
@@ -105,6 +106,24 @@ export class Routing implements RoutingInterface {
105
106
  this.initialise();
106
107
  }
107
108
 
109
+ public expandRouteNetwork(additionalNetwork: FeatureCollection<LineString>) {
110
+ const clonedNetwork = this.clone(additionalNetwork);
111
+
112
+ // Ensure the network is updated correctly for the router finder
113
+ this.routeFinder.expandNetwork(clonedNetwork);
114
+
115
+ const mergedNetwork = {
116
+ type: "FeatureCollection",
117
+ features: [...clonedNetwork.features, ...this.network.features]
118
+ } as FeatureCollection<LineString>;
119
+
120
+ this.network = mergedNetwork;
121
+
122
+ // Re-initialize all internal data structures for this class
123
+ // TODO: Is there a way to avoid re-initialising here?
124
+ this.initialise();
125
+ }
126
+
108
127
  /**
109
128
  * Get the route between two coordinates returned as a GeoJSON LineString
110
129
  * @param startCoord start coordinate
@@ -152,4 +171,8 @@ export class Routing implements RoutingInterface {
152
171
  return route;
153
172
 
154
173
  }
174
+
175
+ private clone(network: FeatureCollection<LineString>) {
176
+ return JSON.parse(JSON.stringify(network)) as FeatureCollection<LineString>;
177
+ }
155
178
  }
@@ -5,6 +5,7 @@ describe("TerraDrawRouteSnapMode", () => {
5
5
  const mockRouteFinder = {
6
6
  getRoute: jest.fn(),
7
7
  setNetwork: jest.fn(),
8
+ expandNetwork: jest.fn(),
8
9
  };
9
10
 
10
11
  it("should construct the class correctly", () => {