@chrtco/sdk 0.10.18 → 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,7 +27441,27 @@ 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
+ ]
27319
27465
  };
27320
27466
  const valnmrNum = ["to-number", ["coalesce", ["get", "VALNMR"], ["get", "valnmr"], 0], 0];
27321
27467
  const isMajor = [">=", valnmrNum, 10];
@@ -28280,92 +28426,273 @@ function getOrderedClasses() {
28280
28426
  return ordered;
28281
28427
  }
28282
28428
 
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));
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]);
28288
28448
  }
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
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;
28341
28461
  }
28342
- };
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;
28362
- const demSrc = opts.reliefSourceId ?? RELIEF_REGION;
28363
- const L = (o) => o;
28364
- const layers = [
28365
- // 1. The land itself — LNDARE ∩ coverage, so it can never outrun the chart.
28366
- L({
28367
- id: "chrtland-base",
28368
- type: "fill",
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",
28695
+ type: "fill",
28369
28696
  source: src,
28370
28697
  "source-layer": "land_base",
28371
28698
  paint: { "fill-color": T.base }
@@ -28399,6 +28726,7 @@ function landLayers(opts = {}) {
28399
28726
  id: "chrtland-hillshade",
28400
28727
  type: "hillshade",
28401
28728
  source: demSrc,
28729
+ minzoom: RELIEF_MINZOOM2,
28402
28730
  paint: {
28403
28731
  "hillshade-exaggeration": T.exaggeration,
28404
28732
  "hillshade-shadow-color": T.shadow,
@@ -28450,13 +28778,78 @@ function landLayers(opts = {}) {
28450
28778
  "line-opacity": 0.8
28451
28779
  }
28452
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
+ }));
28453
28841
  layers.push(L({
28454
28842
  id: "chrtland-town",
28455
28843
  type: "symbol",
28456
28844
  source: src,
28457
28845
  "source-layer": "land_town",
28458
28846
  minzoom: 9,
28459
- filter: FITS,
28847
+ filter: [
28848
+ "all",
28849
+ FITS,
28850
+ ["<", ["coalesce", ["get", "prank"], 0], 10],
28851
+ ["!=", ["get", "enc_dup"], true]
28852
+ ],
28460
28853
  layout: {
28461
28854
  "text-field": [
28462
28855
  "case",
@@ -28489,187 +28882,316 @@ function landLayers(opts = {}) {
28489
28882
  "text-offset": anchorOffset(0.8),
28490
28883
  "text-anchor": ANCHOR,
28491
28884
  "text-padding": 5,
28492
- "text-allow-overlap": false,
28493
- "symbol-sort-key": 3
28494
- },
28495
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28496
- }));
28497
- layers.push(L({
28498
- id: "chrtland-poi",
28499
- type: "symbol",
28500
- source: src,
28501
- "source-layer": "land_poi",
28502
- minzoom: 13,
28503
- filter: FITS,
28504
- 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),
28509
- "text-anchor": ANCHOR,
28510
- "text-padding": 5,
28511
- "text-allow-overlap": false,
28512
- "symbol-sort-key": 5
28513
- },
28514
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28515
- }));
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;
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);
28551
28913
  }
28552
- return false;
28914
+ return layers;
28553
28915
  }
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;
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"
28563
28971
  }
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
28972
  };
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];
28606
- }
28607
- return ["all", baseFilter, bandFilter];
28608
- }
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
- ];
28622
- }
28623
- return ["==", ["get", "_dominated_by_min"], dInt];
28624
- }
28625
- function withinBboxFilter(bbox) {
28626
- const [w, s] = bbox.sw;
28627
- 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;
28628
28978
  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
- }
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
+ })
28642
29099
  ];
28643
29100
  }
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
- };
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
+ ];
28668
29188
  }
28669
29189
 
28670
29190
  // src/atlas/band-collapse.ts
28671
- function collapsedDominanceGate(B, minzoom) {
28672
- 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
+ ];
28673
29195
  const bInt = parseInt(B, 10);
