@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.js CHANGED
@@ -134,7 +134,7 @@ var TileTokenError = class extends Error {
134
134
  this.status = status;
135
135
  }
136
136
  };
137
- var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.mvt(?:\?.*)?$/i;
137
+ var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.(?:mvt|png)(?:\?.*)?$/i;
138
138
  var STYLE_URL_PATH = /\/v1\/styles\/[A-Za-z0-9_]+\/style\.json(?:\?.*)?$/i;
139
139
  var REFRESH_MARGIN_SECONDS = 30;
140
140
  var STALE_GRACE_SECONDS = 1;
@@ -145,6 +145,15 @@ function nowSeconds() {
145
145
  function sleep2(ms) {
146
146
  return new Promise((r) => setTimeout(r, ms));
147
147
  }
148
+ function readBuildTags(raw, region, buildTag) {
149
+ const out = { [region]: buildTag };
150
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
151
+ for (const [key, value] of Object.entries(raw)) {
152
+ if (typeof value === "string" && value) out[key] = value;
153
+ }
154
+ }
155
+ return out;
156
+ }
148
157
  function mapErrorCode(raw, status) {
149
158
  const known = [
150
159
  "invalid_api_key",
@@ -202,6 +211,7 @@ var TileAuth = class {
202
211
  expiresAt: b.expires_at,
203
212
  kid: b.kid,
204
213
  buildTag: b.build_tag,
214
+ buildTags: readBuildTags(b.build_tags, b.region, b.build_tag),
205
215
  region: b.region
206
216
  };
207
217
  }
@@ -277,6 +287,22 @@ var TileAuth = class {
277
287
  if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return null;
278
288
  return entry.response.token;
279
289
  }
290
+ /**
291
+ * Synchronous peek at the cached mint's per-region build ids — `{}` when the
292
+ * cache is cold.
293
+ *
294
+ * `chrt.style()` is synchronous and cannot mint, so this is how a style
295
+ * generated after the cache is warm picks up the build discriminator without
296
+ * the caller threading it by hand. A cold cache yields `{}`, which produces
297
+ * the undiscriminated URL the SDK emitted before this existed — never an
298
+ * error, and never a wrong build.
299
+ */
300
+ peekBuildTags(region) {
301
+ const entry = this.cache.get(region ?? this.defaultRegion);
302
+ if (!entry) return {};
303
+ if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return {};
304
+ return entry.response.buildTags;
305
+ }
280
306
  /**
281
307
  * Produce a MapLibre / Mapbox GL `transformRequest`. Attaches
282
308
  * `Authorization: Bearer <jwt>` only to CHRT tile URLs and the CHRT
@@ -330,6 +356,78 @@ var TileAuth = class {
330
356
  }
331
357
  };
332
358
 
359
+ // src/atlas/tile-zoom.ts
360
+ var BUILD_MINZOOM = 0;
361
+ var BUILD_MAXZOOM = 14;
362
+
363
+ // src/atlas/source-registry.ts
364
+ var LAND_REGION = "CHRT_LAND";
365
+ var RELIEF_REGION = "CHRT_RELIEF";
366
+ var BASE_REGION = "CHRT_BASE";
367
+ var BASE_MINZOOM = 0;
368
+ var BASE_MAXZOOM = 11;
369
+ var RELIEF_MINZOOM = 8;
370
+ var RELIEF_MAXZOOM = 11;
371
+ var RELIEF_TILE_SIZE = 512;
372
+ var BUILD_PARAM = "v";
373
+ function tileTemplate(base, region, extension, buildId) {
374
+ const url = `${base}/v1/tiles/${region}/{z}/{x}/{y}.${extension}`;
375
+ const id = buildId?.trim();
376
+ if (!id) return url;
377
+ return `${url}?${BUILD_PARAM}=${encodeURIComponent(id)}`;
378
+ }
379
+ var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
380
+ var DEFAULT_REGION = "NOAA_ENC";
381
+ function buildSources(opts = {}) {
382
+ const mode = opts.mode ?? "pmtiles";
383
+ const region = opts.region ?? DEFAULT_REGION;
384
+ if (mode === "mapbox") {
385
+ return {
386
+ [region]: {
387
+ type: "vector",
388
+ url: `mapbox://chrt.${region.toLowerCase()}`
389
+ }
390
+ };
391
+ }
392
+ const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
393
+ const buildFor = (r) => opts.buildTags?.[r] ?? (r === region ? opts.buildTag : void 0);
394
+ const sources = {
395
+ [region]: {
396
+ type: "vector",
397
+ tiles: [tileTemplate(base, region, "mvt", buildFor(region))],
398
+ minzoom: BUILD_MINZOOM,
399
+ maxzoom: BUILD_MAXZOOM
400
+ }
401
+ };
402
+ if (opts.land) {
403
+ sources[LAND_REGION] = {
404
+ type: "vector",
405
+ tiles: [tileTemplate(base, LAND_REGION, "mvt", buildFor(LAND_REGION))],
406
+ minzoom: BUILD_MINZOOM,
407
+ maxzoom: BUILD_MAXZOOM
408
+ };
409
+ }
410
+ if (opts.base) {
411
+ sources[BASE_REGION] = {
412
+ type: "vector",
413
+ tiles: [`${base}/v1/tiles/${BASE_REGION}/{z}/{x}/{y}.mvt`],
414
+ minzoom: BASE_MINZOOM,
415
+ maxzoom: BASE_MAXZOOM
416
+ };
417
+ }
418
+ if (opts.relief) {
419
+ sources[RELIEF_REGION] = {
420
+ type: "raster-dem",
421
+ tiles: [tileTemplate(base, RELIEF_REGION, "png", buildFor(RELIEF_REGION))],
422
+ encoding: "terrarium",
423
+ tileSize: RELIEF_TILE_SIZE,
424
+ minzoom: RELIEF_MINZOOM,
425
+ maxzoom: RELIEF_MAXZOOM
426
+ };
427
+ }
428
+ return sources;
429
+ }
430
+
333
431
  // src/endpoints/tiles.ts
334
432
  var TilesEndpoint = class {
335
433
  constructor(http, tileAuth, opts) {
@@ -401,13 +499,20 @@ var TilesEndpoint = class {
401
499
  return await res.json();
402
500
  }
403
501
  /**
404
- * Vector tile URL template for the configured region, served by the
405
- * tile-worker at `tiles.chrt.co`. Tiles are JWT-gated — pair this with
502
+ * Vector tile URL for the configured region, served by the tile-worker at
503
+ * `tiles.chrt.co`. Tiles are JWT-gated — pair this with
406
504
  * `chrt.transformRequest()`, which attaches the `Authorization: Bearer`
407
505
  * header. Prefer `chrt.style()`, which wires the source for you.
506
+ *
507
+ * Carries the build discriminator (`?v=`) once the token cache is warm, so
508
+ * two buildTags never share one cache entry — see `BUILD_PARAM`. A cold cache
509
+ * yields the bare URL, which is equally valid: the server ignores the
510
+ * parameter, and warming is a `chrt.warmTiles()` (or one
511
+ * `chrt.transformRequest()` round-trip) away.
408
512
  */
409
513
  tileUrl(z, x, y) {
410
- return `${this.tileBaseUrl}/v1/tiles/${this.region}/${z}/${x}/${y}.mvt`;
514
+ const build = this.tileAuth.peekBuildTags(this.region)[this.region];
515
+ return tileTemplate(this.tileBaseUrl, this.region, "mvt", build).replace("{z}", String(z)).replace("{x}", String(x)).replace("{y}", String(y));
411
516
  }
412
517
  /**
413
518
  * @deprecated Tilesets were consolidated to one PMTiles per region (2026-04);
@@ -633,6 +738,27 @@ var SearchEndpoint = class {
633
738
  }
634
739
  };
635
740
 
741
+ // src/endpoints/poi.ts
742
+ var PoiEndpoint = class {
743
+ constructor(http) {
744
+ this.http = http;
745
+ }
746
+ async list(options) {
747
+ return this.http.request(
748
+ "/poi",
749
+ {
750
+ bbox: options.bbox ? options.bbox.join(",") : void 0,
751
+ near: options.near ? `${options.near[0]},${options.near[1]}` : void 0,
752
+ radius_m: options.radiusM,
753
+ category: options.category,
754
+ limit: options.limit,
755
+ offset: options.offset
756
+ },
757
+ options.signal
758
+ );
759
+ }
760
+ };
761
+
636
762
  // src/endpoints/query.ts
637
763
  var METRES_PER_NAUTICAL_MILE2 = 1852;
638
764
  var QueryEndpoint = class {
@@ -6153,6 +6279,38 @@ var S52_LOOKUP = {
6153
6279
  // polygons that overlap the security-zone CTNARE polygons; with both
6154
6280
  // as flood fills, the harbor inner waters disappeared under stacked
6155
6281
  // magenta. Same boundary-only treatment as CTNARE.
6282
+ // ── DASHED, 2026-08-24. A solid line here read as a second coastline.
6283
+ //
6284
+ // User report: "narragansett bay has 2 offset coastlines... this is so
6285
+ // sloppy. we can't sell this right now." Measured by ablating classes
6286
+ // out of the shipping style and diffing offscreen renders of that exact
6287
+ // frame (z10.54, -71.1745/41.6031): dropping every magenta-painting
6288
+ // class changed 38,580 px and the doubled shoreline vanished; RESARE
6289
+ // ALONE was 37,613 of them — 97.5%. CBLARE, CTNARE, ACHARE and ADMARE
6290
+ // each changed zero.
6291
+ //
6292
+ // Restricted areas in Narragansett Bay follow the shore, so their
6293
+ // boundary runs parallel to the coastline a short distance off it. Drawn
6294
+ // SOLID, in a chart where the coastline is also a solid line, that is a
6295
+ // second coastline to any reader — the geometry is right and the
6296
+ // portrayal lies about what it is.
6297
+ //
6298
+ // This is not a SCAMIN problem, and the first fix attempted was wrong:
6299
+ // the tile pipeline already honours SCAMIN (the z10 tile carries 9
6300
+ // RESARE features, all SCAMIN 1:119,999-1:999,999 — NOAA's own
6301
+ // instruction to draw them at overview scale — while the z11 tile
6302
+ // carries 35). Gating harder would have hidden features the HO asks for.
6303
+ //
6304
+ // Dashed restores the convention this class was already believed to
6305
+ // follow: MIPARE [4,3], TESARE [6,4] and PRDARE [4,2] are all dashed
6306
+ // magenta boundaries, and PRDARE's own comment describes itself as
6307
+ // "matching RESARE/CTNARE/MIPARE/TESARE convention". RESARE was the lone
6308
+ // solid outlier. It also moves toward the S-52 line style for this
6309
+ // class, RESARE51 ("boundary of a restricted area"), a broken magenta
6310
+ // line with inward T-marks — which cannot be used directly yet: the
6311
+ // served sprite (s52-day, 101 entries) carries no RESARE51 pattern, and
6312
+ // a `line-pattern` naming a missing sprite renders NOTHING, which would
6313
+ // turn a portrayal complaint into invisible restricted areas.
6156
6314
  objectClass: "RESARE",
6157
6315
  name: "Restricted Area",
6158
6316
  category: "Regulated Areas",
@@ -6163,7 +6321,8 @@ var S52_LOOKUP = {
6163
6321
  paint: {
6164
6322
  "line-color": "TRFCD",
6165
6323
  "line-width": 1.2,
6166
- "line-opacity": 0.7
6324
+ "line-opacity": 0.7,
6325
+ "line-dasharray": [4, 3]
6167
6326
  }
6168
6327
  },
6169
6328
  viewingGroup: 26040,
@@ -6247,7 +6406,13 @@ var S52_LOOKUP = {
6247
6406
  category: "Regulated Areas",
6248
6407
  displayCategory: "OTHER",
6249
6408
  int1Ref: "N23",
6250
- style: { type: "fill", paint: { "fill-color": "LITYW", "fill-opacity": 0.15 } },
6409
+ // 0.15 painted Hanapepe Bay's dumping ground as a solid land-green wash
6410
+ // over the white deep-water ground — owner-read as "land painting up to
6411
+ // the coastline" (2026-08-31). INT1 N23/N24 portrays a dumping ground
6412
+ // as a dashed limit with a label, not an area tint; 0.04 matches the
6413
+ // TSSLPT/TSSRON faintness so the area reads as WATER with a regulated
6414
+ // overlay, at every tier that shows it.
6415
+ style: { type: "fill", paint: { "fill-color": "LITYW", "fill-opacity": 0.04 } },
6251
6416
  viewingGroup: 26230,
6252
6417
  scamin: 22e3
6253
6418
  },
@@ -7297,12 +7462,19 @@ var S52_LOOKUP = {
7297
7462
  // Inherits RESARE (S-57 112) — boundary-only, no flood fill. The stacked
7298
7463
  // magenta wash that motivated RESARE's treatment applies here too: an
7299
7464
  // inland reach can carry overlapping regulated areas just as a harbour can.
7465
+ // ── DASHED, matching RESARE (2026-08-25). The inland twin was missed
7466
+ // when RESARE was dashed the day before, so USACE inland waters kept
7467
+ // drawing restricted areas as a SOLID magenta line running alongside
7468
+ // the bank — the same "second coastline" a user reported on the coastal
7469
+ // chart, still shipping on the inland one. Found by auditing every
7470
+ // lowercase variant against its NOAA parent: of 14, this was the only
7471
+ // pair that diverged.
7300
7472
  objectClass: "resare",
7301
7473
  name: "Restricted Area (Inland)",
7302
7474
  category: "Regulated Areas",
7303
7475
  displayCategory: "STANDARD",
7304
7476
  int1Ref: "N2",
7305
- style: { type: "line", paint: { "line-color": "TRFCD", "line-width": 1.2, "line-opacity": 0.7 } },
7477
+ style: { type: "line", paint: { "line-color": "TRFCD", "line-width": 1.2, "line-opacity": 0.7, "line-dasharray": [4, 3] } },
7306
7478
  viewingGroup: 26010,
7307
7479
  scamin: 9e4
7308
7480
  },
@@ -25755,7 +25927,7 @@ var S52_POINT_SVGS = {
25755
25927
  "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',
25756
25928
  "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',
25757
25929
  "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',
25758
- "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',
25930
+ "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',
25759
25931
  "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',
25760
25932
  "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',
25761
25933
  "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',
@@ -26270,6 +26442,8 @@ var CHRT_GLYPH_SVGS = {
26270
26442
  "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>',
26271
26443
  "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>',
26272
26444
  "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>',
26445
+ "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>',
26446
+ "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>',
26273
26447
  "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>',
26274
26448
  "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>',
26275
26449
  "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>',
@@ -26718,59 +26892,6 @@ function iconExpressionForFamily(objectClass, family) {
26718
26892
  }
26719
26893
  }
26720
26894
 
26721
- // src/atlas/tile-zoom.ts
26722
- var BUILD_MINZOOM = 0;
26723
- var BUILD_MAXZOOM = 14;
26724
-
26725
- // src/atlas/source-registry.ts
26726
- var LAND_REGION = "CHRT_LAND";
26727
- var RELIEF_REGION = "CHRT_RELIEF";
26728
- var RELIEF_MINZOOM = 8;
26729
- var RELIEF_MAXZOOM = 13;
26730
- var RELIEF_TILE_SIZE = 512;
26731
- var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
26732
- var DEFAULT_REGION = "NOAA_ENC";
26733
- function buildSources(opts = {}) {
26734
- const mode = opts.mode ?? "pmtiles";
26735
- const region = opts.region ?? DEFAULT_REGION;
26736
- if (mode === "mapbox") {
26737
- return {
26738
- [region]: {
26739
- type: "vector",
26740
- url: `mapbox://chrt.${region.toLowerCase()}`
26741
- }
26742
- };
26743
- }
26744
- const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
26745
- const sources = {
26746
- [region]: {
26747
- type: "vector",
26748
- tiles: [`${base}/v1/tiles/${region}/{z}/{x}/{y}.mvt`],
26749
- minzoom: BUILD_MINZOOM,
26750
- maxzoom: BUILD_MAXZOOM
26751
- }
26752
- };
26753
- if (opts.land) {
26754
- sources[LAND_REGION] = {
26755
- type: "vector",
26756
- tiles: [`${base}/v1/tiles/${LAND_REGION}/{z}/{x}/{y}.mvt`],
26757
- minzoom: BUILD_MINZOOM,
26758
- maxzoom: BUILD_MAXZOOM
26759
- };
26760
- }
26761
- if (opts.relief) {
26762
- sources[RELIEF_REGION] = {
26763
- type: "raster-dem",
26764
- tiles: [`${base}/v1/tiles/${RELIEF_REGION}/{z}/{x}/{y}.png`],
26765
- encoding: "terrarium",
26766
- tileSize: RELIEF_TILE_SIZE,
26767
- minzoom: RELIEF_MINZOOM,
26768
- maxzoom: RELIEF_MAXZOOM
26769
- };
26770
- }
26771
- return sources;
26772
- }
26773
-
26774
26895
  // src/atlas/layer-builder.ts
26775
26896
  var SCAMIN_DECLUTTER_CLASSES = /* @__PURE__ */ new Set([
26776
26897
  "OFSPLF",
@@ -27098,8 +27219,40 @@ function buildLayersForClass(objectClass, ct, sourceId = DEFAULT_REGION, opts =
27098
27219
  layer.layout = {
27099
27220
  ...layer.layout,
27100
27221
  "icon-allow-overlap": true,
27101
- "icon-ignore-placement": true
27222
+ "icon-ignore-placement": true,
27223
+ // Flare-specific size (owner ask 2026-08-31: "make the flares a
27224
+ // little smaller — they should scale down a lot as you zoom out").
27225
+ // The safety-critical curve serves buoy/beacon HULLS, whose set
27226
+ // this overrides for LIGHTS only: flares are annotations on a
27227
+ // point, not shapes a mariner ranges against, so they start small
27228
+ // at planning scales and only approach full size at berthing
27229
+ // zooms. Applied after the safety-critical spread so it wins.
27230
+ "icon-size": [
27231
+ "interpolate",
27232
+ ["linear"],
27233
+ ["zoom"],
27234
+ 6,
27235
+ 0.3,
27236
+ 12,
27237
+ 0.42,
27238
+ 14,
27239
+ 0.6,
27240
+ 18,
27241
+ 1
27242
+ ]
27243
+ };
27244
+ const valnmrNum = ["to-number", ["coalesce", ["get", "VALNMR"], ["get", "valnmr"], 0], 0];
27245
+ const isMajor = [">=", valnmrNum, 10];
27246
+ const majorLayer = {
27247
+ ...layer,
27248
+ id: `${layer.id}-major`,
27249
+ filter: layer.filter ? ["all", layer.filter, isMajor] : isMajor,
27250
+ layout: { ...layer.layout },
27251
+ minzoom: 6
27102
27252
  };
27253
+ layer.filter = layer.filter ? ["all", layer.filter, ["!", isMajor]] : ["!", isMajor];
27254
+ layer.minzoom = scaminToMinZoom(entry?.scamin) ?? layer.minzoom;
27255
+ layers.push(majorLayer);
27103
27256
  }
27104
27257
  } else if (resolved.layout) {
27105
27258
  layer.layout = resolved.layout;
@@ -28051,91 +28204,272 @@ function getOrderedClasses() {
28051
28204
  return ordered;
28052
28205
  }
28053
28206
 
28054
- // src/atlas/land-layers.ts
28055
- var FADE_PX = 80;
28056
- var FADE_BANDS = 16;
28057
- function fadeAlpha(r) {
28058
- return Math.max(0, Math.min(1, (FADE_PX / 2 - Math.abs(r)) / FADE_PX));
28207
+ // src/atlas/band-priority.ts
28208
+ var BAND_PRIORITY_RANGES = {
28209
+ "1": { minzoom: 0 },
28210
+ "2": { minzoom: 5 },
28211
+ "3": { minzoom: 7 },
28212
+ "4": { minzoom: 9 },
28213
+ "5": { minzoom: 11 },
28214
+ "6": { minzoom: 11 }
28215
+ };
28216
+ var BAND_DATA_MINZOOM = {
28217
+ "1": 0,
28218
+ "2": 5,
28219
+ "3": 7,
28220
+ "4": 9,
28221
+ "5": 11,
28222
+ "6": 13
28223
+ };
28224
+ function dominatedVariantMaxzoom(D) {
28225
+ return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
28059
28226
  }
28060
- var LAND_THEMES = {
28061
- DAY: {
28062
- base: "#e8d9b0",
28063
- cover: { wood: "#cfd8b4", wetland: "#c9d3c4", urban: "#dedad2", sand: "#efe4c2" },
28064
- contour: "#a89878",
28065
- contourIndex: "#8a7a5c",
28066
- road: "#d9c9a4",
28067
- roadMajor: "#d2bd8e",
28068
- label: "#5b4f3a",
28069
- halo: "#f2ead6",
28070
- shadow: "#6b5a3e",
28071
- highlight: "#fffaf0",
28072
- fade: "#e8f2f8",
28073
- // Raised from 0.45 on 2026-08-23. ⚠ Above the ~0.5 that
28074
- // hillshade-style.mjs warns "starts competing with depth areas" — a
28075
- // deliberate, sighted choice made against rendered evidence, not a
28076
- // default. If soundings or DEPARE ever read as fighting the terrain,
28077
- // this number is the first thing to walk back.
28078
- exaggeration: 0.8
28079
- },
28080
- DUSK: {
28081
- base: "#4a4433",
28082
- cover: { wood: "#414a38", wetland: "#3f4740", urban: "#4e4a42", sand: "#544c39" },
28083
- contour: "#6b6250",
28084
- contourIndex: "#837961",
28085
- road: "#565040",
28086
- roadMajor: "#63594440",
28087
- label: "#c3b898",
28088
- halo: "#2b2820",
28089
- shadow: "#2e2a20",
28090
- highlight: "#8a8068",
28091
- fade: "#16202a",
28092
- // Unchanged. DUSK was not the palette the 0.8 decision was made against.
28093
- exaggeration: 0.45
28094
- },
28095
- NIGHT: {
28096
- base: "#2a2620",
28097
- cover: { wood: "#242a20", wetland: "#232823", urban: "#2c2925", sand: "#302b22" },
28098
- contour: "#413b30",
28099
- contourIndex: "#524a3b",
28100
- road: "#332f27",
28101
- roadMajor: "#3d382d",
28102
- label: "#7d745e",
28103
- halo: "#15130f",
28104
- shadow: "#1a1712",
28105
- highlight: "#4a4436",
28106
- fade: "#07090b",
28107
- // ⚠ DELIBERATELY NOT RAISED. See the type's note: a strong hillshade
28108
- // under red night vision is a grey haze over exactly the contrast a
28109
- // skipper is navigating by. Left at the shipped value rather than
28110
- // lowered, because lowering it is a separate decision from this one.
28111
- exaggeration: 0.45
28227
+ var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
28228
+ "LNDARE",
28229
+ "DEPARE",
28230
+ "UNSARE",
28231
+ "LAKARE",
28232
+ "DRGARE",
28233
+ "SEAARE"
28234
+ ]);
28235
+ function isExemptOpaqueFill(layer) {
28236
+ if (layer.type !== "fill") return false;
28237
+ for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
28238
+ if (layer.id.startsWith(`s52-${cls}-`)) return true;
28112
28239
  }
28113
- };
28114
- var FITS = ["<=", ["coalesce", ["get", "min_z"], 0], ["zoom"]];
28115
- var ANCHOR = ["coalesce", ["get", "anchor"], "top"];
28116
- var anchorOffset = (em) => [
28117
- "match",
28118
- ["get", "anchor"],
28119
- "left",
28120
- ["literal", [0.6, 0]],
28121
- "right",
28122
- ["literal", [-0.6, 0]],
28123
- "bottom",
28124
- ["literal", [0, -em]],
28125
- ["literal", [0, em]]
28126
- ];
28127
- var FONT = ["Open Sans Regular"];
28128
- var FONT_ITALIC = ["Open Sans Italic"];
28129
- function landLayers(opts = {}) {
28130
- const palette = opts.palette ?? "DAY";
28131
- const T = LAND_THEMES[palette] ?? LAND_THEMES.DAY;
28132
- const src = opts.sourceId ?? LAND_REGION;
28133
- const demSrc = opts.reliefSourceId ?? RELIEF_REGION;
28134
- const L = (o) => o;
28135
- const layers = [
28136
- // 1. The land itself — LNDARE ∩ coverage, so it can never outrun the chart.
28137
- L({
28138
- id: "chrtland-base",
28240
+ return false;
28241
+ }
28242
+ var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28243
+ "1",
28244
+ "2",
28245
+ "3"
28246
+ ]);
28247
+ function bandVariantMinzoom(layer, B) {
28248
+ const classMinzoom = layer.minzoom ?? 0;
28249
+ if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28250
+ return classMinzoom;
28251
+ }
28252
+ return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
28253
+ }
28254
+ var GENERALISED_AREA_FILL_CLASSES = /* @__PURE__ */ new Set([
28255
+ "BUAARE",
28256
+ // 2026-08-28, New York Harbor: the "New York Marine Inspection Zone and
28257
+ // Captain of the Port Zone" RESARE exists at bands 2/3 as a generalized
28258
+ // shore-hugging polygon whose point-sampled dominance tag MISSED (the
28259
+ // Providence-BUAARE failure mode), so its magenta dashed boundary drew
28260
+ // OFFSET over the band-5 detail at z11.5 — read in the field as "a
28261
+ // magenta shoreline bleeding down from a less coarse band". The same
28262
+ // tile carried untagged band-2 ADMARE and CTNARE giants in the same
28263
+ // state. All three are area classes whose coarse-band boundaries hug
28264
+ // shorelines and whose fine-band copies are authoritative.
28265
+ "RESARE",
28266
+ "CTNARE",
28267
+ "ADMARE"
28268
+ ]);
28269
+ var GENERALISED_AREA_MAX_BAND = 3;
28270
+ function generalisedAreaCeiling(layer, B) {
28271
+ if (layer.type !== "fill" && layer.type !== "line") return null;
28272
+ if (parseInt(B, 10) > GENERALISED_AREA_MAX_BAND) return null;
28273
+ for (const cls of GENERALISED_AREA_FILL_CLASSES) {
28274
+ if (layer.id.startsWith(`s52-${cls}-`)) return BAND_PRIORITY_RANGES["5"].minzoom;
28275
+ }
28276
+ return null;
28277
+ }
28278
+ var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
28279
+ var CHART_BAND_CODES = {
28280
+ Overview: ["1"],
28281
+ General: ["2"],
28282
+ Coastal: ["3"],
28283
+ Approach: ["4"],
28284
+ Harbor: ["5", "6"]
28285
+ };
28286
+ var CHART_CASCADE_CODES = {
28287
+ Overview: ["1"],
28288
+ General: ["1", "2"],
28289
+ Coastal: ["1", "2", "3"],
28290
+ Approach: ["1", "2", "3", "4"],
28291
+ Harbor: ["1", "2", "3", "4", "5", "6"]
28292
+ };
28293
+ var CHRT_STYLE_MODES = [
28294
+ "continuous",
28295
+ ...Object.keys(CHART_BAND_CODES).map(
28296
+ (band) => `chart:${band}`
28297
+ )
28298
+ ];
28299
+ function normalizeStyleMode(raw) {
28300
+ if (raw === null || raw === void 0 || raw === "") return "continuous";
28301
+ const lower = raw.toLowerCase();
28302
+ return CHRT_STYLE_MODES.find((mode) => mode.toLowerCase() === lower) ?? null;
28303
+ }
28304
+ function styleModeBand(mode) {
28305
+ return mode === "continuous" ? null : mode.slice("chart:".length);
28306
+ }
28307
+ function bandFilterExpression(codes) {
28308
+ const list = Array.isArray(codes) ? codes : [codes];
28309
+ if (list.length === 1) {
28310
+ return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
28311
+ }
28312
+ return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
28313
+ }
28314
+ function combineWithBandFilter(baseFilter, bandFilter) {
28315
+ if (!baseFilter) return bandFilter;
28316
+ if (baseFilter[0] === "all") {
28317
+ return ["all", ...baseFilter.slice(1), bandFilter];
28318
+ }
28319
+ return ["all", baseFilter, bandFilter];
28320
+ }
28321
+ var NOT_DOMINATED_FILTER = [
28322
+ "all",
28323
+ ["!", ["has", "_dominated_by_min"]],
28324
+ ["!=", ["get", "_dominated"], true]
28325
+ ];
28326
+ function dominatedByMinFilter(D) {
28327
+ const dInt = parseInt(D, 10);
28328
+ if (D === "6") {
28329
+ return [
28330
+ "any",
28331
+ ["==", ["get", "_dominated_by_min"], dInt],
28332
+ ["==", ["get", "_dominated"], true]
28333
+ ];
28334
+ }
28335
+ return ["==", ["get", "_dominated_by_min"], dInt];
28336
+ }
28337
+ function withinBboxFilter(bbox) {
28338
+ const [w, s] = bbox.sw;
28339
+ const [e, n] = bbox.ne;
28340
+ return [
28341
+ "within",
28342
+ {
28343
+ type: "Polygon",
28344
+ coordinates: [
28345
+ [
28346
+ [w, s],
28347
+ [e, s],
28348
+ [e, n],
28349
+ [w, n],
28350
+ [w, s]
28351
+ ]
28352
+ ]
28353
+ }
28354
+ ];
28355
+ }
28356
+ function worldMinusBboxPolygon(bbox) {
28357
+ const [w, s] = bbox.sw;
28358
+ const [e, n] = bbox.ne;
28359
+ return {
28360
+ type: "Polygon",
28361
+ coordinates: [
28362
+ [
28363
+ [-180, -85],
28364
+ [180, -85],
28365
+ [180, 85],
28366
+ [-180, 85],
28367
+ [-180, -85]
28368
+ ],
28369
+ // Inner ring (hole) — winding opposite to outer ring per GeoJSON
28370
+ // spec. MapLibre is permissive but we follow strict winding.
28371
+ [
28372
+ [w, s],
28373
+ [w, n],
28374
+ [e, n],
28375
+ [e, s],
28376
+ [w, s]
28377
+ ]
28378
+ ]
28379
+ };
28380
+ }
28381
+
28382
+ // src/atlas/land-layers.ts
28383
+ var LAND_SURFACE_MINZOOM = BAND_PRIORITY_RANGES["5"].minzoom;
28384
+ var RELIEF_MINZOOM2 = 10;
28385
+ var LAND_SURFACE_LAYERS = (id) => id === "chrtland-base" || id === "chrtland-cover" || id === "chrtland-contour" || id.startsWith("chrtland-fade-");
28386
+ var FADE_PX = 80;
28387
+ var FADE_BANDS = 16;
28388
+ function fadeAlpha(r) {
28389
+ return Math.max(0, Math.min(1, (FADE_PX / 2 - Math.abs(r)) / FADE_PX));
28390
+ }
28391
+ function landBaseColor(palette) {
28392
+ return (LAND_THEMES[palette] ?? LAND_THEMES.DAY).base;
28393
+ }
28394
+ var LAND_THEMES = {
28395
+ DAY: {
28396
+ base: "#e8d9b0",
28397
+ cover: { wood: "#cfd8b4", wetland: "#c9d3c4", urban: "#dedad2", sand: "#efe4c2" },
28398
+ contour: "#a89878",
28399
+ contourIndex: "#8a7a5c",
28400
+ road: "#d9c9a4",
28401
+ roadMajor: "#d2bd8e",
28402
+ label: "#5b4f3a",
28403
+ halo: "#f2ead6",
28404
+ shadow: "#6b5a3e",
28405
+ highlight: "#fffaf0",
28406
+ fade: "#e8f2f8",
28407
+ // Raised from 0.45 on 2026-08-23. ⚠ Above the ~0.5 that
28408
+ // hillshade-style.mjs warns "starts competing with depth areas" — a
28409
+ // deliberate, sighted choice made against rendered evidence, not a
28410
+ // default. If soundings or DEPARE ever read as fighting the terrain,
28411
+ // this number is the first thing to walk back.
28412
+ exaggeration: 0.8
28413
+ },
28414
+ DUSK: {
28415
+ base: "#4a4433",
28416
+ cover: { wood: "#414a38", wetland: "#3f4740", urban: "#4e4a42", sand: "#544c39" },
28417
+ contour: "#6b6250",
28418
+ contourIndex: "#837961",
28419
+ road: "#565040",
28420
+ roadMajor: "#63594440",
28421
+ label: "#c3b898",
28422
+ halo: "#2b2820",
28423
+ shadow: "#2e2a20",
28424
+ highlight: "#8a8068",
28425
+ fade: "#16202a",
28426
+ // Unchanged. DUSK was not the palette the 0.8 decision was made against.
28427
+ exaggeration: 0.45
28428
+ },
28429
+ NIGHT: {
28430
+ base: "#2a2620",
28431
+ cover: { wood: "#242a20", wetland: "#232823", urban: "#2c2925", sand: "#302b22" },
28432
+ contour: "#413b30",
28433
+ contourIndex: "#524a3b",
28434
+ road: "#332f27",
28435
+ roadMajor: "#3d382d",
28436
+ label: "#7d745e",
28437
+ halo: "#15130f",
28438
+ shadow: "#1a1712",
28439
+ highlight: "#4a4436",
28440
+ fade: "#07090b",
28441
+ // ⚠ DELIBERATELY NOT RAISED. See the type's note: a strong hillshade
28442
+ // under red night vision is a grey haze over exactly the contrast a
28443
+ // skipper is navigating by. Left at the shipped value rather than
28444
+ // lowered, because lowering it is a separate decision from this one.
28445
+ exaggeration: 0.45
28446
+ }
28447
+ };
28448
+ var FITS = ["<=", ["coalesce", ["get", "min_z"], 0], ["zoom"]];
28449
+ var ANCHOR = ["coalesce", ["get", "anchor"], "top"];
28450
+ var anchorOffset = (em) => [
28451
+ "match",
28452
+ ["get", "anchor"],
28453
+ "left",
28454
+ ["literal", [0.6, 0]],
28455
+ "right",
28456
+ ["literal", [-0.6, 0]],
28457
+ "bottom",
28458
+ ["literal", [0, -em]],
28459
+ ["literal", [0, em]]
28460
+ ];
28461
+ var FONT = ["Open Sans Regular"];
28462
+ var FONT_ITALIC = ["Open Sans Italic"];
28463
+ function landLayers(opts = {}) {
28464
+ const palette = opts.palette ?? "DAY";
28465
+ const T = LAND_THEMES[palette] ?? LAND_THEMES.DAY;
28466
+ const src = opts.sourceId ?? LAND_REGION;
28467
+ const demSrc = opts.reliefSourceId ?? RELIEF_REGION;
28468
+ const L = (o) => o;
28469
+ const layers = [
28470
+ // 1. The land itself — LNDARE ∩ coverage, so it can never outrun the chart.
28471
+ L({
28472
+ id: "chrtland-base",
28139
28473
  type: "fill",
28140
28474
  source: src,
28141
28475
  "source-layer": "land_base",
@@ -28170,6 +28504,7 @@ function landLayers(opts = {}) {
28170
28504
  id: "chrtland-hillshade",
28171
28505
  type: "hillshade",
28172
28506
  source: demSrc,
28507
+ minzoom: RELIEF_MINZOOM2,
28173
28508
  paint: {
28174
28509
  "hillshade-exaggeration": T.exaggeration,
28175
28510
  "hillshade-shadow-color": T.shadow,
@@ -28221,13 +28556,78 @@ function landLayers(opts = {}) {
28221
28556
  "line-opacity": 0.8
28222
28557
  }
28223
28558
  }));
28559
+ layers.push(L({
28560
+ id: "chrtland-city-major",
28561
+ type: "symbol",
28562
+ source: src,
28563
+ "source-layer": "land_town",
28564
+ minzoom: 6.5,
28565
+ filter: [">=", ["coalesce", ["get", "prank"], 0], 10],
28566
+ layout: {
28567
+ "text-field": [
28568
+ "case",
28569
+ ["==", ["get", "upcase"], true],
28570
+ ["upcase", ["get", "name"]],
28571
+ ["get", "name"]
28572
+ ],
28573
+ "text-font": FONT,
28574
+ "text-size": [
28575
+ "interpolate",
28576
+ ["linear"],
28577
+ ["zoom"],
28578
+ 6.5,
28579
+ 11.5,
28580
+ 9,
28581
+ ["+", 10, ["*", 0.4, ["coalesce", ["get", "prank"], 10]]],
28582
+ 13,
28583
+ ["+", 13, ["*", 0.5, ["coalesce", ["get", "prank"], 10]]]
28584
+ ],
28585
+ "text-letter-spacing": ["case", ["==", ["get", "upcase"], true], 0.14, 0.02],
28586
+ "text-offset": anchorOffset(0.75),
28587
+ "text-anchor": ANCHOR,
28588
+ "text-padding": 10,
28589
+ "text-allow-overlap": false,
28590
+ "symbol-sort-key": ["*", -1, ["coalesce", ["get", "pop"], 0]]
28591
+ },
28592
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.6 }
28593
+ }));
28594
+ layers.push(L({
28595
+ id: "chrtland-town-port",
28596
+ type: "symbol",
28597
+ source: src,
28598
+ "source-layer": "land_town",
28599
+ minzoom: 8.5,
28600
+ filter: [
28601
+ "all",
28602
+ ["==", ["get", "enc_dup"], true],
28603
+ ["<", ["coalesce", ["get", "prank"], 0], 10]
28604
+ ],
28605
+ layout: {
28606
+ "text-field": ["get", "name"],
28607
+ "text-font": FONT,
28608
+ "text-size": ["interpolate", ["linear"], ["zoom"], 8.5, 11, 12, 13.5],
28609
+ "text-offset": anchorOffset(0.75),
28610
+ "text-anchor": ANCHOR,
28611
+ "text-padding": 8,
28612
+ "text-allow-overlap": false,
28613
+ // Within the tier, the bigger place wins the space: without this the
28614
+ // tie broke on data order and "West Gloucester" beat Gloucester.
28615
+ "symbol-sort-key": ["*", -1, ["coalesce", ["get", "pop"], 0]]
28616
+ },
28617
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.4 }
28618
+ }));
28224
28619
  layers.push(L({
28225
28620
  id: "chrtland-town",
28226
28621
  type: "symbol",
28227
28622
  source: src,
28228
28623
  "source-layer": "land_town",
28229
28624
  minzoom: 9,
28230
- filter: FITS,
28625
+ filter: [
28626
+ "all",
28627
+ FITS,
28628
+ ["<", ["coalesce", ["get", "prank"], 0], 10],
28629
+ ["!=", ["get", "enc_dup"], true]
28630
+ ],
28231
28631
  layout: {
28232
28632
  "text-field": [
28233
28633
  "case",
@@ -28260,187 +28660,316 @@ function landLayers(opts = {}) {
28260
28660
  "text-offset": anchorOffset(0.8),
28261
28661
  "text-anchor": ANCHOR,
28262
28662
  "text-padding": 5,
28263
- "text-allow-overlap": false,
28264
- "symbol-sort-key": 3
28265
- },
28266
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28267
- }));
28268
- layers.push(L({
28269
- id: "chrtland-poi",
28270
- type: "symbol",
28271
- source: src,
28272
- "source-layer": "land_poi",
28273
- minzoom: 13,
28274
- filter: FITS,
28275
- layout: {
28276
- "text-field": ["get", "name"],
28277
- "text-font": FONT_ITALIC,
28278
- "text-size": ["interpolate", ["linear"], ["zoom"], 13, 8, 14, 9.5],
28279
- "text-offset": anchorOffset(0.7),
28280
- "text-anchor": ANCHOR,
28281
- "text-padding": 5,
28282
- "text-allow-overlap": false,
28283
- "symbol-sort-key": 5
28284
- },
28285
- paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28286
- }));
28287
- return layers;
28288
- }
28289
-
28290
- // src/atlas/band-priority.ts
28291
- var BAND_PRIORITY_RANGES = {
28292
- "1": { minzoom: 0 },
28293
- "2": { minzoom: 5 },
28294
- "3": { minzoom: 7 },
28295
- "4": { minzoom: 9 },
28296
- "5": { minzoom: 11 },
28297
- "6": { minzoom: 11 }
28298
- };
28299
- var BAND_DATA_MINZOOM = {
28300
- "1": 0,
28301
- "2": 5,
28302
- "3": 7,
28303
- "4": 9,
28304
- "5": 11,
28305
- "6": 13
28306
- };
28307
- function dominatedVariantMaxzoom(D) {
28308
- return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
28309
- }
28310
- var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
28311
- "LNDARE",
28312
- "DEPARE",
28313
- "UNSARE",
28314
- "LAKARE",
28315
- "DRGARE",
28316
- "SEAARE"
28317
- ]);
28318
- function isExemptOpaqueFill(layer) {
28319
- if (layer.type !== "fill") return false;
28320
- for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
28321
- if (layer.id.startsWith(`s52-${cls}-`)) return true;
28663
+ "text-allow-overlap": false,
28664
+ "symbol-sort-key": 3
28665
+ },
28666
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28667
+ }));
28668
+ layers.push(L({
28669
+ id: "chrtland-poi",
28670
+ type: "symbol",
28671
+ source: src,
28672
+ "source-layer": "land_poi",
28673
+ minzoom: 13,
28674
+ filter: FITS,
28675
+ layout: {
28676
+ "text-field": ["get", "name"],
28677
+ "text-font": FONT_ITALIC,
28678
+ "text-size": ["interpolate", ["linear"], ["zoom"], 13, 8, 14, 9.5],
28679
+ "text-offset": anchorOffset(0.7),
28680
+ "text-anchor": ANCHOR,
28681
+ "text-padding": 5,
28682
+ "text-allow-overlap": false,
28683
+ "symbol-sort-key": 5
28684
+ },
28685
+ paint: { "text-color": T.label, "text-halo-color": T.halo, "text-halo-width": 1.2 }
28686
+ }));
28687
+ for (const layer of layers) {
28688
+ const l = layer;
28689
+ if (!LAND_SURFACE_LAYERS(l.id)) continue;
28690
+ l.minzoom = Math.max(l.minzoom ?? 0, LAND_SURFACE_MINZOOM);
28322
28691
  }
