@mapmap/maps 0.6.0 → 0.7.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,5 +1,5 @@
1
- import { directionIcons, iconNameForStep } from './chunk-CH53LJR7.js';
2
- export { directionIconSvg, directionIcons, iconNameForManeuver, iconNameForStep } from './chunk-CH53LJR7.js';
1
+ import { directionIcons, iconNameForStep } from './chunk-J657OWYE.js';
2
+ export { directionIconSvg, directionIcons, iconNameForManeuver, iconNameForStep, iconNamesForSteps } from './chunk-J657OWYE.js';
3
3
  import maplibregl from 'maplibre-gl';
4
4
  import { Protocol } from 'pmtiles';
5
5
 
@@ -619,6 +619,10 @@ function num(v, fallback, min, max) {
619
619
  function optNum(v, min, max) {
620
620
  return typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : void 0;
621
621
  }
622
+ function dashArray(v) {
623
+ if (!Array.isArray(v) || v.length === 0) return void 0;
624
+ return v.every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0) ? v : void 0;
625
+ }
622
626
  function bool(v, fallback) {
623
627
  return typeof v === "boolean" ? v : fallback;
624
628
  }
@@ -692,13 +696,23 @@ function parseNavDesign(value) {
692
696
  const route = isRecord(value.route) ? value.route : {};
693
697
  const puck = isRecord(value.puck) ? value.puck : {};
694
698
  const banner = isRecord(value.banner) ? value.banner : {};
699
+ const casingWidth = optNum(route.casingWidth, 0.5, 40);
700
+ const casingOpacity = optNum(route.casingOpacity, 0, 1);
701
+ const dash = dashArray(route.dash);
695
702
  return {
696
703
  version: 1,
697
704
  route: {
698
705
  color: str(route.color, d.route.color),
699
706
  width: num(route.width, d.route.width, 0.5, 20),
700
707
  opacity: num(route.opacity, d.route.opacity, 0, 1),
701
- casingColor: str(route.casingColor, d.route.casingColor)
708
+ casingColor: str(route.casingColor, d.route.casingColor),
709
+ // Casing width/opacity and the dash pattern stay OPTIONAL: absent must
710
+ // round-trip as absent so `RouteLayer` keeps its documented `width + 4`
711
+ // and shared-opacity fallbacks, and a theme saved before these fields
712
+ // existed re-exports byte-identical.
713
+ ...casingWidth !== void 0 ? { casingWidth } : {},
714
+ ...casingOpacity !== void 0 ? { casingOpacity } : {},
715
+ ...dash !== void 0 ? { dash } : {}
702
716
  },
703
717
  puck: {
704
718
  color: str(puck.color, d.puck.color),
@@ -1098,8 +1112,22 @@ function landcoverColour(p) {
1098
1112
  base
1099
1113
  ];
1100
1114
  }
1115
+ var NOT_A_ROAD_CLASSES = [
1116
+ "rail",
1117
+ "transit",
1118
+ "ferry",
1119
+ "path",
1120
+ "track",
1121
+ "pedestrian",
1122
+ "aerialway",
1123
+ "bridge",
1124
+ "pier"
1125
+ ];
1101
1126
  function notARoadFilter() {
1102
- return ["!in", "class", "rail", "transit", "ferry", "path", "track", "pedestrian", "aerialway"];
1127
+ return ["!in", "class", ...NOT_A_ROAD_CLASSES];
1128
+ }
1129
+ function minorRoadFilter() {
1130
+ return ["!in", "class", "motorway", "trunk", "primary", ...NOT_A_ROAD_CLASSES];
1103
1131
  }
1104
1132
  function tunnelOpacity() {
1105
1133
  return ["match", ["get", "brunnel"], "tunnel", 0.55, 1];
@@ -1346,12 +1374,23 @@ function buildStyle(options = {}) {
1346
1374
  source: "territory",
1347
1375
  "source-layer": "transportation",
1348
1376
  minzoom: 14,
1349
- // A fill layer only ever draws polygonal geometry, so the class match
1350
- // is the whole filter. An explicit ["==", ["geometry-type"], "Polygon"]
1351
- // clause looks right but matches NOTHING here: MapLibre reports these
1352
- // multi-ring plazas as "MultiPolygon" (verified live the clause
1353
- // rendered 0 of 73 features).
1354
- filter: ["match", ["get", "class"], ["path", "pedestrian"], true, false],
1377
+ // The geometry-type clause is LOAD-BEARING, not belt and braces. A fill
1378
+ // layer does not skip line geometry: MapLibre's FillBucket tessellates
1379
+ // every ring `loadGeometry()` hands it, so an open footway LineString
1380
+ // gets closed and filled, and the chord from its last vertex back to its
1381
+ // first is drawn as a solid wedge. Over land that wedge hides in the
1382
+ // landuse beige; over water it is a grey slab across the bay. Class
1383
+ // alone matched 206 features at Lugano z15, 165 of them lines.
1384
+ //
1385
+ // Both Polygon and MultiPolygon are needed: MapLibre reports a plaza as
1386
+ // "MultiPolygon" as soon as it has more than one ring (a hole is
1387
+ // enough), so matching only "Polygon" silently drops the
1388
+ // doughnut-shaped plazas.
1389
+ filter: [
1390
+ "all",
1391
+ ["match", ["get", "class"], ["path", "pedestrian"], true, false],
1392
+ ["in", ["geometry-type"], ["literal", ["Polygon", "MultiPolygon"]]]
1393
+ ],
1355
1394
  paint: {
1356
1395
  "fill-color": p("path"),
1357
1396
  "fill-opacity": 0.7
@@ -1391,19 +1430,7 @@ function buildStyle(options = {}) {
1391
1430
  type: "line",
1392
1431
  source: "territory",
1393
1432
  "source-layer": "transportation",
1394
- filter: [
1395
- "!in",
1396
- "class",
1397
- "motorway",
1398
- "trunk",
1399
- "primary",
1400
- "rail",
1401
- "transit",
1402
- "path",
1403
- "track",
1404
- "pedestrian",
1405
- "ferry"
1406
- ],
1433
+ filter: minorRoadFilter(),
1407
1434
  layout: { "line-cap": "round", "line-join": "round" },
1408
1435
  paint: {
1409
1436
  "line-color": mixColour(p("road"), p("textSecondary"), 0.45),
@@ -1472,19 +1499,7 @@ function buildStyle(options = {}) {
1472
1499
  type: "line",
1473
1500
  source: "territory",
1474
1501
  "source-layer": "transportation",
1475
- filter: [
1476
- "!in",
1477
- "class",
1478
- "motorway",
1479
- "trunk",
1480
- "primary",
1481
- "rail",
1482
- "transit",
1483
- "path",
1484
- "track",
1485
- "pedestrian",
1486
- "ferry"
1487
- ],
1502
+ filter: minorRoadFilter(),
1488
1503
  layout: { "line-cap": "round", "line-join": "round" },
1489
1504
  paint: {
1490
1505
  "line-color": p("road"),
@@ -1870,7 +1885,14 @@ var MapMapMap = class {
1870
1885
  this.effectName = null;
1871
1886
  this.effectOptions = {};
1872
1887
  this.effectExplicit = false;
1888
+ /** True while an effect re-apply is already queued behind the style. */
1889
+ this.effectWaiting = false;
1890
+ // Lifecycle: `destroy()` must silence anything still queued behind a
1891
+ // style load, and the cleanups let it detach those listeners.
1892
+ this.destroyed = false;
1893
+ this.pendingWaits = /* @__PURE__ */ new Set();
1873
1894
  this.handleStyleLoadForEffects = () => {
1895
+ if (this.destroyed) return;
1874
1896
  if (!this.effectExplicit) {
1875
1897
  const block = effectsFromStyleMetadata(this.styleMetadata());
1876
1898
  this.effectName = block?.route ?? null;
@@ -1909,6 +1931,37 @@ var MapMapMap = class {
1909
1931
  apiKey: this.apiKey
1910
1932
  });
1911
1933
  }
1934
+ /**
1935
+ * Run `action` as soon as the style can take layer edits, or now if it
1936
+ * already can.
1937
+ *
1938
+ * Never wait on `load` alone: MapLibre fires it exactly once per map, so
1939
+ * anything deferred after the first load is dropped for good - and
1940
+ * `isStyleLoaded()` reports false for the whole of every `style.load`
1941
+ * (the new style's sources are still loading) and whenever a source is
1942
+ * loading afterwards. Waiting for whichever of `style.load` / `idle` /
1943
+ * `load` arrives first covers the first load and every later style swap,
1944
+ * and the listeners detach as soon as one fires (or on `destroy()`).
1945
+ */
1946
+ whenStyleReady(action) {
1947
+ if (this.destroyed) return;
1948
+ if (this.map.isStyleLoaded()) {
1949
+ action();
1950
+ return;
1951
+ }
1952
+ const events = ["style.load", "idle", "load"];
1953
+ const run = () => {
1954
+ cleanup();
1955
+ if (this.destroyed) return;
1956
+ action();
1957
+ };
1958
+ const cleanup = () => {
1959
+ this.pendingWaits.delete(cleanup);
1960
+ for (const event of events) this.map.off(event, run);
1961
+ };
1962
+ this.pendingWaits.add(cleanup);
1963
+ for (const event of events) this.map.on(event, run);
1964
+ }
1912
1965
  /** The current style's `metadata`, if it can be read yet. */
1913
1966
  styleMetadata() {
1914
1967
  try {
@@ -1948,10 +2001,10 @@ var MapMapMap = class {
1948
2001
  setRouteEffect(effect, options = {}) {
1949
2002
  this.effectExplicit = true;
1950
2003
  const { geometry, ...flowOptions } = options;
2004
+ this.removeEffectLayer();
1951
2005
  this.effectName = effect;
1952
2006
  this.effectOptions = flowOptions;
1953
2007
  if (geometry) this.effectGeometry = geometry;
1954
- this.removeEffectLayer();
1955
2008
  this.applyRouteEffect();
1956
2009
  }
1957
2010
  /**
@@ -1984,7 +2037,12 @@ var MapMapMap = class {
1984
2037
  return;
1985
2038
  }
1986
2039
  if (!this.map.isStyleLoaded()) {
1987
- this.map.once("load", () => this.applyRouteEffect());
2040
+ if (this.effectWaiting) return;
2041
+ this.effectWaiting = true;
2042
+ this.whenStyleReady(() => {
2043
+ this.effectWaiting = false;
2044
+ this.applyRouteEffect();
2045
+ });
1988
2046
  return;
1989
2047
  }
1990
2048
  if (this.effectLayer && this.map.getLayer(id)) {
@@ -2013,7 +2071,7 @@ var MapMapMap = class {
2013
2071
  */
2014
2072
  setBuildings3d(enabled) {
2015
2073
  if (!this.map.isStyleLoaded()) {
2016
- this.map.once("load", () => this.setBuildings3d(enabled));
2074
+ this.whenStyleReady(() => this.setBuildings3d(enabled));
2017
2075
  return;
2018
2076
  }
2019
2077
  if (enabled === (this.map.getLayer("building-3d") !== void 0)) return;
@@ -2036,18 +2094,45 @@ var MapMapMap = class {
2036
2094
  this.map.setLayerZoomRange("building", minzoom, 24);
2037
2095
  }
2038
2096
  }
2039
- /** Resolves once the style and first tiles are loaded. */
2097
+ /**
2098
+ * Resolves once the style and first tiles are loaded.
2099
+ *
2100
+ * Safe to call at any point in the map's life, not just before its
2101
+ * first load: `map.loaded()` goes false again whenever a source is
2102
+ * loading (a theme swap, a pan into new tiles) while `load` only ever
2103
+ * fires once, so this waits on `idle` too rather than hanging.
2104
+ */
2040
2105
  whenReady() {
2041
2106
  return new Promise((resolve) => {
2042
2107
  if (this.map.loaded()) {
2043
2108
  resolve();
2044
2109
  return;
2045
2110
  }
2046
- this.map.once("load", () => resolve());
2111
+ const events = ["load", "idle"];
2112
+ const done = () => {
2113
+ for (const event of events) this.map.off(event, done);
2114
+ this.pendingWaits.delete(cleanup);
2115
+ resolve();
2116
+ };
2117
+ const cleanup = () => {
2118
+ for (const event of events) this.map.off(event, done);
2119
+ this.pendingWaits.delete(cleanup);
2120
+ };
2121
+ this.pendingWaits.add(cleanup);
2122
+ for (const event of events) this.map.on(event, done);
2047
2123
  });
2048
2124
  }
2049
- /** Remove the map and release its WebGL context. */
2125
+ /**
2126
+ * Remove the map and release its WebGL context. Anything still queued
2127
+ * behind a style load is dropped first, so nothing touches the map
2128
+ * after this returns.
2129
+ */
2050
2130
  destroy() {
2131
+ this.destroyed = true;
2132
+ for (const cleanup of [...this.pendingWaits]) cleanup();
2133
+ this.pendingWaits.clear();
2134
+ this.effectWaiting = false;
2135
+ this.effectLayer = void 0;
2051
2136
  this.map.remove();
2052
2137
  }
2053
2138
  };
@@ -2153,11 +2238,540 @@ function numberOr(value, fallback) {
2153
2238
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
2154
2239
  }
2155
2240
 
2241
+ // src/marker-icons.ts
2242
+ var MARKER_GLYPH_IDS = [
2243
+ "pin",
2244
+ "dot",
2245
+ "star",
2246
+ "heart",
2247
+ "flag",
2248
+ "home",
2249
+ "work",
2250
+ "food",
2251
+ "cafe",
2252
+ "bar",
2253
+ "shop",
2254
+ "hotel",
2255
+ "parking",
2256
+ "fuel",
2257
+ "charging",
2258
+ "transit",
2259
+ "bike",
2260
+ "camera",
2261
+ "music",
2262
+ "warning",
2263
+ "info"
2264
+ ];
2265
+ function isMarkerGlyphId(value) {
2266
+ return typeof value === "string" && MARKER_GLYPH_IDS.includes(value);
2267
+ }
2268
+ var markerGlyphPaths = {
2269
+ pin: "M7 12a5 5 0 1 1 10 0a5 5 0 1 1-10 0Z",
2270
+ dot: "M4 12a8 8 0 1 1 16 0a8 8 0 1 1-16 0Z",
2271
+ star: "M12 3L14.41 9.483L21.32 9.772L15.899 14.067L17.76 20.728L12 16.9L6.24 20.728L8.101 14.067L2.68 9.772L9.59 9.483Z",
2272
+ heart: "M12 21C6.5 16.5 3 13.2 3 9.5C3 6.5 5.4 4.5 8 4.5C9.7 4.5 11.2 5.4 12 6.8C12.8 5.4 14.3 4.5 16 4.5C18.6 4.5 21 6.5 21 9.5C21 13.2 17.5 16.5 12 21Z",
2273
+ flag: "M5.5 2.5h2v19h-2V2.5Zm2 2H19l-2.8 4l2.8 4H7.5v-8Z",
2274
+ home: "M12 3.2l9 7.6h-2.6v9.7h-4.9v-5.9h-3v5.9H5.6v-9.7H3l9-7.6Z",
2275
+ work: "M9.5 4h5a1.5 1.5 0 0 1 1.5 1.5V7h4a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h4V5.5A1.5 1.5 0 0 1 9.5 4Zm0.5 3h4V5.5h-4V7Z",
2276
+ food: "M6.75 8.25V3.75a0.75 0.75 0 0 1 1.5 0V8.25a0.75 0.75 0 0 1-1.5 0ZM20.25 3.75V21a0.75 0.75 0 0 1-1.5 0V16.5H14.25a0.75 0.75 0 0 1-0.75-0.75 25.195 25.195 0 0 1 0.677-5.333c0.917-3.796 2.655-6.34 5.028-7.357A0.75 0.75 0 0 1 20.25 3.75ZM18.75 5.053c-3.016 2.303-3.607 7.914-3.722 9.947H18.75ZM11.24 3.627a0.75 0.75 0 1 0-1.479 0.247L10.5 8.309a3 3 0 0 1-6 0l0.739-4.435a0.75 0.75 0 1 0-1.479-0.247l-0.75 4.5A0.766 0.766 0 0 0 3 8.25a4.507 4.507 0 0 0 3.75 4.436V21a0.75 0.75 0 0 0 1.5 0V12.686A4.507 4.507 0 0 0 12 8.25a0.766 0.766 0 0 0-0.01-0.123Z",
2277
+ cafe: "M3 6.2h13.4v6.3a5.5 5.5 0 0 1-5.5 5.5H8.5A5.5 5.5 0 0 1 3 12.5V6.2Zm13.4 2h2.4a3.2 3.2 0 0 1 0 6.4h-1.6v-2.2h1.6a1 1 0 0 0 0-2h-2.4V8.2Z",
2278
+ bar: "M3.5 4h17l-7.2 8.2V19h3.7v2H7v-2h3.3v-6.8L3.5 4Z",
2279
+ shop: "M20.25 3.75H3.75A1.5 1.5 0 0 0 2.25 5.25V18.75a1.5 1.5 0 0 0 1.5 1.5H20.25a1.5 1.5 0 0 0 1.5-1.5V5.25A1.5 1.5 0 0 0 20.25 3.75Zm0 15H3.75V5.25H20.25V18.75ZM16.5 8.25a4.5 4.5 0 0 1-9 0 0.75 0.75 0 0 1 1.5 0 3 3 0 0 0 6 0 0.75 0.75 0 0 1 1.5 0Z",
2280
+ hotel: "M20.25 6.75H3V4.5a0.75 0.75 0 0 0-1.5 0V19.5a0.75 0.75 0 0 0 1.5 0V16.5H22.5v3a0.75 0.75 0 0 0 1.5 0V10.5A3.75 3.75 0 0 0 20.25 6.75ZM3 8.25h6.75v6.75H3Zm8.25 6.75V8.25h9a2.25 2.25 0 0 1 2.25 2.25v4.5Z",
2281
+ parking: "M7.5 3.5h6.2a5.4 5.4 0 0 1 0 10.8h-3v6.2H7.5V3.5Zm3.2 3v4.8h3a2.4 2.4 0 0 0 0-4.8h-3Z",
2282
+ fuel: "M4.5 3h9a1 1 0 0 1 1 1v17h-11V4a1 1 0 0 1 1-1Zm2 2.5v3.8h5V5.5h-5ZM15.5 9h1.4l2.7 2.7c0.3 0.3 0.5 0.7 0.5 1.1V17a2.3 2.3 0 0 1-4.6 0V9Z",
2283
+ charging: "M13.4 2L5.2 13.2h4.9L9 22l8.3-11.4h-5L13.4 2Z",
2284
+ transit: "M17.25 2.25H6.75A3 3 0 0 0 3.75 5.25V17.25a3 3 0 0 0 3 3h0.75L6.15 22.05a0.75 0.75 0 1 0 1.2 0.9L9.375 20.25h5.25l2.025 2.7a0.75 0.75 0 1 0 1.2-0.9L16.5 20.25h0.75a3 3 0 0 0 3-3V5.25A3 3 0 0 0 17.25 2.25ZM6.75 3.75H17.25a1.5 1.5 0 0 1 1.5 1.5v6H5.25V5.25A1.5 1.5 0 0 1 6.75 3.75ZM17.25 18.75H6.75a1.5 1.5 0 0 1-1.5-1.5V12.75H18.75v4.5A1.5 1.5 0 0 1 17.25 18.75ZM9 16.125a1.125 1.125 0 1 1-1.125-1.125A1.125 1.125 0 0 1 9 16.125Zm8.25 0a1.125 1.125 0 1 1-1.125-1.125A1.125 1.125 0 0 1 17.25 16.125Z",
2285
+ bike: "M2.3 16.9a3.9 3.9 0 1 1 7.8 0a3.9 3.9 0 1 1-7.8 0ZM3.9 16.9a2.3 2.3 0 1 0 4.6 0a2.3 2.3 0 1 0-4.6 0ZM13.9 16.9a3.9 3.9 0 1 1 7.8 0a3.9 3.9 0 1 1-7.8 0ZM15.5 16.9a2.3 2.3 0 1 0 4.6 0a2.3 2.3 0 1 0-4.6 0ZM6.9 17.2l3-6.7h4.6l2.9 6.7l-1.4 0.6l-2.3-5.3h-2.9l-2.5 5.6l-1.4-0.6Z",
2286
+ camera: "M9.2 4.5h5.6l1.6 2.2H20a1.6 1.6 0 0 1 1.6 1.6v9.6a1.6 1.6 0 0 1-1.6 1.6H4a1.6 1.6 0 0 1-1.6-1.6V8.3A1.6 1.6 0 0 1 4 6.7h3.6L9.2 4.5ZM8.1 12.9a3.9 3.9 0 1 0 7.8 0a3.9 3.9 0 1 0-7.8 0ZM9.9 12.9a2.1 2.1 0 1 1 4.2 0a2.1 2.1 0 1 1-4.2 0Z",
2287
+ music: "M4.9 18.2a2.7 2.7 0 1 1 5.4 0a2.7 2.7 0 1 1-5.4 0ZM14.1 16.4a2.7 2.7 0 1 1 5.4 0a2.7 2.7 0 1 1-5.4 0ZM8.5 7h1.8v11.2H8.5ZM17.7 5.2h1.8v11.2h-1.8ZM8.5 7l11-1.8v3l-11 1.8V7Z",
2288
+ warning: "M12 2.8L22.3 20.5H1.7L12 2.8Zm-1 6.6v5.4h2V9.4h-2Zm0 7.2v2.2h2v-2.2h-2Z",
2289
+ info: "M2.5 12a9.5 9.5 0 1 1 19 0a9.5 9.5 0 1 1-19 0ZM11 6.4v2.4h2V6.4h-2Zm0 4.2v6.9h2v-6.9h-2Z"
2290
+ };
2291
+ var MARKER_PIN_BODY_PATH = "M12 24C7 17.2 3 13.2 3 9.4a9 9 0 0 1 18 0C21 13.2 17 17.2 12 24Z";
2292
+ var MARKER_GLYPH_VIEWBOX = 24;
2293
+
2294
+ // src/markers.ts
2295
+ var MARKER_SIZES = ["s", "m", "l"];
2296
+ var MARKER_SIZES_PX = {
2297
+ s: 24,
2298
+ m: 32,
2299
+ l: 40
2300
+ };
2301
+ var MARKER_LABEL_TEXT_SIZES = {
2302
+ s: 12,
2303
+ m: 13,
2304
+ l: 14
2305
+ };
2306
+ var MARKER_DEFAULT_COLOUR = "#1a6bff";
2307
+ var MAX_MARKER_IMAGE_BYTES = 64 * 1024;
2308
+ var MAX_MARKER_ITEMS = 200;
2309
+ var MAX_MARKER_ID_LENGTH = 64;
2310
+ var MAX_MARKER_LABEL_LENGTH = 120;
2311
+ var MAX_MARKER_TEXT_LENGTH = 3;
2312
+ var MARKERS_ID = "mm-user-markers";
2313
+ var MARKER_IMAGE_MIME = /^data:image\/(png|jpeg|webp|svg\+xml);base64,/;
2314
+ function isRecord3(v) {
2315
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2316
+ }
2317
+ function boundedNumber(v, min, max) {
2318
+ return typeof v === "number" && Number.isFinite(v) && v >= min && v <= max ? v : void 0;
2319
+ }
2320
+ function markerColour(v) {
2321
+ return typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v.trim()) ? v.trim().toLowerCase() : MARKER_DEFAULT_COLOUR;
2322
+ }
2323
+ function dataUriBytes(uri) {
2324
+ const comma = uri.indexOf(",");
2325
+ const payload = comma === -1 ? "" : uri.slice(comma + 1);
2326
+ const meta = comma === -1 ? uri : uri.slice(0, comma);
2327
+ if (!/;base64$/i.test(meta) && !/;base64;/i.test(meta)) return payload.length;
2328
+ return Math.floor(payload.replace(/=+$/, "").length * 3 / 4);
2329
+ }
2330
+ function truncateChars(text, max) {
2331
+ const chars = Array.from(text);
2332
+ return chars.length <= max ? text : chars.slice(0, max).join("");
2333
+ }
2334
+ function markerImage(v) {
2335
+ if (typeof v !== "string") return void 0;
2336
+ const uri = v.trim();
2337
+ if (!MARKER_IMAGE_MIME.test(uri)) return void 0;
2338
+ if (dataUriBytes(uri) > MAX_MARKER_IMAGE_BYTES) return void 0;
2339
+ return uri;
2340
+ }
2341
+ function parseMarkers(value) {
2342
+ const block = isRecord3(value) ? value : {};
2343
+ const items = [];
2344
+ if (block.version !== 1) return { version: 1, items };
2345
+ const seen = /* @__PURE__ */ new Set();
2346
+ const rawItems = Array.isArray(block.items) ? block.items : [];
2347
+ for (const raw of rawItems) {
2348
+ if (items.length >= MAX_MARKER_ITEMS) break;
2349
+ if (!isRecord3(raw)) continue;
2350
+ const id = typeof raw.id === "string" ? raw.id : void 0;
2351
+ if (!id || id.length === 0 || Array.from(id).length > MAX_MARKER_ID_LENGTH) continue;
2352
+ if (seen.has(id)) continue;
2353
+ const lng = boundedNumber(raw.lng, -180, 180);
2354
+ const lat = boundedNumber(raw.lat, -90, 90);
2355
+ if (lng === void 0 || lat === void 0) continue;
2356
+ seen.add(id);
2357
+ const item = {
2358
+ id,
2359
+ lng,
2360
+ lat,
2361
+ icon: isMarkerGlyphId(raw.icon) ? raw.icon : "pin",
2362
+ colour: markerColour(raw.colour),
2363
+ size: raw.size === "s" || raw.size === "l" ? raw.size : "m"
2364
+ };
2365
+ const image = markerImage(raw.image);
2366
+ if (image !== void 0) item.image = image;
2367
+ if (typeof raw.label === "string" && raw.label.length > 0) {
2368
+ item.label = truncateChars(raw.label, MAX_MARKER_LABEL_LENGTH);
2369
+ }
2370
+ items.push(item);
2371
+ }
2372
+ return { version: 1, items };
2373
+ }
2374
+ function markersFromTheme(theme) {
2375
+ const extra = theme?.extra;
2376
+ if (typeof extra !== "object" || extra === null || !("markers" in extra)) {
2377
+ return void 0;
2378
+ }
2379
+ return parseMarkers(extra.markers);
2380
+ }
2381
+ async function markersFromThemeUrl(url, fetchImpl) {
2382
+ const doFetch = fetchImpl ?? ((input) => globalThis.fetch(input));
2383
+ const response = await doFetch(url);
2384
+ if (!response.ok) {
2385
+ throw new Error(`fetching theme ${url}: HTTP ${response.status}`);
2386
+ }
2387
+ const theme = await response.json();
2388
+ return markersFromTheme(theme);
2389
+ }
2390
+ function markerImageKey(icon, colour2, size) {
2391
+ return `mm-marker-${icon}-${colour2.toLowerCase()}-${size}`;
2392
+ }
2393
+ function markerText(value) {
2394
+ if (typeof value !== "string") return void 0;
2395
+ const text = truncateChars(value.trim(), MAX_MARKER_TEXT_LENGTH);
2396
+ return text.length > 0 ? text : void 0;
2397
+ }
2398
+ function markerTextImageKey(text, colour2, size) {
2399
+ return `mm-marker-text-${text}-${colour2.toLowerCase()}-${size}`;
2400
+ }
2401
+ function fnv1a(text) {
2402
+ let hash = 2166136261;
2403
+ for (let i = 0; i < text.length; i++) {
2404
+ hash ^= text.charCodeAt(i);
2405
+ hash = Math.imul(hash, 16777619);
2406
+ }
2407
+ return (hash >>> 0).toString(16).padStart(8, "0");
2408
+ }
2409
+ function markerImageDataKey(dataUri) {
2410
+ return `mm-marker-image-${fnv1a(dataUri)}`;
2411
+ }
2412
+ var GLYPH_FRACTION = 0.62;
2413
+ var PIN_HEAD_CENTRE_Y = 9.4;
2414
+ var PIN_HEAD_RADIUS = 9;
2415
+ var DOT_RADIUS = 8;
2416
+ var DOT_STROKE = 2;
2417
+ var PIN_TEXT_FONT_STACK = "system-ui, sans-serif";
2418
+ function pinTextStartSize(length) {
2419
+ if (length <= 1) return 10;
2420
+ return length === 2 ? 8.2 : 6.6;
2421
+ }
2422
+ var PIN_TEXT_MIN_SIZE = 4.5;
2423
+ var PIN_TEXT_WIDTH_FRACTION = 0.78;
2424
+ function renderMarkerImage(options = {}) {
2425
+ const text = markerText(options.text);
2426
+ const icon = options.icon ?? "pin";
2427
+ const colour2 = options.colour ?? MARKER_DEFAULT_COLOUR;
2428
+ const sizePx = MARKER_SIZES_PX[options.size ?? "m"];
2429
+ const g = globalThis;
2430
+ if (!g.document || !g.Path2D) return null;
2431
+ const side = sizePx * 2;
2432
+ const canvas = g.document.createElement("canvas");
2433
+ canvas.width = side;
2434
+ canvas.height = side;
2435
+ const ctx = canvas.getContext("2d");
2436
+ if (!ctx) return null;
2437
+ const scale = side / MARKER_GLYPH_VIEWBOX;
2438
+ if (icon === "dot" && text === void 0) {
2439
+ const cx = side / 2;
2440
+ const cy = side / 2;
2441
+ const r = DOT_RADIUS * scale;
2442
+ ctx.beginPath();
2443
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
2444
+ ctx.fillStyle = colour2;
2445
+ ctx.fill();
2446
+ ctx.lineWidth = DOT_STROKE * scale;
2447
+ ctx.strokeStyle = "#ffffff";
2448
+ ctx.stroke();
2449
+ return { image: ctx.getImageData(0, 0, side, side), pixelRatio: 2 };
2450
+ }
2451
+ ctx.save();
2452
+ ctx.scale(scale, scale);
2453
+ ctx.fillStyle = colour2;
2454
+ ctx.fill(new g.Path2D(MARKER_PIN_BODY_PATH));
2455
+ ctx.restore();
2456
+ if (text !== void 0) {
2457
+ const maxWidth = PIN_HEAD_RADIUS * 2 * PIN_TEXT_WIDTH_FRACTION * scale;
2458
+ let unitSize = pinTextStartSize(text.length);
2459
+ ctx.textAlign = "center";
2460
+ ctx.textBaseline = "middle";
2461
+ ctx.fillStyle = "#ffffff";
2462
+ const canMeasure = typeof ctx.measureText === "function";
2463
+ for (; ; ) {
2464
+ ctx.font = `bold ${unitSize * scale}px ${PIN_TEXT_FONT_STACK}`;
2465
+ if (!canMeasure || unitSize <= PIN_TEXT_MIN_SIZE) break;
2466
+ if (ctx.measureText(text).width <= maxWidth) break;
2467
+ unitSize -= 0.5;
2468
+ }
2469
+ ctx.fillText(text, side / 2, PIN_HEAD_CENTRE_Y * scale);
2470
+ return { image: ctx.getImageData(0, 0, side, side), pixelRatio: 2 };
2471
+ }
2472
+ const glyphSide = PIN_HEAD_RADIUS * 2 * GLYPH_FRACTION * scale;
2473
+ const glyphScale = glyphSide / MARKER_GLYPH_VIEWBOX;
2474
+ ctx.save();
2475
+ ctx.translate(
2476
+ side / 2 - glyphSide / 2,
2477
+ PIN_HEAD_CENTRE_Y * scale - glyphSide / 2
2478
+ );
2479
+ ctx.scale(glyphScale, glyphScale);
2480
+ ctx.fillStyle = "#ffffff";
2481
+ ctx.fill(new g.Path2D(markerGlyphPaths[icon]));
2482
+ ctx.restore();
2483
+ return { image: ctx.getImageData(0, 0, side, side), pixelRatio: 2 };
2484
+ }
2485
+ var TEXT_OFFSET_EXPRESSION = [
2486
+ "match",
2487
+ ["get", "offsetKey"],
2488
+ "tip",
2489
+ ["literal", [0, 0.4]],
2490
+ "cs",
2491
+ ["literal", [0, 1.3]],
2492
+ "cm",
2493
+ ["literal", [0, 1.55]],
2494
+ "cl",
2495
+ ["literal", [0, 1.75]],
2496
+ ["literal", [0, 0.4]]
2497
+ ];
2498
+ var MARKER_TEXT_FONT = "MapMap Sans Regular";
2499
+ var MarkersLayer = class {
2500
+ constructor(map, markers, options = {}) {
2501
+ this.items = [];
2502
+ this.destroyed = false;
2503
+ /**
2504
+ * Bumped by {@link clear} (and {@link destroy}). An image load started
2505
+ * before the bump resolves into a cleared layer, so its handler compares
2506
+ * the generation it was started in and drops out — without this a
2507
+ * pending `loadImage` would rebuild and RE-INSTALL the layer on a map the
2508
+ * caller had already cleaned up.
2509
+ */
2510
+ this.generation = 0;
2511
+ /** Custom images by cache key, once loaded (kept across style swaps). */
2512
+ this.loadedImages = /* @__PURE__ */ new Map();
2513
+ /** Custom-image cache keys that failed to load (pin fallback, once). */
2514
+ this.failedImages = /* @__PURE__ */ new Set();
2515
+ /** Custom-image cache keys with a load in flight. */
2516
+ this.pendingImages = /* @__PURE__ */ new Set();
2517
+ /**
2518
+ * Every `map.addImage` key this layer currently owns, so a rebuild can
2519
+ * drop the ones nothing references any more and teardown removes exactly
2520
+ * what it added (the RouteLayer endpoints pattern). Without it every
2521
+ * icon/colour/size change leaked a 2x RGBA image — 25 KB per click at
2522
+ * the 40px preset — and re-invalidated the sprite atlas.
2523
+ */
2524
+ this.registeredImageKeys = /* @__PURE__ */ new Set();
2525
+ this.handleStyleLoad = () => {
2526
+ if (this.data) this.install();
2527
+ };
2528
+ this.map = map instanceof MapMapMap ? map.map : map;
2529
+ this.options = options;
2530
+ this.map.on("style.load", this.handleStyleLoad);
2531
+ if (markers) this.setMarkers(markers);
2532
+ }
2533
+ /**
2534
+ * Replace the layer's markers with a parsed block (`undefined` or an
2535
+ * empty block clears the map of markers but keeps the layer alive).
2536
+ * Custom images load asynchronously: their markers render as default
2537
+ * pins until the image arrives, then swap in place; a failed or
2538
+ * oversized image keeps the pin fallback.
2539
+ */
2540
+ setMarkers(markers) {
2541
+ this.items = markers?.items ?? [];
2542
+ this.rebuild();
2543
+ }
2544
+ /** The layer's current markers. */
2545
+ get current() {
2546
+ return this.items;
2547
+ }
2548
+ /**
2549
+ * The MapLibre source/layer ids - public API for escape-hatch styling.
2550
+ * Both are the contract id `mm-user-markers`.
2551
+ */
2552
+ get ids() {
2553
+ return { source: MARKERS_ID, layer: MARKERS_ID };
2554
+ }
2555
+ /**
2556
+ * Rebuild the source data from the items and (re)install. Mirrors
2557
+ * PlacesLayer's never-drop-an-update rule: once the source exists the
2558
+ * data is applied immediately (even while `isStyleLoaded()` is
2559
+ * transiently false mid-render); before first installation the latest
2560
+ * data is stashed for the next `style.load`.
2561
+ */
2562
+ rebuild() {
2563
+ this.data = {
2564
+ type: "FeatureCollection",
2565
+ features: this.items.map((item) => this.featureFor(item))
2566
+ };
2567
+ const source = this.map.getSource(MARKERS_ID);
2568
+ if (source) {
2569
+ this.registerImages();
2570
+ source.setData(this.data);
2571
+ } else if (this.map.isStyleLoaded()) {
2572
+ this.install();
2573
+ }
2574
+ }
2575
+ /** Build one feature, kicking off the custom-image load if needed. */
2576
+ featureFor(item) {
2577
+ const sizePx = MARKER_SIZES_PX[item.size];
2578
+ const textSize = MARKER_LABEL_TEXT_SIZES[item.size];
2579
+ let iconImage;
2580
+ let iconSize;
2581
+ let anchor;
2582
+ const imageKey = item.image !== void 0 ? markerImageDataKey(item.image) : void 0;
2583
+ const loaded = imageKey !== void 0 ? this.loadedImages.get(imageKey) : void 0;
2584
+ if (item.image !== void 0 && imageKey !== void 0 && !this.failedImages.has(imageKey)) {
2585
+ if (loaded) {
2586
+ iconImage = imageKey;
2587
+ iconSize = loaded.data.width > 0 ? sizePx / loaded.data.width : 1;
2588
+ anchor = "center";
2589
+ } else {
2590
+ this.loadCustomImage(imageKey, item.image);
2591
+ iconImage = markerImageKey(item.icon, item.colour, item.size);
2592
+ iconSize = 1;
2593
+ anchor = item.icon === "dot" ? "center" : "bottom";
2594
+ }
2595
+ } else {
2596
+ iconImage = markerImageKey(item.icon, item.colour, item.size);
2597
+ iconSize = 1;
2598
+ anchor = item.icon === "dot" ? "center" : "bottom";
2599
+ }
2600
+ const properties = {
2601
+ id: item.id,
2602
+ iconImage,
2603
+ iconSize,
2604
+ iconAnchor: anchor,
2605
+ textSize,
2606
+ offsetKey: anchor === "bottom" ? "tip" : `c${item.size}`
2607
+ };
2608
+ if (item.label !== void 0) properties["label"] = item.label;
2609
+ return {
2610
+ type: "Feature",
2611
+ geometry: { type: "Point", coordinates: [item.lng, item.lat] },
2612
+ properties
2613
+ };
2614
+ }
2615
+ /** Load a custom `data:` image once; rebuild the features when done. */
2616
+ loadCustomImage(key, uri) {
2617
+ if (this.pendingImages.has(key)) return;
2618
+ this.pendingImages.add(key);
2619
+ const generation = this.generation;
2620
+ const stale = () => this.destroyed || generation !== this.generation;
2621
+ void this.map.loadImage(uri).then((image) => {
2622
+ this.pendingImages.delete(key);
2623
+ if (stale()) return;
2624
+ this.loadedImages.set(key, {
2625
+ data: image.data,
2626
+ raw: image.data
2627
+ });
2628
+ this.rebuild();
2629
+ }).catch((error) => {
2630
+ this.pendingImages.delete(key);
2631
+ if (stale()) return;
2632
+ this.failedImages.add(key);
2633
+ this.options.onImageError?.(uri, error);
2634
+ this.rebuild();
2635
+ });
2636
+ }
2637
+ /**
2638
+ * Register every image the current features reference and drop the ones
2639
+ * they no longer do (idempotent). Runtime images survive a diffed
2640
+ * `setStyle` and are rebuilt after a full reload, so this runs on every
2641
+ * install as well as every data rebuild.
2642
+ */
2643
+ registerImages() {
2644
+ const needed = /* @__PURE__ */ new Set();
2645
+ for (const item of this.items) {
2646
+ const imageKey = item.image !== void 0 ? markerImageDataKey(item.image) : void 0;
2647
+ const loaded = imageKey !== void 0 ? this.loadedImages.get(imageKey) : void 0;
2648
+ if (imageKey !== void 0 && loaded && !this.failedImages.has(imageKey)) {
2649
+ needed.add(imageKey);
2650
+ if (!this.map.hasImage(imageKey)) {
2651
+ this.map.addImage(imageKey, loaded.raw);
2652
+ }
2653
+ continue;
2654
+ }
2655
+ const key = markerImageKey(item.icon, item.colour, item.size);
2656
+ needed.add(key);
2657
+ if (this.map.hasImage(key)) continue;
2658
+ const rendered = renderMarkerImage({
2659
+ icon: item.icon,
2660
+ colour: item.colour,
2661
+ size: item.size
2662
+ });
2663
+ if (rendered) {
2664
+ this.map.addImage(key, rendered.image, { pixelRatio: rendered.pixelRatio });
2665
+ }
2666
+ }
2667
+ for (const key of this.registeredImageKeys) {
2668
+ if (needed.has(key)) continue;
2669
+ if (this.map.hasImage(key)) this.map.removeImage(key);
2670
+ this.registeredImageKeys.delete(key);
2671
+ }
2672
+ for (const key of needed) this.registeredImageKeys.add(key);
2673
+ }
2674
+ /** Add-or-update the images, source and layer on the current style. */
2675
+ install() {
2676
+ if (!this.data) return;
2677
+ this.registerImages();
2678
+ const existing = this.map.getSource(MARKERS_ID);
2679
+ if (existing) {
2680
+ existing.setData(this.data);
2681
+ } else {
2682
+ this.map.addSource(MARKERS_ID, { type: "geojson", data: this.data });
2683
+ }
2684
+ if (!this.map.getLayer(MARKERS_ID)) {
2685
+ const layer = {
2686
+ id: MARKERS_ID,
2687
+ type: "symbol",
2688
+ source: MARKERS_ID,
2689
+ layout: {
2690
+ "icon-image": ["get", "iconImage"],
2691
+ "icon-size": ["get", "iconSize"],
2692
+ "icon-anchor": ["get", "iconAnchor"],
2693
+ "icon-allow-overlap": true,
2694
+ "text-field": ["get", "label"],
2695
+ "text-font": [MARKER_TEXT_FONT],
2696
+ "text-size": ["get", "textSize"],
2697
+ "text-anchor": "top",
2698
+ "text-offset": TEXT_OFFSET_EXPRESSION,
2699
+ "text-optional": true
2700
+ },
2701
+ paint: {
2702
+ "text-color": "#333333",
2703
+ "text-halo-color": "#ffffff",
2704
+ "text-halo-width": 1.5
2705
+ }
2706
+ };
2707
+ this.map.addLayer(layer);
2708
+ }
2709
+ }
2710
+ /**
2711
+ * Remove the markers' layer, source and registered images from the map.
2712
+ * Images are removed by the keys this layer REGISTERED, not by whatever
2713
+ * `items` happens to hold now — deriving them from the items meant
2714
+ * `setMarkers(undefined)` followed by `clear()`/`destroy()` removed
2715
+ * nothing at all.
2716
+ */
2717
+ clear() {
2718
+ if (this.map.getLayer(MARKERS_ID)) this.map.removeLayer(MARKERS_ID);
2719
+ if (this.map.getSource(MARKERS_ID)) this.map.removeSource(MARKERS_ID);
2720
+ for (const key of this.registeredImageKeys) {
2721
+ if (this.map.hasImage(key)) this.map.removeImage(key);
2722
+ }
2723
+ this.registeredImageKeys.clear();
2724
+ this.items = [];
2725
+ this.data = void 0;
2726
+ this.loadedImages.clear();
2727
+ this.failedImages.clear();
2728
+ this.pendingImages.clear();
2729
+ this.generation++;
2730
+ }
2731
+ /**
2732
+ * Remove the markers and detach the style.load listener. Call when
2733
+ * disposing of the layer (the map is left untouched). Any image load
2734
+ * still in flight resolves into a no-op.
2735
+ */
2736
+ destroy() {
2737
+ this.destroyed = true;
2738
+ this.map.off("style.load", this.handleStyleLoad);
2739
+ this.clear();
2740
+ }
2741
+ };
2742
+
2156
2743
  // src/route.ts
2157
2744
  var SIGNAL_BLUE = "#3a86ff";
2158
2745
  var CASING_COLOR = "#1f438a";
2159
2746
  var CORRIDOR_COLOR = "#e05c6c";
2160
2747
  var CORRIDOR_OPACITY = 0.55;
2748
+ var ENDPOINT_START_COLOUR = "#3ecf8e";
2749
+ var DEFAULT_START = {
2750
+ icon: "dot",
2751
+ colour: ENDPOINT_START_COLOUR,
2752
+ size: "m"
2753
+ };
2754
+ var DEFAULT_END = {
2755
+ icon: "pin",
2756
+ colour: SIGNAL_BLUE,
2757
+ size: "m"
2758
+ };
2759
+ var DEFAULT_WAYPOINT = {
2760
+ icon: "pin",
2761
+ colour: SIGNAL_BLUE,
2762
+ size: "s"
2763
+ };
2764
+ function resolveEndpoints(option) {
2765
+ if (option === void 0 || option === false) return void 0;
2766
+ const design = option === true ? {} : option;
2767
+ const merge = (value, fallback) => value === false ? false : { ...fallback, ...value };
2768
+ return {
2769
+ start: merge(design.start, DEFAULT_START),
2770
+ end: merge(design.end, DEFAULT_END),
2771
+ waypoint: merge(design.waypoint, DEFAULT_WAYPOINT),
2772
+ numberWaypoints: design.numberWaypoints ?? true
2773
+ };
2774
+ }
2161
2775
  var PROGRESS_COLOR = "#b0b0b0";
2162
2776
  var ALT_CASING_COLOR = "#5a6b84";
2163
2777
  var ALT_LINE_COLOR = "#aebdd2";
@@ -2187,14 +2801,64 @@ function arrowImage() {
2187
2801
  }
2188
2802
  return { width: size, height: size, data };
2189
2803
  }
2804
+ var ENDPOINT_TEXT_FONT = "MapMap Sans Regular";
2805
+ var ENDPOINT_TEXT_OFFSET = [
2806
+ "match",
2807
+ ["get", "offsetKey"],
2808
+ "tip",
2809
+ ["literal", [0, 0.4]],
2810
+ "cs",
2811
+ ["literal", [0, 1.3]],
2812
+ "cm",
2813
+ ["literal", [0, 1.55]],
2814
+ "cl",
2815
+ ["literal", [0, 1.75]],
2816
+ ["literal", [0, 0.4]]
2817
+ ];
2818
+ function endpointRender(marker, coordinates, role, numbered) {
2819
+ const size = marker.size ?? "m";
2820
+ const icon = marker.icon ?? "pin";
2821
+ const colour2 = marker.colour ?? SIGNAL_BLUE;
2822
+ const text = markerText(marker.text) ?? markerText(numbered);
2823
+ const key = text !== void 0 ? markerTextImageKey(text, colour2, size) : markerImageKey(icon, colour2, size);
2824
+ const anchor = icon === "dot" && text === void 0 ? "center" : "bottom";
2825
+ const properties = {
2826
+ role,
2827
+ iconImage: key,
2828
+ iconAnchor: anchor,
2829
+ textSize: MARKER_LABEL_TEXT_SIZES[size],
2830
+ offsetKey: anchor === "bottom" ? "tip" : `c${size}`
2831
+ };
2832
+ if (marker.label !== void 0) properties["label"] = marker.label;
2833
+ return {
2834
+ feature: {
2835
+ type: "Feature",
2836
+ geometry: { type: "Point", coordinates },
2837
+ properties
2838
+ },
2839
+ key,
2840
+ render: text !== void 0 ? { text, colour: colour2, size } : { icon, colour: colour2, size }
2841
+ };
2842
+ }
2190
2843
  var RouteLayer = class {
2191
2844
  constructor(map, options = {}) {
2845
+ /** Intermediate stops for the endpoint markers, `[lng, lat]` each. */
2846
+ this.waypoints = [];
2847
+ /** Marker images this layer registered, so teardown can remove them. */
2848
+ this.endpointImageKeys = /* @__PURE__ */ new Set();
2192
2849
  /** Unselected alternatives, drawn beneath the selected route. */
2193
2850
  this.alternatives = [];
2194
2851
  /** Ferry legs of the selected route, drawn as dashes over its line. */
2195
2852
  this.ferrySegments = [];
2196
2853
  this.progress = 0;
2197
2854
  this.corridors = [];
2855
+ /**
2856
+ * Monotonic id of the newest route request. A response only draws when it
2857
+ * still owns this value - see {@link RouteLayer.routePath}.
2858
+ */
2859
+ this.requestSeq = 0;
2860
+ /** Whether the alternative-line click listener is currently attached. */
2861
+ this.altClickBound = false;
2198
2862
  this.handleStyleLoad = () => {
2199
2863
  this.installAlternatives();
2200
2864
  if (this.lastRoute) this.install(this.lastRoute);
@@ -2243,12 +2907,33 @@ var RouteLayer = class {
2243
2907
  this.altLineLayerId = `${id}-alt-line`;
2244
2908
  this.ferrySourceId = `${id}-ferry-src`;
2245
2909
  this.ferryLayerId = `${id}-ferry`;
2910
+ this.endpointsSourceId = `${id}-endpoints`;
2911
+ this.endpointsLayerId = `${id}-endpoints`;
2912
+ this.endpoints = resolveEndpoints(options.endpoints);
2246
2913
  this.progressColor = options.progressColor ?? PROGRESS_COLOR;
2247
2914
  this.altCasingColor = options.alternativeCasingColor ?? ALT_CASING_COLOR;
2248
2915
  this.altColor = options.alternativeColor ?? ALT_LINE_COLOR;
2249
2916
  this.ferryColor = options.ferryColor ?? FERRY_COLOR;
2250
2917
  this.map.on("style.load", this.handleStyleLoad);
2251
2918
  }
2919
+ /**
2920
+ * The MapLibre source and layer ids this layer owns - public API for
2921
+ * escape-hatch styling and layer ordering (`map.moveLayer`). All are
2922
+ * derived from the `id` option (default `mapmap-route`).
2923
+ */
2924
+ get ids() {
2925
+ return {
2926
+ source: this.sourceId,
2927
+ casing: this.casingLayerId,
2928
+ line: this.lineLayerId,
2929
+ maneuverSource: this.maneuverSourceId,
2930
+ maneuver: this.maneuverLayerId,
2931
+ corridorSource: this.corridorSourceId,
2932
+ corridor: this.corridorLayerId,
2933
+ endpointsSource: this.endpointsSourceId,
2934
+ endpoints: this.endpointsLayerId
2935
+ };
2936
+ }
2252
2937
  /**
2253
2938
  * Route from `from` to `to` (with optional intermediate `via` points passed
2254
2939
  * through `options` is not supported here - pass a coordinate list to
@@ -2257,8 +2942,21 @@ var RouteLayer = class {
2257
2942
  async route(from, to, options = {}) {
2258
2943
  return this.routePath([from, to], options);
2259
2944
  }
2260
- /** Route through an ordered list of coordinates and draw the result. */
2945
+ /**
2946
+ * Route through an ordered list of coordinates and draw the result.
2947
+ *
2948
+ * Concurrent calls are ordered by ISSUE, not by arrival: only the newest
2949
+ * request draws. A destination retyped while the first request is still in
2950
+ * flight used to leave the map showing whichever response happened to land
2951
+ * last, so a driver could be looking at the line to the address they had
2952
+ * already replaced. Each caller still receives its own parsed route, so a
2953
+ * caller that wants the data of a superseded request keeps getting it -
2954
+ * only the drawing is suppressed. {@link RouteLayer.clear} likewise
2955
+ * supersedes anything in flight.
2956
+ */
2261
2957
  async routePath(points, options = {}) {
2958
+ const seq = ++this.requestSeq;
2959
+ this.waypoints = points.slice(1, -1).map(toLngLat);
2262
2960
  const url = buildRouteUrl(
2263
2961
  this.baseUrl,
2264
2962
  options.profile ?? "driving",
@@ -2280,7 +2978,7 @@ var RouteLayer = class {
2280
2978
  }
2281
2979
  }
2282
2980
  const parsed = parseOsrmRoute(body);
2283
- this.draw(parsed);
2981
+ if (seq === this.requestSeq) this.draw(parsed);
2284
2982
  return parsed;
2285
2983
  }
2286
2984
  /** The most recently drawn route, if any. */
@@ -2314,6 +3012,7 @@ var RouteLayer = class {
2314
3012
  this.applyProgress();
2315
3013
  this.applyLineStyle();
2316
3014
  this.installManeuver();
3015
+ this.installEndpoints();
2317
3016
  return;
2318
3017
  }
2319
3018
  const casing = {
@@ -2350,6 +3049,115 @@ var RouteLayer = class {
2350
3049
  this.applyProgress();
2351
3050
  this.applyLineStyle();
2352
3051
  this.installManeuver();
3052
+ this.installEndpoints();
3053
+ }
3054
+ /**
3055
+ * Set the intermediate stops drawn by the endpoint markers, in order.
3056
+ * {@link routePath} fills these in from its own via points, so this is
3057
+ * only needed when drawing a route parsed elsewhere (e.g. a route fetched
3058
+ * by the app and handed to {@link draw}). An empty array clears them.
3059
+ */
3060
+ setWaypoints(waypoints) {
3061
+ this.waypoints = waypoints.map(toLngLat);
3062
+ if (this.lastRoute && this.map.isStyleLoaded()) this.installEndpoints();
3063
+ }
3064
+ /** The intermediate stops currently drawn, `[lng, lat]` each. */
3065
+ get currentWaypoints() {
3066
+ return this.waypoints;
3067
+ }
3068
+ /**
3069
+ * Build one Point feature per endpoint marker: start (first route
3070
+ * coordinate), each waypoint in order, then end (last coordinate). The
3071
+ * end is added last so it wins MapLibre's symbol-collision ordering when
3072
+ * markers overlap on a short route.
3073
+ */
3074
+ endpointFeatures(route) {
3075
+ const design = this.endpoints;
3076
+ if (!design) return [];
3077
+ const line = route.geometry.coordinates;
3078
+ const first = line[0];
3079
+ const last = line[line.length - 1];
3080
+ if (!first || !last) return [];
3081
+ const rendered = [];
3082
+ const push = (marker, coordinates, role, numbered) => {
3083
+ if (marker === false) return;
3084
+ rendered.push(endpointRender(marker, coordinates, role, numbered));
3085
+ };
3086
+ push(design.start, first, "start");
3087
+ this.waypoints.forEach((coordinates, index) => {
3088
+ const numbered = design.numberWaypoints ? String(index + 1) : void 0;
3089
+ push(design.waypoint, coordinates, "waypoint", numbered);
3090
+ });
3091
+ push(design.end, last, "end");
3092
+ return rendered;
3093
+ }
3094
+ /**
3095
+ * Register every endpoint marker image the current features need
3096
+ * (idempotent; `setStyle` wipes runtime images, so this runs on each
3097
+ * install). Keys are remembered so teardown removes exactly what this
3098
+ * layer added.
3099
+ */
3100
+ registerEndpointImages(rendered) {
3101
+ for (const item of rendered) {
3102
+ this.endpointImageKeys.add(item.key);
3103
+ if (this.map.hasImage(item.key)) continue;
3104
+ const image = renderMarkerImage(item.render);
3105
+ if (image) {
3106
+ this.map.addImage(item.key, image.image, { pixelRatio: image.pixelRatio });
3107
+ }
3108
+ }
3109
+ }
3110
+ /** Add-or-update the endpoint markers' source and symbol layer. */
3111
+ installEndpoints() {
3112
+ if (!this.endpoints || !this.lastRoute) return;
3113
+ const rendered = this.endpointFeatures(this.lastRoute);
3114
+ const data = {
3115
+ type: "FeatureCollection",
3116
+ features: rendered.map((item) => item.feature)
3117
+ };
3118
+ this.registerEndpointImages(rendered);
3119
+ const source = this.map.getSource(this.endpointsSourceId);
3120
+ if (source) {
3121
+ source.setData(data);
3122
+ } else {
3123
+ this.map.addSource(this.endpointsSourceId, { type: "geojson", data });
3124
+ }
3125
+ if (this.map.getLayer(this.endpointsLayerId)) return;
3126
+ const layer = {
3127
+ id: this.endpointsLayerId,
3128
+ type: "symbol",
3129
+ source: this.endpointsSourceId,
3130
+ layout: {
3131
+ "icon-image": ["get", "iconImage"],
3132
+ "icon-anchor": ["get", "iconAnchor"],
3133
+ "icon-allow-overlap": true,
3134
+ "text-field": ["get", "label"],
3135
+ "text-font": [ENDPOINT_TEXT_FONT],
3136
+ "text-size": ["get", "textSize"],
3137
+ "text-anchor": "top",
3138
+ "text-offset": ENDPOINT_TEXT_OFFSET,
3139
+ "text-optional": true
3140
+ },
3141
+ paint: {
3142
+ "text-color": "#333333",
3143
+ "text-halo-color": "#ffffff",
3144
+ "text-halo-width": 1.5
3145
+ }
3146
+ };
3147
+ this.map.addLayer(layer);
3148
+ }
3149
+ /** Remove the endpoint markers, their source and their images. */
3150
+ clearEndpoints() {
3151
+ if (this.map.getLayer(this.endpointsLayerId)) {
3152
+ this.map.removeLayer(this.endpointsLayerId);
3153
+ }
3154
+ if (this.map.getSource(this.endpointsSourceId)) {
3155
+ this.map.removeSource(this.endpointsSourceId);
3156
+ }
3157
+ for (const key of this.endpointImageKeys) {
3158
+ if (this.map.hasImage(key)) this.map.removeImage(key);
3159
+ }
3160
+ this.endpointImageKeys.clear();
2353
3161
  }
2354
3162
  /**
2355
3163
  * Draw a bare line from coordinates, without a parsed OSRM route.
@@ -2466,12 +3274,18 @@ var RouteLayer = class {
2466
3274
  "line-opacity": 0.9
2467
3275
  }
2468
3276
  }, below);
3277
+ }
3278
+ if (!this.altClickBound) {
2469
3279
  this.map.on("click", this.altLineLayerId, this.handleAlternativeClick);
3280
+ this.altClickBound = true;
2470
3281
  }
2471
3282
  }
2472
3283
  removeAlternativeLayers() {
2473
- if (this.map.getLayer(this.altLineLayerId)) {
3284
+ if (this.altClickBound) {
2474
3285
  this.map.off("click", this.altLineLayerId, this.handleAlternativeClick);
3286
+ this.altClickBound = false;
3287
+ }
3288
+ if (this.map.getLayer(this.altLineLayerId)) {
2475
3289
  this.map.removeLayer(this.altLineLayerId);
2476
3290
  }
2477
3291
  if (this.map.getLayer(this.altCasingLayerId)) this.map.removeLayer(this.altCasingLayerId);
@@ -2680,14 +3494,21 @@ var RouteLayer = class {
2680
3494
  this.map.setPaintProperty(this.corridorLayerId, "line-width", width);
2681
3495
  }
2682
3496
  }
2683
- /** Remove the route's layers and source from the map. */
3497
+ /**
3498
+ * Remove the route's layers and source from the map. Any route request
3499
+ * still in flight is superseded, so a late response cannot redraw a route
3500
+ * the caller has already cleared.
3501
+ */
2684
3502
  clear() {
3503
+ this.requestSeq += 1;
2685
3504
  this.clearManeuver();
2686
3505
  this.clearAlertCorridors();
2687
3506
  this.clearFerrySegments();
2688
3507
  this.alternatives = [];
2689
3508
  this.altOriginalIndices = [];
2690
3509
  this.removeAlternativeLayers();
3510
+ this.clearEndpoints();
3511
+ this.waypoints = [];
2691
3512
  for (const layerId of [this.lineLayerId, this.casingLayerId]) {
2692
3513
  if (this.map.getLayer(layerId)) this.map.removeLayer(layerId);
2693
3514
  }
@@ -2757,7 +3578,7 @@ function alertContrastIssues(design) {
2757
3578
  return issues;
2758
3579
  }
2759
3580
  var MAX_PUCK_IMAGE_BYTES = 64 * 1024;
2760
- function dataUriBytes(uri) {
3581
+ function dataUriBytes2(uri) {
2761
3582
  const comma = uri.indexOf(",");
2762
3583
  const payload = comma === -1 ? "" : uri.slice(comma + 1);
2763
3584
  const meta = comma === -1 ? uri : uri.slice(0, comma);
@@ -2766,7 +3587,7 @@ function dataUriBytes(uri) {
2766
3587
  function effectiveImageUrl(design) {
2767
3588
  const url = design.imageUrl?.trim();
2768
3589
  if (!url) return void 0;
2769
- if (/^data:/i.test(url) && dataUriBytes(url) > MAX_PUCK_IMAGE_BYTES) {
3590
+ if (/^data:/i.test(url) && dataUriBytes2(url) > MAX_PUCK_IMAGE_BYTES) {
2770
3591
  return void 0;
2771
3592
  }
2772
3593
  return url;
@@ -3085,7 +3906,7 @@ async function uploadProbeBatch(baseUrl, apiKey, body, options = {}) {
3085
3906
  } catch {
3086
3907
  outcome = "gaveUp";
3087
3908
  }
3088
- if (outcome === "gaveUp" && attempt < maxAttempts) {
3909
+ if (outcome === "gaveUp" && attempt < maxAttempts && options.signal?.aborted !== true) {
3089
3910
  await sleep(backoffMs(attempt));
3090
3911
  continue;
3091
3912
  }
@@ -3131,6 +3952,8 @@ function haversineDistanceM(a, b) {
3131
3952
  }
3132
3953
  var PlacesLayer = class {
3133
3954
  constructor(map, options = {}) {
3955
+ /** Per-place icon keys whose image failed to load (circle fallback). */
3956
+ this.failedIconKeys = /* @__PURE__ */ new Set();
3134
3957
  this.places = [];
3135
3958
  this.fitted = false;
3136
3959
  this.destroyed = false;
@@ -3183,13 +4006,21 @@ var PlacesLayer = class {
3183
4006
  this.clustersLayerId = `${id}-clusters`;
3184
4007
  this.clusterCountLayerId = `${id}-cluster-count`;
3185
4008
  this.pointsLayerId = `${id}-points`;
4009
+ this.pointsFallbackLayerId = `${id}-points-fallback`;
4010
+ this.labelsLayerId = `${id}-labels`;
3186
4011
  this.iconImageId = `${id}-icon`;
3187
4012
  this.cluster = options.cluster ?? true;
3188
4013
  this.clusterRadius = options.clusterRadius;
3189
4014
  this.clusterMaxZoom = options.clusterMaxZoom;
3190
4015
  this.color = options.color ?? SIGNAL_BLUE;
3191
4016
  this.clusterColor = options.clusterColor ?? (typeof this.color === "string" ? this.color : SIGNAL_BLUE);
3192
- this.icon = options.icon;
4017
+ if (options.icon && typeof options.icon.url === "string") {
4018
+ this.singleIcon = options.icon;
4019
+ } else if (options.icon) {
4020
+ this.iconRecord = options.icon;
4021
+ }
4022
+ this.iconProperty = options.iconProperty ?? "kind";
4023
+ this.label = options.label === true ? {} : options.label ? options.label : void 0;
3193
4024
  this.wantFitBounds = options.fitBounds ?? false;
3194
4025
  this.onPlaceClick = options.onPlaceClick;
3195
4026
  this.popupFn = options.popup;
@@ -3197,6 +4028,11 @@ var PlacesLayer = class {
3197
4028
  this.map.on("click", this.pointsLayerId, this.handlePlaceClick);
3198
4029
  this.map.on("mouseenter", this.pointsLayerId, this.handleMouseEnter);
3199
4030
  this.map.on("mouseleave", this.pointsLayerId, this.handleMouseLeave);
4031
+ if (this.iconRecord) {
4032
+ this.map.on("click", this.pointsFallbackLayerId, this.handlePlaceClick);
4033
+ this.map.on("mouseenter", this.pointsFallbackLayerId, this.handleMouseEnter);
4034
+ this.map.on("mouseleave", this.pointsFallbackLayerId, this.handleMouseLeave);
4035
+ }
3200
4036
  if (this.cluster) {
3201
4037
  this.map.on("click", this.clustersLayerId, this.handleClusterClick);
3202
4038
  this.map.on("mouseenter", this.clustersLayerId, this.handleMouseEnter);
@@ -3256,7 +4092,9 @@ var PlacesLayer = class {
3256
4092
  source: this.sourceId,
3257
4093
  points: this.pointsLayerId,
3258
4094
  clusters: this.clustersLayerId,
3259
- clusterCounts: this.clusterCountLayerId
4095
+ clusterCounts: this.clusterCountLayerId,
4096
+ labels: this.labelsLayerId,
4097
+ pointsFallback: this.pointsFallbackLayerId
3260
4098
  };
3261
4099
  }
3262
4100
  /**
@@ -3300,6 +4138,11 @@ var PlacesLayer = class {
3300
4138
  * attached; unreachable places (`null` duration) are dropped. Needs the
3301
4139
  * gateway `baseUrl`/`apiKey` - picked up from a `MapMapMap` automatically,
3302
4140
  * or pass them as layer options.
4141
+ *
4142
+ * Answers always describe the places that were asked about: the list is
4143
+ * snapshotted at call time, so a `setPlaces` landing mid-flight (a
4144
+ * search-as-you-type filter, say) cannot mis-pair the matrix rows with a
4145
+ * different set of places.
3303
4146
  */
3304
4147
  async nearestByDriveTime(origin, options = {}) {
3305
4148
  if (!this.baseUrl) {
@@ -3307,7 +4150,8 @@ var PlacesLayer = class {
3307
4150
  "PlacesLayer needs a baseUrl (or a MapMapMap) for nearestByDriveTime"
3308
4151
  );
3309
4152
  }
3310
- if (this.places.length === 0) return [];
4153
+ const places = this.places;
4154
+ if (places.length === 0) return [];
3311
4155
  const headers = {
3312
4156
  "Content-Type": "application/json",
3313
4157
  Accept: "application/json"
@@ -3315,7 +4159,7 @@ var PlacesLayer = class {
3315
4159
  if (this.apiKey) headers["Authorization"] = `Bearer ${this.apiKey}`;
3316
4160
  const body = {
3317
4161
  sources: [{ lat: origin.lat, lon: origin.lon }],
3318
- targets: this.places.map((p) => ({ lat: p.lat, lon: p.lon })),
4162
+ targets: places.map((p) => ({ lat: p.lat, lon: p.lon })),
3319
4163
  costing: options.costing ?? "auto"
3320
4164
  };
3321
4165
  if (options.costingOptions) body["costing_options"] = options.costingOptions;
@@ -3334,7 +4178,7 @@ var PlacesLayer = class {
3334
4178
  }
3335
4179
  const { durations, distances } = matrixRows(payload);
3336
4180
  const reachable = [];
3337
- this.places.forEach((place, index) => {
4181
+ places.forEach((place, index) => {
3338
4182
  const durationS = durations[index];
3339
4183
  if (typeof durationS !== "number") return;
3340
4184
  const distanceM = distances?.[index];
@@ -3363,6 +4207,38 @@ var PlacesLayer = class {
3363
4207
  }
3364
4208
  if (this.cluster) this.installClusterLayers();
3365
4209
  this.installPointsLayer();
4210
+ this.installLabelsLayer();
4211
+ }
4212
+ /**
4213
+ * The per-place name labels layer (only with a `label` option): the map
4214
+ * font with a halo, anchored just below each unclustered point, hidden
4215
+ * before colliding (`text-optional`). Re-installed with the rest of the
4216
+ * layers on every `style.load`.
4217
+ */
4218
+ installLabelsLayer() {
4219
+ if (!this.label || this.map.getLayer(this.labelsLayerId)) return;
4220
+ const labels = {
4221
+ id: this.labelsLayerId,
4222
+ type: "symbol",
4223
+ source: this.sourceId,
4224
+ ...this.cluster ? { filter: ["!", ["has", "point_count"]] } : {},
4225
+ layout: {
4226
+ "text-field": ["get", this.label.property ?? "name"],
4227
+ "text-font": [CLUSTER_TEXT_FONT],
4228
+ "text-size": this.label.size ?? 12,
4229
+ "text-anchor": "top",
4230
+ "text-offset": [0, 0.9],
4231
+ "text-optional": true
4232
+ },
4233
+ paint: {
4234
+ // Both spellings are accepted (British wins) — see
4235
+ // PlacesLabelOptions.
4236
+ "text-color": this.label.colour ?? this.label.color ?? "#333333",
4237
+ "text-halo-color": this.label.haloColour ?? this.label.haloColor ?? "#ffffff",
4238
+ "text-halo-width": 1.5
4239
+ }
4240
+ };
4241
+ this.map.addLayer(labels);
3366
4242
  }
3367
4243
  /** Cluster circles (sized by count) and the count badge on top. */
3368
4244
  installClusterLayers() {
@@ -3404,8 +4280,12 @@ var PlacesLayer = class {
3404
4280
  * circle look if it fails, like PositionPuck's image fallback).
3405
4281
  */
3406
4282
  installPointsLayer() {
4283
+ if (this.iconRecord) {
4284
+ this.installPerPlaceIconLayers(this.iconRecord);
4285
+ return;
4286
+ }
3407
4287
  if (this.map.getLayer(this.pointsLayerId)) return;
3408
- if (!this.icon) {
4288
+ if (!this.singleIcon) {
3409
4289
  this.addCirclePointsLayer();
3410
4290
  return;
3411
4291
  }
@@ -3413,7 +4293,7 @@ var PlacesLayer = class {
3413
4293
  this.addIconPointsLayer();
3414
4294
  return;
3415
4295
  }
3416
- void this.map.loadImage(this.icon.url).then((image) => {
4296
+ void this.map.loadImage(this.singleIcon.url).then((image) => {
3417
4297
  if (this.destroyed || this.map.getLayer(this.pointsLayerId)) return;
3418
4298
  if (!this.map.hasImage(this.iconImageId)) {
3419
4299
  this.map.addImage(this.iconImageId, image.data);
@@ -3424,6 +4304,103 @@ var PlacesLayer = class {
3424
4304
  this.addCirclePointsLayer();
3425
4305
  });
3426
4306
  }
4307
+ /** The `map.addImage` id for one key of the per-place icon record. */
4308
+ iconImageIdFor(key) {
4309
+ return `${this.iconImageId}-${key}`;
4310
+ }
4311
+ /**
4312
+ * Per-place icon mode: load every record image (each key independently -
4313
+ * one bad URL never sinks the rest), then install a symbol layer whose
4314
+ * `icon-image` is a match expression on `iconProperty`, plus a circle
4315
+ * fallback layer for places whose property value has no loaded icon.
4316
+ */
4317
+ installPerPlaceIconLayers(record) {
4318
+ const keys = Object.keys(record);
4319
+ if (keys.length === 0) {
4320
+ if (!this.map.getLayer(this.pointsLayerId)) this.addCirclePointsLayer();
4321
+ return;
4322
+ }
4323
+ const toLoad = keys.filter(
4324
+ (key) => !this.map.hasImage(this.iconImageIdFor(key)) && !this.failedIconKeys.has(key)
4325
+ );
4326
+ if (toLoad.length === 0) {
4327
+ this.addPerPlaceLayers(record);
4328
+ return;
4329
+ }
4330
+ void Promise.all(
4331
+ toLoad.map(async (key) => {
4332
+ try {
4333
+ const image = await this.map.loadImage(record[key].url);
4334
+ if (this.destroyed) return;
4335
+ if (!this.map.hasImage(this.iconImageIdFor(key))) {
4336
+ this.map.addImage(this.iconImageIdFor(key), image.data);
4337
+ }
4338
+ } catch {
4339
+ this.failedIconKeys.add(key);
4340
+ }
4341
+ })
4342
+ ).then(() => {
4343
+ if (!this.destroyed) this.addPerPlaceLayers(record);
4344
+ });
4345
+ }
4346
+ /** Install the per-place icon symbol layer + circle fallback layer. */
4347
+ addPerPlaceLayers(record) {
4348
+ const loaded = Object.keys(record).filter(
4349
+ (key) => this.map.hasImage(this.iconImageIdFor(key))
4350
+ );
4351
+ const unclustered = ["!", ["has", "point_count"]];
4352
+ if (!this.map.getLayer(this.pointsFallbackLayerId)) {
4353
+ const notCovered = [
4354
+ "!",
4355
+ ["in", ["get", this.iconProperty], ["literal", loaded]]
4356
+ ];
4357
+ const fallback = {
4358
+ id: this.pointsFallbackLayerId,
4359
+ type: "circle",
4360
+ source: this.sourceId,
4361
+ filter: this.cluster ? ["all", unclustered, notCovered] : notCovered,
4362
+ paint: {
4363
+ "circle-color": this.color,
4364
+ "circle-radius": 7,
4365
+ "circle-stroke-color": "#ffffff",
4366
+ "circle-stroke-width": 2
4367
+ }
4368
+ };
4369
+ this.map.addLayer(fallback);
4370
+ }
4371
+ if (loaded.length === 0) return;
4372
+ if (this.map.getLayer(this.pointsLayerId)) return;
4373
+ const iconImage = [
4374
+ "match",
4375
+ ["get", this.iconProperty],
4376
+ ...loaded.flatMap((key) => [key, this.iconImageIdFor(key)]),
4377
+ this.iconImageIdFor(loaded[0])
4378
+ // unreachable under the filter
4379
+ ];
4380
+ const iconSize = [
4381
+ "match",
4382
+ ["get", this.iconProperty],
4383
+ ...loaded.flatMap((key) => [key, record[key]?.size ?? 1]),
4384
+ 1
4385
+ ];
4386
+ const covered = [
4387
+ "in",
4388
+ ["get", this.iconProperty],
4389
+ ["literal", loaded]
4390
+ ];
4391
+ const points = {
4392
+ id: this.pointsLayerId,
4393
+ type: "symbol",
4394
+ source: this.sourceId,
4395
+ filter: this.cluster ? ["all", unclustered, covered] : covered,
4396
+ layout: {
4397
+ "icon-image": iconImage,
4398
+ "icon-size": iconSize,
4399
+ "icon-allow-overlap": true
4400
+ }
4401
+ };
4402
+ this.map.addLayer(points);
4403
+ }
3427
4404
  addCirclePointsLayer() {
3428
4405
  const points = {
3429
4406
  id: this.pointsLayerId,
@@ -3447,7 +4424,7 @@ var PlacesLayer = class {
3447
4424
  ...this.cluster ? { filter: ["!", ["has", "point_count"]] } : {},
3448
4425
  layout: {
3449
4426
  "icon-image": this.iconImageId,
3450
- "icon-size": this.icon?.size ?? 1,
4427
+ "icon-size": this.singleIcon?.size ?? 1,
3451
4428
  "icon-allow-overlap": true
3452
4429
  }
3453
4430
  };
@@ -3471,7 +4448,9 @@ var PlacesLayer = class {
3471
4448
  this.popup?.remove();
3472
4449
  this.popup = void 0;
3473
4450
  for (const layerId of [
4451
+ this.labelsLayerId,
3474
4452
  this.pointsLayerId,
4453
+ this.pointsFallbackLayerId,
3475
4454
  this.clusterCountLayerId,
3476
4455
  this.clustersLayerId
3477
4456
  ]) {
@@ -3481,6 +4460,11 @@ var PlacesLayer = class {
3481
4460
  if (this.map.hasImage(this.iconImageId)) {
3482
4461
  this.map.removeImage(this.iconImageId);
3483
4462
  }
4463
+ for (const key of Object.keys(this.iconRecord ?? {})) {
4464
+ if (this.map.hasImage(this.iconImageIdFor(key))) {
4465
+ this.map.removeImage(this.iconImageIdFor(key));
4466
+ }
4467
+ }
3484
4468
  this.places = [];
3485
4469
  this.data = void 0;
3486
4470
  }
@@ -3494,6 +4478,11 @@ var PlacesLayer = class {
3494
4478
  this.map.off("click", this.pointsLayerId, this.handlePlaceClick);
3495
4479
  this.map.off("mouseenter", this.pointsLayerId, this.handleMouseEnter);
3496
4480
  this.map.off("mouseleave", this.pointsLayerId, this.handleMouseLeave);
4481
+ if (this.iconRecord) {
4482
+ this.map.off("click", this.pointsFallbackLayerId, this.handlePlaceClick);
4483
+ this.map.off("mouseenter", this.pointsFallbackLayerId, this.handleMouseEnter);
4484
+ this.map.off("mouseleave", this.pointsFallbackLayerId, this.handleMouseLeave);
4485
+ }
3497
4486
  if (this.cluster) {
3498
4487
  this.map.off("click", this.clustersLayerId, this.handleClusterClick);
3499
4488
  this.map.off("mouseenter", this.clustersLayerId, this.handleMouseEnter);
@@ -3503,16 +4492,26 @@ var PlacesLayer = class {
3503
4492
  }
3504
4493
  };
3505
4494
  function boundsOf(places) {
3506
- let minLng = Infinity;
3507
4495
  let minLat = Infinity;
3508
- let maxLng = -Infinity;
3509
4496
  let maxLat = -Infinity;
3510
- for (const { lon, lat } of places) {
3511
- if (lon < minLng) minLng = lon;
4497
+ for (const { lat } of places) {
3512
4498
  if (lat < minLat) minLat = lat;
3513
- if (lon > maxLng) maxLng = lon;
3514
4499
  if (lat > maxLat) maxLat = lat;
3515
4500
  }
4501
+ const lngs = places.map((p) => p.lon).sort((a, b) => a - b);
4502
+ let widestGap = -Infinity;
4503
+ let cutAfter = lngs.length - 1;
4504
+ for (let i = 0; i < lngs.length; i++) {
4505
+ const here = lngs[i];
4506
+ const next = i === lngs.length - 1 ? lngs[0] + 360 : lngs[i + 1];
4507
+ if (next - here > widestGap) {
4508
+ widestGap = next - here;
4509
+ cutAfter = i;
4510
+ }
4511
+ }
4512
+ const minLng = lngs[(cutAfter + 1) % lngs.length];
4513
+ let maxLng = lngs[cutAfter];
4514
+ if (maxLng < minLng) maxLng += 360;
3516
4515
  return [
3517
4516
  [minLng, minLat],
3518
4517
  [maxLng, maxLat]
@@ -3631,7 +4630,7 @@ var NavigationCamera = class {
3631
4630
  if (!route || route.coordinates.length === 0) return;
3632
4631
  this.clearRecentreTimer();
3633
4632
  this.currentMode = "overview";
3634
- this.m.fitBounds(boundsOf2(route.coordinates), {
4633
+ this.m.fitBounds(boundsOf2(unwrapLngs(route.coordinates)), {
3635
4634
  padding: OVERVIEW_PADDING_PX,
3636
4635
  pitch: 0,
3637
4636
  bearing: 0,
@@ -3994,10 +4993,20 @@ var IsochroneLayer = class {
3994
4993
  get current() {
3995
4994
  return this.lastData;
3996
4995
  }
3997
- /** Draw (or update) a ring collection. Safe before the style has loaded. */
4996
+ /**
4997
+ * Draw (or update) a ring collection. Safe before the style has loaded.
4998
+ * Never silently drops an update: once the source exists the data is
4999
+ * applied immediately, even while `isStyleLoaded()` is transiently
5000
+ * `false` mid-render (PlacesLayer has the same rule) - otherwise the map
5001
+ * would keep showing the previous rings until the next style swap.
5002
+ * Calls made before the source has first been installed are stashed in
5003
+ * `lastData` - the latest wins - and installed on the next `style.load`.
5004
+ */
3998
5005
  draw(collection) {
3999
5006
  this.lastData = collection;
4000
- if (!this.map.isStyleLoaded()) return;
5007
+ if (!this.map.getSource(this.sourceId) && !this.map.isStyleLoaded()) {
5008
+ return;
5009
+ }
4001
5010
  this.install(collection);
4002
5011
  }
4003
5012
  /** Add-or-update the source and ring layers on the current style. */
@@ -4153,10 +5162,13 @@ var AdrCheck = class {
4153
5162
  throw new Error("ADR check returned a non-object response");
4154
5163
  }
4155
5164
  const obj = body;
4156
- const result = {
4157
- status: typeof obj["status"] === "string" ? obj["status"] : "unknown",
4158
- raw: obj
4159
- };
5165
+ const status = obj["status"];
5166
+ if (status !== "allowed" && status !== "blocked") {
5167
+ throw new Error(
5168
+ `ADR check returned an undecodable decision (status ${JSON.stringify(status)}) - refusing to report a non-blocking result for a compliance check`
5169
+ );
5170
+ }
5171
+ const result = { status, raw: obj };
4160
5172
  if (typeof obj["reason"] === "string") result.reason = obj["reason"];
4161
5173
  return result;
4162
5174
  }
@@ -4235,9 +5247,15 @@ function bannerLanes(banner) {
4235
5247
  const lane = { directions: c.directions ?? [] };
4236
5248
  if (c.active !== void 0) lane.active = c.active;
4237
5249
  if (c.active === true) lane.valid = true;
5250
+ if (typeof c.active_direction === "string" && c.active_direction.length > 0) {
5251
+ lane.activeDirection = c.active_direction;
5252
+ }
4238
5253
  return lane;
4239
5254
  });
4240
5255
  }
5256
+ function laneArrowDirection(lane) {
5257
+ return lane.activeDirection ?? lane.directions[0] ?? "none";
5258
+ }
4241
5259
  function directionArrow(direction) {
4242
5260
  switch (direction) {
4243
5261
  case "left":
@@ -4328,13 +5346,13 @@ var GuidanceBanner = class {
4328
5346
  laneRow.style.cssText = `margin-left:auto;letter-spacing:${spacing}px;white-space:nowrap`;
4329
5347
  for (const lane of lanes) {
4330
5348
  const glyph = doc.createElement("span");
4331
- glyph.textContent = directionArrow(lane.directions[0] ?? "none");
5349
+ glyph.textContent = directionArrow(laneArrowDirection(lane));
4332
5350
  glyph.style.opacity = lane.active ? "0.95" : "0.35";
4333
5351
  laneRow.appendChild(glyph);
4334
5352
  }
4335
5353
  } else {
4336
5354
  laneRow.style.cssText = "margin-left:auto;letter-spacing:4px";
4337
- laneRow.textContent = lanes.map((lane) => directionArrow(lane.directions[0] ?? "none")).join("");
5355
+ laneRow.textContent = lanes.map((lane) => directionArrow(laneArrowDirection(lane))).join("");
4338
5356
  laneRow.style.opacity = "0.9";
4339
5357
  }
4340
5358
  children.push(laneRow);
@@ -4370,11 +5388,11 @@ function cameraKindsShownOnMap(design, mode = "browse") {
4370
5388
  }
4371
5389
  function cameraSymbolLayer(design, source, mode = "browse", variant = "dark", id = "safety-cameras") {
4372
5390
  const kinds = cameraKindsShownOnMap(design, mode);
4373
- const iconImage = ["match", ["get", "kind"]];
4374
- for (const kind of NAV_CAMERA_KINDS) {
4375
- if (kind === "unknown") continue;
4376
- const values = kind === "red_light" ? ["red_light", "redlight"] : [kind];
4377
- iconImage.push(values.length === 1 ? values[0] : values, cameraSpriteName(kind, variant));
5391
+ const kind = cameraKindExpression();
5392
+ const iconImage = ["match", kind];
5393
+ for (const canonical of NAV_CAMERA_KINDS) {
5394
+ if (canonical === "unknown") continue;
5395
+ iconImage.push(canonical, cameraSpriteName(canonical, variant));
4378
5396
  }
4379
5397
  iconImage.push(cameraSpriteName("unknown", variant));
4380
5398
  return {
@@ -4382,7 +5400,7 @@ function cameraSymbolLayer(design, source, mode = "browse", variant = "dark", id
4382
5400
  type: "symbol",
4383
5401
  source,
4384
5402
  minzoom: design.mapMinZoom,
4385
- filter: ["in", ["get", "kind"], ["literal", kinds]],
5403
+ filter: ["in", kind, ["literal", kinds]],
4386
5404
  layout: {
4387
5405
  "icon-image": iconImage,
4388
5406
  "icon-size": design.mapIconSize,
@@ -4391,6 +5409,22 @@ function cameraSymbolLayer(design, source, mode = "browse", variant = "dark", id
4391
5409
  }
4392
5410
  };
4393
5411
  }
5412
+ var CAMERA_KIND_WIRE_SPELLINGS = {
5413
+ fixed: ["fixed", "permanent"],
5414
+ average: ["average", "average_speed", "section", "section_control"],
5415
+ red_light: ["red_light", "redlight", "traffic_signals"],
5416
+ mobile_site: ["mobile_site", "mobile"],
5417
+ unknown: ["unknown"]
5418
+ };
5419
+ function cameraKindExpression(property = "kind") {
5420
+ const match = ["match", ["to-string", ["get", property]]];
5421
+ for (const canonical of NAV_CAMERA_KINDS) {
5422
+ if (canonical === "unknown") continue;
5423
+ match.push(CAMERA_KIND_WIRE_SPELLINGS[canonical], canonical);
5424
+ }
5425
+ match.push("unknown");
5426
+ return match;
5427
+ }
4394
5428
  function cameraSpriteName(kind, variant = "dark") {
4395
5429
  const normalised = navCameraKind(kind);
4396
5430
  const slug = normalised === "red_light" ? "redlight" : normalised.replace(/_/g, "-");
@@ -4653,6 +5687,6 @@ function clampVolume(volume) {
4653
5687
  return Math.min(1, Math.max(0, volume));
4654
5688
  }
4655
5689
 
4656
- export { ALERT_CONTRAST_MIN, AdrCheck, CameraAlertChip, DEFAULT_GLYPHS_URL, DEFAULT_TERRITORY_TILES_URL, EFFECTS_METADATA_KEY, FLOW_DEFAULTS, FULL_ATTRIBUTION, FlowRouteEffectLayer, GuidanceBanner, IsochroneLayer, LOGO_SVG, LogoControl, MAX_PUCK_IMAGE_BYTES, MapMapMap, NAV_ALERT_AUDIO_MODES, NAV_ALERT_CHIP_POSITIONS, NAV_ALERT_ICON_SETS, NAV_ALERT_LEAD_DISTANCES, NAV_ALERT_LEAD_DISTANCE_M, NAV_ALERT_MODES, NAV_ALERT_SOUNDS, NAV_ALERT_SOUND_BASE_URL, NAV_ALERT_SOUND_FILES, NAV_CAMERA_DEFAULTS, NAV_CAMERA_KINDS, NavigationCamera, OPENMAPTILES_ATTRIBUTION, OSM_ATTRIBUTION, PALETTE_SLOTS, POI_CATEGORY_COLORS, POI_CATEGORY_IDS, POI_CLASS_CATEGORIES, PlacesLayer, PositionPuck, RIBBON_FLOATS_PER_VERTEX, ROUTE_EFFECTS, RouteLayer, SIGNAL_BLUE, SOURCE_LAYERS, ThemeScheduler, VoiceGuidance, alertChipContent, alertContrastIssues, alertIconSvg, alertIconUrl, alertPresentation, alertSoundUrl, alertSpeedReadoutColor, applyPoiDesign, bannerLanes, bearingBetween, bindFlythroughToScroll, buildAdrCheckBody, buildProbeUrl, buildRouteQuery, buildRouteUrl, buildStyle, builtInPoiColor, cameraKindLabel, cameraKindsShownOnMap, cameraShownOnMap, cameraSpriteName, cameraSymbolLayer, contrastRatio, createMap, createRouteEffect, defaultNavAlertsDesign, defaultNavDesign, defaultPoiDesign, directionArrow, effectsFromStyleMetadata, extractGuidance, flythrough, flythroughPose, formatCoord, formatCoords, haversineDistanceM, isNameTextField, languageTextField, lngLatToMercator, navAlertLeadDistanceM, navAlertSoundUrl, navCameraKind, navDesignFromTheme, navDesignFromThemeUrl, parseCssColour, parseHexColor, parseNavAlertsDesign, parseNavDesign, parseOsrmRoute, parsePoiDesign, placesFromGeoJSON, poiDesignFromTheme, poiDesignFromThemeUrl, poiDesignIsDefault, poiTextColorExpression, prefersReducedMotion, registerPmtilesProtocol, relativeLuminance, resetDiagnostics, resolveTheme, runMapDiagnostics, setMapLanguage, severityProsody, shortestArcDeg, shortestArcDelta, speak, ssmlToText, sunTimes, tessellateRouteRibbon, toLngLat, toPmtilesUrl, uploadProbeBatch };
5690
+ export { ALERT_CONTRAST_MIN, AdrCheck, CameraAlertChip, DEFAULT_GLYPHS_URL, DEFAULT_TERRITORY_TILES_URL, EFFECTS_METADATA_KEY, FLOW_DEFAULTS, FULL_ATTRIBUTION, FlowRouteEffectLayer, GuidanceBanner, IsochroneLayer, LOGO_SVG, LogoControl, MARKERS_ID, MARKER_DEFAULT_COLOUR, MARKER_GLYPH_IDS, MARKER_GLYPH_VIEWBOX, MARKER_LABEL_TEXT_SIZES, MARKER_PIN_BODY_PATH, MARKER_SIZES, MARKER_SIZES_PX, MAX_MARKER_ID_LENGTH, MAX_MARKER_IMAGE_BYTES, MAX_MARKER_ITEMS, MAX_MARKER_LABEL_LENGTH, MAX_MARKER_TEXT_LENGTH, MAX_PUCK_IMAGE_BYTES, MapMapMap, MarkersLayer, NAV_ALERT_AUDIO_MODES, NAV_ALERT_CHIP_POSITIONS, NAV_ALERT_ICON_SETS, NAV_ALERT_LEAD_DISTANCES, NAV_ALERT_LEAD_DISTANCE_M, NAV_ALERT_MODES, NAV_ALERT_SOUNDS, NAV_ALERT_SOUND_BASE_URL, NAV_ALERT_SOUND_FILES, NAV_CAMERA_DEFAULTS, NAV_CAMERA_KINDS, NavigationCamera, OPENMAPTILES_ATTRIBUTION, OSM_ATTRIBUTION, PALETTE_SLOTS, POI_CATEGORY_COLORS, POI_CATEGORY_IDS, POI_CLASS_CATEGORIES, PlacesLayer, PositionPuck, RIBBON_FLOATS_PER_VERTEX, ROUTE_EFFECTS, RouteLayer, SIGNAL_BLUE, SOURCE_LAYERS, ThemeScheduler, VoiceGuidance, alertChipContent, alertContrastIssues, alertIconSvg, alertIconUrl, alertPresentation, alertSoundUrl, alertSpeedReadoutColor, applyPoiDesign, bannerLanes, bearingBetween, bindFlythroughToScroll, buildAdrCheckBody, buildProbeUrl, buildRouteQuery, buildRouteUrl, buildStyle, builtInPoiColor, cameraKindLabel, cameraKindsShownOnMap, cameraShownOnMap, cameraSpriteName, cameraSymbolLayer, contrastRatio, createMap, createRouteEffect, defaultNavAlertsDesign, defaultNavDesign, defaultPoiDesign, directionArrow, effectsFromStyleMetadata, extractGuidance, flythrough, flythroughPose, formatCoord, formatCoords, haversineDistanceM, isMarkerGlyphId, isNameTextField, laneArrowDirection, languageTextField, lngLatToMercator, markerGlyphPaths, markerImage, markerImageDataKey, markerImageKey, markerText, markerTextImageKey, markersFromTheme, markersFromThemeUrl, navAlertLeadDistanceM, navAlertSoundUrl, navCameraKind, navDesignFromTheme, navDesignFromThemeUrl, parseCssColour, parseHexColor, parseMarkers, parseNavAlertsDesign, parseNavDesign, parseOsrmRoute, parsePoiDesign, placesFromGeoJSON, poiDesignFromTheme, poiDesignFromThemeUrl, poiDesignIsDefault, poiTextColorExpression, prefersReducedMotion, registerPmtilesProtocol, relativeLuminance, renderMarkerImage, resetDiagnostics, resolveTheme, runMapDiagnostics, setMapLanguage, severityProsody, shortestArcDeg, shortestArcDelta, speak, ssmlToText, sunTimes, tessellateRouteRibbon, toLngLat, toPmtilesUrl, truncateChars, uploadProbeBatch };
4657
5691
  //# sourceMappingURL=index.js.map
4658
5692
  //# sourceMappingURL=index.js.map