@chrtco/sdk 0.10.17 → 0.10.19

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 = 8;
592
+ var RELIEF_MAXZOOM = 11;
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
  },
@@ -25972,7 +26149,7 @@ var S52_POINT_SVGS = {
25972
26149
  "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
26150
  "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
26151
  "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',
26152
+ "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
26153
  "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
26154
  "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
26155
  "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 +26664,8 @@ var CHRT_GLYPH_SVGS = {
26487
26664
  "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
26665
  "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
26666
  "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>',
26667
+ "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>',
26668
+ "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
26669
  "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
26670
  "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
26671
  "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 +27114,6 @@ function iconExpressionForFamily(objectClass, family) {
26935
27114
  }
26936
27115
  }
26937
27116
 
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
27117
  // src/atlas/layer-builder.ts
26992
27118
  var SCAMIN_DECLUTTER_CLASSES = /* @__PURE__ */ new Set([
26993
27119
  "OFSPLF",
@@ -27315,8 +27441,40 @@ function buildLayersForClass(objectClass, ct, sourceId = DEFAULT_REGION, opts =
27315
27441
  layer.layout = {
27316
27442
  ...layer.layout,
27317
27443
  "icon-allow-overlap": true,
27318
- "icon-ignore-placement": true
27444
+ "icon-ignore-placement": true,
27445
+ // Flare-specific size (owner ask 2026-08-31: "make the flares a
27446
+ // little smaller — they should scale down a lot as you zoom out").
27447
+ // The safety-critical curve serves buoy/beacon HULLS, whose set
27448
+ // this overrides for LIGHTS only: flares are annotations on a
27449
+ // point, not shapes a mariner ranges against, so they start small
27450
+ // at planning scales and only approach full size at berthing
27451
+ // zooms. Applied after the safety-critical spread so it wins.
27452
+ "icon-size": [
27453
+ "interpolate",
27454
+ ["linear"],
27455
+ ["zoom"],
27456
+ 6,
27457
+ 0.3,
27458
+ 12,
27459
+ 0.42,
27460
+ 14,
27461
+ 0.6,
27462
+ 18,
27463
+ 1
27464
+ ]
27465
+ };
27466
+ const valnmrNum = ["to-number", ["coalesce", ["get", "VALNMR"], ["get", "valnmr"], 0], 0];
27467
+ const isMajor = [">=", valnmrNum, 10];
27468
+ const majorLayer = {
27469
+ ...layer,
27470
+ id: `${layer.id}-major`,
27471
+ filter: layer.filter ? ["all", layer.filter, isMajor] : isMajor,
27472
+ layout: { ...layer.layout },
27473
+ minzoom: 6
27319
27474
  };
27475
+ layer.filter = layer.filter ? ["all", layer.filter, ["!", isMajor]] : ["!", isMajor];
27476
+ layer.minzoom = scaminToMinZoom(entry?.scamin) ?? layer.minzoom;
27477
+ layers.push(majorLayer);
27320
27478
  }
27321
27479
  } else if (resolved.layout) {
27322
27480
  layer.layout = resolved.layout;
@@ -28268,91 +28426,272 @@ function getOrderedClasses() {
28268
28426
  return ordered;
28269
28427
  }
28270
28428
 
28271
- // src/atlas/land-layers.ts
28272
- var FADE_PX = 80;
28273
- var FADE_BANDS = 16;
28274
- function fadeAlpha(r) {
28275
- return Math.max(0, Math.min(1, (FADE_PX / 2 - Math.abs(r)) / FADE_PX));
28429
+ // src/atlas/band-priority.ts
28430
+ var BAND_PRIORITY_RANGES = {
28431
+ "1": { minzoom: 0 },
28432
+ "2": { minzoom: 5 },
28433
+ "3": { minzoom: 7 },
28434
+ "4": { minzoom: 9 },
28435
+ "5": { minzoom: 11 },
28436
+ "6": { minzoom: 11 }
28437
+ };
28438
+ var BAND_DATA_MINZOOM = {
28439
+ "1": 0,
28440
+ "2": 5,
28441
+ "3": 7,
28442
+ "4": 9,
28443
+ "5": 11,
28444
+ "6": 13
28445
+ };
28446
+ function dominatedVariantMaxzoom(D) {
28447
+ return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
28276
28448
  }
28277
- var LAND_THEMES = {
28278
- DAY: {
28279
- base: "#e8d9b0",
28280
- cover: { wood: "#cfd8b4", wetland: "#c9d3c4", urban: "#dedad2", sand: "#efe4c2" },
28281
- contour: "#a89878",
28282
- contourIndex: "#8a7a5c",
28283
- road: "#d9c9a4",
28284
- roadMajor: "#d2bd8e",
28285
- label: "#5b4f3a",
28286
- halo: "#f2ead6",
28287
- shadow: "#6b5a3e",
28288
- highlight: "#fffaf0",
28289
- fade: "#e8f2f8",
28290
- // Raised from 0.45 on 2026-08-23. ⚠ Above the ~0.5 that
28291
- // hillshade-style.mjs warns "starts competing with depth areas" — a
28292
- // deliberate, sighted choice made against rendered evidence, not a
28293
- // default. If soundings or DEPARE ever read as fighting the terrain,
28294
- // this number is the first thing to walk back.
28295
- exaggeration: 0.8
28296
- },
28297
- DUSK: {
28298
- base: "#4a4433",
28299
- cover: { wood: "#414a38", wetland: "#3f4740", urban: "#4e4a42", sand: "#544c39" },
28300
- contour: "#6b6250",
28301
- contourIndex: "#837961",
28302
- road: "#565040",
28303
- roadMajor: "#63594440",
28304
- label: "#c3b898",
28305
- halo: "#2b2820",
28306
- shadow: "#2e2a20",
28307
- highlight: "#8a8068",
28308
- fade: "#16202a",
28309
- // Unchanged. DUSK was not the palette the 0.8 decision was made against.
28310
- exaggeration: 0.45
28311
- },
28312
- NIGHT: {
28313
- base: "#2a2620",
28314
- cover: { wood: "#242a20", wetland: "#232823", urban: "#2c2925", sand: "#302b22" },
28315
- contour: "#413b30",
28316
- contourIndex: "#524a3b",
28317
- road: "#332f27",
28318
- roadMajor: "#3d382d",
28319
- label: "#7d745e",
28320
- halo: "#15130f",
28321
- shadow: "#1a1712",
28322
- highlight: "#4a4436",
28323
- fade: "#07090b",
28324
- // ⚠ DELIBERATELY NOT RAISED. See the type's note: a strong hillshade
28325
- // under red night vision is a grey haze over exactly the contrast a
28326
- // skipper is navigating by. Left at the shipped value rather than
28327
- // lowered, because lowering it is a separate decision from this one.
28328
- exaggeration: 0.45
28449
+ var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
28450
+ "LNDARE",
28451
+ "DEPARE",
28452
+ "UNSARE",
28453
+ "LAKARE",
28454
+ "DRGARE",
28455
+ "SEAARE"
28456
+ ]);
28457
+ function isExemptOpaqueFill(layer) {
28458
+ if (layer.type !== "fill") return false;
28459
+ for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
28460
+ if (layer.id.startsWith(`s52-${cls}-`)) return true;
28329
28461
  }
28330
- };
28331
- var FITS = ["<=", ["coalesce", ["get", "min_z"], 0], ["zoom"]];
28332
- var ANCHOR = ["coalesce", ["get", "anchor"], "top"];
28333
- var anchorOffset = (em) => [
28334
- "match",
28335
- ["get", "anchor"],
28336
- "left",
28337
- ["literal", [0.6, 0]],
28338
- "right",
28339
- ["literal", [-0.6, 0]],
28340
- "bottom",
28341
- ["literal", [0, -em]],
28342
- ["literal", [0, em]]
28343
- ];
28344
- var FONT = ["Open Sans Regular"];
28345
- var FONT_ITALIC = ["Open Sans Italic"];
28346
- function landLayers(opts = {}) {
28347
- const palette = opts.palette ?? "DAY";
28348
- const T = LAND_THEMES[palette] ?? LAND_THEMES.DAY;
28349
- const src = opts.sourceId ?? LAND_REGION;
28350
- const demSrc = opts.reliefSourceId ?? RELIEF_REGION;
28351
- const L = (o) => o;
28352
- const layers = [
28353
- // 1. The land itself — LNDARE ∩ coverage, so it can never outrun the chart.
28354
- L({
28355
- id: "chrtland-base",
28462
+ return false;
28463
+ }
28464
+ var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28465
+ "1",
28466
+ "2",
28467
+ "3"
28468
+ ]);
28469
+ function bandVariantMinzoom(layer, B) {
28470
+ const classMinzoom = layer.minzoom ?? 0;
28471
+ if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28472
+ return classMinzoom;
28473
+ }
28474
+ return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
28475
+ }
28476
+ var GENERALISED_AREA_FILL_CLASSES = /* @__PURE__ */ new Set([
28477
+ "BUAARE",
28478
+ // 2026-08-28, New York Harbor: the "New York Marine Inspection Zone and
28479
+ // Captain of the Port Zone" RESARE exists at bands 2/3 as a generalized
28480
+ // shore-hugging polygon whose point-sampled dominance tag MISSED (the
28481
+ // Providence-BUAARE failure mode), so its magenta dashed boundary drew
28482
+ // OFFSET over the band-5 detail at z11.5 — read in the field as "a
28483
+ // magenta shoreline bleeding down from a less coarse band". The same
28484
+ // tile carried untagged band-2 ADMARE and CTNARE giants in the same
28485
+ // state. All three are area classes whose coarse-band boundaries hug
28486
+ // shorelines and whose fine-band copies are authoritative.
28487
+ "RESARE",
28488
+ "CTNARE",
28489
+ "ADMARE"
28490
+ ]);
28491
+ var GENERALISED_AREA_MAX_BAND = 3;
28492
+ function generalisedAreaCeiling(layer, B) {
28493
+ if (layer.type !== "fill" && layer.type !== "line") return null;
28494
+ if (parseInt(B, 10) > GENERALISED_AREA_MAX_BAND) return null;
28495
+ for (const cls of GENERALISED_AREA_FILL_CLASSES) {
28496
+ if (layer.id.startsWith(`s52-${cls}-`)) return BAND_PRIORITY_RANGES["5"].minzoom;
28497
+ }
28498
+ return null;
28499
+ }
28500
+ var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
28501
+ var CHART_BAND_CODES = {
28502
+ Overview: ["1"],
28503
+ General: ["2"],
28504
+ Coastal: ["3"],
28505
+ Approach: ["4"],
28506
+ Harbor: ["5", "6"]
28507
+ };
28508
+ var CHART_CASCADE_CODES = {
28509
+ Overview: ["1"],
28510
+ General: ["1", "2"],
28511
+ Coastal: ["1", "2", "3"],
28512
+ Approach: ["1", "2", "3", "4"],
28513
+ Harbor: ["1", "2", "3", "4", "5", "6"]
28514
+ };
28515
+ var CHRT_STYLE_MODES = [
28516
+ "continuous",
28517
+ ...Object.keys(CHART_BAND_CODES).map(
28518
+ (band) => `chart:${band}`
28519
+ )
28520
+ ];
28521
+ function normalizeStyleMode(raw) {
28522
+ if (raw === null || raw === void 0 || raw === "") return "continuous";
28523
+ const lower = raw.toLowerCase();
28524
+ return CHRT_STYLE_MODES.find((mode) => mode.toLowerCase() === lower) ?? null;
28525
+ }
28526
+ function styleModeBand(mode) {
28527
+ return mode === "continuous" ? null : mode.slice("chart:".length);
28528
+ }
28529
+ function bandFilterExpression(codes) {
28530
+ const list = Array.isArray(codes) ? codes : [codes];
28531
+ if (list.length === 1) {
28532
+ return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
28533
+ }
28534
+ return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
28535
+ }
28536
+ function combineWithBandFilter(baseFilter, bandFilter) {
28537
+ if (!baseFilter) return bandFilter;
28538
+ if (baseFilter[0] === "all") {
28539
+ return ["all", ...baseFilter.slice(1), bandFilter];
28540
+ }
28541
+ return ["all", baseFilter, bandFilter];
28542
+ }
28543
+ var NOT_DOMINATED_FILTER = [
28544
+ "all",
28545
+ ["!", ["has", "_dominated_by_min"]],
28546
+ ["!=", ["get", "_dominated"], true]
28547
+ ];
28548
+ function dominatedByMinFilter(D) {
28549
+ const dInt = parseInt(D, 10);
28550
+ if (D === "6") {
28551
+ return [
28552
+ "any",
28553
+ ["==", ["get", "_dominated_by_min"], dInt],
28554
+ ["==", ["get", "_dominated"], true]
28555
+ ];
28556
+ }
28557
+ return ["==", ["get", "_dominated_by_min"], dInt];
28558
+ }
28559
+ function withinBboxFilter(bbox) {
28560
+ const [w, s] = bbox.sw;
28561
+ const [e, n] = bbox.ne;
28562
+ return [
28563
+ "within",
28564
+ {
28565
+ type: "Polygon",
28566
+ coordinates: [
28567
+ [
28568
+ [w, s],
28569
+ [e, s],
28570
+ [e, n],
28571
+ [w, n],
28572
+ [w, s]
28573
+ ]
28574
+ ]
28575
+ }
28576
+ ];
28577
+ }
28578
+ function worldMinusBboxPolygon(bbox) {
28579
+ const [w, s] = bbox.sw;
28580
+ const [e, n] = bbox.ne;
28581
+ return {
28582
+ type: "Polygon",
28583
+ coordinates: [
28584
+ [
28585
+ [-180, -85],
28586
+ [180, -85],
28587
+ [180, 85],
28588
+ [-180, 85],
28589
+ [-180, -85]
28590
+ ],
28591
+ // Inner ring (hole) — winding opposite to outer ring per GeoJSON
28592
+ // spec. MapLibre is permissive but we follow strict winding.
28593
+ [
28594
+ [w, s],
28595
+ [w, n],
28596
+ [e, n],
28597
+ [e, s],
28598
+ [w, s]
28599
+ ]
28600
+ ]
28601
+ };
28602
+ }
28603
+
28604
+ // src/atlas/land-layers.ts
28605
+ var LAND_SURFACE_MINZOOM = BAND_PRIORITY_RANGES["5"].minzoom;
28606
+ var RELIEF_MINZOOM2 = 10;
28607
+ var LAND_SURFACE_LAYERS = (id) => id === "chrtland-base" || id === "chrtland-cover" || id === "chrtland-contour" || id.startsWith("chrtland-fade-");
28608
+ var FADE_PX = 80;
28609
+ var FADE_BANDS = 16;
28610
+ function fadeAlpha(r) {
28611
+ return Math.max(0, Math.min(1, (FADE_PX / 2 - Math.abs(r)) / FADE_PX));
28612
+ }
28613
+ function landBaseColor(palette) {
28614
+ return (LAND_THEMES[palette] ?? LAND_THEMES.DAY).base;
28615
+ }
28616
+ var LAND_THEMES = {
28617
+ DAY: {
28618
+ base: "#e8d9b0",
28619
+ cover: { wood: "#cfd8b4", wetland: "#c9d3c4", urban: "#dedad2", sand: "#efe4c2" },
28620
+ contour: "#a89878",
28621
+ contourIndex: "#8a7a5c",
28622
+ road: "#d9c9a4",
28623
+ roadMajor: "#d2bd8e",
28624
+ label: "#5b4f3a",
28625
+ halo: "#f2ead6",
28626
+ shadow: "#6b5a3e",
28627
+ highlight: "#fffaf0",
28628
+ fade: "#e8f2f8",
28629
+ // Raised from 0.45 on 2026-08-23. ⚠ Above the ~0.5 that
28630
+ // hillshade-style.mjs warns "starts competing with depth areas" — a
28631
+ // deliberate, sighted choice made against rendered evidence, not a
28632
+ // default. If soundings or DEPARE ever read as fighting the terrain,
28633
+ // this number is the first thing to walk back.
28634
+ exaggeration: 0.8
28635
+ },
28636
+ DUSK: {
28637
+ base: "#4a4433",
28638
+ cover: { wood: "#414a38", wetland: "#3f4740", urban: "#4e4a42", sand: "#544c39" },
28639
+ contour: "#6b6250",
28640
+ contourIndex: "#837961",
28641
+ road: "#565040",
28642
+ roadMajor: "#63594440",
28643
+ label: "#c3b898",
28644
+ halo: "#2b2820",
28645
+ shadow: "#2e2a20",
28646
+ highlight: "#8a8068",
28647
+ fade: "#16202a",
28648
+ // Unchanged. DUSK was not the palette the 0.8 decision was made against.
28649
+ exaggeration: 0.45
28650
+ },
28651
+ NIGHT: {
28652
+ base: "#2a2620",
28653
+ cover: { wood: "#242a20", wetland: "#232823", urban: "#2c2925", sand: "#302b22" },
28654
+ contour: "#413b30",
28655
+ contourIndex: "#524a3b",
28656
+ road: "#332f27",
28657
+ roadMajor: "#3d382d",
28658
+ label: "#7d745e",
28659
+ halo: "#15130f",
28660
+ shadow: "#1a1712",
28661
+ highlight: "#4a4436",
28662
+ fade: "#07090b",
28663
+ // ⚠ DELIBERATELY NOT RAISED. See the type's note: a strong hillshade
28664
+ // under red night vision is a grey haze over exactly the contrast a
28665
+ // skipper is navigating by. Left at the shipped value rather than
28666
+ // lowered, because lowering it is a separate decision from this one.
28667
+ exaggeration: 0.45
28668
+ }
28669
+ };
28670
+ var FITS = ["<=", ["coalesce", ["get", "min_z"], 0], ["zoom"]];
28671
+ var ANCHOR = ["coalesce", ["get", "anchor"], "top"];
28672
+ var anchorOffset = (em) => [
28673
+ "match",
28674
+ ["get", "anchor"],
28675
+ "left",
28676
+ ["literal", [0.6, 0]],
28677
+ "right",
28678
+ ["literal", [-0.6, 0]],
28679
+ "bottom",
28680
+ ["literal", [0, -em]],
28681
+ ["literal", [0, em]]
28682
+ ];
28683
+ var FONT = ["Open Sans Regular"];
28684
+ var FONT_ITALIC = ["Open Sans Italic"];
28685
+ function landLayers(opts = {}) {
28686
+ const palette = opts.palette ?? "DAY";
28687
+ const T = LAND_THEMES[palette] ?? LAND_THEMES.DAY;
28688
+ const src = opts.sourceId ?? LAND_REGION;
28689
+ const demSrc = opts.reliefSourceId ?? RELIEF_REGION;
28690
+ const L = (o) => o;
28691
+ const layers = [
28692
+ // 1. The land itself — LNDARE ∩ coverage, so it can never outrun the chart.
28693
+ L({
28694
+ id: "chrtland-base",
28356
28695
  type: "fill",
28357
28696
  source: src,
28358
28697
  "source-layer": "land_base",
@@ -28387,6 +28726,7 @@ function landLayers(opts = {}) {
28387
28726
  id: "chrtland-hillshade",
28388
28727
  type: "hillshade",
28389
28728
  source: demSrc,
28729
+ minzoom: RELIEF_MINZOOM2,
28390
28730
  paint: {
28391
28731
  "hillshade-exaggeration": T.exaggeration,
28392
28732
  "hillshade-shadow-color": T.shadow,
@@ -28438,13 +28778,78 @@ function landLayers(opts = {}) {
28438
28778
  "line-opacity": 0.8
28439
28779
  }
28440
28780
  }));
28781
+ layers.push(L({
28782
+ id: "chrtland-city-major",
28783
+ type: "symbol",
28784
+ source: src,
28785
+ "source-layer": "land_town",
28786
+ minzoom: 6.5,
28787
+ filter: [">=", ["coalesce", ["get", "prank"], 0], 10],
28788
+ layout: {
28789
+ "text-field": [
28790
+ "case",
28791
+ ["==", ["get", "upcase"], true],
28792
+ ["upcase", ["get", "name"]],
28793
+ ["get", "name"]
28794
+ ],
28795
+ "text-font": FONT,
28796
+ "text-size": [
28797
+ "interpolate",
28798
+ ["linear"],
28799
+ ["zoom"],
28800
+ 6.5,
28801
+ 11.5,
28802
+ 9,
28803
+ ["+", 10, ["*", 0.4, ["coalesce", ["get", "prank"], 10]]],
28804
+ 13,
28805
+ ["+", 13, ["*", 0.5, ["coalesce", ["get", "prank"], 10]]]
28806
+ ],
28807
+ "text-letter-spacing": ["case", ["==", ["get", "upcase"], true], 0.14, 0.02],
28808
+ "text-offset": anchorOffset(0.75),
28809
+ "text-anchor": ANCHOR,
28810
+ "text-padding": 10,
28811
+ "text-allow-overlap": false,
28812
+ "symbol-sort-key": ["*", -1, ["coalesce", ["get", "pop"], 0]]
28813
+ },
28814
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.6 }
28815
+ }));
28816
+ layers.push(L({
28817
+ id: "chrtland-town-port",
28818
+ type: "symbol",
28819
+ source: src,
28820
+ "source-layer": "land_town",
28821
+ minzoom: 8.5,
28822
+ filter: [
28823
+ "all",
28824
+ ["==", ["get", "enc_dup"], true],
28825
+ ["<", ["coalesce", ["get", "prank"], 0], 10]
28826
+ ],
28827
+ layout: {
28828
+ "text-field": ["get", "name"],
28829
+ "text-font": FONT,
28830
+ "text-size": ["interpolate", ["linear"], ["zoom"], 8.5, 11, 12, 13.5],
28831
+ "text-offset": anchorOffset(0.75),
28832
+ "text-anchor": ANCHOR,
28833
+ "text-padding": 8,
28834
+ "text-allow-overlap": false,
28835
+ // Within the tier, the bigger place wins the space: without this the
28836
+ // tie broke on data order and "West Gloucester" beat Gloucester.
28837
+ "symbol-sort-key": ["*", -1, ["coalesce", ["get", "pop"], 0]]
28838
+ },
28839
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.4 }
28840
+ }));
28441
28841
  layers.push(L({
28442
28842
  id: "chrtland-town",
28443
28843
  type: "symbol",
28444
28844
  source: src,
28445
28845
  "source-layer": "land_town",
28446
28846
  minzoom: 9,
28447
- filter: FITS,
28847
+ filter: [
28848
+ "all",
28849
+ FITS,
28850
+ ["<", ["coalesce", ["get", "prank"], 0], 10],
28851
+ ["!=", ["get", "enc_dup"], true]
28852
+ ],
28448
28853
  layout: {
28449
28854
  "text-field": [
28450
28855
  "case",
@@ -28477,187 +28882,316 @@ function landLayers(opts = {}) {
28477
28882
  "text-offset": anchorOffset(0.8),
28478
28883
  "text-anchor": ANCHOR,
28479
28884
  "text-padding": 5,
28480
- "text-allow-overlap": false,
28481
- "symbol-sort-key": 3
28482
- },
28483
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28484
- }));
28485
- layers.push(L({
28486
- id: "chrtland-poi",
28487
- type: "symbol",
28488
- source: src,
28489
- "source-layer": "land_poi",
28490
- minzoom: 13,
28491
- filter: FITS,
28492
- layout: {
28493
- "text-field": ["get", "name"],
28494
- "text-font": FONT_ITALIC,
28495
- "text-size": ["interpolate", ["linear"], ["zoom"], 13, 8, 14, 9.5],
28496
- "text-offset": anchorOffset(0.7),
28497
- "text-anchor": ANCHOR,
28498
- "text-padding": 5,
28499
- "text-allow-overlap": false,
28500
- "symbol-sort-key": 5
28501
- },
28502
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28503
- }));
28504
- return layers;
28505
- }
28506
-
28507
- // src/atlas/band-priority.ts
28508
- var BAND_PRIORITY_RANGES = {
28509
- "1": { minzoom: 0 },
28510
- "2": { minzoom: 5 },
28511
- "3": { minzoom: 7 },
28512
- "4": { minzoom: 9 },
28513
- "5": { minzoom: 11 },
28514
- "6": { minzoom: 11 }
28515
- };
28516
- var BAND_DATA_MINZOOM = {
28517
- "1": 0,
28518
- "2": 5,
28519
- "3": 7,
28520
- "4": 9,
28521
- "5": 11,
28522
- "6": 13
28523
- };
28524
- function dominatedVariantMaxzoom(D) {
28525
- return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
28526
- }
28527
- var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
28528
- "LNDARE",
28529
- "DEPARE",
28530
- "UNSARE",
28531
- "LAKARE",
28532
- "DRGARE",
28533
- "SEAARE"
28534
- ]);
28535
- function isExemptOpaqueFill(layer) {
28536
- if (layer.type !== "fill") return false;
28537
- for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
28538
- if (layer.id.startsWith(`s52-${cls}-`)) return true;
28885
+ "text-allow-overlap": false,
28886
+ "symbol-sort-key": 3
28887
+ },
28888
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28889
+ }));
28890
+ layers.push(L({
28891
+ id: "chrtland-poi",
28892
+ type: "symbol",
28893
+ source: src,
28894
+ "source-layer": "land_poi",
28895
+ minzoom: 13,
28896
+ filter: FITS,
28897
+ layout: {
28898
+ "text-field": ["get", "name"],
28899
+ "text-font": FONT_ITALIC,
28900
+ "text-size": ["interpolate", ["linear"], ["zoom"], 13, 8, 14, 9.5],
28901
+ "text-offset": anchorOffset(0.7),
28902
+ "text-anchor": ANCHOR,
28903
+ "text-padding": 5,
28904
+ "text-allow-overlap": false,
28905
+ "symbol-sort-key": 5
28906
+ },
28907
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28908
+ }));
28909
+ for (const layer of layers) {
28910
+ const l = layer;
28911
+ if (!LAND_SURFACE_LAYERS(l.id)) continue;
28912
+ l.minzoom = Math.max(l.minzoom ?? 0, LAND_SURFACE_MINZOOM);
28539
28913
  }
