@chrtco/sdk 0.10.18 → 0.10.20

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.cjs CHANGED
@@ -26,9 +26,13 @@ __export(index_exports, {
26
26
  ATON_EXPRESSION_CLASSES: () => ATON_EXPRESSION_CLASSES,
27
27
  BAND_DRAW_ORDER: () => BAND_DRAW_ORDER,
28
28
  BAND_PRIORITY_RANGES: () => BAND_PRIORITY_RANGES,
29
+ BASE_MAXZOOM: () => BASE_MAXZOOM,
30
+ BASE_MINZOOM: () => BASE_MINZOOM,
31
+ BASE_REGION: () => BASE_REGION,
29
32
  BRIDGE_QUERY_LAYERS: () => BRIDGE_QUERY_LAYERS,
30
33
  BUILD_MAXZOOM: () => BUILD_MAXZOOM,
31
34
  BUILD_MINZOOM: () => BUILD_MINZOOM,
35
+ BUILD_PARAM: () => BUILD_PARAM,
32
36
  CHART_BAND_CODES: () => CHART_BAND_CODES,
33
37
  CHART_CASCADE_CODES: () => CHART_CASCADE_CODES,
34
38
  CHART_SECTIONS: () => CHART_SECTIONS,
@@ -210,6 +214,7 @@ __export(index_exports, {
210
214
  scaleSizeValue: () => scaleSizeValue,
211
215
  scaminToMinZoom: () => scaminToMinZoom,
212
216
  styleModeBand: () => styleModeBand,
217
+ tileTemplate: () => tileTemplate,
213
218
  withinBboxFilter: () => withinBboxFilter,
214
219
  worldMinusBboxPolygon: () => worldMinusBboxPolygon
215
220
  });
@@ -351,7 +356,7 @@ var TileTokenError = class extends Error {
351
356
  this.status = status;
352
357
  }
353
358
  };
354
- var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.mvt(?:\?.*)?$/i;
359
+ var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.(?:mvt|png)(?:\?.*)?$/i;
355
360
  var STYLE_URL_PATH = /\/v1\/styles\/[A-Za-z0-9_]+\/style\.json(?:\?.*)?$/i;
356
361
  var REFRESH_MARGIN_SECONDS = 30;
357
362
  var STALE_GRACE_SECONDS = 1;
@@ -362,6 +367,15 @@ function nowSeconds() {
362
367
  function sleep2(ms) {
363
368
  return new Promise((r) => setTimeout(r, ms));
364
369
  }
370
+ function readBuildTags(raw, region, buildTag) {
371
+ const out = { [region]: buildTag };
372
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
373
+ for (const [key, value] of Object.entries(raw)) {
374
+ if (typeof value === "string" && value) out[key] = value;
375
+ }
376
+ }
377
+ return out;
378
+ }
365
379
  function mapErrorCode(raw, status) {
366
380
  const known = [
367
381
  "invalid_api_key",
@@ -419,6 +433,7 @@ var TileAuth = class {
419
433
  expiresAt: b.expires_at,
420
434
  kid: b.kid,
421
435
  buildTag: b.build_tag,
436
+ buildTags: readBuildTags(b.build_tags, b.region, b.build_tag),
422
437
  region: b.region
423
438
  };
424
439
  }
@@ -494,6 +509,22 @@ var TileAuth = class {
494
509
  if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return null;
495
510
  return entry.response.token;
496
511
  }
512
+ /**
513
+ * Synchronous peek at the cached mint's per-region build ids — `{}` when the
514
+ * cache is cold.
515
+ *
516
+ * `chrt.style()` is synchronous and cannot mint, so this is how a style
517
+ * generated after the cache is warm picks up the build discriminator without
518
+ * the caller threading it by hand. A cold cache yields `{}`, which produces
519
+ * the undiscriminated URL the SDK emitted before this existed — never an
520
+ * error, and never a wrong build.
521
+ */
522
+ peekBuildTags(region) {
523
+ const entry = this.cache.get(region ?? this.defaultRegion);
524
+ if (!entry) return {};
525
+ if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return {};
526
+ return entry.response.buildTags;
527
+ }
497
528
  /**
498
529
  * Produce a MapLibre / Mapbox GL `transformRequest`. Attaches
499
530
  * `Authorization: Bearer <jwt>` only to CHRT tile URLs and the CHRT
@@ -547,6 +578,78 @@ var TileAuth = class {
547
578
  }
548
579
  };
549
580
 
581
+ // src/atlas/tile-zoom.ts
582
+ var BUILD_MINZOOM = 0;
583
+ var BUILD_MAXZOOM = 14;
584
+
585
+ // src/atlas/source-registry.ts
586
+ var LAND_REGION = "CHRT_LAND";
587
+ var RELIEF_REGION = "CHRT_RELIEF";
588
+ var BASE_REGION = "CHRT_BASE";
589
+ var BASE_MINZOOM = 0;
590
+ var BASE_MAXZOOM = 11;
591
+ var RELIEF_MINZOOM = 9;
592
+ var RELIEF_MAXZOOM = 12;
593
+ var RELIEF_TILE_SIZE = 512;
594
+ var BUILD_PARAM = "v";
595
+ function tileTemplate(base, region, extension, buildId) {
596
+ const url = `${base}/v1/tiles/${region}/{z}/{x}/{y}.${extension}`;
597
+ const id = buildId?.trim();
598
+ if (!id) return url;
599
+ return `${url}?${BUILD_PARAM}=${encodeURIComponent(id)}`;
600
+ }
601
+ var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
602
+ var DEFAULT_REGION = "NOAA_ENC";
603
+ function buildSources(opts = {}) {
604
+ const mode = opts.mode ?? "pmtiles";
605
+ const region = opts.region ?? DEFAULT_REGION;
606
+ if (mode === "mapbox") {
607
+ return {
608
+ [region]: {
609
+ type: "vector",
610
+ url: `mapbox://chrt.${region.toLowerCase()}`
611
+ }
612
+ };
613
+ }
614
+ const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
615
+ const buildFor = (r) => opts.buildTags?.[r] ?? (r === region ? opts.buildTag : void 0);
616
+ const sources = {
617
+ [region]: {
618
+ type: "vector",
619
+ tiles: [tileTemplate(base, region, "mvt", buildFor(region))],
620
+ minzoom: BUILD_MINZOOM,
621
+ maxzoom: BUILD_MAXZOOM
622
+ }
623
+ };
624
+ if (opts.land) {
625
+ sources[LAND_REGION] = {
626
+ type: "vector",
627
+ tiles: [tileTemplate(base, LAND_REGION, "mvt", buildFor(LAND_REGION))],
628
+ minzoom: BUILD_MINZOOM,
629
+ maxzoom: BUILD_MAXZOOM
630
+ };
631
+ }
632
+ if (opts.base) {
633
+ sources[BASE_REGION] = {
634
+ type: "vector",
635
+ tiles: [`${base}/v1/tiles/${BASE_REGION}/{z}/{x}/{y}.mvt`],
636
+ minzoom: BASE_MINZOOM,
637
+ maxzoom: BASE_MAXZOOM
638
+ };
639
+ }
640
+ if (opts.relief) {
641
+ sources[RELIEF_REGION] = {
642
+ type: "raster-dem",
643
+ tiles: [tileTemplate(base, RELIEF_REGION, "png", buildFor(RELIEF_REGION))],
644
+ encoding: "terrarium",
645
+ tileSize: RELIEF_TILE_SIZE,
646
+ minzoom: RELIEF_MINZOOM,
647
+ maxzoom: RELIEF_MAXZOOM
648
+ };
649
+ }
650
+ return sources;
651
+ }
652
+
550
653
  // src/endpoints/tiles.ts
551
654
  var TilesEndpoint = class {
552
655
  constructor(http, tileAuth, opts) {
@@ -618,13 +721,20 @@ var TilesEndpoint = class {
618
721
  return await res.json();
619
722
  }
620
723
  /**
621
- * Vector tile URL template for the configured region, served by the
622
- * tile-worker at `tiles.chrt.co`. Tiles are JWT-gated — pair this with
724
+ * Vector tile URL for the configured region, served by the tile-worker at
725
+ * `tiles.chrt.co`. Tiles are JWT-gated — pair this with
623
726
  * `chrt.transformRequest()`, which attaches the `Authorization: Bearer`
624
727
  * header. Prefer `chrt.style()`, which wires the source for you.
728
+ *
729
+ * Carries the build discriminator (`?v=`) once the token cache is warm, so
730
+ * two buildTags never share one cache entry — see `BUILD_PARAM`. A cold cache
731
+ * yields the bare URL, which is equally valid: the server ignores the
732
+ * parameter, and warming is a `chrt.warmTiles()` (or one
733
+ * `chrt.transformRequest()` round-trip) away.
625
734
  */
626
735
  tileUrl(z, x, y) {
627
- return `${this.tileBaseUrl}/v1/tiles/${this.region}/${z}/${x}/${y}.mvt`;
736
+ const build = this.tileAuth.peekBuildTags(this.region)[this.region];
737
+ return tileTemplate(this.tileBaseUrl, this.region, "mvt", build).replace("{z}", String(z)).replace("{x}", String(x)).replace("{y}", String(y));
628
738
  }
629
739
  /**
630
740
  * @deprecated Tilesets were consolidated to one PMTiles per region (2026-04);
@@ -850,6 +960,27 @@ var SearchEndpoint = class {
850
960
  }
851
961
  };
852
962
 
963
+ // src/endpoints/poi.ts
964
+ var PoiEndpoint = class {
965
+ constructor(http) {
966
+ this.http = http;
967
+ }
968
+ async list(options) {
969
+ return this.http.request(
970
+ "/poi",
971
+ {
972
+ bbox: options.bbox ? options.bbox.join(",") : void 0,
973
+ near: options.near ? `${options.near[0]},${options.near[1]}` : void 0,
974
+ radius_m: options.radiusM,
975
+ category: options.category,
976
+ limit: options.limit,
977
+ offset: options.offset
978
+ },
979
+ options.signal
980
+ );
981
+ }
982
+ };
983
+
853
984
  // src/endpoints/query.ts
854
985
  var METRES_PER_NAUTICAL_MILE2 = 1852;
855
986
  var QueryEndpoint = class {
@@ -6370,6 +6501,38 @@ var S52_LOOKUP = {
6370
6501
  // polygons that overlap the security-zone CTNARE polygons; with both
6371
6502
  // as flood fills, the harbor inner waters disappeared under stacked
6372
6503
  // magenta. Same boundary-only treatment as CTNARE.
6504
+ // ── DASHED, 2026-08-24. A solid line here read as a second coastline.
6505
+ //
6506
+ // User report: "narragansett bay has 2 offset coastlines... this is so
6507
+ // sloppy. we can't sell this right now." Measured by ablating classes
6508
+ // out of the shipping style and diffing offscreen renders of that exact
6509
+ // frame (z10.54, -71.1745/41.6031): dropping every magenta-painting
6510
+ // class changed 38,580 px and the doubled shoreline vanished; RESARE
6511
+ // ALONE was 37,613 of them — 97.5%. CBLARE, CTNARE, ACHARE and ADMARE
6512
+ // each changed zero.
6513
+ //
6514
+ // Restricted areas in Narragansett Bay follow the shore, so their
6515
+ // boundary runs parallel to the coastline a short distance off it. Drawn
6516
+ // SOLID, in a chart where the coastline is also a solid line, that is a
6517
+ // second coastline to any reader — the geometry is right and the
6518
+ // portrayal lies about what it is.
6519
+ //
6520
+ // This is not a SCAMIN problem, and the first fix attempted was wrong:
6521
+ // the tile pipeline already honours SCAMIN (the z10 tile carries 9
6522
+ // RESARE features, all SCAMIN 1:119,999-1:999,999 — NOAA's own
6523
+ // instruction to draw them at overview scale — while the z11 tile
6524
+ // carries 35). Gating harder would have hidden features the HO asks for.
6525
+ //
6526
+ // Dashed restores the convention this class was already believed to
6527
+ // follow: MIPARE [4,3], TESARE [6,4] and PRDARE [4,2] are all dashed
6528
+ // magenta boundaries, and PRDARE's own comment describes itself as
6529
+ // "matching RESARE/CTNARE/MIPARE/TESARE convention". RESARE was the lone
6530
+ // solid outlier. It also moves toward the S-52 line style for this
6531
+ // class, RESARE51 ("boundary of a restricted area"), a broken magenta
6532
+ // line with inward T-marks — which cannot be used directly yet: the
6533
+ // served sprite (s52-day, 101 entries) carries no RESARE51 pattern, and
6534
+ // a `line-pattern` naming a missing sprite renders NOTHING, which would
6535
+ // turn a portrayal complaint into invisible restricted areas.
6373
6536
  objectClass: "RESARE",
6374
6537
  name: "Restricted Area",
6375
6538
  category: "Regulated Areas",
@@ -6380,7 +6543,8 @@ var S52_LOOKUP = {
6380
6543
  paint: {
6381
6544
  "line-color": "TRFCD",
6382
6545
  "line-width": 1.2,
6383
- "line-opacity": 0.7
6546
+ "line-opacity": 0.7,
6547
+ "line-dasharray": [4, 3]
6384
6548
  }
6385
6549
  },
6386
6550
  viewingGroup: 26040,
@@ -6464,7 +6628,13 @@ var S52_LOOKUP = {
6464
6628
  category: "Regulated Areas",
6465
6629
  displayCategory: "OTHER",
6466
6630
  int1Ref: "N23",
6467
- style: { type: "fill", paint: { "fill-color": "LITYW", "fill-opacity": 0.15 } },
6631
+ // 0.15 painted Hanapepe Bay's dumping ground as a solid land-green wash
6632
+ // over the white deep-water ground — owner-read as "land painting up to
6633
+ // the coastline" (2026-08-31). INT1 N23/N24 portrays a dumping ground
6634
+ // as a dashed limit with a label, not an area tint; 0.04 matches the
6635
+ // TSSLPT/TSSRON faintness so the area reads as WATER with a regulated
6636
+ // overlay, at every tier that shows it.
6637
+ style: { type: "fill", paint: { "fill-color": "LITYW", "fill-opacity": 0.04 } },
6468
6638
  viewingGroup: 26230,
6469
6639
  scamin: 22e3
6470
6640
  },
@@ -7514,12 +7684,19 @@ var S52_LOOKUP = {
7514
7684
  // Inherits RESARE (S-57 112) — boundary-only, no flood fill. The stacked
7515
7685
  // magenta wash that motivated RESARE's treatment applies here too: an
7516
7686
  // inland reach can carry overlapping regulated areas just as a harbour can.
7687
+ // ── DASHED, matching RESARE (2026-08-25). The inland twin was missed
7688
+ // when RESARE was dashed the day before, so USACE inland waters kept
7689
+ // drawing restricted areas as a SOLID magenta line running alongside
7690
+ // the bank — the same "second coastline" a user reported on the coastal
7691
+ // chart, still shipping on the inland one. Found by auditing every
7692
+ // lowercase variant against its NOAA parent: of 14, this was the only
7693
+ // pair that diverged.
7517
7694
  objectClass: "resare",
7518
7695
  name: "Restricted Area (Inland)",
7519
7696
  category: "Regulated Areas",
7520
7697
  displayCategory: "STANDARD",
7521
7698
  int1Ref: "N2",
7522
- style: { type: "line", paint: { "line-color": "TRFCD", "line-width": 1.2, "line-opacity": 0.7 } },
7699
+ style: { type: "line", paint: { "line-color": "TRFCD", "line-width": 1.2, "line-opacity": 0.7, "line-dasharray": [4, 3] } },
7523
7700
  viewingGroup: 26010,
7524
7701
  scamin: 9e4
7525
7702
  },
@@ -22874,14 +23051,19 @@ var ORIENTATION_BAND_SORT_KEY = [
22874
23051
  ["slice", ["coalesce", ["get", "_cell_id"], "??9???"], 2, 3],
22875
23052
  9
22876
23053
  ];
22877
- function orientationLabelLayout(extras = {}) {
23054
+ function orientationSortKey(tier) {
23055
+ const band = ["*", 100, ORIENTATION_BAND_SORT_KEY];
23056
+ if (!tier) return band;
23057
+ return ["+", band, tier];
23058
+ }
23059
+ function orientationLabelLayout(extras = {}, tier) {
22878
23060
  return {
22879
23061
  "text-anchor": "center",
22880
23062
  "text-allow-overlap": false,
22881
23063
  "text-ignore-placement": false,
22882
23064
  "text-optional": true,
22883
23065
  "text-padding": 15,
22884
- "symbol-sort-key": ORIENTATION_BAND_SORT_KEY,
23066
+ "symbol-sort-key": orientationSortKey(tier),
22885
23067
  ...extras
22886
23068
  };
22887
23069
  }
@@ -22891,15 +23073,17 @@ function tieredSizeCurve(tierExpr, stops) {
22891
23073
  if (i > 0) {
22892
23074
  const [pz, pv] = stops[i - 1];
22893
23075
  const [nz, nv] = stops[i];
22894
- if (pv.some((v, t) => v === 0 && nv[t] > 0)) {
23076
+ if (pv.some((v, t) => v === 0 && nv[t] > 0 || v > 0 && nv[t] === 0)) {
22895
23077
  const hz = Math.round((nz - 0.2) * 10) / 10;
22896
23078
  if (hz > pz) {
22897
23079
  const f = (hz - pz) / (nz - pz);
22898
23080
  withHolds.push([
22899
23081
  hz,
22900
- pv.map(
22901
- (v, t) => v === 0 && nv[t] > 0 ? 0 : Math.round((v + (nv[t] - v) * f) * 10) / 10
22902
- )
23082
+ pv.map((v, t) => {
23083
+ if (v === 0 && nv[t] > 0) return 0;
23084
+ if (v > 0 && nv[t] === 0) return v;
23085
+ return Math.round((v + (nv[t] - v) * f) * 10) / 10;
23086
+ })
22903
23087
  ]);
22904
23088
  }
22905
23089
  }
@@ -22928,6 +23112,19 @@ var ORIENTATION_HALO_WIDTH = [
22928
23112
  16,
22929
23113
  0.7
22930
23114
  ];
23115
+ var LAND_HALO_WIDTH = [
23116
+ "interpolate",
23117
+ ["linear"],
23118
+ ["zoom"],
23119
+ 8,
23120
+ 1.6,
23121
+ 11,
23122
+ 1.7,
23123
+ 14,
23124
+ 1.8,
23125
+ 16,
23126
+ 1.8
23127
+ ];
22931
23128
  var LABEL_BEHAVIOR = {
22932
23129
  // Orientation labels — render once across all bands.
22933
23130
  SEAARE: "orientation-cross-band",
@@ -23001,6 +23198,9 @@ var LABEL_PRIORITY = {
23001
23198
  };
23002
23199
  var DEFAULT_LABEL_PRIORITY = 450;
23003
23200
  var ORIENTATION_LABEL_PRIORITY = 900;
23201
+ var MAJOR_WATER_TIER_MAX = 3;
23202
+ var MAJOR_WATER_LABEL_PRIORITY = 280;
23203
+ var ISLAND_LABEL_HOIST_MINZOOM = 12;
23004
23204
  function getLabelPriority(layerName) {
23005
23205
  if (getLabelBehavior(layerName) === "orientation-cross-band") {
23006
23206
  return ORIENTATION_LABEL_PRIORITY;
@@ -23173,21 +23373,25 @@ function getLabelConfig(layerName, opts = {}) {
23173
23373
  // LNDRGN labels until coastal zoom. The Carto basemap already labels
23174
23374
  // major land regions at z5-z6. Columns: t1 major region, t2 local
23175
23375
  // point (also the default).
23376
+ // Tier 2 (local point: "Bird Island", "Cottage Park") gets the same
23377
+ // lift as LNDARE's small islands — it is the other half of "show me
23378
+ // what this land is". Tier 1 is unchanged.
23176
23379
  textSize: tieredSizeCurve(tierExpr, [
23177
23380
  [5, [0, 0]],
23178
23381
  [7, [11, 0]],
23179
23382
  [9, [12, 0]],
23180
- [11, [12, 9]],
23181
- [13, [11, 11]],
23182
- [16, [10, 10]]
23383
+ [11, [12, 10]],
23384
+ [13, [11.5, 11.5]],
23385
+ [16, [10.5, 11.5]]
23183
23386
  ]),
23184
23387
  textColor: DARK_BG,
23185
23388
  textHaloColor: COLOR.LAND_REGION,
23186
- textHaloWidth: ORIENTATION_HALO_WIDTH,
23389
+ // Land halo, not the shared taper — see LAND_HALO_WIDTH.
23390
+ textHaloWidth: LAND_HALO_WIDTH,
23187
23391
  textOffset: [0, 0],
23188
23392
  textFont: ["Open Sans Regular"],
23189
23393
  minzoom: 5,
23190
- layout: orientationLabelLayout()
23394
+ layout: orientationLabelLayout({}, tierExpr)
23191
23395
  };
23192
23396
  }
23193
23397
  case "LNDMRK":
@@ -23352,14 +23556,56 @@ function getLabelConfig(layerName, opts = {}) {
23352
23556
  // at approach scale (z9) when the user has zoomed in enough that
23353
23557
  // individual banks are useful context. Columns: t1 ocean, t2 sea/gulf,
23354
23558
  // t3 bay/sound, t4 anchorage, default.
23559
+ // ── The zoom ladder (owner, 2026-08-31) ────────────────────────────
23560
+ //
23561
+ // "When zoomed in I want water area labels (Nubble Channel etc.); as
23562
+ // I zoom out, the major stuff."
23563
+ //
23564
+ // The old curve gave EVERY tier a size at z9-z11, and the field that
23565
+ // produced is the defect. Rendered in isolation at Boston z10.4
23566
+ // (prod 48404b1d): ten copies of "North Atlantic Ocean" scattered
23567
+ // across the harbour, a dozen ledges and shoals — Finns, Boston,
23568
+ // Thieves, Ultonia, Harding, Tinkers, Dread, Newcomb, Three and
23569
+ // One-Half Fathom — and, competing with all of them for the same
23570
+ // pixels, the bays the user actually needs: Broad Sound, Dorchester,
23571
+ // Quincy, Hull, Hingham, President Roads. Nothing was missing. The
23572
+ // majors simply had no better claim on the space than a ledge did.
23573
+ //
23574
+ // So the ladder now steps tiers OUT as well as in:
23575
+ //
23576
+ // t1 ocean overview only — gone by z11. "North Atlantic
23577
+ // Ocean" inside Boston Harbor is noise, not context.
23578
+ // t2 sea/gulf through approach — gone by z13.
23579
+ // t3 bay/sound the spine: visible z5 through z16, peaking where
23580
+ // a bay IS the answer to "where am I".
23581
+ // t4 anchorage harbour detail, z13+ (unchanged).
23582
+ // t5 default channels, coves, ledges. Floor UNCHANGED at z9 —
23583
+ // and deliberately so. "Nubble Channel" is already
23584
+ // a z11 feature without touching this curve: NOAA
23585
+ // encodes it only in band-5 cells (US5BOSCD ×2,
23586
+ // US5BOSCE), band-5 features first exist in tiles at
23587
+ // z11, and the cell-band fan floors their label
23588
+ // variant at z11 too. The owner's "channels/coves at
23589
+ // z11+" is therefore satisfied by the DATA, and
23590
+ // moving this floor would only have cost the
23591
+ // offshore relief its approach-zoom entry (Georges
23592
+ // Bank, Hudson Canyon — the population this tier was
23593
+ // built for, pinned by seaare-tier.test.ts).
23594
+ //
23595
+ // What crowds the bays at z10 is fixed by RANK, not by floors:
23596
+ // `orientationSortKey` now places tier 3 ahead of tier 5 inside a
23597
+ // band, and the padding ladder below thins the field so that
23598
+ // ordering decides the survivors.
23599
+ //
23600
+ // Columns: t1 ocean, t2 sea/gulf, t3 bay/sound, t4 anchorage, default.
23355
23601
  textSize: tieredSizeCurve(tierExpr, [
23356
23602
  [3, [14, 8, 0, 0, 0]],
23357
23603
  [5, [16, 13, 9, 0, 0]],
23358
23604
  [7, [14, 14, 12, 0, 0]],
23359
- [9, [12, 13, 14, 0, 9]],
23360
- [11, [10, 12, 13, 0, 11]],
23361
- [13, [9, 11, 12, 10, 11]],
23362
- [16, [9, 10, 11, 11, 10]]
23605
+ [9, [11, 13, 14, 0, 9]],
23606
+ [11, [0, 11, 14, 0, 11]],
23607
+ [13, [0, 0, 13, 10, 11]],
23608
+ [16, [0, 0, 12, 11, 10]]
23363
23609
  ]),
23364
23610
  textColor: DARK_BG,
23365
23611
  textHaloColor: COLOR.WATER_DEEP,
@@ -23383,8 +23629,41 @@ function getLabelConfig(layerName, opts = {}) {
23383
23629
  // the tiles and replaces this fan outright — do not restore
23384
23630
  // always-paint on a starvation report; flip `labels: "placename"`
23385
23631
  // instead once the build carries the layer.
23386
- "text-padding": 2
23387
- })
23632
+ //
23633
+ // ── The padding is the zoom LADDER (owner, 2026-08-31) ───────────
23634
+ //
23635
+ // "When zoomed in I want water area labels (Nubble Channel etc.);
23636
+ // as I zoom out, the major stuff."
23637
+ //
23638
+ // A flat 2 px moat gives every name in view the same claim on the
23639
+ // space, so at z10 all 34 band-4 names in one Boston tile compete
23640
+ // as peers and the result reads as clutter rather than as
23641
+ // orientation. Widening the moat as the user zooms OUT thins that
23642
+ // field by collision alone: with a 20 px moat only a few names
23643
+ // survive per screen, and `orientationSortKey` decides which —
23644
+ // lowest band first, then lowest class tier, i.e. the bays and
23645
+ // sounds. Narrowing it back to 2 px by z13 restores exactly
23646
+ // today's harbour behaviour, where the channels and coves should
23647
+ // all be readable.
23648
+ //
23649
+ // Same mechanism (and the same lesson) as the lake-label grid:
23650
+ // a flat padding let six copies of "Lake Ontario" crowd z8.9.
23651
+ // Nothing is filtered and no name is invented — the ladder is
23652
+ // entirely a collision-space decision.
23653
+ "text-padding": [
23654
+ "interpolate",
23655
+ ["linear"],
23656
+ ["zoom"],
23657
+ 5,
23658
+ 20,
23659
+ 9,
23660
+ 14,
23661
+ 11,
23662
+ 8,
23663
+ 13,
23664
+ 2
23665
+ ]
23666
+ }, tierExpr)
23388
23667
  };
23389
23668
  }
23390
23669
  case "LAKARE": {
@@ -23405,7 +23684,7 @@ function getLabelConfig(layerName, opts = {}) {
23405
23684
  textOffset: [0, 0],
23406
23685
  textFont: ["Open Sans Italic"],
23407
23686
  minzoom: 7,
23408
- layout: orientationLabelLayout()
23687
+ layout: orientationLabelLayout({}, tierExpr)
23409
23688
  };
23410
23689
  }
23411
23690
  case "RIVERS": {
@@ -23426,7 +23705,7 @@ function getLabelConfig(layerName, opts = {}) {
23426
23705
  textOffset: [0, 0],
23427
23706
  textFont: ["Open Sans Italic"],
23428
23707
  minzoom: 7,
23429
- layout: orientationLabelLayout()
23708
+ layout: orientationLabelLayout({}, tierExpr)
23430
23709
  };
23431
23710
  }
23432
23711
  case "CANALS":
@@ -23457,21 +23736,31 @@ function getLabelConfig(layerName, opts = {}) {
23457
23736
  return {
23458
23737
  type: "symbol",
23459
23738
  textField: ["get", "OBJNAM"],
23739
+ // Tier 2 is the SMALL island — the one the owner cannot identify.
23740
+ // Its old curve peaked at 10 px and fell to 10 px, i.e. it never got
23741
+ // its own scale: a name that only exists because you zoomed in was
23742
+ // rendered at the size used for ambient context. It now grows through
23743
+ // harbour zoom (10 → 11.5) while tier 1, the major island already
23744
+ // legible from coastal zoom, is left almost where it was. Small
23745
+ // absolute numbers on purpose: the ask was "clear", not "large" —
23746
+ // the legibility comes mostly from LAND_HALO_WIDTH and the opacity
23747
+ // scale, both of which cost no extra footprint.
23460
23748
  textSize: tieredSizeCurve(tierExpr, [
23461
23749
  [5, [8, 0]],
23462
23750
  [7, [10, 0]],
23463
23751
  [9, [11, 0]],
23464
- [11, [11, 9]],
23465
- [13, [10, 10]],
23466
- [16, [9, 10]]
23752
+ [11, [11.5, 10]],
23753
+ [13, [11.5, 11.5]],
23754
+ [16, [10.5, 11.5]]
23467
23755
  ]),
23468
23756
  textColor: DARK_BG,
23469
23757
  textHaloColor: COLOR.LAND_NATURAL,
23470
- textHaloWidth: ORIENTATION_HALO_WIDTH,
23758
+ // Land halo, not the shared taper — see LAND_HALO_WIDTH.
23759
+ textHaloWidth: LAND_HALO_WIDTH,
23471
23760
  textOffset: [0, 0],
23472
23761
  textFont: ["Open Sans Regular"],
23473
23762
  minzoom: 5,
23474
- layout: orientationLabelLayout()
23763
+ layout: orientationLabelLayout({}, tierExpr)
23475
23764
  };
23476
23765
  }
23477
23766
  case "BUAARE": {
@@ -23496,7 +23785,7 @@ function getLabelConfig(layerName, opts = {}) {
23496
23785
  minzoom: 3,
23497
23786
  layout: orientationLabelLayout({
23498
23787
  "text-letter-spacing": 0.05
23499
- })
23788
+ }, tierExpr)
23500
23789
  };
23501
23790
  }
23502
23791
  case "BRIDGE":
@@ -23588,7 +23877,7 @@ function getLabelConfig(layerName, opts = {}) {
23588
23877
  textOffset: [0, 0],
23589
23878
  textFont: ["Open Sans Regular"],
23590
23879
  minzoom: 7,
23591
- layout: orientationLabelLayout()
23880
+ layout: orientationLabelLayout({}, tierExpr)
23592
23881
  };
23593
23882
  }
23594
23883
  case "SBDARE":
@@ -25972,7 +26261,7 @@ var S52_POINT_SVGS = {
25972
26261
  "LIGHTS14": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.8438" transform="translate(16 16) scale(0.8438) translate(-16 -16)">\n <path d="M 16.3 28.0 L 11.8 7.1 L 11.8 6.3 L 11.8 4.9 L 12.4 3.5 L 13.2 3.0 L 14.3 2.1 L 16.0 1.9 L 17.4 2.1 L 18.5 2.4 L 19.6 3.2 L 20.2 4.6 L 20.4 5.7 L 20.4 6.9 L 16.3 28.0 Z" fill="CHMGD" />\n <path d="M 16.3 28.0 L 11.8 7.1 L 11.8 6.3 L 11.8 4.9 L 12.4 3.5 L 13.2 3.0 L 14.3 2.1 L 16.0 1.9 L 17.4 2.1 L 18.5 2.4 L 19.6 3.2 L 20.2 4.6 L 20.4 5.7 L 20.4 6.9 L 16.3 28.0" fill="none" stroke="CHMGD" stroke-width="1.185" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 21.3 5.2 L 21.0 6.6 L 16.3 31.0" fill="none" stroke="CHBLK" stroke-width="1.185" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 16.3 28.0 L 11.5 6.6 L 11.8 5.2 L 12.1 4.1 L 12.9 3.2 L 14.1 2.1 L 15.4 1.9 L 16.3 1.9 L 17.4 2.1 L 18.8 2.7 L 19.6 3.5 L 20.2 4.6 L 20.4 5.7 L 20.2 7.7 L 16.3 28.0" fill="none" stroke="CHMGD" stroke-width="1.185" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 16.3 31.0 L 10.7 6.0 L 11.5 3.5 L 13.8 1.6 L 16.3 1.0 L 18.5 1.9 L 19.9 2.7 L 21.0 4.9" fill="none" stroke="CHBLK" stroke-width="1.185" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
25973
26262
  "LIGHTS81": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.3896" transform="translate(16 16) scale(0.3896) translate(-16 -16)">\n <path d="M 1.0 25.9 L 6.9 5.9 L 12.9 26.0 L 19.0 5.9 L 25.0 26.1 L 31.0 5.9" fill="none" stroke="CHMGD" stroke-width="2.567" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
25974
26263
  "LIGHTS82": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.6083" transform="translate(16 16) scale(0.6083) translate(-16 -15.98)">\n <circle cx="6.96" cy="17.80" r="5.96" fill="none" stroke="CHMGD" stroke-width="3.288" />\n <path d="M 14.9 23.1 L 25.9 23.5" fill="none" stroke="CHMGD" stroke-width="3.288" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 13.3 12.1 L 28.0 8.2" fill="none" stroke="CHMGD" stroke-width="3.288" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 15.8 17.5 L 31.0 16.0" fill="none" stroke="CHMGD" stroke-width="3.288" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
25975
- "LITDEF11": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.7292" transform="translate(16 16) scale(0.7292) translate(-16 -16)">\n <path d="M 16.3 31.0 L 10.9 6.9 L 10.9 5.8 L 11.2 4.2 L 12.0 2.8 L 12.8 2.1 L 14.1 1.0 L 16.0 1.0 L 17.6 1.0 L 18.7 1.3 L 20.3 2.3 L 20.8 3.9 L 21.1 5.3 L 21.1 6.6 L 16.3 31.0 Z" fill="CHMGD" />\n <path d="M 16.3 31.0 L 10.9 6.9 L 10.9 5.8 L 11.2 4.2 L 12.0 2.8 L 12.8 2.1 L 14.1 1.0 L 16.0 1.0 L 17.6 1.0 L 18.7 1.3 L 20.3 2.3 L 20.8 3.9 L 21.1 5.3 L 21.1 6.6 L 16.3 31.0" fill="none" stroke="CHMGD" stroke-width="1.371" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 16.3 31.0 L 10.9 6.4 L 10.9 4.7 L 11.4 3.1 L 12.3 2.3 L 13.6 1.3 L 15.2 1.0 L 16.3 1.0 L 17.6 1.0 L 18.9 1.5 L 20.0 2.6 L 20.8 4.2 L 21.1 5.3 L 20.8 7.7 L 16.3 31.0" fill="none" stroke="CHMGD" stroke-width="1.371" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
26264
+ "LITDEF11": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.7292" transform="translate(16 16) scale(0.7292) translate(-16.1 -14.95)">\n <g transform="translate(10 10) rotate(135) scale(0.9) translate(-16.3 -31)">\n <path d="M 16.3 31.0 L 10.9 6.9 L 10.9 5.8 L 11.2 4.2 L 12.0 2.8 L 12.8 2.1 L 14.1 1.0 L 16.0 1.0 L 17.6 1.0 L 18.7 1.3 L 20.3 2.3 L 20.8 3.9 L 21.1 5.3 L 21.1 6.6 L 16.3 31.0 Z" fill="CHMGD" />\n <path d="M 16.3 31.0 L 10.9 6.9 L 10.9 5.8 L 11.2 4.2 L 12.0 2.8 L 12.8 2.1 L 14.1 1.0 L 16.0 1.0 L 17.6 1.0 L 18.7 1.3 L 20.3 2.3 L 20.8 3.9 L 21.1 5.3 L 21.1 6.6 L 16.3 31.0" fill="none" stroke="CHMGD" stroke-width="1.371" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 16.3 31.0 L 10.9 6.4 L 10.9 4.7 L 11.4 3.1 L 12.3 2.3 L 13.6 1.3 L 15.2 1.0 L 16.3 1.0 L 17.6 1.0 L 18.9 1.5 L 20.0 2.6 L 20.8 4.2 L 21.1 5.3 L 20.8 7.7 L 16.3 31.0" fill="none" stroke="CHMGD" stroke-width="1.371" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n </g>\n</svg>\n',
25976
26265
  "LITFLT01": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.9375" transform="translate(16 16) scale(0.9375) translate(-16 -15.985)">\n <circle cx="16.00" cy="18.97" r="2.40" fill="none" stroke="CHBLK" stroke-width="2.133" />\n <path d="M 1.0 19.0 L 12.7 19.0" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 6.0 19.0 L 2.7 12.3" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 26.0 19.0 L 29.3 12.3" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 11.0 14.0 L 12.7 10.6 L 19.3 10.6 L 21.0 14.0" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 2.7 12.3 L 9.3 14.0 L 22.7 14.0 L 29.3 12.3" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 19.3 19.0 L 31.0 19.0" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
25977
26266
  "LITFLT02": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.6385" transform="translate(16 16) scale(0.6385) translate(-16 -16)">\n <path d="M 1.0 13.0 L 5.6 21.5 L 26.9 21.5 L 31.0 12.9 Z" fill="CHBLK" />\n <path d="M 1.0 13.0 L 5.6 21.5 L 26.9 21.5 L 31.0 12.9" fill="none" stroke="CHBLK" stroke-width="1.566" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 1.0 12.9 L 5.6 21.5 L 26.9 21.5 L 30.9 12.9 L 1.0 12.9 Z" fill="CHBLK" />\n <path d="M 1.0 12.9 L 5.6 21.5 L 26.9 21.5 L 30.9 12.9 L 1.0 12.9" fill="none" stroke="CHBLK" stroke-width="1.566" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 10.9 12.9 L 10.9 10.5 L 21.2 10.5 L 21.2 12.9" fill="none" stroke="CHBLK" stroke-width="1.566" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
25978
26267
  "LITVES01": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">\n <g data-chrt-scale="0.9375" transform="translate(16 16) scale(0.9375) translate(-16 -15.98)">\n <circle cx="16.00" cy="24.83" r="2.33" fill="none" stroke="CHBLK" stroke-width="2.133" />\n <path d="M 1.0 24.8 L 12.7 24.8" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 6.0 24.8 L 2.7 16.5" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 26.0 24.8 L 29.3 16.5" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 16.0 19.0 L 16.0 9.8" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 9.3 19.0 L 9.3 13.0" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 22.7 19.0 L 22.7 13.2" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 16.0 9.8 L 16.0 4.8" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 12.7 6.5 L 19.3 9.8" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 12.7 9.8 L 19.3 6.5" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 4.7 21.5 L 10.2 21.5" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 21.8 21.5 L 27.3 21.5" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 2.7 16.5 L 9.2 19.0 L 16.0 19.0 L 22.7 19.0 L 29.3 16.5" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n <path d="M 19.3 24.8 L 31.0 24.8" fill="none" stroke="CHBLK" stroke-width="2.133" stroke-linecap="butt" stroke-linejoin="round" />\n </g>\n</svg>\n',
@@ -26487,6 +26776,8 @@ var CHRT_GLYPH_SVGS = {
26487
26776
  "cardinal-w": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M16 6 12 1.5h8Z" fill="#101418"/><path d="M16 6.5 20 11h-8Z" fill="#101418"/><path d="M13 12h6l1 13H12Z" fill="#FFC805"/><path d="M6 28h20" stroke="#101418" stroke-width="2.4" stroke-linecap="round"/></svg>',
26488
26777
  "daymark-square": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect x="6" y="6" width="20" height="20" rx="1.5" fill="#fff"/></svg>',
26489
26778
  "daymark-triangle": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M16 5 28 26H4Z" fill="#fff"/></svg>',
26779
+ "hatch-nocoverage": '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M-3 9 L9 -3 M-3 21 L21 -3 M3 27 L27 3 M15 27 L27 15" stroke="#8A97A0" stroke-width="0.8" fill="none"/><path d="M-3 15 L15 33 M-3 3 L21 27 M3 -3 L27 21 M15 -3 L27 9" stroke="#8A97A0" stroke-width="0.8" fill="none"/></svg>',
26780
+ "hatch-uncharted": '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M-3 9 L9 -3 M-3 21 L21 -3 M3 27 L27 3 M15 27 L27 15" stroke="#5B7383" stroke-width="0.65" fill="none"/></svg>',
26490
26781
  "isolated-danger": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="3.5" r="2.4" fill="#101418"/><circle cx="16" cy="9.5" r="2.4" fill="#101418"/><path d="M13 13h6l1 12H12Z" fill="#101418"/><rect x="12.4" y="17.5" width="7.2" height="3.4" fill="#E5484D"/><path d="M6 28h20" stroke="#101418" stroke-width="2.4" stroke-linecap="round"/></svg>',
26491
26782
  "light-flare": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="11" cy="11" r="3.2" fill="#fff"/><path d="M13.4 13.4 24 19.5a2.6 2.6 0 0 1-4.5 4.5L13.4 13.4Z" fill="#fff"/></svg>',
26492
26783
  "lightship": '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M6 20l3-5h14l3 5-3.5 4h-13Z" fill="#fff"/><path d="M16 15V8" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/><circle cx="16" cy="6" r="2.2" fill="#fff"/><path d="M5 28h22" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/></svg>',
@@ -26935,59 +27226,6 @@ function iconExpressionForFamily(objectClass, family) {
26935
27226
  }
26936
27227
  }
26937
27228
 
26938
- // src/atlas/tile-zoom.ts
26939
- var BUILD_MINZOOM = 0;
26940
- var BUILD_MAXZOOM = 14;
26941
-
26942
- // src/atlas/source-registry.ts
26943
- var LAND_REGION = "CHRT_LAND";
26944
- var RELIEF_REGION = "CHRT_RELIEF";
26945
- var RELIEF_MINZOOM = 8;
26946
- var RELIEF_MAXZOOM = 13;
26947
- var RELIEF_TILE_SIZE = 512;
26948
- var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
26949
- var DEFAULT_REGION = "NOAA_ENC";
26950
- function buildSources(opts = {}) {
26951
- const mode = opts.mode ?? "pmtiles";
26952
- const region = opts.region ?? DEFAULT_REGION;
26953
- if (mode === "mapbox") {
26954
- return {
26955
- [region]: {
26956
- type: "vector",
26957
- url: `mapbox://chrt.${region.toLowerCase()}`
26958
- }
26959
- };
26960
- }
26961
- const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
26962
- const sources = {
26963
- [region]: {
26964
- type: "vector",
26965
- tiles: [`${base}/v1/tiles/${region}/{z}/{x}/{y}.mvt`],
26966
- minzoom: BUILD_MINZOOM,
26967
- maxzoom: BUILD_MAXZOOM
26968
- }
26969
- };
26970
- if (opts.land) {
26971
- sources[LAND_REGION] = {
26972
- type: "vector",
26973
- tiles: [`${base}/v1/tiles/${LAND_REGION}/{z}/{x}/{y}.mvt`],
26974
- minzoom: BUILD_MINZOOM,
26975
- maxzoom: BUILD_MAXZOOM
26976
- };
26977
- }
26978
- if (opts.relief) {
26979
- sources[RELIEF_REGION] = {
26980
- type: "raster-dem",
26981
- tiles: [`${base}/v1/tiles/${RELIEF_REGION}/{z}/{x}/{y}.png`],
26982
- encoding: "terrarium",
26983
- tileSize: RELIEF_TILE_SIZE,
26984
- minzoom: RELIEF_MINZOOM,
26985
- maxzoom: RELIEF_MAXZOOM
26986
- };
26987
- }
26988
- return sources;
26989
- }
26990
-
26991
27229
  // src/atlas/layer-builder.ts
26992
27230
  var SCAMIN_DECLUTTER_CLASSES = /* @__PURE__ */ new Set([
26993
27231
  "OFSPLF",
@@ -27315,7 +27553,27 @@ function buildLayersForClass(objectClass, ct, sourceId = DEFAULT_REGION, opts =
27315
27553
  layer.layout = {
27316
27554
  ...layer.layout,
27317
27555
  "icon-allow-overlap": true,
27318
- "icon-ignore-placement": true
27556
+ "icon-ignore-placement": true,
27557
+ // Flare-specific size (owner ask 2026-08-31: "make the flares a
27558
+ // little smaller — they should scale down a lot as you zoom out").
27559
+ // The safety-critical curve serves buoy/beacon HULLS, whose set
27560
+ // this overrides for LIGHTS only: flares are annotations on a
27561
+ // point, not shapes a mariner ranges against, so they start small
27562
+ // at planning scales and only approach full size at berthing
27563
+ // zooms. Applied after the safety-critical spread so it wins.
27564
+ "icon-size": [
27565
+ "interpolate",
27566
+ ["linear"],
27567
+ ["zoom"],
27568
+ 6,
27569
+ 0.3,
27570
+ 12,
27571
+ 0.42,
27572
+ 14,
27573
+ 0.6,
27574
+ 18,
27575
+ 1
27576
+ ]
27319
27577
  };
27320
27578
  const valnmrNum = ["to-number", ["coalesce", ["get", "VALNMR"], ["get", "valnmr"], 0], 0];
27321
27579
  const isMajor = [">=", valnmrNum, 10];
@@ -28280,85 +28538,265 @@ function getOrderedClasses() {
28280
28538
  return ordered;
28281
28539
  }
28282
28540
 
28283
- // src/atlas/land-layers.ts
28284
- var FADE_PX = 80;
28285
- var FADE_BANDS = 16;
28286
- function fadeAlpha(r) {
28287
- return Math.max(0, Math.min(1, (FADE_PX / 2 - Math.abs(r)) / FADE_PX));
28288
- }
28289
- var LAND_THEMES = {
28290
- DAY: {
28291
- base: "#e8d9b0",
28292
- cover: { wood: "#cfd8b4", wetland: "#c9d3c4", urban: "#dedad2", sand: "#efe4c2" },
28293
- contour: "#a89878",
28294
- contourIndex: "#8a7a5c",
28295
- road: "#d9c9a4",
28296
- roadMajor: "#d2bd8e",
28297
- label: "#5b4f3a",
28298
- halo: "#f2ead6",
28299
- shadow: "#6b5a3e",
28300
- highlight: "#fffaf0",
28301
- fade: "#e8f2f8",
28302
- // Raised from 0.45 on 2026-08-23. ⚠ Above the ~0.5 that
28303
- // hillshade-style.mjs warns "starts competing with depth areas" — a
28304
- // deliberate, sighted choice made against rendered evidence, not a
28305
- // default. If soundings or DEPARE ever read as fighting the terrain,
28306
- // this number is the first thing to walk back.
28307
- exaggeration: 0.8
28308
- },
28309
- DUSK: {
28310
- base: "#4a4433",
28311
- cover: { wood: "#414a38", wetland: "#3f4740", urban: "#4e4a42", sand: "#544c39" },
28312
- contour: "#6b6250",
28313
- contourIndex: "#837961",
28314
- road: "#565040",
28315
- roadMajor: "#63594440",
28316
- label: "#c3b898",
28317
- halo: "#2b2820",
28318
- shadow: "#2e2a20",
28319
- highlight: "#8a8068",
28320
- fade: "#16202a",
28321
- // Unchanged. DUSK was not the palette the 0.8 decision was made against.
28322
- exaggeration: 0.45
28323
- },
28324
- NIGHT: {
28325
- base: "#2a2620",
28326
- cover: { wood: "#242a20", wetland: "#232823", urban: "#2c2925", sand: "#302b22" },
28327
- contour: "#413b30",
28328
- contourIndex: "#524a3b",
28329
- road: "#332f27",
28330
- roadMajor: "#3d382d",
28331
- label: "#7d745e",
28332
- halo: "#15130f",
28333
- shadow: "#1a1712",
28334
- highlight: "#4a4436",
28335
- fade: "#07090b",
28336
- // ⚠ DELIBERATELY NOT RAISED. See the type's note: a strong hillshade
28337
- // under red night vision is a grey haze over exactly the contrast a
28338
- // skipper is navigating by. Left at the shipped value rather than
28339
- // lowered, because lowering it is a separate decision from this one.
28340
- exaggeration: 0.45
28341
- }
28541
+ // src/atlas/band-priority.ts
28542
+ var BAND_PRIORITY_RANGES = {
28543
+ "1": { minzoom: 0 },
28544
+ "2": { minzoom: 5 },
28545
+ "3": { minzoom: 7 },
28546
+ "4": { minzoom: 9 },
28547
+ "5": { minzoom: 11 },
28548
+ "6": { minzoom: 11 }
28342
28549
  };
28343
- var FITS = ["<=", ["coalesce", ["get", "min_z"], 0], ["zoom"]];
28344
- var ANCHOR = ["coalesce", ["get", "anchor"], "top"];
28345
- var anchorOffset = (em) => [
28346
- "match",
28347
- ["get", "anchor"],
28348
- "left",
28349
- ["literal", [0.6, 0]],
28350
- "right",
28351
- ["literal", [-0.6, 0]],
28352
- "bottom",
28353
- ["literal", [0, -em]],
28354
- ["literal", [0, em]]
28355
- ];
28356
- var FONT = ["Open Sans Regular"];
28357
- var FONT_ITALIC = ["Open Sans Italic"];
28358
- function landLayers(opts = {}) {
28359
- const palette = opts.palette ?? "DAY";
28360
- const T = LAND_THEMES[palette] ?? LAND_THEMES.DAY;
28361
- const src = opts.sourceId ?? LAND_REGION;
28550
+ var BAND_DATA_MINZOOM = {
28551
+ "1": 0,
28552
+ "2": 5,
28553
+ "3": 7,
28554
+ "4": 9,
28555
+ "5": 11,
28556
+ "6": 13
28557
+ };
28558
+ function dominatedVariantMaxzoom(D) {
28559
+ return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
28560
+ }
28561
+ var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
28562
+ "LNDARE",
28563
+ "DEPARE",
28564
+ "UNSARE",
28565
+ "LAKARE",
28566
+ "DRGARE",
28567
+ "SEAARE"
28568
+ ]);
28569
+ function isExemptOpaqueFill(layer) {
28570
+ if (layer.type !== "fill") return false;
28571
+ for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
28572
+ if (layer.id.startsWith(`s52-${cls}-`)) return true;
28573
+ }
28574
+ return false;
28575
+ }
28576
+ var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28577
+ "1",
28578
+ "2",
28579
+ "3"
28580
+ ]);
28581
+ function bandVariantMinzoom(layer, B) {
28582
+ const classMinzoom = layer.minzoom ?? 0;
28583
+ if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28584
+ return classMinzoom;
28585
+ }
28586
+ return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
28587
+ }
28588
+ var GENERALISED_AREA_FILL_CLASSES = /* @__PURE__ */ new Set([
28589
+ "BUAARE",
28590
+ // 2026-08-28, New York Harbor: the "New York Marine Inspection Zone and
28591
+ // Captain of the Port Zone" RESARE exists at bands 2/3 as a generalized
28592
+ // shore-hugging polygon whose point-sampled dominance tag MISSED (the
28593
+ // Providence-BUAARE failure mode), so its magenta dashed boundary drew
28594
+ // OFFSET over the band-5 detail at z11.5 — read in the field as "a
28595
+ // magenta shoreline bleeding down from a less coarse band". The same
28596
+ // tile carried untagged band-2 ADMARE and CTNARE giants in the same
28597
+ // state. All three are area classes whose coarse-band boundaries hug
28598
+ // shorelines and whose fine-band copies are authoritative.
28599
+ "RESARE",
28600
+ "CTNARE",
28601
+ "ADMARE"
28602
+ ]);
28603
+ var GENERALISED_AREA_MAX_BAND = 3;
28604
+ function generalisedAreaCeiling(layer, B) {
28605
+ if (layer.type !== "fill" && layer.type !== "line") return null;
28606
+ if (parseInt(B, 10) > GENERALISED_AREA_MAX_BAND) return null;
28607
+ for (const cls of GENERALISED_AREA_FILL_CLASSES) {
28608
+ if (layer.id.startsWith(`s52-${cls}-`)) return BAND_PRIORITY_RANGES["5"].minzoom;
28609
+ }
28610
+ return null;
28611
+ }
28612
+ var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
28613
+ var CHART_BAND_CODES = {
28614
+ Overview: ["1"],
28615
+ General: ["2"],
28616
+ Coastal: ["3"],
28617
+ Approach: ["4"],
28618
+ Harbor: ["5", "6"]
28619
+ };
28620
+ var CHART_CASCADE_CODES = {
28621
+ Overview: ["1"],
28622
+ General: ["1", "2"],
28623
+ Coastal: ["1", "2", "3"],
28624
+ Approach: ["1", "2", "3", "4"],
28625
+ Harbor: ["1", "2", "3", "4", "5", "6"]
28626
+ };
28627
+ var CHRT_STYLE_MODES = [
28628
+ "continuous",
28629
+ ...Object.keys(CHART_BAND_CODES).map(
28630
+ (band) => `chart:${band}`
28631
+ )
28632
+ ];
28633
+ function normalizeStyleMode(raw) {
28634
+ if (raw === null || raw === void 0 || raw === "") return "continuous";
28635
+ const lower = raw.toLowerCase();
28636
+ return CHRT_STYLE_MODES.find((mode) => mode.toLowerCase() === lower) ?? null;
28637
+ }
28638
+ function styleModeBand(mode) {
28639
+ return mode === "continuous" ? null : mode.slice("chart:".length);
28640
+ }
28641
+ function bandFilterExpression(codes) {
28642
+ const list = Array.isArray(codes) ? codes : [codes];
28643
+ if (list.length === 1) {
28644
+ return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
28645
+ }
28646
+ return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
28647
+ }
28648
+ function combineWithBandFilter(baseFilter, bandFilter) {
28649
+ if (!baseFilter) return bandFilter;
28650
+ if (baseFilter[0] === "all") {
28651
+ return ["all", ...baseFilter.slice(1), bandFilter];
28652
+ }
28653
+ return ["all", baseFilter, bandFilter];
28654
+ }
28655
+ var NOT_DOMINATED_FILTER = [
28656
+ "all",
28657
+ ["!", ["has", "_dominated_by_min"]],
28658
+ ["!=", ["get", "_dominated"], true]
28659
+ ];
28660
+ function dominatedByMinFilter(D) {
28661
+ const dInt = parseInt(D, 10);
28662
+ if (D === "6") {
28663
+ return [
28664
+ "any",
28665
+ ["==", ["get", "_dominated_by_min"], dInt],
28666
+ ["==", ["get", "_dominated"], true]
28667
+ ];
28668
+ }
28669
+ return ["==", ["get", "_dominated_by_min"], dInt];
28670
+ }
28671
+ function withinBboxFilter(bbox) {
28672
+ const [w, s] = bbox.sw;
28673
+ const [e, n] = bbox.ne;
28674
+ return [
28675
+ "within",
28676
+ {
28677
+ type: "Polygon",
28678
+ coordinates: [
28679
+ [
28680
+ [w, s],
28681
+ [e, s],
28682
+ [e, n],
28683
+ [w, n],
28684
+ [w, s]
28685
+ ]
28686
+ ]
28687
+ }
28688
+ ];
28689
+ }
28690
+ function worldMinusBboxPolygon(bbox) {
28691
+ const [w, s] = bbox.sw;
28692
+ const [e, n] = bbox.ne;
28693
+ return {
28694
+ type: "Polygon",
28695
+ coordinates: [
28696
+ [
28697
+ [-180, -85],
28698
+ [180, -85],
28699
+ [180, 85],
28700
+ [-180, 85],
28701
+ [-180, -85]
28702
+ ],
28703
+ // Inner ring (hole) — winding opposite to outer ring per GeoJSON
28704
+ // spec. MapLibre is permissive but we follow strict winding.
28705
+ [
28706
+ [w, s],
28707
+ [w, n],
28708
+ [e, n],
28709
+ [e, s],
28710
+ [w, s]
28711
+ ]
28712
+ ]
28713
+ };
28714
+ }
28715
+
28716
+ // src/atlas/land-layers.ts
28717
+ var LAND_SURFACE_MINZOOM = BAND_PRIORITY_RANGES["5"].minzoom;
28718
+ var LAND_SURFACE_LAYERS = (id) => id === "chrtland-base" || id === "chrtland-cover" || id === "chrtland-contour" || id === "chrtland-hillshade" || id.startsWith("chrtland-fade-");
28719
+ var FADE_PX = 80;
28720
+ var FADE_BANDS = 16;
28721
+ function fadeAlpha(r) {
28722
+ return Math.max(0, Math.min(1, (FADE_PX / 2 - Math.abs(r)) / FADE_PX));
28723
+ }
28724
+ function landBaseColor(palette) {
28725
+ return (LAND_THEMES[palette] ?? LAND_THEMES.DAY).base;
28726
+ }
28727
+ var LAND_THEMES = {
28728
+ DAY: {
28729
+ base: "#e8d9b0",
28730
+ cover: { wood: "#cfd8b4", wetland: "#c9d3c4", urban: "#dedad2", sand: "#efe4c2" },
28731
+ contour: "#a89878",
28732
+ contourIndex: "#8a7a5c",
28733
+ road: "#d9c9a4",
28734
+ roadMajor: "#d2bd8e",
28735
+ label: "#5b4f3a",
28736
+ halo: "#f2ead6",
28737
+ shadow: "#6b5a3e",
28738
+ highlight: "#fffaf0",
28739
+ fade: "#e8f2f8",
28740
+ // Raised from 0.45 on 2026-08-23. ⚠ Above the ~0.5 that
28741
+ // hillshade-style.mjs warns "starts competing with depth areas" — a
28742
+ // deliberate, sighted choice made against rendered evidence, not a
28743
+ // default. If soundings or DEPARE ever read as fighting the terrain,
28744
+ // this number is the first thing to walk back.
28745
+ exaggeration: 0.8
28746
+ },
28747
+ DUSK: {
28748
+ base: "#4a4433",
28749
+ cover: { wood: "#414a38", wetland: "#3f4740", urban: "#4e4a42", sand: "#544c39" },
28750
+ contour: "#6b6250",
28751
+ contourIndex: "#837961",
28752
+ road: "#565040",
28753
+ roadMajor: "#63594440",
28754
+ label: "#c3b898",
28755
+ halo: "#2b2820",
28756
+ shadow: "#2e2a20",
28757
+ highlight: "#8a8068",
28758
+ fade: "#16202a",
28759
+ // Unchanged. DUSK was not the palette the 0.8 decision was made against.
28760
+ exaggeration: 0.45
28761
+ },
28762
+ NIGHT: {
28763
+ base: "#2a2620",
28764
+ cover: { wood: "#242a20", wetland: "#232823", urban: "#2c2925", sand: "#302b22" },
28765
+ contour: "#413b30",
28766
+ contourIndex: "#524a3b",
28767
+ road: "#332f27",
28768
+ roadMajor: "#3d382d",
28769
+ label: "#7d745e",
28770
+ halo: "#15130f",
28771
+ shadow: "#1a1712",
28772
+ highlight: "#4a4436",
28773
+ fade: "#07090b",
28774
+ // ⚠ DELIBERATELY NOT RAISED. See the type's note: a strong hillshade
28775
+ // under red night vision is a grey haze over exactly the contrast a
28776
+ // skipper is navigating by. Left at the shipped value rather than
28777
+ // lowered, because lowering it is a separate decision from this one.
28778
+ exaggeration: 0.45
28779
+ }
28780
+ };
28781
+ var FITS = ["<=", ["coalesce", ["get", "min_z"], 0], ["zoom"]];
28782
+ var ANCHOR = ["coalesce", ["get", "anchor"], "top"];
28783
+ var anchorOffset = (em) => [
28784
+ "match",
28785
+ ["get", "anchor"],
28786
+ "left",
28787
+ ["literal", [0.6, 0]],
28788
+ "right",
28789
+ ["literal", [-0.6, 0]],
28790
+ "bottom",
28791
+ ["literal", [0, -em]],
28792
+ ["literal", [0, em]]
28793
+ ];
28794
+ var FONT = ["Open Sans Regular"];
28795
+ var FONT_ITALIC = ["Open Sans Italic"];
28796
+ function landLayers(opts = {}) {
28797
+ const palette = opts.palette ?? "DAY";
28798
+ const T = LAND_THEMES[palette] ?? LAND_THEMES.DAY;
28799
+ const src = opts.sourceId ?? LAND_REGION;
28362
28800
  const demSrc = opts.reliefSourceId ?? RELIEF_REGION;
28363
28801
  const L = (o) => o;
28364
28802
  const layers = [
@@ -28396,6 +28834,8 @@ function landLayers(opts = {}) {
28396
28834
  }));
28397
28835
  if (opts.relief) {
28398
28836
  layers.push(L({
28837
+ // No minzoom here: LAND_SURFACE_LAYERS floors it with the rest of the
28838
+ // surface, so the two can never drift apart again.
28399
28839
  id: "chrtland-hillshade",
28400
28840
  type: "hillshade",
28401
28841
  source: demSrc,
@@ -28451,12 +28891,12 @@ function landLayers(opts = {}) {
28451
28891
  }
28452
28892
  }));
28453
28893
  layers.push(L({
28454
- id: "chrtland-town",
28894
+ id: "chrtland-city-major",
28455
28895
  type: "symbol",
28456
28896
  source: src,
28457
28897
  "source-layer": "land_town",
28458
- minzoom: 9,
28459
- filter: FITS,
28898
+ minzoom: 6.5,
28899
+ filter: [">=", ["coalesce", ["get", "prank"], 0], 10],
28460
28900
  layout: {
28461
28901
  "text-field": [
28462
28902
  "case",
@@ -28465,211 +28905,526 @@ function landLayers(opts = {}) {
28465
28905
  ["get", "name"]
28466
28906
  ],
28467
28907
  "text-font": FONT,
28468
- "text-size": ["interpolate", ["linear"], ["zoom"], 9, 10, 14, 13.5],
28469
- "text-letter-spacing": ["case", ["==", ["get", "upcase"], true], 0.14, 0],
28908
+ "text-size": [
28909
+ "interpolate",
28910
+ ["linear"],
28911
+ ["zoom"],
28912
+ 6.5,
28913
+ 11.5,
28914
+ 9,
28915
+ ["+", 10, ["*", 0.4, ["coalesce", ["get", "prank"], 10]]],
28916
+ 13,
28917
+ ["+", 13, ["*", 0.5, ["coalesce", ["get", "prank"], 10]]]
28918
+ ],
28919
+ "text-letter-spacing": ["case", ["==", ["get", "upcase"], true], 0.14, 0.02],
28470
28920
  "text-offset": anchorOffset(0.75),
28471
28921
  "text-anchor": ANCHOR,
28472
- "text-padding": 6,
28922
+ "text-padding": 10,
28473
28923
  "text-allow-overlap": false,
28474
- "symbol-sort-key": 2
28924
+ "symbol-sort-key": ["*", -1, ["coalesce", ["get", "pop"], 0]]
28475
28925
  },
28476
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28926
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.6 }
28477
28927
  }));
28478
28928
  layers.push(L({
28479
- id: "chrtland-summit",
28929
+ id: "chrtland-town-port",
28480
28930
  type: "symbol",
28481
28931
  source: src,
28482
- "source-layer": "land_summit",
28483
- minzoom: 11.5,
28484
- filter: ["all", FITS, [">=", ["get", "peak_m"], 40]],
28932
+ "source-layer": "land_town",
28933
+ minzoom: 8.5,
28934
+ filter: [
28935
+ "all",
28936
+ ["==", ["get", "enc_dup"], true],
28937
+ ["<", ["coalesce", ["get", "prank"], 0], 10]
28938
+ ],
28485
28939
  layout: {
28486
- "text-field": ["concat", ["get", "name"], "\n", ["to-string", ["get", "peak_m"]], " m"],
28940
+ "text-field": ["get", "name"],
28487
28941
  "text-font": FONT,
28488
- "text-size": ["interpolate", ["linear"], ["zoom"], 11.5, 8.5, 14, 11],
28489
- "text-offset": anchorOffset(0.8),
28942
+ "text-size": ["interpolate", ["linear"], ["zoom"], 8.5, 11, 12, 13.5],
28943
+ "text-offset": anchorOffset(0.75),
28490
28944
  "text-anchor": ANCHOR,
28491
- "text-padding": 5,
28945
+ "text-padding": 8,
28492
28946
  "text-allow-overlap": false,
28493
- "symbol-sort-key": 3
28947
+ // Within the tier, the bigger place wins the space: without this the
28948
+ // tie broke on data order and "West Gloucester" beat Gloucester.
28949
+ "symbol-sort-key": ["*", -1, ["coalesce", ["get", "pop"], 0]]
28494
28950
  },
28495
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28951
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.4 }
28496
28952
  }));
28497
28953
  layers.push(L({
28498
- id: "chrtland-poi",
28954
+ id: "chrtland-town",
28499
28955
  type: "symbol",
28500
28956
  source: src,
28501
- "source-layer": "land_poi",
28502
- minzoom: 13,
28503
- filter: FITS,
28957
+ "source-layer": "land_town",
28958
+ minzoom: 9,
28959
+ filter: [
28960
+ "all",
28961
+ FITS,
28962
+ ["<", ["coalesce", ["get", "prank"], 0], 10],
28963
+ ["!=", ["get", "enc_dup"], true]
28964
+ ],
28504
28965
  layout: {
28505
- "text-field": ["get", "name"],
28506
- "text-font": FONT_ITALIC,
28507
- "text-size": ["interpolate", ["linear"], ["zoom"], 13, 8, 14, 9.5],
28508
- "text-offset": anchorOffset(0.7),
28966
+ "text-field": [
28967
+ "case",
28968
+ ["==", ["get", "upcase"], true],
28969
+ ["upcase", ["get", "name"]],
28970
+ ["get", "name"]
28971
+ ],
28972
+ "text-font": FONT,
28973
+ "text-size": ["interpolate", ["linear"], ["zoom"], 9, 10, 14, 13.5],
28974
+ "text-letter-spacing": ["case", ["==", ["get", "upcase"], true], 0.14, 0],
28975
+ "text-offset": anchorOffset(0.75),
28509
28976
  "text-anchor": ANCHOR,
28510
- "text-padding": 5,
28977
+ "text-padding": 6,
28511
28978
  "text-allow-overlap": false,
28512
- "symbol-sort-key": 5
28979
+ "symbol-sort-key": 2
28513
28980
  },
28514
28981
  paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28515
28982
  }));
28516
- return layers;
28517
- }
28518
-
28519
- // src/atlas/band-priority.ts
28520
- var BAND_PRIORITY_RANGES = {
28521
- "1": { minzoom: 0 },
28522
- "2": { minzoom: 5 },
28523
- "3": { minzoom: 7 },
28524
- "4": { minzoom: 9 },
28525
- "5": { minzoom: 11 },
28526
- "6": { minzoom: 11 }
28527
- };
28528
- var BAND_DATA_MINZOOM = {
28529
- "1": 0,
28530
- "2": 5,
28531
- "3": 7,
28532
- "4": 9,
28533
- "5": 11,
28534
- "6": 13
28535
- };
28536
- function dominatedVariantMaxzoom(D) {
28537
- return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
28538
- }
28539
- var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
28540
- "LNDARE",
28541
- "DEPARE",
28542
- "UNSARE",
28543
- "LAKARE",
28544
- "DRGARE",
28545
- "SEAARE"
28546
- ]);
28547
- function isExemptOpaqueFill(layer) {
28548
- if (layer.type !== "fill") return false;
28549
- for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
28550
- if (layer.id.startsWith(`s52-${cls}-`)) return true;
28551
- }
28552
- return false;
28553
- }
28554
- var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28555
- "1",
28556
- "2",
28557
- "3"
28558
- ]);
28559
- function bandVariantMinzoom(layer, B) {
28560
- const classMinzoom = layer.minzoom ?? 0;
28561
- if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28562
- return classMinzoom;
28563
- }
28564
- return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
28565
- }
28566
- var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
28567
- var CHART_BAND_CODES = {
28568
- Overview: ["1"],
28569
- General: ["2"],
28570
- Coastal: ["3"],
28571
- Approach: ["4"],
28572
- Harbor: ["5", "6"]
28573
- };
28574
- var CHART_CASCADE_CODES = {
28575
- Overview: ["1"],
28576
- General: ["1", "2"],
28577
- Coastal: ["1", "2", "3"],
28578
- Approach: ["1", "2", "3", "4"],
28579
- Harbor: ["1", "2", "3", "4", "5", "6"]
28580
- };
28581
- var CHRT_STYLE_MODES = [
28582
- "continuous",
28583
- ...Object.keys(CHART_BAND_CODES).map(
28584
- (band) => `chart:${band}`
28585
- )
28586
- ];
28587
- function normalizeStyleMode(raw) {
28588
- if (raw === null || raw === void 0 || raw === "") return "continuous";
28589
- const lower = raw.toLowerCase();
28590
- return CHRT_STYLE_MODES.find((mode) => mode.toLowerCase() === lower) ?? null;
28591
- }
28592
- function styleModeBand(mode) {
28593
- return mode === "continuous" ? null : mode.slice("chart:".length);
28594
- }
28595
- function bandFilterExpression(codes) {
28596
- const list = Array.isArray(codes) ? codes : [codes];
28597
- if (list.length === 1) {
28598
- return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
28599
- }
28600
- return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
28601
- }
28602
- function combineWithBandFilter(baseFilter, bandFilter) {
28603
- if (!baseFilter) return bandFilter;
28604
- if (baseFilter[0] === "all") {
28605
- return ["all", ...baseFilter.slice(1), bandFilter];
28983
+ layers.push(L({
28984
+ id: "chrtland-summit",
28985
+ type: "symbol",
28986
+ source: src,
28987
+ "source-layer": "land_summit",
28988
+ minzoom: 11.5,
28989
+ filter: ["all", FITS, [">=", ["get", "peak_m"], 40]],
28990
+ layout: {
28991
+ "text-field": ["concat", ["get", "name"], "\n", ["to-string", ["get", "peak_m"]], " m"],
28992
+ "text-font": FONT,
28993
+ "text-size": ["interpolate", ["linear"], ["zoom"], 11.5, 8.5, 14, 11],
28994
+ "text-offset": anchorOffset(0.8),
28995
+ "text-anchor": ANCHOR,
28996
+ "text-padding": 5,
28997
+ "text-allow-overlap": false,
28998
+ "symbol-sort-key": 3
28999
+ },
29000
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
29001
+ }));
29002
+ layers.push(L({
29003
+ id: "chrtland-poi",
29004
+ type: "symbol",
29005
+ source: src,
29006
+ "source-layer": "land_poi",
29007
+ minzoom: 13,
29008
+ filter: FITS,
29009
+ layout: {
29010
+ "text-field": ["get", "name"],
29011
+ "text-font": FONT_ITALIC,
29012
+ "text-size": ["interpolate", ["linear"], ["zoom"], 13, 8, 14, 9.5],
29013
+ "text-offset": anchorOffset(0.7),
29014
+ "text-anchor": ANCHOR,
29015
+ "text-padding": 5,
29016
+ "text-allow-overlap": false,
29017
+ "symbol-sort-key": 5
29018
+ },
29019
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
29020
+ }));
29021
+ for (const layer of layers) {
29022
+ const l = layer;
29023
+ if (!LAND_SURFACE_LAYERS(l.id)) continue;
29024
+ l.minzoom = Math.max(l.minzoom ?? 0, LAND_SURFACE_MINZOOM);
28606
29025
  }
28607
- return ["all", baseFilter, bandFilter];
29026
+ return layers;
28608
29027
  }
28609
- var NOT_DOMINATED_FILTER = [
28610
- "all",
28611
- ["!", ["has", "_dominated_by_min"]],
28612
- ["!=", ["get", "_dominated"], true]
28613
- ];
28614
- function dominatedByMinFilter(D) {
28615
- const dInt = parseInt(D, 10);
28616
- if (D === "6") {
28617
- return [
28618
- "any",
28619
- ["==", ["get", "_dominated_by_min"], dInt],
28620
- ["==", ["get", "_dominated"], true]
28621
- ];
29028
+
29029
+ // src/atlas/base-layers.ts
29030
+ var NOCOVERAGE_PATTERN = "chrt:hatch-nocoverage";
29031
+ var UNCHARTED_PATTERN = "chrt:hatch-uncharted";
29032
+ var BASE_THEMES = {
29033
+ DAY: {
29034
+ earth: "#2A3439",
29035
+ water: "#1B2328",
29036
+ river: "#24313A",
29037
+ boundary: "#4A575E",
29038
+ jurisdiction: "#E8D9B0",
29039
+ // CHRTland's own tan — one land colour
29040
+ inlandWater: "#B9CFDD",
29041
+ // muted, unmistakably not a charted blue
29042
+ // State furniture. These three were referenced by the layers but missing
29043
+ // from this table (and its type — the long-standing TS2339 trio), so the
29044
+ // lines shipped on MapLibre's DEFAULT black. Now deliberate: a hairline
29045
+ // a shade deeper than the tan, quiet caps, tan halo.
29046
+ stateLine: "#C4B389",
29047
+ stateLabel: "#8E8468",
29048
+ stateHalo: "#E8D9B0",
29049
+ // Rough-lake furniture (owner ask 2026-08-28: "the lakes need labels,
29050
+ // and shorelines drawn"): a shoreline a shade deeper than the rough
29051
+ // water, and a name in the water's own voice, haloed by the water.
29052
+ inlandShore: "#8FA9BA",
29053
+ inlandLabel: "#54707F",
29054
+ inlandHalo: "#B9CFDD"
29055
+ },
29056
+ DUSK: {
29057
+ earth: "#232B30",
29058
+ water: "#161D21",
29059
+ river: "#1E2930",
29060
+ boundary: "#3E4A50",
29061
+ jurisdiction: "#C9BC96",
29062
+ inlandWater: "#93A9B7",
29063
+ stateLine: "#A6976F",
29064
+ stateLabel: "#776E55",
29065
+ stateHalo: "#C9BC96",
29066
+ inlandShore: "#74899A",
29067
+ inlandLabel: "#41586A",
29068
+ inlandHalo: "#93A9B7"
29069
+ },
29070
+ NIGHT: {
29071
+ earth: "#1A2125",
29072
+ water: "#10161A",
29073
+ river: "#182228",
29074
+ boundary: "#333E44",
29075
+ jurisdiction: "#8F866C",
29076
+ inlandWater: "#5F7280",
29077
+ stateLine: "#6B6350",
29078
+ stateLabel: "#565040",
29079
+ stateHalo: "#8F866C",
29080
+ inlandShore: "#4A5C68",
29081
+ inlandLabel: "#8CA2B0",
29082
+ inlandHalo: "#5F7280"
28622
29083
  }
28623
- return ["==", ["get", "_dominated_by_min"], dInt];
28624
- }
28625
- function withinBboxFilter(bbox) {
28626
- const [w, s] = bbox.sw;
28627
- const [e, n] = bbox.ne;
29084
+ };
29085
+ function baseLayers(opts = {}) {
29086
+ const palette = opts.palette ?? "DAY";
29087
+ const T = BASE_THEMES[palette] ?? BASE_THEMES.DAY;
29088
+ const src = opts.sourceId ?? BASE_REGION;
29089
+ const L = (o) => o;
28628
29090
  return [
28629
- "within",
28630
- {
28631
- type: "Polygon",
28632
- coordinates: [
28633
- [
28634
- [w, s],
28635
- [e, s],
28636
- [e, n],
28637
- [w, n],
28638
- [w, s]
28639
- ]
28640
- ]
28641
- }
29091
+ // 1. Geography — the world as it is, in the quiet dark voice. The OCEAN
29092
+ // is a background, not a polygon: the archive ships land and lakes,
29093
+ // and "everything else is sea" is exactly what a background expresses
29094
+ // — geometry-free, gap-free at every antimeridian and pole. Without
29095
+ // it the sea outside coverage was bare S-52 NODTA, a light grey that
29096
+ // read as "charted ground" with a hatch on it.
29097
+ L({
29098
+ id: "chrtbase-ocean",
29099
+ type: "background",
29100
+ paint: { "background-color": T.water }
29101
+ }),
29102
+ L({
29103
+ id: "chrtbase-earth",
29104
+ type: "fill",
29105
+ source: src,
29106
+ "source-layer": "base_earth",
29107
+ paint: { "fill-color": T.earth }
29108
+ }),
29109
+ L({
29110
+ id: "chrtbase-water",
29111
+ type: "fill",
29112
+ source: src,
29113
+ "source-layer": "base_water",
29114
+ paint: { "fill-color": T.water }
29115
+ }),
29116
+ L({
29117
+ id: "chrtbase-river",
29118
+ type: "line",
29119
+ source: src,
29120
+ "source-layer": "base_river",
29121
+ minzoom: 9,
29122
+ paint: {
29123
+ "line-color": T.river,
29124
+ "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.6, 14, 1.6]
29125
+ }
29126
+ }),
29127
+ L({
29128
+ id: "chrtbase-boundary",
29129
+ type: "line",
29130
+ source: src,
29131
+ "source-layer": "base_boundary",
29132
+ paint: { "line-color": T.boundary, "line-width": 0.6, "line-opacity": 0.8 }
29133
+ }),
29134
+ // 2. THE DEFAULT: no coverage, everywhere, as a geometry-free background.
29135
+ // Constant opacity by design — see the header. What kills it is being
29136
+ // painted OVER, never fading.
29137
+ L({
29138
+ id: "chrtbase-nocoverage",
29139
+ type: "background",
29140
+ paint: { "background-pattern": NOCOVERAGE_PATTERN, "background-opacity": 0.55 }
29141
+ }),
29142
+ // 3. The jurisdiction: countries whose charts CHRT carries go tan — the
29143
+ // same tan as CHRTland, so "land in our world" is ONE colour. Opaque,
29144
+ // so it takes the crosshatch off its ground entirely.
29145
+ L({
29146
+ id: "chrtbase-jurisdiction",
29147
+ type: "fill",
29148
+ source: src,
29149
+ "source-layer": "jurisdiction",
29150
+ paint: { "fill-color": T.jurisdiction }
29151
+ }),
29152
+ // 4. States — internal boundaries and names, inside the jurisdiction
29153
+ // only (owner decision 2026-08-28). A hairline a shade deeper than the
29154
+ // tan, and quiet letter-spaced caps that fade as harbour detail takes
29155
+ // over. The one deliberate exception to no-names-on-the-basemap:
29156
+ // ADMIN furniture, nothing else — see the fence test.
29157
+ L({
29158
+ id: "chrtbase-state-boundary",
29159
+ type: "line",
29160
+ source: src,
29161
+ "source-layer": "state_boundary",
29162
+ minzoom: 4,
29163
+ paint: {
29164
+ "line-color": T.stateLine,
29165
+ "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.5, 10, 1],
29166
+ "line-dasharray": [3, 2],
29167
+ "line-opacity": 0.8
29168
+ }
29169
+ }),
29170
+ // 5. Country borders, drawn to be HOISTED above the whole chart by the
29171
+ // generator (owner, 2026-08-31: "where there is charted land, can we
29172
+ // add country borders on top of everything? I want it clear where
29173
+ // there is a boarder."). Sourced from NE boundary_lines_land — land
29174
+ // borders only, never coastline — so riding on top cannot stroke a
29175
+ // coast. Dashed so it reads as an administrative line, not chart
29176
+ // linework.
29177
+ L({
29178
+ id: "chrtbase-country-boundary",
29179
+ type: "line",
29180
+ source: src,
29181
+ "source-layer": "country_boundary",
29182
+ paint: {
29183
+ "line-color": T.boundary,
29184
+ "line-width": ["interpolate", ["linear"], ["zoom"], 3, 0.8, 8, 1.4, 12, 2.2],
29185
+ "line-dasharray": [4, 2],
29186
+ "line-opacity": 0.9
29187
+ }
29188
+ }),
29189
+ L({
29190
+ id: "chrtbase-state-label",
29191
+ type: "symbol",
29192
+ source: src,
29193
+ "source-layer": "state_label",
29194
+ minzoom: 4,
29195
+ maxzoom: 10,
29196
+ layout: {
29197
+ "text-field": ["upcase", ["get", "name"]],
29198
+ "text-font": ["Open Sans Regular"],
29199
+ "text-size": ["interpolate", ["linear"], ["zoom"], 4, 9, 8, 12],
29200
+ "text-letter-spacing": 0.25,
29201
+ "text-allow-overlap": false,
29202
+ "text-padding": 8
29203
+ },
29204
+ paint: {
29205
+ "text-color": T.stateLabel,
29206
+ "text-halo-color": T.stateHalo,
29207
+ "text-halo-width": 1.2,
29208
+ "text-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.7, 8, 0.85, 10, 0.4]
29209
+ }
29210
+ })
28642
29211
  ];
28643
29212
  }
28644
- function worldMinusBboxPolygon(bbox) {
28645
- const [w, s] = bbox.sw;
28646
- const [e, n] = bbox.ne;
28647
- return {
28648
- type: "Polygon",
28649
- coordinates: [
28650
- [
28651
- [-180, -85],
28652
- [180, -85],
28653
- [180, 85],
28654
- [-180, 85],
28655
- [-180, -85]
28656
- ],
28657
- // Inner ring (hole) — winding opposite to outer ring per GeoJSON
28658
- // spec. MapLibre is permissive but we follow strict winding.
28659
- [
28660
- [w, s],
28661
- [w, n],
28662
- [e, n],
28663
- [e, s],
28664
- [w, s]
28665
- ]
28666
- ]
28667
- };
29213
+ function baseInlandWaterLayers(opts = {}) {
29214
+ const palette = opts.palette ?? "DAY";
29215
+ const T = BASE_THEMES[palette] ?? BASE_THEMES.DAY;
29216
+ const src = opts.sourceId ?? BASE_REGION;
29217
+ const L = (o) => o;
29218
+ const NE_TIER = ["!", ["has", "kind"]];
29219
+ const PLANET_TIER = ["has", "kind"];
29220
+ const HANDOVER = 9;
29221
+ return [
29222
+ // ── The fill pair, SPLIT BY TIER at the handover ────────────────────────
29223
+ //
29224
+ // Both tiers share `inland_water` (tile-join concatenates the same layer
29225
+ // name), and an unsplit fill draws BOTH — which means the NE polygon is
29226
+ // painted over the planet one at every zoom. That is not a cosmetic
29227
+ // double-draw: NE 10m carries no interior rings, so its blob has no
29228
+ // ISLANDS in it, and painting it over the planet's holed polygons buries
29229
+ // every island in the lake under "uncharted water". Owner-reported at
29230
+ // Winnipesaukee z11.7 ("there is land (and islands) being covered by the
29231
+ // 'no chart here' boundary"), and measured: rendering the planet tier
29232
+ // alone brings back the whole archipelago — Rattlesnake, Bear, Long
29233
+ // Island, Diamond, the Barndoors — while the NE tier alone is a faceted
29234
+ // blob whose edges bear no relation to the shoreline drawn beneath it.
29235
+ //
29236
+ // So each tier owns its own zoom range, the same handover the shoreline
29237
+ // below uses. Known residue, measured over the whole archive: of the 141
29238
+ // NE lakes we still carry, 138 have planet water beneath them; the
29239
+ // exceptions are two unnamed western-Alaska tundra lakes and a 2e-5 deg²
29240
+ // Lake Michigan sliver, which lose their rough fill above z9 because the
29241
+ // planet has no `water` polygon there at all (checked against the raw
29242
+ // pre-clip NDJSON — not a clip artefact).
29243
+ L({
29244
+ id: "chrtbase-inland-water",
29245
+ type: "fill",
29246
+ source: src,
29247
+ "source-layer": "inland_water",
29248
+ filter: NE_TIER,
29249
+ maxzoom: HANDOVER,
29250
+ paint: { "fill-color": T.inlandWater }
29251
+ }),
29252
+ L({
29253
+ id: "chrtbase-inland-hatch",
29254
+ type: "fill",
29255
+ source: src,
29256
+ "source-layer": "inland_water",
29257
+ filter: NE_TIER,
29258
+ maxzoom: HANDOVER,
29259
+ paint: { "fill-pattern": UNCHARTED_PATTERN, "fill-opacity": 0.7 }
29260
+ }),
29261
+ L({
29262
+ id: "chrtbase-inland-water-detail",
29263
+ type: "fill",
29264
+ source: src,
29265
+ "source-layer": "inland_water",
29266
+ filter: PLANET_TIER,
29267
+ minzoom: HANDOVER,
29268
+ paint: { "fill-color": T.inlandWater }
29269
+ }),
29270
+ L({
29271
+ id: "chrtbase-inland-hatch-detail",
29272
+ type: "fill",
29273
+ source: src,
29274
+ "source-layer": "inland_water",
29275
+ filter: PLANET_TIER,
29276
+ minzoom: HANDOVER,
29277
+ paint: { "fill-pattern": UNCHARTED_PATTERN, "fill-opacity": 0.7 }
29278
+ }),
29279
+ // The rough shoreline (owner ask 2026-08-28). Drawn ABOVE the hatch so
29280
+ // the edge stays crisp through the texture, and BELOW the charted-water
29281
+ // repaint clones the generator splices in after it — so wherever the
29282
+ // chart repaints its own water, the rough NE10m line yields to the
29283
+ // chart's COALNE instead of double-striking it.
29284
+ //
29285
+ // NE-tier geometry ONLY (`kind` absent): the planet detail tier joins
29286
+ // the same source-layer from z9 as MANY polygons whose chunk-clip seams
29287
+ // rejoin invisibly as FILLS — but a line layer strokes every seam, and
29288
+ // Winnipesaukee rendered as a mesh of internal edges. Planet features
29289
+ // carry `kind` (the INLAND_KINDS filter's own field); NE's carry only
29290
+ // `name`, so the absence of `kind` IS the tier discriminator.
29291
+ L({
29292
+ id: "chrtbase-inland-shore",
29293
+ type: "line",
29294
+ source: src,
29295
+ "source-layer": "inland_water",
29296
+ filter: NE_TIER,
29297
+ // Hands the shoreline over to the planet tier at ITS floor. Both
29298
+ // tiers live in `inland_water` from z9 up (tile-join concatenates the
29299
+ // same layer name), and their FILLS merge invisibly — but two
29300
+ // outlines do not: NE 10m generalises Winnipesaukee's shape, so its
29301
+ // outline ran straight across the middle of the lake the planet had
29302
+ // drawn correctly around. The coarse line is right only while it is
29303
+ // the only shape we have.
29304
+ maxzoom: HANDOVER,
29305
+ paint: {
29306
+ "line-color": T.inlandShore,
29307
+ "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.5, 10, 1.1, 14, 1.6],
29308
+ "line-opacity": 0.9
29309
+ }
29310
+ }),
29311
+ // The PLANET tier's shoreline (owner ask 2026-08-31: "uncharted small
29312
+ // lakes render with no outline"). It reads its own `inland_shore` layer,
29313
+ // NOT `inland_water`, and that indirection is the whole fix: the mesh
29314
+ // that forced the `!has kind` fence above is a CLIP artefact — an MVT
29315
+ // feature arrives already cut to its own z10 tile, so stroking the
29316
+ // polygon strokes every tile boundary it crosses. The build now walks
29317
+ // those rings, drops the axis-aligned runs that sit on the read zoom's
29318
+ // tile grid, and emits what is left as lines (measured on one New
29319
+ // England chunk: 61 long axis-aligned mesh edges → 5, and the 5 survivors
29320
+ // are 0.02–0.5 tiles off the grid, i.e. real straight water edges).
29321
+ //
29322
+ // Thinner and quieter than the NE line: this is finer geometry describing
29323
+ // water nobody has surveyed, and approximate data should look
29324
+ // approximate.
29325
+ L({
29326
+ id: "chrtbase-inland-shore-detail",
29327
+ type: "line",
29328
+ source: src,
29329
+ "source-layer": "inland_shore",
29330
+ minzoom: HANDOVER,
29331
+ paint: {
29332
+ "line-color": T.inlandShore,
29333
+ "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.5, 12, 0.9, 15, 1.2],
29334
+ "line-opacity": 0.85
29335
+ }
29336
+ }),
29337
+ // The lake's name (owner ask 2026-08-28) — the second scoped exception
29338
+ // to no-names-on-the-basemap, after the states: WATER-BODY names only.
29339
+ // No maxzoom: an uncharted lake keeps its name at every zoom; over
29340
+ // charted lakes the chart's own SEAARE label takes over at detail zooms
29341
+ // and collision culls the echo.
29342
+ //
29343
+ // The archive's `inland_water_label` anchors (v3+). v3 shipped ONE
29344
+ // PointOnSurface anchor per lake, and a big lake's single anchor pans
29345
+ // off-viewport — Winnipesaukee at z11.7 read nameless with its name two
29346
+ // tiles west of the screen (owner-reported). Labelling the polygons
29347
+ // instead is NOT the fix: tippecanoe's coalescing drops the NE polygon
29348
+ // from some z11 tiles (measured: 11/619/747 carries only planet pieces),
29349
+ // so a polygon-sourced label vanishes tile by tile. v4 therefore bakes a
29350
+ // deterministic anchor GRID into big lakes — the paper-chart convention
29351
+ // of repeating a name along a large water body — and this one layer
29352
+ // renders however many anchors are in view; collision culls the rest.
29353
+ // Planet-tier water-body NAMES (owner ask 2026-08-31: NE 10m names only
29354
+ // 113 US lakes). Emitted BELOW the NE layer on purpose — MapLibre places
29355
+ // symbols topmost-layer-first, so the big NE lake wins collision against
29356
+ // the pond beside it, and both lose to the chart's own labels (every ENC
29357
+ // class is emitted after this pair).
29358
+ //
29359
+ // The size threshold is OSM's own `min_zoom`, carried through the build:
29360
+ // Lake Winnipesaukee reads 10, Squam Lake 12, so a pond simply is not in
29361
+ // the filter until the user is close enough for it to be the answer to
29362
+ // "what is that water". `["zoom"]` in a filter evaluates at integer zoom
29363
+ // levels and removes the feature from placement entirely — verified
29364
+ // against maplibre-gl-native rather than assumed, because a text-size-0
29365
+ // gate would still claim a collision slot.
29366
+ //
29367
+ // The tiers are separate LAYERS, not a filtered split of one — see the
29368
+ // builder for why: an archive that a not-yet-deployed style would
29369
+ // mis-render cannot be promoted first, and this one has to be.
29370
+ L({
29371
+ id: "chrtbase-inland-label-detail",
29372
+ type: "symbol",
29373
+ source: src,
29374
+ "source-layer": "inland_water_label_detail",
29375
+ minzoom: HANDOVER,
29376
+ filter: [">=", ["zoom"], ["get", "min_zoom"]],
29377
+ layout: {
29378
+ "text-field": ["get", "name"],
29379
+ "text-font": ["Open Sans Regular"],
29380
+ "text-size": ["interpolate", ["linear"], ["zoom"], 9, 9, 12, 10.5, 15, 11],
29381
+ "text-max-width": 8,
29382
+ "text-allow-overlap": false,
29383
+ "text-padding": 6,
29384
+ // Bigger water first within the tier — min_zoom IS the size rank.
29385
+ "symbol-sort-key": ["get", "min_zoom"]
29386
+ },
29387
+ paint: {
29388
+ "text-color": T.inlandLabel,
29389
+ "text-halo-color": T.inlandHalo,
29390
+ "text-halo-width": 1,
29391
+ "text-opacity": 0.9
29392
+ }
29393
+ }),
29394
+ L({
29395
+ id: "chrtbase-inland-label",
29396
+ type: "symbol",
29397
+ source: src,
29398
+ "source-layer": "inland_water_label",
29399
+ minzoom: 5,
29400
+ layout: {
29401
+ "text-field": ["get", "name"],
29402
+ "text-font": ["Open Sans Regular"],
29403
+ "text-size": ["interpolate", ["linear"], ["zoom"], 5, 9.5, 10, 12],
29404
+ "text-max-width": 7,
29405
+ "text-allow-overlap": false,
29406
+ // Zoom-scaled spacing for the anchor GRID: at z8.9 the 0.08-deg
29407
+ // anchors sit ~54 px apart and a flat padding let six copies of
29408
+ // "Lake Ontario" crowd one screen (owner-reported 2026-08-30).
29409
+ // Wide collision boxes at low zoom thin the grid to the repeat-
29410
+ // by-convention cadence; by z12 the anchors are ~850 px apart and
29411
+ // the padding relaxes to normal.
29412
+ "text-padding": ["interpolate", ["linear"], ["zoom"], 5, 80, 9, 64, 12, 16]
29413
+ },
29414
+ paint: {
29415
+ "text-color": T.inlandLabel,
29416
+ "text-halo-color": T.inlandHalo,
29417
+ "text-halo-width": 1.1
29418
+ }
29419
+ })
29420
+ ];
28668
29421
  }
28669
29422
 
28670
29423
  // src/atlas/band-collapse.ts
28671
- function collapsedDominanceGate(B, minzoom) {
28672
- const clauses = [NOT_DOMINATED_FILTER];
29424
+ function collapsedDominanceGate(B, minzoom, notDominatedCeiling = null) {
29425
+ const clauses = [
29426
+ notDominatedCeiling !== null && notDominatedCeiling > minzoom ? ["all", NOT_DOMINATED_FILTER, ["<", ["zoom"], notDominatedCeiling]] : NOT_DOMINATED_FILTER
29427
+ ];
28673
29428
  const bInt = parseInt(B, 10);
28674
29429
  for (const D of BAND_DRAW_ORDER) {
28675
29430
  const dInt = parseInt(D, 10);
@@ -28698,7 +29453,7 @@ function makeCollapsedBandVariant(layer, B, isExempt) {
28698
29453
  filter: combineWithBandFilter(layer.filter, [
28699
29454
  "all",
28700
29455
  bandFilterExpression(B),
28701
- collapsedDominanceGate(B, minzoom)
29456
+ collapsedDominanceGate(B, minzoom, generalisedAreaCeiling(layer, B))
28702
29457
  ]),
28703
29458
  minzoom
28704
29459
  };
@@ -28833,6 +29588,7 @@ function makeBandVariants(layer, B) {
28833
29588
  delete exempt.maxzoom;
28834
29589
  return [exempt];
28835
29590
  }
29591
+ const ceiling = generalisedAreaCeiling(layer, B);
28836
29592
  const notDom = {
28837
29593
  ...layer,
28838
29594
  id: `${layer.id}-band${B}`,
@@ -28843,6 +29599,7 @@ function makeBandVariants(layer, B) {
28843
29599
  minzoom
28844
29600
  };
28845
29601
  delete notDom.maxzoom;
29602
+ if (ceiling !== null && ceiling > minzoom) notDom.maxzoom = ceiling;
28846
29603
  variants.push(notDom);
28847
29604
  const bInt = parseInt(B, 10);
28848
29605
  for (const D of BAND_DRAW_ORDER) {
@@ -28865,14 +29622,21 @@ function makeBandVariants(layer, B) {
28865
29622
  return variants;
28866
29623
  }
28867
29624
  var CELL_BAND_OPACITY_CURVE = {
28868
- "1": [[3, 0.5], [5, 0.3], [16, 0.15]],
28869
- "2": [[5, 0.5], [7, 0.3], [16, 0.15]],
28870
- "3": [[7, 0.55], [9, 0.35], [16, 0.18]],
28871
- "4": [[9, 0.55], [11, 0.35], [16, 0.2]],
28872
- "5": [[11, 0.6], [13, 0.35], [18, 0.25]],
28873
- "6": [[13, 0.75], [16, 0.55], [22, 0.45]]
29625
+ "1": [[3, 0.5], [5, 0.42], [16, 0.34]],
29626
+ "2": [[5, 0.52], [7, 0.44], [16, 0.34]],
29627
+ "3": [[7, 0.55], [9, 0.46], [16, 0.36]],
29628
+ "4": [[9, 0.58], [11, 0.5], [16, 0.42]],
29629
+ "5": [[11, 0.62], [13, 0.52], [18, 0.44]],
29630
+ "6": [[13, 0.75], [16, 0.6], [22, 0.5]]
28874
29631
  };
28875
- function fanOrientationLabelByCellBand(label, bandFan) {
29632
+ var ORIENTATION_OPACITY_SCALE = {
29633
+ LNDARE: 1.45,
29634
+ LNDRGN: 1.4,
29635
+ BUAARE: 1.25
29636
+ };
29637
+ var ORIENTATION_OPACITY_MAX = 0.92;
29638
+ var ORIENTATION_BOOST_MINZOOM = 11;
29639
+ function fanOrientationLabelByCellBand(label, bandFan, objectClass) {
28876
29640
  const cellBandExpr = [
28877
29641
  "slice",
28878
29642
  ["coalesce", ["get", "_cell_id"], "??9???"],
@@ -28880,11 +29644,15 @@ function fanOrientationLabelByCellBand(label, bandFan) {
28880
29644
  3
28881
29645
  ];
28882
29646
  const variants = [];
29647
+ const scale = objectClass ? ORIENTATION_OPACITY_SCALE[objectClass] ?? 1 : 1;
28883
29648
  for (const code of BAND_DRAW_ORDER) {
28884
29649
  const range = BAND_PRIORITY_RANGES[code];
28885
29650
  const curve = CELL_BAND_OPACITY_CURVE[code];
28886
29651
  const opacityExpr = ["interpolate", ["linear"], ["zoom"]];
28887
- for (const [z, o] of curve) opacityExpr.push(z, o);
29652
+ for (const [z, o] of curve) {
29653
+ const s = z >= ORIENTATION_BOOST_MINZOOM ? scale : 1;
29654
+ opacityExpr.push(z, Math.round(Math.min(o * s, ORIENTATION_OPACITY_MAX) * 100) / 100);
29655
+ }
28888
29656
  const cellFiltered = combineWithBandFilter(
28889
29657
  label.filter,
28890
29658
  ["==", cellBandExpr, code]
@@ -28931,6 +29699,33 @@ function fanOrientationLabelByCellBand(label, bandFan) {
28931
29699
  }
28932
29700
  return variants;
28933
29701
  }
29702
+ var MAJOR_WATER_TEXT_PADDING = [
29703
+ "interpolate",
29704
+ ["linear"],
29705
+ ["zoom"],
29706
+ 5,
29707
+ 24,
29708
+ 9,
29709
+ 20,
29710
+ 13,
29711
+ 16,
29712
+ 16,
29713
+ 14
29714
+ ];
29715
+ function splitOrientationFan(fan, hoistedFilter, watermarkFilter, hoistedLayout) {
29716
+ return {
29717
+ hoist: fan.map((l) => ({
29718
+ ...l,
29719
+ id: `${l.id}-major`,
29720
+ filter: combineWithBandFilter(l.filter, hoistedFilter),
29721
+ ...hoistedLayout ? { layout: { ...l.layout ?? {}, ...hoistedLayout } } : {}
29722
+ })),
29723
+ watermark: fan.map((l) => ({
29724
+ ...l,
29725
+ filter: combineWithBandFilter(l.filter, watermarkFilter)
29726
+ }))
29727
+ };
29728
+ }
28934
29729
  function byLabelPriorityBottomUp(bundles) {
28935
29730
  return [...bundles].sort(
28936
29731
  (a, b) => getLabelPriority(b.objectClass) - getLabelPriority(a.objectClass)
@@ -28992,13 +29787,45 @@ function generateStyle(palette = "DAY", opts = {}) {
28992
29787
  paint: { "background-color": resolveColor("NODTA", palette) }
28993
29788
  };
28994
29789
  const layers = [backgroundLayer];
29790
+ if (opts.base) {
29791
+ const base = baseLayers({ palette });
29792
+ const coverageGround = {
29793
+ id: "s52-coverage-ground",
29794
+ type: "fill",
29795
+ source: sourceId,
29796
+ "source-layer": "M_COVR",
29797
+ filter: [
29798
+ "all",
29799
+ ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "MultiPolygon"]],
29800
+ ["==", ["to-number", ["coalesce", ["get", "CATCOV"], 0]], 1]
29801
+ ],
29802
+ paint: { "fill-color": resolveColor("DEPDW", palette) },
29803
+ metadata: { "s52:objectClass": "M_COVR", "s52:role": "coverage-ground" }
29804
+ };
29805
+ const jurisdictionAt = base.findIndex(
29806
+ (l) => l.id === "chrtbase-jurisdiction"
29807
+ );
29808
+ if (jurisdictionAt === -1) {
29809
+ layers.push(...base, coverageGround);
29810
+ } else {
29811
+ layers.push(
29812
+ ...base.slice(0, jurisdictionAt),
29813
+ coverageGround,
29814
+ ...base.slice(jurisdictionAt)
29815
+ );
29816
+ }
29817
+ }
28995
29818
  if (opts.land) {
28996
29819
  layers.push(...landLayers({
28997
29820
  palette,
28998
29821
  ...opts.relief ? { relief: true } : {}
28999
29822
  }));
29000
29823
  }
29824
+ if (opts.base) {
29825
+ layers.push(...baseInlandWaterLayers({ palette }));
29826
+ }
29001
29827
  const labelLayers = [];
29828
+ const majorWaterLayers = [];
29002
29829
  const chartCascade = opts.chartMode ? CHART_CASCADE_CODES[opts.chartMode.band] : null;
29003
29830
  const chartRequestedCodes = opts.chartMode ? CHART_BAND_CODES[opts.chartMode.band] : null;
29004
29831
  const bandFan = opts.bandFan ?? "collapsed";
@@ -29114,13 +29941,44 @@ function generateStyle(palette = "DAY", opts = {}) {
29114
29941
  }
29115
29942
  }
29116
29943
  }
29944
+ {
29945
+ const TERMINAL_FILL_RE = /^s52-(?:LNDARE|DEPARE)-fill-band[456]$/;
29946
+ for (const l of layers) {
29947
+ const lay = l;
29948
+ if (!TERMINAL_FILL_RE.test(lay.id)) continue;
29949
+ const floor = lay.minzoom ?? 0;
29950
+ if (floor <= 0) continue;
29951
+ lay.filter = [
29952
+ "all",
29953
+ lay.filter,
29954
+ ["any", [">=", ["zoom"], floor], ["==", ["get", "_terminal_fill"], true]]
29955
+ ];
29956
+ delete lay.minzoom;
29957
+ }
29958
+ }
29117
29959
  if (opts.labels === "placename") {
29118
29960
  labelLayers.push(...buildPlaceNameLayers(sourceId));
29119
29961
  } else {
29120
29962
  for (const { objectClass, label } of bundles) {
29121
29963
  if (!label) continue;
29122
29964
  if (getLabelBehavior(objectClass) === "orientation-cross-band") {
29123
- labelLayers.push(...fanOrientationLabelByCellBand(label, bandFan));
29965
+ if (objectClass === "LNDARE" || objectClass === "SEAARE") {
29966
+ const Z = ISLAND_LABEL_HOIST_MINZOOM;
29967
+ const { hoist, watermark } = objectClass === "LNDARE" ? splitOrientationFan(
29968
+ fanOrientationLabelByCellBand(label, bandFan, objectClass),
29969
+ [">=", ["zoom"], Z],
29970
+ ["<", ["zoom"], Z]
29971
+ ) : splitOrientationFan(
29972
+ fanOrientationLabelByCellBand(label, bandFan, objectClass),
29973
+ ["<=", buildSeaAreaTierExpression(), MAJOR_WATER_TIER_MAX],
29974
+ [">", buildSeaAreaTierExpression(), MAJOR_WATER_TIER_MAX],
29975
+ { "text-padding": MAJOR_WATER_TEXT_PADDING }
29976
+ );
29977
+ majorWaterLayers.push(...hoist);
29978
+ labelLayers.push(...watermark);
29979
+ continue;
29980
+ }
29981
+ labelLayers.push(...fanOrientationLabelByCellBand(label, bandFan, objectClass));
29124
29982
  }
29125
29983
  }
29126
29984
  }
@@ -29129,7 +29987,12 @@ function generateStyle(palette = "DAY", opts = {}) {
29129
29987
  (b) => b.label !== null && getLabelBehavior(b.objectClass) !== "orientation-cross-band"
29130
29988
  )
29131
29989
  );
29132
- for (const { label } of featureLabelBundles) {
29990
+ let majorsEmitted = majorWaterLayers.length === 0;
29991
+ for (const { label, objectClass: cls } of featureLabelBundles) {
29992
+ if (!majorsEmitted && getLabelPriority(cls) < MAJOR_WATER_LABEL_PRIORITY) {
29993
+ labelLayers.push(...majorWaterLayers);
29994
+ majorsEmitted = true;
29995
+ }
29133
29996
  if (bandFan === "collapsed") {
29134
29997
  labelLayers.push(makeCollapsedCrossBandLabel(label, isDominanceExempt(label)));
29135
29998
  continue;
@@ -29138,18 +30001,121 @@ function generateStyle(palette = "DAY", opts = {}) {
29138
30001
  labelLayers.push(...emitBand(label, code));
29139
30002
  }
29140
30003
  }
30004
+ if (!majorsEmitted) labelLayers.push(...majorWaterLayers);
29141
30005
  layers.push(...labelLayers);
29142
30006
  }
29143
30007
  const symbolSet = opts.symbolSet ?? "s52";
29144
30008
  const spriteBase = `${opts.tileBaseUrl ?? "https://tiles.chrt.co"}/v1/sprites`;
29145
30009
  const isLndareFill = (l) => /^s52-LNDARE-fill-band\d+$/.test(l.id);
29146
- const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
30010
+ const isWaterBaseFill = (l) => /^s52-(DEPARE|DRGARE|UNSARE)-fill-band\d+$/.test(l.id);
30011
+ const bandOf = (l) => {
30012
+ const m = /-band(\d+)$/.exec(l.id);
30013
+ return m ? Number(m[1]) : 0;
30014
+ };
29147
30015
  const emitted = opts.land ? (() => {
29148
- const lndareFills = layers.filter(isLndareFill);
29149
- const seaareFills = layers.filter(isSeaareFill);
29150
- const rest = layers.filter((l) => !isLndareFill(l) && !isSeaareFill(l));
29151
- return [rest[0], ...seaareFills, ...lndareFills, ...rest.slice(1)];
30016
+ const relocated = layers.filter((l) => isLndareFill(l) || isWaterBaseFill(l));
30017
+ const others = layers.filter((l) => !isLndareFill(l) && !isWaterBaseFill(l));
30018
+ const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
30019
+ const seaare = others.filter(isSeaareFill);
30020
+ const rest = others.filter((l) => !isSeaareFill(l));
30021
+ const isBaseLayer = (l) => {
30022
+ const id = l.id;
30023
+ if (id.startsWith("chrtbase-inland-")) return false;
30024
+ return id.startsWith("chrtbase-") || id === "s52-coverage-ground";
30025
+ };
30026
+ const baseBlock = rest.filter(isBaseLayer);
30027
+ const restAbove = rest.filter((l) => !isBaseLayer(l));
30028
+ const bands = [...new Set(relocated.map(bandOf))].sort((a, b) => a - b);
30029
+ const oneTan = landBaseColor(palette);
30030
+ const asBaseTan = (l) => ({
30031
+ ...l,
30032
+ paint: {
30033
+ ...l.paint ?? {},
30034
+ "fill-color": oneTan
30035
+ }
30036
+ });
30037
+ const ordered = [];
30038
+ for (const b of bands) {
30039
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isWaterBaseFill(l)));
30040
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isLndareFill(l)).map(asBaseTan));
30041
+ }
30042
+ const findRest = (id) => restAbove.findIndex((l) => l.id === id);
30043
+ const shoreAt = findRest("chrtbase-inland-shore");
30044
+ const pairAt = shoreAt !== -1 ? shoreAt : findRest("chrtbase-inland-hatch");
30045
+ let restFinal = restAbove;
30046
+ if (pairAt !== -1) {
30047
+ const repaint = [];
30048
+ for (const b of bands) {
30049
+ for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
30050
+ const src = l;
30051
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
30052
+ const notDom = {
30053
+ ...l,
30054
+ id: `${src.id}-overpair`,
30055
+ filter: ["all", src.filter, NOT_DOMINATED_FILTER],
30056
+ metadata: meta
30057
+ };
30058
+ delete notDom.maxzoom;
30059
+ delete notDom.minzoom;
30060
+ repaint.push(notDom);
30061
+ for (const D of BAND_DRAW_ORDER) {
30062
+ if (parseInt(D, 10) <= b) continue;
30063
+ const dMaxzoom = dominatedVariantMaxzoom(D);
30064
+ if (dMaxzoom <= 0) continue;
30065
+ const domVariant = {
30066
+ ...l,
30067
+ id: `${src.id}-overpair-domBy${D}`,
30068
+ filter: ["all", src.filter, dominatedByMinFilter(D)],
30069
+ metadata: meta,
30070
+ maxzoom: dMaxzoom
30071
+ };
30072
+ delete domVariant.minzoom;
30073
+ repaint.push(domVariant);
30074
+ }
30075
+ }
30076
+ }
30077
+ const isCoalneLine = (l) => /^s52-COALNE-line-band\d(-domBy\d)?$/.test(l.id);
30078
+ for (const l of layers.filter(isCoalneLine)) {
30079
+ const src = l;
30080
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
30081
+ const banded = {
30082
+ ...l,
30083
+ id: `${src.id}-overpair`,
30084
+ metadata: meta,
30085
+ minzoom: 9
30086
+ };
30087
+ repaint.push(banded);
30088
+ const terminal = {
30089
+ ...l,
30090
+ id: `${src.id}-overpair-terminal`,
30091
+ filter: ["all", src.filter, ["==", ["get", "_terminal_shore"], true]],
30092
+ metadata: meta
30093
+ };
30094
+ delete terminal.minzoom;
30095
+ terminal.maxzoom = 9;
30096
+ repaint.push(terminal);
30097
+ }
30098
+ restFinal = [
30099
+ ...restAbove.slice(0, pairAt + 1),
30100
+ ...repaint,
30101
+ ...restAbove.slice(pairAt + 1)
30102
+ ];
30103
+ }
30104
+ return [restFinal[0], ...baseBlock, ...seaare, ...ordered, ...restFinal.slice(1)];
29152
30105
  })() : layers;
30106
+ for (const id of [
30107
+ "chrtbase-country-boundary",
30108
+ "chrtland-town-port",
30109
+ "chrtland-city-major"
30110
+ ]) {
30111
+ const at = emitted.findIndex(
30112
+ (l) => l.id === id
30113
+ );
30114
+ if (at !== -1) {
30115
+ const [layer] = emitted.splice(at, 1);
30116
+ emitted.push(layer);
30117
+ }
30118
+ }
29153
30119
  const style = {
29154
30120
  version: 8,
29155
30121
  name: `CHRT ${SYMBOL_SET_LABELS[symbolSet]} ${PALETTE_NAMES[palette]}${opts.chartMode ? ` \u2014 chart:${opts.chartMode.band}` : ""}`,
@@ -29161,6 +30127,7 @@ function generateStyle(palette = "DAY", opts = {}) {
29161
30127
  "chrt:layer-count": emitted.length,
29162
30128
  "chrt:land": Boolean(opts.land),
29163
30129
  "chrt:relief": Boolean(opts.relief),
30130
+ "chrt:base": Boolean(opts.base),
29164
30131
  "chrt:render-mode": opts.chartMode ? `chart:${opts.chartMode.band}` : "continuous",
29165
30132
  "chrt:description": "IHO S-52 portrayal rules for Electronic Navigational Charts (ENCs). Generated from the CHRT atlas engine."
29166
30133
  },
@@ -29654,6 +30621,7 @@ var Chrt = class {
29654
30621
  interact;
29655
30622
  featuresEp;
29656
30623
  searchEp;
30624
+ poiEp;
29657
30625
  queryEp;
29658
30626
  http;
29659
30627
  tileAuth;
@@ -29675,6 +30643,7 @@ var Chrt = class {
29675
30643
  this.weather = new WeatherEndpoint(this.http);
29676
30644
  this.featuresEp = new FeaturesEndpoint(this.http);
29677
30645
  this.searchEp = new SearchEndpoint(this.http);
30646
+ this.poiEp = new PoiEndpoint(this.http);
29678
30647
  this.queryEp = new QueryEndpoint(this.http);
29679
30648
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
29680
30649
  this.interact = new InteractClient(this.http);
@@ -29710,6 +30679,26 @@ var Chrt = class {
29710
30679
  async search(options) {
29711
30680
  return this.searchEp.find(options);
29712
30681
  }
30682
+ /**
30683
+ * CHRTpoi — charted marine facilities in a viewport or radius.
30684
+ *
30685
+ * v1 serves marinas, derived from the ENC's own HRBFAC facility class —
30686
+ * the chart's data, with the source cell's currency inline per POI.
30687
+ *
30688
+ * ```ts
30689
+ * const { pois, coverage } = await chrt.pois({ bbox: [-70.7, 41.4, -70.5, 41.6] });
30690
+ * ```
30691
+ *
30692
+ * **Read `coverage.state` before interpreting an empty list.** `covered` +
30693
+ * empty means no marinas are charted there — a real answer. `none` means
30694
+ * CHRT cannot answer for that area at all, and an empty list means nothing.
30695
+ * The two must never be shown the same way, and `coverage.message` is
30696
+ * written to be relayed unedited. Metered on its own allowance
30697
+ * (`poi_called`), separate from features and search; not gated by tier.
30698
+ */
30699
+ async pois(options) {
30700
+ return this.poiEp.list(options);
30701
+ }
29713
30702
  /** Spatial proximity query around a point */
29714
30703
  async query(options) {
29715
30704
  return this.queryEp.nearby(options);
@@ -29880,6 +30869,18 @@ var Chrt = class {
29880
30869
  return generateStyle(palette, {
29881
30870
  region: this.region,
29882
30871
  tileBaseUrl: this.tileBaseUrl,
30872
+ // Pin each source's tile URL to the build it serves, from whatever the
30873
+ // token cache already holds. Without a discriminator one URL is shared
30874
+ // by every build, so after a cutover a map keeps painting already-fetched
30875
+ // tiles from the OLD build beside newly-fetched ones from the new.
30876
+ //
30877
+ // BEFORE the spread, so an explicit `buildTags` wins — and only ever a
30878
+ // warm-cache read: this method is synchronous and must not mint. A cold
30879
+ // cache yields `{}`, i.e. exactly the URLs this SDK emitted before the
30880
+ // discriminator existed. Warm it with `chrt.transformRequest()` (which
30881
+ // kicks off a mint) or `await chrt.styleUrl()`; `await chrt.warmTiles()`
30882
+ // is the explicit form, and it returns the map.
30883
+ buildTags: this.tileAuth.peekBuildTags(this.region),
29883
30884
  ...styleOpts,
29884
30885
  // The vessel's safety depth IS the style's safety contour — same number, same
29885
30886
  // meaning. Threading it here is what stops the chart shading at one depth while
@@ -29953,9 +30954,21 @@ var Chrt = class {
29953
30954
  * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
29954
30955
  * the cache automatically — but useful if you want to surface a mint error
29955
30956
  * (e.g. `region_not_licensed`, `tile_quota_exceeded`) before the map loads.
30957
+ *
30958
+ * Returns what it minted, so a caller building its own sources can take the
30959
+ * `buildTags` map straight from here:
30960
+ *
30961
+ * ```ts
30962
+ * const { buildTags } = await chrt.warmTiles();
30963
+ * map.addSource("chrt", buildSources({ region: "NOAA_ENC", buildTags })["NOAA_ENC"]);
30964
+ * ```
30965
+ *
30966
+ * That pins each tile URL to the build it serves, so a build cutover gets its
30967
+ * own client cache namespace instead of leaving old and new tiles mixed on
30968
+ * screen. `chrt.style()` reads the same cache and needs no argument.
29956
30969
  */
29957
30970
  async warmTiles(region) {
29958
- await this.tileAuth.getToken(region);
30971
+ return this.tileAuth.getToken(region);
29959
30972
  }
29960
30973
  };
29961
30974
  // Annotate the CommonJS export names for ESM import in node:
@@ -29966,9 +30979,13 @@ var Chrt = class {
29966
30979
  ATON_EXPRESSION_CLASSES,
29967
30980
  BAND_DRAW_ORDER,
29968
30981
  BAND_PRIORITY_RANGES,
30982
+ BASE_MAXZOOM,
30983
+ BASE_MINZOOM,
30984
+ BASE_REGION,
29969
30985
  BRIDGE_QUERY_LAYERS,
29970
30986
  BUILD_MAXZOOM,
29971
30987
  BUILD_MINZOOM,
30988
+ BUILD_PARAM,
29972
30989
  CHART_BAND_CODES,
29973
30990
  CHART_CASCADE_CODES,
29974
30991
  CHART_SECTIONS,
@@ -30150,6 +31167,7 @@ var Chrt = class {
30150
31167
  scaleSizeValue,
30151
31168
  scaminToMinZoom,
30152
31169
  styleModeBand,
31170
+ tileTemplate,
30153
31171
  withinBboxFilter,
30154
31172
  worldMinusBboxPolygon
30155
31173
  });