@osmix/router 0.0.2

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 (64) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +149 -0
  3. package/dist/src/algorithms/bidirectional.d.ts +19 -0
  4. package/dist/src/algorithms/bidirectional.d.ts.map +1 -0
  5. package/dist/src/algorithms/bidirectional.js +128 -0
  6. package/dist/src/algorithms/bidirectional.js.map +1 -0
  7. package/dist/src/algorithms/index.d.ts +17 -0
  8. package/dist/src/algorithms/index.d.ts.map +1 -0
  9. package/dist/src/algorithms/index.js +19 -0
  10. package/dist/src/algorithms/index.js.map +1 -0
  11. package/dist/src/algorithms/shortest-path.d.ts +24 -0
  12. package/dist/src/algorithms/shortest-path.d.ts.map +1 -0
  13. package/dist/src/algorithms/shortest-path.js +115 -0
  14. package/dist/src/algorithms/shortest-path.js.map +1 -0
  15. package/dist/src/binary-heap.d.ts +40 -0
  16. package/dist/src/binary-heap.d.ts.map +1 -0
  17. package/dist/src/binary-heap.js +122 -0
  18. package/dist/src/binary-heap.js.map +1 -0
  19. package/dist/src/graph.d.ts +135 -0
  20. package/dist/src/graph.d.ts.map +1 -0
  21. package/dist/src/graph.js +339 -0
  22. package/dist/src/graph.js.map +1 -0
  23. package/dist/src/index.d.ts +41 -0
  24. package/dist/src/index.d.ts.map +1 -0
  25. package/dist/src/index.js +41 -0
  26. package/dist/src/index.js.map +1 -0
  27. package/dist/src/router.d.ts +55 -0
  28. package/dist/src/router.d.ts.map +1 -0
  29. package/dist/src/router.js +189 -0
  30. package/dist/src/router.js.map +1 -0
  31. package/dist/src/types.d.ts +118 -0
  32. package/dist/src/types.d.ts.map +1 -0
  33. package/dist/src/types.js +5 -0
  34. package/dist/src/types.js.map +1 -0
  35. package/dist/src/utils.d.ts +20 -0
  36. package/dist/src/utils.d.ts.map +1 -0
  37. package/dist/src/utils.js +101 -0
  38. package/dist/src/utils.js.map +1 -0
  39. package/dist/test/algorithms.test.d.ts +2 -0
  40. package/dist/test/algorithms.test.d.ts.map +1 -0
  41. package/dist/test/algorithms.test.js +382 -0
  42. package/dist/test/algorithms.test.js.map +1 -0
  43. package/dist/test/router.bench.d.ts +2 -0
  44. package/dist/test/router.bench.d.ts.map +1 -0
  45. package/dist/test/router.bench.js +191 -0
  46. package/dist/test/router.bench.js.map +1 -0
  47. package/dist/test/router.test.d.ts +2 -0
  48. package/dist/test/router.test.d.ts.map +1 -0
  49. package/dist/test/router.test.js +515 -0
  50. package/dist/test/router.test.js.map +1 -0
  51. package/package.json +51 -0
  52. package/src/algorithms/bidirectional.ts +147 -0
  53. package/src/algorithms/index.ts +23 -0
  54. package/src/algorithms/shortest-path.ts +149 -0
  55. package/src/binary-heap.ts +147 -0
  56. package/src/graph.ts +414 -0
  57. package/src/index.ts +41 -0
  58. package/src/router.ts +262 -0
  59. package/src/types.ts +138 -0
  60. package/src/utils.ts +114 -0
  61. package/test/algorithms.test.ts +466 -0
  62. package/test/router.bench.ts +257 -0
  63. package/test/router.test.ts +738 -0
  64. package/tsconfig.json +9 -0
