roadgraph-router-v2 0.1.0 → 0.3.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/dist/worker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  decodeGraph,
3
3
  route
4
- } from "./chunk-4FIOQAFG.js";
4
+ } from "./chunk-QSIKDM4A.js";
5
5
 
6
6
  // src/worker.ts
7
7
  var graph = null;
@@ -13,7 +13,7 @@ self.onmessage = ({ data }) => {
13
13
  return;
14
14
  }
15
15
  if (!graph) throw new Error("Load a routing graph before requesting a route.");
16
- self.postMessage({ id: data.id, ok: true, route: route(graph, data.from, data.to, data.preference) });
16
+ self.postMessage({ id: data.id, ok: true, route: route(graph, data.from, data.to, data.preference, { departAt: data.departAt, publicHoliday: data.publicHoliday }) });
17
17
  } catch (error) {
18
18
  self.postMessage({ id: data.id, ok: false, error: error instanceof Error ? error.message : "Routing failed" });
19
19
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roadgraph-router-v2",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Compile any OpenStreetMap PBF extract into compact, range-addressable routing bundles and route over them with A* in Node.js or the browser.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  "homepage": "https://github.com/danieljvsa/roadgraph-router#readme",
36
36
  "license": "MIT",
37
37
  "engines": {
38
- "node": ">=22.5"
38
+ "node": ">=18"
39
39
  },
40
40
  "exports": {
41
41
  ".": {
@@ -16,20 +16,28 @@ import { spawnSync } from "node:child_process";
16
16
  const EFR2_MAGIC = 0x32524645, EFRB_MAGIC = 0x42524645, SCALE = 10_000_000, HEADER = 32, DIRECTORY = 40;
17
17
  let observedPeakRss = 0;
18
18
 
19
- async function loadProfile(name) {
19
+ async function loadLibrary() {
20
20
  try {
21
- const { getRoutingProfile } = await import(new URL("../dist/index.js", import.meta.url).href);
22
- return getRoutingProfile(name);
21
+ return await import(new URL("../dist/index.js", import.meta.url).href);
23
22
  } catch (error) {
24
23
  if (error?.code === "ERR_MODULE_NOT_FOUND") throw new Error("roadgraph-compile needs the package build. Run `npm run build` first.");
25
24
  throw error;
26
25
  }
27
26
  }
28
27
 
28
+ /** `--profile car,truck` compiles one bundle both vehicles can be routed on. */
29
+ async function loadProfiles(names) {
30
+ const library = await loadLibrary();
31
+ const wanted = names.split(",").map((name) => name.trim()).filter(Boolean);
32
+ if (!wanted.length) throw new Error("--profile needs at least one vehicle.");
33
+ if (new Set(wanted).size !== wanted.length) throw new Error("--profile lists the same vehicle twice.");
34
+ return { library, profiles: wanted.map((name) => library.getRoutingProfile(name)) };
35
+ }
36
+
29
37
  /** Reports a problem the way a command-line tool should, without a stack trace. */
30
38
  function fail(message) { console.error(`roadgraph-compile: ${message}`); process.exit(1); }
31
39
 
32
- function usage() { console.error("Usage: roadgraph-compile input.osm.pbf --output region.efrb [--profile car] [--tile-size-km 120] [--padding-km 2] [--compression gzip|none] [--shape-tolerance-m 2] [--max-memory-mb 2048] [--bbox minLon,minLat,maxLon,maxLat]"); }
40
+ function usage() { console.error("Usage: roadgraph-compile input.osm.pbf --output region.efrb [--profile car,truck,motorcycle] [--default-vehicle articulated] [--tile-size-km 120] [--padding-km 2] [--compression gzip|none] [--shape-tolerance-m 2] [--max-memory-mb 2048] [--bbox minLon,minLat,maxLon,maxLat]"); }
33
41
  function opt(name, fallback) { const i = process.argv.indexOf(name); return i < 0 ? fallback : process.argv[i + 1]; }
34
42
  function inputArg() { const values = process.argv.slice(2); for (let i = 0; i < values.length; i += 1) { if (values[i].startsWith("--")) { i += 1; continue; } return values[i]; } return undefined; }
35
43
  function parseBounds(value) { const p = value?.split(",").map(Number); return p?.length === 4 && p.every(Number.isFinite) && p[0] < p[2] && p[1] < p[3] ? { minLon: p[0], minLat: p[1], maxLon: p[2], maxLat: p[3] } : undefined; }
@@ -63,16 +71,25 @@ class Column {
63
71
  view() { return this.values.subarray(0, this.length); }
64
72
  }
65
73
 
66
- function createTileGraph(tile) {
74
+ function createTileGraph(tile, profileCount) {
75
+ const perProfile = (make) => Array.from({ length: profileCount }, make);
67
76
  return {
68
77
  tile,
69
78
  ids: new Map(),
70
79
  nodeLat: new Column(Int32Array), nodeLon: new Column(Int32Array),
71
80
  edgeFrom: new Column(Uint32Array), edgeTo: new Column(Uint32Array),
72
- edgeDistance: new Column(Float64Array), edgeDuration: new Column(Float64Array),
81
+ edgeDistance: new Column(Float64Array),
73
82
  edgeWay: new Column(Float64Array), edgeShapeStart: new Column(Uint32Array),
74
83
  shapeLat: new Column(Int32Array), shapeLon: new Column(Int32Array),
75
- wayEdges: new Map(), restrictions: [], runs: [],
84
+ wayEdges: new Map(),
85
+ // The road network is shared; everything below it belongs to one vehicle.
86
+ edgeDurations: perProfile(() => new Column(Float64Array)),
87
+ edgeUsable: perProfile(() => new Column(Uint8Array)),
88
+ // Sparse: almost no road states a height or a weight.
89
+ edgeLimits: perProfile(() => new Map()),
90
+ edgeConditions: perProfile(() => []),
91
+ restrictions: perProfile(() => []),
92
+ runs: perProfile(() => []),
76
93
  };
77
94
  }
78
95
 
@@ -105,60 +122,164 @@ function scaleFor(values) { let greatest = 0; for (const value of values) greate
105
122
  * Orders a tile's edges by source node and hands the result to the library
106
123
  * encoder, so the compiler and the runtime cannot drift apart on the layout.
107
124
  */
108
- function encodeEfr2(graph, encodeCompactGraph) {
125
+ /**
126
+ * Orders a tile's edges by source node and hands the result to the library
127
+ * encoder, so the compiler and the runtime cannot drift apart on the layout.
128
+ *
129
+ * One vehicle with no physical limits still writes the single-chunk layout it
130
+ * always has. Anything else writes the road network once and a small chunk per
131
+ * vehicle, because that is the only layout with room for an access mask, a
132
+ * restriction mask and a limit table.
133
+ */
134
+ function encodeEfr2(graph, library, profiles) {
109
135
  const nodes = graph.nodeLat.length, edges = graph.edgeFrom.length;
110
- const distances = graph.edgeDistance.view(), durations = graph.edgeDuration.view();
111
- const distanceScale = scaleFor(distances), durationScale = scaleFor(durations);
136
+ const distances = graph.edgeDistance.view();
137
+ const distanceScale = scaleFor(distances);
112
138
  const offsets = new Uint32Array(nodes + 1);
113
139
  for (let i = 0; i < edges; i += 1) offsets[graph.edgeFrom.at(i) + 1] += 1;
114
140
  for (let i = 1; i < offsets.length; i += 1) offsets[i] += offsets[i - 1];
115
141
  const order = offsets.slice(), targets = new Uint32Array(edges), placement = new Uint32Array(edges);
116
- const packedDistances = new Uint16Array(edges), packedDurations = new Uint16Array(edges);
142
+ const packedDistances = new Uint16Array(edges);
117
143
  const shapeOffsets = new Uint32Array(edges + 1);
118
144
  for (let i = 0; i < edges; i += 1) {
119
- const index = order[graph.edgeFrom.at(i)]++;
120
- placement[i] = index;
121
- targets[index] = graph.edgeTo.at(i);
122
- packedDistances[index] = Math.round(distances[i] / distanceScale);
123
- packedDurations[index] = Math.round(durations[i] / durationScale);
145
+ const at = order[graph.edgeFrom.at(i)]++;
146
+ placement[i] = at;
147
+ targets[at] = graph.edgeTo.at(i);
148
+ packedDistances[at] = Math.round(distances[i] / distanceScale);
149
+ const start = graph.edgeShapeStart.at(i);
150
+ const stop = i + 1 < edges ? graph.edgeShapeStart.at(i + 1) : graph.shapeLat.length;
151
+ shapeOffsets[at + 1] = stop - start;
124
152
  }
125
- // Shape runs are stored per source edge, so copy them into CSR order.
126
- const shapeStart = graph.edgeShapeStart.view(), totalPoints = graph.shapeLat.length;
127
- const lengthOf = (i) => (i + 1 < edges ? shapeStart[i + 1] : totalPoints) - shapeStart[i];
128
- const sourceOf = new Uint32Array(edges);
129
- for (let i = 0; i < edges; i += 1) sourceOf[placement[i]] = i;
130
- for (let index = 0; index < edges; index += 1) shapeOffsets[index + 1] = shapeOffsets[index] + lengthOf(sourceOf[index]);
131
- const shapeLat = new Int32Array(totalPoints), shapeLon = new Int32Array(totalPoints);
132
- const sourceLat = graph.shapeLat.view(), sourceLon = graph.shapeLon.view();
133
- for (let index = 0; index < edges; index += 1) {
134
- const source = sourceOf[index], from = shapeStart[source], count = lengthOf(source);
135
- for (let point = 0; point < count; point += 1) { shapeLat[shapeOffsets[index] + point] = sourceLat[from + point]; shapeLon[shapeOffsets[index] + point] = sourceLon[from + point]; }
153
+ for (let i = 1; i <= edges; i += 1) shapeOffsets[i] += shapeOffsets[i - 1];
154
+ const shapeLat = new Int32Array(graph.shapeLat.length), shapeLon = new Int32Array(graph.shapeLon.length);
155
+ for (let i = 0; i < edges; i += 1) {
156
+ const start = graph.edgeShapeStart.at(i);
157
+ const stop = i + 1 < edges ? graph.edgeShapeStart.at(i + 1) : graph.shapeLat.length;
158
+ let out = shapeOffsets[placement[i]];
159
+ for (let point = start; point < stop; point += 1, out += 1) { shapeLat[out] = graph.shapeLat.at(point); shapeLon[out] = graph.shapeLon.at(point); }
136
160
  }
161
+
137
162
  // Restrictions are collected against pre-grouping edge positions, so they
138
- // follow each edge into the CSR layout through the same permutation.
139
- const banned = [...new Set(graph.restrictions.map(([from, to]) => `${placement[from]}:${placement[to]}`))].map((pair) => pair.split(":").map(Number)).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
140
- const runs = [...new Set(graph.runs.map((run) => run.map((edge) => placement[edge]).join(",")))].map((run) => run.split(",").map(Number)).filter((run) => run.length >= 3).sort((a, b) => a[0] - b[0] || a.length - b.length || a[1] - b[1]);
163
+ // follow each edge into the CSR layout through the same permutation. Every
164
+ // vehicle's bans go into one list, with a bit each saying who obeys which.
165
+ const pairKeys = new Map(), runKeys = new Map();
166
+ const pairOwners = [], runOwners = [];
167
+ for (let profileIndex = 0; profileIndex < profiles.length; profileIndex += 1) {
168
+ for (const [from, to] of graph.restrictions[profileIndex]) {
169
+ const key = `${placement[from]}:${placement[to]}`;
170
+ let row = pairKeys.get(key);
171
+ if (row === undefined) { row = pairKeys.size; pairKeys.set(key, row); pairOwners.push(new Set()); }
172
+ pairOwners[row].add(profileIndex);
173
+ }
174
+ for (const run of graph.runs[profileIndex]) {
175
+ const key = run.map((edge) => placement[edge]).join(",");
176
+ let row = runKeys.get(key);
177
+ if (row === undefined) { row = runKeys.size; runKeys.set(key, row); runOwners.push(new Set()); }
178
+ runOwners[row].add(profileIndex);
179
+ }
180
+ }
181
+ const pairRows = [...pairKeys.keys()].map((key) => key.split(":").map(Number));
182
+ const runRows = [...runKeys.keys()].map((key) => key.split(",").map(Number)).filter((run) => run.length >= 3);
183
+ const pairOrder = pairRows.map((pair, index) => index).sort((a, b) => pairRows[a][0] - pairRows[b][0] || pairRows[a][1] - pairRows[b][1]);
184
+ const runOrder = runRows.map((run, index) => index).sort((a, b) => runRows[a][0] - runRows[b][0] || runRows[a].length - runRows[b].length || runRows[a][1] - runRows[b][1]);
185
+ const banned = pairOrder.map((index) => pairRows[index]);
186
+ const runs = runOrder.map((index) => runRows[index]);
141
187
  const runOffsets = new Uint32Array(runs.length + 1);
142
188
  for (let i = 0; i < runs.length; i += 1) runOffsets[i + 1] = runOffsets[i] + runs[i].length;
143
- return encodeCompactGraph({
189
+
190
+ const durationScales = profiles.map((_, index) => scaleFor(graph.edgeDurations[index].view()));
191
+ const durationColumns = profiles.map((_, index) => {
192
+ const source = graph.edgeDurations[index].view(), packed = new Uint16Array(edges);
193
+ const scale = durationScales[index];
194
+ // Durations are whole units, and rounding one down can leave a short edge
195
+ // costing nothing at all or implying a speed no vehicle of this class can
196
+ // reach. A free edge is a shortcut the search will take, and a speed above
197
+ // the profile's ceiling makes the A* heuristic inadmissible, which lets it
198
+ // settle for a route that is not the best one. So no edge is ever stored
199
+ // as quicker than its own length at the profile's top speed.
200
+ const ceilingMps = (profiles[index].maxSpeedKph * 1000) / 3600;
201
+ for (let i = 0; i < edges; i += 1) {
202
+ const atCeiling = distances[i] / ceilingMps;
203
+ packed[placement[i]] = Math.max(Math.round(source[i] / scale), Math.ceil(atCeiling / scale));
204
+ }
205
+ return packed;
206
+ });
207
+
208
+ const assembled = {
144
209
  nodeLatitudes: graph.nodeLat.view(), nodeLongitudes: graph.nodeLon.view(),
145
210
  edgeOffsets: offsets, edgeTargets: targets,
146
- edgeDistancesMeters: packedDistances, edgeDurationsSeconds: packedDurations,
147
- coordinateScale: SCALE, edgeDistanceScale: distanceScale, edgeDurationScale: durationScale,
211
+ edgeDistancesMeters: packedDistances, edgeDurationsSeconds: durationColumns[0],
212
+ coordinateScale: SCALE, edgeDistanceScale: distanceScale, edgeDurationScale: durationScales[0],
213
+ // Without this a bundle compiled for one vehicle reads back as a car,
214
+ // which is the one mistake the profile metadata exists to prevent.
215
+ profileId: profiles[0].id, maxSpeedKph: profiles[0].maxSpeedKph,
148
216
  edgeShapeOffsets: shapeOffsets, edgeShapeLatitudes: shapeLat, edgeShapeLongitudes: shapeLon,
149
217
  ...(banned.length ? { turnRestrictionFromEdges: Uint32Array.from(banned, (pair) => pair[0]), turnRestrictionToEdges: Uint32Array.from(banned, (pair) => pair[1]) } : {}),
150
218
  ...(runs.length ? { turnRestrictionSequenceOffsets: runOffsets, turnRestrictionSequenceEdges: Uint32Array.from(runs.flat()) } : {}),
219
+ };
220
+
221
+ const split = profiles.length > 1 || profiles.some((entry) => entry.usesPhysicalLimits);
222
+ if (!split) {
223
+ // One vehicle writes one chunk, and that chunk is its whole graph, so the
224
+ // conditional restrictions belong in it. Without this a bundle compiled
225
+ // for a single vehicle would read as though no road were ever shut.
226
+ const onlyConditions = library.packEdgeConditions(
227
+ graph.edgeConditions[0].map((row) => ({ ...row, edge: placement[row.edge] })),
228
+ );
229
+ const encoded = library.encodeCompactGraph(onlyConditions ? { ...assembled, edgeConditions: onlyConditions } : assembled);
230
+ return { shared: encoded, profiles: [], limitedEdges: 0, conditionalEdges: onlyConditions?.edges.length ?? 0 };
231
+ }
232
+
233
+ const restrictionCount = banned.length + runs.length;
234
+ let limitedEdges = 0;
235
+ let conditionalEdges = 0;
236
+ const chunks = profiles.map((entry, profileIndex) => {
237
+ const usable = graph.edgeUsable[profileIndex].view();
238
+ // `placement` maps a built edge to its stored position, so the mask is
239
+ // opened for everything and then closed at the stored index of each road
240
+ // this vehicle cannot use.
241
+ const edgeAccess = library.accessMaskFor(edges, () => true);
242
+ for (let i = 0; i < edges; i += 1) if (!usable[i]) edgeAccess[placement[i] >> 3] &= ~(1 << (placement[i] & 7));
243
+ const entries = [];
244
+ for (const [edge, limits] of graph.edgeLimits[profileIndex]) entries.push({ edge: placement[edge], limits });
245
+ const edgeLimits = library.packEdgeLimits(entries);
246
+ limitedEdges += edgeLimits?.edges.length ?? 0;
247
+ const edgeConditions = library.packEdgeConditions(
248
+ graph.edgeConditions[profileIndex].map((row) => ({ ...row, edge: placement[row.edge] })),
249
+ );
250
+ conditionalEdges += edgeConditions?.edges.length ?? 0;
251
+ const restrictionMask = library.accessMaskFor(restrictionCount, (index) => index < banned.length
252
+ ? pairOwners[pairOrder[index]].has(profileIndex)
253
+ : runOwners[runOrder[index - banned.length]].has(profileIndex));
254
+ return library.encodeProfileChunk({
255
+ profileId: entry.id,
256
+ maxSpeedKph: entry.maxSpeedKph,
257
+ edgeDurationScale: durationScales[profileIndex],
258
+ edgeDurationsSeconds: durationColumns[profileIndex],
259
+ edgeAccess,
260
+ ...(edgeConditions ? { edgeConditions } : {}),
261
+ ...(edgeLimits ? { edgeLimits } : {}),
262
+ ...(restrictionCount ? { restrictionCount, restrictionMask } : {}),
263
+ });
151
264
  });
265
+ return { shared: library.encodeSharedGraph(assembled), profiles: chunks, limitedEdges, conditionalEdges };
152
266
  }
153
267
 
154
268
  /** Writes the fixed-size directory, then streams each chunk straight from the
155
269
  * spill file. Neither the chunk list nor the finished bundle is ever held in
156
270
  * memory, so output size no longer bounds how large a region can be built. */
157
- async function writeBundle(output, chunks, compression, spillPath) {
271
+ async function writeBundle(output, chunks, compression, spillPath, profiles, split, defaultVehicle, VEHICLE_PRESET_IDS) {
158
272
  const directoryBytes = chunks.length * DIRECTORY;
159
273
  const head = Buffer.alloc(HEADER + directoryBytes);
160
- head.writeUInt32LE(EFRB_MAGIC, 0); head.writeUInt16LE(1, 4); head.writeUInt16LE(HEADER, 6);
274
+ head.writeUInt32LE(EFRB_MAGIC, 0); head.writeUInt16LE(split ? 2 : 1, 4); head.writeUInt16LE(HEADER, 6);
161
275
  head.writeUInt32LE(chunks.length, 8); head.writeUInt32LE(HEADER, 12); head.writeUInt32LE(directoryBytes, 16);
276
+ // The vehicle, in the header as well as in every chunk, so a reader knows it
277
+ // before downloading a tile and can size its tile budget accordingly.
278
+ head.writeUInt8(profiles[0].id, 20);
279
+ head.writeUInt8(defaultVehicle ? VEHICLE_PRESET_IDS[defaultVehicle] ?? 0 : 0, 21);
280
+ head.writeUInt16LE(Math.round(Math.max(...profiles.map((entry) => entry.maxSpeedKph))), 22);
281
+ head.writeUInt32LE(profiles.reduce((mask, entry) => mask | (1 << (entry.id - 1)), 0) >>> 0, 24);
282
+ head.writeUInt32LE(split ? chunks.length / (profiles.length + 1) : chunks.length, 28);
162
283
  let offset = HEADER + directoryBytes;
163
284
  for (let i = 0; i < chunks.length; i += 1) {
164
285
  const chunk = chunks[i], at = HEADER + i * DIRECTORY, b = chunk.tile.bounds;
@@ -167,6 +288,7 @@ async function writeBundle(output, chunks, compression, spillPath) {
167
288
  head.writeInt32LE(fixed(b.maxLat), at + 12); head.writeInt32LE(fixed(b.maxLon), at + 16);
168
289
  head.writeUInt32LE(offset, at + 20); head.writeUInt32LE(chunk.byteLength, at + 24);
169
290
  head.writeUInt32LE(chunk.rawBytes, at + 28); head.writeUInt16LE(compression === "gzip" ? 1 : 0, at + 32);
291
+ head.writeUInt8(chunk.profileId ?? 0, at + 34);
170
292
  offset += chunk.byteLength;
171
293
  }
172
294
  await mkdir(dirname(output), { recursive: true });
@@ -205,16 +327,46 @@ function memoryGuard(limitBytes, label, count) { if (count % 1_000 !== 0) return
205
327
  * restrictions that apply to it. Restriction members are kept as OSM ids and
206
328
  * resolved to edges later, because the relation may be read before its ways.
207
329
  */
208
- async function firstPass(file, roadFile, limitBytes, profile) {
330
+ async function firstPass(file, roadFile, limitBytes, profiles) {
209
331
  const required = approximateIds(), keeperNodes = approximateIds(), stream = createWriteStream(roadFile), bounds = { minLat: Infinity, minLon: Infinity, maxLat: -Infinity, maxLon: -Infinity };
210
- const restrictions = [], restrictionWays = new Set();
211
- let ways = 0, entities = 0, skippedRestrictions = 0;
212
- for await (const entity of createOSMStream(file, { withTags: { node: false, way: [...profile.wayTagKeys], relation: [...profile.relationTagKeys] } })) {
332
+ const restrictions = [], restrictionWays = new Set(), barriers = [];
333
+ let ways = 0, entities = 0, skippedRestrictions = 0, barrierCount = 0, conditionalRestrictions = 0;
334
+ // One pass serves every vehicle: the reader is asked for the union of the
335
+ // tags they read, and each way is offered to all of them.
336
+ const wayTags = [...new Set(profiles.flatMap((entry) => entry.wayTagKeys))];
337
+ const relationTags = [...new Set(profiles.flatMap((entry) => entry.relationTagKeys))];
338
+ // Barriers live on nodes, so the reader is asked for their tags too. Only
339
+ // nodes carrying one of these keys arrive with tags, which is a small
340
+ // fraction of a country.
341
+ const nodeTags = [...new Set(profiles.flatMap((entry) => entry.nodeTagKeys ?? []))];
342
+ for await (const entity of createOSMStream(file, { withTags: { node: nodeTags.length ? nodeTags : false, way: wayTags, relation: relationTags } })) {
213
343
  memoryGuard(limitBytes, "scanning PBF topology", ++entities);
214
- if (entity.type === "node") { bounds.minLat = Math.min(bounds.minLat, entity.lat); bounds.minLon = Math.min(bounds.minLon, entity.lon); bounds.maxLat = Math.max(bounds.maxLat, entity.lat); bounds.maxLon = Math.max(bounds.maxLon, entity.lon); continue; }
344
+ if (entity.type === "node") {
345
+ bounds.minLat = Math.min(bounds.minLat, entity.lat); bounds.minLon = Math.min(bounds.minLon, entity.lon);
346
+ bounds.maxLat = Math.max(bounds.maxLat, entity.lat); bounds.maxLon = Math.max(bounds.maxLon, entity.lon);
347
+ // A node that stops somebody is remembered against the profiles it
348
+ // stops. Most nodes carry no tags at all and cost nothing here.
349
+ const tags = entity.tags;
350
+ if (tags?.barrier) {
351
+ for (let index = 0; index < profiles.length; index += 1) {
352
+ if (profiles[index].evaluateBarrier?.(tags) === false) {
353
+ (barriers[index] ??= new Set()).add(entity.id);
354
+ barrierCount += 1;
355
+ }
356
+ }
357
+ }
358
+ continue;
359
+ }
215
360
  if (entity.type === "relation") {
216
- const parsed = profile.evaluateTurnRestriction(entity.tags ?? {});
361
+ // A restriction can apply to one vehicle and not another, and can even
362
+ // mean something different to each, so every vehicle's answer is kept.
363
+ const perProfile = profiles.map((entry) => entry.evaluateTurnRestriction(entity.tags ?? {}));
364
+ const parsed = perProfile.find(Boolean) ?? null;
217
365
  if (!parsed) continue;
366
+ // A turn banned only at certain hours is banned at every hour here.
367
+ // Counting them says how much of a region that costs rather than
368
+ // leaving it to be discovered: in Portugal it is 1 relation in 26,599.
369
+ if (Object.keys(entity.tags ?? {}).some((key) => key.endsWith(":conditional"))) conditionalRestrictions += 1;
218
370
  const members = entity.members ?? [];
219
371
  const from = members.find((member) => member.role === "from" && member.type === "way");
220
372
  const to = members.find((member) => member.role === "to" && member.type === "way");
@@ -224,7 +376,7 @@ async function firstPass(file, roadFile, limitBytes, profile) {
224
376
  if (via.length === 1 && via[0].type === "node") {
225
377
  const viaNode = Number(via[0].ref);
226
378
  keeperNodes.add(viaNode);
227
- restrictions.push({ kind: parsed.kind, from: Number(from.ref), to: Number(to.ref), via: viaNode });
379
+ restrictions.push({ kinds: perProfile.map((entry) => entry?.kind ?? null), from: Number(from.ref), to: Number(to.ref), via: viaNode });
228
380
  continue;
229
381
  }
230
382
  // A `via` way names a whole link road, so the ban covers a run of edges
@@ -233,25 +385,32 @@ async function firstPass(file, roadFile, limitBytes, profile) {
233
385
  if (!via.every((member) => member.type === "way")) { skippedRestrictions += 1; continue; }
234
386
  const viaWays = via.map((member) => Number(member.ref));
235
387
  for (const way of viaWays) restrictionWays.add(way);
236
- restrictions.push({ kind: parsed.kind, from: Number(from.ref), to: Number(to.ref), viaWays });
388
+ restrictions.push({ kinds: perProfile.map((entry) => entry?.kind ?? null), from: Number(from.ref), to: Number(to.ref), viaWays });
237
389
  continue;
238
390
  }
239
391
  if (entity.type !== "way") continue;
240
392
  const tags = entity.tags ?? {}, refs = entity.refs ?? [];
241
393
  if (refs.length < 2) continue;
242
- const traversal = profile.evaluateWay(tags);
243
- if (!traversal) continue;
394
+ const lanes = profiles.map((entry) => entry.evaluateWay(tags));
395
+ if (!lanes.some(Boolean)) continue;
244
396
  ways += 1;
245
397
  for (const id of refs) { if (required.has(id)) keeperNodes.add(id); else required.add(id); }
246
398
  keeperNodes.add(refs[0]); keeperNodes.add(refs[refs.length - 1]);
247
- await write(stream, { id: entity.id, refs, forward: traversal.forward, reverse: traversal.backward, forwardSpeed: traversal.forwardSpeedKph, reverseSpeed: traversal.backwardSpeedKph });
399
+ await write(stream, {
400
+ id: entity.id, refs,
401
+ forward: lanes.map((lane) => lane?.forward ? { speed: lane.forwardSpeedKph, limits: lane.forwardLimits ?? null, conditions: lane.conditions ?? null } : null),
402
+ reverse: lanes.map((lane) => lane?.backward ? { speed: lane.backwardSpeedKph, limits: lane.backwardLimits ?? null, conditions: lane.conditions ?? null } : null),
403
+ });
248
404
  }
249
405
  await new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
250
406
  if (!Number.isFinite(bounds.minLat)) throw new Error("The input contains no OSM nodes.");
251
407
  // Drop restrictions whose ways were filtered out by the profile; they can
252
408
  // never produce a banned pair and would only cost memory downstream.
253
409
  const usable = restrictions.filter((restriction) => restriction.viaWays || required.has(restriction.via));
254
- return { required, keeperNodes, bounds, ways, restrictions: usable, restrictionWays, skippedRestrictions };
410
+ // A barrier can only matter where a road passes through it, and the node
411
+ // has to survive simplification for the ban to have edges to name.
412
+ for (const blocked of barriers) { if (blocked) for (const id of blocked) if (required.has(id)) keeperNodes.add(id); }
413
+ return { required, keeperNodes, bounds, ways, restrictions: usable, restrictionWays, skippedRestrictions, conditionalRestrictions, barriers, barrierCount };
255
414
  }
256
415
 
257
416
  async function secondPass(file, required, limitBytes, databasePath) {
@@ -305,7 +464,7 @@ function viaChains(graph, outgoing, start, viaWays) {
305
464
  * link. `only_*` relations are expanded here into bans on every other way out,
306
465
  * so the runtime never has to know the difference.
307
466
  */
308
- function resolveRestrictions(graph, restrictions) {
467
+ function resolveRestrictions(graph, restrictions, profileIndex) {
309
468
  if (!graph.wayEdges.size) return { banned: [], runs: [] };
310
469
  const outgoing = new Map();
311
470
  for (let index = 0; index < graph.edgeFrom.length; index += 1) {
@@ -314,6 +473,9 @@ function resolveRestrictions(graph, restrictions) {
314
473
  }
315
474
  const banned = [], runs = [];
316
475
  for (const restriction of restrictions) {
476
+ // What this relation means to this vehicle, which may be nothing at all.
477
+ const kind = restriction.kinds[profileIndex];
478
+ if (!kind) continue;
317
479
  const fromAll = graph.wayEdges.get(restriction.from) ?? [];
318
480
  const toAll = graph.wayEdges.get(restriction.to) ?? [];
319
481
  if (!fromAll.length || !toAll.length) continue;
@@ -324,7 +486,7 @@ function resolveRestrictions(graph, restrictions) {
324
486
  const fromEdges = fromAll.filter((index) => graph.edgeTo.at(index) === via);
325
487
  const toEdges = toAll.filter((index) => graph.edgeFrom.at(index) === via);
326
488
  if (!fromEdges.length || !toEdges.length) continue;
327
- if (restriction.kind === "no") { for (const from of fromEdges) for (const to of toEdges) banned.push([from, to]); continue; }
489
+ if (kind === "no") { for (const from of fromEdges) for (const to of toEdges) banned.push([from, to]); continue; }
328
490
  const mandatory = new Set(toEdges);
329
491
  for (const from of fromEdges) for (const to of outgoing.get(via) ?? []) if (!mandatory.has(to)) banned.push([from, to]);
330
492
  continue;
@@ -337,7 +499,7 @@ function resolveRestrictions(graph, restrictions) {
337
499
  const end = graph.edgeTo.at(chain[chain.length - 1]);
338
500
  const toEdges = toAll.filter((index) => graph.edgeFrom.at(index) === end && !chain.includes(index));
339
501
  if (!toEdges.length) continue;
340
- if (restriction.kind === "no") { for (const to of toEdges) runs.push([from, ...chain, to]); continue; }
502
+ if (kind === "no") { for (const to of toEdges) runs.push([from, ...chain, to]); continue; }
341
503
  const mandatory = new Set(toEdges);
342
504
  for (const to of outgoing.get(end) ?? []) if (!mandatory.has(to) && !chain.includes(to)) runs.push([from, ...chain, to]);
343
505
  }
@@ -346,16 +508,21 @@ function resolveRestrictions(graph, restrictions) {
346
508
  return { banned, runs };
347
509
  }
348
510
 
349
- async function build(roadFile, coordinates, topology, layout, bounds, tolerance, paddingKm, tileSizeKm, limitBytes) {
511
+ async function build(roadFile, coordinates, topology, layout, bounds, tolerance, paddingKm, tileSizeKm, limitBytes, profileCount) {
350
512
  const { tiles, grid } = layout, ring = Math.max(1, Math.ceil(paddingKm / tileSizeKm));
351
- const graphs = new Map(tiles.map((tile) => [tile.id, createTileGraph(tile)]));
513
+ const graphs = new Map(tiles.map((tile) => [tile.id, createTileGraph(tile, profileCount)]));
352
514
  const add = (graph, id, point) => {
353
515
  let index = graph.ids.get(id);
354
516
  if (index === undefined) { index = graph.nodeLat.push(fixed(point.lat)); graph.nodeLon.push(fixed(point.lon)); graph.ids.set(id, index); }
355
517
  return index;
356
518
  };
357
519
  let routingEdges = 0;
358
- const addSegment = (wayId, ids, points, velocity) => {
520
+ /**
521
+ * One directed edge, shared by every vehicle that can drive it. `lanes[i]`
522
+ * is that vehicle's speed and limits for this direction, or null where it
523
+ * cannot use the road at all: the edge still exists, with its bit clear.
524
+ */
525
+ const addSegment = (wayId, ids, points, lanes) => {
359
526
  let distance = 0; for (let i = 1; i < points.length; i += 1) distance += metres(points[i - 1], points[i]);
360
527
  if (distance < .2) return;
361
528
  routingEdges += 1;
@@ -366,7 +533,17 @@ async function build(roadFile, coordinates, topology, layout, bounds, tolerance,
366
533
  const index = graph.edgeFrom.push(from);
367
534
  graph.edgeTo.push(to);
368
535
  graph.edgeDistance.push(distance);
369
- graph.edgeDuration.push(distance / (velocity * (1000 / 3600)));
536
+ for (let profileIndex = 0; profileIndex < lanes.length; profileIndex += 1) {
537
+ const lane = lanes[profileIndex];
538
+ // An edge this vehicle cannot use still needs a duration, because the
539
+ // column has one slot per edge. Walking pace makes it expensive rather
540
+ // than free, so a lost access bit cannot turn it into a shortcut.
541
+ const speed = lane ? lane.speed : 5;
542
+ graph.edgeDurations[profileIndex].push(distance / (speed * (1000 / 3600)));
543
+ graph.edgeUsable[profileIndex].push(lane ? 1 : 0);
544
+ if (lane?.limits) graph.edgeLimits[profileIndex].set(index, lane.limits);
545
+ if (lane?.conditions) for (const condition of lane.conditions) graph.edgeConditions[profileIndex].push({ edge: index, ...condition });
546
+ }
370
547
  graph.edgeWay.push(tracked ? wayId : 0);
371
548
  graph.edgeShapeStart.push(graph.shapeLat.length);
372
549
  for (const coordinate of shape) { graph.shapeLat.push(fixed(coordinate.lat)); graph.shapeLon.push(fixed(coordinate.lon)); }
@@ -387,35 +564,80 @@ async function build(roadFile, coordinates, topology, layout, bounds, tolerance,
387
564
  if (!keep(way.refs[i], points[i], i, way.refs.length - 1)) continue;
388
565
  const ids = way.refs.slice(start, i + 1), segment = points.slice(start, i + 1);
389
566
  if (inside(segment[0], bounds)) {
390
- if (way.forward) addSegment(way.id, ids, segment, way.forwardSpeed);
391
- if (way.reverse) addSegment(way.id, ids.slice().reverse(), segment.slice().reverse(), way.reverseSpeed);
567
+ if (way.forward.some(Boolean)) addSegment(way.id, ids, segment, way.forward);
568
+ if (way.reverse.some(Boolean)) addSegment(way.id, ids.slice().reverse(), segment.slice().reverse(), way.reverse);
392
569
  }
393
570
  start = i;
394
571
  }
395
572
  }
396
- let bannedTurns = 0, bannedRuns = 0;
573
+ let bannedTurns = 0, bannedRuns = 0, barrierBans = 0;
397
574
  for (const graph of graphs.values()) {
398
- const resolved = resolveRestrictions(graph, topology.restrictions);
399
- graph.restrictions = resolved.banned;
400
- graph.runs = resolved.runs;
401
- bannedTurns += resolved.banned.length;
402
- bannedRuns += resolved.runs.length;
575
+ // Incoming and outgoing edges per node, for turning a barrier into bans.
576
+ const incoming = new Map(), outgoing = new Map();
577
+ if (topology.barriers?.some(Boolean)) {
578
+ for (let edge = 0; edge < graph.edgeFrom.length; edge += 1) {
579
+ const from = graph.edgeFrom.at(edge), to = graph.edgeTo.at(edge);
580
+ const out = outgoing.get(from); if (out) out.push(edge); else outgoing.set(from, [edge]);
581
+ const into = incoming.get(to); if (into) into.push(edge); else incoming.set(to, [edge]);
582
+ }
583
+ }
584
+
585
+ for (let profileIndex = 0; profileIndex < profileCount; profileIndex += 1) {
586
+ const resolved = resolveRestrictions(graph, topology.restrictions, profileIndex);
587
+ // A barrier stops you passing through a node, which is a ban on every
588
+ // movement across it. Turning back the way you came is still allowed:
589
+ // you can drive up to a locked gate and reverse out again.
590
+ const blocked = topology.barriers?.[profileIndex];
591
+ if (blocked) {
592
+ for (const id of blocked) {
593
+ const node = graph.ids.get(id);
594
+ if (node === undefined) continue;
595
+ for (const into of incoming.get(node) ?? []) {
596
+ for (const out of outgoing.get(node) ?? []) {
597
+ if (graph.edgeTo.at(out) === graph.edgeFrom.at(into)) continue;
598
+ resolved.banned.push([into, out]);
599
+ barrierBans += 1;
600
+ }
601
+ }
602
+ }
603
+ }
604
+ graph.restrictions[profileIndex] = resolved.banned;
605
+ graph.runs[profileIndex] = resolved.runs;
606
+ bannedTurns += resolved.banned.length;
607
+ bannedRuns += resolved.runs.length;
608
+ }
403
609
  }
404
610
  const result = [...graphs.values()].filter((graph) => graph.edgeFrom.length), routingNodeIds = new Set();
405
611
  for (const graph of result) for (const id of graph.ids.keys()) routingNodeIds.add(id);
406
612
  for (const graph of graphs.values()) { graph.ids = new Map(); graph.wayEdges = new Map(); graph.edgeWay = null; }
407
- return { graphs: result, routingEdges, routingNodes: routingNodeIds.size, bannedTurns, bannedRuns };
613
+ return { graphs: result, routingEdges, routingNodes: routingNodeIds.size, bannedTurns, bannedRuns, barrierBans };
408
614
  }
409
615
 
410
616
  const input = inputArg(), outputOption = opt("--output"), profileName = opt("--profile", "car"), compression = opt("--compression", "gzip"), tileSizeKm = Number(opt("--tile-size-km", "120")), paddingKm = Number(opt("--padding-km", "2")), tolerance = Number(opt("--shape-tolerance-m", "2")), requested = parseBounds(opt("--bbox"));
411
- const maxMemoryMb = Number(opt("--max-memory-mb", "2048"));
617
+ const maxMemoryMb = Number(opt("--max-memory-mb", "2048")), defaultVehicle = opt("--default-vehicle");
618
+ /**
619
+ * The coordinate index needs `node:sqlite`, which arrived in Node 22.5. The
620
+ * import itself sits in the second pass, by which point the first pass has
621
+ * already streamed the whole extract — minutes of work before an older Node
622
+ * is told it cannot finish. Ask here instead, before anything is read.
623
+ */
624
+ async function assertCompilerRuntime() {
625
+ try { await import("node:sqlite"); } catch {
626
+ fail(`The roadgraph-compile CLI needs Node.js 22.5 or newer for its built-in SQLite coordinate index; this is ${process.version}. The routing library itself runs on Node.js 18 and in the browser.`);
627
+ }
628
+ }
629
+
412
630
  if (!input || !outputOption || !["gzip", "none"].includes(compression) || !(tileSizeKm > 0) || !(paddingKm >= 0) || !(tolerance >= 0) || !(maxMemoryMb > 0 && maxMemoryMb <= 3072) || (opt("--bbox") && !requested)) { usage(); process.exitCode = 1; }
413
631
  else if (!process.env.ROADGRAPH_HEAP_LIMITED) {
632
+ await assertCompilerRuntime();
414
633
  const result = spawnSync(process.execPath, [`--max-old-space-size=${Math.floor(maxMemoryMb)}`, import.meta.filename, ...process.argv.slice(2)], { stdio: "inherit", env: { ...process.env, ROADGRAPH_HEAP_LIMITED: "1" } });
415
634
  process.exitCode = result.status ?? 1;
416
635
  }
417
636
  else {
418
- const profile = await loadProfile(profileName);
637
+ const { library, profiles } = await loadProfiles(profileName);
638
+ const profile = profiles[0];
639
+ if (defaultVehicle && !profiles.some((entry) => entry.usesPhysicalLimits)) fail("--default-vehicle only applies to a profile whose routes depend on the vehicle's size, such as truck.");
640
+ if (defaultVehicle) library.vehiclePreset(defaultVehicle);
419
641
  const started = performance.now(), file = resolve(process.cwd(), input), output = resolve(process.cwd(), outputOption);
420
642
  if (!file.endsWith(".pbf")) fail("roadgraph-compile accepts .osm.pbf files so compilation stays streaming at country scale.");
421
643
  const source = await stat(file).catch(() => fail(`Cannot read "${input}". Check the path and that the file exists.`));
@@ -431,28 +653,37 @@ else {
431
653
  const onSignal = (signal) => { sweep(); process.exit(signal === "SIGINT" ? 130 : 143); };
432
654
  process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal);
433
655
  try {
434
- const limitBytes = maxMemoryMb * 1024 * 1024, first = await firstPass(file, resolve(workspace, "ways.ndjson"), limitBytes, profile), relevantOsmNodes = first.required.count, bounds = requested ?? first.bounds, layout = tilesFor(bounds, tileSizeKm), coordinates = await secondPass(file, first.required, limitBytes, resolve(workspace, "coordinates.sqlite")); first.required.clear();
435
- const built = await build(resolve(workspace, "ways.ndjson"), coordinates, first, layout, bounds, tolerance, paddingKm, tileSizeKm, limitBytes); const storedNodes = coordinates.storedNodes; coordinates.close();
656
+ const limitBytes = maxMemoryMb * 1024 * 1024, first = await firstPass(file, resolve(workspace, "ways.ndjson"), limitBytes, profiles), relevantOsmNodes = first.required.count, bounds = requested ?? first.bounds, layout = tilesFor(bounds, tileSizeKm), coordinates = await secondPass(file, first.required, limitBytes, resolve(workspace, "coordinates.sqlite")); first.required.clear();
657
+ const built = await build(resolve(workspace, "ways.ndjson"), coordinates, first, layout, bounds, tolerance, paddingKm, tileSizeKm, limitBytes, profiles.length); const storedNodes = coordinates.storedNodes; coordinates.close();
436
658
  // Encode, compress and spill each tile in turn, releasing it immediately,
437
659
  // so peak memory tracks the largest single tile rather than the region.
438
- const { encodeCompactGraph } = await import(new URL("../dist/index.js", import.meta.url).href);
439
660
  const spillPath = resolve(workspace, "chunks.bin"), spill = createWriteStream(spillPath), chunks = [];
661
+ let limitedEdges = 0;
662
+ let conditionalEdges = 0;
440
663
  for (let index = 0; index < built.graphs.length; index += 1) {
441
664
  const graph = built.graphs[index];
442
- const raw = new Uint8Array(encodeEfr2(graph, encodeCompactGraph));
443
- const data = compression === "gzip" ? gzipSync(raw, { level: constants.Z_BEST_COMPRESSION }) : raw;
444
- chunks.push({ tile: graph.tile, byteLength: data.byteLength, rawBytes: raw.byteLength });
445
- if (!spill.write(data)) await new Promise((done) => spill.once("drain", done));
665
+ const encoded = encodeEfr2(graph, library, profiles);
666
+ // The shared half first, then each vehicle's: the reader needs a tile's
667
+ // shared row before the rows that depend on it.
668
+ limitedEdges += encoded.limitedEdges ?? 0;
669
+ conditionalEdges += encoded.conditionalEdges ?? 0;
670
+ for (const [profileId, buffer] of [[0, encoded.shared], ...encoded.profiles.map((chunk, at) => [profiles[at].id, chunk])]) {
671
+ const raw = new Uint8Array(buffer);
672
+ const data = compression === "gzip" ? gzipSync(raw, { level: constants.Z_BEST_COMPRESSION }) : raw;
673
+ chunks.push({ tile: graph.tile, profileId, byteLength: data.byteLength, rawBytes: raw.byteLength });
674
+ if (!spill.write(data)) await new Promise((done) => spill.once("drain", done));
675
+ }
446
676
  built.graphs[index] = null;
447
677
  memoryGuard(limitBytes, "writing compressed tiles", 1000);
448
678
  }
449
679
  await new Promise((done, fail) => spill.end((error) => error ? fail(error) : done()));
450
- const bundleBytes = await writeBundle(output, chunks, compression, spillPath);
451
- await verifyBundle(output, chunks.length);
680
+ const split = profiles.length > 1 || profiles.some((entry) => entry.usesPhysicalLimits);
681
+ const bundleBytes = await writeBundle(output, chunks, compression, spillPath, profiles, split, defaultVehicle, library.VehiclePresetId);
682
+ await verifyBundle(output, built.graphs.length);
452
683
  const nodesBefore = storedNodes, nodesAfter = built.routingNodes, edges = built.routingEdges, rawBytes = chunks.reduce((sum, chunk) => sum + chunk.rawBytes, 0);
453
- const report = { formatVersion: 1, input: file, inputBytes: source.size, profile: profile.name, compression: `${compression}-per-tile`, tileSizeKm, paddingKm, pbfPasses: 2, tiles: chunks.length, relevantOsmWays: first.ways, relevantOsmNodes, relevantOsmNodesExact: false, routingNodesBeforeSimplification: nodesBefore, routingNodesAfterSimplification: nodesAfter, reductionPercent: Number((100 * (1 - nodesAfter / Math.max(1, nodesBefore))).toFixed(2)), routingEdges: edges, turnRestrictionsRead: first.restrictions.length, turnRestrictionsSkipped: first.skippedRestrictions, bannedTurnPairs: built.bannedTurns, bannedTurnRuns: built.bannedRuns, uncompressedGraphBytes: rawBytes, bundleBytes, compressionRatio: Number((bundleBytes / Math.max(1, rawBytes)).toFixed(4)), peakRssBytes: Math.max(observedPeakRss, process.memoryUsage().rss), elapsedMilliseconds: Math.round(performance.now() - started) };
684
+ const report = { formatVersion: 1, input: file, inputBytes: source.size, profile: profiles.map((entry) => entry.name).join(","), compression: `${compression}-per-tile`, tileSizeKm, paddingKm, pbfPasses: 2, tiles: built.graphs.length, relevantOsmWays: first.ways, relevantOsmNodes, relevantOsmNodesExact: false, routingNodesBeforeSimplification: nodesBefore, routingNodesAfterSimplification: nodesAfter, reductionPercent: Number((100 * (1 - nodesAfter / Math.max(1, nodesBefore))).toFixed(2)), routingEdges: edges, turnRestrictionsRead: first.restrictions.length, turnRestrictionsSkipped: first.skippedRestrictions, conditionalTurnRestrictionsIgnored: first.conditionalRestrictions, barriersRead: first.barrierCount, barrierBans: built.barrierBans, bannedTurnPairs: built.bannedTurns, bannedTurnRuns: built.bannedRuns, layout: split ? "shared-plus-profile" : "single-chunk", profiles: profiles.map((entry) => entry.name), defaultVehicle: defaultVehicle ?? null, edgesWithPhysicalLimits: limitedEdges, edgesWithConditionalRestrictions: conditionalEdges, uncompressedGraphBytes: rawBytes, bundleBytes, compressionRatio: Number((bundleBytes / Math.max(1, rawBytes)).toFixed(4)), peakRssBytes: Math.max(observedPeakRss, process.memoryUsage().rss), elapsedMilliseconds: Math.round(performance.now() - started) };
454
685
  await writeFile(`${output}.json`, `${JSON.stringify(report, null, 2)}\n`);
455
- console.table({ input_mb: (source.size / 1e6).toFixed(1), pbf_passes: report.pbfPasses, relevant_ways: first.ways.toLocaleString(), relevant_nodes: relevantOsmNodes.toLocaleString(), nodes_before: nodesBefore.toLocaleString(), nodes_after: nodesAfter.toLocaleString(), reduction_percent: report.reductionPercent, routing_edges: edges.toLocaleString(), turn_restrictions: first.restrictions.length.toLocaleString(), banned_turn_pairs: built.bannedTurns.toLocaleString(), banned_turn_runs: built.bannedRuns.toLocaleString(), tiles: chunks.length, raw_mb: (rawBytes / 1e6).toFixed(1), efrb_mb: (bundleBytes / 1e6).toFixed(1), compression_ratio: report.compressionRatio, peak_rss_mb: (report.peakRssBytes / 1e6).toFixed(1), elapsed_seconds: (report.elapsedMilliseconds / 1000).toFixed(1) });
686
+ console.table({ input_mb: (source.size / 1e6).toFixed(1), pbf_passes: report.pbfPasses, relevant_ways: first.ways.toLocaleString(), relevant_nodes: relevantOsmNodes.toLocaleString(), nodes_before: nodesBefore.toLocaleString(), nodes_after: nodesAfter.toLocaleString(), reduction_percent: report.reductionPercent, routing_edges: edges.toLocaleString(), turn_restrictions: first.restrictions.length.toLocaleString(), barriers: first.barrierCount.toLocaleString(), banned_turn_pairs: built.bannedTurns.toLocaleString(), banned_turn_runs: built.bannedRuns.toLocaleString(), tiles: built.graphs.length, raw_mb: (rawBytes / 1e6).toFixed(1), efrb_mb: (bundleBytes / 1e6).toFixed(1), compression_ratio: report.compressionRatio, peak_rss_mb: (report.peakRssBytes / 1e6).toFixed(1), elapsed_seconds: (report.elapsedMilliseconds / 1000).toFixed(1) });
456
687
  } catch (error) {
457
688
  // A truncated or non-PBF file surfaces as a parser or stream error deep in
458
689
  // the pipeline; report it as a problem with the input, not as a crash.