28674
29196
  for (const D of BAND_DRAW_ORDER) {
28675
29197
  const dInt = parseInt(D, 10);
@@ -28698,7 +29220,7 @@ function makeCollapsedBandVariant(layer, B, isExempt) {
28698
29220
  filter: combineWithBandFilter(layer.filter, [
28699
29221
  "all",
28700
29222
  bandFilterExpression(B),
28701
- collapsedDominanceGate(B, minzoom)
29223
+ collapsedDominanceGate(B, minzoom, generalisedAreaCeiling(layer, B))
28702
29224
  ]),
28703
29225
  minzoom
28704
29226
  };
@@ -28833,6 +29355,7 @@ function makeBandVariants(layer, B) {
28833
29355
  delete exempt.maxzoom;
28834
29356
  return [exempt];
28835
29357
  }
29358
+ const ceiling = generalisedAreaCeiling(layer, B);
28836
29359
  const notDom = {
28837
29360
  ...layer,
28838
29361
  id: `${layer.id}-band${B}`,
@@ -28843,6 +29366,7 @@ function makeBandVariants(layer, B) {
28843
29366
  minzoom
28844
29367
  };
28845
29368
  delete notDom.maxzoom;
29369
+ if (ceiling !== null && ceiling > minzoom) notDom.maxzoom = ceiling;
28846
29370
  variants.push(notDom);
28847
29371
  const bInt = parseInt(B, 10);
28848
29372
  for (const D of BAND_DRAW_ORDER) {
@@ -28992,12 +29516,43 @@ function generateStyle(palette = "DAY", opts = {}) {
28992
29516
  paint: { "background-color": resolveColor("NODTA", palette) }
28993
29517
  };
28994
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
+ }
28995
29547
  if (opts.land) {
28996
29548
  layers.push(...landLayers({
28997
29549
  palette,
28998
29550
  ...opts.relief ? { relief: true } : {}
28999
29551
  }));
29000
29552
  }
29553
+ if (opts.base) {
29554
+ layers.push(...baseInlandWaterLayers({ palette }));
29555
+ }
29001
29556
  const labelLayers = [];
29002
29557
  const chartCascade = opts.chartMode ? CHART_CASCADE_CODES[opts.chartMode.band] : null;
29003
29558
  const chartRequestedCodes = opts.chartMode ? CHART_BAND_CODES[opts.chartMode.band] : null;
@@ -29114,6 +29669,22 @@ function generateStyle(palette = "DAY", opts = {}) {
29114
29669
  }
29115
29670
  }
29116
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
+ }
29117
29688
  if (opts.labels === "placename") {
29118
29689
  labelLayers.push(...buildPlaceNameLayers(sourceId));
29119
29690
  } else {
@@ -29143,13 +29714,115 @@ function generateStyle(palette = "DAY", opts = {}) {
29143
29714
  const symbolSet = opts.symbolSet ?? "s52";
29144
29715
  const spriteBase = `${opts.tileBaseUrl ?? "https://tiles.chrt.co"}/v1/sprites`;
29145
29716
  const isLndareFill = (l) => /^s52-LNDARE-fill-band\d+$/.test(l.id);
29146
- 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
+ };
29147
29722
  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)];
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)];
29152
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
+ }
29153
29826
  const style = {
29154
29827
  version: 8,
29155
29828
  name: `CHRT ${SYMBOL_SET_LABELS[symbolSet]} ${PALETTE_NAMES[palette]}${opts.chartMode ? ` \u2014 chart:${opts.chartMode.band}` : ""}`,
@@ -29161,6 +29834,7 @@ function generateStyle(palette = "DAY", opts = {}) {
29161
29834
  "chrt:layer-count": emitted.length,
29162
29835
  "chrt:land": Boolean(opts.land),
29163
29836
  "chrt:relief": Boolean(opts.relief),
29837
+ "chrt:base": Boolean(opts.base),
29164
29838
  "chrt:render-mode": opts.chartMode ? `chart:${opts.chartMode.band}` : "continuous",
29165
29839
  "chrt:description": "IHO S-52 portrayal rules for Electronic Navigational Charts (ENCs). Generated from the CHRT atlas engine."
29166
29840
  },
@@ -29654,6 +30328,7 @@ var Chrt = class {
29654
30328
  interact;
29655
30329
  featuresEp;
29656
30330
  searchEp;
30331
+ poiEp;
29657
30332
  queryEp;
29658
30333
  http;
29659
30334
  tileAuth;
@@ -29675,6 +30350,7 @@ var Chrt = class {
29675
30350
  this.weather = new WeatherEndpoint(this.http);
29676
30351
  this.featuresEp = new FeaturesEndpoint(this.http);
29677
30352
  this.searchEp = new SearchEndpoint(this.http);
30353
+ this.poiEp = new PoiEndpoint(this.http);
29678
30354
  this.queryEp = new QueryEndpoint(this.http);
29679
30355
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
29680
30356
  this.interact = new InteractClient(this.http);
@@ -29710,6 +30386,26 @@ var Chrt = class {
29710
30386
  async search(options) {
29711
30387
  return this.searchEp.find(options);
29712
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
+ }
29713
30409
  /** Spatial proximity query around a point */
29714
30410
  async query(options) {
29715
30411
  return this.queryEp.nearby(options);
@@ -29880,6 +30576,18 @@ var Chrt = class {
29880
30576
  return generateStyle(palette, {
29881
30577
  region: this.region,
29882
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),
29883
30591
  ...styleOpts,
29884
30592
  // The vessel's safety depth IS the style's safety contour — same number, same
29885
30593
  // meaning. Threading it here is what stops the chart shading at one depth while
@@ -29953,9 +30661,21 @@ var Chrt = class {
29953
30661
  * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
29954
30662
  * the cache automatically — but useful if you want to surface a mint error
29955
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.
29956
30676
  */
29957
30677
  async warmTiles(region) {
29958
- await this.tileAuth.getToken(region);
30678
+ return this.tileAuth.getToken(region);
29959
30679
  }
29960
30680
  };
29961
30681
  // Annotate the CommonJS export names for ESM import in node:
@@ -29966,9 +30686,13 @@ var Chrt = class {
29966
30686
  ATON_EXPRESSION_CLASSES,
29967
30687
  BAND_DRAW_ORDER,
29968
30688
  BAND_PRIORITY_RANGES,
30689
+ BASE_MAXZOOM,
30690
+ BASE_MINZOOM,
30691
+ BASE_REGION,
29969
30692
  BRIDGE_QUERY_LAYERS,
29970
30693
  BUILD_MAXZOOM,
29971
30694
  BUILD_MINZOOM,
30695
+ BUILD_PARAM,
29972
30696
  CHART_BAND_CODES,
29973
30697
  CHART_CASCADE_CODES,
29974
30698
  CHART_SECTIONS,
@@ -30150,6 +30874,7 @@ var Chrt = class {
30150
30874
  scaleSizeValue,
30151
30875
  scaminToMinZoom,
30152
30876
  styleModeBand,
30877
+ tileTemplate,
30153
30878
  withinBboxFilter,
30154
30879
  worldMinusBboxPolygon
30155
30880
  });