@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.
- package/CHANGELOG.md +12 -0
- package/README.md +149 -0
- package/dist/src/algorithms/bidirectional.d.ts +19 -0
- package/dist/src/algorithms/bidirectional.d.ts.map +1 -0
- package/dist/src/algorithms/bidirectional.js +128 -0
- package/dist/src/algorithms/bidirectional.js.map +1 -0
- package/dist/src/algorithms/index.d.ts +17 -0
- package/dist/src/algorithms/index.d.ts.map +1 -0
- package/dist/src/algorithms/index.js +19 -0
- package/dist/src/algorithms/index.js.map +1 -0
- package/dist/src/algorithms/shortest-path.d.ts +24 -0
- package/dist/src/algorithms/shortest-path.d.ts.map +1 -0
- package/dist/src/algorithms/shortest-path.js +115 -0
- package/dist/src/algorithms/shortest-path.js.map +1 -0
- package/dist/src/binary-heap.d.ts +40 -0
- package/dist/src/binary-heap.d.ts.map +1 -0
- package/dist/src/binary-heap.js +122 -0
- package/dist/src/binary-heap.js.map +1 -0
- package/dist/src/graph.d.ts +135 -0
- package/dist/src/graph.d.ts.map +1 -0
- package/dist/src/graph.js +339 -0
- package/dist/src/graph.js.map +1 -0
- package/dist/src/index.d.ts +41 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +41 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/router.d.ts +55 -0
- package/dist/src/router.d.ts.map +1 -0
- package/dist/src/router.js +189 -0
- package/dist/src/router.js.map +1 -0
- package/dist/src/types.d.ts +118 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +5 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils.d.ts +20 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +101 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/test/algorithms.test.d.ts +2 -0
- package/dist/test/algorithms.test.d.ts.map +1 -0
- package/dist/test/algorithms.test.js +382 -0
- package/dist/test/algorithms.test.js.map +1 -0
- package/dist/test/router.bench.d.ts +2 -0
- package/dist/test/router.bench.d.ts.map +1 -0
- package/dist/test/router.bench.js +191 -0
- package/dist/test/router.bench.js.map +1 -0
- package/dist/test/router.test.d.ts +2 -0
- package/dist/test/router.test.d.ts.map +1 -0
- package/dist/test/router.test.js +515 -0
- package/dist/test/router.test.js.map +1 -0
- package/package.json +51 -0
- package/src/algorithms/bidirectional.ts +147 -0
- package/src/algorithms/index.ts +23 -0
- package/src/algorithms/shortest-path.ts +149 -0
- package/src/binary-heap.ts +147 -0
- package/src/graph.ts +414 -0
- package/src/index.ts +41 -0
- package/src/router.ts +262 -0
- package/src/types.ts +138 -0
- package/src/utils.ts +114 -0
- package/test/algorithms.test.ts +466 -0
- package/test/router.bench.ts +257 -0
- package/test/router.test.ts +738 -0
- 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
|
+
}
|