@@ -0,0 +1,466 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { astar, bidirectional, dijkstra } from "../src/algorithms"
3
+ import type { GraphEdge } from "../src/types"
4
+
5
+ /**
6
+ * Test suite for individual routing algorithms.
7
+ *
8
+ * These tests verify the core behavior of each algorithm in isolation,
9
+ * using simple mock graphs instead of full OSM data. This allows us to
10
+ * test specific edge cases and verify algorithm correctness.
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Mock Graph Helpers
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /**
18
+ * Creates a simple graph function from an adjacency list.
19
+ * The adjacency list maps node IDs to their outgoing edges.
20
+ */
21
+ function createGraph(
22
+ adjacencyList: Map<number, GraphEdge[]>,
23
+ ): (nodeId: number) => GraphEdge[] {
24
+ return (nodeId: number) => adjacencyList.get(nodeId) ?? []
25
+ }
26
+
27
+ /**
28
+ * Simple edge weight function that uses distance.
29
+ */
30
+ function distanceWeight(edge: GraphEdge): number {
31
+ return edge.distance
32
+ }
33
+
34
+ /**
35
+ * Creates a mock edge with default values.
36
+ */
37
+ function mockEdge(
38
+ targetNodeIndex: number,
39
+ wayIndex: number,
40
+ distance: number,
41
+ ): GraphEdge {
42
+ return {
43
+ targetNodeIndex: targetNodeIndex,
44
+ wayIndex,
45
+ distance,
46
+ time: distance / 10, // 10 m/s default speed
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Creates a simple linear graph: 1 <-> 2 <-> 3 <-> 4
52
+ * Bidirectional edges for proper routing.
53
+ */
54
+ function createLinearGraph(): Map<number, GraphEdge[]> {
55
+ return new Map([
56
+ [1, [mockEdge(2, 100, 10)]],
57
+ [2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
58
+ [3, [mockEdge(2, 101, 10), mockEdge(4, 102, 10)]],
59
+ [4, [mockEdge(3, 102, 10)]],
60
+ ])
61
+ }
62
+
63
+ /**
64
+ * Creates a graph with multiple paths (bidirectional):
65
+ *
66
+ * 1 <---(way 100, dist 10)---> 2
67
+ * ^ ^
68
+ * | (way 101, dist 5) | (way 102, dist 5)
69
+ * v v
70
+ * 3 <---(way 103, dist 10)---> 4
71
+ *
72
+ * Path 1->2->4 has distance 15
73
+ * Path 1->3->4 has distance 15
74
+ */
75
+ function createSquareGraph(): Map<number, GraphEdge[]> {
76
+ return new Map([
77
+ [1, [mockEdge(2, 100, 10), mockEdge(3, 101, 5)]],
78
+ [2, [mockEdge(1, 100, 10), mockEdge(4, 102, 5)]],
79
+ [3, [mockEdge(1, 101, 5), mockEdge(4, 103, 10)]],
80
+ [4, [mockEdge(2, 102, 5), mockEdge(3, 103, 10)]],
81
+ ])
82
+ }
83
+
84
+ /**
85
+ * Creates a graph where shortest path is indirect (bidirectional):
86
+ *
87
+ * 1 <---(way 100, dist 100)---> 4 (direct but long)
88
+ * ^
89
+ * | (way 101, dist 10)
90
+ * v
91
+ * 2 <---(way 102, dist 10)---> 3 <---(way 103, dist 10)---> 4
92
+ *
93
+ * Direct path: 100
94
+ * Indirect path: 30 (should be chosen)
95
+ */
96
+ function createIndirectShorterGraph(): Map<number, GraphEdge[]> {
97
+ return new Map([
98
+ [
99
+ 1,
100
+ [
101
+ mockEdge(4, 100, 100), // Direct but long
102
+ mockEdge(2, 101, 10), // Indirect start
103
+ ],
104
+ ],
105
+ [2, [mockEdge(1, 101, 10), mockEdge(3, 102, 10)]],
106
+ [3, [mockEdge(2, 102, 10), mockEdge(4, 103, 10)]],
107
+ [4, [mockEdge(1, 100, 100), mockEdge(3, 103, 10)]],
108
+ ])
109
+ }
110
+
111
+ /**
112
+ * Creates a disconnected graph where node 4 is unreachable.
113
+ * Nodes 1-3 are connected bidirectionally, but 4 has no edges.
114
+ */
115
+ function createDisconnectedGraph(): Map<number, GraphEdge[]> {
116
+ return new Map([
117
+ [1, [mockEdge(2, 100, 10)]],
118
+ [2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
119
+ [3, [mockEdge(2, 101, 10)]],
120
+ [4, []], // Unreachable - no edges to/from this node
121
+ ])
122
+ }
123
+
124
+ /**
125
+ * Creates a mock coordinate function for A* heuristic.
126
+ *
127
+ * The coordinates are carefully chosen so that the heuristic (straight-line
128
+ * distance) is admissible - it never overestimates the actual path cost.
129
+ * This ensures A* will find optimal paths.
130
+ *
131
+ * Node layout (not to scale):
132
+ * 1 ---- 2 ---- 3 ---- 4
133
+ *
134
+ * Each segment is ~5m (0.00005 degrees), so total 1->4 is ~15m.
135
+ * Edge weights are 10+ units, so heuristic <= actual cost (admissible).
136
+ */
137
+ function createCoordFn(): (nodeId: number) => [number, number] | undefined {
138
+ const coords = new Map<number, [number, number]>([
139
+ [1, [0, 0]],
140
+ [2, [0.00005, 0]], // ~5m east
141
+ [3, [0.0001, 0]], // ~10m east
142
+ [4, [0.00015, 0]], // ~15m east
143
+ ])
144
+ return (nodeId: number) => coords.get(nodeId)
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Dijkstra Tests
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe("dijkstra", () => {
152
+ it("should find direct path between adjacent nodes", () => {
153
+ const graph = createGraph(createLinearGraph())
154
+ const path = dijkstra(graph, 1, 2, distanceWeight)
155
+
156
+ expect(path).not.toBeNull()
157
+ expect(path?.length).toBe(2)
158
+ expect(path?.[0]?.nodeIndex).toBe(1)
159
+ expect(path?.[1]?.nodeIndex).toBe(2)
160
+ expect(path?.[1]?.wayIndex).toBe(100)
161
+ })
162
+
163
+ it("should find path through multiple nodes", () => {
164
+ const graph = createGraph(createLinearGraph())
165
+ const path = dijkstra(graph, 1, 4, distanceWeight)
166
+
167
+ expect(path).not.toBeNull()
168
+ expect(path?.length).toBe(4)
169
+
170
+ // Verify node order
171
+ const nodeIds = path?.map((p) => p.nodeIndex)
172
+ expect(nodeIds).toEqual([1, 2, 3, 4])
173
+
174
+ // Verify way indexes are tracked
175
+ expect(path?.[1]?.wayIndex).toBe(100)
176
+ expect(path?.[2]?.wayIndex).toBe(101)
177
+ expect(path?.[3]?.wayIndex).toBe(102)
178
+ })
179
+
180
+ it("should return null when no path exists", () => {
181
+ const graph = createGraph(createDisconnectedGraph())
182
+ const path = dijkstra(graph, 1, 4, distanceWeight)
183
+
184
+ expect(path).toBeNull()
185
+ })
186
+
187
+ it("should handle same start and end node", () => {
188
+ const graph = createGraph(createLinearGraph())
189
+ const path = dijkstra(graph, 1, 1, distanceWeight)
190
+
191
+ expect(path).not.toBeNull()
192
+ expect(path?.length).toBe(1)
193
+ expect(path?.[0]?.nodeIndex).toBe(1)
194
+ })
195
+
196
+ it("should find shortest path when indirect is shorter", () => {
197
+ const graph = createGraph(createIndirectShorterGraph())
198
+ const path = dijkstra(graph, 1, 4, distanceWeight)
199
+
200
+ expect(path).not.toBeNull()
201
+
202
+ // Should take indirect route 1->2->3->4 (cost 30) not 1->4 (cost 100)
203
+ const nodeIds = path?.map((p) => p.nodeIndex)
204
+ expect(nodeIds).toEqual([1, 2, 3, 4])
205
+
206
+ // Verify total cost
207
+ const lastSegment = path?.[path.length - 1]
208
+ expect(lastSegment?.cost).toBe(30)
209
+ })
210
+
211
+ it("should calculate correct costs", () => {
212
+ const graph = createGraph(createLinearGraph())
213
+ const path = dijkstra(graph, 1, 4, distanceWeight)
214
+
215
+ expect(path).not.toBeNull()
216
+
217
+ // Each edge has distance 10
218
+ expect(path?.[0]?.cost).toBe(0) // Start node
219
+ expect(path?.[1]?.cost).toBe(10)
220
+ expect(path?.[2]?.cost).toBe(20)
221
+ expect(path?.[3]?.cost).toBe(30)
222
+ })
223
+
224
+ it("should track correct way indexes in path", () => {
225
+ const graph = createGraph(createSquareGraph())
226
+ const path = dijkstra(graph, 1, 4, distanceWeight)
227
+
228
+ expect(path).not.toBeNull()
229
+
230
+ // Verify way indexes are present for traversed edges
231
+ const wayIndexes = path?.slice(1).map((p) => p.wayIndex)
232
+ expect(wayIndexes?.every((idx) => idx !== undefined)).toBe(true)
233
+ })
234
+ })
235
+
236
+ // ---------------------------------------------------------------------------
237
+ // A* Tests
238
+ // ---------------------------------------------------------------------------
239
+
240
+ describe("astar", () => {
241
+ it("should find direct path between adjacent nodes", () => {
242
+ const graph = createGraph(createLinearGraph())
243
+ const path = astar(graph, 1, 2, distanceWeight, createCoordFn())
244
+
245
+ expect(path).not.toBeNull()
246
+ expect(path?.length).toBe(2)
247
+ expect(path?.[0]?.nodeIndex).toBe(1)
248
+ expect(path?.[1]?.nodeIndex).toBe(2)
249
+ })
250
+
251
+ it("should find path through multiple nodes", () => {
252
+ const graph = createGraph(createLinearGraph())
253
+ const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
254
+
255
+ expect(path).not.toBeNull()
256
+ const nodeIds = path?.map((p) => p.nodeIndex)
257
+ expect(nodeIds).toEqual([1, 2, 3, 4])
258
+ })
259
+
260
+ it("should return null when no path exists", () => {
261
+ const graph = createGraph(createDisconnectedGraph())
262
+ const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
263
+
264
+ expect(path).toBeNull()
265
+ })
266
+
267
+ it("should handle same start and end node", () => {
268
+ const graph = createGraph(createLinearGraph())
269
+ const path = astar(graph, 1, 1, distanceWeight, createCoordFn())
270
+
271
+ expect(path).not.toBeNull()
272
+ expect(path?.length).toBe(1)
273
+ expect(path?.[0]?.nodeIndex).toBe(1)
274
+ })
275
+
276
+ it("should find shortest path when indirect is shorter", () => {
277
+ const graph = createGraph(createIndirectShorterGraph())
278
+ const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
279
+
280
+ expect(path).not.toBeNull()
281
+ const nodeIds = path?.map((p) => p.nodeIndex)
282
+ expect(nodeIds).toEqual([1, 2, 3, 4])
283
+ })
284
+
285
+ it("should use heuristic to guide search", () => {
286
+ // With heuristic, A* should explore fewer nodes than Dijkstra
287
+ // We can verify it finds the correct path
288
+ const graph = createGraph(createIndirectShorterGraph())
289
+ const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
290
+
291
+ expect(path).not.toBeNull()
292
+ expect(path?.[path.length - 1]?.cost).toBe(30)
293
+ })
294
+
295
+ it("should return null when coordinate function is missing", () => {
296
+ const graph = createGraph(createLinearGraph())
297
+ const path = astar(graph, 1, 4, distanceWeight, undefined)
298
+
299
+ expect(path).toBeNull()
300
+ })
301
+
302
+ it("should handle missing coordinate for node gracefully", () => {
303
+ const graph = createGraph(createLinearGraph())
304
+ // Coordinate function that only knows about nodes 1 and 4
305
+ const partialCoordFn = (nodeId: number) => {
306
+ if (nodeId === 1) return [0, 0] as [number, number]
307
+ if (nodeId === 4) return [0.001, 0.001] as [number, number]
308
+ return undefined
309
+ }
310
+ const path = astar(graph, 1, 4, distanceWeight, partialCoordFn)
311
+
312
+ // A* should still find a path, using 0 heuristic for unknown nodes
313
+ expect(path).not.toBeNull()
314
+ const nodeIds = path?.map((p) => p.nodeIndex)
315
+ expect(nodeIds).toEqual([1, 2, 3, 4])
316
+ })
317
+
318
+ it("should track correct way indexes in path", () => {
319
+ const graph = createGraph(createSquareGraph())
320
+ const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
321
+
322
+ expect(path).not.toBeNull()
323
+ const wayIndexes = path?.slice(1).map((p) => p.wayIndex)
324
+ expect(wayIndexes?.every((idx) => idx !== undefined)).toBe(true)
325
+ })
326
+ })
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Bidirectional Tests
330
+ // ---------------------------------------------------------------------------
331
+
332
+ describe("bidirectional", () => {
333
+ it("should find direct path between adjacent nodes", () => {
334
+ const graph = createGraph(createLinearGraph())
335
+ const path = bidirectional(graph, 1, 2, distanceWeight)
336
+
337
+ expect(path).not.toBeNull()
338
+ // Bidirectional may have different path structure but same nodes
339
+ const nodeIds = path?.map((p) => p.nodeIndex)
340
+ expect(nodeIds).toContain(1)
341
+ expect(nodeIds).toContain(2)
342
+ })
343
+
344
+ it("should find path through multiple nodes", () => {
345
+ const graph = createGraph(createLinearGraph())
346
+ const path = bidirectional(graph, 1, 4, distanceWeight)
347
+
348
+ expect(path).not.toBeNull()
349
+ const nodeIds = path?.map((p) => p.nodeIndex)
350
+ // Should contain all nodes in the path
351
+ expect(nodeIds).toContain(1)
352
+ expect(nodeIds).toContain(4)
353
+ })
354
+
355
+ it("should return null when no path exists", () => {
356
+ const graph = createGraph(createDisconnectedGraph())
357
+ const path = bidirectional(graph, 1, 4, distanceWeight)
358
+
359
+ expect(path).toBeNull()
360
+ })
361
+
362
+ it("should handle same start and end node", () => {
363
+ const graph = createGraph(createLinearGraph())
364
+ const path = bidirectional(graph, 1, 1, distanceWeight)
365
+
366
+ expect(path).not.toBeNull()
367
+ expect(path?.some((p) => p.nodeIndex === 1)).toBe(true)
368
+ })
369
+
370
+ it("should meet in the middle correctly", () => {
371
+ // Create a symmetric graph where meeting in middle is clear
372
+ // 1 <-> 2 <-> 3 <-> 4 <-> 5
373
+ const symmetricGraph = new Map([
374
+ [1, [mockEdge(2, 100, 10)]],
375
+ [2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
376
+ [3, [mockEdge(2, 101, 10), mockEdge(4, 102, 10)]],
377
+ [4, [mockEdge(3, 102, 10), mockEdge(5, 103, 10)]],
378
+ [5, [mockEdge(4, 103, 10)]],
379
+ ])
380
+
381
+ const graph = createGraph(symmetricGraph)
382
+ const path = bidirectional(graph, 1, 5, distanceWeight)
383
+
384
+ expect(path).not.toBeNull()
385
+ // Should include start and end
386
+ const nodeIds = path?.map((p) => p.nodeIndex)
387
+ expect(nodeIds).toContain(1)
388
+ expect(nodeIds).toContain(5)
389
+ })
390
+
391
+ it("should combine forward and backward paths properly", () => {
392
+ const graph = createGraph(createSquareGraph())
393
+ const path = bidirectional(graph, 1, 4, distanceWeight)
394
+
395
+ expect(path).not.toBeNull()
396
+
397
+ // Path should go from 1 to 4
398
+ const nodeIds = path?.map((p) => p.nodeIndex)
399
+ expect(nodeIds?.[0]).toBe(1)
400
+ // Last node should be 4 or path should include 4
401
+ expect(nodeIds).toContain(4)
402
+ })
403
+
404
+ it("should track way indexes in combined path", () => {
405
+ const graph = createGraph(createSquareGraph())
406
+ const path = bidirectional(graph, 1, 4, distanceWeight)
407
+
408
+ expect(path).not.toBeNull()
409
+
410
+ // Way indexes should be present (except possibly for start node)
411
+ const wayIndexes = path
412
+ ?.filter((p) => p.wayIndex !== undefined)
413
+ .map((p) => p.wayIndex)
414
+ expect(wayIndexes?.length).toBeGreaterThan(0)
415
+ })
416
+ })
417
+
418
+ // ---------------------------------------------------------------------------
419
+ // Cross-Algorithm Comparison Tests
420
+ // ---------------------------------------------------------------------------
421
+
422
+ describe("algorithm consistency", () => {
423
+ it("should find same path length with all algorithms", () => {
424
+ const graph = createGraph(createIndirectShorterGraph())
425
+
426
+ const dijkstraPath = dijkstra(graph, 1, 4, distanceWeight)
427
+ const astarPath = astar(graph, 1, 4, distanceWeight, createCoordFn())
428
+ const biPath = bidirectional(graph, 1, 4, distanceWeight)
429
+
430
+ // All should find a path
431
+ expect(dijkstraPath).not.toBeNull()
432
+ expect(astarPath).not.toBeNull()
433
+ expect(biPath).not.toBeNull()
434
+
435
+ // Dijkstra and A* should have same final cost
436
+ const dijkstraCost = dijkstraPath?.[dijkstraPath.length - 1]?.cost
437
+ const astarCost = astarPath?.[astarPath.length - 1]?.cost
438
+
439
+ expect(dijkstraCost).toBe(astarCost)
440
+ })
441
+
442
+ it("all algorithms should return null for unreachable nodes", () => {
443
+ const graph = createGraph(createDisconnectedGraph())
444
+
445
+ expect(dijkstra(graph, 1, 4, distanceWeight)).toBeNull()
446
+ expect(astar(graph, 1, 4, distanceWeight, createCoordFn())).toBeNull()
447
+ expect(bidirectional(graph, 1, 4, distanceWeight)).toBeNull()
448
+ })
449
+
450
+ it("all algorithms should handle trivial single-node path", () => {
451
+ const graph = createGraph(createLinearGraph())
452
+
453
+ const dijkstraPath = dijkstra(graph, 1, 1, distanceWeight)
454
+ const astarPath = astar(graph, 1, 1, distanceWeight, createCoordFn())
455
+ const biPath = bidirectional(graph, 1, 1, distanceWeight)
456
+
457
+ expect(dijkstraPath).not.toBeNull()
458
+ expect(astarPath).not.toBeNull()
459
+ expect(biPath).not.toBeNull()
460
+
461
+ // All should include node 1
462
+ expect(dijkstraPath?.some((p) => p.nodeIndex === 1)).toBe(true)
463
+ expect(astarPath?.some((p) => p.nodeIndex === 1)).toBe(true)
464
+ expect(biPath?.some((p) => p.nodeIndex === 1)).toBe(true)
465
+ })
466
+ })