@osmix/router 0.0.13 → 0.1.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -53
  4. package/dist/algorithms/bidirectional.d.ts +6 -9
  5. package/dist/algorithms/bidirectional.d.ts.map +1 -1
  6. package/dist/algorithms/bidirectional.js +84 -104
  7. package/dist/algorithms/bidirectional.js.map +1 -1
  8. package/dist/algorithms/index.d.ts +1 -1
  9. package/dist/algorithms/index.d.ts.map +1 -1
  10. package/dist/algorithms/index.js +1 -1
  11. package/dist/algorithms/index.js.map +1 -1
  12. package/dist/algorithms/shortest-path.d.ts +2 -1
  13. package/dist/algorithms/shortest-path.d.ts.map +1 -1
  14. package/dist/algorithms/shortest-path.js +9 -7
  15. package/dist/algorithms/shortest-path.js.map +1 -1
  16. package/dist/binary-heap.d.ts +5 -0
  17. package/dist/binary-heap.d.ts.map +1 -1
  18. package/dist/binary-heap.js +11 -4
  19. package/dist/binary-heap.js.map +1 -1
  20. package/dist/graph.d.ts +35 -7
  21. package/dist/graph.d.ts.map +1 -1
  22. package/dist/graph.js +161 -119
  23. package/dist/graph.js.map +1 -1
  24. package/dist/index.d.ts +9 -4
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/router.d.ts +1 -1
  29. package/dist/router.d.ts.map +1 -1
  30. package/dist/router.js +6 -3
  31. package/dist/router.js.map +1 -1
  32. package/dist/topology-stats.d.ts +37 -0
  33. package/dist/topology-stats.d.ts.map +1 -0
  34. package/dist/topology-stats.js +62 -0
  35. package/dist/topology-stats.js.map +1 -0
  36. package/dist/types.d.ts +15 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/utils.d.ts +1 -1
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +1 -7
  41. package/dist/utils.js.map +1 -1
  42. package/package.json +32 -29
  43. package/src/algorithms/bidirectional.ts +124 -136
  44. package/src/algorithms/index.ts +10 -10
  45. package/src/algorithms/shortest-path.ts +96 -92
  46. package/src/binary-heap.ts +133 -129
  47. package/src/graph.ts +389 -355
  48. package/src/index.ts +14 -9
  49. package/src/router.ts +221 -233
  50. package/src/topology-stats.ts +84 -0
  51. package/src/types.ts +102 -87
  52. package/src/utils.ts +67 -74
  53. package/test/algorithms.test.ts +396 -353
  54. package/test/binary-heap.test.ts +27 -0
  55. package/test/limits.test.ts +82 -0
  56. package/test/no-shared-array-buffer.test.ts +37 -0
  57. package/test/router.bench.ts +171 -188
  58. package/test/router.test.ts +685 -726
  59. package/test/spatial-capability.test.ts +23 -0
  60. package/test/topology-stats.test.ts +82 -0
  61. package/tsconfig.build.json +6 -6
  62. package/tsconfig.json +7 -7
package/src/graph.ts CHANGED
@@ -11,16 +11,19 @@
11
11
  * @module
12
12
  */
13
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"
14
+ import { BufferConstructor, type BufferType, type Osm } from "@osmix/core";
15
+ import { haversineDistance } from "@osmix/geo/haversine-distance";
16
+ import { BitSet } from "@osmix/shared/bit-set";
17
+ import type { LonLat } from "@osmix/types";
18
+ import { normalizedWayDirection } from "@osmix/types/way-direction";
19
+
17
20
  import type {
18
- DefaultSpeeds,
19
- GraphEdge,
20
- HighwayFilter,
21
- RoutingGraphTransferables,
22
- } from "./types.ts"
23
- import { DEFAULT_SPEEDS, defaultHighwayFilter, getSpeedLimit } from "./utils.ts"
21
+ DefaultSpeeds,
22
+ GraphEdge,
23
+ HighwayFilter,
24
+ RoutingGraphTransferables,
25
+ } from "./types.ts";
26
+ import { DEFAULT_SPEEDS, defaultHighwayFilter, getSpeedLimit } from "./utils.ts";
24
27
 
