roadgraph-router-v2 0.0.0-stage → 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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,44 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims
5
+ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-12
10
+
11
+ First public release. Everything below is new, so this entry describes what the
12
+ package does rather than how it changed.
13
+
14
+ ### Added
15
+
16
+ - **Generic OSM compiler.** `roadgraph-compile` turns any `.osm.pbf` extract
17
+ into an EFRB bundle in two passes over the file, with no assumptions about
18
+ which region it describes. Coordinates are indexed on disk through
19
+ `node:sqlite`, tiles are encoded and spilled one at a time, and the bundle is
20
+ streamed out, so peak memory tracks the largest tile rather than the region.
21
+ - **Routing profiles.** Tag handling lives behind a `RoutingProfile`. The
22
+ bundled `car` profile resolves the OSM access hierarchy, `oneway`,
23
+ `junction`, and `maxspeed` including `mph`, falling back to highway-class
24
+ defaults for values it cannot parse.
25
+ - **Turn restrictions.** `no_*` and `only_*` relations are enforced, both the
26
+ `via`-node form and the `via`-way form. A graph carrying restrictions is
27
+ searched with an edge-labelled A*, so a banned turn cannot appear in a result.
28
+ - **Snapping to roads.** A coordinate is projected onto the nearest road rather
29
+ than the nearest junction, which matters after topology simplification, where
30
+ a rural road can run for a kilometre without a node.
31
+ - **Tile selection by distance.** The router loads the tiles that could hold a
32
+ better route, judged by ground distance, so results do not depend on the tile
33
+ size a region was compiled at. `maxDetourFactor` caps how far it will look.
34
+ - **Compact storage.** EFR2 v6 stores each road's geometry once and mirrors it
35
+ for the reverse direction, writes coordinates and edge targets as
36
+ variable-length differences, and rebuilds node offsets from degrees.
37
+ - **Range-addressable bundles.** An `.efrb` holds independently gzipped tiles
38
+ behind a fixed directory, so a route downloads only the tiles it needs.
39
+ - **Formats.** Reads EFRT v1 and EFR2 v2 through v6; writes EFR2 v6 by default
40
+ and the v2-v5 layout on request.
41
+
42
+ ### Known limitations
43
+
44
+ See the "Supported OSM features and limitations" section of the README.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Sá
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,449 @@
1
- # Temporary Holding Version
1
+ # roadgraph-router
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Compile any OpenStreetMap `.osm.pbf` extract into a compact, range-addressable
4
+ routing bundle, then route over it with A* — in Node.js or in the browser, with
5
+ no routing service involved.
6
+
7
+ A 425 MB Portugal extract compiles to a 38 MB bundle. A city route downloads
8
+ 5 MB of it and takes about 200 ms end to end. The browser build is 18 kB
9
+ gzipped and pulls in no Node built-ins.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install roadgraph-router
15
+ ```
16
+
17
+ **Requirements.** The routing engine runs on Node.js 18+ and in any browser
18
+ with `fetch` and `DecompressionStream` (Chrome 103+, Firefox 113+, Safari 16.4+).
19
+ The `roadgraph-compile` CLI needs **Node.js 22.5 or newer**, because it indexes
20
+ coordinates through the built-in `node:sqlite` module.
21
+
22
+ ## Quick start
23
+
24
+ Compile an extract, then route over it:
25
+
26
+ ```sh
27
+ npx roadgraph-compile monaco.osm.pbf --output monaco.efrb
28
+ ```
29
+
30
+ ```ts
31
+ import { RoadRouter } from "roadgraph-router";
32
+
33
+ const router = await RoadRouter.open("/routing/monaco.efrb");
34
+ const result = await router.route(
35
+ { lat: 43.7384, lng: 7.4246 },
36
+ { lat: 43.7325, lng: 7.4189 },
37
+ );
38
+
39
+ result.distanceMeters; // 1210.17
40
+ result.durationSeconds; // 108.68
41
+ result.geometry; // a GeoJSON LineString of 38 points following the roads
42
+ ```
43
+
44
+ `RoadRouter.open()` also accepts an already-decoded `RoadGraph`, which is
45
+ useful for embedded or application-managed data.
46
+
47
+ ## Routing profile
48
+
49
+ Every tag decision lives behind a `RoutingProfile` rather than in the compiler.
50
+ The bundled `car` profile resolves the OSM access hierarchy
51
+ (`access` < `vehicle` < `motor_vehicle` < `motorcar`, most specific wins),
52
+ direction (`oneway=yes|-1|reverse|no|reversible`, `junction=roundabout|circular`,
53
+ and the implicit one-way default on `motorway`/`motorway_link`), and speed from
54
+ `maxspeed`, `maxspeed:forward` and `maxspeed:backward`.
55
+
56
+ `maxspeed` accepts plain km/h, `mph`, `km/h`, `kmh` and `knots`. Any value that
57
+ is not a number with an optional unit — `none`, `signals`, `variable`, or a
58
+ country code such as `PT:urban` — falls back to the highway class default
59
+ rather than to a country-specific table, and `walk` maps to 7 km/h. Speeds are
60
+ clamped to the profile range the A* heuristic assumes, so a mistagged
61
+ `maxspeed=400` cannot make the heuristic inadmissible.
62
+
63
+ ```ts
64
+ import { carRoutingProfile } from "roadgraph-router";
65
+
66
+ carRoutingProfile.evaluateWay({ highway: "primary", maxspeed: "60 mph" });
67
+ // { forward: true, backward: true, forwardSpeedKph: 96.56, backwardSpeedKph: 96.56 }
68
+ ```
69
+
70
+ ## Turn restrictions
71
+
72
+ The compiler reads OSM `type=restriction` relations and the routing graph
73
+ enforces them. `no_left_turn`, `no_right_turn`, `no_straight_on`, `no_u_turn`,
74
+ `no_entry` and `no_exit` become banned turns directly; `only_left_turn`,
75
+ `only_right_turn`, `only_straight_on` and `only_u_turn` are expanded at compile
76
+ time into bans on every other exit from the via node, including the u-turn back
77
+ along the approach. `restriction:motorcar`, `restriction:motor_vehicle` and
78
+ `restriction:vehicle` override the generic `restriction` key, and a relation
79
+ whose `except` list names a car mode is ignored.
80
+
81
+ A restriction is stored as an ordered pair of directed edges: having arrived
82
+ along edge *f*, a vehicle may not leave along edge *t*. When a graph carries
83
+ restrictions, `route()` switches from node-labelled to edge-labelled A*, whose
84
+ search state is "arrived at this node along this edge" — the smallest state that
85
+ can express a banned turn. A* therefore cannot return a route that violates one;
86
+ it detours, and will re-approach the same junction from a permitted arm if that
87
+ is the only way through. Graphs without restrictions keep the cheaper
88
+ node-labelled search.
89
+
90
+ Restrictions whose `via` is a **way** rather than a node ban a whole run of
91
+ edges, which a single pair cannot express. They are stored as banned
92
+ *sequences*: a run of three or more directed edges that may not be driven one
93
+ after another. The search carries the partial match along only while a run is
94
+ actually being driven, so the ordinary case still costs one array slot per
95
+ edge, and the ban applies to the whole run rather than to any pair inside it —
96
+ driving the link is still allowed, only continuing off it is not.
97
+
98
+ A `via`-way run is enforced against the path actually taken, not against an
99
+ approximation. In a Portugal extract they are 541 runs against 33,796 pairs,
100
+ and they add 5 KB to a 71 MB bundle.
101
+
102
+ ## Snapping to the road, not to the junction
103
+
104
+ Topology simplification keeps only junctions and way endpoints as routing
105
+ nodes, so on a rural road the nearest node can be more than a kilometre away.
106
+ Snapping to nodes therefore fails on roads that are plainly there: measured on
107
+ a compiled Portugal bundle, **3.9% of points lying exactly on a routable road
108
+ in the Arouca region could not snap within 500 m** (p99 704 m, max 1,140 m),
109
+ while Lisbon never failed.
110
+
111
+ `route()` snaps to the nearest *road* instead, projecting the coordinate onto
112
+ edge geometry that the bundle already stores. The same measurement after the
113
+ change is **0.0% in both regions**. A point that lands within a metre of an
114
+ endpoint is still reported as that node, so routing between exact node
115
+ coordinates is unchanged.
116
+
117
+ Starting part-way along a road is not a special case for the search: the
118
+ edge-labelled A* state is already "arrived at this node along edge *e*", which
119
+ is exactly what a mid-edge start provides. One-ways therefore work for free —
120
+ a mid-road start may only set off in a direction the graph allows — and a turn
121
+ restriction applies to the first turn after the start and the last turn before
122
+ the finish.
123
+
124
+ `RouteResult` carries `originSnap` and `destinationSnap` describing where each
125
+ coordinate met the network and how far away it was. Route geometry begins and
126
+ ends at those snapped points rather than at a junction, so `distanceMeters`
127
+ includes the partial first and last edges. Those partial edges are deliberately
128
+ absent from `edgeIds`, which lists only whole hops between two nodes.
129
+
130
+ The edge index this needs is built on first use and cached per graph, not at
131
+ decode, so loading a graph costs nothing extra and repeated routes amortise it:
132
+
133
+ | | |
134
+ | --- | --- |
135
+ | First route on a freshly loaded tile | 102 ms |
136
+ | Each route after that, same tile | 7–13 ms |
137
+
138
+ ## Choosing which tiles to load
139
+
140
+ A route is not confined to the rectangle between its endpoints: the quickest
141
+ road often bulges well outside it. Rather than loading that rectangle plus a
142
+ ring of neighbours — a rule that changes meaning when a region is recompiled at
143
+ a different tile size — the router asks a geometric question.
144
+
145
+ Any route passing through a tile is at least
146
+ `distance(origin, tile) + distance(tile, destination)` long. Compare that
147
+ against the route already found and a tile either could hold something better,
148
+ or provably could not and is never downloaded. The test is a ground distance,
149
+ so the set of tiles considered does not depend on how the bundle was cut. On a
150
+ Portugal extract, 120 km and 40 km tilings now return the identical
151
+ Braga–Évora route (460.16 km / 244.3 min) where they previously disagreed by
152
+ 22 minutes.
153
+
154
+ The confirmation pass carries the cost of the route already found, so it
155
+ abandons anything no better almost immediately and is much cheaper than the
156
+ first search.
157
+
158
+ This is insurance, and on a long route it is not free: see the measurements
159
+ below. `maxDetourFactor` caps how far off the straight line the search will
160
+ reach, as a multiple of the direct distance, trading the guarantee for a
161
+ smaller download while staying independent of tile size:
162
+
163
+ ```ts
164
+ const router = await RoadRouter.open("/routing/region.efrb", undefined, { maxDetourFactor: 1.15 });
165
+ ```
166
+
167
+ ## Compile any region
168
+
169
+ The package includes a Node 22.5+ streaming PBF compiler. It discovers bounds,
170
+ partitions the extract automatically, preserves one-way edges, and emits an
171
+ EFRB bundle that can be opened by `RoadRouter`. No country, region name, or
172
+ fixed grid is built into the command:
173
+
174
+ ```sh
175
+ roadgraph-compile input.osm.pbf --output region.efrb \
176
+ --profile car --tile-size-km 120 --compression gzip
177
+ ```
178
+
179
+ Use `--padding-km` to tune cross-tile overlap (2 km by default; the router
180
+ picks neighbouring tiles by ground distance, so a large overlap is no longer
181
+ needed and mostly duplicated data), `--shape-tolerance-m` to simplify
182
+ display-only shape points, and `--bbox minLon,minLat,maxLon,maxLat` to compile a
183
+ smaller area from a larger extract. Compilation uses two PBF passes regardless
184
+ of tile count: one global topology/way pass and one pass for referenced node
185
+ coordinates. The compiler writes a `region.efrb.json` report with input size,
186
+ global OSM-road counts, routing-node reduction, edges, bundle size, peak RSS,
187
+ PBF passes, and elapsed time.
188
+
189
+ To keep compiler RAM bounded for country extracts, referenced coordinates are
190
+ stored in a temporary on-disk SQLite index and deleted once the bundle is
191
+ written. Global ID tracking uses conservative bounded Bloom filters, so the reported
192
+ `relevantOsmNodes` is a conservative Bloom-filter estimate (`relevantOsmNodesExact`
193
+ is `false`). Routing ways, output topology nodes, and edges are counted exactly.
194
+
195
+ The first importer targets `.osm.pbf`, because PBF supports country-scale
196
+ streaming. Convert XML `.osm` extracts to PBF before compiling; this avoids
197
+ loading a whole XML document into memory. The runtime format is unchanged:
198
+ EFR2 chunks inside EFRB.
199
+
200
+ ## Using it in a browser
201
+
202
+ The routing engine has no Node built-ins in it. Bundled for the browser it is
203
+ about 18 kB gzipped, and it needs only `fetch` and `DecompressionStream`:
204
+
205
+ ```ts
206
+ import { RoadRouter } from "roadgraph-router";
207
+
208
+ // Served as application/octet-stream, without whole-file content encoding,
209
+ // so the byte offsets in the bundle directory stay valid.
210
+ const router = await RoadRouter.open("/routing/region.efrb");
211
+ const result = await router.route(origin, destination);
212
+ ```
213
+
214
+ Routing a long distance merges a lot of tiles, so run it off the main thread
215
+ when the region is large:
216
+
217
+ ```ts
218
+ import RoutingWorker from "roadgraph-router/worker?worker";
219
+ ```
220
+
221
+ The `roadgraph-compile` CLI is Node-only and is never imported by the browser
222
+ entry point, so bundlers do not pull it in.
223
+
224
+ ## How a chunk is stored
225
+
226
+ EFR2 v6 keeps the same graph as earlier versions but writes it far more
227
+ tightly. Every choice below was measured on a compiled Portugal extract rather
228
+ than assumed, by profiling which sections of a chunk actually cost bytes:
229
+
230
+ - **Shared geometry.** A two-way road used to store its shape twice, once per
231
+ direction. 47.1% of all shape points were a mirror of the opposite
232
+ direction's, so each road's geometry is now stored once and mirrored back
233
+ when the reverse edge is read.
234
+ - **Differences instead of positions.** Shape points are written as a step from
235
+ the previous point, and an edge's target as a step from its own source node.
236
+ - **Variable-length integers.** Those steps are small, so they take one or two
237
+ bytes instead of four.
238
+ - **Degrees instead of offsets.** The per-node edge offsets are a running total
239
+ rebuilt on load, so only each node's edge count is stored.
240
+
241
+ Together these cut the uncompressed chunk by 46% and the gzipped bundle by 35%,
242
+ with the decoded graph identical in every array. Decoding costs one pass of
243
+ varint reads, which is more than paid back by having less to decompress: graph
244
+ load time went down, not up.
245
+
246
+ `encodeCompactGraph(graph, { layout: "legacy" })` still writes the older v2-v5
247
+ layout, and every version from EFRT v1 onwards still decodes.
248
+
249
+ ## Graph formats
250
+
251
+ The package reads the original EFRT v1 format and compact EFR2 v2/v3/v4 chunks.
252
+ EFR2 stores latitude/longitude as fixed-point `Int32` values and edge weights as
253
+ scaled `Uint16` values. EFR2 v3 adds an optional compact intermediate-geometry
254
+ stream per edge, so the routing topology can omit road-shape nodes without
255
+ turning returned GeoJSON routes into straight lines. EFR2 v4 appends two
256
+ parallel `Uint32` arrays of banned directed-edge pairs, costing eight bytes per
257
+ restriction and nothing at all for a tile that has none. Existing v1, v2 and v3
258
+ data remain supported, and a chunk is written at the lowest version that can
259
+ represent it.
260
+
261
+ For fast snapping, add a local index after loading a graph:
262
+
263
+ ```ts
264
+ import { withSpatialIndex } from "roadgraph-router";
265
+
266
+ const indexedGraph = withSpatialIndex(graph);
267
+ ```
268
+
269
+ The spatial index is built locally after a chunk has been loaded, avoiding a
270
+ full graph scan when snapping a coordinate to the road network.
271
+
272
+ ## Range-addressable bundles
273
+
274
+ `encodeRoadGraphBundle()` creates a single `.efrb` file with a fixed-size
275
+ directory followed by independently readable EFR2 tile chunks. Opening an
276
+ `.efrb` URL downloads its 32-byte header and directory first, then requests
277
+ only the route-relevant tile byte ranges:
278
+
279
+ ```ts
280
+ const router = await RoadRouter.open("/routing/region.efrb");
281
+ ```
282
+
283
+ Serve bundles as `application/octet-stream` without whole-file HTTP content
284
+ encoding so byte offsets remain stable. The bundle reader already supports
285
+ individually gzip-compressed chunks; use
286
+ `encodeCompressedRoadGraphBundle()` when creating one in a browser-compatible
287
+ runtime. Spatial indexes are runtime structures for now, rather than part of
288
+ the on-disk format.
289
+
290
+ `roadgraph-compile` starts its worker with a 2 GB heap ceiling by default and
291
+ checks RSS during every compiler stage. Use `--max-memory-mb` to set a lower
292
+ ceiling; values above 3072 are intentionally rejected.
293
+
294
+ ## Measured on a real extract
295
+
296
+ Portugal from Geofabrik (`portugal-latest.osm.pbf`, 425 MB) compiled on one
297
+ desktop machine with default settings. "Before" is the same pipeline prior to
298
+ the v6 storage layout and the smaller tile overlap:
299
+
300
+ | | Before | Now | |
301
+ | --- | --- | --- | --- |
302
+ | PBF passes | 2 | 2 | unchanged |
303
+ | Compile time | 8 min 04 s | 7 min 46 s | −4% |
304
+ | Peak RSS | 1.80 GB | **1.11 GB** | −38% |
305
+ | Uncompressed graph | 114.85 MB | **51.17 MB** | −55% |
306
+ | Final EFRB | 71.23 MB | **38.03 MB** | −47% |
307
+ | Compression ratio | 0.620 | 0.743 | denser input compresses less |
308
+ | Routing edges | 2,757,265 | 2,757,265 | identical graph |
309
+ | Banned turn pairs / runs | 33,796 / 541 | 28,344 / 468 | fewer duplicate copies across tiles |
310
+
311
+ Routes from that bundle, each measured against a fresh reader:
312
+
313
+ | Route | Tiles | Range bytes | Graph load | A* | Distance | Duration |
314
+ | --- | --- | --- | --- | --- | --- | --- |
315
+ | Short urban (Lisbon) | 1 | **4.93 MB** | 118 ms | 86 ms | 5.37 km | 5.9 min |
316
+ | Medium regional (Lisbon–Setúbal) | 2 | **5.00 MB** | 340 ms | 152 ms | 48.41 km | 32.4 min |
317
+ | Long distance (Porto–Faro) | 15 | **36.41 MB** | 1.99 s | 1.47 s | 553.20 km | 284.8 min |
318
+ | Cross-tile (Braga–Évora) | 15 | **36.41 MB** | 2.09 s | 1.35 s | 460.16 km | 244.3 min |
319
+
320
+ Downloads are roughly 35% smaller for short routes and 47% smaller for long
321
+ ones, and graph load is *faster* than before despite the varint decoding,
322
+ because there is less to decompress.
323
+
324
+ ### Nothing routes differently
325
+
326
+ `tools/compare-bundles.mjs` routes the same random pairs through two bundles
327
+ and reports any disagreement. Comparing the previous bundle against this one
328
+ over 145 routes drawn from real node positions in the densest tile:
329
+
330
+ ```
331
+ routes_compared 145 identical_duration 145 differing 0 worst_duration_drift 0.0000%
332
+ ```
333
+
334
+ `scripts/validate-bundle.mjs` separately confirms that none of 300 sampled
335
+ routes uses a banned turn pair or a banned via-way run.
336
+
337
+ ### Compression: gzip stays
338
+
339
+ Measured on the three densest tiles (58.8 MB uncompressed):
340
+
341
+ | Codec | Size | vs gzip | Compress | Decompress | Browser |
342
+ | --- | --- | --- | --- | --- | --- |
343
+ | gzip −9 (current) | 37.10 MB | — | 1.6 s | 285 ms | yes |
344
+ | brotli q5 | 30.39 MB | −18.1% | 1.8 s | 294 ms | no |
345
+ | brotli q11 | 25.37 MB | −31.6% | 140 s | 336 ms | no |
346
+ | zstd level 3 | 37.79 MB | +1.8% | 0.3 s | 93 ms | no |
347
+ | zstd level 19 | 32.47 MB | −12.5% | 14 s | 142 ms | no |
348
+
349
+ Brotli and Zstandard both beat gzip on size, and Zstandard is three times
350
+ faster to decode. Neither is reachable from `DecompressionStream`, which only
351
+ offers gzip and deflate, so adopting one would mean shipping a decoder and
352
+ giving up browser support for an 18% saving. gzip stays.
353
+
354
+ ### Tile size is the main lever
355
+
356
+ The same extract at `--tile-size-km 40` produces 126 tiles and a 105.3 MB
357
+ bundle (compile time 8 min 9 s, same two passes, peak RSS 2.04 GB). Local routes get much
358
+ cheaper, because a tile is the unit a route pays for:
359
+
360
+ | Route | 120 km tiles | 40 km tiles |
361
+ | --- | --- | --- |
362
+ | Short urban | 7.41 MB, 182 ms load | 3.44 MB, 62 ms load |
363
+ | Medium regional | 7.41 MB, 156 ms load | 4.73 MB, 267 ms load |
364
+ | Long distance | 42.66 MB, 1.48 s load | 48.18 MB, 1.26 s load |
365
+
366
+ Smaller tiles also narrow the corridor a long route loads, which can cost
367
+ optimality at the edges: Braga–Évora comes back as 460 km / 244 min at 120 km
368
+ tiles and 430 km / 266 min at 40 km — a shorter but slower road, because the
369
+ faster motorway link fell outside the narrower corridor. Pick the tile size
370
+ for the routes an application actually serves.
371
+
372
+ Smaller tiles also cost compiler memory, because padding duplicates each
373
+ boundary road into every tile that overlaps it: 40 km tiles peaked at 2.04 GB
374
+ against the default 2 GB ceiling. Raise `--max-memory-mb` (up to 3072) before
375
+ going below that, or the memory guard will stop the compile rather than let it
376
+ swap.
377
+
378
+ ### Restrictions checked against the compiled data
379
+
380
+ `scripts/validate-bundle.mjs` checks the restriction invariant on real
381
+ compiled data rather than on hand-built graphs. It samples routes between
382
+ junctions that a restriction constrains and asserts that no returned route uses
383
+ a banned pair:
384
+
385
+ ```sh
386
+ node scripts/validate-bundle.mjs region.efrb --samples 300 [--merge]
387
+ ```
388
+
389
+ Across 300 routes in the densest tile (386k nodes, 853k edges, 7,120 banned
390
+ pairs) there were no violations, and 102 of those routes differ from what the
391
+ same search returns with the restrictions removed — so the restrictions are
392
+ being enforced, not merely stored. Repeating it over two merged tiles (568k
393
+ nodes, 11,604 banned pairs) also finds no violations, so restrictions survive
394
+ the cross-tile merge.
395
+
396
+ ## Supported OSM features and limitations
397
+
398
+ What the `car` profile reads, and what it deliberately does not.
399
+
400
+ **Supported**
401
+
402
+ | Area | Detail |
403
+ | --- | --- |
404
+ | Highway classes | `motorway` through `service` and `road`, including `_link` variants |
405
+ | Access | `access`, `vehicle`, `motor_vehicle`, `motorcar`, most specific key wins |
406
+ | Direction | `oneway=yes/no/-1/reverse/true/false/1/0`, `junction=roundabout/circular`, implicit one-way on motorways |
407
+ | Speed | `maxspeed`, `maxspeed:forward`, `maxspeed:backward`; km/h, `mph`, `km/h`, `kmh`, `knots`, `walk` |
408
+ | Turn restrictions | `no_left_turn`, `no_right_turn`, `no_straight_on`, `no_u_turn`, `no_entry`, `no_exit`, `only_left_turn`, `only_right_turn`, `only_straight_on`, `only_u_turn` |
409
+ | Restriction scope | `via` node and `via` way; `restriction:motorcar`, `restriction:motor_vehicle` and `restriction:vehicle` override the generic key; `except` listing a car mode drops the relation |
410
+
411
+ **Not supported**
412
+
413
+ | Area | Behaviour |
414
+ | --- | --- |
415
+ | Profiles other than `car` | Only `car` ships. `getRoutingProfile` throws for anything else. |
416
+ | Conditional restrictions | `restriction:conditional`, `access:conditional` and time-of-day rules are ignored, so a time-limited restriction is treated as absent. |
417
+ | `via` ways longer than four edges | Skipped rather than approximated, and counted as `turnRestrictionsSkipped` in the compile report. |
418
+ | Restrictions spanning a tile boundary | Dropped for tiles that do not hold every edge of the run, rather than enforced in a shortened form. |
419
+ | Turn costs and traffic signals | Not modelled; a turn costs nothing beyond the roads it joins. |
420
+ | Live traffic, elevation, ferries as a distinct mode | Not modelled. Ferry ways are excluded with everything that is not a road. |
421
+ | `barrier`, `motor_vehicle:conditional`, toll avoidance | Not read. |
422
+ | EFRT v1 | Carries topology only: encoding to it drops geometry and turn restrictions. Use EFR2 for anything that needs them. |
423
+
424
+ The compiler reports what it skipped. `turnRestrictionsRead` against
425
+ `turnRestrictionsSkipped` in `<output>.json` tells you how much of a region's
426
+ restriction data was understood.
427
+
428
+ ## Licence
429
+
430
+ MIT. See [LICENSE](LICENSE).
431
+
432
+ ## Benchmark a bundle
433
+
434
+ Use exact or near-road coordinates to measure representative routes without
435
+ downloading the whole file. Each `--route` is measured against a fresh bundle
436
+ reader, so nothing is reused between them. The runner reports bundle size,
437
+ compression ratio, nodes, edges and banned turns for the whole bundle, then
438
+ per route: tiles loaded, Range bytes, graph-load time, A* time, memory delta,
439
+ distance and duration.
440
+
441
+ ```sh
442
+ npm run benchmark -- path/to/region.efrb \
443
+ --route "short urban=38.7223,-9.1393>38.7369,-9.1427" \
444
+ --route "long distance=41.1496,-8.6109>37.0194,-7.9304"
445
+ ```
446
+
447
+ Large `.osm.pbf` extracts are external benchmark inputs. Download one from a
448
+ provider such as Geofabrik when you want to reproduce these numbers; none is
449
+ committed to, or depended on by, this package.