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 +44 -0
- package/LICENSE +21 -0
- package/README.md +448 -2
- package/dist/chunk-4FIOQAFG.js +1333 -0
- package/dist/index.d.ts +478 -0
- package/dist/index.js +616 -0
- package/dist/worker.d.ts +2 -0
- package/dist/worker.js +20 -0
- package/package.json +75 -4
- package/scripts/roadgraph-compile.mjs +462 -0
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
|
-
#
|
|
1
|
+
# roadgraph-router
|
|
2
2
|
|
|
3
|
-
|
|
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.
|