28323
- return false;
28692
+ return layers;
28324
28693
  }
28325
- var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28326
- "1",
28327
- "2",
28328
- "3"
28329
- ]);
28330
- function bandVariantMinzoom(layer, B) {
28331
- const classMinzoom = layer.minzoom ?? 0;
28332
- if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28333
- return classMinzoom;
28694
+
28695
+ // src/atlas/base-layers.ts
28696
+ var NOCOVERAGE_PATTERN = "chrt:hatch-nocoverage";
28697
+ var UNCHARTED_PATTERN = "chrt:hatch-uncharted";
28698
+ var BASE_THEMES = {
28699
+ DAY: {
28700
+ earth: "#2A3439",
28701
+ water: "#1B2328",
28702
+ river: "#24313A",
28703
+ boundary: "#4A575E",
28704
+ jurisdiction: "#E8D9B0",
28705
+ // CHRTland's own tan — one land colour
28706
+ inlandWater: "#B9CFDD",
28707
+ // muted, unmistakably not a charted blue
28708
+ // State furniture. These three were referenced by the layers but missing
28709
+ // from this table (and its type — the long-standing TS2339 trio), so the
28710
+ // lines shipped on MapLibre's DEFAULT black. Now deliberate: a hairline
28711
+ // a shade deeper than the tan, quiet caps, tan halo.
28712
+ stateLine: "#C4B389",
28713
+ stateLabel: "#8E8468",
28714
+ stateHalo: "#E8D9B0",
28715
+ // Rough-lake furniture (owner ask 2026-08-28: "the lakes need labels,
28716
+ // and shorelines drawn"): a shoreline a shade deeper than the rough
28717
+ // water, and a name in the water's own voice, haloed by the water.
28718
+ inlandShore: "#8FA9BA",
28719
+ inlandLabel: "#54707F",
28720
+ inlandHalo: "#B9CFDD"
28721
+ },
28722
+ DUSK: {
28723
+ earth: "#232B30",
28724
+ water: "#161D21",
28725
+ river: "#1E2930",
28726
+ boundary: "#3E4A50",
28727
+ jurisdiction: "#C9BC96",
28728
+ inlandWater: "#93A9B7",
28729
+ stateLine: "#A6976F",
28730
+ stateLabel: "#776E55",
28731
+ stateHalo: "#C9BC96",
28732
+ inlandShore: "#74899A",
28733
+ inlandLabel: "#41586A",
28734
+ inlandHalo: "#93A9B7"
28735
+ },
28736
+ NIGHT: {
28737
+ earth: "#1A2125",
28738
+ water: "#10161A",
28739
+ river: "#182228",
28740
+ boundary: "#333E44",
28741
+ jurisdiction: "#8F866C",
28742
+ inlandWater: "#5F7280",
28743
+ stateLine: "#6B6350",
28744
+ stateLabel: "#565040",
28745
+ stateHalo: "#8F866C",
28746
+ inlandShore: "#4A5C68",
28747
+ inlandLabel: "#8CA2B0",
28748
+ inlandHalo: "#5F7280"
28334
28749
  }
28335
- return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
28336
- }
28337
- var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
28338
- var CHART_BAND_CODES = {
28339
- Overview: ["1"],
28340
- General: ["2"],
28341
- Coastal: ["3"],
28342
- Approach: ["4"],
28343
- Harbor: ["5", "6"]
28344
- };
28345
- var CHART_CASCADE_CODES = {
28346
- Overview: ["1"],
28347
- General: ["1", "2"],
28348
- Coastal: ["1", "2", "3"],
28349
- Approach: ["1", "2", "3", "4"],
28350
- Harbor: ["1", "2", "3", "4", "5", "6"]
28351
28750
  };
28352
- var CHRT_STYLE_MODES = [
28353
- "continuous",
28354
- ...Object.keys(CHART_BAND_CODES).map(
28355
- (band) => `chart:${band}`
28356
- )
28357
- ];
28358
- function normalizeStyleMode(raw) {
28359
- if (raw === null || raw === void 0 || raw === "") return "continuous";
28360
- const lower = raw.toLowerCase();
28361
- return CHRT_STYLE_MODES.find((mode) => mode.toLowerCase() === lower) ?? null;
28362
- }
28363
- function styleModeBand(mode) {
28364
- return mode === "continuous" ? null : mode.slice("chart:".length);
28365
- }
28366
- function bandFilterExpression(codes) {
28367
- const list = Array.isArray(codes) ? codes : [codes];
28368
- if (list.length === 1) {
28369
- return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
28370
- }
28371
- return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
28372
- }
28373
- function combineWithBandFilter(baseFilter, bandFilter) {
28374
- if (!baseFilter) return bandFilter;
28375
- if (baseFilter[0] === "all") {
28376
- return ["all", ...baseFilter.slice(1), bandFilter];
28377
- }
28378
- return ["all", baseFilter, bandFilter];
28379
- }
28380
- var NOT_DOMINATED_FILTER = [
28381
- "all",
28382
- ["!", ["has", "_dominated_by_min"]],
28383
- ["!=", ["get", "_dominated"], true]
28384
- ];
28385
- function dominatedByMinFilter(D) {
28386
- const dInt = parseInt(D, 10);
28387
- if (D === "6") {
28388
- return [
28389
- "any",
28390
- ["==", ["get", "_dominated_by_min"], dInt],
28391
- ["==", ["get", "_dominated"], true]
28392
- ];
28393
- }
28394
- return ["==", ["get", "_dominated_by_min"], dInt];
28395
- }
28396
- function withinBboxFilter(bbox) {
28397
- const [w, s] = bbox.sw;
28398
- const [e, n] = bbox.ne;
28751
+ function baseLayers(opts = {}) {
28752
+ const palette = opts.palette ?? "DAY";
28753
+ const T = BASE_THEMES[palette] ?? BASE_THEMES.DAY;
28754
+ const src = opts.sourceId ?? BASE_REGION;
28755
+ const L = (o) => o;
28399
28756
  return [
28400
- "within",
28401
- {
28402
- type: "Polygon",
28403
- coordinates: [
28404
- [
28405
- [w, s],
28406
- [e, s],
28407
- [e, n],
28408
- [w, n],
28409
- [w, s]
28410
- ]
28411
- ]
28412
- }
28757
+ // 1. Geography — the world as it is, in the quiet dark voice. The OCEAN
28758
+ // is a background, not a polygon: the archive ships land and lakes,
28759
+ // and "everything else is sea" is exactly what a background expresses
28760
+ // — geometry-free, gap-free at every antimeridian and pole. Without
28761
+ // it the sea outside coverage was bare S-52 NODTA, a light grey that
28762
+ // read as "charted ground" with a hatch on it.
28763
+ L({
28764
+ id: "chrtbase-ocean",
28765
+ type: "background",
28766
+ paint: { "background-color": T.water }
28767
+ }),
28768
+ L({
28769
+ id: "chrtbase-earth",
28770
+ type: "fill",
28771
+ source: src,
28772
+ "source-layer": "base_earth",
28773
+ paint: { "fill-color": T.earth }
28774
+ }),
28775
+ L({
28776
+ id: "chrtbase-water",
28777
+ type: "fill",
28778
+ source: src,
28779
+ "source-layer": "base_water",
28780
+ paint: { "fill-color": T.water }
28781
+ }),
28782
+ L({
28783
+ id: "chrtbase-river",
28784
+ type: "line",
28785
+ source: src,
28786
+ "source-layer": "base_river",
28787
+ minzoom: 9,
28788
+ paint: {
28789
+ "line-color": T.river,
28790
+ "line-width": ["interpolate", ["linear"], ["zoom"], 9, 0.6, 14, 1.6]
28791
+ }
28792
+ }),
28793
+ L({
28794
+ id: "chrtbase-boundary",
28795
+ type: "line",
28796
+ source: src,
28797
+ "source-layer": "base_boundary",
28798
+ paint: { "line-color": T.boundary, "line-width": 0.6, "line-opacity": 0.8 }
28799
+ }),
28800
+ // 2. THE DEFAULT: no coverage, everywhere, as a geometry-free background.
28801
+ // Constant opacity by design — see the header. What kills it is being
28802
+ // painted OVER, never fading.
28803
+ L({
28804
+ id: "chrtbase-nocoverage",
28805
+ type: "background",
28806
+ paint: { "background-pattern": NOCOVERAGE_PATTERN, "background-opacity": 0.55 }
28807
+ }),
28808
+ // 3. The jurisdiction: countries whose charts CHRT carries go tan — the
28809
+ // same tan as CHRTland, so "land in our world" is ONE colour. Opaque,
28810
+ // so it takes the crosshatch off its ground entirely.
28811
+ L({
28812
+ id: "chrtbase-jurisdiction",
28813
+ type: "fill",
28814
+ source: src,
28815
+ "source-layer": "jurisdiction",
28816
+ paint: { "fill-color": T.jurisdiction }
28817
+ }),
28818
+ // 4. States — internal boundaries and names, inside the jurisdiction
28819
+ // only (owner decision 2026-08-28). A hairline a shade deeper than the
28820
+ // tan, and quiet letter-spaced caps that fade as harbour detail takes
28821
+ // over. The one deliberate exception to no-names-on-the-basemap:
28822
+ // ADMIN furniture, nothing else — see the fence test.
28823
+ L({
28824
+ id: "chrtbase-state-boundary",
28825
+ type: "line",
28826
+ source: src,
28827
+ "source-layer": "state_boundary",
28828
+ minzoom: 4,
28829
+ paint: {
28830
+ "line-color": T.stateLine,
28831
+ "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.5, 10, 1],
28832
+ "line-dasharray": [3, 2],
28833
+ "line-opacity": 0.8
28834
+ }
28835
+ }),
28836
+ // 5. Country borders, drawn to be HOISTED above the whole chart by the
28837
+ // generator (owner, 2026-08-31: "where there is charted land, can we
28838
+ // add country borders on top of everything? I want it clear where
28839
+ // there is a boarder."). Sourced from NE boundary_lines_land — land
28840
+ // borders only, never coastline — so riding on top cannot stroke a
28841
+ // coast. Dashed so it reads as an administrative line, not chart
28842
+ // linework.
28843
+ L({
28844
+ id: "chrtbase-country-boundary",
28845
+ type: "line",
28846
+ source: src,
28847
+ "source-layer": "country_boundary",
28848
+ paint: {
28849
+ "line-color": T.boundary,
28850
+ "line-width": ["interpolate", ["linear"], ["zoom"], 3, 0.8, 8, 1.4, 12, 2.2],
28851
+ "line-dasharray": [4, 2],
28852
+ "line-opacity": 0.9
28853
+ }
28854
+ }),
28855
+ L({
28856
+ id: "chrtbase-state-label",
28857
+ type: "symbol",
28858
+ source: src,
28859
+ "source-layer": "state_label",
28860
+ minzoom: 4,
28861
+ maxzoom: 10,
28862
+ layout: {
28863
+ "text-field": ["upcase", ["get", "name"]],
28864
+ "text-font": ["Open Sans Regular"],
28865
+ "text-size": ["interpolate", ["linear"], ["zoom"], 4, 9, 8, 12],
28866
+ "text-letter-spacing": 0.25,
28867
+ "text-allow-overlap": false,
28868
+ "text-padding": 8
28869
+ },
28870
+ paint: {
28871
+ "text-color": T.stateLabel,
28872
+ "text-halo-color": T.stateHalo,
28873
+ "text-halo-width": 1.2,
28874
+ "text-opacity": ["interpolate", ["linear"], ["zoom"], 4, 0.7, 8, 0.85, 10, 0.4]
28875
+ }
28876
+ })
28413
28877
  ];
28414
28878
  }
28415
- function worldMinusBboxPolygon(bbox) {
28416
- const [w, s] = bbox.sw;
28417
- const [e, n] = bbox.ne;
28418
- return {
28419
- type: "Polygon",
28420
- coordinates: [
28421
- [
28422
- [-180, -85],
28423
- [180, -85],
28424
- [180, 85],
28425
- [-180, 85],
28426
- [-180, -85]
28427
- ],
28428
- // Inner ring (hole) — winding opposite to outer ring per GeoJSON
28429
- // spec. MapLibre is permissive but we follow strict winding.
28430
- [
28431
- [w, s],
28432
- [w, n],
28433
- [e, n],
28434
- [e, s],
28435
- [w, s]
28436
- ]
28437
- ]
28438
- };
28879
+ function baseInlandWaterLayers(opts = {}) {
28880
+ const palette = opts.palette ?? "DAY";
28881
+ const T = BASE_THEMES[palette] ?? BASE_THEMES.DAY;
28882
+ const src = opts.sourceId ?? BASE_REGION;
28883
+ const L = (o) => o;
28884
+ return [
28885
+ L({
28886
+ id: "chrtbase-inland-water",
28887
+ type: "fill",
28888
+ source: src,
28889
+ "source-layer": "inland_water",
28890
+ paint: { "fill-color": T.inlandWater }
28891
+ }),
28892
+ L({
28893
+ id: "chrtbase-inland-hatch",
28894
+ type: "fill",
28895
+ source: src,
28896
+ "source-layer": "inland_water",
28897
+ paint: { "fill-pattern": UNCHARTED_PATTERN, "fill-opacity": 0.7 }
28898
+ }),
28899
+ // The rough shoreline (owner ask 2026-08-28). Drawn ABOVE the hatch so
28900
+ // the edge stays crisp through the texture, and BELOW the charted-water
28901
+ // repaint clones the generator splices in after it — so wherever the
28902
+ // chart repaints its own water, the rough NE10m line yields to the
28903
+ // chart's COALNE instead of double-striking it.
28904
+ //
28905
+ // NE-tier geometry ONLY (`kind` absent): the planet detail tier joins
28906
+ // the same source-layer from z9 as MANY polygons whose chunk-clip seams
28907
+ // rejoin invisibly as FILLS — but a line layer strokes every seam, and
28908
+ // Winnipesaukee rendered as a mesh of internal edges. Planet features
28909
+ // carry `kind` (the INLAND_KINDS filter's own field); NE's carry only
28910
+ // `name`, so the absence of `kind` IS the tier discriminator.
28911
+ L({
28912
+ id: "chrtbase-inland-shore",
28913
+ type: "line",
28914
+ source: src,
28915
+ "source-layer": "inland_water",
28916
+ filter: ["!", ["has", "kind"]],
28917
+ paint: {
28918
+ "line-color": T.inlandShore,
28919
+ "line-width": ["interpolate", ["linear"], ["zoom"], 4, 0.5, 10, 1.1, 14, 1.6],
28920
+ "line-opacity": 0.9
28921
+ }
28922
+ }),
28923
+ // The lake's name (owner ask 2026-08-28) — the second scoped exception
28924
+ // to no-names-on-the-basemap, after the states: WATER-BODY names only.
28925
+ // No maxzoom: an uncharted lake keeps its name at every zoom; over
28926
+ // charted lakes the chart's own SEAARE label takes over at detail zooms
28927
+ // and collision culls the echo.
28928
+ //
28929
+ // The archive's `inland_water_label` anchors (v3+). v3 shipped ONE
28930
+ // PointOnSurface anchor per lake, and a big lake's single anchor pans
28931
+ // off-viewport — Winnipesaukee at z11.7 read nameless with its name two
28932
+ // tiles west of the screen (owner-reported). Labelling the polygons
28933
+ // instead is NOT the fix: tippecanoe's coalescing drops the NE polygon
28934
+ // from some z11 tiles (measured: 11/619/747 carries only planet pieces),
28935
+ // so a polygon-sourced label vanishes tile by tile. v4 therefore bakes a
28936
+ // deterministic anchor GRID into big lakes — the paper-chart convention
28937
+ // of repeating a name along a large water body — and this one layer
28938
+ // renders however many anchors are in view; collision culls the rest.
28939
+ L({
28940
+ id: "chrtbase-inland-label",
28941
+ type: "symbol",
28942
+ source: src,
28943
+ "source-layer": "inland_water_label",
28944
+ minzoom: 5,
28945
+ layout: {
28946
+ "text-field": ["get", "name"],
28947
+ "text-font": ["Open Sans Regular"],
28948
+ "text-size": ["interpolate", ["linear"], ["zoom"], 5, 9.5, 10, 12],
28949
+ "text-max-width": 7,
28950
+ "text-allow-overlap": false,
28951
+ // Zoom-scaled spacing for the anchor GRID: at z8.9 the 0.08-deg
28952
+ // anchors sit ~54 px apart and a flat padding let six copies of
28953
+ // "Lake Ontario" crowd one screen (owner-reported 2026-08-30).
28954
+ // Wide collision boxes at low zoom thin the grid to the repeat-
28955
+ // by-convention cadence; by z12 the anchors are ~850 px apart and
28956
+ // the padding relaxes to normal.
28957
+ "text-padding": ["interpolate", ["linear"], ["zoom"], 5, 80, 9, 64, 12, 16]
28958
+ },
28959
+ paint: {
28960
+ "text-color": T.inlandLabel,
28961
+ "text-halo-color": T.inlandHalo,
28962
+ "text-halo-width": 1.1
28963
+ }
28964
+ })
28965
+ ];
28439
28966
  }
28440
28967
 
28441
28968
  // src/atlas/band-collapse.ts
28442
- function collapsedDominanceGate(B, minzoom) {
28443
- const clauses = [NOT_DOMINATED_FILTER];
28969
+ function collapsedDominanceGate(B, minzoom, notDominatedCeiling = null) {
28970
+ const clauses = [
28971
+ notDominatedCeiling !== null && notDominatedCeiling > minzoom ? ["all", NOT_DOMINATED_FILTER, ["<", ["zoom"], notDominatedCeiling]] : NOT_DOMINATED_FILTER
28972
+ ];
28444
28973
  const bInt = parseInt(B, 10);
28445
28974
  for (const D of BAND_DRAW_ORDER) {
28446
28975
  const dInt = parseInt(D, 10);
@@ -28469,7 +28998,7 @@ function makeCollapsedBandVariant(layer, B, isExempt) {
28469
28998
  filter: combineWithBandFilter(layer.filter, [
28470
28999
  "all",
28471
29000
  bandFilterExpression(B),
28472
- collapsedDominanceGate(B, minzoom)
29001
+ collapsedDominanceGate(B, minzoom, generalisedAreaCeiling(layer, B))
28473
29002
  ]),
28474
29003
  minzoom
28475
29004
  };
@@ -28604,6 +29133,7 @@ function makeBandVariants(layer, B) {
28604
29133
  delete exempt.maxzoom;
28605
29134
  return [exempt];
28606
29135
  }
29136
+ const ceiling = generalisedAreaCeiling(layer, B);
28607
29137
  const notDom = {
28608
29138
  ...layer,
28609
29139
  id: `${layer.id}-band${B}`,
@@ -28614,6 +29144,7 @@ function makeBandVariants(layer, B) {
28614
29144
  minzoom
28615
29145
  };
28616
29146
  delete notDom.maxzoom;
29147
+ if (ceiling !== null && ceiling > minzoom) notDom.maxzoom = ceiling;
28617
29148
  variants.push(notDom);
28618
29149
  const bInt = parseInt(B, 10);
28619
29150
  for (const D of BAND_DRAW_ORDER) {
@@ -28763,12 +29294,43 @@ function generateStyle(palette = "DAY", opts = {}) {
28763
29294
  paint: { "background-color": resolveColor("NODTA", palette) }
28764
29295
  };
28765
29296
  const layers = [backgroundLayer];
29297
+ if (opts.base) {
29298
+ const base = baseLayers({ palette });
29299
+ const coverageGround = {
29300
+ id: "s52-coverage-ground",
29301
+ type: "fill",
29302
+ source: sourceId,
29303
+ "source-layer": "M_COVR",
29304
+ filter: [
29305
+ "all",
29306
+ ["any", ["==", ["geometry-type"], "Polygon"], ["==", ["geometry-type"], "MultiPolygon"]],
29307
+ ["==", ["to-number", ["coalesce", ["get", "CATCOV"], 0]], 1]
29308
+ ],
29309
+ paint: { "fill-color": resolveColor("DEPDW", palette) },
29310
+ metadata: { "s52:objectClass": "M_COVR", "s52:role": "coverage-ground" }
29311
+ };
29312
+ const jurisdictionAt = base.findIndex(
29313
+ (l) => l.id === "chrtbase-jurisdiction"
29314
+ );
29315
+ if (jurisdictionAt === -1) {
29316
+ layers.push(...base, coverageGround);
29317
+ } else {
29318
+ layers.push(
29319
+ ...base.slice(0, jurisdictionAt),
29320
+ coverageGround,
29321
+ ...base.slice(jurisdictionAt)
29322
+ );
29323
+ }
29324
+ }
28766
29325
  if (opts.land) {
28767
29326
  layers.push(...landLayers({
28768
29327
  palette,
28769
29328
  ...opts.relief ? { relief: true } : {}
28770
29329
  }));
28771
29330
  }
29331
+ if (opts.base) {
29332
+ layers.push(...baseInlandWaterLayers({ palette }));
29333
+ }
28772
29334
  const labelLayers = [];
28773
29335
  const chartCascade = opts.chartMode ? CHART_CASCADE_CODES[opts.chartMode.band] : null;
28774
29336
  const chartRequestedCodes = opts.chartMode ? CHART_BAND_CODES[opts.chartMode.band] : null;
@@ -28885,6 +29447,22 @@ function generateStyle(palette = "DAY", opts = {}) {
28885
29447
  }
28886
29448
  }
28887
29449
  }
29450
+ {
29451
+ const TERMINAL_FILL_RE = /^s52-(LNDARE|DEPARE)-fill-band([456])$/;
29452
+ for (let i = layers.length - 1; i >= 0; i--) {
29453
+ const src = layers[i];
29454
+ const m = TERMINAL_FILL_RE.exec(src.id);
29455
+ if (!m) continue;
29456
+ const clone = {
29457
+ ...layers[i],
29458
+ id: `s52-${m[1]}-fill-terminal-band${m[2]}`,
29459
+ filter: ["all", src.filter, ["==", ["get", "_terminal_fill"], true]]
29460
+ };
29461
+ delete clone.minzoom;
29462
+ clone.maxzoom = src.minzoom ?? 0;
29463
+ layers.splice(i + 1, 0, clone);
29464
+ }
29465
+ }
28888
29466
  if (opts.labels === "placename") {
28889
29467
  labelLayers.push(...buildPlaceNameLayers(sourceId));
28890
29468
  } else {
@@ -28914,13 +29492,115 @@ function generateStyle(palette = "DAY", opts = {}) {
28914
29492
  const symbolSet = opts.symbolSet ?? "s52";
28915
29493
  const spriteBase = `${opts.tileBaseUrl ?? "https://tiles.chrt.co"}/v1/sprites`;
28916
29494
  const isLndareFill = (l) => /^s52-LNDARE-fill-band\d+$/.test(l.id);
28917
- const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
29495
+ const isWaterBaseFill = (l) => /^s52-(DEPARE|DRGARE|UNSARE)-fill-band\d+$/.test(l.id);
29496
+ const bandOf = (l) => {
29497
+ const m = /-band(\d+)$/.exec(l.id);
29498
+ return m ? Number(m[1]) : 0;
29499
+ };
28918
29500
  const emitted = opts.land ? (() => {
28919
- const lndareFills = layers.filter(isLndareFill);
28920
- const seaareFills = layers.filter(isSeaareFill);
28921
- const rest = layers.filter((l) => !isLndareFill(l) && !isSeaareFill(l));
28922
- return [rest[0], ...seaareFills, ...lndareFills, ...rest.slice(1)];
29501
+ const relocated = layers.filter((l) => isLndareFill(l) || isWaterBaseFill(l));
29502
+ const others = layers.filter((l) => !isLndareFill(l) && !isWaterBaseFill(l));
29503
+ const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
29504
+ const seaare = others.filter(isSeaareFill);
29505
+ const rest = others.filter((l) => !isSeaareFill(l));
29506
+ const isBaseLayer = (l) => {
29507
+ const id = l.id;
29508
+ if (id.startsWith("chrtbase-inland-")) return false;
29509
+ return id.startsWith("chrtbase-") || id === "s52-coverage-ground";
29510
+ };
29511
+ const baseBlock = rest.filter(isBaseLayer);
29512
+ const restAbove = rest.filter((l) => !isBaseLayer(l));
29513
+ const bands = [...new Set(relocated.map(bandOf))].sort((a, b) => a - b);
29514
+ const oneTan = landBaseColor(palette);
29515
+ const asBaseTan = (l) => ({
29516
+ ...l,
29517
+ paint: {
29518
+ ...l.paint ?? {},
29519
+ "fill-color": oneTan
29520
+ }
29521
+ });
29522
+ const ordered = [];
29523
+ for (const b of bands) {
29524
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isWaterBaseFill(l)));
29525
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isLndareFill(l)).map(asBaseTan));
29526
+ }
29527
+ const findRest = (id) => restAbove.findIndex((l) => l.id === id);
29528
+ const shoreAt = findRest("chrtbase-inland-shore");
29529
+ const pairAt = shoreAt !== -1 ? shoreAt : findRest("chrtbase-inland-hatch");
29530
+ let restFinal = restAbove;
29531
+ if (pairAt !== -1) {
29532
+ const repaint = [];
29533
+ for (const b of bands) {
29534
+ for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
29535
+ const src = l;
29536
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
29537
+ const notDom = {
29538
+ ...l,
29539
+ id: `${src.id}-overpair`,
29540
+ filter: ["all", src.filter, NOT_DOMINATED_FILTER],
29541
+ metadata: meta
29542
+ };
29543
+ delete notDom.maxzoom;
29544
+ delete notDom.minzoom;
29545
+ repaint.push(notDom);
29546
+ for (const D of BAND_DRAW_ORDER) {
29547
+ if (parseInt(D, 10) <= b) continue;
29548
+ const dMaxzoom = dominatedVariantMaxzoom(D);
29549
+ if (dMaxzoom <= 0) continue;
29550
+ const domVariant = {
29551
+ ...l,
29552
+ id: `${src.id}-overpair-domBy${D}`,
29553
+ filter: ["all", src.filter, dominatedByMinFilter(D)],
29554
+ metadata: meta,
29555
+ maxzoom: dMaxzoom
29556
+ };
29557
+ delete domVariant.minzoom;
29558
+ repaint.push(domVariant);
29559
+ }
29560
+ }
29561
+ }
29562
+ const isCoalneLine = (l) => /^s52-COALNE-line-band\d(-domBy\d)?$/.test(l.id);
29563
+ for (const l of layers.filter(isCoalneLine)) {
29564
+ const src = l;
29565
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
29566
+ const banded = {
29567
+ ...l,
29568
+ id: `${src.id}-overpair`,
29569
+ metadata: meta,
29570
+ minzoom: 9
29571
+ };
29572
+ repaint.push(banded);
29573
+ const terminal = {
29574
+ ...l,
29575
+ id: `${src.id}-overpair-terminal`,
29576
+ filter: ["all", src.filter, ["==", ["get", "_terminal_shore"], true]],
29577
+ metadata: meta
29578
+ };
29579
+ delete terminal.minzoom;
29580
+ terminal.maxzoom = 9;
29581
+ repaint.push(terminal);
29582
+ }
29583
+ restFinal = [
29584
+ ...restAbove.slice(0, pairAt + 1),
29585
+ ...repaint,
29586
+ ...restAbove.slice(pairAt + 1)
29587
+ ];
29588
+ }
29589
+ return [restFinal[0], ...baseBlock, ...seaare, ...ordered, ...restFinal.slice(1)];
28923
29590
  })() : layers;
29591
+ for (const id of [
29592
+ "chrtbase-country-boundary",
29593
+ "chrtland-town-port",
29594
+ "chrtland-city-major"
29595
+ ]) {
29596
+ const at = emitted.findIndex(
29597
+ (l) => l.id === id
29598
+ );
29599
+ if (at !== -1) {
29600
+ const [layer] = emitted.splice(at, 1);
29601
+ emitted.push(layer);
29602
+ }
29603
+ }
28924
29604
  const style = {
28925
29605
  version: 8,
28926
29606
  name: `CHRT ${SYMBOL_SET_LABELS[symbolSet]} ${PALETTE_NAMES[palette]}${opts.chartMode ? ` \u2014 chart:${opts.chartMode.band}` : ""}`,
@@ -28932,6 +29612,7 @@ function generateStyle(palette = "DAY", opts = {}) {
28932
29612
  "chrt:layer-count": emitted.length,
28933
29613
  "chrt:land": Boolean(opts.land),
28934
29614
  "chrt:relief": Boolean(opts.relief),
29615
+ "chrt:base": Boolean(opts.base),
28935
29616
  "chrt:render-mode": opts.chartMode ? `chart:${opts.chartMode.band}` : "continuous",
28936
29617
  "chrt:description": "IHO S-52 portrayal rules for Electronic Navigational Charts (ENCs). Generated from the CHRT atlas engine."
28937
29618
  },
@@ -29425,6 +30106,7 @@ var Chrt = class {
29425
30106
  interact;
29426
30107
  featuresEp;
29427
30108
  searchEp;
30109
+ poiEp;
29428
30110
  queryEp;
29429
30111
  http;
29430
30112
  tileAuth;
@@ -29446,6 +30128,7 @@ var Chrt = class {
29446
30128
  this.weather = new WeatherEndpoint(this.http);
29447
30129
  this.featuresEp = new FeaturesEndpoint(this.http);
29448
30130
  this.searchEp = new SearchEndpoint(this.http);
30131
+ this.poiEp = new PoiEndpoint(this.http);
29449
30132
  this.queryEp = new QueryEndpoint(this.http);
29450
30133
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
29451
30134
  this.interact = new InteractClient(this.http);
@@ -29481,6 +30164,26 @@ var Chrt = class {
29481
30164
  async search(options) {
29482
30165
  return this.searchEp.find(options);
29483
30166
  }
30167
+ /**
30168
+ * CHRTpoi — charted marine facilities in a viewport or radius.
30169
+ *
30170
+ * v1 serves marinas, derived from the ENC's own HRBFAC facility class —
30171
+ * the chart's data, with the source cell's currency inline per POI.
30172
+ *
30173
+ * ```ts
30174
+ * const { pois, coverage } = await chrt.pois({ bbox: [-70.7, 41.4, -70.5, 41.6] });
30175
+ * ```
30176
+ *
30177
+ * **Read `coverage.state` before interpreting an empty list.** `covered` +
30178
+ * empty means no marinas are charted there — a real answer. `none` means
30179
+ * CHRT cannot answer for that area at all, and an empty list means nothing.
30180
+ * The two must never be shown the same way, and `coverage.message` is
30181
+ * written to be relayed unedited. Metered on its own allowance
30182
+ * (`poi_called`), separate from features and search; not gated by tier.
30183
+ */
30184
+ async pois(options) {
30185
+ return this.poiEp.list(options);
30186
+ }
29484
30187
  /** Spatial proximity query around a point */
29485
30188
  async query(options) {
29486
30189
  return this.queryEp.nearby(options);
@@ -29651,6 +30354,18 @@ var Chrt = class {
29651
30354
  return generateStyle(palette, {
29652
30355
  region: this.region,
29653
30356
  tileBaseUrl: this.tileBaseUrl,
30357
+ // Pin each source's tile URL to the build it serves, from whatever the
30358
+ // token cache already holds. Without a discriminator one URL is shared
30359
+ // by every build, so after a cutover a map keeps painting already-fetched
30360
+ // tiles from the OLD build beside newly-fetched ones from the new.
30361
+ //
30362
+ // BEFORE the spread, so an explicit `buildTags` wins — and only ever a
30363
+ // warm-cache read: this method is synchronous and must not mint. A cold
30364
+ // cache yields `{}`, i.e. exactly the URLs this SDK emitted before the
30365
+ // discriminator existed. Warm it with `chrt.transformRequest()` (which
30366
+ // kicks off a mint) or `await chrt.styleUrl()`; `await chrt.warmTiles()`
30367
+ // is the explicit form, and it returns the map.
30368
+ buildTags: this.tileAuth.peekBuildTags(this.region),
29654
30369
  ...styleOpts,
29655
30370
  // The vessel's safety depth IS the style's safety contour — same number, same
29656
30371
  // meaning. Threading it here is what stops the chart shading at one depth while
@@ -29724,9 +30439,21 @@ var Chrt = class {
29724
30439
  * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
29725
30440
  * the cache automatically — but useful if you want to surface a mint error
29726
30441
  * (e.g. `region_not_licensed`, `tile_quota_exceeded`) before the map loads.
30442
+ *
30443
+ * Returns what it minted, so a caller building its own sources can take the
30444
+ * `buildTags` map straight from here:
30445
+ *
30446
+ * ```ts
30447
+ * const { buildTags } = await chrt.warmTiles();
30448
+ * map.addSource("chrt", buildSources({ region: "NOAA_ENC", buildTags })["NOAA_ENC"]);
30449
+ * ```
30450
+ *
30451
+ * That pins each tile URL to the build it serves, so a build cutover gets its
30452
+ * own client cache namespace instead of leaving old and new tiles mixed on
30453
+ * screen. `chrt.style()` reads the same cache and needs no argument.
29727
30454
  */
29728
30455
  async warmTiles(region) {
29729
- await this.tileAuth.getToken(region);
30456
+ return this.tileAuth.getToken(region);
29730
30457
  }
29731
30458
  };
29732
30459
  export {
@@ -29736,9 +30463,13 @@ export {
29736
30463
  ATON_EXPRESSION_CLASSES,
29737
30464
  BAND_DRAW_ORDER,
29738
30465
  BAND_PRIORITY_RANGES,
30466
+ BASE_MAXZOOM,
30467
+ BASE_MINZOOM,
30468
+ BASE_REGION,
29739
30469
  BRIDGE_QUERY_LAYERS,
29740
30470
  BUILD_MAXZOOM,
29741
30471
  BUILD_MINZOOM,
30472
+ BUILD_PARAM,
29742
30473
  CHART_BAND_CODES,
29743
30474
  CHART_CASCADE_CODES,
29744
30475
  CHART_SECTIONS,
@@ -29920,6 +30651,7 @@ export {
29920
30651
  scaleSizeValue,
29921
30652
  scaminToMinZoom,
29922
30653
  styleModeBand,
30654
+ tileTemplate,
29923
30655
  withinBboxFilter,
29924
30656
  worldMinusBboxPolygon
29925
30657
  };