@osmix/router 0.0.11 → 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 +78 -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 +2 -2
  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 -3
  62. package/tsconfig.json +7 -7
package/src/index.ts CHANGED
@@ -9,18 +9,22 @@
9
9
  * - **Graph construction**: Build routing graphs from OSM ways with highway filtering.
10
10
  * - **Multiple algorithms**: Dijkstra, A*, and bidirectional search.
11
11
  * - **Time-based routing**: Uses maxspeed tags and default speeds by highway type.
12
- * - **One-way support**: Respects oneway=yes/1 tags.
12
+ * - **One-way support**: Respects explicit one-way tags and implicit roundabout direction.
13
13
  * - **Snapping**: Find nearest routable node from arbitrary coordinates.
14
14
  *
15
+ * Not modeled: turn restrictions, access tags, `oneway:*` per-mode overrides,
16
+ * conditional restrictions, turn costs and elevation. See the README "Limitations".
17
+ *
15
18
  * @example
16
19
  * ```ts
17
- * import { buildGraph, Router, findNearestNodeOnGraph } from "@osmix/router"
20
+ * import { buildGraph, Router } from "@osmix/router"
18
21
  *
19
22
  * const graph = buildGraph(osm)
20
23
  * const router = new Router(osm, graph)
21
24
  *
22
- * const start = findNearestNodeOnGraph(osm, graph, [-73.989, 40.733], 1)
23
- * const end = findNearestNodeOnGraph(osm, graph, [-73.988, 40.734], 1)
25
+ * // Snap radius is in meters. Needs the "all" node spatial index.
26
+ * const start = graph.findNearestRoutableNode(osm, [-73.989, 40.733], 500)
27
+ * const end = graph.findNearestRoutableNode(osm, [-73.988, 40.734], 500)
24
28
  *
25
29
  * if (start && end) {
26
30
  * const path = router.route(start.nodeIndex, end.nodeIndex)
@@ -34,8 +38,9 @@
34
38
  * @module @osmix/router
35
39
  */
36
40
 
37
- export * from "./algorithms/index.ts"
38
- export * from "./graph.ts"
39
- export * from "./router.ts"
40
- export * from "./types.ts"
41
- export * from "./utils.ts"
41
+ export * from "./algorithms/index.ts";
42
+ export * from "./graph.ts";
43
+ export * from "./router.ts";
44
+ export * from "./topology-stats.ts";
45
+ export * from "./types.ts";
46
+ export * from "./utils.ts";
package/src/router.ts CHANGED
@@ -7,24 +7,25 @@
7
7
  * @module
8
8
  */
9
9
 
10
- import type { Osm } from "@osmix/core"
11
- import type { LonLat } from "@osmix/shared/types"
12
- import { routingAlgorithms } from "./algorithms/index.ts"
13
- import type { RoutingGraph } from "./graph.ts"
10
+ import type { Osm } from "@osmix/core";
11
+ import type { LonLat } from "@osmix/types";
12
+
13
+ import { routingAlgorithms } from "./algorithms/index.ts";
14
+ import type { RoutingGraph } from "./graph.ts";
14
15
  import type {
15
- PathSegment,
16
- RouteOptions,
17
- RoutePathInfo,
18
- RouteResult,
19
- RouteStatistics,
20
- WaySegment,
21
- } from "./types.ts"
16
+ PathSegment,
17
+ RouteOptions,
18
+ RoutePathInfo,
19
+ RouteResult,
20
+ RouteStatistics,
21
+ WaySegment,
22
+ } from "./types.ts";
22
23
 
23
24
  /**
24
25
  * Router for finding paths through OSM road networks.
25
26
  *
26
27
  * Uses pathfinding algorithms (Dijkstra, A*, or bidirectional) to find
27
- * routes between node indexes. Use `findNearestNodeOnGraph()` to snap
28
+ * routes between node indexes. Use `graph.findNearestRoutableNode()` to snap
28
29
  * arbitrary coordinates to routable nodes before calling `route()`.
29
30
  *
30
31
  * @example
@@ -38,225 +39,212 @@ import type {
38
39
  * ```
