@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
package/src/graph.ts ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Routing graph construction from OSM data.
3
+ *
4
+ * Builds a directed graph from OSM ways suitable for pathfinding. Edges are
5
+ * created between consecutive nodes in each way, with pre-computed distance
6
+ * and time costs. Respects one-way restrictions.
7
+ *
8
+ * Uses CSR (Compressed Sparse Row) format for efficient storage and
9
+ * zero-copy transfer between workers via SharedArrayBuffer.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { BufferConstructor, type BufferType, type Osm } from "@osmix/core"
15
+ import { haversineDistance } from "@osmix/shared/haversine-distance"
16
+ import type { LonLat } from "@osmix/shared/types"
17
+ import type {
18
+ DefaultSpeeds,
19
+ GraphEdge,
20
+ HighwayFilter,
21
+ RoutingGraphTransferables,
22
+ } from "./types"
23
+ import { DEFAULT_SPEEDS, defaultHighwayFilter, getSpeedLimit } from "./utils"
24
+
25
+ /**
26
+ * Routing graph built from OSM ways and nodes.
27
+ *
28
+ * Uses CSR (Compressed Sparse Row) format internally for memory efficiency
29
+ * and zero-copy transfer between workers.
30
+ *
31
+ * @example Build from OSM data
32
+ * ```ts
33
+ * const graph = new RoutingGraph(osm)
34
+ * const route = router.route(graph, startNode, endNode)
35
+ * ```
36
+ *
37
+ * @example Transfer between workers
38
+ * ```ts
39
+ * // In source worker
40
+ * const buffers = graph.transferables()
41
+ * Comlink.transfer(buffers, getTransferableBuffers(buffers))
42
+ *
43
+ * // In target worker
44
+ * const graph = new RoutingGraph(buffers)
45
+ * ```
46
+ */
47
+ export class RoutingGraph {
48
+ /** Total number of nodes in the source OSM data. */
49
+ private nodeCount = 0
50
+ /** Total number of edges in the graph. */
51
+ private edgeCount = 0
52
+
53
+ // CSR format arrays (populated after compact())
54
+ private edgeOffsets: Uint32Array | null = null
55
+ private edgeTargets: Uint32Array | null = null
56
+ private edgeWayIndexes: Uint32Array | null = null
57
+ private edgeDistances: Float32Array | null = null
58
+ private edgeTimes: Float32Array | null = null
59
+ private routableBits: Uint8Array | null = null
60
+ private intersectionBits: Uint8Array | null = null
61
+
62
+ // Expose highway filter and default speeds
63
+ readonly filter: HighwayFilter
64
+ readonly defaultSpeeds: DefaultSpeeds
65
+
66
+ /**
67
+ * Create a RoutingGraph.
68
+ *
69
+ * @param source - Either an Osm instance to build from, or RoutingGraphTransferables to reconstruct.
70
+ * @param filter - Function to determine which ways are routable (only used when building from Osm).
71
+ * @param defaultSpeeds - Speed limits by highway type (only used when building from Osm).
72
+ */
73
+ constructor(
74
+ source: Osm | RoutingGraphTransferables,
75
+ filter: HighwayFilter = defaultHighwayFilter,
76
+ defaultSpeeds: DefaultSpeeds = DEFAULT_SPEEDS,
77
+ ) {
78
+ this.filter = filter
79
+ this.defaultSpeeds = defaultSpeeds
80
+ if ("nodeCount" in source) {
81
+ // Reconstruct from transferables
82
+ this.fromTransferables(source)
83
+ } else {
84
+ // Build from OSM data
85
+ this.buildFromOsm(source, filter, defaultSpeeds)
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Reconstruct from transferables (worker transfer).
91
+ */
92
+ private fromTransferables(t: RoutingGraphTransferables) {
93
+ this.nodeCount = t.nodeCount
94
+ this.edgeCount = t.edgeCount
95
+ this.edgeOffsets = new Uint32Array(t.edgeOffsets)
96
+ this.edgeTargets = new Uint32Array(t.edgeTargets)
97
+ this.edgeWayIndexes = new Uint32Array(t.edgeWayIndexes)
98
+ this.edgeDistances = new Float32Array(t.edgeDistances)
99
+ this.edgeTimes = new Float32Array(t.edgeTimes)
100
+ this.routableBits = new Uint8Array(t.routableBits)
101
+ this.intersectionBits = new Uint8Array(t.intersectionBits)
102
+ }
103
+
104
+ /**
105
+ * Build the graph from OSM data.
106
+ */
107
+ private buildFromOsm(
108
+ osm: Osm,
109
+ filter: HighwayFilter,
110
+ defaultSpeeds: DefaultSpeeds,
111
+ ) {
112
+ this.nodeCount = osm.nodes.size
113
+ const tempEdges = new Map<number, GraphEdge[]>()
114
+ const tempRoutable = new Set<number>()
115
+ const tempIntersections = new Set<number>()
116
+
117
+ const addEdgeToNode = (from: number, edge: GraphEdge) => {
118
+ let nodeEdges = tempEdges.get(from)
119
+ if (!nodeEdges) {
120
+ nodeEdges = []
121
+ tempEdges.set(from, nodeEdges)
122
+ }
123
+ nodeEdges.push(edge)
124
+ }
125
+
126
+ for (let wayIndex = 0; wayIndex < osm.ways.size; wayIndex++) {
127
+ const tags = osm.ways.tags.getTags(wayIndex)
128
+ if (!filter(tags)) continue
129
+
130
+ const refs = osm.ways.getRefIds(wayIndex)
131
+ if (refs.length < 2) continue
132
+
133
+ // Create bidirectional edges between consecutive nodes (respecting one-way)
134
+ const oneway = tags?.["oneway"] === "yes" || tags?.["oneway"] === "1"
135
+ const speedKph = getSpeedLimit(tags, defaultSpeeds)
136
+ const speedMps = (speedKph * 1_000) / 60 / 60
137
+ const nodes = refs.map((ref) => osm.nodes.ids.getIndexFromId(ref))
138
+
139
+ for (let i = 0; i < nodes.length - 1; i++) {
140
+ const nodeIndex = nodes[i]!
141
+ const targetNodeIndex = nodes[i + 1]!
142
+ const fromCoord = osm.nodes.getNodeLonLat({ index: nodeIndex })
143
+ const targetCoord = osm.nodes.getNodeLonLat({ index: targetNodeIndex })
144
+
145
+ const distanceM = haversineDistance(fromCoord, targetCoord)
146
+ const time = distanceM / speedMps
147
+
148
+ // Forward edge
149
+ addEdgeToNode(nodeIndex, {
150
+ targetNodeIndex: targetNodeIndex,
151
+ wayIndex,
152
+ distance: distanceM,
153
+ time,
154
+ })
155
+ this.edgeCount++
156
+
157
+ // Reverse edge (unless one-way)
158
+ if (!oneway) {
159
+ addEdgeToNode(targetNodeIndex, {
160
+ targetNodeIndex: nodeIndex,
161
+ wayIndex,
162
+ distance: distanceM,
163
+ time,
164
+ })
165
+ this.edgeCount++
166
+ }
167
+
168
+ // Track routable nodes and intersections (nodes appearing in multiple ways)
169
+ if (tempRoutable.has(nodeIndex)) {
170
+ tempIntersections.add(nodeIndex)
171
+ } else {
172
+ tempRoutable.add(nodeIndex)
173
+ }
174
+
175
+ if (tempRoutable!.has(targetNodeIndex)) {
176
+ tempIntersections.add(targetNodeIndex)
177
+ } else {
178
+ tempRoutable!.add(targetNodeIndex)
179
+ }
180
+ }
181
+ }
182
+
183
+ // Convert to CSR format and free temporary structures
184
+
185
+ // Allocate CSR arrays
186
+ const offsetsBuffer = new BufferConstructor(
187
+ (this.nodeCount + 1) * Uint32Array.BYTES_PER_ELEMENT,
188
+ )
189
+ const targetsBuffer = new BufferConstructor(
190
+ this.edgeCount * Uint32Array.BYTES_PER_ELEMENT,
191
+ )
192
+ const wayIndexesBuffer = new BufferConstructor(
193
+ this.edgeCount * Uint32Array.BYTES_PER_ELEMENT,
194
+ )
195
+ const distancesBuffer = new BufferConstructor(
196
+ this.edgeCount * Float32Array.BYTES_PER_ELEMENT,
197
+ )
198
+ const timesBuffer = new BufferConstructor(
199
+ this.edgeCount * Float32Array.BYTES_PER_ELEMENT,
200
+ )
201
+
202
+ this.edgeOffsets = new Uint32Array(offsetsBuffer)
203
+ this.edgeTargets = new Uint32Array(targetsBuffer)
204
+ this.edgeWayIndexes = new Uint32Array(wayIndexesBuffer)
205
+ this.edgeDistances = new Float32Array(distancesBuffer)
206
+ this.edgeTimes = new Float32Array(timesBuffer)
207
+
208
+ // Build CSR format: iterate through all nodes in order
209
+ let edgeIndex = 0
210
+ for (let nodeIndex = 0; nodeIndex < this.nodeCount; nodeIndex++) {
211
+ this.edgeOffsets[nodeIndex] = edgeIndex
212
+ const edges = tempEdges.get(nodeIndex)
213
+ if (edges) {
214
+ for (const edge of edges) {
215
+ this.edgeTargets[edgeIndex] = edge.targetNodeIndex
216
+ this.edgeWayIndexes[edgeIndex] = edge.wayIndex
217
+ this.edgeDistances[edgeIndex] = edge.distance
218
+ this.edgeTimes[edgeIndex] = edge.time
219
+ edgeIndex++
220
+ }
221
+ }
222
+ }
223
+ this.edgeOffsets[this.nodeCount] = edgeIndex
224
+
225
+ // Build bitsets for routable and intersection flags
226
+ const bitsetLength = Math.ceil(this.nodeCount / 8)
227
+ const routableBuffer = new BufferConstructor(bitsetLength)
228
+ const intersectionBuffer = new BufferConstructor(bitsetLength)
229
+ this.routableBits = new Uint8Array(routableBuffer)
230
+ this.intersectionBits = new Uint8Array(intersectionBuffer)
231
+
232
+ for (const nodeIndex of tempRoutable) {
233
+ const byteIndex = nodeIndex >> 3
234
+ const bitMask = 1 << (nodeIndex & 7)
235
+ this.routableBits[byteIndex]! |= bitMask
236
+ }
237
+
238
+ for (const nodeIndex of tempIntersections) {
239
+ const byteIndex = nodeIndex >> 3
240
+ const bitMask = 1 << (nodeIndex & 7)
241
+ this.intersectionBits[byteIndex]! |= bitMask
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Check if a node is part of the routable network.
247
+ */
248
+ isRoutable(nodeIndex: number): boolean {
249
+ if (nodeIndex < 0 || nodeIndex >= this.nodeCount) return false
250
+ const byteIndex = nodeIndex >> 3
251
+ const bitMask = 1 << (nodeIndex & 7)
252
+ return (this.routableBits![byteIndex]! & bitMask) !== 0
253
+ }
254
+
255
+ /**
256
+ * Check if a node is an intersection (multiple ways meet).
257
+ */
258
+ isIntersection(nodeIndex: number): boolean {
259
+ if (nodeIndex < 0 || nodeIndex >= this.nodeCount) return false
260
+ const byteIndex = nodeIndex >> 3
261
+ const bitMask = 1 << (nodeIndex & 7)
262
+ return (this.intersectionBits![byteIndex]! & bitMask) !== 0
263
+ }
264
+
265
+ /**
266
+ * Get outgoing edges from a node.
267
+ */
268
+ getEdges(nodeIndex: number): GraphEdge[] {
269
+ if (
270
+ nodeIndex < 0 ||
271
+ nodeIndex >= this.nodeCount ||
272
+ !this.edgeOffsets ||
273
+ !this.edgeTargets
274
+ ) {
275
+ return []
276
+ }
277
+
278
+ const start = this.edgeOffsets[nodeIndex]!
279
+ const end = this.edgeOffsets[nodeIndex + 1]!
280
+ const edges: GraphEdge[] = []
281
+
282
+ for (let i = start; i < end; i++) {
283
+ edges.push({
284
+ targetNodeIndex: this.edgeTargets[i]!,
285
+ wayIndex: this.edgeWayIndexes![i]!,
286
+ distance: this.edgeDistances![i]!,
287
+ time: this.edgeTimes![i]!,
288
+ })
289
+ }
290
+
291
+ return edges
292
+ }
293
+
294
+ /**
295
+ * Get transferable buffers for passing to another thread.
296
+ */
297
+ transferables(): RoutingGraphTransferables {
298
+ return {
299
+ nodeCount: this.nodeCount,
300
+ edgeCount: this.edgeCount,
301
+ edgeOffsets: this.edgeOffsets!.buffer as BufferType,
302
+ edgeTargets: this.edgeTargets!.buffer as BufferType,
303
+ edgeWayIndexes: this.edgeWayIndexes!.buffer as BufferType,
304
+ edgeDistances: this.edgeDistances!.buffer as BufferType,
305
+ edgeTimes: this.edgeTimes!.buffer as BufferType,
306
+ routableBits: this.routableBits!.buffer as BufferType,
307
+ intersectionBits: this.intersectionBits!.buffer as BufferType,
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Get the number of nodes in the graph.
313
+ */
314
+ get size(): number {
315
+ return this.nodeCount
316
+ }
317
+
318
+ /**
319
+ * Get the number of edges in the graph.
320
+ */
321
+ get edges(): number {
322
+ return this.edgeCount
323
+ }
324
+
325
+ /**
326
+ * Find the nearest routable OSM node from a geographic point.
327
+ *
328
+ * Searches for nodes within the given radius that are part of the routing
329
+ * graph (i.e., lie on a routable way). Returns the closest match with its
330
+ * coordinates and distance.
331
+ *
332
+ * @param osm - The OSM dataset.
333
+ * @param point - The [lon, lat] coordinates to search from.
334
+ * @param maxDistanceM - Maximum search radius in meters.
335
+ * @returns The nearest routable node, or null if none found.
336
+ *
337
+ * @example
338
+ * ```ts
339
+ * const nearest = graph.findNearestNodeOnGraph(osm, [-73.989, 40.733], 0.5)
340
+ * if (nearest) {
341
+ * console.log(`Found node ${nearest.nodeIndex} at ${nearest.distance}m`)
342
+ * }
343
+ * ```
344
+ */
345
+ findNearestRoutableNode(osm: Osm, point: LonLat, maxDistanceM: number) {
346
+ const nearby = osm.nodes.findIndexesWithinRadius(
347
+ point[0],
348
+ point[1],
349
+ maxDistanceM / 1_000,
350
+ )
351
+
352
+ let best: {
353
+ nodeIndex: number
354
+ coordinates: LonLat
355
+ distance: number
356
+ } | null = null
357
+ let bestDistM = Number.POSITIVE_INFINITY
358
+
359
+ for (const nodeIndex of nearby) {
360
+ if (!this.isRoutable(nodeIndex)) continue
361
+
362
+ const nodeCoord = osm.nodes.getNodeLonLat({ index: nodeIndex })
363
+ const distance = haversineDistance(point, nodeCoord)
364
+ if (distance < bestDistM && distance <= maxDistanceM) {
365
+ bestDistM = distance
366
+ best = { nodeIndex, coordinates: nodeCoord, distance }
367
+ }
368
+ }
369
+
370
+ return best
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Build a routing graph from OSM ways.
376
+ *
377
+ * Convenience function that creates a RoutingGraph.
378
+ *
379
+ * @param osm - The OSM dataset to build from.
380
+ * @param filter - Function to determine which ways are routable.
381
+ * @param defaultSpeeds - Speed limits (km/h) by highway type.
382
+ * @returns A RoutingGraph ready for pathfinding.
383
+ */
384
+ export function buildGraph(
385
+ osm: Osm,
386
+ filter: HighwayFilter = defaultHighwayFilter,
387
+ defaultSpeeds: DefaultSpeeds = DEFAULT_SPEEDS,
388
+ ): RoutingGraph {
389
+ return new RoutingGraph(osm, filter, defaultSpeeds)
390
+ }
391
+
392
+ /**
393
+ * Get an array of buffers suitable for Comlink.transfer() or postMessage transfer list.
394
+ * Note: SharedArrayBuffers don't need to be transferred (they're shared automatically),
395
+ * but ArrayBuffers do. This returns only the ArrayBuffers that need explicit transfer.
396
+ */
397
+ export function getTransferableBuffers(
398
+ t: RoutingGraphTransferables,
399
+ ): ArrayBuffer[] {
400
+ const buffers = [
401
+ t.edgeOffsets,
402
+ t.edgeTargets,
403
+ t.edgeWayIndexes,
404
+ t.edgeDistances,
405
+ t.edgeTimes,
406
+ t.routableBits,
407
+ t.intersectionBits,
408
+ ]
409
+ // Only ArrayBuffers need to be transferred; SharedArrayBuffers are shared automatically
410
+ return buffers.filter(
411
+ (b): b is ArrayBuffer =>
412
+ b instanceof ArrayBuffer && !(b instanceof SharedArrayBuffer),
413
+ )
414
+ }
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @osmix/router - Pathfinding on OSM road networks.
3
+ *
4
+ * Builds a routeable graph from OSM ways and provides pathfinding algorithms
5
+ * (Dijkstra, A*, bidirectional) for finding routes between nodes. Supports
6
+ * both distance and time-based routing.
7
+ *
8
+ * Key features:
9
+ * - **Graph construction**: Build routing graphs from OSM ways with highway filtering.
10
+ * - **Multiple algorithms**: Dijkstra, A*, and bidirectional search.
11
+ * - **Time-based routing**: Uses maxspeed tags and default speeds by highway type.
12
+ * - **One-way support**: Respects oneway=yes/1 tags.
13
+ * - **Snapping**: Find nearest routable node from arbitrary coordinates.
14
+ *
15
+ * @example
16
+ * ```ts
17
+ * import { buildGraph, Router, findNearestNodeOnGraph } from "@osmix/router"
18
+ *
19
+ * const graph = buildGraph(osm)
20
+ * const router = new Router(osm, graph)
21
+ *
22
+ * const start = findNearestNodeOnGraph(osm, graph, [-73.989, 40.733], 1)
23
+ * const end = findNearestNodeOnGraph(osm, graph, [-73.988, 40.734], 1)
24
+ *
25
+ * if (start && end) {
26
+ * const path = router.route(start.nodeIndex, end.nodeIndex)
27
+ * if (path) {
28
+ * const result = router.buildResult(path)
29
+ * console.log(result.coordinates)
30
+ * }
31
+ * }
32
+ * ```
33
+ *
34
+ * @module @osmix/router
35
+ */
36
+
37
+ export * from "./algorithms"
38
+ export * from "./graph"
39
+ export * from "./router"
40
+ export * from "./types"
41
+ export * from "./utils"
package/src/router.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * High-level router API.
3
+ *
4
+ * Wraps the routing graph and algorithms to provide a convenient interface
5
+ * for finding routes and building result objects with coordinates.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { Osm } from "@osmix/core"
11
+ import type { LonLat } from "@osmix/shared/types"
12
+ import { routingAlgorithms } from "./algorithms"
13
+ import type { RoutingGraph } from "./graph"
14
+ import type {
15
+ PathSegment,
16
+ RouteOptions,
17
+ RoutePathInfo,
18
+ RouteResult,
19
+ RouteStatistics,
20
+ WaySegment,
21
+ } from "./types"
22
+
23
+ /**
24
+ * Router for finding paths through OSM road networks.
25
+ *
26
+ * Uses pathfinding algorithms (Dijkstra, A*, or bidirectional) to find
27
+ * routes between node indexes. Use `findNearestNodeOnGraph()` to snap
28
+ * arbitrary coordinates to routable nodes before calling `route()`.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * const router = new Router(osm, graph, { algorithm: "astar" })
33
+ * const path = router.route(startNodeIndex, endNodeIndex)
34
+ * if (path) {
35
+ * const result = router.buildResult(path)
36
+ * console.log(result.coordinates)
37
+ * }
38
+ * ```
39
+ */
40
+ export class Router {
41
+ readonly graph: RoutingGraph
42
+ readonly osm: Osm
43
+ private readonly defaults: Required<RouteOptions>
44
+
45
+ constructor(
46
+ osm: Osm,
47
+ graph: RoutingGraph,
48
+ options: Partial<RouteOptions> = {},
49
+ ) {
50
+ this.osm = osm
51
+ this.graph = graph
52
+ this.defaults = {
53
+ algorithm: options.algorithm ?? "astar",
54
+ metric: options.metric ?? "distance",
55
+ includeStats: options.includeStats ?? false,
56
+ includePathInfo: options.includePathInfo ?? false,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find a route between two node indexes.
62
+ * Returns path segments or null if no path exists.
63
+ */
64
+ route(
65
+ fromNodeIndex: number,
66
+ toNodeIndex: number,
67
+ options: Partial<RouteOptions> = {},
68
+ ): PathSegment[] | null {
69
+ // Trivial case: same node
70
+ if (fromNodeIndex === toNodeIndex)
71
+ return [{ nodeIndex: fromNodeIndex, cost: 0 }]
72
+
73
+ const algorithm =
74
+ routingAlgorithms[options.algorithm ?? this.defaults.algorithm]
75
+ const metric = options.metric ?? this.defaults.metric
76
+
77
+ return algorithm(
78
+ (nodeIndex) => this.graph.getEdges(nodeIndex),
79
+ fromNodeIndex,
80
+ toNodeIndex,
81
+ (edge) => (metric === "distance" ? edge.distance : edge.time),
82
+ (nodeIndex) => this.osm.nodes.getNodeLonLat({ index: nodeIndex }),
83
+ metric,
84
+ )
85
+ }
86
+
87
+ /** Build route result with coordinates from path segments. */
88
+ buildResult(
89
+ path: PathSegment[],
90
+ options: Partial<RouteOptions> = {},
91
+ ): RouteResult {
92
+ const coordinates: LonLat[] = []
93
+ const wayIndexes: number[] = []
94
+ const nodeIndexes: number[] = []
95
+ const seenNodes = new Set<number>()
96
+
97
+ let currentWay: number | undefined
98
+
99
+ for (let i = 0; i < path.length; i++) {
100
+ const seg = path[i]!
101
+ const coord = this.osm.nodes.getNodeLonLat({ index: seg.nodeIndex })
102
+ if (coord) coordinates.push(coord)
103
+
104
+ // Track way transitions
105
+ if (seg.wayIndex !== undefined && seg.wayIndex !== currentWay) {
106
+ wayIndexes.push(seg.wayIndex)
107
+ currentWay = seg.wayIndex
108
+ // Mark node at way transition
109
+ if (i > 0 && !seenNodes.has(seg.nodeIndex)) {
110
+ seenNodes.add(seg.nodeIndex)
111
+ nodeIndexes.push(seg.nodeIndex)
112
+ }
113
+ }
114
+
115
+ // Mark start, end, and intersections
116
+ const isEndpoint = i === 0 || i === path.length - 1
117
+ const isIntersection = this.graph.isIntersection(seg.nodeIndex)
118
+ if ((isEndpoint || isIntersection) && !seenNodes.has(seg.nodeIndex)) {
119
+ seenNodes.add(seg.nodeIndex)
120
+ nodeIndexes.push(seg.nodeIndex)
121
+ }
122
+ }
123
+
124
+ const result: RouteResult = { coordinates, wayIndexes, nodeIndexes }
125
+
126
+ const includeStats = options.includeStats ?? this.defaults.includeStats
127
+ const includePathInfo =
128
+ options.includePathInfo ?? this.defaults.includePathInfo
129
+
130
+ if (includeStats) {
131
+ const stats = this.getRouteStatistics(path)
132
+ result.distance = stats.distance
133
+ result.time = stats.time
134
+ }
135
+
136
+ if (includePathInfo) {
137
+ const pathInfo = this.getRoutePathInfo(path)
138
+ result.segments = pathInfo.segments
139
+ result.turnPoints = pathInfo.turnPoints
140
+ }
141
+
142
+ return result
143
+ }
144
+
145
+ /**
146
+ * Calculate route statistics (distance and time) from path segments.
147
+ * @param path - Path segments from route().
148
+ * @returns Total distance in meters and time in seconds.
149
+ */
150
+ getRouteStatistics(path: PathSegment[]): RouteStatistics {
151
+ let distance = 0
152
+ let time = 0
153
+
154
+ for (const seg of path) {
155
+ if (seg.wayIndex !== undefined && seg.previousNodeIndex !== undefined) {
156
+ const edges = this.graph.getEdges(seg.previousNodeIndex)
157
+ const edge = edges.find(
158
+ (e) =>
159
+ e.targetNodeIndex === seg.nodeIndex && e.wayIndex === seg.wayIndex,
160
+ )
161
+ if (edge) {
162
+ distance += edge.distance
163
+ time += edge.time
164
+ }
165
+ }
166
+ }
167
+
168
+ return { distance, time }
169
+ }
170
+
171
+ /**
172
+ * Build route path info (segments and turn points) from path segments.
173
+ * Consecutive ways with the same name are merged into a single segment.
174
+ * @param path - Path segments from route().
175
+ * @returns Segments with per-way breakdown and turn point coordinates.
176
+ */
177
+ getRoutePathInfo(path: PathSegment[]): RoutePathInfo {
178
+ // First, collect edge traversals with way info
179
+ interface EdgeTraversal {
180
+ wayIndex: number
181
+ transitionNodeIndex: number
182
+ distance: number
183
+ time: number
184
+ }
185
+ const edgeSequence: EdgeTraversal[] = []
186
+
187
+ for (const seg of path) {
188
+ if (seg.wayIndex !== undefined && seg.previousNodeIndex !== undefined) {
189
+ const edges = this.graph.getEdges(seg.previousNodeIndex)
190
+ const edge = edges.find(
191
+ (e) =>
192
+ e.targetNodeIndex === seg.nodeIndex && e.wayIndex === seg.wayIndex,
193
+ )
194
+ if (edge) {
195
+ edgeSequence.push({
196
+ wayIndex: seg.wayIndex,
197
+ transitionNodeIndex: seg.previousNodeIndex,
198
+ distance: edge.distance,
199
+ time: edge.time,
200
+ })
201
+ }
202
+ }
203
+ }
204
+
205
+ // Build segments, merging consecutive same-name ways
206
+ const segments: WaySegment[] = []
207
+ const turnPoints: LonLat[] = []
208
+ let currentSegment: WaySegment | null = null
209
+ let currentDisplayName: string | null = null
210
+
211
+ const getDisplayName = (tags?: Record<string, string | number>) => {
212
+ const name = (tags?.["name"] as string) ?? ""
213
+ const highway = (tags?.["highway"] as string) ?? ""
214
+ return name || highway
215
+ }
216
+
217
+ for (const {
218
+ wayIndex,
219
+ transitionNodeIndex,
220
+ distance,
221
+ time,
222
+ } of edgeSequence) {
223
+ const wayId = this.osm.ways.ids.at(wayIndex)
224
+ const tags = this.osm.ways.tags.getTags(wayIndex)
225
+ const name = (tags?.["name"] as string) ?? ""
226
+ const highway = (tags?.["highway"] as string) ?? ""
227
+ const displayName = getDisplayName(tags)
228
+
229
+ if (currentSegment && currentDisplayName === displayName) {
230
+ if (!currentSegment.wayIds.includes(wayId)) {
231
+ currentSegment.wayIds.push(wayId)
232
+ }
233
+ currentSegment.distance += distance
234
+ currentSegment.time += time
235
+ } else {
236
+ if (currentSegment) {
237
+ segments.push(currentSegment)
238
+ const coord = this.osm.nodes.getNodeLonLat({
239
+ index: transitionNodeIndex,
240
+ })
241
+ if (coord) {
242
+ turnPoints.push(coord)
243
+ }
244
+ }
245
+ currentSegment = {
246
+ wayIds: [wayId],
247
+ name,
248
+ highway,
249
+ distance,
250
+ time,
251
+ }
252
+ currentDisplayName = displayName
253
+ }
254
+ }
255
+
256
+ if (currentSegment) {
257
+ segments.push(currentSegment)
258
+ }
259
+
260
+ return { segments, turnPoints }
261
+ }
262
+ }