roadgraph-router-v2 0.2.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/index.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import {
2
+ ConditionKind,
3
+ MAX_STORED_VALUES,
2
4
  ProfileId,
3
5
  RouterError,
4
6
  VarintReader,
5
7
  VarintWriter,
8
+ accessAtTime,
9
+ accessAvoiding,
6
10
  accessForVehicle,
7
11
  accessMaskFor,
8
12
  allowsEverything,
@@ -12,7 +16,14 @@ import {
12
16
  combineProfiledGraph,
13
17
  componentSizes,
14
18
  componentsFor,
19
+ conditionHolds,
20
+ conditionalDurations,
21
+ conditionalValue,
22
+ conditionsOf,
23
+ conditionsOnEdge,
24
+ countAvoided,
15
25
  countBlocked,
26
+ countClosedAt,
16
27
  createEdgeSpatialIndex,
17
28
  createSpatialIndex,
18
29
  decodeGraph,
@@ -36,9 +47,12 @@ import {
36
47
  maxSpeedMpsFor,
37
48
  motorcycleRoutingProfile,
38
49
  nearbyEdgeIds,
50
+ packEdgeConditions,
39
51
  packEdgeLimits,
40
52
  packTurnRestrictions,
41
53
  packTurnSequences,
54
+ parseConditional,
55
+ parseEdgeConditions,
42
56
  parseLengthMeters,
43
57
  parseMaxSpeedKph,
44
58
  parseWeightTonnes,
@@ -50,11 +64,14 @@ import {
50
64
  straightLineAtMost,
51
65
  toDecimetres,
52
66
  toHalfTonnes,
67
+ touchesArea,
68
+ travelTimeFrom,
53
69
  truckRoutingProfile,
70
+ unreadableConditions,
54
71
  unzigzag,
55
72
  withSpatialIndex,
56
73
  zigzag
57
- } from "./chunk-PXL65P3L.js";
74
+ } from "./chunk-QSIKDM4A.js";
58
75
 
59
76
  // src/compact.ts
60
77
  function scaleFor(values, maximum) {
@@ -107,6 +124,104 @@ function compactGraph(graph, options = {}) {
107
124
  };
108
125
  }
109
126
 
127
+ // src/alternatives.ts
128
+ var DEFAULT_MAX_STRETCH = 1.4;
129
+ var DEFAULTS = { count: 3, maxStretch: DEFAULT_MAX_STRETCH, maxSharing: 0.75, penaltyFactor: 2 };
130
+ var MOST_ROUTES = 10;
131
+ function checkAlternativeOptions(options) {
132
+ const whole = (name, value) => {
133
+ if (!Number.isInteger(value)) throw new RangeError(`routeAlternatives: ${name} must be a whole number, not ${value}.`);
134
+ };
135
+ const finite = (name, value, least, most) => {
136
+ if (!Number.isFinite(value) || value < least || value > most) {
137
+ throw new RangeError(`routeAlternatives: ${name} must be between ${least} and ${most}, not ${value}.`);
138
+ }
139
+ };
140
+ if (options.count !== void 0) {
141
+ whole("count", options.count);
142
+ finite("count", options.count, 1, MOST_ROUTES);
143
+ }
144
+ if (options.maxStretch !== void 0) finite("maxStretch", options.maxStretch, 1, Number.MAX_SAFE_INTEGER);
145
+ if (options.maxSharing !== void 0) finite("maxSharing", options.maxSharing, 0, 1);
146
+ if (options.penaltyFactor !== void 0) finite("penaltyFactor", options.penaltyFactor, 1, Number.MAX_SAFE_INTEGER);
147
+ }
148
+ var costOfRoute = (result, preference) => preference === "fastest" ? result.durationSeconds : result.distanceMeters;
149
+ function roadOf(graph, edge) {
150
+ const source = edgeSource(graph, edge);
151
+ const target = graph.edgeTargets[edge];
152
+ const ends = source < target ? `${source}:${target}` : `${target}:${source}`;
153
+ const polyline = edgePolyline(graph, edge, source);
154
+ const points = source < target ? polyline : [...polyline].reverse();
155
+ const scale = graph.coordinateScale ?? 1e7;
156
+ let first = 2166136261;
157
+ let second = 16777619;
158
+ for (const point of points) {
159
+ for (const value of [Math.round(point.lat * scale), Math.round(point.lng * scale)]) {
160
+ first = Math.imul(first ^ value & 65535, 16777619) >>> 0;
161
+ first = Math.imul(first ^ value >>> 16, 16777619) >>> 0;
162
+ second = Math.imul(second ^ value & 65535, 2246822507) >>> 0;
163
+ second = Math.imul(second ^ value >>> 16, 3266489909) >>> 0;
164
+ }
165
+ }
166
+ return `${ends}:${first.toString(36)}${second.toString(36)}`;
167
+ }
168
+ function roadsOf(graph, route2) {
169
+ const roads = /* @__PURE__ */ new Set();
170
+ for (const edge of route2.edgeIds) roads.add(roadOf(graph, edge));
171
+ for (const snap of [route2.originSnap, route2.destinationSnap]) {
172
+ if (snap.edge >= 0) roads.add(roadOf(graph, snap.edge));
173
+ }
174
+ return roads;
175
+ }
176
+ function sharedFraction(graph, candidate, used) {
177
+ const scale = graph.edgeDistanceScale ?? 1;
178
+ let shared = 0;
179
+ let total = 0;
180
+ for (const edge of candidate.edgeIds) {
181
+ const length = graph.edgeDistancesMeters[edge] * scale;
182
+ total += length;
183
+ if (used.has(roadOf(graph, edge))) shared += length;
184
+ }
185
+ for (const snap of [candidate.originSnap, candidate.destinationSnap]) {
186
+ if (snap.edge < 0) continue;
187
+ const travelled = snap.travelledMeters ?? 0;
188
+ total += travelled;
189
+ if (used.has(roadOf(graph, snap.edge))) shared += travelled;
190
+ }
191
+ return total > 0 ? shared / total : 1;
192
+ }
193
+ function routeAlternatives(graph, from, to, preference = "fastest", options = {}) {
194
+ checkAlternativeOptions(options);
195
+ const { count, maxStretch, maxSharing, penaltyFactor } = { ...DEFAULTS, ...options };
196
+ const best = route(graph, from, to, preference, options);
197
+ if (count <= 1) return [best];
198
+ const chosen = [best];
199
+ const ceiling = costOfRoute(best, preference) * maxStretch;
200
+ const used = roadsOf(graph, best);
201
+ const penalties = /* @__PURE__ */ new Map();
202
+ const penalise = (edges) => {
203
+ for (const edge of edges) penalties.set(edge, (penalties.get(edge) ?? 1) * penaltyFactor);
204
+ };
205
+ penalise(best.edgeIds);
206
+ const attempts = (count - 1) * 3;
207
+ for (let attempt = 0; attempt < attempts && chosen.length < count; attempt += 1) {
208
+ let candidate;
209
+ try {
210
+ candidate = route(graph, from, to, preference, { ...options, costPenalties: penalties });
211
+ } catch (error) {
212
+ if (error instanceof RouterError && error.code === "NO_ROUTE") break;
213
+ throw error;
214
+ }
215
+ penalise(candidate.edgeIds);
216
+ if (costOfRoute(candidate, preference) > ceiling) continue;
217
+ if (sharedFraction(graph, candidate, used) > maxSharing) continue;
218
+ chosen.push(candidate);
219
+ for (const road of roadsOf(graph, candidate)) used.add(road);
220
+ }
221
+ const rest = chosen.slice(1).sort((left, right) => costOfRoute(left, preference) - costOfRoute(right, preference));
222
+ return [best, ...rest];
223
+ }
224
+
110
225
  // src/merge.ts
111
226
  var COORDINATE_SCALE = 1e7;
112
227
  var LENGTH_EPSILON_METERS = 0.01;
@@ -261,6 +376,8 @@ function mergeRoadGraphs(graphs) {
261
376
  const hasAccess = graphs.some((graph) => graph.edgeAccess);
262
377
  const mergedAccess = hasAccess ? accessMaskFor(edgeCount, () => true) : void 0;
263
378
  const limitRows = /* @__PURE__ */ new Map();
379
+ const conditionRows = [];
380
+ const seenConditions = /* @__PURE__ */ new Set();
264
381
  for (let index = 0; index < graphs.length; index += 1) {
265
382
  const graph = graphs[index];
266
383
  const edgeMap = edgeMaps[index];
@@ -286,6 +403,18 @@ function mergeRoadGraphs(graphs) {
286
403
  limitRows.set(merged, existing);
287
404
  }
288
405
  }
406
+ const conditions = graph.edgeConditions;
407
+ if (conditions) {
408
+ for (let row = 0; row < conditions.edges.length; row += 1) {
409
+ const merged = rebase(conditions.edges[row]);
410
+ if (merged < 0) continue;
411
+ const value = conditions.values[conditions.rules[row]];
412
+ const key = `${merged}:${conditions.kinds[row]}:${value}`;
413
+ if (seenConditions.has(key)) continue;
414
+ seenConditions.add(key);
415
+ conditionRows.push({ edge: merged, kind: conditions.kinds[row], value });
416
+ }
417
+ }
289
418
  const { turnRestrictionFromEdges: fromEdges, turnRestrictionToEdges: toEdges } = graph;
290
419
  if (fromEdges && toEdges) {
291
420
  for (let pair = 0; pair < fromEdges.length; pair += 1) {
@@ -307,6 +436,7 @@ function mergeRoadGraphs(graphs) {
307
436
  }
308
437
  }
309
438
  const mergedLimits = packEdgeLimits([...limitRows].map(([edge, limits]) => ({ edge, limits })));
439
+ const mergedConditions = packEdgeConditions(conditionRows);
310
440
  return withSpatialIndex({
311
441
  nodeLatitudes: mergedLatitudes.slice(0, nodeCount),
312
442
  nodeLongitudes: mergedLongitudes.slice(0, nodeCount),
@@ -319,6 +449,7 @@ function mergeRoadGraphs(graphs) {
319
449
  ...graphs[0].maxSpeedKph ? { maxSpeedKph: graphs[0].maxSpeedKph } : {},
320
450
  ...mergedAccess ? { edgeAccess: mergedAccess } : {},
321
451
  ...mergedLimits ? { edgeLimits: mergedLimits } : {},
452
+ ...mergedConditions ? { edgeConditions: mergedConditions } : {},
322
453
  ...pointCount ? { edgeShapeOffsets, edgeShapeLatitudes, edgeShapeLongitudes } : {},
323
454
  ...bannedPairs.length ? packTurnRestrictions(bannedPairs) : {},
324
455
  ...bannedSequences.length ? packTurnSequences(bannedSequences) : {}
@@ -830,10 +961,15 @@ var RoadRouter = class _RoadRouter {
830
961
  }
831
962
  return new _RoadRouter(void 0, this.loader, this.options, wanted.id, this.bundleVehicle);
832
963
  }
833
- async route(from, to, preference = "fastest") {
834
- if (this.graph) return route(this.graph, from, to, preference, { vehicle: this.vehicle });
835
- if (isBundle(this.loader)) return this.routeOverBundle(this.loader, from, to, preference);
836
- return this.routeOverLoader(this.loader, from, to, preference);
964
+ /**
965
+ * `when` says when the journey starts, so conditional restrictions apply.
966
+ * Leaving it out ignores them, which is how this library behaved before it
967
+ * could read them.
968
+ */
969
+ async route(from, to, preference = "fastest", when = {}) {
970
+ if (this.graph) return route(this.graph, from, to, preference, { vehicle: this.vehicle, ...when });
971
+ if (isBundle(this.loader)) return this.routeOverBundle(this.loader, from, to, preference, when);
972
+ return this.routeOverLoader(this.loader, from, to, preference, when);
837
973
  }
838
974
  /**
839
975
  * Downloads a corridor, routes, then asks whether any tile left out could
@@ -844,7 +980,39 @@ var RoadRouter = class _RoadRouter {
844
980
  * graph contains. Because the test measures ground distance rather than
845
981
  * counting neighbours, the answer does not depend on the tile size.
846
982
  */
847
- async routeOverBundle(bundle, from, to, preference) {
983
+ /**
984
+ * Several ways there, best first, with up to `count - 1` alternatives after
985
+ * it. Fewer than asked for means the network held nothing else worth
986
+ * offering, which is the usual answer where there is one obvious way.
987
+ *
988
+ * Over a bundle this downloads a wider corridor than a single route needs:
989
+ * an alternative may be worse than the best by as much as `maxStretch`, and
990
+ * the tiles holding it lie outside the corridor that proved the best route.
991
+ */
992
+ async routeAlternatives(from, to, preference = "fastest", options = {}) {
993
+ checkAlternativeOptions(options);
994
+ const withVehicle = { vehicle: this.vehicle, ...options };
995
+ if (this.graph) return routeAlternatives(this.graph, from, to, preference, withVehicle);
996
+ if (isBundle(this.loader)) {
997
+ const { best, graphNow, widen, reachFor } = await this.bestOverBundle(this.loader, from, to, preference, options);
998
+ if (options.count !== void 0 && options.count <= 1) return [best];
999
+ if (options.searchWiderCorridor === false) return routeAlternatives(graphNow(), from, to, preference, withVehicle);
1000
+ await widen(reachFor(best) * (options.maxStretch ?? DEFAULT_MAX_STRETCH));
1001
+ return routeAlternatives(graphNow(), from, to, preference, withVehicle);
1002
+ }
1003
+ const loader = this.loader;
1004
+ return routeAlternatives(await loader.loadForRoute(from, to), from, to, preference, withVehicle);
1005
+ }
1006
+ async routeOverBundle(bundle, from, to, preference, when = {}) {
1007
+ return (await this.bestOverBundle(bundle, from, to, preference, when)).best;
1008
+ }
1009
+ /**
1010
+ * The same corridor walk, stopping with the graph it settled on as well as
1011
+ * the route. Alternatives need the graph, and need to be able to widen it
1012
+ * once more: a second route may be worse than the best by more than the
1013
+ * corridor was ever sized to hold.
1014
+ */
1015
+ async bestOverBundle(bundle, from, to, preference, when = {}) {
848
1016
  const straightLine = haversineMeters2(from, to);
849
1017
  const ceiling = straightLine * (this.options.maxDetourFactor ?? Infinity);
850
1018
  const selected = this.profileId ?? bundle.profileId;
@@ -867,7 +1035,7 @@ var RoadRouter = class _RoadRouter {
867
1035
  for (const factor of [1, ...SEARCH_WIDENING_FACTORS]) {
868
1036
  if (factor !== 1 && !await widen(factor === Infinity ? Infinity : budget * factor)) break;
869
1037
  try {
870
- best = route(graph, from, to, preference, { vehicle: this.vehicle });
1038
+ best = route(graph, from, to, preference, { vehicle: this.vehicle, ...when });
871
1039
  break;
872
1040
  } catch (error) {
873
1041
  failure = error;
@@ -876,29 +1044,31 @@ var RoadRouter = class _RoadRouter {
876
1044
  }
877
1045
  if (!best) throw failure ?? new RouterError("NO_ROUTE", "No route connects the selected points.");
878
1046
  for (let pass = 0; pass < MAX_CONFIRMATION_PASSES; pass += 1) {
879
- if (!await widen(reachFor(best))) return best;
1047
+ if (!await widen(reachFor(best))) return { best, graphNow: () => graph, widen, reachFor };
880
1048
  try {
881
- best = route(graph, from, to, preference, { costBound: preference === "fastest" ? best.durationSeconds : best.distanceMeters, vehicle: this.vehicle });
1049
+ best = route(graph, from, to, preference, { costBound: preference === "fastest" ? best.durationSeconds : best.distanceMeters, vehicle: this.vehicle, ...when });
882
1050
  } catch (error) {
883
1051
  if (error.code !== "NO_ROUTE") throw error;
884
- return best;
1052
+ return { best, graphNow: () => graph, widen, reachFor };
885
1053
  }
886
1054
  }
887
- return best;
1055
+ return { best, graphNow: () => graph, widen, reachFor };
888
1056
  }
889
- async routeOverLoader(loader, from, to, preference) {
1057
+ async routeOverLoader(loader, from, to, preference, when = {}) {
890
1058
  const graph = await loader.loadForRoute(from, to);
891
1059
  try {
892
- return route(graph, from, to, preference, { vehicle: this.vehicle });
1060
+ return route(graph, from, to, preference, { vehicle: this.vehicle, ...when });
893
1061
  } catch (error) {
894
1062
  if (!(error instanceof Error) || error.code !== "NO_ROUTE" || !loader.loadExpandedForRoute) throw error;
895
- return route(await loader.loadExpandedForRoute(from, to), from, to, preference, { vehicle: this.vehicle });
1063
+ return route(await loader.loadExpandedForRoute(from, to), from, to, preference, { vehicle: this.vehicle, ...when });
896
1064
  }
897
1065
  }
898
1066
  };
899
1067
  export {
900
1068
  BundleCompression,
1069
+ ConditionKind,
901
1070
  DEFAULT_VEHICLE_PRESET,
1071
+ MAX_STORED_VALUES,
902
1072
  ProfileId,
903
1073
  RoadRouter,
904
1074
  RouterError,
@@ -906,17 +1076,27 @@ export {
906
1076
  VarintReader,
907
1077
  VarintWriter,
908
1078
  VehiclePresetId,
1079
+ accessAtTime,
1080
+ accessAvoiding,
909
1081
  accessForVehicle,
910
1082
  accessMaskFor,
911
1083
  allowsEverything,
912
1084
  buildTurnRestrictionIndex,
913
1085
  buildTurnSequenceIndex,
914
1086
  carRoutingProfile,
1087
+ checkAlternativeOptions,
915
1088
  combineProfiledGraph,
916
1089
  compactGraph,
917
1090
  componentSizes,
918
1091
  componentsFor,
1092
+ conditionHolds,
1093
+ conditionalDurations,
1094
+ conditionalValue,
1095
+ conditionsOf,
1096
+ conditionsOnEdge,
1097
+ countAvoided,
919
1098
  countBlocked,
1099
+ countClosedAt,
920
1100
  createEdgeSpatialIndex,
921
1101
  createRouteGraphLoader,
922
1102
  createSpatialIndex,
@@ -947,22 +1127,29 @@ export {
947
1127
  motorcycleRoutingProfile,
948
1128
  nearbyEdgeIds,
949
1129
  openRoadGraphBundle,
1130
+ packEdgeConditions,
950
1131
  packEdgeLimits,
951
1132
  packTurnRestrictions,
952
1133
  packTurnSequences,
1134
+ parseConditional,
1135
+ parseEdgeConditions,
953
1136
  parseLengthMeters,
954
1137
  parseMaxSpeedKph,
955
1138
  parseWeightTonnes,
956
1139
  profileOf,
957
1140
  resolveVehicle,
958
1141
  route,
1142
+ routeAlternatives,
959
1143
  routingProfiles,
960
1144
  searchBoundsFor,
961
1145
  snapToGraph,
962
1146
  straightLineAtMost,
963
1147
  toDecimetres,
964
1148
  toHalfTonnes,
1149
+ touchesArea,
1150
+ travelTimeFrom,
965
1151
  truckRoutingProfile,
1152
+ unreadableConditions,
966
1153
  unzigzag,
967
1154
  vehiclePreset,
968
1155
  vehiclePresetName,
package/dist/worker.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  decodeGraph,
3
3
  route
4
- } from "./chunk-PXL65P3L.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.2.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",
@@ -87,6 +87,7 @@ function createTileGraph(tile, profileCount) {
87
87
  edgeUsable: perProfile(() => new Column(Uint8Array)),
88
88
  // Sparse: almost no road states a height or a weight.
89
89
  edgeLimits: perProfile(() => new Map()),
90
+ edgeConditions: perProfile(() => []),
90
91
  restrictions: perProfile(() => []),
91
92
  runs: perProfile(() => []),
92
93
  };
@@ -218,10 +219,20 @@ function encodeEfr2(graph, library, profiles) {
218
219
  };
219
220
 
220
221
  const split = profiles.length > 1 || profiles.some((entry) => entry.usesPhysicalLimits);
221
- if (!split) return { shared: library.encodeCompactGraph(assembled), profiles: [], limitedEdges: 0 };
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
+ }
222
232
 
223
233
  const restrictionCount = banned.length + runs.length;
224
234
  let limitedEdges = 0;
235
+ let conditionalEdges = 0;
225
236
  const chunks = profiles.map((entry, profileIndex) => {
226
237
  const usable = graph.edgeUsable[profileIndex].view();
227
238
  // `placement` maps a built edge to its stored position, so the mask is
@@ -233,6 +244,10 @@ function encodeEfr2(graph, library, profiles) {
233
244
  for (const [edge, limits] of graph.edgeLimits[profileIndex]) entries.push({ edge: placement[edge], limits });
234
245
  const edgeLimits = library.packEdgeLimits(entries);
235
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;
236
251
  const restrictionMask = library.accessMaskFor(restrictionCount, (index) => index < banned.length
237
252
  ? pairOwners[pairOrder[index]].has(profileIndex)
238
253
  : runOwners[runOrder[index - banned.length]].has(profileIndex));
@@ -242,11 +257,12 @@ function encodeEfr2(graph, library, profiles) {
242
257
  edgeDurationScale: durationScales[profileIndex],
243
258
  edgeDurationsSeconds: durationColumns[profileIndex],
244
259
  edgeAccess,
260
+ ...(edgeConditions ? { edgeConditions } : {}),
245
261
  ...(edgeLimits ? { edgeLimits } : {}),
246
262
  ...(restrictionCount ? { restrictionCount, restrictionMask } : {}),
247
263
  });
248
264
  });
249
- return { shared: library.encodeSharedGraph(assembled), profiles: chunks, limitedEdges };
265
+ return { shared: library.encodeSharedGraph(assembled), profiles: chunks, limitedEdges, conditionalEdges };
250
266
  }
251
267
 
252
268
  /** Writes the fixed-size directory, then streams each chunk straight from the
@@ -314,7 +330,7 @@ function memoryGuard(limitBytes, label, count) { if (count % 1_000 !== 0) return
314
330
  async function firstPass(file, roadFile, limitBytes, profiles) {
315
331
  const required = approximateIds(), keeperNodes = approximateIds(), stream = createWriteStream(roadFile), bounds = { minLat: Infinity, minLon: Infinity, maxLat: -Infinity, maxLon: -Infinity };
316
332
  const restrictions = [], restrictionWays = new Set(), barriers = [];
317
- let ways = 0, entities = 0, skippedRestrictions = 0, barrierCount = 0;
333
+ let ways = 0, entities = 0, skippedRestrictions = 0, barrierCount = 0, conditionalRestrictions = 0;
318
334
  // One pass serves every vehicle: the reader is asked for the union of the
319
335
  // tags they read, and each way is offered to all of them.
320
336
  const wayTags = [...new Set(profiles.flatMap((entry) => entry.wayTagKeys))];
@@ -347,6 +363,10 @@ async function firstPass(file, roadFile, limitBytes, profiles) {
347
363
  const perProfile = profiles.map((entry) => entry.evaluateTurnRestriction(entity.tags ?? {}));
348
364
  const parsed = perProfile.find(Boolean) ?? null;
349
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;
350
370
  const members = entity.members ?? [];
351
371
  const from = members.find((member) => member.role === "from" && member.type === "way");
352
372
  const to = members.find((member) => member.role === "to" && member.type === "way");
@@ -378,8 +398,8 @@ async function firstPass(file, roadFile, limitBytes, profiles) {
378
398
  keeperNodes.add(refs[0]); keeperNodes.add(refs[refs.length - 1]);
379
399
  await write(stream, {
380
400
  id: entity.id, refs,
381
- forward: lanes.map((lane) => lane?.forward ? { speed: lane.forwardSpeedKph, limits: lane.forwardLimits ?? null } : null),
382
- reverse: lanes.map((lane) => lane?.backward ? { speed: lane.backwardSpeedKph, limits: lane.backwardLimits ?? null } : null),
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),
383
403
  });
384
404
  }
385
405
  await new Promise((resolve, reject) => stream.end((error) => error ? reject(error) : resolve()));
@@ -390,7 +410,7 @@ async function firstPass(file, roadFile, limitBytes, profiles) {
390
410
  // A barrier can only matter where a road passes through it, and the node
391
411
  // has to survive simplification for the ban to have edges to name.
392
412
  for (const blocked of barriers) { if (blocked) for (const id of blocked) if (required.has(id)) keeperNodes.add(id); }
393
- return { required, keeperNodes, bounds, ways, restrictions: usable, restrictionWays, skippedRestrictions, barriers, barrierCount };
413
+ return { required, keeperNodes, bounds, ways, restrictions: usable, restrictionWays, skippedRestrictions, conditionalRestrictions, barriers, barrierCount };
394
414
  }
395
415
 
396
416
  async function secondPass(file, required, limitBytes, databasePath) {
@@ -522,6 +542,7 @@ async function build(roadFile, coordinates, topology, layout, bounds, tolerance,
522
542
  graph.edgeDurations[profileIndex].push(distance / (speed * (1000 / 3600)));
523
543
  graph.edgeUsable[profileIndex].push(lane ? 1 : 0);
524
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 });
525
546
  }
526
547
  graph.edgeWay.push(tracked ? wayId : 0);
527
548
  graph.edgeShapeStart.push(graph.shapeLat.length);
@@ -638,12 +659,14 @@ else {
638
659
  // so peak memory tracks the largest single tile rather than the region.
639
660
  const spillPath = resolve(workspace, "chunks.bin"), spill = createWriteStream(spillPath), chunks = [];
640
661
  let limitedEdges = 0;
662
+ let conditionalEdges = 0;
641
663
  for (let index = 0; index < built.graphs.length; index += 1) {
642
664
  const graph = built.graphs[index];
643
665
  const encoded = encodeEfr2(graph, library, profiles);
644
666
  // The shared half first, then each vehicle's: the reader needs a tile's
645
667
  // shared row before the rows that depend on it.
646
668
  limitedEdges += encoded.limitedEdges ?? 0;
669
+ conditionalEdges += encoded.conditionalEdges ?? 0;
647
670
  for (const [profileId, buffer] of [[0, encoded.shared], ...encoded.profiles.map((chunk, at) => [profiles[at].id, chunk])]) {
648
671
  const raw = new Uint8Array(buffer);
649
672
  const data = compression === "gzip" ? gzipSync(raw, { level: constants.Z_BEST_COMPRESSION }) : raw;
@@ -658,7 +681,7 @@ else {
658
681
  const bundleBytes = await writeBundle(output, chunks, compression, spillPath, profiles, split, defaultVehicle, library.VehiclePresetId);
659
682
  await verifyBundle(output, built.graphs.length);
660
683
  const nodesBefore = storedNodes, nodesAfter = built.routingNodes, edges = built.routingEdges, rawBytes = chunks.reduce((sum, chunk) => sum + chunk.rawBytes, 0);
661
- 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, 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, 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) };
662
685
  await writeFile(`${output}.json`, `${JSON.stringify(report, null, 2)}\n`);
663
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) });
664
687
  } catch (error) {