28540
- return false;
28914
+ return layers;
28541
28915
  }
28542
- var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28543
- "1",
28544
- "2",
28545
- "3"
28546
- ]);
28547
- function bandVariantMinzoom(layer, B) {
28548
- const classMinzoom = layer.minzoom ?? 0;
28549
- if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28550
- return classMinzoom;
28916
+
28917
+ // src/atlas/base-layers.ts
28918
+ var NOCOVERAGE_PATTERN = "chrt:hatch-nocoverage";
28919
+ var UNCHARTED_PATTERN = "chrt:hatch-uncharted";
28920
+ var BASE_THEMES = {
28921
+ DAY: {
28922
+ earth: "#2A3439",
28923
+ water: "#1B2328",
28924
+ river: "#24313A",
28925
+ boundary: "#4A575E",
28926
+ jurisdiction: "#E8D9B0",
28927
+ // CHRTland's own tan — one land colour
28928
+ inlandWater: "#B9CFDD",
28929
+ // muted, unmistakably not a charted blue
28930
+ // State furniture. These three were referenced by the layers but missing
28931
+ // from this table (and its type — the long-standing TS2339 trio), so the
28932
+ // lines shipped on MapLibre's DEFAULT black. Now deliberate: a hairline
28933
+ // a shade deeper than the tan, quiet caps, tan halo.
28934
+ stateLine: "#C4B389",
28935
+ stateLabel: "#8E8468",
28936
+ stateHalo: "#E8D9B0",
28937
+ // Rough-lake furniture (owner ask 2026-08-28: "the lakes need labels,
28938
+ // and shorelines drawn"): a shoreline a shade deeper than the rough
28939
+ // water, and a name in the water's own voice, haloed by the water.
28940
+ inlandShore: "#8FA9BA",
28941
+ inlandLabel: "#54707F",
28942
+ inlandHalo: "#B9CFDD"
28943
+ },
28944
+ DUSK: {
28945
+ earth: "#232B30",
28946
+ water: "#161D21",
28947
+ river: "#1E2930",
28948
+ boundary: "#3E4A50",
28949
+ jurisdiction: "#C9BC96",
28950
+ inlandWater: "#93A9B7",
28951
+ stateLine: "#A6976F",
28952
+ stateLabel: "#776E55",
28953
+ stateHalo: "#C9BC96",
28954
+ inlandShore: "#74899A",
28955
+ inlandLabel: "#41586A",
28956
+ inlandHalo: "#93A9B7"
28957
+ },
28958
+ NIGHT: {
28959
+ earth: "#1A2125",
28960
+ water: "#10161A",
28961
+ river: "#182228",
28962
+ boundary: "#333E44",
28963
+ jurisdiction: "#8F866C",
28964
+ inlandWater: "#5F7280",
28965
+ stateLine: "#6B6350",
28966
+ stateLabel: "#565040",
28967
+ stateHalo: "#8F866C",
28968
+ inlandShore: "#4A5C68",
28969
+ inlandLabel: "#8CA2B0",
28970
+ inlandHalo: "#5F7280"
28551
28971
  }
28552
- return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
28553
- }
28554
- var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
28555
- var CHART_BAND_CODES = {
28556
- Overview: ["1"],
28557
- General: ["2"],
28558
- Coastal: ["3"],
28559
- Approach: ["4"],
28560
- Harbor: ["5", "6"]
28561
- };
28562
- var CHART_CASCADE_CODES = {
28563
- Overview: ["1"],
28564
- General: ["1", "2"],
28565
- Coastal: ["1", "2", "3"],
28566
- Approach: ["1", "2", "3", "4"],
28567
- Harbor: ["1", "2", "3", "4", "5", "6"]
28568
28972
  };
28569
- var CHRT_STYLE_MODES = [
28570
- "continuous",
28571
- ...Object.keys(CHART_BAND_CODES).map(
28572
- (band) => `chart:${band}`
28573
- )
28574
- ];
28575
- function normalizeStyleMode(raw) {
28576
- if (raw === null || raw === void 0 || raw === "") return "continuous";
28577
- const lower = raw.toLowerCase();
28578
- return CHRT_STYLE_MODES.find((mode) => mode.toLowerCase() === lower) ?? null;
28579
- }
28580
- function styleModeBand(mode) {
28581
- return mode === "continuous" ? null : mode.slice("chart:".length);
28582
- }
28583
- function bandFilterExpression(codes) {
28584
- const list = Array.isArray(codes) ? codes : [codes];
28585
- if (list.length === 1) {
28586
- return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
28587
- }
28588
- return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
28589
- }
28590
- function combineWithBandFilter(baseFilter, bandFilter) {
28591
- if (!baseFilter) return bandFilter;
28592
- if (baseFilter[0] === "all") {
28593
- return ["all", ...baseFilter.slice(1), bandFilter];
28594
- }
28595
- return ["all", baseFilter, bandFilter];
28596
- }
28597
- var NOT_DOMINATED_FILTER = [
28598
- "all",
28599
- ["!", ["has", "_dominated_by_min"]],
28600
- ["!=", ["get", "_dominated"], true]
28601
- ];
28602
- function dominatedByMinFilter(D) {
28603
- const dInt = parseInt(D, 10);
28604
- if (D === "6") {
28605
- return [
28606
- "any",
28607
- ["==", ["get", "_dominated_by_min"], dInt],
28608
- ["==", ["get", "_dominated"], true]
28609
- ];
28610
- }
28611
- return ["==", ["get", "_dominated_by_min"], dInt];
28612
- }
28613
- function withinBboxFilter(bbox) {
28614
- const [w, s] = bbox.sw;
28615
- const [e, n] = bbox.ne;
28973
+ function baseLayers(opts = {}) {
28974
+ const palette = opts.palette ?? "DAY";
28975
+ const T = BASE_THEMES[palette] ?? BASE_THEMES.DAY;
28976
+ const src = opts.sourceId ?? BASE_REGION;
28977
+ const L = (o) => o;
28616
28978
  return [
28617
- "within",
28618
- {
28619
- type: "Polygon",
28620
- coordinates: [
28621
- [
28622
- [w, s],
28623
- [e, s],
28624
- [e, n],
28625
- [w, n],
28626
- [w, s]
28627
- ]
28628
- ]
28629
- }
28979
+ // 1. Geography — the world as it is, in the quiet dark voice. The OCEAN
28980
+ // is a background, not a polygon: the archive ships land and lakes,
28981
+ // and "everything else is sea" is exactly what a background expresses
28982
+ // — geometry-free, gap-free at every antimeridian and pole. Without
28983
+ // it the sea outside coverage was bare S-52 NODTA, a light grey that
28984
+ // read as "charted ground" with a hatch on it.
28985
+ L({
28986
+ id: "chrtbase-ocean",
28987
+ type: "background",
28988
+ paint: { "background-color": T.water }
28989
+ }),
28990
+ L({
28991
+ id: "chrtbase-earth",
28992
+ type: "fill",
28993
+ source: src,
28994
+ "source-layer": "base_earth",
28995
+ paint: { "fill-color": T.earth }
28996
+ }),
28997
+ L({
28998
+ id: "chrtbase-water",
28999
+ type: "fill",
29000
+ source: src,
29001
+ "source-layer": "base_water",
29002
+ paint: { "fill-color": T.water }
29003
+ }),
29004
+ L({
29005
+ id: "chrtbase-river",
29006
+ type: "line",
29007
+ source: src,
29008
+ "source-layer": "base_river",
29009
+ minzoom: 9,
29010
+ paint: {
29011
+ "line-color": T.river,
29012
+ "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.6, 14, 1.6]
29013
+ }
29014
+ }),
29015
+ L({
29016
+ id: "chrtbase-boundary",
29017
+ type: "line",
29018
+ source: src,
29019
+ "source-layer": "base_boundary",
29020
+ paint: { "line-color": T.boundary, "line-width": 0.6, "line-opacity": 0.8 }
29021
+ }),
29022
+ // 2. THE DEFAULT: no coverage, everywhere, as a geometry-free background.
29023
+ // Constant opacity by design — see the header. What kills it is being
29024
+ // painted OVER, never fading.
29025
+ L({
29026
+ id: "chrtbase-nocoverage",
29027
+ type: "background",
29028
+ paint: { "background-pattern": NOCOVERAGE_PATTERN, "background-opacity": 0.55 }
29029
+ }),
29030
+ // 3. The jurisdiction: countries whose charts CHRT carries go tan — the
29031
+ // same tan as CHRTland, so "land in our world" is ONE colour. Opaque,
29032
+ // so it takes the crosshatch off its ground entirely.
29033
+ L({
29034
+ id: "chrtbase-jurisdiction",
29035
+ type: "fill",
29036
+ source: src,
29037
+ "source-layer": "jurisdiction",
29038
+ paint: { "fill-color": T.jurisdiction }
29039
+ }),
29040
+ // 4. States — internal boundaries and names, inside the jurisdiction
29041
+ // only (owner decision 2026-08-28). A hairline a shade deeper than the
29042
+ // tan, and quiet letter-spaced caps that fade as harbour detail takes
29043
+ // over. The one deliberate exception to no-names-on-the-basemap:
29044
+ // ADMIN furniture, nothing else — see the fence test.
29045
+ L({
29046
+ id: "chrtbase-state-boundary",
29047
+ type: "line",
29048
+ source: src,
29049
+ "source-layer": "state_boundary",
29050
+ minzoom: 4,
29051
+ paint: {
29052
+ "line-color": T.stateLine,
29053
+ "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.5, 10, 1],
29054
+ "line-dasharray": [3, 2],
29055
+ "line-opacity": 0.8
29056
+ }
29057
+ }),
29058
+ // 5. Country borders, drawn to be HOISTED above the whole chart by the
29059
+ // generator (owner, 2026-08-31: "where there is charted land, can we
29060
+ // add country borders on top of everything? I want it clear where
29061
+ // there is a boarder."). Sourced from NE boundary_lines_land — land
29062
+ // borders only, never coastline — so riding on top cannot stroke a
29063
+ // coast. Dashed so it reads as an administrative line, not chart
29064
+ // linework.
29065
+ L({
29066
+ id: "chrtbase-country-boundary",
29067
+ type: "line",
29068
+ source: src,
29069
+ "source-layer": "country_boundary",
29070
+ paint: {
29071
+ "line-color": T.boundary,
29072
+ "line-width": ["interpolate", ["linear"], ["zoom"], 3, 0.8, 8, 1.4, 12, 2.2],
29073
+ "line-dasharray": [4, 2],
29074
+ "line-opacity": 0.9
29075
+ }
29076
+ }),
29077
+ L({
29078
+ id: "chrtbase-state-label",
29079
+ type: "symbol",
29080
+ source: src,
29081
+ "source-layer": "state_label",
29082
+ minzoom: 4,
29083
+ maxzoom: 10,
29084
+ layout: {
29085
+ "text-field": ["upcase", ["get", "name"]],
29086
+ "text-font": ["Open Sans Regular"],
29087
+ "text-size": ["interpolate", ["linear"], ["zoom"], 4, 9, 8, 12],
29088
+ "text-letter-spacing": 0.25,
29089
+ "text-allow-overlap": false,
29090
+ "text-padding": 8
29091
+ },
29092
+ paint: {
29093
+ "text-color": T.stateLabel,
29094
+ "text-halo-color": T.stateHalo,
29095
+ "text-halo-width": 1.2,
29096
+ "text-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.7, 8, 0.85, 10, 0.4]
29097
+ }
29098
+ })
28630
29099
  ];
28631
29100
  }
28632
- function worldMinusBboxPolygon(bbox) {
28633
- const [w, s] = bbox.sw;
28634
- const [e, n] = bbox.ne;
28635
- return {
28636
- type: "Polygon",
28637
- coordinates: [
28638
- [
28639
- [-180, -85],
28640
- [180, -85],
28641
- [180, 85],
28642
- [-180, 85],
28643
- [-180, -85]
28644
- ],
28645
- // Inner ring (hole) — winding opposite to outer ring per GeoJSON
28646
- // spec. MapLibre is permissive but we follow strict winding.
28647
- [
28648
- [w, s],
28649
- [w, n],
28650
- [e, n],
28651
- [e, s],
28652
- [w, s]
28653
- ]
28654
- ]
28655
- };
29101
+ function baseInlandWaterLayers(opts = {}) {
29102
+ const palette = opts.palette ?? "DAY";
29103
+ const T = BASE_THEMES[palette] ?? BASE_THEMES.DAY;
29104
+ const src = opts.sourceId ?? BASE_REGION;
29105
+ const L = (o) => o;
29106
+ return [
29107
+ L({
29108
+ id: "chrtbase-inland-water",
29109
+ type: "fill",
29110
+ source: src,
29111
+ "source-layer": "inland_water",
29112
+ paint: { "fill-color": T.inlandWater }
29113
+ }),
29114
+ L({
29115
+ id: "chrtbase-inland-hatch",
29116
+ type: "fill",
29117
+ source: src,
29118
+ "source-layer": "inland_water",
29119
+ paint: { "fill-pattern": UNCHARTED_PATTERN, "fill-opacity": 0.7 }
29120
+ }),
29121
+ // The rough shoreline (owner ask 2026-08-28). Drawn ABOVE the hatch so
29122
+ // the edge stays crisp through the texture, and BELOW the charted-water
29123
+ // repaint clones the generator splices in after it — so wherever the
29124
+ // chart repaints its own water, the rough NE10m line yields to the
29125
+ // chart's COALNE instead of double-striking it.
29126
+ //
29127
+ // NE-tier geometry ONLY (`kind` absent): the planet detail tier joins
29128
+ // the same source-layer from z9 as MANY polygons whose chunk-clip seams
29129
+ // rejoin invisibly as FILLS — but a line layer strokes every seam, and
29130
+ // Winnipesaukee rendered as a mesh of internal edges. Planet features
29131
+ // carry `kind` (the INLAND_KINDS filter's own field); NE's carry only
29132
+ // `name`, so the absence of `kind` IS the tier discriminator.
29133
+ L({
29134
+ id: "chrtbase-inland-shore",
29135
+ type: "line",
29136
+ source: src,
29137
+ "source-layer": "inland_water",
29138
+ filter: ["!", ["has", "kind"]],
29139
+ paint: {
29140
+ "line-color": T.inlandShore,
29141
+ "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.5, 10, 1.1, 14, 1.6],
29142
+ "line-opacity": 0.9
29143
+ }
29144
+ }),
29145
+ // The lake's name (owner ask 2026-08-28) — the second scoped exception
29146
+ // to no-names-on-the-basemap, after the states: WATER-BODY names only.
29147
+ // No maxzoom: an uncharted lake keeps its name at every zoom; over
29148
+ // charted lakes the chart's own SEAARE label takes over at detail zooms
29149
+ // and collision culls the echo.
29150
+ //
29151
+ // The archive's `inland_water_label` anchors (v3+). v3 shipped ONE
29152
+ // PointOnSurface anchor per lake, and a big lake's single anchor pans
29153
+ // off-viewport — Winnipesaukee at z11.7 read nameless with its name two
29154
+ // tiles west of the screen (owner-reported). Labelling the polygons
29155
+ // instead is NOT the fix: tippecanoe's coalescing drops the NE polygon
29156
+ // from some z11 tiles (measured: 11/619/747 carries only planet pieces),
29157
+ // so a polygon-sourced label vanishes tile by tile. v4 therefore bakes a
29158
+ // deterministic anchor GRID into big lakes — the paper-chart convention
29159
+ // of repeating a name along a large water body — and this one layer
29160
+ // renders however many anchors are in view; collision culls the rest.
29161
+ L({
29162
+ id: "chrtbase-inland-label",
29163
+ type: "symbol",
29164
+ source: src,
29165
+ "source-layer": "inland_water_label",
29166
+ minzoom: 5,
29167
+ layout: {
29168
+ "text-field": ["get", "name"],
29169
+ "text-font": ["Open Sans Regular"],
29170
+ "text-size": ["interpolate", ["linear"], ["zoom"], 5, 9.5, 10, 12],
29171
+ "text-max-width": 7,
29172
+ "text-allow-overlap": false,
29173
+ // Zoom-scaled spacing for the anchor GRID: at z8.9 the 0.08-deg
29174
+ // anchors sit ~54 px apart and a flat padding let six copies of
29175
+ // "Lake Ontario" crowd one screen (owner-reported 2026-08-30).
29176
+ // Wide collision boxes at low zoom thin the grid to the repeat-
29177
+ // by-convention cadence; by z12 the anchors are ~850 px apart and
29178
+ // the padding relaxes to normal.
29179
+ "text-padding": ["interpolate", ["linear"], ["zoom"], 5, 80, 9, 64, 12, 16]
29180
+ },
29181
+ paint: {
29182
+ "text-color": T.inlandLabel,
29183
+ "text-halo-color": T.inlandHalo,
29184
+ "text-halo-width": 1.1
29185
+ }
29186
+ })
29187
+ ];
28656
29188
  }
28657
29189
 
28658
29190
  // src/atlas/band-collapse.ts
28659
- function collapsedDominanceGate(B, minzoom) {
28660
- const clauses = [NOT_DOMINATED_FILTER];
29191
+ function collapsedDominanceGate(B, minzoom, notDominatedCeiling = null) {
29192
+ const clauses = [
29193
+ notDominatedCeiling !== null && notDominatedCeiling > minzoom ? ["all", NOT_DOMINATED_FILTER, ["<", ["zoom"], notDominatedCeiling]] : NOT_DOMINATED_FILTER
29194
+ ];
28661
29195
  const bInt = parseInt(B, 10);
28662
29196
  for (const D of BAND_DRAW_ORDER) {
28663
29197
  const dInt = parseInt(D, 10);
@@ -28686,7 +29220,7 @@ function makeCollapsedBandVariant(layer, B, isExempt) {
28686
29220
  filter: combineWithBandFilter(layer.filter, [
28687
29221
  "all",
28688
29222
  bandFilterExpression(B),
28689
- collapsedDominanceGate(B, minzoom)
29223
+ collapsedDominanceGate(B, minzoom, generalisedAreaCeiling(layer, B))
28690
29224
  ]),
28691
29225
  minzoom
28692
29226
  };
@@ -28821,6 +29355,7 @@ function makeBandVariants(layer, B) {
28821
29355
  delete exempt.maxzoom;
28822
29356
  return [exempt];
28823
29357
  }
29358
+ const ceiling = generalisedAreaCeiling(layer, B);
28824
29359
  const notDom = {
28825
29360
  ...layer,
28826
29361
  id: `${layer.id}-band${B}`,
@@ -28831,6 +29366,7 @@ function makeBandVariants(layer, B) {
28831
29366
  minzoom
28832
29367
  };
28833
29368
  delete notDom.maxzoom;
29369
+ if (ceiling !== null && ceiling > minzoom) notDom.maxzoom = ceiling;
28834
29370
  variants.push(notDom);
28835
29371
  const bInt = parseInt(B, 10);
28836
29372
  for (const D of BAND_DRAW_ORDER) {
@@ -28980,12 +29516,43 @@ function generateStyle(palette = "DAY", opts = {}) {
28980
29516
  paint: { "background-color": resolveColor("NODTA", palette) }
28981
29517
  };
28982
29518
  const layers = [backgroundLayer];
29519
+ if (opts.base) {
29520
+ const base = baseLayers({ palette });
29521
+ const coverageGround = {
29522
+ id: "s52-coverage-ground",
29523
+ type: "fill",
29524
+ source: sourceId,
29525
+ "source-layer": "M_COVR",
29526
+ filter: [
29527
+ "all",
29528
+ ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "MultiPolygon"]],
29529
+ ["==", ["to-number", ["coalesce", ["get", "CATCOV"], 0]], 1]
29530
+ ],
29531
+ paint: { "fill-color": resolveColor("DEPDW", palette) },
29532
+ metadata: { "s52:objectClass": "M_COVR", "s52:role": "coverage-ground" }
29533
+ };
29534
+ const jurisdictionAt = base.findIndex(
29535
+ (l) => l.id === "chrtbase-jurisdiction"
29536
+ );
29537
+ if (jurisdictionAt === -1) {
29538
+ layers.push(...base, coverageGround);
29539
+ } else {
29540
+ layers.push(
29541
+ ...base.slice(0, jurisdictionAt),
29542
+ coverageGround,
29543
+ ...base.slice(jurisdictionAt)
29544
+ );
29545
+ }
29546
+ }
28983
29547
  if (opts.land) {
28984
29548
  layers.push(...landLayers({
28985
29549
  palette,
28986
29550
  ...opts.relief ? { relief: true } : {}
28987
29551
  }));
28988
29552
  }
29553
+ if (opts.base) {
29554
+ layers.push(...baseInlandWaterLayers({ palette }));
29555
+ }
28989
29556
  const labelLayers = [];
28990
29557
  const chartCascade = opts.chartMode ? CHART_CASCADE_CODES[opts.chartMode.band] : null;
28991
29558
  const chartRequestedCodes = opts.chartMode ? CHART_BAND_CODES[opts.chartMode.band] : null;
@@ -29102,6 +29669,22 @@ function generateStyle(palette = "DAY", opts = {}) {
29102
29669
  }
29103
29670
  }
29104
29671
  }
29672
+ {
29673
+ const TERMINAL_FILL_RE = /^s52-(LNDARE|DEPARE)-fill-band([456])$/;
29674
+ for (let i = layers.length - 1; i >= 0; i--) {
29675
+ const src = layers[i];
29676
+ const m = TERMINAL_FILL_RE.exec(src.id);
29677
+ if (!m) continue;
29678
+ const clone = {
29679
+ ...layers[i],
29680
+ id: `s52-${m[1]}-fill-terminal-band${m[2]}`,
29681
+ filter: ["all", src.filter, ["==", ["get", "_terminal_fill"], true]]
29682
+ };
29683
+ delete clone.minzoom;
29684
+ clone.maxzoom = src.minzoom ?? 0;
29685
+ layers.splice(i + 1, 0, clone);
29686
+ }
29687
+ }
29105
29688
  if (opts.labels === "placename") {
29106
29689
  labelLayers.push(...buildPlaceNameLayers(sourceId));
29107
29690
  } else {
@@ -29131,13 +29714,115 @@ function generateStyle(palette = "DAY", opts = {}) {
29131
29714
  const symbolSet = opts.symbolSet ?? "s52";
29132
29715
  const spriteBase = `${opts.tileBaseUrl ?? "https://tiles.chrt.co"}/v1/sprites`;
29133
29716
  const isLndareFill = (l) => /^s52-LNDARE-fill-band\d+$/.test(l.id);
29134
- const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
29717
+ const isWaterBaseFill = (l) => /^s52-(DEPARE|DRGARE|UNSARE)-fill-band\d+$/.test(l.id);
29718
+ const bandOf = (l) => {
29719
+ const m = /-band(\d+)$/.exec(l.id);
29720
+ return m ? Number(m[1]) : 0;
29721
+ };
29135
29722
  const emitted = opts.land ? (() => {
29136
- const lndareFills = layers.filter(isLndareFill);
29137
- const seaareFills = layers.filter(isSeaareFill);
29138
- const rest = layers.filter((l) => !isLndareFill(l) && !isSeaareFill(l));
29139
- return [rest[0], ...seaareFills, ...lndareFills, ...rest.slice(1)];
29723
+ const relocated = layers.filter((l) => isLndareFill(l) || isWaterBaseFill(l));
29724
+ const others = layers.filter((l) => !isLndareFill(l) && !isWaterBaseFill(l));
29725
+ const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
29726
+ const seaare = others.filter(isSeaareFill);
29727
+ const rest = others.filter((l) => !isSeaareFill(l));
29728
+ const isBaseLayer = (l) => {
29729
+ const id = l.id;
29730
+ if (id.startsWith("chrtbase-inland-")) return false;
29731
+ return id.startsWith("chrtbase-") || id === "s52-coverage-ground";
29732
+ };
29733
+ const baseBlock = rest.filter(isBaseLayer);
29734
+ const restAbove = rest.filter((l) => !isBaseLayer(l));
29735
+ const bands = [...new Set(relocated.map(bandOf))].sort((a, b) => a - b);
29736
+ const oneTan = landBaseColor(palette);
29737
+ const asBaseTan = (l) => ({
29738
+ ...l,
29739
+ paint: {
29740
+ ...l.paint ?? {},
29741
+ "fill-color": oneTan
29742
+ }
29743
+ });
29744
+ const ordered = [];
29745
+ for (const b of bands) {
29746
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isWaterBaseFill(l)));
29747
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isLndareFill(l)).map(asBaseTan));
29748
+ }
29749
+ const findRest = (id) => restAbove.findIndex((l) => l.id === id);
29750
+ const shoreAt = findRest("chrtbase-inland-shore");
29751
+ const pairAt = shoreAt !== -1 ? shoreAt : findRest("chrtbase-inland-hatch");
29752
+ let restFinal = restAbove;
29753
+ if (pairAt !== -1) {
29754
+ const repaint = [];
29755
+ for (const b of bands) {
29756
+ for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
29757
+ const src = l;
29758
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
29759
+ const notDom = {
29760
+ ...l,
29761
+ id: `${src.id}-overpair`,
29762
+ filter: ["all", src.filter, NOT_DOMINATED_FILTER],
29763
+ metadata: meta
29764
+ };
29765
+ delete notDom.maxzoom;
29766
+ delete notDom.minzoom;
29767
+ repaint.push(notDom);
29768
+ for (const D of BAND_DRAW_ORDER) {
29769
+ if (parseInt(D, 10) <= b) continue;
29770
+ const dMaxzoom = dominatedVariantMaxzoom(D);
29771
+ if (dMaxzoom <= 0) continue;
29772
+ const domVariant = {
29773
+ ...l,
29774
+ id: `${src.id}-overpair-domBy${D}`,
29775
+ filter: ["all", src.filter, dominatedByMinFilter(D)],
29776
+ metadata: meta,
29777
+ maxzoom: dMaxzoom
29778
+ };
29779
+ delete domVariant.minzoom;
29780
+ repaint.push(domVariant);
29781
+ }
29782
+ }
29783
+ }
29784
+ const isCoalneLine = (l) => /^s52-COALNE-line-band\d(-domBy\d)?$/.test(l.id);
29785
+ for (const l of layers.filter(isCoalneLine)) {
29786
+ const src = l;
29787
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
29788
+ const banded = {
29789
+ ...l,
29790
+ id: `${src.id}-overpair`,
29791
+ metadata: meta,
29792
+ minzoom: 9
29793
+ };
29794
+ repaint.push(banded);
29795
+ const terminal = {
29796
+ ...l,
29797
+ id: `${src.id}-overpair-terminal`,
29798
+ filter: ["all", src.filter, ["==", ["get", "_terminal_shore"], true]],
29799
+ metadata: meta
29800
+ };
29801
+ delete terminal.minzoom;
29802
+ terminal.maxzoom = 9;
29803
+ repaint.push(terminal);
29804
+ }
29805
+ restFinal = [
29806
+ ...restAbove.slice(0, pairAt + 1),
29807
+ ...repaint,
29808
+ ...restAbove.slice(pairAt + 1)
29809
+ ];
29810
+ }
29811
+ return [restFinal[0], ...baseBlock, ...seaare, ...ordered, ...restFinal.slice(1)];
29140
29812
  })() : layers;
29813
+ for (const id of [
29814
+ "chrtbase-country-boundary",
29815
+ "chrtland-town-port",
29816
+ "chrtland-city-major"
29817
+ ]) {
29818
+ const at = emitted.findIndex(
29819
+ (l) => l.id === id
29820
+ );
29821
+ if (at !== -1) {
29822
+ const [layer] = emitted.splice(at, 1);
29823
+ emitted.push(layer);
29824
+ }
29825
+ }
29141
29826
  const style = {
29142
29827
  version: 8,
29143
29828
  name: `CHRT ${SYMBOL_SET_LABELS[symbolSet]} ${PALETTE_NAMES[palette]}${opts.chartMode ? ` \u2014 chart:${opts.chartMode.band}` : ""}`,
@@ -29149,6 +29834,7 @@ function generateStyle(palette = "DAY", opts = {}) {
29149
29834
  "chrt:layer-count": emitted.length,
29150
29835
  "chrt:land": Boolean(opts.land),
29151
29836
  "chrt:relief": Boolean(opts.relief),
29837
+ "chrt:base": Boolean(opts.base),
29152
29838
  "chrt:render-mode": opts.chartMode ? `chart:${opts.chartMode.band}` : "continuous",
29153
29839
  "chrt:description": "IHO S-52 portrayal rules for Electronic Navigational Charts (ENCs). Generated from the CHRT atlas engine."
29154
29840
  },
@@ -29642,6 +30328,7 @@ var Chrt = class {
29642
30328
  interact;
29643
30329
  featuresEp;
29644
30330
  searchEp;
30331
+ poiEp;
29645
30332
  queryEp;
29646
30333
  http;
29647
30334
  tileAuth;
@@ -29663,6 +30350,7 @@ var Chrt = class {
29663
30350
  this.weather = new WeatherEndpoint(this.http);
29664
30351
  this.featuresEp = new FeaturesEndpoint(this.http);
29665
30352
  this.searchEp = new SearchEndpoint(this.http);
30353
+ this.poiEp = new PoiEndpoint(this.http);
29666
30354
  this.queryEp = new QueryEndpoint(this.http);
29667
30355
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
29668
30356
  this.interact = new InteractClient(this.http);
@@ -29698,6 +30386,26 @@ var Chrt = class {
29698
30386
  async search(options) {
29699
30387
  return this.searchEp.find(options);
29700
30388
  }
30389
+ /**
30390
+ * CHRTpoi — charted marine facilities in a viewport or radius.
30391
+ *
30392
+ * v1 serves marinas, derived from the ENC's own HRBFAC facility class —
30393
+ * the chart's data, with the source cell's currency inline per POI.
30394
+ *
30395
+ * ```ts
30396
+ * const { pois, coverage } = await chrt.pois({ bbox: [-70.7, 41.4, -70.5, 41.6] });
30397
+ * ```
30398
+ *
30399
+ * **Read `coverage.state` before interpreting an empty list.** `covered` +
30400
+ * empty means no marinas are charted there — a real answer. `none` means
30401
+ * CHRT cannot answer for that area at all, and an empty list means nothing.
30402
+ * The two must never be shown the same way, and `coverage.message` is
30403
+ * written to be relayed unedited. Metered on its own allowance
30404
+ * (`poi_called`), separate from features and search; not gated by tier.
30405
+ */
30406
+ async pois(options) {
30407
+ return this.poiEp.list(options);
30408
+ }
29701
30409
  /** Spatial proximity query around a point */
29702
30410
  async query(options) {
29703
30411
  return this.queryEp.nearby(options);
@@ -29868,6 +30576,18 @@ var Chrt = class {
29868
30576
  return generateStyle(palette, {
29869
30577
  region: this.region,
29870
30578
  tileBaseUrl: this.tileBaseUrl,
30579
+ // Pin each source's tile URL to the build it serves, from whatever the
30580
+ // token cache already holds. Without a discriminator one URL is shared
30581
+ // by every build, so after a cutover a map keeps painting already-fetched
30582
+ // tiles from the OLD build beside newly-fetched ones from the new.
30583
+ //
30584
+ // BEFORE the spread, so an explicit `buildTags` wins — and only ever a
30585
+ // warm-cache read: this method is synchronous and must not mint. A cold
30586
+ // cache yields `{}`, i.e. exactly the URLs this SDK emitted before the
30587
+ // discriminator existed. Warm it with `chrt.transformRequest()` (which
30588
+ // kicks off a mint) or `await chrt.styleUrl()`; `await chrt.warmTiles()`
30589
+ // is the explicit form, and it returns the map.
30590
+ buildTags: this.tileAuth.peekBuildTags(this.region),
29871
30591
  ...styleOpts,
29872
30592
  // The vessel's safety depth IS the style's safety contour — same number, same
29873
30593
  // meaning. Threading it here is what stops the chart shading at one depth while
@@ -29941,9 +30661,21 @@ var Chrt = class {
29941
30661
  * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
29942
30662
  * the cache automatically — but useful if you want to surface a mint error
29943
30663
  * (e.g. `region_not_licensed`, `tile_quota_exceeded`) before the map loads.
30664
+ *
30665
+ * Returns what it minted, so a caller building its own sources can take the
30666
+ * `buildTags` map straight from here:
30667
+ *
30668
+ * ```ts
30669
+ * const { buildTags } = await chrt.warmTiles();
30670
+ * map.addSource("chrt", buildSources({ region: "NOAA_ENC", buildTags })["NOAA_ENC"]);
30671
+ * ```
30672
+ *
30673
+ * That pins each tile URL to the build it serves, so a build cutover gets its
30674
+ * own client cache namespace instead of leaving old and new tiles mixed on
30675
+ * screen. `chrt.style()` reads the same cache and needs no argument.
29944
30676
  */
29945
30677
  async warmTiles(region) {
29946
- await this.tileAuth.getToken(region);
30678
+ return this.tileAuth.getToken(region);
29947
30679
  }
29948
30680
  };
29949
30681
  // Annotate the CommonJS export names for ESM import in node:
@@ -29954,9 +30686,13 @@ var Chrt = class {
29954
30686
  ATON_EXPRESSION_CLASSES,
29955
30687
  BAND_DRAW_ORDER,
29956
30688
  BAND_PRIORITY_RANGES,
30689
+ BASE_MAXZOOM,
30690
+ BASE_MINZOOM,
30691
+ BASE_REGION,
29957
30692
  BRIDGE_QUERY_LAYERS,
29958
30693
  BUILD_MAXZOOM,
29959
30694
  BUILD_MINZOOM,
30695
+ BUILD_PARAM,
29960
30696
  CHART_BAND_CODES,
29961
30697
  CHART_CASCADE_CODES,
29962
30698
  CHART_SECTIONS,
@@ -30138,6 +30874,7 @@ var Chrt = class {
30138
30874
  scaleSizeValue,
30139
30875
  scaminToMinZoom,
30140
30876
  styleModeBand,
30877
+ tileTemplate,
30141
30878
  withinBboxFilter,
30142
30879
  worldMinusBboxPolygon
30143
30880
  });