39
40
  */
40
41
  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
- }
42
+ readonly graph: RoutingGraph;
43
+ readonly osm: Osm;
44
+ private readonly defaults: Required<RouteOptions>;
45
+
46
+ constructor(osm: Osm, graph: RoutingGraph, options: Partial<RouteOptions> = {}) {
47
+ this.osm = osm;
48
+ this.graph = graph;
49
+ this.defaults = {
50
+ algorithm: options.algorithm ?? "astar",
51
+ metric: options.metric ?? "distance",
52
+ includeStats: options.includeStats ?? false,
53
+ includePathInfo: options.includePathInfo ?? false,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Find a route between two node indexes.
59
+ * Returns path segments or null if no path exists.
60
+ */
61
+ route(
62
+ fromNodeIndex: number,
63
+ toNodeIndex: number,
64
+ options: Partial<RouteOptions> = {},
65
+ ): PathSegment[] | null {
66
+ // Trivial case: same node
67
+ if (fromNodeIndex === toNodeIndex) return [{ nodeIndex: fromNodeIndex, cost: 0 }];
68
+
69
+ const algorithm = routingAlgorithms[options.algorithm ?? this.defaults.algorithm];
70
+ const metric = options.metric ?? this.defaults.metric;
71
+
72
+ return algorithm(
73
+ (nodeIndex) => this.graph.getEdges(nodeIndex),
74
+ fromNodeIndex,
75
+ toNodeIndex,
76
+ (edge) => (metric === "distance" ? edge.distance : edge.time),
77
+ (nodeIndex) => this.osm.nodes.getNodeLonLat({ index: nodeIndex }),
78
+ metric,
79
+ {
80
+ reverseGraph: (nodeIndex) => this.graph.getIncomingEdges(nodeIndex),
81
+ maxSpeedMps: this.graph.maxSpeedMps,
82
+ },
83
+ );
84
+ }
85
+
86
+ /** Build route result with coordinates from path segments. */
87
+ buildResult(path: PathSegment[], options: Partial<RouteOptions> = {}): RouteResult {
88
+ const coordinates: LonLat[] = [];
89
+ const wayIndexes: number[] = [];
90
+ const nodeIndexes: number[] = [];
91
+ const seenNodes = new Set<number>();
92
+
93
+ let currentWay: number | undefined;
94
+
95
+ for (let i = 0; i < path.length; i++) {
96
+ const seg = path[i]!;
97
+ const coord = this.osm.nodes.getNodeLonLat({ index: seg.nodeIndex });
98
+ if (coord) coordinates.push(coord);
99
+
100
+ // Track way transitions
101
+ if (seg.wayIndex !== undefined && seg.wayIndex !== currentWay) {
102
+ wayIndexes.push(seg.wayIndex);
103
+ currentWay = seg.wayIndex;
104
+ // Mark node at way transition
105
+ if (i > 0 && !seenNodes.has(seg.nodeIndex)) {
106
+ seenNodes.add(seg.nodeIndex);
107
+ nodeIndexes.push(seg.nodeIndex);
108
+ }
109
+ }
110
+
111
+ // Mark start, end, and intersections
112
+ const isEndpoint = i === 0 || i === path.length - 1;
113
+ const isIntersection = this.graph.isIntersection(seg.nodeIndex);
114
+ if ((isEndpoint || isIntersection) && !seenNodes.has(seg.nodeIndex)) {
115
+ seenNodes.add(seg.nodeIndex);
116
+ nodeIndexes.push(seg.nodeIndex);
117
+ }
118
+ }
119
+
120
+ const result: RouteResult = { coordinates, wayIndexes, nodeIndexes };
121
+
122
+ const includeStats = options.includeStats ?? this.defaults.includeStats;
123
+ const includePathInfo = options.includePathInfo ?? this.defaults.includePathInfo;
124
+
125
+ if (includeStats) {
126
+ const stats = this.getRouteStatistics(path);
127
+ result.distance = stats.distance;
128
+ result.time = stats.time;
129
+ }
130
+
131
+ if (includePathInfo) {
132
+ const pathInfo = this.getRoutePathInfo(path);
133
+ result.segments = pathInfo.segments;
134
+ result.turnPoints = pathInfo.turnPoints;
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * Calculate route statistics (distance and time) from path segments.
142
+ * @param path - Path segments from route().
143
+ * @returns Total distance in meters and time in seconds.
144
+ */
145
+ getRouteStatistics(path: PathSegment[]): RouteStatistics {
146
+ let distance = 0;
147
+ let time = 0;
148
+
149
+ for (const seg of path) {
150
+ if (seg.wayIndex !== undefined && seg.previousNodeIndex !== undefined) {
151
+ const edges = this.graph.getEdges(seg.previousNodeIndex);
152
+ const edge = edges.find(
153
+ (e) => e.targetNodeIndex === seg.nodeIndex && e.wayIndex === seg.wayIndex,
154
+ );
155
+ if (edge) {
156
+ distance += edge.distance;
157
+ time += edge.time;
158
+ }
159
+ }
160
+ }
161
+
162
+ return { distance, time };
163
+ }
164
+
165
+ /**
166
+ * Build route path info (segments and turn points) from path segments.
167
+ * Consecutive ways with the same name are merged into a single segment.
168
+ * @param path - Path segments from route().
169
+ * @returns Segments with per-way breakdown and turn point coordinates.
170
+ */
171
+ getRoutePathInfo(path: PathSegment[]): RoutePathInfo {
172
+ // First, collect edge traversals with way info
173
+ interface EdgeTraversal {
174
+ wayIndex: number;
175
+ transitionNodeIndex: number;
176
+ distance: number;
177
+ time: number;
178
+ }
179
+ const edgeSequence: EdgeTraversal[] = [];
180
+
181
+ for (const seg of path) {
182
+ if (seg.wayIndex !== undefined && seg.previousNodeIndex !== undefined) {
183
+ const edges = this.graph.getEdges(seg.previousNodeIndex);
184
+ const edge = edges.find(
185
+ (e) => e.targetNodeIndex === seg.nodeIndex && e.wayIndex === seg.wayIndex,
186
+ );
187
+ if (edge) {
188
+ edgeSequence.push({
189
+ wayIndex: seg.wayIndex,
190
+ transitionNodeIndex: seg.previousNodeIndex,
191
+ distance: edge.distance,
192
+ time: edge.time,
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ // Build segments, merging consecutive same-name ways
199
+ const segments: WaySegment[] = [];
200
+ const turnPoints: LonLat[] = [];
201
+ let currentSegment: WaySegment | null = null;
202
+ let currentDisplayName: string | null = null;
203
+
204
+ const getDisplayName = (tags?: Record<string, string | number>) => {
205
+ const name = (tags?.["name"] as string) ?? "";
206
+ const highway = (tags?.["highway"] as string) ?? "";
207
+ return name || highway;
208
+ };
209
+
210
+ for (const { wayIndex, transitionNodeIndex, distance, time } of edgeSequence) {
211
+ const wayId = this.osm.ways.ids.at(wayIndex);
212
+ const tags = this.osm.ways.tags.getTags(wayIndex);
213
+ const name = (tags?.["name"] as string) ?? "";
214
+ const highway = (tags?.["highway"] as string) ?? "";
215
+ const displayName = getDisplayName(tags);
216
+
217
+ if (currentSegment && currentDisplayName === displayName) {
218
+ if (!currentSegment.wayIds.includes(wayId)) {
219
+ currentSegment.wayIds.push(wayId);
220
+ }
221
+ currentSegment.distance += distance;
222
+ currentSegment.time += time;
223
+ } else {
224
+ if (currentSegment) {
225
+ segments.push(currentSegment);
226
+ const coord = this.osm.nodes.getNodeLonLat({
227
+ index: transitionNodeIndex,
228
+ });
229
+ if (coord) {
230
+ turnPoints.push(coord);
231
+ }
232
+ }
233
+ currentSegment = {
234
+ wayIds: [wayId],
235
+ name,
236
+ highway,
237
+ distance,
238
+ time,
239
+ };
240
+ currentDisplayName = displayName;
241
+ }
242
+ }
243
+
244
+ if (currentSegment) {
245
+ segments.push(currentSegment);
246
+ }
247
+
248
+ return { segments, turnPoints };
249
+ }
262
250
  }
@@ -0,0 +1,84 @@
1
+ import type { OsmTags } from "@osmix/types";
2
+ import { normalizedWayDirection } from "@osmix/types/way-direction";
3
+
4
+ import type { HighwayFilter } from "./types.ts";
5
+
6
+ /** Counts that describe a routing network's shape. */
7
+ export interface RoutingTopologyStats {
8
+ /** Every node in the source, routable or not. */
9
+ nodes: number;
10
+ /** Nodes on at least one way the filter accepts. */
11
+ routableNodes: number;
12
+ /** Directed edges: two per segment, one for a one-way segment. */
13
+ edges: number;
14
+ /** Weakly connected components among routable nodes. */
15
+ components: number;
16
+ }
17
+
18
+ /** Anything that can list its ways: an `Osm`, or a planner view of pending changes. */
19
+ export interface RoutingTopologySource {
20
+ nodeCount: number;
21
+ ways(): Iterable<{ tags?: OsmTags | undefined; refs: readonly number[] }>;
22
+ }
23
+
24
+ /**
25
+ * Directed edges one way adds for `filter` (two per segment, one when one-way), or null when
26
+ * the filter does not route it. Direction follows the router: an unsupported one-way value
27
+ * counts as forward on a roundabout and as two-way elsewhere.
28
+ */
29
+ export function routingWayEdges(
30
+ way: { tags?: OsmTags | undefined; refs: readonly number[] },
31
+ filter: HighwayFilter,
32
+ ): number | null {
33
+ if (!filter(way.tags) || way.refs.length < 2) return null;
34
+ const normalized = normalizedWayDirection(way.tags);
35
+ const direction =
36
+ normalized === "unsupported"
37
+ ? way.tags?.["junction"] === "roundabout"
38
+ ? "forward"
39
+ : "both"
40
+ : normalized;
41
+ return (direction === "both" ? 2 : 1) * (way.refs.length - 1);
42
+ }
43
+
44
+ /**
45
+ * The same counts `RoutingGraph` would give for `filter`, computed from ways alone, without
46
+ * building the graph (no coordinates, speeds or CSR arrays). Direction follows the router: an
47
+ * unsupported one-way value counts as forward on a roundabout and as two-way elsewhere.
48
+ */
49
+ export function routingTopologyStats(
50
+ source: RoutingTopologySource,
51
+ filter: HighwayFilter,
52
+ ): RoutingTopologyStats {
53
+ const parent = new Map<number, number>();
54
+ const find = (id: number): number => {
55
+ let root = id;
56
+ while (parent.get(root) !== root) root = parent.get(root)!;
57
+ let cursor = id;
58
+ while (parent.get(cursor) !== cursor) {
59
+ const next = parent.get(cursor)!;
60
+ parent.set(cursor, root);
61
+ cursor = next;
62
+ }
63
+ return root;
64
+ };
65
+ const union = (a: number, b: number) => {
66
+ if (!parent.has(a)) parent.set(a, a);
67
+ if (!parent.has(b)) parent.set(b, b);
68
+ const left = find(a);
69
+ const right = find(b);
70
+ if (left !== right) parent.set(right, left);
71
+ };
72
+ let edges = 0;
73
+ for (const way of source.ways()) {
74
+ const wayEdges = routingWayEdges(way, filter);
75
+ if (wayEdges == null) continue;
76
+ for (let index = 0; index < way.refs.length - 1; index++) {
77
+ union(way.refs[index]!, way.refs[index + 1]!);
78
+ }
79
+ edges += wayEdges;
80
+ }
81
+ const roots = new Set<number>();
82
+ for (const id of parent.keys()) roots.add(find(id));
83
+ return { nodes: source.nodeCount, routableNodes: parent.size, edges, components: roots.size };
84
+ }