25
28
  /**
26
29
  * Routing graph built from OSM ways and nodes.
@@ -31,7 +34,8 @@ import { DEFAULT_SPEEDS, defaultHighwayFilter, getSpeedLimit } from "./utils.ts"
31
34
  * @example Build from OSM data
32
35
  * ```ts
33
36
  * const graph = new RoutingGraph(osm)
34
- * const route = router.route(graph, startNode, endNode)
37
+ * const router = new Router(osm, graph)
38
+ * const path = router.route(startNodeIndex, endNodeIndex)
35
39
  * ```
36
40
  *
37
41
  * @example Transfer between workers
@@ -45,330 +49,365 @@ import { DEFAULT_SPEEDS, defaultHighwayFilter, getSpeedLimit } from "./utils.ts"
45
49
  * ```
46
50
  */
47
51
  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
- }
52
+ /** Total number of nodes in the source OSM data. */
53
+ private nodeCount = 0;
54
+ /** Total number of edges in the graph. */
55
+ private edgeCount = 0;
56
+
57
+ // CSR format arrays (populated after compact())
58
+ private edgeOffsets: Uint32Array | null = null;
59
+ private edgeTargets: Uint32Array | null = null;
60
+ private edgeWayIndexes: Uint32Array | null = null;
61
+ private edgeDistances: Float32Array | null = null;
62
+ private edgeTimes: Float32Array | null = null;
63
+ private routable: BitSet | null = null;
64
+ private intersections: BitSet | null = null;
65
+
66
+ // Incoming-edge CSR, built on first use by getIncomingEdges(). Per worker; not transferred.
67
+ private reverseOffsets: Uint32Array | null = null;
68
+ /** Forward edge index for each incoming edge, grouped by target node. */
69
+ private reverseEdges: Uint32Array | null = null;
70
+ private reverseSources: Uint32Array | null = null;
71
+ private cachedMaxSpeedMps: number | null = null;
72
+
73
+ // Expose highway filter and default speeds
74
+ readonly filter: HighwayFilter;
75
+ readonly defaultSpeeds: DefaultSpeeds;
76
+
77
+ /**
78
+ * Create a RoutingGraph.
79
+ *
80
+ * @param source - Either an Osm instance to build from, or RoutingGraphTransferables to reconstruct.
81
+ * @param filter - Function to determine which ways are routable (only used when building from Osm).
82
+ * @param defaultSpeeds - Speed limits by highway type (only used when building from Osm).
83
+ */
84
+ constructor(
85
+ source: Osm | RoutingGraphTransferables,
86
+ filter: HighwayFilter = defaultHighwayFilter,
87
+ defaultSpeeds: DefaultSpeeds = DEFAULT_SPEEDS,
88
+ ) {
89
+ this.filter = filter;
90
+ this.defaultSpeeds = defaultSpeeds;
91
+ if ("nodeCount" in source) {
92
+ // Reconstruct from transferables
93
+ this.fromTransferables(source);
94
+ } else {
95
+ // Build from OSM data
96
+ this.buildFromOsm(source, filter, defaultSpeeds);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Reconstruct from transferables (worker transfer).
102
+ */
103
+ private fromTransferables(t: RoutingGraphTransferables) {
104
+ this.nodeCount = t.nodeCount;
105
+ this.edgeCount = t.edgeCount;
106
+ this.edgeOffsets = new Uint32Array(t.edgeOffsets);
107
+ this.edgeTargets = new Uint32Array(t.edgeTargets);
108
+ this.edgeWayIndexes = new Uint32Array(t.edgeWayIndexes);
109
+ this.edgeDistances = new Float32Array(t.edgeDistances);
110
+ this.edgeTimes = new Float32Array(t.edgeTimes);
111
+ this.routable = new BitSet(t.nodeCount, t.routableBits);
112
+ this.intersections = new BitSet(t.nodeCount, t.intersectionBits);
113
+ }
114
+
115
+ /**
116
+ * Build the graph from OSM data in two passes over the routable ways, straight into typed
117
+ * arrays: the first counts each node's outgoing edges and sets the node flags, the second
118
+ * fills the CSR arrays. No per-edge objects or `Map`/`Set` (V8 caps those near 2^24 entries).
119
+ */
120
+ private buildFromOsm(osm: Osm, filter: HighwayFilter, defaultSpeeds: DefaultSpeeds) {
121
+ this.nodeCount = osm.nodes.size;
122
+ const bitsetLength = BitSet.byteLength(this.nodeCount);
123
+ this.routable = new BitSet(this.nodeCount, new BufferConstructor(bitsetLength));
124
+ this.intersections = new BitSet(this.nodeCount, new BufferConstructor(bitsetLength));
125
+ const offsets = new Uint32Array(
126
+ new BufferConstructor((this.nodeCount + 1) * Uint32Array.BYTES_PER_ELEMENT),
127
+ );
128
+ this.edgeOffsets = offsets;
129
+
130
+ // Pass 1: count outgoing edges into offsets[from + 1], and mark routable nodes and
131
+ // intersections (a node seen a second time).
132
+ this.forEachSegment(osm, filter, defaultSpeeds, (from, to, direction) => {
133
+ if (direction !== "reverse") offsets[from + 1]!++;
134
+ if (direction !== "forward") offsets[to + 1]!++;
135
+ this.markRoutable(from);
136
+ this.markRoutable(to);
137
+ });
138
+ for (let n = 0; n < this.nodeCount; n++) offsets[n + 1]! += offsets[n]!;
139
+ this.edgeCount = offsets[this.nodeCount]!;
140
+
141
+ this.edgeTargets = new Uint32Array(
142
+ new BufferConstructor(this.edgeCount * Uint32Array.BYTES_PER_ELEMENT),
143
+ );
144
+ this.edgeWayIndexes = new Uint32Array(
145
+ new BufferConstructor(this.edgeCount * Uint32Array.BYTES_PER_ELEMENT),
146
+ );
147
+ this.edgeDistances = new Float32Array(
148
+ new BufferConstructor(this.edgeCount * Float32Array.BYTES_PER_ELEMENT),
149
+ );
150
+ this.edgeTimes = new Float32Array(
151
+ new BufferConstructor(this.edgeCount * Float32Array.BYTES_PER_ELEMENT),
152
+ );
153
+
154
+ // Pass 2: fill each node's edges in way order.
155
+ const cursor = offsets.slice(0, this.nodeCount);
156
+ const addEdge = (
157
+ from: number,
158
+ to: number,
159
+ wayIndex: number,
160
+ distance: number,
161
+ time: number,
162
+ ) => {
163
+ const edgeIndex = cursor[from]!++;
164
+ this.edgeTargets![edgeIndex] = to;
165
+ this.edgeWayIndexes![edgeIndex] = wayIndex;
166
+ this.edgeDistances![edgeIndex] = distance;
167
+ this.edgeTimes![edgeIndex] = time;
168
+ };
169
+ this.forEachSegment(osm, filter, defaultSpeeds, (from, to, direction, wayIndex, speedMps) => {
170
+ const distance = haversineDistance(
171
+ osm.nodes.getNodeLonLat({ index: from }),
172
+ osm.nodes.getNodeLonLat({ index: to }),
173
+ );
174
+ const time = distance / speedMps;
175
+ if (direction !== "reverse") addEdge(from, to, wayIndex, distance, time);
176
+ if (direction !== "forward") addEdge(to, from, wayIndex, distance, time);
177
+ });
178
+ }
179
+
180
+ /** Visit each segment of each routable way, in way order, with its direction and speed. */
181
+ private forEachSegment(
182
+ osm: Osm,
183
+ filter: HighwayFilter,
184
+ defaultSpeeds: DefaultSpeeds,
185
+ visit: (
186
+ from: number,
187
+ to: number,
188
+ direction: "forward" | "reverse" | "both",
189
+ wayIndex: number,
190
+ speedMps: number,
191
+ ) => void,
192
+ ) {
193
+ for (let wayIndex = 0; wayIndex < osm.ways.size; wayIndex++) {
194
+ const tags = osm.ways.tags.getTags(wayIndex);
195
+ if (!filter(tags)) continue;
196
+
197
+ const refs = osm.ways.getRefIds(wayIndex);
198
+ if (refs.length < 2) continue;
199
+
200
+ const normalizedDirection = normalizedWayDirection(tags);
201
+ // Preserve the existing router approximation for unsupported dynamic values.
202
+ // Matching does not use this fallback to establish direction equivalence.
203
+ const direction =
204
+ normalizedDirection === "unsupported"
205
+ ? tags?.["junction"] === "roundabout"
206
+ ? "forward"
207
+ : "both"
208
+ : normalizedDirection;
209
+ const speedMps = (getSpeedLimit(tags, defaultSpeeds) * 1_000) / 60 / 60;
210
+
211
+ let from = osm.nodes.ids.getIndexFromId(refs[0]!);
212
+ for (let i = 1; i < refs.length; i++) {
213
+ const to = osm.nodes.ids.getIndexFromId(refs[i]!);
214
+ // A ref to a node outside the dataset (for example, cut off by an extract) has index -1.
215
+ // Skip the segments that touch it rather than store a wrapped Uint32 index.
216
+ if (from >= 0 && to >= 0) visit(from, to, direction, wayIndex, speedMps);
217
+ from = to;
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Mark a node routable, or an intersection if it was already routable. */
223
+ private markRoutable(nodeIndex: number) {
224
+ // Segment endpoints are resolved node indexes, so they are in range.
225
+ if (this.routable!.hasUnchecked(nodeIndex)) this.intersections!.addUnchecked(nodeIndex);
226
+ else this.routable!.addUnchecked(nodeIndex);
227
+ }
228
+
229
+ /**
230
+ * Check if a node is part of the routable network.
231
+ */
232
+ isRoutable(nodeIndex: number): boolean {
233
+ if (nodeIndex < 0 || nodeIndex >= this.nodeCount) return false;
234
+ return this.routable!.hasUnchecked(nodeIndex);
235
+ }
236
+
237
+ /**
238
+ * Check if a node is an intersection (multiple ways meet).
239
+ */
240
+ isIntersection(nodeIndex: number): boolean {
241
+ if (nodeIndex < 0 || nodeIndex >= this.nodeCount) return false;
242
+ return this.intersections!.hasUnchecked(nodeIndex);
243
+ }
244
+
245
+ /**
246
+ * Get outgoing edges from a node.
247
+ */
248
+ getEdges(nodeIndex: number): GraphEdge[] {
249
+ if (nodeIndex < 0 || nodeIndex >= this.nodeCount || !this.edgeOffsets || !this.edgeTargets) {
250
+ return [];
251
+ }
252
+
253
+ const start = this.edgeOffsets[nodeIndex]!;
254
+ const end = this.edgeOffsets[nodeIndex + 1]!;
255
+ const edges: GraphEdge[] = [];
256
+
257
+ for (let i = start; i < end; i++) {
258
+ edges.push({
259
+ targetNodeIndex: this.edgeTargets[i]!,
260
+ wayIndex: this.edgeWayIndexes![i]!,
261
+ distance: this.edgeDistances![i]!,
262
+ time: this.edgeTimes![i]!,
263
+ });
264
+ }
265
+
266
+ return edges;
267
+ }
268
+
269
+ /**
270
+ * Get incoming edges of a node. In each returned edge, `targetNodeIndex` is the
271
+ * edge's source node. The reverse index (4 bytes per node plus 8 bytes per edge)
272
+ * is built on the first call.
273
+ */
274
+ getIncomingEdges(nodeIndex: number): GraphEdge[] {
275
+ if (nodeIndex < 0 || nodeIndex >= this.nodeCount || !this.edgeOffsets || !this.edgeTargets) {
276
+ return [];
277
+ }
278
+ if (!this.reverseOffsets) this.buildReverseIndex();
279
+ const start = this.reverseOffsets![nodeIndex]!;
280
+ const end = this.reverseOffsets![nodeIndex + 1]!;
281
+ const edges: GraphEdge[] = [];
282
+ for (let i = start; i < end; i++) {
283
+ const edgeIndex = this.reverseEdges![i]!;
284
+ edges.push({
285
+ targetNodeIndex: this.reverseSources![i]!,
286
+ wayIndex: this.edgeWayIndexes![edgeIndex]!,
287
+ distance: this.edgeDistances![edgeIndex]!,
288
+ time: this.edgeTimes![edgeIndex]!,
289
+ });
290
+ }
291
+ return edges;
292
+ }
293
+
294
+ private buildReverseIndex() {
295
+ const offsets = this.edgeOffsets!;
296
+ const targets = this.edgeTargets!;
297
+ const reverseOffsets = new Uint32Array(this.nodeCount + 1);
298
+ for (let i = 0; i < this.edgeCount; i++) reverseOffsets[targets[i]! + 1]!++;
299
+ for (let n = 0; n < this.nodeCount; n++) {
300
+ reverseOffsets[n + 1]! += reverseOffsets[n]!;
301
+ }
302
+ const cursor = reverseOffsets.slice(0, this.nodeCount);
303
+ const reverseEdges = new Uint32Array(this.edgeCount);
304
+ const reverseSources = new Uint32Array(this.edgeCount);
305
+ for (let source = 0; source < this.nodeCount; source++) {
306
+ for (let e = offsets[source]!; e < offsets[source + 1]!; e++) {
307
+ const slot = cursor[targets[e]!]!++;
308
+ reverseEdges[slot] = e;
309
+ reverseSources[slot] = source;
310
+ }
311
+ }
312
+ this.reverseOffsets = reverseOffsets;
313
+ this.reverseEdges = reverseEdges;
314
+ this.reverseSources = reverseSources;
315
+ }
316
+
317
+ /**
318
+ * Fastest edge speed in the graph, in meters per second. The A* time heuristic
319
+ * divides by this so that it never overestimates travel time.
320
+ */
321
+ get maxSpeedMps(): number {
322
+ if (this.cachedMaxSpeedMps !== null) return this.cachedMaxSpeedMps;
323
+ let max = 0;
324
+ for (let i = 0; i < this.edgeCount; i++) {
325
+ const time = this.edgeTimes![i]!;
326
+ if (time > 0) max = Math.max(max, this.edgeDistances![i]! / time);
327
+ }
328
+ // Float32 rounding can shave a little off the true speed; keep the bound safe.
329
+ this.cachedMaxSpeedMps = max > 0 ? max * (1 + 1e-6) : Number.POSITIVE_INFINITY;
330
+ return this.cachedMaxSpeedMps;
331
+ }
332
+
333
+ /**
334
+ * Get transferable buffers for passing to another thread.
335
+ */
336
+ transferables(): RoutingGraphTransferables {
337
+ return {
338
+ nodeCount: this.nodeCount,
339
+ edgeCount: this.edgeCount,
340
+ edgeOffsets: this.edgeOffsets!.buffer as BufferType,
341
+ edgeTargets: this.edgeTargets!.buffer as BufferType,
342
+ edgeWayIndexes: this.edgeWayIndexes!.buffer as BufferType,
343
+ edgeDistances: this.edgeDistances!.buffer as BufferType,
344
+ edgeTimes: this.edgeTimes!.buffer as BufferType,
345
+ routableBits: this.routable!.buffer as BufferType,
346
+ intersectionBits: this.intersections!.buffer as BufferType,
347
+ };
348
+ }
349
+
350
+ /**
351
+ * Number of node slots in the graph: every node in the source OSM data, not only
352
+ * routable ones. Use `isRoutable()` to test a node.
353
+ */
354
+ get size(): number {
355
+ return this.nodeCount;
356
+ }
357
+
358
+ /**
359
+ * Get the number of edges in the graph.
360
+ */
361
+ get edges(): number {
362
+ return this.edgeCount;
363
+ }
364
+
365
+ /**
366
+ * Find the nearest routable OSM node from a geographic point.
367
+ *
368
+ * Searches for nodes within the given radius that are part of the routing
369
+ * graph (i.e., lie on a routable way). Returns the closest match with its
370
+ * coordinates and distance.
371
+ *
372
+ * Snaps to graph nodes only, not to points along edges. Requires the "all" node
373
+ * spatial index; throws `SpatialIndexNotBuiltError` without it.
374
+ *
375
+ * @param osm - The OSM dataset.
376
+ * @param point - The [lon, lat] coordinates to search from.
377
+ * @param maxDistanceM - Maximum search radius in meters.
378
+ * @returns The nearest routable node, or null if none found.
379
+ *
380
+ * @example
381
+ * ```ts
382
+ * const nearest = graph.findNearestRoutableNode(osm, [-73.989, 40.733], 500)
383
+ * if (nearest) {
384
+ * console.log(`Found node ${nearest.nodeIndex} at ${nearest.distance}m`)
385
+ * }
386
+ * ```
387
+ */
388
+ findNearestRoutableNode(osm: Osm, point: LonLat, maxDistanceM: number) {
389
+ const nearby = osm.nodes.findIndexesWithinRadius(point[0], point[1], maxDistanceM / 1_000);
390
+
391
+ let best: {
392
+ nodeIndex: number;
393
+ coordinates: LonLat;
394
+ distance: number;
395
+ } | null = null;
396
+ let bestDistM = Number.POSITIVE_INFINITY;
397
+
398
+ for (const nodeIndex of nearby) {
399
+ if (!this.isRoutable(nodeIndex)) continue;
400
+
401
+ const nodeCoord = osm.nodes.getNodeLonLat({ index: nodeIndex });
402
+ const distance = haversineDistance(point, nodeCoord);
403
+ if (distance < bestDistM && distance <= maxDistanceM) {
404
+ bestDistM = distance;
405
+ best = { nodeIndex, coordinates: nodeCoord, distance };
406
+ }
407
+ }
408
+
409
+ return best;
410
+ }
372
411
  }
373
412
 
374
413
  /**
@@ -382,11 +421,11 @@ export class RoutingGraph {
382
421
  * @returns A RoutingGraph ready for pathfinding.
383
422
  */
384
423
  export function buildGraph(
385
- osm: Osm,
386
- filter: HighwayFilter = defaultHighwayFilter,
387
- defaultSpeeds: DefaultSpeeds = DEFAULT_SPEEDS,
424
+ osm: Osm,
425
+ filter: HighwayFilter = defaultHighwayFilter,
426
+ defaultSpeeds: DefaultSpeeds = DEFAULT_SPEEDS,
388
427
  ): RoutingGraph {
389
- return new RoutingGraph(osm, filter, defaultSpeeds)
428
+ return new RoutingGraph(osm, filter, defaultSpeeds);
390
429
  }
391
430
 
392
431
  /**
@@ -394,21 +433,16 @@ export function buildGraph(
394
433
  * Note: SharedArrayBuffers don't need to be transferred (they're shared automatically),
395
434
  * but ArrayBuffers do. This returns only the ArrayBuffers that need explicit transfer.
396
435
  */
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
- )
436
+ export function getTransferableBuffers(t: RoutingGraphTransferables): ArrayBuffer[] {
437
+ const buffers = [
438
+ t.edgeOffsets,
439
+ t.edgeTargets,
440
+ t.edgeWayIndexes,
441
+ t.edgeDistances,
442
+ t.edgeTimes,
443
+ t.routableBits,
444
+ t.intersectionBits,
445
+ ];
446
+ // Only ArrayBuffers need to be transferred; SharedArrayBuffers are shared automatically
447
+ return buffers.filter((b): b is ArrayBuffer => b instanceof ArrayBuffer);
414
448
  }