@chrtco/sdk 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -135,6 +135,7 @@ var TileTokenError = class extends Error {
135
135
  }
136
136
  };
137
137
  var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.mvt(?:\?.*)?$/i;
138
+ var STYLE_URL_PATH = /\/v1\/styles\/[A-Za-z0-9_]+\/style\.json(?:\?.*)?$/i;
138
139
  var REFRESH_MARGIN_SECONDS = 30;
139
140
  var STALE_GRACE_SECONDS = 1;
140
141
  var MINT_RETRY_DELAYS_MS = [500, 1e3, 2e3];
@@ -278,17 +279,22 @@ var TileAuth = class {
278
279
  }
279
280
  /**
280
281
  * Produce a MapLibre / Mapbox GL `transformRequest`. Attaches
281
- * `Authorization: Bearer <jwt>` only to CHRT tile URLs; style, sprite, and
282
- * glyph URLs pass through untouched (never leak the token to third-party
283
- * CDNs). Kicks off a background mint immediately so the cache warms within
284
- * one round-trip; the first tile or two may go unauthenticated and be
285
- * retried by the renderer.
282
+ * `Authorization: Bearer <jwt>` only to CHRT tile URLs and the CHRT
283
+ * served-style URL (`/v1/styles/{region}/style.json`); sprite, glyph, and
284
+ * third-party URLs pass through untouched (never leak the token to
285
+ * third-party CDNs). Kicks off a background mint immediately so the cache
286
+ * warms within one round-trip; the first tile or two may go
287
+ * unauthenticated and be retried by the renderer. When pointing a map's
288
+ * `style` at the served style URL, obtain it via `chrt.styleUrl()` — it
289
+ * awaits the mint, so the style request (which MapLibre does NOT retry)
290
+ * is guaranteed to carry auth.
286
291
  */
287
292
  transformRequest(region) {
288
293
  const r = region ?? this.defaultRegion;
289
294
  void this.getToken(r).catch(() => void 0);
290
295
  return (url) => {
291
- if (!url.startsWith(this.tileBaseUrl) || !TILE_URL_PATH.test(url)) return void 0;
296
+ if (!url.startsWith(this.tileBaseUrl)) return void 0;
297
+ if (!TILE_URL_PATH.test(url) && !STYLE_URL_PATH.test(url)) return void 0;
292
298
  const token = this.peek(r);
293
299
  if (!token) return { url };
294
300
  return { url, headers: { Authorization: `Bearer ${token}` } };
@@ -326,23 +332,67 @@ var TileAuth = class {
326
332
 
327
333
  // src/endpoints/tiles.ts
328
334
  var TilesEndpoint = class {
329
- constructor(http, opts) {
335
+ constructor(http, tileAuth, opts) {
330
336
  this.http = http;
337
+ this.tileAuth = tileAuth;
331
338
  this.region = opts?.region ?? DEFAULT_TILE_REGION;
332
339
  this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
333
340
  }
334
341
  region;
335
342
  tileBaseUrl;
343
+ styleEndpoint(theme) {
344
+ const query2 = theme ? `?theme=${theme}` : "";
345
+ return `${this.tileBaseUrl}/v1/styles/${this.region}/style.json${query2}`;
346
+ }
336
347
  /**
337
- * @deprecated CHRT has no remote `style.json` endpoint — the S-52 style is
338
- * generated client-side. Use `chrt.style(opts)` to get a ready-to-use
339
- * MapLibre style object instead. Calling this throws to prevent silently
340
- * pointing a map at a dead URL.
348
+ * URL of the served S-52 style for the configured region — the CHRTstyle
349
+ * Style API (`GET /v1/styles/{region}/style.json`). The served document
350
+ * honors the API key's bound custom style (a Dev+ feature configured at
351
+ * platform → Map Styles); Free keys and unbound keys get the default
352
+ * themes, identical to the locally generated `chrt.style()`.
353
+ *
354
+ * The endpoint is JWT-gated, so pair the URL with
355
+ * `chrt.transformRequest()`, which attaches the Bearer token to both the
356
+ * style request and its tile requests. This method is async because it
357
+ * pre-mints the tile token, guaranteeing the auth cache is warm before
358
+ * MapLibre fetches the URL.
359
+ *
360
+ * @example
361
+ * const map = new maplibregl.Map({
362
+ * container: 'map',
363
+ * style: await chrt.styleUrl({ theme: 'dusk' }),
364
+ * transformRequest: chrt.transformRequest(),
365
+ * });
341
366
  */
342
- styleUrl() {
343
- throw new Error(
344
- "chrt.tiles.styleUrl() is no longer supported: CHRT serves no remote style.json. Use chrt.style(opts) to generate an S-52 MapLibre style object, and chrt.transformRequest() for tile auth. See https://chrt.co/docs/vector-tiles"
345
- );
367
+ async styleUrl(opts = {}) {
368
+ await this.tileAuth.getToken(this.region);
369
+ return this.styleEndpoint(opts.theme);
370
+ }
371
+ /**
372
+ * Fetch the served S-52 style as a MapLibre style object (see
373
+ * {@link styleUrl} for what "served" means). Prefer this over `styleUrl()`
374
+ * when you want to inspect or mutate the document, or when you can't use
375
+ * `transformRequest` for the style request itself.
376
+ *
377
+ * Falls back nowhere: a network or auth failure throws (`ChrtError` /
378
+ * `TileTokenError`). For an offline-capable local fallback, use
379
+ * `chrt.style()` — the client-side generator.
380
+ */
381
+ async fetchStyle(opts = {}) {
382
+ const token = await this.tileAuth.getToken(this.region);
383
+ const res = await fetch(this.styleEndpoint(opts.theme), {
384
+ headers: { authorization: `Bearer ${token.token}` },
385
+ signal: opts.signal
386
+ });
387
+ if (!res.ok) {
388
+ let body;
389
+ try {
390
+ body = await res.json();
391
+ } catch {
392
+ }
393
+ throw new ChrtError(res.status, body);
394
+ }
395
+ return await res.json();
346
396
  }
347
397
  /**
348
398
  * Vector tile URL template for the configured region, served by the
@@ -1146,51 +1196,2409 @@ var OfflineEndpoint = class {
1146
1196
  { resumable: true, bytes_written: bytesDownloaded }
1147
1197
  );
1148
1198
  }
1149
- emit(true);
1150
- const computedSha = await fs.sha256(partialPath);
1151
- if (computedSha.toLowerCase() !== bundle.sha256) {
1152
- await fs.unlink(partialPath);
1153
- throw new OfflineError(
1154
- "download_corrupt",
1155
- `SHA-256 mismatch for ${region}: expected ${bundle.sha256}, got ${computedSha}`,
1156
- void 0,
1157
- { expected: bundle.sha256, actual: computedSha }
1199
+ emit(true);
1200
+ const computedSha = await fs.sha256(partialPath);
1201
+ if (computedSha.toLowerCase() !== bundle.sha256) {
1202
+ await fs.unlink(partialPath);
1203
+ throw new OfflineError(
1204
+ "download_corrupt",
1205
+ `SHA-256 mismatch for ${region}: expected ${bundle.sha256}, got ${computedSha}`,
1206
+ void 0,
1207
+ { expected: bundle.sha256, actual: computedSha }
1208
+ );
1209
+ }
1210
+ await fs.rename(partialPath, path);
1211
+ await this.persistManifestEntry(region, bundle, path, fs);
1212
+ return {
1213
+ path,
1214
+ bytes: bundle.bytes,
1215
+ sha256: bundle.sha256,
1216
+ build_id: bundle.build_id,
1217
+ build_tag: bundle.build_tag,
1218
+ resumed
1219
+ };
1220
+ }
1221
+ async persistManifestEntry(region, bundle, path, fs) {
1222
+ if (!this.manifestPath) return;
1223
+ const manifest = await readManifest(this.manifestPath, fs);
1224
+ const nowSec = Math.floor(Date.now() / 1e3);
1225
+ manifest.regions[region] = {
1226
+ path,
1227
+ bytes: bundle.bytes,
1228
+ sha256: bundle.sha256,
1229
+ build_id: bundle.build_id,
1230
+ build_tag: bundle.build_tag,
1231
+ downloaded_at: nowSec,
1232
+ last_accessed_at: nowSec
1233
+ };
1234
+ await writeManifest(this.manifestPath, fs, manifest);
1235
+ }
1236
+ };
1237
+ function dirname(p) {
1238
+ const i = p.lastIndexOf("/");
1239
+ const j = p.lastIndexOf("\\");
1240
+ const k = Math.max(i, j);
1241
+ if (k <= 0) return ".";
1242
+ return p.slice(0, k);
1243
+ }
1244
+
1245
+ // src/interact/decode.ts
1246
+ var LITCHR_TABLE = {
1247
+ 1: { name: "Fixed", abbr: "F" },
1248
+ 2: { name: "Flashing", abbr: "Fl" },
1249
+ 3: { name: "Long Flashing", abbr: "LFl" },
1250
+ 4: { name: "Occulting", abbr: "Oc" },
1251
+ 5: { name: "Isophase", abbr: "Iso" },
1252
+ 6: { name: "Short Flashing", abbr: "S.Fl" },
1253
+ 7: { name: "Short Long Flashing", abbr: "S.LFl" },
1254
+ 8: { name: "Alternating Occulting", abbr: "Al.Oc" },
1255
+ 9: { name: "Alternating Long Flashing", abbr: "Al.LFl" },
1256
+ 10: { name: "Alternating Flashing", abbr: "Al.Fl" },
1257
+ 11: { name: "Alternating Group", abbr: "Al.Gp" },
1258
+ 12: { name: "Morse", abbr: "Mo" },
1259
+ 13: { name: "Quick", abbr: "Q" },
1260
+ 14: { name: "Very Quick", abbr: "VQ" },
1261
+ 15: { name: "Ultra Quick", abbr: "UQ" },
1262
+ 16: { name: "Alternating", abbr: "Al" },
1263
+ 17: { name: "Alternating Fixed/Flash", abbr: "Al.F.Fl" },
1264
+ 25: { name: "Quick + Long Flash", abbr: "Q+LFl" },
1265
+ 26: { name: "Very Quick + Long Flash", abbr: "VQ+LFl" },
1266
+ 27: { name: "Ultra Quick + Long Flash", abbr: "UQ+LFl" },
1267
+ 28: { name: "Alternating", abbr: "Al" },
1268
+ 29: { name: "Fixed + Long Flash", abbr: "F.LFl" }
1269
+ };
1270
+ var COLOUR_TABLE = {
1271
+ 1: { name: "White", abbr: "W", hex: "#ffffff" },
1272
+ 2: { name: "Black", abbr: "B", hex: "#333333" },
1273
+ 3: { name: "Red", abbr: "R", hex: "#ef4444" },
1274
+ 4: { name: "Green", abbr: "G", hex: "#22c55e" },
1275
+ 5: { name: "Blue", abbr: "Bu", hex: "#3b82f6" },
1276
+ 6: { name: "Yellow", abbr: "Y", hex: "#eab308" },
1277
+ 7: { name: "Grey", abbr: "Gr", hex: "#9ca3af" },
1278
+ 8: { name: "Brown", abbr: "Bn", hex: "#92400e" },
1279
+ 9: { name: "Amber", abbr: "Am", hex: "#f59e0b" },
1280
+ 10: { name: "Violet", abbr: "Vi", hex: "#8b5cf6" },
1281
+ 11: { name: "Orange", abbr: "Or", hex: "#f97316" },
1282
+ 12: { name: "Magenta", abbr: "Mg", hex: "#d946ef" },
1283
+ 13: { name: "Pink", abbr: "Pk", hex: "#f472b6" }
1284
+ };
1285
+ var BOYSHP_TABLE = {
1286
+ 1: "Conical (nun)",
1287
+ 2: "Can (cylindrical)",
1288
+ 3: "Spherical",
1289
+ 4: "Pillar",
1290
+ 5: "Spar",
1291
+ 6: "Barrel",
1292
+ 7: "Super buoy",
1293
+ 8: "Ice buoy"
1294
+ };
1295
+ var CATLAM_TABLE = {
1296
+ 1: "Port",
1297
+ 2: "Starboard",
1298
+ 3: "Preferred channel to starboard",
1299
+ 4: "Preferred channel to port"
1300
+ };
1301
+ var CATCAM_TABLE = {
1302
+ 1: "North cardinal",
1303
+ 2: "East cardinal",
1304
+ 3: "South cardinal",
1305
+ 4: "West cardinal"
1306
+ };
1307
+ var CATLMK_TABLE = {
1308
+ 1: "Cairn",
1309
+ 2: "Cemetery",
1310
+ 3: "Chimney",
1311
+ 4: "Dish aerial",
1312
+ 5: "Flagstaff",
1313
+ 6: "Flare stack",
1314
+ 7: "Mast",
1315
+ 8: "Windsock",
1316
+ 9: "Monument",
1317
+ 10: "Column / pillar",
1318
+ 11: "Memorial plaque",
1319
+ 12: "Obelisk",
1320
+ 13: "Statue",
1321
+ 14: "Cross",
1322
+ 15: "Dome",
1323
+ 16: "Radar scanner",
1324
+ 17: "Tower",
1325
+ 18: "Windmill",
1326
+ 19: "Wind turbine",
1327
+ 20: "Spire"
1328
+ };
1329
+ var CATFOG_TABLE = {
1330
+ 1: "Explosive",
1331
+ 2: "Diaphone",
1332
+ 3: "Siren",
1333
+ 4: "Nautophone",
1334
+ 5: "Bell",
1335
+ 6: "Whistle",
1336
+ 7: "Reed",
1337
+ 8: "Gong",
1338
+ 9: "Horn"
1339
+ };
1340
+ var CATFOG_ABBR_TABLE = {
1341
+ 1: "EXPLOS",
1342
+ 2: "DIA",
1343
+ 3: "SIREN",
1344
+ 4: "NAUTO",
1345
+ 5: "BELL",
1346
+ 6: "WHIS",
1347
+ 7: "REED",
1348
+ 8: "GONG",
1349
+ 9: "HORN"
1350
+ };
1351
+ var TOPSHP_TABLE = {
1352
+ 1: "Cone, point up",
1353
+ 2: "Cone, point down",
1354
+ 3: "Sphere",
1355
+ 4: "2 spheres",
1356
+ 5: "Cylinder",
1357
+ 6: "Board",
1358
+ 7: "X-shape",
1359
+ 8: "Upright cross",
1360
+ 9: "Cube, point up",
1361
+ 10: "2 cones, point to point",
1362
+ 11: "2 cones, base to base",
1363
+ 12: "Rhombus",
1364
+ 13: "2 cones, up",
1365
+ 14: "2 cones, down",
1366
+ 15: "Besom, point up",
1367
+ 16: "Besom, point down",
1368
+ 17: "Flag",
1369
+ 18: "Sphere over rhombus",
1370
+ 19: "Square",
1371
+ 33: "T-shape"
1372
+ };
1373
+ var STATUS_TABLE = {
1374
+ 1: "Permanent",
1375
+ 2: "Occasional",
1376
+ 3: "Recommended",
1377
+ 4: "Not in use",
1378
+ 5: "Periodic/intermittent",
1379
+ 6: "Reserved",
1380
+ 7: "Temporary",
1381
+ 8: "Private",
1382
+ 9: "Mandatory",
1383
+ 11: "Extinguished",
1384
+ 12: "Illuminated",
1385
+ 13: "Historic",
1386
+ 14: "Public",
1387
+ 15: "Synchronized",
1388
+ 16: "Watched",
1389
+ 17: "Un-watched",
1390
+ 18: "Existence doubtful"
1391
+ };
1392
+ var MARSYS_TABLE = {
1393
+ 1: "IALA-A",
1394
+ 2: "IALA-B"
1395
+ };
1396
+ var LAYER_TYPE_MAP = {
1397
+ BOYLAT: "Lateral Buoy",
1398
+ BOYCAR: "Cardinal Buoy",
1399
+ BOYSPP: "Special Purpose Buoy",
1400
+ BOYISD: "Isolated Danger Buoy",
1401
+ BOYSAW: "Safe Water Buoy",
1402
+ BCNLAT: "Lateral Beacon",
1403
+ BCNCAR: "Cardinal Beacon",
1404
+ BCNSPP: "Special Purpose Beacon",
1405
+ BCNISD: "Isolated Danger Beacon",
1406
+ BCNSAW: "Safe Water Beacon",
1407
+ LIGHTS: "Light",
1408
+ DAYMAR: "Daymark",
1409
+ FOGSIG: "Fog Signal",
1410
+ RTPBCN: "Radar Transponder Beacon",
1411
+ RDOSTA: "Radio/Radar Station",
1412
+ TOPMAR: "Topmark",
1413
+ MORFAC: "Mooring Facility",
1414
+ LNDMRK: "Landmark",
1415
+ SISTAT: "Signal Station",
1416
+ PILBOP: "Pilot Boarding Place",
1417
+ HRBFAC: "Harbour Facility",
1418
+ SMCFAC: "Small Craft Facility",
1419
+ CRANES: "Crane",
1420
+ OFSPLF: "Offshore Platform",
1421
+ ACHBRT: "Anchor Berth",
1422
+ DISMAR: "Distance Mark",
1423
+ LITVES: "Light Vessel",
1424
+ LITFLT: "Light Float"
1425
+ };
1426
+ var NAV_AID_QUERY_LAYERS = [
1427
+ "enc-BOYLAT",
1428
+ "enc-BOYCAR",
1429
+ "enc-BOYSPP",
1430
+ "enc-BOYISD",
1431
+ "enc-BOYSAW",
1432
+ "enc-BCNLAT",
1433
+ "enc-BCNCAR",
1434
+ "enc-BCNSPP",
1435
+ "enc-BCNISD",
1436
+ "enc-BCNSAW",
1437
+ "enc-LIGHTS",
1438
+ "enc-LIGHTS-point",
1439
+ "enc-DAYMAR",
1440
+ "enc-FOGSIG",
1441
+ "enc-RTPBCN",
1442
+ "enc-RDOSTA",
1443
+ "enc-TOPMAR",
1444
+ "enc-MORFAC",
1445
+ "enc-MORFAC-point",
1446
+ "enc-LNDMRK",
1447
+ "enc-LNDMRK-point",
1448
+ "enc-SISTAT",
1449
+ "enc-SISTAT-point",
1450
+ "enc-PILBOP",
1451
+ "enc-PILBOP-point",
1452
+ "enc-HRBFAC",
1453
+ "enc-SMCFAC",
1454
+ "enc-CRANES",
1455
+ "enc-CRANES-point",
1456
+ "enc-OFSPLF",
1457
+ "enc-OFSPLF-point",
1458
+ "enc-ACHBRT",
1459
+ "enc-DISMAR",
1460
+ "enc-LITVES",
1461
+ "enc-LITFLT"
1462
+ ];
1463
+ var AREA_QUERY_LAYERS = [
1464
+ "enc-DEPARE",
1465
+ "enc-DEPARE-polygon",
1466
+ "enc-OBSTRN",
1467
+ "enc-OBSTRN-polygon",
1468
+ "enc-OBSTRN-point",
1469
+ "enc-WRECKS",
1470
+ "enc-WRECKS-polygon",
1471
+ "enc-WRECKS-point",
1472
+ "enc-RESARE",
1473
+ "enc-RESARE-polygon",
1474
+ "enc-ACHARE",
1475
+ "enc-ACHARE-polygon",
1476
+ "enc-CTNARE",
1477
+ "enc-CTNARE-polygon",
1478
+ "enc-DRGARE",
1479
+ "enc-DRGARE-polygon",
1480
+ "enc-FAIRWY",
1481
+ "enc-FAIRWY-polygon",
1482
+ "enc-DMPGRD",
1483
+ "enc-DMPGRD-polygon",
1484
+ "enc-MIPARE",
1485
+ "enc-MIPARE-polygon",
1486
+ "enc-PRCARE",
1487
+ "enc-PRCARE-polygon",
1488
+ "enc-TSSLPT",
1489
+ "enc-TSSLPT-polygon",
1490
+ "enc-TSELNE",
1491
+ "enc-TSELNE-line",
1492
+ "enc-ISTZNE",
1493
+ "enc-ISTZNE-polygon",
1494
+ "enc-OSPARE",
1495
+ "enc-OSPARE-polygon",
1496
+ "enc-MARCUL",
1497
+ "enc-MARCUL-polygon",
1498
+ "enc-CBLARE",
1499
+ "enc-CBLARE-polygon",
1500
+ "enc-PIPARE",
1501
+ "enc-PIPARE-polygon",
1502
+ // Tier 3: infrastructure lines (BRIDGE handled by BRIDGE_QUERY_LAYERS)
1503
+ "enc-CBLSUB",
1504
+ "enc-CBLSUB-line",
1505
+ "enc-CBLOHD",
1506
+ "enc-CBLOHD-line",
1507
+ "enc-PIPSOL",
1508
+ "enc-PIPSOL-line",
1509
+ "enc-PIPOHD",
1510
+ "enc-PIPOHD-line",
1511
+ "enc-BERTHS",
1512
+ "enc-BERTHS-line",
1513
+ "enc-DAMCON",
1514
+ "enc-DAMCON-line",
1515
+ "enc-FERYRT",
1516
+ "enc-FERYRT-line",
1517
+ "enc-RECTRC",
1518
+ "enc-RECTRC-line",
1519
+ // Tier 4: land/context features
1520
+ "enc-BUISGL",
1521
+ "enc-BUISGL-polygon",
1522
+ "enc-BUISGL-point",
1523
+ "enc-SILTNK",
1524
+ "enc-SILTNK-polygon",
1525
+ "enc-SILTNK-point",
1526
+ "enc-HULKES",
1527
+ "enc-HULKES-polygon",
1528
+ "enc-HULKES-point",
1529
+ "enc-DRYDOC",
1530
+ "enc-DRYDOC-polygon",
1531
+ "enc-FLODOC",
1532
+ "enc-FLODOC-polygon",
1533
+ "enc-PONTON",
1534
+ "enc-PONTON-polygon",
1535
+ "enc-GATCON",
1536
+ "enc-GATCON-polygon",
1537
+ "enc-GATCON-point",
1538
+ "enc-LOGPON",
1539
+ "enc-LOGPON-polygon",
1540
+ "enc-AIRARE",
1541
+ "enc-AIRARE-polygon",
1542
+ "enc-SLCONS",
1543
+ "enc-SLCONS-polygon",
1544
+ "enc-SLCONS-line",
1545
+ "enc-SLCONS-point",
1546
+ // Tier 5: hydrographic context
1547
+ "enc-COALNE",
1548
+ "enc-COALNE-line",
1549
+ "enc-DEPCNT",
1550
+ "enc-DEPCNT-line",
1551
+ "enc-RIVERS",
1552
+ "enc-RIVERS-polygon",
1553
+ "enc-RIVERS-line",
1554
+ "enc-CANALS",
1555
+ "enc-CANALS-polygon",
1556
+ "enc-CANALS-line",
1557
+ "enc-LAKARE",
1558
+ "enc-LAKARE-polygon",
1559
+ "enc-RAPIDS",
1560
+ "enc-RAPIDS-polygon",
1561
+ "enc-RAPIDS-point",
1562
+ "enc-WATFAL",
1563
+ "enc-WATFAL-polygon",
1564
+ "enc-WATFAL-point",
1565
+ "enc-LNDARE",
1566
+ "enc-LNDARE-polygon"
1567
+ ];
1568
+ var BRIDGE_QUERY_LAYERS = [
1569
+ "enc-BRIDGE-midpoint",
1570
+ "enc-BRIDGE",
1571
+ "enc-BRIDGE-line",
1572
+ "s52-BRIDGE-polygon",
1573
+ "s52-BRIDGE-polygon-outline"
1574
+ ];
1575
+ var CATBRG_LABELS = {
1576
+ 1: "Fixed bridge",
1577
+ 2: "Opening bridge",
1578
+ 3: "Swing bridge",
1579
+ 4: "Lifting bridge",
1580
+ 5: "Bascule bridge",
1581
+ 6: "Pontoon bridge",
1582
+ 7: "Drawbridge",
1583
+ 8: "Transporter bridge",
1584
+ 9: "Footbridge",
1585
+ 10: "Viaduct",
1586
+ 11: "Aqueduct",
1587
+ 12: "Suspension bridge"
1588
+ };
1589
+ var CONDTN_TABLE = {
1590
+ 1: "Under construction",
1591
+ 2: "Ruined",
1592
+ 3: "Under reclamation",
1593
+ 5: "Planned"
1594
+ };
1595
+ function parseFirstNum(val) {
1596
+ if (val == null) return null;
1597
+ const s = String(val);
1598
+ if (s.startsWith("[")) {
1599
+ try {
1600
+ const arr = JSON.parse(s);
1601
+ if (Array.isArray(arr)) {
1602
+ const n2 = Number(arr[0]);
1603
+ return Number.isFinite(n2) ? n2 : null;
1604
+ }
1605
+ } catch {
1606
+ }
1607
+ }
1608
+ const n = Number(s.split(",")[0]);
1609
+ return Number.isFinite(n) ? n : null;
1610
+ }
1611
+ function asNumber(val) {
1612
+ if (val == null || val === "") return null;
1613
+ const n = Number(val);
1614
+ return Number.isFinite(n) ? n : null;
1615
+ }
1616
+ function decodeBridgeProps(props) {
1617
+ const catCode = parseFirstNum(props.CATBRG);
1618
+ const statusCode = parseFirstNum(props.STATUS);
1619
+ const condCode = parseFirstNum(props.CONDTN);
1620
+ return {
1621
+ name: props.OBJNAM ? String(props.OBJNAM) : null,
1622
+ categoryCode: catCode,
1623
+ categoryLabel: catCode ? CATBRG_LABELS[catCode] ?? null : null,
1624
+ verticalClearance: asNumber(props.VERCLR),
1625
+ verticalClearanceClosed: asNumber(props.VERCCL),
1626
+ verticalClearanceOpen: asNumber(props.VERCOP),
1627
+ horizontalClearance: asNumber(props.HORCLR),
1628
+ status: statusCode ? STATUS_TABLE[statusCode] ?? null : null,
1629
+ condition: condCode ? CONDTN_TABLE[condCode] ?? null : null,
1630
+ information: props.INFORM ? String(props.INFORM) : null,
1631
+ nationalName: props.NOBJNM ? String(props.NOBJNM) : null,
1632
+ sourceIndication: props.SORIND ? String(props.SORIND) : null,
1633
+ sourceDate: props.SORDAT ? String(props.SORDAT) : null
1634
+ };
1635
+ }
1636
+ var CATOBS_TABLE = {
1637
+ 1: "Snag / stump",
1638
+ 2: "Wellhead",
1639
+ 3: "Diffuser",
1640
+ 4: "Crib",
1641
+ 5: "Fish haven",
1642
+ 6: "Foul area",
1643
+ 7: "Foul ground",
1644
+ 8: "Ice boom",
1645
+ 9: "Ground tackle",
1646
+ 10: "Boom",
1647
+ 11: "Platform"
1648
+ };
1649
+ var CATWRK_TABLE = {
1650
+ 1: "Non-dangerous",
1651
+ 2: "Dangerous",
1652
+ 3: "Distributed remains",
1653
+ 4: "Mast showing",
1654
+ 5: "Hull showing"
1655
+ };
1656
+ var WATLEV_TABLE = {
1657
+ 1: "Partly submerged at high water",
1658
+ 2: "Always dry",
1659
+ 3: "Always submerged",
1660
+ 4: "Covers and uncovers",
1661
+ 5: "Awash",
1662
+ 6: "Subject to inundation / flooding",
1663
+ 7: "Floating"
1664
+ };
1665
+ var CATREA_TABLE = {
1666
+ 1: "Offshore safety zone",
1667
+ 2: "Anchoring prohibition",
1668
+ 3: "Fishing prohibition",
1669
+ 4: "Nature reserve",
1670
+ 5: "Bird sanctuary",
1671
+ 6: "Game preserve",
1672
+ 7: "Seal sanctuary",
1673
+ 8: "Degaussing range",
1674
+ 9: "Military area",
1675
+ 10: "Historic wreck",
1676
+ 12: "Navigational aid safety zone",
1677
+ 14: "No wake zone",
1678
+ 18: "Swimming area",
1679
+ 19: "Waiting area",
1680
+ 20: "Research area",
1681
+ 21: "Dredging area",
1682
+ 22: "Fish sanctuary",
1683
+ 23: "Ecological reserve",
1684
+ 24: "No discharge zone",
1685
+ 25: "No anchoring area",
1686
+ 26: "No fishing area",
1687
+ 27: "Speed restriction zone",
1688
+ 28: "To be avoided area"
1689
+ };
1690
+ var RESTRN_TABLE = {
1691
+ 1: "Anchoring prohibited",
1692
+ 2: "Anchoring restricted",
1693
+ 3: "Fishing prohibited",
1694
+ 4: "Fishing restricted",
1695
+ 5: "Trawling prohibited",
1696
+ 6: "Trawling restricted",
1697
+ 7: "Entry prohibited",
1698
+ 8: "Entry restricted",
1699
+ 9: "Dredging prohibited",
1700
+ 10: "Dredging restricted",
1701
+ 11: "Diving prohibited",
1702
+ 12: "Diving restricted",
1703
+ 13: "No wake",
1704
+ 14: "To be avoided",
1705
+ 15: "Construction prohibited",
1706
+ 16: "Discharging prohibited",
1707
+ 17: "Discharging restricted",
1708
+ 18: "Industrial prohibited",
1709
+ 19: "Metallic hull vessels prohibited",
1710
+ 20: "Navigating prohibited",
1711
+ 21: "Speed restricted",
1712
+ 24: "Stopping prohibited",
1713
+ 25: "Landing prohibited"
1714
+ };
1715
+ var CATHAF_TABLE = {
1716
+ 1: "RoRo terminal",
1717
+ 3: "Ferry terminal",
1718
+ 4: "Fishing harbour",
1719
+ 5: "Yacht harbour / marina",
1720
+ 6: "Naval base",
1721
+ 7: "Tanker terminal",
1722
+ 8: "Passenger terminal",
1723
+ 9: "Shipyard",
1724
+ 10: "Container terminal",
1725
+ 11: "Bulk terminal",
1726
+ 12: "Synchrolift",
1727
+ 13: "Straddle carrier",
1728
+ 14: "Port terminal"
1729
+ };
1730
+ var CATSCF_TABLE = {
1731
+ 1: "Visitors' berth",
1732
+ 2: "Nautical club",
1733
+ 3: "Boat hoist",
1734
+ 4: "Sailmaker",
1735
+ 5: "Boatyard",
1736
+ 6: "Public inn",
1737
+ 7: "Restaurant",
1738
+ 8: "Chandler",
1739
+ 9: "Provisions",
1740
+ 10: "Doctor",
1741
+ 11: "Pharmacy",
1742
+ 12: "Water tap",
1743
+ 13: "Fuel station",
1744
+ 14: "Electricity",
1745
+ 15: "Bottle gas",
1746
+ 16: "Showers",
1747
+ 17: "Launderette",
1748
+ 18: "Public toilets",
1749
+ 19: "Post box"
1750
+ };
1751
+ var CATCRN_TABLE = {
1752
+ 2: "Container crane / gantry",
1753
+ 3: "Sheerlegs",
1754
+ 4: "Travelling crane"
1755
+ };
1756
+ var CATACH_TABLE = {
1757
+ 1: "Unrestricted anchorage",
1758
+ 2: "Deep water anchorage",
1759
+ 3: "Tanker anchorage",
1760
+ 4: "Explosives anchorage",
1761
+ 5: "Quarantine anchorage",
1762
+ 6: "Sea-plane anchorage",
1763
+ 7: "Small craft anchorage",
1764
+ 8: "Small craft mooring area",
1765
+ 9: "Anchorage for 24 hours or less"
1766
+ };
1767
+ var CATDPG_TABLE = {
1768
+ 1: "General dumping ground",
1769
+ 2: "Chemical waste",
1770
+ 3: "Nuclear waste",
1771
+ 4: "Explosives",
1772
+ 5: "Spoil ground",
1773
+ 6: "Vessel dumping ground"
1774
+ };
1775
+ var CATMFA_TABLE = {
1776
+ 1: "Crustaceans",
1777
+ 2: "Oysters / mussels",
1778
+ 3: "Fish",
1779
+ 4: "Seaweed",
1780
+ 5: "Pearl culture"
1781
+ };
1782
+ var CATCBL_TABLE = {
1783
+ 1: "Submarine power cable",
1784
+ 2: "Submarine transmission cable",
1785
+ 3: "Submarine telegraph cable",
1786
+ 4: "Submarine telephone cable",
1787
+ 5: "Mooring cable",
1788
+ 6: "Ferry cable"
1789
+ };
1790
+ var CATPIP_TABLE = {
1791
+ 1: "Pipeline",
1792
+ 2: "Outfall pipe",
1793
+ 3: "Intake pipe",
1794
+ 4: "Sewer",
1795
+ 5: "Bubbler system",
1796
+ 6: "Supply pipe"
1797
+ };
1798
+ var TRAFIC_TABLE = {
1799
+ 1: "Inbound",
1800
+ 2: "Outbound",
1801
+ 3: "One-way",
1802
+ 4: "Two-way"
1803
+ };
1804
+ var QUASOU_TABLE = {
1805
+ 1: "Depth known",
1806
+ 2: "Depth unknown",
1807
+ 3: "Doubtful sounding",
1808
+ 4: "Unreliable sounding",
1809
+ 6: "Least depth known",
1810
+ 7: "Least depth unknown",
1811
+ 9: "Not regularly maintained",
1812
+ 10: "Maintained depth",
1813
+ 11: "Not surveyed"
1814
+ };
1815
+ function decodeAreaFeatureProps(layerName, props) {
1816
+ const rawLayer = layerName.replace(/^enc-/, "").replace(/-(point|line|polygon|midpoint)$/, "");
1817
+ const parseCodeLocal = (val) => {
1818
+ if (val == null) return null;
1819
+ const s = String(val);
1820
+ try {
1821
+ const arr = JSON.parse(s);
1822
+ if (Array.isArray(arr)) return Number(arr[0]) || null;
1823
+ } catch {
1824
+ }
1825
+ return Number(s.split(",")[0]) || null;
1826
+ };
1827
+ const entries = [];
1828
+ let type = rawLayer;
1829
+ let title = props.OBJNAM ? String(props.OBJNAM) : "";
1830
+ if (rawLayer === "DEPARE") {
1831
+ type = "Depth Area";
1832
+ const drval1 = props.DRVAL1 != null ? Number(props.DRVAL1) : null;
1833
+ const drval2 = props.DRVAL2 != null ? Number(props.DRVAL2) : null;
1834
+ if (drval1 != null && drval2 != null) {
1835
+ title = title || `${drval1}m \u2013 ${drval2}m`;
1836
+ entries.push({ label: "Depth Range", value: `${drval1}m \u2013 ${drval2}m` });
1837
+ } else if (drval1 != null) {
1838
+ entries.push({ label: "Min Depth", value: `${drval1}m` });
1839
+ }
1840
+ const qc = parseCodeLocal(props.QUASOU);
1841
+ if (qc && QUASOU_TABLE[qc]) entries.push({ label: "Quality", value: QUASOU_TABLE[qc] });
1842
+ } else if (rawLayer === "OBSTRN") {
1843
+ type = "Obstruction";
1844
+ const cat = parseCodeLocal(props.CATOBS);
1845
+ if (cat && CATOBS_TABLE[cat]) {
1846
+ entries.push({ label: "Category", value: CATOBS_TABLE[cat] });
1847
+ if (!title) title = CATOBS_TABLE[cat];
1848
+ }
1849
+ if (props.VALSOU != null) entries.push({ label: "Depth", value: `${props.VALSOU}m` });
1850
+ const wl = parseCodeLocal(props.WATLEV);
1851
+ if (wl && WATLEV_TABLE[wl]) entries.push({ label: "Water Level", value: WATLEV_TABLE[wl] });
1852
+ const qc = parseCodeLocal(props.QUASOU);
1853
+ if (qc && QUASOU_TABLE[qc]) entries.push({ label: "Sounding Quality", value: QUASOU_TABLE[qc] });
1854
+ } else if (rawLayer === "WRECKS") {
1855
+ type = "Wreck";
1856
+ const cat = parseCodeLocal(props.CATWRK);
1857
+ if (cat && CATWRK_TABLE[cat]) {
1858
+ entries.push({ label: "Category", value: CATWRK_TABLE[cat] });
1859
+ if (!title) title = CATWRK_TABLE[cat];
1860
+ }
1861
+ if (props.VALSOU != null) entries.push({ label: "Depth", value: `${props.VALSOU}m` });
1862
+ const wl = parseCodeLocal(props.WATLEV);
1863
+ if (wl && WATLEV_TABLE[wl]) entries.push({ label: "Water Level", value: WATLEV_TABLE[wl] });
1864
+ const qc = parseCodeLocal(props.QUASOU);
1865
+ if (qc && QUASOU_TABLE[qc]) entries.push({ label: "Sounding Quality", value: QUASOU_TABLE[qc] });
1866
+ } else if (rawLayer === "RESARE") {
1867
+ type = "Restricted Area";
1868
+ const cat = parseCodeLocal(props.CATREA);
1869
+ if (cat && CATREA_TABLE[cat]) {
1870
+ entries.push({ label: "Category", value: CATREA_TABLE[cat] });
1871
+ if (!title) title = CATREA_TABLE[cat];
1872
+ }
1873
+ if (props.RESTRN) {
1874
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1875
+ if (codes.length > 0) {
1876
+ entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1877
+ }
1878
+ }
1879
+ } else if (rawLayer === "ACHARE") {
1880
+ type = "Anchorage Area";
1881
+ const cat = parseCodeLocal(props.CATACH);
1882
+ if (cat && CATACH_TABLE[cat]) {
1883
+ entries.push({ label: "Category", value: CATACH_TABLE[cat] });
1884
+ if (!title) title = CATACH_TABLE[cat];
1885
+ }
1886
+ if (props.RESTRN) {
1887
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1888
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1889
+ }
1890
+ const st2 = parseCodeLocal(props.STATUS);
1891
+ if (st2 && STATUS_TABLE[st2]) entries.push({ label: "Status", value: STATUS_TABLE[st2] });
1892
+ } else if (rawLayer === "CTNARE") {
1893
+ type = "Caution Area";
1894
+ } else if (rawLayer === "DRGARE") {
1895
+ type = "Dredged Area";
1896
+ const drval1 = props.DRVAL1 != null ? Number(props.DRVAL1) : null;
1897
+ const drval2 = props.DRVAL2 != null ? Number(props.DRVAL2) : null;
1898
+ if (drval1 != null && drval2 != null) {
1899
+ entries.push({ label: "Maintained Depth", value: `${drval1}m \u2013 ${drval2}m` });
1900
+ } else if (drval1 != null) {
1901
+ entries.push({ label: "Maintained Depth", value: `${drval1}m` });
1902
+ }
1903
+ const qc = parseCodeLocal(props.QUASOU);
1904
+ if (qc && QUASOU_TABLE[qc]) entries.push({ label: "Quality", value: QUASOU_TABLE[qc] });
1905
+ } else if (rawLayer === "FAIRWY") {
1906
+ type = "Fairway";
1907
+ const trafic = parseCodeLocal(props.TRAFIC);
1908
+ if (trafic && TRAFIC_TABLE[trafic]) entries.push({ label: "Traffic", value: TRAFIC_TABLE[trafic] });
1909
+ if (props.ORIENT != null) entries.push({ label: "Orientation", value: `${props.ORIENT}\xB0` });
1910
+ } else if (rawLayer === "DMPGRD") {
1911
+ type = "Dumping Ground";
1912
+ const cat = parseCodeLocal(props.CATDPG);
1913
+ if (cat && CATDPG_TABLE[cat]) {
1914
+ entries.push({ label: "Category", value: CATDPG_TABLE[cat] });
1915
+ if (!title) title = CATDPG_TABLE[cat];
1916
+ }
1917
+ } else if (rawLayer === "MIPARE") {
1918
+ type = "Military Practice Area";
1919
+ if (props.RESTRN) {
1920
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1921
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1922
+ }
1923
+ } else if (rawLayer === "PRCARE") {
1924
+ type = "Precautionary Area";
1925
+ const trafic = parseCodeLocal(props.TRAFIC);
1926
+ if (trafic && TRAFIC_TABLE[trafic]) entries.push({ label: "Traffic", value: TRAFIC_TABLE[trafic] });
1927
+ if (props.ORIENT != null) entries.push({ label: "Orientation", value: `${props.ORIENT}\xB0` });
1928
+ } else if (rawLayer === "TSSLPT") {
1929
+ type = "TSS Lane";
1930
+ const trafic = parseCodeLocal(props.TRAFIC);
1931
+ if (trafic && TRAFIC_TABLE[trafic]) entries.push({ label: "Traffic", value: TRAFIC_TABLE[trafic] });
1932
+ if (props.ORIENT != null) entries.push({ label: "Orientation", value: `${props.ORIENT}\xB0` });
1933
+ } else if (rawLayer === "TSELNE") {
1934
+ type = "TSS Boundary";
1935
+ } else if (rawLayer === "ISTZNE") {
1936
+ type = "Inshore Traffic Zone";
1937
+ } else if (rawLayer === "OSPARE") {
1938
+ type = "Offshore Production Area";
1939
+ if (props.RESTRN) {
1940
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1941
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1942
+ }
1943
+ } else if (rawLayer === "MARCUL") {
1944
+ type = "Marine Farm / Culture";
1945
+ const cat = parseCodeLocal(props.CATMFA);
1946
+ if (cat && CATMFA_TABLE[cat]) {
1947
+ entries.push({ label: "Category", value: CATMFA_TABLE[cat] });
1948
+ if (!title) title = CATMFA_TABLE[cat];
1949
+ }
1950
+ if (props.RESTRN) {
1951
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1952
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1953
+ }
1954
+ } else if (rawLayer === "CBLARE") {
1955
+ type = "Cable Area";
1956
+ const cat = parseCodeLocal(props.CATCBL);
1957
+ if (cat && CATCBL_TABLE[cat]) {
1958
+ entries.push({ label: "Category", value: CATCBL_TABLE[cat] });
1959
+ if (!title) title = `${CATCBL_TABLE[cat]} area`;
1960
+ }
1961
+ const st2 = parseCodeLocal(props.STATUS);
1962
+ if (st2 && STATUS_TABLE[st2]) entries.push({ label: "Status", value: STATUS_TABLE[st2] });
1963
+ if (props.RESTRN) {
1964
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1965
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1966
+ }
1967
+ } else if (rawLayer === "PIPARE") {
1968
+ type = "Pipeline Area";
1969
+ const cat = parseCodeLocal(props.CATPIP);
1970
+ if (cat && CATPIP_TABLE[cat]) {
1971
+ entries.push({ label: "Category", value: CATPIP_TABLE[cat] });
1972
+ if (!title) title = `${CATPIP_TABLE[cat]} area`;
1973
+ }
1974
+ const st2 = parseCodeLocal(props.STATUS);
1975
+ if (st2 && STATUS_TABLE[st2]) entries.push({ label: "Status", value: STATUS_TABLE[st2] });
1976
+ if (props.RESTRN) {
1977
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
1978
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
1979
+ }
1980
+ } else if (rawLayer === "BRIDGE") {
1981
+ const CATBRG_SHORT = {
1982
+ 1: "Fixed",
1983
+ 2: "Opening",
1984
+ 3: "Swing",
1985
+ 4: "Lifting",
1986
+ 5: "Bascule",
1987
+ 6: "Pontoon",
1988
+ 7: "Draw",
1989
+ 8: "Transporter",
1990
+ 9: "Footbridge",
1991
+ 10: "Viaduct",
1992
+ 11: "Aqueduct",
1993
+ 12: "Suspension"
1994
+ };
1995
+ const NATCON_TABLE = {
1996
+ 1: "Masonry",
1997
+ 2: "Concrete",
1998
+ 3: "Loose boulders",
1999
+ 4: "Hard-surfaced",
2000
+ 5: "Unsurfaced",
2001
+ 6: "Wooden",
2002
+ 7: "Metal",
2003
+ 8: "GRP"
2004
+ };
2005
+ const CONDTN_SHORT = {
2006
+ 1: "Under construction",
2007
+ 2: "Ruined",
2008
+ 3: "Under reclamation",
2009
+ 5: "Planned"
2010
+ };
2011
+ const catCode = parseCodeLocal(props.CATBRG);
2012
+ const catName = catCode ? CATBRG_SHORT[catCode] : null;
2013
+ type = catName ? `${catName} Bridge` : "Bridge";
2014
+ if (catName) entries.push({ label: "Category", value: catName });
2015
+ if (props.VERCLR != null) entries.push({ label: "Vertical Clearance", value: `${props.VERCLR} m` });
2016
+ if (props.VERCCL != null) entries.push({ label: "Clearance (closed)", value: `${props.VERCCL} m` });
2017
+ if (props.VERCOP != null) entries.push({ label: "Clearance (open)", value: `${props.VERCOP} m` });
2018
+ if (props.HORCLR != null) entries.push({ label: "Horizontal Clearance", value: `${props.HORCLR} m` });
2019
+ const natCode = parseCodeLocal(props.NATCON);
2020
+ if (natCode && NATCON_TABLE[natCode]) entries.push({ label: "Construction", value: NATCON_TABLE[natCode] });
2021
+ const condCode = parseCodeLocal(props.CONDTN);
2022
+ if (condCode && CONDTN_SHORT[condCode]) entries.push({ label: "Condition", value: CONDTN_SHORT[condCode] });
2023
+ const st2 = parseCodeLocal(props.STATUS);
2024
+ if (st2 && STATUS_TABLE[st2]) entries.push({ label: "Status", value: STATUS_TABLE[st2] });
2025
+ if (props.NOBJNM) entries.push({ label: "National Name", value: String(props.NOBJNM) });
2026
+ } else if (rawLayer === "CBLSUB" || rawLayer === "CBLOHD") {
2027
+ const CATCBL_LINE = {
2028
+ 1: "Power line",
2029
+ 2: "Transmission line",
2030
+ 3: "Telephone",
2031
+ 4: "Telegraph",
2032
+ 5: "Mooring cable/chain",
2033
+ 6: "Ferry cable"
2034
+ };
2035
+ type = rawLayer === "CBLSUB" ? "Submarine Cable" : "Overhead Cable";
2036
+ const cat = parseCodeLocal(props.CATCBL);
2037
+ if (cat && CATCBL_LINE[cat]) {
2038
+ entries.push({ label: "Category", value: CATCBL_LINE[cat] });
2039
+ if (!title) title = `${CATCBL_LINE[cat]} (${rawLayer === "CBLSUB" ? "submarine" : "overhead"})`;
2040
+ }
2041
+ if (props.VERCLR != null) entries.push({ label: "Vertical Clearance", value: `${props.VERCLR}m` });
2042
+ if (props.VERCSA != null) entries.push({ label: "Clearance (safe)", value: `${props.VERCSA}m` });
2043
+ if (props.RESTRN) {
2044
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
2045
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
2046
+ }
2047
+ } else if (rawLayer === "PIPSOL" || rawLayer === "PIPOHD") {
2048
+ const CATPIP_LINE = {
2049
+ 2: "Outfall pipe",
2050
+ 3: "Intake pipe",
2051
+ 4: "Sewer",
2052
+ 5: "Bubbler system",
2053
+ 6: "Supply pipe"
2054
+ };
2055
+ type = rawLayer === "PIPSOL" ? "Submarine Pipeline" : "Overhead Pipeline";
2056
+ const cat = parseCodeLocal(props.CATPIP);
2057
+ if (cat && CATPIP_LINE[cat]) {
2058
+ entries.push({ label: "Category", value: CATPIP_LINE[cat] });
2059
+ if (!title) title = `${CATPIP_LINE[cat]} (${rawLayer === "PIPSOL" ? "submarine" : "overhead"})`;
2060
+ }
2061
+ if (props.VERCLR != null) entries.push({ label: "Vertical Clearance", value: `${props.VERCLR}m` });
2062
+ if (props.RESTRN) {
2063
+ const codes = parseListCodes(props.RESTRN).filter((n) => RESTRN_TABLE[n]);
2064
+ if (codes.length > 0) entries.push({ label: "Restrictions", value: codes.map((c) => RESTRN_TABLE[c]).join("; ") });
2065
+ }
2066
+ } else if (rawLayer === "BERTHS") {
2067
+ type = "Berth";
2068
+ } else if (rawLayer === "DAMCON") {
2069
+ type = "Dam";
2070
+ if (props.VERCLR != null) entries.push({ label: "Vertical Clearance", value: `${props.VERCLR}m` });
2071
+ } else if (rawLayer === "FERYRT") {
2072
+ type = "Ferry Route";
2073
+ } else if (rawLayer === "RECTRC") {
2074
+ type = "Recommended Track";
2075
+ const trafic = parseCodeLocal(props.TRAFIC);
2076
+ if (trafic && TRAFIC_TABLE[trafic]) entries.push({ label: "Traffic", value: TRAFIC_TABLE[trafic] });
2077
+ if (props.ORIENT != null) entries.push({ label: "Orientation", value: `${props.ORIENT}\xB0` });
2078
+ } else if (rawLayer === "BUISGL") {
2079
+ type = "Building";
2080
+ const cat = parseCodeLocal(props.CATBUA);
2081
+ if (cat) {
2082
+ const CATBUA = {
2083
+ 1: "Urban area",
2084
+ 5: "Settlement",
2085
+ 6: "Town",
2086
+ 7: "Village",
2087
+ 8: "City"
2088
+ };
2089
+ if (CATBUA[cat]) {
2090
+ entries.push({ label: "Category", value: CATBUA[cat] });
2091
+ if (!title) title = CATBUA[cat];
2092
+ }
2093
+ }
2094
+ const func = parseCodeLocal(props.FUNCTN);
2095
+ if (func) {
2096
+ const FUNCTN = {
2097
+ 2: "Harbour master's office",
2098
+ 3: "Customs office",
2099
+ 4: "Health office",
2100
+ 5: "Hospital",
2101
+ 6: "Post office",
2102
+ 7: "Hotel",
2103
+ 8: "Railway station",
2104
+ 9: "Police station",
2105
+ 10: "Water-police station",
2106
+ 11: "Pilot office",
2107
+ 13: "Bank",
2108
+ 14: "Headquarters",
2109
+ 15: "Transit shed/warehouse",
2110
+ 18: "Church",
2111
+ 19: "Chapel",
2112
+ 20: "Temple",
2113
+ 21: "Synagogue",
2114
+ 22: "Mosque",
2115
+ 24: "Lookout",
2116
+ 25: "Communication",
2117
+ 28: "Power station",
2118
+ 33: "Factory",
2119
+ 35: "Refinery",
2120
+ 36: "Religious order",
2121
+ 37: "School",
2122
+ 38: "University"
2123
+ };
2124
+ if (FUNCTN[func]) entries.push({ label: "Function", value: FUNCTN[func] });
2125
+ }
2126
+ if (props.VERLEN != null) entries.push({ label: "Height", value: `${props.VERLEN}m` });
2127
+ } else if (rawLayer === "SILTNK") {
2128
+ type = "Silo / Tank";
2129
+ const cat = parseCodeLocal(props.CATSIL);
2130
+ if (cat) {
2131
+ const CATSIL = { 1: "Silo", 2: "Tank", 3: "Grain elevator", 4: "Water tower" };
2132
+ if (CATSIL[cat]) {
2133
+ type = CATSIL[cat];
2134
+ if (!title) title = CATSIL[cat];
2135
+ }
2136
+ }
2137
+ if (parseCodeLocal(props.CONVIS) === 1) entries.push({ label: "Visibility", value: "Conspicuous from seaward" });
2138
+ if (props.VERLEN != null) entries.push({ label: "Height", value: `${props.VERLEN}m` });
2139
+ } else if (rawLayer === "HULKES") {
2140
+ type = "Hulk";
2141
+ if (props.CATHLK != null) {
2142
+ const cat = parseCodeLocal(props.CATHLK);
2143
+ const CATHLK = { 1: "Floating restaurant", 2: "Historic ship", 3: "Museum", 4: "Accommodation", 5: "Floating breakwater" };
2144
+ if (cat && CATHLK[cat]) {
2145
+ entries.push({ label: "Use", value: CATHLK[cat] });
2146
+ if (!title) title = CATHLK[cat];
2147
+ }
2148
+ }
2149
+ } else if (rawLayer === "DRYDOC") {
2150
+ type = "Dry Dock";
2151
+ } else if (rawLayer === "FLODOC") {
2152
+ type = "Floating Dock";
2153
+ if (props.VERLEN != null) entries.push({ label: "Length", value: `${props.VERLEN}m` });
2154
+ if (props.HORLEN != null) entries.push({ label: "Horiz. Length", value: `${props.HORLEN}m` });
2155
+ } else if (rawLayer === "PONTON") {
2156
+ type = "Pontoon";
2157
+ } else if (rawLayer === "PYLONS") {
2158
+ type = "Pylon / Bridge Support";
2159
+ const cat = parseCodeLocal(props.CATPYL);
2160
+ if (cat) {
2161
+ const CATPYL = {
2162
+ 1: "Power transmission pylon",
2163
+ 2: "Telecom pylon",
2164
+ 3: "Aerial cableway pylon",
2165
+ 4: "Bridge pylon",
2166
+ 5: "Bridge pier",
2167
+ 6: "Chimney",
2168
+ 7: "Flare stack"
2169
+ };
2170
+ if (CATPYL[cat]) {
2171
+ entries.push({ label: "Type", value: CATPYL[cat] });
2172
+ if (!title) title = CATPYL[cat];
2173
+ }
2174
+ }
2175
+ if (props.VERLEN != null) entries.push({ label: "Height", value: `${props.VERLEN}m` });
2176
+ } else if (rawLayer === "GATCON") {
2177
+ type = "Gate";
2178
+ const cat = parseCodeLocal(props.CATGAT);
2179
+ if (cat) {
2180
+ const CATGAT = { 1: "General gate", 2: "Flood barrage gate", 3: "Caisson", 4: "Lock gate", 5: "Dyke gate" };
2181
+ if (CATGAT[cat]) {
2182
+ entries.push({ label: "Type", value: CATGAT[cat] });
2183
+ if (!title) title = CATGAT[cat];
2184
+ }
2185
+ }
2186
+ } else if (rawLayer === "LOGPON") {
2187
+ type = "Log Pond";
2188
+ } else if (rawLayer === "AIRARE") {
2189
+ type = "Airport / Airfield";
2190
+ const cat = parseCodeLocal(props.CATAIR);
2191
+ if (cat) {
2192
+ const CATAIR = { 1: "Airport", 2: "Heliport", 3: "Helipad", 4: "Emergency helipad" };
2193
+ if (CATAIR[cat]) {
2194
+ entries.push({ label: "Type", value: CATAIR[cat] });
2195
+ if (!title) title = CATAIR[cat];
2196
+ }
2197
+ }
2198
+ } else if (rawLayer === "SLCONS") {
2199
+ type = "Shoreline Construction";
2200
+ const cat = parseCodeLocal(props.CATSLC);
2201
+ if (cat) {
2202
+ const CATSLC = {
2203
+ 1: "Breakwater",
2204
+ 2: "Groyne",
2205
+ 3: "Mole",
2206
+ 4: "Pier",
2207
+ 5: "Promenade pier",
2208
+ 6: "Wharf",
2209
+ 7: "Training wall",
2210
+ 8: "Rip rap",
2211
+ 9: "Revetment",
2212
+ 10: "Sea wall",
2213
+ 11: "Landing stairs",
2214
+ 12: "Landing place",
2215
+ 13: "Fender",
2216
+ 14: "Lock",
2217
+ 15: "Flood gate",
2218
+ 16: "Slipway",
2219
+ 17: "Log ramp"
2220
+ };
2221
+ if (CATSLC[cat]) {
2222
+ entries.push({ label: "Type", value: CATSLC[cat] });
2223
+ if (!title) title = CATSLC[cat];
2224
+ }
2225
+ }
2226
+ const wl = parseCodeLocal(props.WATLEV);
2227
+ if (wl && WATLEV_TABLE[wl]) entries.push({ label: "Water Level", value: WATLEV_TABLE[wl] });
2228
+ } else if (rawLayer === "COALNE") {
2229
+ type = "Coastline";
2230
+ const cat = parseCodeLocal(props.CATCOA);
2231
+ if (cat) {
2232
+ const CATCOA = {
2233
+ 1: "Steep coast",
2234
+ 2: "Flat coast",
2235
+ 3: "Sandy shore",
2236
+ 4: "Stony shore",
2237
+ 5: "Shingly shore",
2238
+ 6: "Glacier",
2239
+ 7: "Mangrove",
2240
+ 8: "Marshy shore",
2241
+ 9: "Coral reef",
2242
+ 10: "Ice coast",
2243
+ 11: "Rocky shore"
2244
+ };
2245
+ if (CATCOA[cat]) {
2246
+ entries.push({ label: "Type", value: CATCOA[cat] });
2247
+ if (!title) title = CATCOA[cat];
2248
+ }
2249
+ }
2250
+ } else if (rawLayer === "DEPCNT") {
2251
+ type = "Depth Contour";
2252
+ if (props.VALDCO != null) {
2253
+ title = title || `${props.VALDCO}m contour`;
2254
+ entries.push({ label: "Depth", value: `${props.VALDCO}m` });
2255
+ }
2256
+ } else if (rawLayer === "RIVERS") {
2257
+ type = "River";
2258
+ } else if (rawLayer === "CANALS") {
2259
+ type = "Canal";
2260
+ if (props.HORWID != null) entries.push({ label: "Width", value: `${props.HORWID}m` });
2261
+ } else if (rawLayer === "LAKARE") {
2262
+ type = "Lake";
2263
+ } else if (rawLayer === "RAPIDS") {
2264
+ type = "Rapids";
2265
+ } else if (rawLayer === "WATFAL") {
2266
+ type = "Waterfall";
2267
+ if (props.HEIGHT != null) entries.push({ label: "Height", value: `${props.HEIGHT}m` });
2268
+ } else if (rawLayer === "LNDARE") {
2269
+ type = "Land Area";
2270
+ }
2271
+ if (props.INFORM) entries.push({ label: "Information", value: String(props.INFORM) });
2272
+ if (props.TXTDSC) entries.push({ label: "Text Description", value: String(props.TXTDSC) });
2273
+ const st = parseCodeLocal(props.STATUS);
2274
+ if (st && STATUS_TABLE[st]) entries.push({ label: "Status", value: STATUS_TABLE[st] });
2275
+ return { type, title: title || type, properties: entries };
2276
+ }
2277
+ function parseCode(val) {
2278
+ if (val == null) return null;
2279
+ let s = String(val).trim();
2280
+ if (s.startsWith("[")) {
2281
+ try {
2282
+ const arr = JSON.parse(s);
2283
+ if (Array.isArray(arr) && arr.length > 0) s = String(arr[0]);
2284
+ else return null;
2285
+ } catch {
2286
+ }
2287
+ }
2288
+ s = s.split(/[,;]/)[0].trim();
2289
+ const n = Number(s);
2290
+ return isNaN(n) ? null : n;
2291
+ }
2292
+ function parseListCodes(val) {
2293
+ if (val == null) return [];
2294
+ if (Array.isArray(val)) {
2295
+ return val.map(Number).filter((n) => !isNaN(n) && n > 0);
2296
+ }
2297
+ const s = String(val).trim();
2298
+ if (s.startsWith("[")) {
2299
+ try {
2300
+ const arr = JSON.parse(s);
2301
+ if (Array.isArray(arr)) return arr.map(Number).filter((n) => !isNaN(n) && n > 0);
2302
+ } catch {
2303
+ }
2304
+ }
2305
+ return s.split(/[,;]/).map((v) => Number(v.trim())).filter((n) => !isNaN(n) && n > 0);
2306
+ }
2307
+ function parseGroup(val) {
2308
+ if (val == null) return null;
2309
+ const s = String(val).trim();
2310
+ const parenMatch = s.match(/^\(?([\d+]+)\)?$/);
2311
+ if (parenMatch) {
2312
+ const parts = parenMatch[1].split("+").map(Number);
2313
+ if (parts.every((n2) => !isNaN(n2) && n2 > 0)) return parts;
2314
+ }
2315
+ const n = Number(s);
2316
+ return isNaN(n) || n <= 0 ? null : [n];
2317
+ }
2318
+ var CHILD_LAYERS = /* @__PURE__ */ new Set(["LIGHTS", "FOGSIG", "RTPBCN", "DAYMAR", "TOPMAR"]);
2319
+ var PARENT_LAYERS = [
2320
+ "enc-BOYLAT",
2321
+ "enc-BOYCAR",
2322
+ "enc-BOYSPP",
2323
+ "enc-BOYISD",
2324
+ "enc-BOYSAW",
2325
+ "enc-BCNLAT",
2326
+ "enc-BCNCAR",
2327
+ "enc-BCNSPP",
2328
+ "enc-BCNISD",
2329
+ "enc-BCNSAW"
2330
+ ];
2331
+ function deriveMarinerName(objnam, colourCode) {
2332
+ if (!objnam) return "";
2333
+ const m = objnam.match(/(\d+[A-Z]?)\s*$/);
2334
+ if (!m) return objnam;
2335
+ const num = m[1];
2336
+ const colourAbbr = colourCode ? COLOUR_TABLE[colourCode]?.abbr : null;
2337
+ if (colourAbbr && colourAbbr !== "W") {
2338
+ return `${colourAbbr} "${num}"`;
2339
+ }
2340
+ return `"${num}"`;
2341
+ }
2342
+ function getTopmarkMeaning(topshpCode, rawLayer) {
2343
+ if (topshpCode == null) return null;
2344
+ switch (topshpCode) {
2345
+ case 10:
2346
+ return "West cardinal \u2014 pass to the west";
2347
+ // 2 cones, point to point
2348
+ case 11:
2349
+ return "East cardinal \u2014 pass to the east";
2350
+ // 2 cones, base to base
2351
+ case 13:
2352
+ return "North cardinal \u2014 pass to the north";
2353
+ // 2 cones, points up
2354
+ case 14:
2355
+ return "South cardinal \u2014 pass to the south";
2356
+ }
2357
+ const parent = rawLayer.replace(/^(BOY|BCN)/, "");
2358
+ if (parent === "LAT") {
2359
+ if (topshpCode === 1) return "Starboard-hand mark";
2360
+ if (topshpCode === 5) return "Port-hand mark";
2361
+ }
2362
+ if (parent === "SAW" && topshpCode === 3) return "Safe water \u2014 navigable water all around";
2363
+ if (parent === "ISD" && topshpCode === 4) return "Isolated danger \u2014 danger of limited extent";
2364
+ if (parent === "SPP" && topshpCode === 7) return "Special-purpose mark";
2365
+ return null;
2366
+ }
2367
+ function decodeNavAidProps(layerName, props, coords) {
2368
+ const rawLayer = layerName.replace(/^enc-/, "").replace(/-(point|line|polygon|midpoint)$/, "");
2369
+ const fullName = props.OBJNAM || props.NOBJNM || "";
2370
+ const colourCode = parseCode(props.COLOUR);
2371
+ const marinerName = deriveMarinerName(fullName, colourCode);
2372
+ const name = marinerName || fullName;
2373
+ const type = LAYER_TYPE_MAP[rawLayer] ?? rawLayer;
2374
+ const lightColourSource = props.LIGHT_COLOUR ?? props.COLOUR;
2375
+ const lightColourCodes = parseListCodes(lightColourSource);
2376
+ const colourEntry = colourCode ? COLOUR_TABLE[colourCode] : null;
2377
+ const lightColourHexList = lightColourCodes.map((c) => COLOUR_TABLE[c]?.hex ?? "#ffffff");
2378
+ const boyshpCode = parseCode(props.BOYSHP);
2379
+ const shape = boyshpCode ? BOYSHP_TABLE[boyshpCode] ?? null : null;
2380
+ const topshpCode = parseCode(props.TOPSHP);
2381
+ const topmark = topshpCode ? TOPSHP_TABLE[topshpCode] ?? null : null;
2382
+ const topmarkMeaning = getTopmarkMeaning(topshpCode, rawLayer);
2383
+ const litchrCode = parseCode(props.LITCHR);
2384
+ const litchrEntry = litchrCode ? LITCHR_TABLE[litchrCode] : null;
2385
+ const sigper = props.SIGPER != null ? Number(props.SIGPER) : null;
2386
+ const group = parseGroup(props.SIGGRP);
2387
+ const nameForFog = String(props.OBJNAM ?? "").toLowerCase();
2388
+ let catfogCode = null;
2389
+ if (/\bwhistle\b/.test(nameForFog)) catfogCode = 6;
2390
+ else if (/\bgong\b/.test(nameForFog)) catfogCode = 8;
2391
+ else if (/\bbell\b/.test(nameForFog)) catfogCode = 5;
2392
+ else if (/\bhorn\b/.test(nameForFog)) catfogCode = 9;
2393
+ else if (/\bsiren\b/.test(nameForFog)) catfogCode = 3;
2394
+ else if (/diaphone/.test(nameForFog)) catfogCode = 2;
2395
+ else if (/nautophone|nauto/.test(nameForFog)) catfogCode = 4;
2396
+ if (!catfogCode) catfogCode = parseCode(props.CATFOG);
2397
+ const fogSignal2 = catfogCode ? CATFOG_TABLE[catfogCode] ?? null : null;
2398
+ const fogSignalAbbr = catfogCode ? CATFOG_ABBR_TABLE[catfogCode] ?? null : null;
2399
+ const radarReflector = props.RETRFL != null && String(props.RETRFL) !== "0" && String(props.RETRFL) !== "";
2400
+ const valsou = props.VALSOU != null ? Number(props.VALSOU) : null;
2401
+ const depth = valsou != null && !isNaN(valsou) ? valsou : null;
2402
+ const statusCode = parseCode(props.STATUS);
2403
+ const status = statusCode ? STATUS_TABLE[statusCode] ?? null : null;
2404
+ const marsysCode = parseCode(props.MARSYS);
2405
+ const markingSystem = marsysCode ? MARSYS_TABLE[marsysCode] ?? null : null;
2406
+ const catlamCode = parseCode(props.CATLAM);
2407
+ const catcamCode = parseCode(props.CATCAM);
2408
+ const cathafCode = parseCode(props.CATHAF);
2409
+ const catscfCode = parseCode(props.CATSCF);
2410
+ const catcrnCode = parseCode(props.CATCRN);
2411
+ const catlmkNames = parseListCodes(props.CATLMK).map((c) => CATLMK_TABLE[c]).filter((n) => Boolean(n));
2412
+ const category = catlamCode ? CATLAM_TABLE[catlamCode] ?? null : catcamCode ? CATCAM_TABLE[catcamCode] ?? null : cathafCode ? CATHAF_TABLE[cathafCode] ?? null : catscfCode ? CATSCF_TABLE[catscfCode] ?? null : catcrnCode ? CATCRN_TABLE[catcrnCode] ?? null : catlmkNames.length > 0 ? catlmkNames.join(" / ") : null;
2413
+ const information = props.INFORM || null;
2414
+ const lightColourAbbrs = lightColourCodes.map((c) => COLOUR_TABLE[c]?.abbr).filter(Boolean);
2415
+ const notation = buildNotation(litchrEntry?.abbr ?? null, lightColourAbbrs, sigper, group);
2416
+ const sectr1 = props.SECTR1 != null ? Number(props.SECTR1) : null;
2417
+ const sectr2 = props.SECTR2 != null ? Number(props.SECTR2) : null;
2418
+ const orient = props.ORIENT != null ? Number(props.ORIENT) : null;
2419
+ const catlit = parseCode(props.CATLIT);
2420
+ const isDirectional = catlit === 1;
2421
+ const sectors = [];
2422
+ const isSectorLight = sectr1 != null && sectr2 != null && !isNaN(sectr1) && !isNaN(sectr2);
2423
+ const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
2424
+ const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
2425
+ if (isSectorLight) {
2426
+ sectors.push({
2427
+ sectorStart: sectr1,
2428
+ sectorEnd: sectr2,
2429
+ colour: colourEntry?.name ?? "White",
2430
+ colourHex: colourEntry?.hex ?? "#ffffff",
2431
+ notation,
2432
+ nominalRange
2433
+ });
2434
+ }
2435
+ return {
2436
+ name,
2437
+ fullName: fullName && fullName !== name ? fullName : null,
2438
+ type,
2439
+ layerName: rawLayer,
2440
+ colour: colourEntry?.name ?? null,
2441
+ colourHex: colourEntry?.hex ?? "#ffffff",
2442
+ lightColourHex: lightColourHexList[0] ?? colourEntry?.hex ?? "#ffffff",
2443
+ colourHexList: lightColourHexList.length > 0 ? lightColourHexList : [colourEntry?.hex ?? "#ffffff"],
2444
+ shape,
2445
+ topmark,
2446
+ topmarkMeaning,
2447
+ lightCharacter: litchrEntry?.abbr ?? null,
2448
+ lightCharacterFull: litchrEntry?.name ?? null,
2449
+ period: sigper,
2450
+ group,
2451
+ fogSignal: fogSignal2,
2452
+ fogSignalAbbr,
2453
+ radarReflector,
2454
+ depth,
2455
+ status,
2456
+ markingSystem,
2457
+ category,
2458
+ information,
2459
+ notation,
2460
+ coordinates: coords ?? null,
2461
+ sectors,
2462
+ orientation: orient != null && !isNaN(orient) ? orient : null,
2463
+ isDirectional,
2464
+ isSectorLight
2465
+ };
2466
+ }
2467
+ function buildNotation(litchrAbbr, colourAbbrs, sigper, group) {
2468
+ if (!litchrAbbr) return "";
2469
+ const parts = [];
2470
+ let charPart = litchrAbbr;
2471
+ const totalFlashes = group ? group.reduce((a, b) => a + b, 0) : 0;
2472
+ if (totalFlashes > 1) charPart += `(${group.join("+")})`;
2473
+ parts.push(charPart);
2474
+ if (colourAbbrs.length > 0) parts.push(colourAbbrs.join("."));
2475
+ if (sigper && sigper > 0) parts.push(`${sigper}s`);
2476
+ return parts.join(" ");
2477
+ }
2478
+ function buildFlashPattern(decoded) {
2479
+ const litchr = decoded.lightCharacter;
2480
+ if (!litchr) return null;
2481
+ const period = decoded.period && decoded.period > 0 ? decoded.period * 1e3 : 6e3;
2482
+ const groups = decoded.group ?? [1];
2483
+ const totalFlashes = groups.reduce((a, b) => a + b, 0);
2484
+ const hex = decoded.lightColourHex;
2485
+ const colours = decoded.colourHexList;
2486
+ const notation = decoded.notation;
2487
+ const phases = [];
2488
+ switch (litchr) {
2489
+ case "F": {
2490
+ phases.push({ on: true, duration: period, color: hex });
2491
+ break;
2492
+ }
2493
+ case "Fl":
2494
+ case "S.Fl": {
2495
+ const flashDur = 400;
2496
+ const interFlashGap = 300;
2497
+ const interGroupGap = 800;
2498
+ const activeDur = groups.reduce((sum, g) => sum + g * flashDur + (g - 1) * interFlashGap, 0) + (groups.length - 1) * interGroupGap;
2499
+ const eclipseDur = Math.max(period - activeDur, 500);
2500
+ for (let gi = 0; gi < groups.length; gi++) {
2501
+ const g = groups[gi];
2502
+ for (let i = 0; i < g; i++) {
2503
+ phases.push({ on: true, duration: flashDur, color: hex });
2504
+ if (i < g - 1) phases.push({ on: false, duration: interFlashGap });
2505
+ }
2506
+ if (gi < groups.length - 1) phases.push({ on: false, duration: interGroupGap });
2507
+ }
2508
+ phases.push({ on: false, duration: eclipseDur });
2509
+ break;
2510
+ }
2511
+ case "LFl":
2512
+ case "S.LFl": {
2513
+ const onDur = 2e3;
2514
+ phases.push({ on: true, duration: onDur, color: hex });
2515
+ phases.push({ on: false, duration: Math.max(period - onDur, 1e3) });
2516
+ break;
2517
+ }
2518
+ case "Oc": {
2519
+ const offDur = 400;
2520
+ const interGroupOn = 300;
2521
+ const interGroupGap = 800;
2522
+ const totalOcclusions = totalFlashes;
2523
+ const activeDur = totalOcclusions * offDur + (totalOcclusions - 1) * interGroupOn + (groups.length > 1 ? (groups.length - 1) * (interGroupGap - interGroupOn) : 0);
2524
+ const onDur = Math.max(period - activeDur, 500);
2525
+ phases.push({ on: true, duration: onDur, color: hex });
2526
+ for (let gi = 0; gi < groups.length; gi++) {
2527
+ const g = groups[gi];
2528
+ for (let i = 0; i < g; i++) {
2529
+ phases.push({ on: false, duration: offDur });
2530
+ if (i < g - 1) phases.push({ on: true, duration: interGroupOn, color: hex });
2531
+ }
2532
+ if (gi < groups.length - 1) phases.push({ on: true, duration: interGroupGap, color: hex });
2533
+ }
2534
+ break;
2535
+ }
2536
+ case "Iso": {
2537
+ const half = period / 2;
2538
+ phases.push({ on: true, duration: half, color: hex });
2539
+ phases.push({ on: false, duration: half });
2540
+ break;
2541
+ }
2542
+ case "Q": {
2543
+ const cycleDur = 1e3;
2544
+ const count = Math.max(totalFlashes, 1);
2545
+ if (count === 1) {
2546
+ phases.push({ on: true, duration: cycleDur / 2, color: hex });
2547
+ phases.push({ on: false, duration: cycleDur / 2 });
2548
+ } else {
2549
+ const flashDur = 250;
2550
+ const gap = 250;
2551
+ for (let i = 0; i < count; i++) {
2552
+ phases.push({ on: true, duration: flashDur, color: hex });
2553
+ if (i < count - 1) phases.push({ on: false, duration: gap });
2554
+ }
2555
+ phases.push({ on: false, duration: Math.max(period - count * (flashDur + gap), 500) });
2556
+ }
2557
+ break;
2558
+ }
2559
+ case "VQ": {
2560
+ const flashDur = 150;
2561
+ const gap = 150;
2562
+ const count = Math.max(totalFlashes, 1);
2563
+ if (count === 1) {
2564
+ phases.push({ on: true, duration: flashDur, color: hex });
2565
+ phases.push({ on: false, duration: gap });
2566
+ } else {
2567
+ for (let i = 0; i < count; i++) {
2568
+ phases.push({ on: true, duration: flashDur, color: hex });
2569
+ if (i < count - 1) phases.push({ on: false, duration: gap });
2570
+ }
2571
+ phases.push({ on: false, duration: Math.max(period - count * (flashDur + gap), 500) });
2572
+ }
2573
+ break;
2574
+ }
2575
+ case "UQ": {
2576
+ const flashDur = 80;
2577
+ const gap = 80;
2578
+ phases.push({ on: true, duration: flashDur, color: hex });
2579
+ phases.push({ on: false, duration: gap });
2580
+ break;
2581
+ }
2582
+ case "Mo": {
2583
+ const dot = 300;
2584
+ const dash = 900;
2585
+ const gap = 300;
2586
+ phases.push({ on: true, duration: dot, color: hex });
2587
+ phases.push({ on: false, duration: gap });
2588
+ phases.push({ on: true, duration: dash, color: hex });
2589
+ phases.push({ on: false, duration: gap });
2590
+ phases.push({ on: true, duration: dot, color: hex });
2591
+ phases.push({ on: false, duration: Math.max(period - 3 * gap - dot * 2 - dash, 1e3) });
2592
+ break;
2593
+ }
2594
+ case "Al":
2595
+ case "Al.Gp":
2596
+ case "Al.Fl":
2597
+ case "Al.LFl":
2598
+ case "Al.Oc":
2599
+ case "Al.F.Fl": {
2600
+ const isFlashing = litchr.includes("Fl") || litchr === "Al.Gp";
2601
+ const isOcculting = litchr.includes("Oc");
2602
+ const isFixed = litchr === "Al" || litchr === "Al.F.Fl";
2603
+ if (isFlashing && totalFlashes > 1) {
2604
+ const flashDur = 400;
2605
+ const interFlashGap = 300;
2606
+ const interGroupGap = 800;
2607
+ const activeDur = groups.reduce((sum, g) => sum + g * flashDur + (g - 1) * interFlashGap, 0) + (groups.length - 1) * interGroupGap;
2608
+ const eclipseDur = Math.max(period - activeDur, 500);
2609
+ for (let gi = 0; gi < groups.length; gi++) {
2610
+ const g = groups[gi];
2611
+ const groupColour = colours[gi % colours.length];
2612
+ for (let i = 0; i < g; i++) {
2613
+ phases.push({ on: true, duration: flashDur, color: groupColour });
2614
+ if (i < g - 1) phases.push({ on: false, duration: interFlashGap });
2615
+ }
2616
+ if (gi < groups.length - 1) phases.push({ on: false, duration: interGroupGap });
2617
+ }
2618
+ phases.push({ on: false, duration: eclipseDur });
2619
+ } else if (isOcculting) {
2620
+ const numColours = Math.max(colours.length, 2);
2621
+ const offDur = 400;
2622
+ const onDur = (period - numColours * offDur) / numColours;
2623
+ for (let i = 0; i < numColours; i++) {
2624
+ phases.push({ on: true, duration: Math.max(onDur, 300), color: colours[i % colours.length] });
2625
+ phases.push({ on: false, duration: offDur });
2626
+ }
2627
+ } else if (isFixed) {
2628
+ const numColours = Math.max(colours.length, 2);
2629
+ const phaseDur = period / numColours;
2630
+ for (let i = 0; i < numColours; i++) {
2631
+ phases.push({ on: true, duration: phaseDur, color: colours[i % colours.length] });
2632
+ }
2633
+ } else {
2634
+ const numColours = Math.max(colours.length, 2);
2635
+ const phaseDur = period / numColours;
2636
+ for (let i = 0; i < numColours; i++) {
2637
+ phases.push({ on: true, duration: phaseDur, color: colours[i % colours.length] });
2638
+ }
2639
+ }
2640
+ break;
2641
+ }
2642
+ default: {
2643
+ phases.push({ on: true, duration: 500, color: hex });
2644
+ phases.push({ on: false, duration: Math.max(period - 500, 500) });
2645
+ break;
2646
+ }
2647
+ }
2648
+ const totalCycle = phases.reduce((sum, p) => sum + p.duration, 0);
2649
+ return { phases, totalCycle, notation };
2650
+ }
2651
+ function buildSectorsFromFeatures(features) {
2652
+ const sectors = [];
2653
+ for (const f of features) {
2654
+ const props = f.properties ?? {};
2655
+ const s1 = props.SECTR1 != null ? Number(props.SECTR1) : null;
2656
+ const s2 = props.SECTR2 != null ? Number(props.SECTR2) : null;
2657
+ if (s1 == null || s2 == null || isNaN(s1) || isNaN(s2)) continue;
2658
+ const colourCode = parseCode(props.COLOUR);
2659
+ const ce = colourCode ? COLOUR_TABLE[colourCode] : null;
2660
+ const litchrCode = parseCode(props.LITCHR);
2661
+ const le = litchrCode ? LITCHR_TABLE[litchrCode] : null;
2662
+ const sigper = props.SIGPER != null ? Number(props.SIGPER) : null;
2663
+ const group = parseGroup(props.SIGGRP);
2664
+ const notation = buildNotation(le?.abbr ?? null, ce ? [ce.abbr] : [], sigper, group);
2665
+ const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
2666
+ const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
2667
+ sectors.push({
2668
+ sectorStart: s1,
2669
+ sectorEnd: s2,
2670
+ colour: ce?.name ?? "White",
2671
+ colourHex: ce?.hex ?? "#ffffff",
2672
+ notation,
2673
+ nominalRange
2674
+ });
2675
+ }
2676
+ return sectors;
2677
+ }
2678
+ function buildSectorArcGeoJSON(center, startBearing, endBearing, radiusNm = 0.5, innerRadiusNm = 0, steps = 64) {
2679
+ const radiusDeg = radiusNm / 60;
2680
+ const innerDeg = innerRadiusNm > 0 ? innerRadiusNm / 60 : 0;
2681
+ const [lng, lat] = center;
2682
+ const cosLat = Math.cos(lat * Math.PI / 180);
2683
+ const s = (startBearing % 360 + 360) % 360;
2684
+ const e = (endBearing % 360 + 360) % 360;
2685
+ let sweep = e - s;
2686
+ if (sweep <= 0) sweep += 360;
2687
+ const project = (bearingDeg, rDeg) => {
2688
+ const rad = bearingDeg * Math.PI / 180;
2689
+ return [
2690
+ lng + rDeg * Math.sin(rad) / cosLat,
2691
+ lat + rDeg * Math.cos(rad)
2692
+ ];
2693
+ };
2694
+ const outerCoords = [];
2695
+ for (let i = 0; i <= steps; i++) {
2696
+ outerCoords.push(project(s + sweep * i / steps, radiusDeg));
2697
+ }
2698
+ let ring;
2699
+ if (innerDeg > 0) {
2700
+ const innerCoords = [];
2701
+ for (let i = steps; i >= 0; i--) {
2702
+ innerCoords.push(project(s + sweep * i / steps, innerDeg));
2703
+ }
2704
+ ring = [...outerCoords, ...innerCoords, outerCoords[0]];
2705
+ } else {
2706
+ ring = [[lng, lat], ...outerCoords, [lng, lat]];
2707
+ }
2708
+ return {
2709
+ type: "Feature",
2710
+ properties: {},
2711
+ geometry: {
2712
+ type: "Polygon",
2713
+ coordinates: [ring]
2714
+ }
2715
+ };
2716
+ }
2717
+ function buildSectorArcsFC(center, sectors, radiusNm = 0.4, innerRadiusFrac = 0.92) {
2718
+ const innerNm = innerRadiusFrac > 0 ? radiusNm * innerRadiusFrac : 0;
2719
+ const features = sectors.map((sec, i) => {
2720
+ const arc = buildSectorArcGeoJSON(center, sec.sectorStart, sec.sectorEnd, radiusNm, innerNm);
2721
+ arc.properties = {
2722
+ sectorIndex: i,
2723
+ colour: sec.colour,
2724
+ colourHex: sec.colourHex,
2725
+ notation: sec.notation,
2726
+ sectorStart: sec.sectorStart,
2727
+ sectorEnd: sec.sectorEnd,
2728
+ nominalRange: sec.nominalRange ?? null
2729
+ };
2730
+ return arc;
2731
+ });
2732
+ const lineFeatures = [];
2733
+ const labelFeatures = [];
2734
+ const seenBearings = /* @__PURE__ */ new Set();
2735
+ const TICK_OUTER_OVERHANG = 1.05;
2736
+ const TICK_INNER_OVERHANG = 0.93;
2737
+ for (const sec of sectors) {
2738
+ for (const bearing of [sec.sectorStart, sec.sectorEnd]) {
2739
+ const rad = bearing * Math.PI / 180;
2740
+ const radiusDeg = radiusNm / 60;
2741
+ const innerRadiusDeg = innerNm > 0 ? innerNm / 60 : 0;
2742
+ const cosLat = Math.cos(center[1] * Math.PI / 180);
2743
+ const sinB = Math.sin(rad);
2744
+ const cosB = Math.cos(rad);
2745
+ const tickOuterDeg = radiusDeg * TICK_OUTER_OVERHANG;
2746
+ const outerPt = [
2747
+ center[0] + tickOuterDeg * sinB / cosLat,
2748
+ center[1] + tickOuterDeg * cosB
2749
+ ];
2750
+ const tickInnerDeg = innerRadiusDeg > 0 ? Math.min(innerRadiusDeg * TICK_INNER_OVERHANG, innerRadiusDeg) : 0;
2751
+ const innerPt = tickInnerDeg > 0 ? [center[0] + tickInnerDeg * sinB / cosLat, center[1] + tickInnerDeg * cosB] : center;
2752
+ lineFeatures.push({
2753
+ type: "Feature",
2754
+ properties: { type: "sector-boundary" },
2755
+ geometry: {
2756
+ type: "LineString",
2757
+ coordinates: [innerPt, outerPt]
2758
+ }
2759
+ });
2760
+ const roundedBearing = Math.round(bearing * 10) / 10;
2761
+ if (!seenBearings.has(roundedBearing)) {
2762
+ seenBearings.add(roundedBearing);
2763
+ const labelRadius = radiusDeg * 1.12;
2764
+ const ldlat = labelRadius * Math.cos(rad);
2765
+ const ldlng = labelRadius * Math.sin(rad) / cosLat;
2766
+ labelFeatures.push({
2767
+ type: "Feature",
2768
+ properties: {
2769
+ type: "bearing-label",
2770
+ bearing: `${roundedBearing}\xB0`,
2771
+ bearingDeg: roundedBearing
2772
+ },
2773
+ geometry: {
2774
+ type: "Point",
2775
+ coordinates: [center[0] + ldlng, center[1] + ldlat]
2776
+ }
2777
+ });
2778
+ }
2779
+ }
2780
+ }
2781
+ const rangeRingFeatures = [];
2782
+ const seenRanges = /* @__PURE__ */ new Set();
2783
+ for (const sec of sectors) {
2784
+ if (sec.nominalRange != null && sec.nominalRange > 0 && !seenRanges.has(sec.nominalRange)) {
2785
+ seenRanges.add(sec.nominalRange);
2786
+ const rangeDeg = sec.nominalRange / 60;
2787
+ const cosLat = Math.cos(center[1] * Math.PI / 180);
2788
+ const ringCoords = [];
2789
+ const ringSteps = 72;
2790
+ for (let i = 0; i <= ringSteps; i++) {
2791
+ const angle = 360 * i / ringSteps;
2792
+ const rad = angle * Math.PI / 180;
2793
+ const dlat = rangeDeg * Math.cos(rad);
2794
+ const dlng = rangeDeg * Math.sin(rad) / cosLat;
2795
+ ringCoords.push([center[0] + dlng, center[1] + dlat]);
2796
+ }
2797
+ rangeRingFeatures.push({
2798
+ type: "Feature",
2799
+ properties: { type: "range-ring", nominalRange: sec.nominalRange },
2800
+ geometry: { type: "LineString", coordinates: ringCoords }
2801
+ });
2802
+ rangeRingFeatures.push({
2803
+ type: "Feature",
2804
+ properties: { type: "range-label", rangeLabel: `${sec.nominalRange} NM` },
2805
+ geometry: { type: "Point", coordinates: [center[0], center[1] + rangeDeg] }
2806
+ });
2807
+ }
2808
+ }
2809
+ return {
2810
+ type: "FeatureCollection",
2811
+ features: [...features, ...lineFeatures, ...labelFeatures, ...rangeRingFeatures]
2812
+ };
2813
+ }
2814
+
2815
+ // src/interact/notices.ts
2816
+ var NON_SOLAS_DISCLAIMER = "\u26A0\uFE0F CHRT data is derived from official electronic navigational charts but is NOT FOR PRIMARY NAVIGATION and is NOT SOLAS-compliant. Always use official, type-approved ECDIS or paper charts for navigation safety. CHRT provides situational awareness data only.";
2817
+ var NON_SOLAS_DISCLAIMER_SHORT = "Not for primary navigation";
2818
+ function baseNotice() {
2819
+ return {
2820
+ disclaimer: NON_SOLAS_DISCLAIMER,
2821
+ attribution: "Derived from official Electronic Navigational Charts issued by national hydrographic offices (e.g. NOAA).",
2822
+ dataCurrency: "Tile attributes carry no per-feature edition/update metadata; call inspect({ enrich: true }) to resolve the source cell's edition, update_number and issue_date."
2823
+ };
2824
+ }
2825
+ function enrichedNotice(cell) {
2826
+ const title = cell.title ? ` \u2014 ${cell.title}` : "";
2827
+ return {
2828
+ disclaimer: NON_SOLAS_DISCLAIMER,
2829
+ attribution: `Official ENC data \xA9 ${cell.chart_source} (cell ${cell.cell_id}${title}).`,
2830
+ dataCurrency: `Edition ${cell.edition ?? "unknown"}, update ${cell.update_number ?? "unknown"}, issued ${cell.issue_date ?? "unknown"}.`
2831
+ };
2832
+ }
2833
+
2834
+ // src/interact/errors.ts
2835
+ var InteractError = class extends Error {
2836
+ code;
2837
+ status;
2838
+ /** The team's current plan id, when the server included it (denials). */
2839
+ plan;
2840
+ constructor(code, message, opts) {
2841
+ super(message);
2842
+ this.name = "InteractError";
2843
+ this.code = code;
2844
+ this.status = opts?.status;
2845
+ this.plan = opts?.plan;
2846
+ }
2847
+ };
2848
+
2849
+ // src/interact/inspect.ts
2850
+ var NAV_AID_RAW_LAYERS = new Set(
2851
+ NAV_AID_QUERY_LAYERS.map((id) => stripLayerId(id))
2852
+ );
2853
+ var BRIDGE_RAW_LAYERS = new Set(BRIDGE_QUERY_LAYERS.map((id) => stripLayerId(id)));
2854
+ function stripLayerId(layerId) {
2855
+ return layerId.replace(/^(enc|s52)-/, "").replace(/-(point|line|polygon|midpoint|outline)$/, "").replace(/-(point|line|polygon|midpoint)$/, "");
2856
+ }
2857
+ function resolveLayerId(feature, opts) {
2858
+ if (opts?.layer) {
2859
+ return opts.layer.includes("-") || opts.layer.startsWith("enc") ? opts.layer : `enc-${opts.layer}`;
2860
+ }
2861
+ const layer = feature.layer;
2862
+ if (typeof layer === "string" && layer) {
2863
+ return layer.includes("-") ? layer : `enc-${layer}`;
2864
+ }
2865
+ if (layer && typeof layer === "object" && typeof layer.id === "string" && layer.id) {
2866
+ return layer.id;
2867
+ }
2868
+ if (feature.sourceLayer) return `enc-${feature.sourceLayer}`;
2869
+ throw new InteractError(
2870
+ "unknown_layer",
2871
+ "inspect(): cannot determine the feature's chart layer \u2014 pass { layer } or a feature from queryRenderedFeatures()"
2872
+ );
2873
+ }
2874
+ function resolveCoordinates(feature, opts) {
2875
+ if (opts?.coordinates) return opts.coordinates;
2876
+ const geom = feature.geometry;
2877
+ if (geom && geom.type === "Point" && Array.isArray(geom.coordinates)) {
2878
+ const [lng, lat] = geom.coordinates;
2879
+ if (typeof lng === "number" && typeof lat === "number") return [lng, lat];
2880
+ }
2881
+ return null;
2882
+ }
2883
+ function navAidPropertyRows(decoded) {
2884
+ const rows = [];
2885
+ const push = (label, value) => {
2886
+ if (value == null || value === "") return;
2887
+ rows.push({ label, value: String(value) });
2888
+ };
2889
+ push("Colour", decoded.colour);
2890
+ push("Shape", decoded.shape);
2891
+ push("Topmark", decoded.topmark);
2892
+ push("Topmark meaning", decoded.topmarkMeaning);
2893
+ push("Light", decoded.lightCharacterFull);
2894
+ push("Characteristic", decoded.notation || null);
2895
+ if (decoded.orientation != null) push("Orientation", `${decoded.orientation}\xB0`);
2896
+ push("Fog Signal", decoded.fogSignal);
2897
+ if (decoded.radarReflector) push("Radar", "Reflector");
2898
+ if (decoded.depth != null) push("Depth", `${decoded.depth}m`);
2899
+ push("Status", decoded.status);
2900
+ push("System", decoded.markingSystem);
2901
+ push("Category", decoded.category);
2902
+ return rows;
2903
+ }
2904
+ function bridgePropertyRows(decoded) {
2905
+ const rows = [];
2906
+ const push = (label, value) => {
2907
+ if (value == null || value === "") return;
2908
+ rows.push({ label, value });
2909
+ };
2910
+ push("Category", decoded.categoryLabel);
2911
+ if (decoded.verticalClearanceClosed != null && decoded.verticalClearanceOpen != null) {
2912
+ push(
2913
+ "Vertical clearance",
2914
+ `Closed: ${decoded.verticalClearanceClosed}m / Open: ${decoded.verticalClearanceOpen}m`
2915
+ );
2916
+ } else if (decoded.verticalClearance != null) {
2917
+ push("Vertical clearance", `${decoded.verticalClearance}m`);
2918
+ } else if (decoded.verticalClearanceClosed != null) {
2919
+ push("Vertical clearance", `${decoded.verticalClearanceClosed}m (closed)`);
2920
+ } else if (decoded.verticalClearanceOpen != null) {
2921
+ push("Vertical clearance", `${decoded.verticalClearanceOpen}m (open)`);
2922
+ }
2923
+ if (decoded.horizontalClearance != null) {
2924
+ push("Horizontal clearance", `${decoded.horizontalClearance}m`);
2925
+ }
2926
+ push("Status", decoded.status);
2927
+ push("Condition", decoded.condition);
2928
+ push("National name", decoded.nationalName);
2929
+ push("Source", decoded.sourceIndication);
2930
+ push("Source date", decoded.sourceDate);
2931
+ return rows;
2932
+ }
2933
+ function buildInspectResult(feature, opts) {
2934
+ const layerId = resolveLayerId(feature, opts);
2935
+ const rawLayer = stripLayerId(layerId);
2936
+ const props = { ...feature.properties ?? {} };
2937
+ const coords = resolveCoordinates(feature, opts);
2938
+ const notice = baseNotice();
2939
+ if (BRIDGE_RAW_LAYERS.has(rawLayer)) {
2940
+ const bridge = decodeBridgeProps(props);
2941
+ const result2 = {
2942
+ kind: "bridge",
2943
+ layer: "BRIDGE",
2944
+ layerId,
2945
+ objectClass: bridge.categoryLabel ?? "Bridge",
2946
+ title: bridge.name || bridge.categoryLabel || "Bridge",
2947
+ coordinates: coords,
2948
+ properties: bridgePropertyRows(bridge),
2949
+ raw: props,
2950
+ notice,
2951
+ enrichment: null,
2952
+ bridge
2953
+ };
2954
+ return result2;
2955
+ }
2956
+ if (NAV_AID_RAW_LAYERS.has(rawLayer)) {
2957
+ const navAid = decodeNavAidProps(layerId, props, coords ?? void 0);
2958
+ const flashPattern = buildFlashPattern(navAid);
2959
+ const result2 = {
2960
+ kind: "navaid",
2961
+ layer: navAid.layerName,
2962
+ layerId,
2963
+ objectClass: navAid.type,
2964
+ title: navAid.fullName || navAid.name || navAid.type,
2965
+ coordinates: navAid.coordinates,
2966
+ properties: navAidPropertyRows(navAid),
2967
+ raw: props,
2968
+ notice,
2969
+ enrichment: null,
2970
+ navAid,
2971
+ characteristic: navAid.notation || null,
2972
+ flashPattern
2973
+ };
2974
+ return result2;
2975
+ }
2976
+ const area = decodeAreaFeatureProps(layerId, props);
2977
+ const result = {
2978
+ kind: "area",
2979
+ layer: rawLayer,
2980
+ layerId,
2981
+ objectClass: area.type,
2982
+ title: area.title,
2983
+ coordinates: coords,
2984
+ properties: area.properties,
2985
+ raw: props,
2986
+ notice,
2987
+ enrichment: null,
2988
+ area
2989
+ };
2990
+ return result;
2991
+ }
2992
+
2993
+ // src/interact/attach.ts
2994
+ function bboxAround(point, pad) {
2995
+ return [
2996
+ [point.x - pad, point.y - pad],
2997
+ [point.x + pad, point.y + pad]
2998
+ ];
2999
+ }
3000
+ function existingLayers(map, ids) {
3001
+ return ids.filter((id) => {
3002
+ try {
3003
+ return Boolean(map.getLayer(id));
3004
+ } catch {
3005
+ return false;
3006
+ }
3007
+ });
3008
+ }
3009
+ function query(map, geometry, layers) {
3010
+ if (layers.length === 0) return [];
3011
+ try {
3012
+ return map.queryRenderedFeatures(geometry, { layers }) ?? [];
3013
+ } catch {
3014
+ return [];
3015
+ }
3016
+ }
3017
+ function featureLayerId(f) {
3018
+ const layer = f.layer;
3019
+ if (layer && typeof layer === "object" && typeof layer.id === "string") return layer.id;
3020
+ if (typeof layer === "string") return layer;
3021
+ return f.sourceLayer ? `enc-${f.sourceLayer}` : "";
3022
+ }
3023
+ function decodeClickedNavAid(map, feature, e, navAidLayers) {
3024
+ const layerId = featureLayerId(feature);
3025
+ const props = { ...feature.properties ?? {} };
3026
+ const geom = feature.geometry;
3027
+ const coords = geom?.type === "Point" && Array.isArray(geom.coordinates) ? [geom.coordinates[0], geom.coordinates[1]] : [e.lngLat.lng, e.lngLat.lat];
3028
+ const rawLayer = stripLayerId(layerId);
3029
+ if (!props.OBJNAM && CHILD_LAYERS.has(rawLayer)) {
3030
+ const parentLayers = existingLayers(map, PARENT_LAYERS);
3031
+ const parents = query(map, bboxAround(e.point, 8), parentLayers);
3032
+ for (const p of parents) {
3033
+ const pName = p.properties?.OBJNAM;
3034
+ if (pName) {
3035
+ props.OBJNAM = pName;
3036
+ if (!props.COLOUR && p.properties?.COLOUR) {
3037
+ props.COLOUR = p.properties.COLOUR;
3038
+ }
3039
+ break;
3040
+ }
3041
+ }
3042
+ }
3043
+ if (!props.CATFOG) {
3044
+ const fogFeats = query(map, bboxAround(e.point, 10), existingLayers(map, ["enc-FOGSIG"]));
3045
+ for (const f of fogFeats) {
3046
+ if (f.properties?.CATFOG) {
3047
+ props.CATFOG = f.properties.CATFOG;
3048
+ break;
3049
+ }
3050
+ }
3051
+ }
3052
+ if (!props.RETRFL) {
3053
+ const radarFeats = query(map, bboxAround(e.point, 10), existingLayers(map, ["enc-RTPBCN"]));
3054
+ if (radarFeats.length > 0) props.RETRFL = "1";
3055
+ }
3056
+ if (!props.TOPSHP) {
3057
+ const topmarFeats = query(map, bboxAround(e.point, 10), existingLayers(map, ["enc-TOPMAR"]));
3058
+ for (const f of topmarFeats) {
3059
+ if (f.properties?.TOPSHP) {
3060
+ props.TOPSHP = f.properties.TOPSHP;
3061
+ break;
3062
+ }
3063
+ }
3064
+ }
3065
+ const lightLayerIds = existingLayers(
3066
+ map,
3067
+ navAidLayers.filter((id) => id === "enc-LIGHTS" || id === "enc-LIGHTS-point")
3068
+ );
3069
+ let coLocatedLightFeats = null;
3070
+ const isLightsLayer = layerId.includes("LIGHTS");
3071
+ if (!props.LITCHR) {
3072
+ const lightFeats = query(map, bboxAround(e.point, 10), lightLayerIds);
3073
+ if (lightFeats.length > 0) {
3074
+ coLocatedLightFeats = lightFeats;
3075
+ let primaryLight = null;
3076
+ for (const lf of lightFeats) {
3077
+ if (lf.properties?.LITCHR) {
3078
+ if (!lf.properties?.SECTR1 && !lf.properties?.SECTR2) {
3079
+ primaryLight = lf;
3080
+ break;
3081
+ }
3082
+ if (!primaryLight) primaryLight = lf;
3083
+ }
3084
+ }
3085
+ if (primaryLight?.properties) {
3086
+ props.LITCHR = primaryLight.properties.LITCHR;
3087
+ if (!props.SIGPER && primaryLight.properties.SIGPER) props.SIGPER = primaryLight.properties.SIGPER;
3088
+ if (!props.SIGGRP && primaryLight.properties.SIGGRP) props.SIGGRP = primaryLight.properties.SIGGRP;
3089
+ if (primaryLight.properties.COLOUR) props.LIGHT_COLOUR = primaryLight.properties.COLOUR;
3090
+ if (primaryLight.properties.CATLIT) props.CATLIT = primaryLight.properties.CATLIT;
3091
+ }
3092
+ }
3093
+ }
3094
+ const result = buildInspectResult(
3095
+ { ...feature, properties: props },
3096
+ { layer: layerId, coordinates: coords }
3097
+ );
3098
+ if (result.kind === "navaid") {
3099
+ let sectorSources = null;
3100
+ if (isLightsLayer) {
3101
+ const nearby = query(map, bboxAround(e.point, 12), lightLayerIds);
3102
+ if (nearby.length > 1) sectorSources = nearby;
3103
+ } else if (coLocatedLightFeats && coLocatedLightFeats.length > 0) {
3104
+ sectorSources = coLocatedLightFeats;
3105
+ }
3106
+ if (sectorSources) {
3107
+ const sectors = buildSectorsFromFeatures(
3108
+ sectorSources.map((f) => ({ properties: f.properties ?? {} }))
3109
+ );
3110
+ if (sectors.length > 0) {
3111
+ result.navAid.sectors = sectors;
3112
+ result.navAid.isSectorLight = true;
3113
+ }
3114
+ }
3115
+ }
3116
+ return result;
3117
+ }
3118
+ function decodeClick(map, e, opts) {
3119
+ const navAidLayers = opts.navAidLayers ?? NAV_AID_QUERY_LAYERS;
3120
+ const bridgeLayers = opts.bridgeLayers ?? BRIDGE_QUERY_LAYERS;
3121
+ const areaLayers = opts.areaLayers ?? AREA_QUERY_LAYERS;
3122
+ const navFeatures = query(map, e.point, existingLayers(map, navAidLayers));
3123
+ if (navFeatures.length > 0) {
3124
+ const feature = navFeatures[0];
3125
+ return { result: decodeClickedNavAid(map, feature, e, navAidLayers), feature };
3126
+ }
3127
+ const bridgeFeatures = query(map, e.point, existingLayers(map, bridgeLayers));
3128
+ if (bridgeFeatures.length > 0) {
3129
+ const feature = bridgeFeatures[0];
3130
+ const result = buildInspectResult(feature, {
3131
+ layer: featureLayerId(feature) || "enc-BRIDGE",
3132
+ coordinates: [e.lngLat.lng, e.lngLat.lat]
3133
+ });
3134
+ return { result, feature };
3135
+ }
3136
+ const areaFeatures = query(map, e.point, existingLayers(map, areaLayers));
3137
+ if (areaFeatures.length > 0) {
3138
+ const feature = areaFeatures[0];
3139
+ const result = buildInspectResult(feature, {
3140
+ layer: featureLayerId(feature),
3141
+ coordinates: [e.lngLat.lng, e.lngLat.lat]
3142
+ });
3143
+ return { result, feature };
3144
+ }
3145
+ return null;
3146
+ }
3147
+
3148
+ // src/interact/entitlement.ts
3149
+ function mapDenialCode(raw, status) {
3150
+ if (raw === "interact_not_enabled") return "interact_not_enabled";
3151
+ if (status === 401) return "invalid_api_key";
3152
+ if (status === 403) return "missing_scope";
3153
+ return "unexpected_response";
3154
+ }
3155
+ var EntitlementClient = class {
3156
+ constructor(http) {
3157
+ this.http = http;
3158
+ }
3159
+ cached = null;
3160
+ pending = null;
3161
+ /**
3162
+ * Check (and cache) the interact entitlement for this client's API key.
3163
+ * Concurrent callers share one in-flight request; a denial is NOT cached so
3164
+ * a plan upgrade takes effect on the next call.
3165
+ */
3166
+ async check() {
3167
+ if (this.cached) return this.cached;
3168
+ if (this.pending) return this.pending;
3169
+ this.pending = (async () => {
3170
+ try {
3171
+ const result = await this.checkOnce();
3172
+ this.cached = result;
3173
+ return result;
3174
+ } finally {
3175
+ this.pending = null;
3176
+ }
3177
+ })();
3178
+ return this.pending;
3179
+ }
3180
+ async checkOnce() {
3181
+ let res;
3182
+ try {
3183
+ res = await this.http.postJson("/interact/entitlement", {});
3184
+ } catch (err) {
3185
+ throw new InteractError(
3186
+ "network_error",
3187
+ err instanceof Error ? err.message : "entitlement request failed"
3188
+ );
3189
+ }
3190
+ if (!res.ok) {
3191
+ const code = mapDenialCode(res.body?.error, res.status);
3192
+ const message = typeof res.body?.message === "string" ? res.body.message : code === "interact_not_enabled" ? "CHRTinteract requires the Dev plan or above. Upgrade at https://chrt.co/pricing." : `entitlement check failed (HTTP ${res.status})`;
3193
+ const plan = typeof res.body?.plan === "string" ? res.body.plan : void 0;
3194
+ throw new InteractError(code, message, { status: res.status, plan });
3195
+ }
3196
+ if (res.body?.enabled !== true) {
3197
+ throw new InteractError(
3198
+ "unexpected_response",
3199
+ "entitlement endpoint returned a malformed body",
3200
+ { status: res.status }
3201
+ );
3202
+ }
3203
+ return {
3204
+ enabled: true,
3205
+ ...typeof res.body.plan === "string" ? { plan: res.body.plan } : {}
3206
+ };
3207
+ }
3208
+ /** Drop the cached grant (e.g. after an API-key swap in tests). */
3209
+ reset() {
3210
+ this.cached = null;
3211
+ this.pending = null;
3212
+ }
3213
+ };
3214
+
3215
+ // src/interact/enrich.ts
3216
+ var ENRICH_BBOX_PAD_DEG = 1e-3;
3217
+ var ENRICH_LIMIT = 5;
3218
+ function featurePoint(f) {
3219
+ if (f.geometry?.type === "Point" && Array.isArray(f.geometry.coordinates)) {
3220
+ const [lng, lat] = f.geometry.coordinates;
3221
+ if (typeof lng === "number" && typeof lat === "number") return [lng, lat];
3222
+ }
3223
+ return null;
3224
+ }
3225
+ function distSq(a, b) {
3226
+ return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2;
3227
+ }
3228
+ function matchEnrichedFeature(candidates, raw, coords) {
3229
+ if (candidates.length === 0) return null;
3230
+ const objnam = typeof raw.OBJNAM === "string" && raw.OBJNAM ? raw.OBJNAM : null;
3231
+ if (objnam) {
3232
+ const byName = candidates.find(
3233
+ (f) => f.properties?.OBJNAM === objnam
3234
+ );
3235
+ if (byName) return byName;
3236
+ }
3237
+ if (coords) {
3238
+ let best = null;
3239
+ let bestD = Infinity;
3240
+ for (const f of candidates) {
3241
+ const pt = featurePoint(f);
3242
+ if (!pt) continue;
3243
+ const d = distSq(pt, coords);
3244
+ if (d < bestD) {
3245
+ bestD = d;
3246
+ best = f;
3247
+ }
3248
+ }
3249
+ if (best) return best;
3250
+ }
3251
+ return candidates[0];
3252
+ }
3253
+ async function enrichResult(http, result, signal) {
3254
+ const coords = result.coordinates;
3255
+ if (!coords) {
3256
+ throw new InteractError(
3257
+ "enrich_failed",
3258
+ "enrich: the feature has no coordinates to look up \u2014 pass { coordinates } to inspect()"
3259
+ );
3260
+ }
3261
+ const [lng, lat] = coords;
3262
+ const bbox = [
3263
+ lng - ENRICH_BBOX_PAD_DEG,
3264
+ lat - ENRICH_BBOX_PAD_DEG,
3265
+ lng + ENRICH_BBOX_PAD_DEG,
3266
+ lat + ENRICH_BBOX_PAD_DEG
3267
+ ].join(",");
3268
+ let fc;
3269
+ try {
3270
+ fc = await http.request(
3271
+ "/features",
3272
+ { bbox, layer: result.layer, limit: ENRICH_LIMIT },
3273
+ signal
3274
+ );
3275
+ } catch (err) {
3276
+ if (err instanceof ChrtError) {
3277
+ throw new InteractError("enrich_failed", `features lookup failed: ${err.message}`, {
3278
+ status: err.status
3279
+ });
3280
+ }
3281
+ throw new InteractError(
3282
+ "network_error",
3283
+ err instanceof Error ? err.message : "features lookup failed"
3284
+ );
3285
+ }
3286
+ const matched = matchEnrichedFeature(fc.features ?? [], result.raw, coords);
3287
+ const enrichment = {
3288
+ matched: matched != null,
3289
+ properties: matched ? matched.properties : null,
3290
+ cellId: null,
3291
+ cell: null
3292
+ };
3293
+ if (matched) {
3294
+ const cellId = matched.properties?.cell_id;
3295
+ if (typeof cellId === "string" && cellId) {
3296
+ enrichment.cellId = cellId;
3297
+ try {
3298
+ const cell = await http.request(
3299
+ `/cells/${encodeURIComponent(cellId)}`,
3300
+ void 0,
3301
+ signal
3302
+ );
3303
+ enrichment.cell = cell;
3304
+ result.notice = enrichedNotice(cell);
3305
+ } catch {
3306
+ }
3307
+ }
3308
+ }
3309
+ result.enrichment = enrichment;
3310
+ return result;
3311
+ }
3312
+
3313
+ // src/interact/popup.ts
3314
+ var POPUP_CLASS = "chrt-interact-popup";
3315
+ var POPUP_CSS = `
3316
+ .${POPUP_CLASS} {
3317
+ position: absolute;
3318
+ z-index: 40;
3319
+ min-width: 220px;
3320
+ max-width: 320px;
3321
+ transform: translate(-50%, calc(-100% - 12px));
3322
+ background: var(--chrt-interact-bg, #0b1524);
3323
+ color: var(--chrt-interact-fg, #e2e8f0);
3324
+ border: 1px solid var(--chrt-interact-border, rgba(148, 163, 184, 0.25));
3325
+ border-radius: 8px;
3326
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
3327
+ font: 12px/1.45 var(--chrt-interact-font, ui-sans-serif, system-ui, sans-serif);
3328
+ pointer-events: auto;
3329
+ }
3330
+ .${POPUP_CLASS} * { box-sizing: border-box; }
3331
+ .${POPUP_CLASS}__inner { padding: 10px 12px 8px; }
3332
+ .${POPUP_CLASS}__close {
3333
+ position: absolute; top: 4px; right: 6px;
3334
+ border: 0; background: none; color: inherit; opacity: 0.55;
3335
+ font-size: 14px; line-height: 1; cursor: pointer; padding: 4px;
3336
+ }
3337
+ .${POPUP_CLASS}__close:hover { opacity: 1; }
3338
+ .${POPUP_CLASS}__title { font-weight: 600; font-size: 13px; padding-right: 16px; }
3339
+ .${POPUP_CLASS}__subtitle { color: var(--chrt-interact-muted, #94a3b8); margin-top: 1px; }
3340
+ .${POPUP_CLASS}__badge {
3341
+ display: inline-flex; align-items: center; gap: 6px;
3342
+ margin-top: 6px; padding: 2px 8px;
3343
+ border: 1px solid var(--chrt-interact-border, rgba(148, 163, 184, 0.25));
3344
+ border-radius: 4px;
3345
+ font-family: var(--chrt-interact-mono, ui-monospace, monospace);
3346
+ font-weight: 600; font-size: 11px;
3347
+ }
3348
+ .${POPUP_CLASS}__dot {
3349
+ width: 10px; height: 10px; border-radius: 50%;
3350
+ background: #555; opacity: 0.15;
3351
+ }
3352
+ .${POPUP_CLASS}__rows { margin-top: 8px; border-top: 1px solid var(--chrt-interact-border, rgba(148,163,184,0.18)); padding-top: 6px; }
3353
+ .${POPUP_CLASS}__row { display: flex; justify-content: space-between; gap: 12px; padding: 1px 0; }
3354
+ .${POPUP_CLASS}__label { color: var(--chrt-interact-muted, #94a3b8); flex-shrink: 0; }
3355
+ .${POPUP_CLASS}__value { text-align: right; font-weight: 500; }
3356
+ .${POPUP_CLASS}__info { margin-top: 4px; font-style: italic; color: var(--chrt-interact-muted, #94a3b8); font-size: 11px; }
3357
+ .${POPUP_CLASS}__footer {
3358
+ margin-top: 8px; padding: 6px 12px 8px;
3359
+ border-top: 1px solid var(--chrt-interact-border, rgba(148,163,184,0.18));
3360
+ color: var(--chrt-interact-muted, #94a3b8);
3361
+ font-size: 10px;
3362
+ }
3363
+ .${POPUP_CLASS}__footer strong { color: var(--chrt-interact-warn, #fbbf24); font-weight: 600; }
3364
+ `;
3365
+ var styleInjected = false;
3366
+ function ensureStyles(doc) {
3367
+ if (styleInjected && doc.getElementById(`${POPUP_CLASS}-style`)) return;
3368
+ const style = doc.createElement("style");
3369
+ style.id = `${POPUP_CLASS}-style`;
3370
+ style.textContent = POPUP_CSS;
3371
+ doc.head.appendChild(style);
3372
+ styleInjected = true;
3373
+ }
3374
+ function el(doc, tag, className, text) {
3375
+ const node = doc.createElement(tag);
3376
+ node.className = className;
3377
+ if (text != null) node.textContent = text;
3378
+ return node;
3379
+ }
3380
+ function animateDot(dot, pattern) {
3381
+ if (pattern.totalCycle <= 0 || typeof requestAnimationFrame === "undefined") {
3382
+ return () => void 0;
3383
+ }
3384
+ let raf = 0;
3385
+ const start = performance.now();
3386
+ const tick = (now2) => {
3387
+ const elapsed = (now2 - start) % pattern.totalCycle;
3388
+ let acc = 0;
3389
+ let phase = pattern.phases[0];
3390
+ for (const p of pattern.phases) {
3391
+ acc += p.duration;
3392
+ if (elapsed < acc) {
3393
+ phase = p;
3394
+ break;
3395
+ }
3396
+ }
3397
+ if (phase.on) {
3398
+ const color = phase.color || "#ffffff";
3399
+ dot.style.opacity = "1";
3400
+ dot.style.backgroundColor = color;
3401
+ dot.style.boxShadow = `0 0 8px 2px ${color}88`;
3402
+ } else {
3403
+ dot.style.opacity = "0.15";
3404
+ dot.style.backgroundColor = "#555";
3405
+ dot.style.boxShadow = "none";
3406
+ }
3407
+ raf = requestAnimationFrame(tick);
3408
+ };
3409
+ raf = requestAnimationFrame(tick);
3410
+ return () => cancelAnimationFrame(raf);
3411
+ }
3412
+ var InteractPopup = class {
3413
+ constructor(map) {
3414
+ this.map = map;
3415
+ }
3416
+ root = null;
3417
+ stopAnimation = null;
3418
+ lngLat = null;
3419
+ reposition = () => this.place();
3420
+ show(result, lngLat) {
3421
+ const container = this.map.getContainer();
3422
+ const doc = container.ownerDocument;
3423
+ ensureStyles(doc);
3424
+ this.close();
3425
+ const root = el(doc, "div", POPUP_CLASS);
3426
+ const inner = el(doc, "div", `${POPUP_CLASS}__inner`);
3427
+ const close = el(doc, "button", `${POPUP_CLASS}__close`, "\u2715");
3428
+ close.setAttribute("aria-label", "Close");
3429
+ close.addEventListener("click", () => this.close());
3430
+ root.appendChild(close);
3431
+ inner.appendChild(el(doc, "div", `${POPUP_CLASS}__title`, result.title));
3432
+ const subtitleParts = [result.objectClass];
3433
+ if (result.kind === "navaid") {
3434
+ if (result.navAid.shape) subtitleParts.push(result.navAid.shape);
3435
+ if (result.navAid.isSectorLight) subtitleParts.push("Sector");
3436
+ if (result.navAid.isDirectional) subtitleParts.push("Directional");
3437
+ }
3438
+ inner.appendChild(
3439
+ el(doc, "div", `${POPUP_CLASS}__subtitle`, subtitleParts.filter(Boolean).join(" \xB7 "))
3440
+ );
3441
+ if (result.kind === "navaid" && result.characteristic) {
3442
+ const badge = el(doc, "div", `${POPUP_CLASS}__badge`);
3443
+ const dot = el(doc, "span", `${POPUP_CLASS}__dot`);
3444
+ badge.appendChild(dot);
3445
+ badge.appendChild(doc.createTextNode(result.characteristic));
3446
+ if (result.navAid.lightColourHex && result.navAid.lightColourHex !== "#ffffff") {
3447
+ badge.style.color = result.navAid.lightColourHex;
3448
+ }
3449
+ inner.appendChild(badge);
3450
+ if (result.flashPattern) {
3451
+ this.stopAnimation = animateDot(dot, result.flashPattern);
3452
+ }
3453
+ }
3454
+ if (result.properties.length > 0) {
3455
+ const rows = el(doc, "div", `${POPUP_CLASS}__rows`);
3456
+ for (const p of result.properties) {
3457
+ const row = el(doc, "div", `${POPUP_CLASS}__row`);
3458
+ row.appendChild(el(doc, "span", `${POPUP_CLASS}__label`, p.label));
3459
+ row.appendChild(el(doc, "span", `${POPUP_CLASS}__value`, p.value));
3460
+ rows.appendChild(row);
3461
+ }
3462
+ inner.appendChild(rows);
3463
+ }
3464
+ if (result.kind === "navaid" && result.navAid.information) {
3465
+ inner.appendChild(el(doc, "div", `${POPUP_CLASS}__info`, result.navAid.information));
3466
+ }
3467
+ root.appendChild(inner);
3468
+ const footer = el(doc, "div", `${POPUP_CLASS}__footer`);
3469
+ footer.title = `${NON_SOLAS_DISCLAIMER}
3470
+
3471
+ ${result.notice.attribution}
3472
+ ${result.notice.dataCurrency}`;
3473
+ const warn = el(doc, "strong", "", `\u26A0 ${NON_SOLAS_DISCLAIMER_SHORT}`);
3474
+ footer.appendChild(warn);
3475
+ footer.appendChild(doc.createElement("br"));
3476
+ footer.appendChild(doc.createTextNode(result.notice.attribution));
3477
+ const currency = result.notice.dataCurrency;
3478
+ if (currency.startsWith("Edition")) {
3479
+ footer.appendChild(doc.createElement("br"));
3480
+ footer.appendChild(doc.createTextNode(currency));
3481
+ }
3482
+ root.appendChild(footer);
3483
+ const position = typeof getComputedStyle !== "undefined" ? getComputedStyle(container).position : container.style.position;
3484
+ if (position === "static" || position === "") {
3485
+ container.style.position = "relative";
3486
+ }
3487
+ container.appendChild(root);
3488
+ this.root = root;
3489
+ this.lngLat = lngLat;
3490
+ this.place();
3491
+ this.map.on("move", this.reposition);
3492
+ }
3493
+ place() {
3494
+ if (!this.root || !this.lngLat) return;
3495
+ const { x, y } = this.map.project(this.lngLat);
3496
+ this.root.style.left = `${x}px`;
3497
+ this.root.style.top = `${y}px`;
3498
+ }
3499
+ close() {
3500
+ if (this.stopAnimation) {
3501
+ this.stopAnimation();
3502
+ this.stopAnimation = null;
3503
+ }
3504
+ if (this.root) {
3505
+ this.map.off("move", this.reposition);
3506
+ this.root.remove();
3507
+ this.root = null;
3508
+ }
3509
+ this.lngLat = null;
3510
+ }
3511
+ };
3512
+
3513
+ // src/interact/index.ts
3514
+ var InteractClient = class {
3515
+ constructor(http) {
3516
+ this.http = http;
3517
+ this.entitlementClient = new EntitlementClient(http);
3518
+ }
3519
+ entitlementClient;
3520
+ /**
3521
+ * Check the CHRTinteract entitlement for this API key (server-side, plan
3522
+ * derived, cached per client). Throws `InteractError` with code
3523
+ * `interact_not_enabled` for Free-plan teams.
3524
+ */
3525
+ async entitlement() {
3526
+ return this.entitlementClient.check();
3527
+ }
3528
+ /**
3529
+ * Decode a chart feature (from `queryRenderedFeatures`, the features API,
3530
+ * or plain `{ layer, properties }`) into a typed {@link InspectResult}.
3531
+ *
3532
+ * The first call performs the server-side entitlement check. With
3533
+ * `{ enrich: true }` the result is upgraded with full S-57 properties and
3534
+ * source-cell provenance (edition / update / issue date) via the features
3535
+ * API — metered against the key's `featureCalls` quota.
3536
+ *
3537
+ * @example
3538
+ * const result = await chrt.interact.inspect(feature);
3539
+ * if (result.kind === "navaid") console.log(result.characteristic); // "Fl R 4s"
3540
+ */
3541
+ async inspect(feature, opts) {
3542
+ await this.entitlementClient.check();
3543
+ const result = buildInspectResult(feature, opts);
3544
+ if (opts?.enrich) {
3545
+ return enrichResult(this.http, result, opts.signal);
3546
+ }
3547
+ return result;
3548
+ }
3549
+ /**
3550
+ * Attach click-to-inspect to a MapLibre / Mapbox GL map rendering CHRT
3551
+ * tiles. Resolves once the server-side entitlement check passes; rejects
3552
+ * with `InteractError` (`interact_not_enabled` on Free plans) otherwise —
3553
+ * no listeners are wired on denial.
3554
+ *
3555
+ * Headless-first: pass `onInspect` to receive every decoded
3556
+ * {@link InspectResult} and render your own UI. Without `onInspect`, the
3557
+ * built-in minimal popup is shown (opt back in explicitly with
3558
+ * `popup: true` to have both).
3559
+ *
3560
+ * @example
3561
+ * const handle = await chrt.interact.attach(map, {
3562
+ * onInspect: (result) => console.log(result.title, result.characteristic),
3563
+ * });
3564
+ * // later: handle.detach();
3565
+ */
3566
+ async attach(map, opts = {}) {
3567
+ if (!map || typeof map.queryRenderedFeatures !== "function" || typeof map.on !== "function") {
3568
+ throw new InteractError(
3569
+ "unexpected_response",
3570
+ "attach(): expected a MapLibre / Mapbox GL map instance"
1158
3571
  );
1159
3572
  }
1160
- await fs.rename(partialPath, path);
1161
- await this.persistManifestEntry(region, bundle, path, fs);
1162
- return {
1163
- path,
1164
- bytes: bundle.bytes,
1165
- sha256: bundle.sha256,
1166
- build_id: bundle.build_id,
1167
- build_tag: bundle.build_tag,
1168
- resumed
3573
+ await this.entitlementClient.check();
3574
+ const showPopup = opts.popup ?? !opts.onInspect;
3575
+ const popup = showPopup ? new InteractPopup(map) : null;
3576
+ const clickHandler = (e) => {
3577
+ const hit = decodeClick(map, e, opts);
3578
+ if (!hit) {
3579
+ popup?.close();
3580
+ opts.onMiss?.({ lngLat: e.lngLat });
3581
+ return;
3582
+ }
3583
+ const finish = (result) => {
3584
+ popup?.show(result, result.coordinates ?? [e.lngLat.lng, e.lngLat.lat]);
3585
+ opts.onInspect?.(result, { lngLat: e.lngLat, point: e.point, feature: hit.feature });
3586
+ };
3587
+ if (opts.enrich) {
3588
+ enrichResult(this.http, hit.result).then(finish).catch(() => finish(hit.result));
3589
+ } else {
3590
+ finish(hit.result);
3591
+ }
1169
3592
  };
1170
- }
1171
- async persistManifestEntry(region, bundle, path, fs) {
1172
- if (!this.manifestPath) return;
1173
- const manifest = await readManifest(this.manifestPath, fs);
1174
- const nowSec = Math.floor(Date.now() / 1e3);
1175
- manifest.regions[region] = {
1176
- path,
1177
- bytes: bundle.bytes,
1178
- sha256: bundle.sha256,
1179
- build_id: bundle.build_id,
1180
- build_tag: bundle.build_tag,
1181
- downloaded_at: nowSec,
1182
- last_accessed_at: nowSec
3593
+ map.on("click", clickHandler);
3594
+ return {
3595
+ detach: () => {
3596
+ map.off("click", clickHandler);
3597
+ popup?.close();
3598
+ }
1183
3599
  };
1184
- await writeManifest(this.manifestPath, fs, manifest);
1185
3600
  }
1186
3601
  };
1187
- function dirname(p) {
1188
- const i = p.lastIndexOf("/");
1189
- const j = p.lastIndexOf("\\");
1190
- const k = Math.max(i, j);
1191
- if (k <= 0) return ".";
1192
- return p.slice(0, k);
1193
- }
1194
3602
 
1195
3603
  // src/atlas/s52-colors.ts
1196
3604
  var S52_DAY = {
@@ -6914,6 +9322,118 @@ var LABEL_ANCHOR_FILTER = [
6914
9322
  ["get", "_label_anchor"],
6915
9323
  false
6916
9324
  ];
9325
+ var LABEL_PRIORITY = {
9326
+ LIGHTS: 100,
9327
+ UWTROC: 100,
9328
+ OBSTRN: 100,
9329
+ WRECKS: 100,
9330
+ CBLOHD: 100,
9331
+ PIPOHD: 100,
9332
+ BOYLAT: 200,
9333
+ BOYCAR: 200,
9334
+ BOYSPP: 200,
9335
+ BOYISD: 200,
9336
+ BOYSAW: 200,
9337
+ BCNLAT: 200,
9338
+ BCNCAR: 200,
9339
+ BCNSPP: 200,
9340
+ FOGSIG: 250,
9341
+ SOUNDG: 300,
9342
+ DEPCNT: 300,
9343
+ BRIDGE: 400,
9344
+ LNDMRK: 400,
9345
+ PILPNT: 400,
9346
+ MORFAC: 400,
9347
+ SLCONS: 400,
9348
+ BUISGL: 400,
9349
+ SBDARE: 500,
9350
+ MAGVAR: 500,
9351
+ WEDKLP: 500
9352
+ };
9353
+ var DEFAULT_LABEL_PRIORITY = 450;
9354
+ var ORIENTATION_LABEL_PRIORITY = 900;
9355
+ function getLabelPriority(layerName) {
9356
+ if (getLabelBehavior(layerName) === "orientation-cross-band") {
9357
+ return ORIENTATION_LABEL_PRIORITY;
9358
+ }
9359
+ return LABEL_PRIORITY[layerName] ?? DEFAULT_LABEL_PRIORITY;
9360
+ }
9361
+ function labelSortKey(rank, within) {
9362
+ if (within === void 0) return rank * 1e3;
9363
+ return [
9364
+ "+",
9365
+ rank * 1e3,
9366
+ ["min", 999, ["max", 0, within]]
9367
+ ];
9368
+ }
9369
+ var NAV_DESIGNATION_STOPWORDS = [
9370
+ "Buoy",
9371
+ "Can",
9372
+ "Nun",
9373
+ "Bell",
9374
+ "Gong",
9375
+ "Mark"
9376
+ ];
9377
+ function extractNavAidDesignation(objnam) {
9378
+ const trimmed = objnam.trim();
9379
+ if (/^[A-Za-z0-9]{1,4}$/.test(trimmed)) {
9380
+ return NAV_DESIGNATION_STOPWORDS.some((w) => w === trimmed) ? null : trimmed;
9381
+ }
9382
+ const m = trimmed.match(/ ([A-Za-z0-9]{1,4})$/);
9383
+ if (!m) return null;
9384
+ const token = m[1];
9385
+ if (NAV_DESIGNATION_STOPWORDS.some((w) => w === token)) return null;
9386
+ return token;
9387
+ }
9388
+ function navAidDesignationTextExpression(objnam, colourPrefix) {
9389
+ const len = ["length", objnam];
9390
+ const tokenBranches = [];
9391
+ for (let k = 1; k <= 4; k++) {
9392
+ tokenBranches.push(
9393
+ [
9394
+ "all",
9395
+ [">", len, k],
9396
+ ["==", ["slice", objnam, ["-", len, k + 1], ["-", len, k]], " "]
9397
+ ],
9398
+ ["slice", objnam, ["-", len, k]]
9399
+ );
9400
+ }
9401
+ return [
9402
+ "let",
9403
+ "desig",
9404
+ ["case", ...tokenBranches, ""],
9405
+ [
9406
+ "case",
9407
+ [
9408
+ "any",
9409
+ ["==", ["var", "desig"], ""],
9410
+ ["match", ["var", "desig"], [...NAV_DESIGNATION_STOPWORDS], true, false]
9411
+ ],
9412
+ ["concat", colourPrefix, objnam],
9413
+ ["concat", colourPrefix, '"', ["var", "desig"], '"']
9414
+ ]
9415
+ ];
9416
+ }
9417
+ function navAidLabelLayout(sortKey) {
9418
+ return {
9419
+ "text-allow-overlap": false,
9420
+ "text-ignore-placement": false,
9421
+ "text-optional": true,
9422
+ "text-padding": 4,
9423
+ "symbol-sort-key": sortKey
9424
+ };
9425
+ }
9426
+ var NAV_AID_SORT_KEY = labelSortKey(200, [
9427
+ "case",
9428
+ ["has", "VALNMR"],
9429
+ ["-", 300, ["min", 299, ["to-number", ["get", "VALNMR"], 0]]],
9430
+ ["+", 400, ["min", 500, ["length", ["coalesce", ["get", "OBJNAM"], ""]]]]
9431
+ ]);
9432
+ var LIGHT_SORT_KEY = labelSortKey(100, [
9433
+ "-",
9434
+ 300,
9435
+ ["min", 299, ["to-number", ["coalesce", ["get", "VALNMR"], 0], 0]]
9436
+ ]);
6917
9437
  function getLabelConfig(layerName, opts = {}) {
6918
9438
  const ct = opts.colorTable ?? "DAY";
6919
9439
  switch (layerName) {
@@ -6981,14 +9501,15 @@ function getLabelConfig(layerName, opts = {}) {
6981
9501
  // sizes — established cartographic typography practice.
6982
9502
  "text-letter-spacing": 0.02,
6983
9503
  // Shallowest-wins: Mapbox GL renders lower sort keys first and
6984
- // gives them collision priority. Using depth directly ensures
6985
- // the shallowest sounding in a cluster is always the one shown,
6986
- // even when labels are hidden due to overlap.
6987
- "symbol-sort-key": [
9504
+ // gives them collision priority. Depth as the within-class key
9505
+ // ensures the shallowest sounding in a cluster is always the
9506
+ // one shown, even when labels are hidden due to overlap. Folded
9507
+ // into the rank-300 band of the LABEL_PRIORITY ladder.
9508
+ "symbol-sort-key": labelSortKey(300, [
6988
9509
  "to-number",
6989
9510
  ["coalesce", ["get", "depth"], ["get", "DEPTH"], ["get", "VALSOU"]],
6990
9511
  999
6991
- ]
9512
+ ])
6992
9513
  }
6993
9514
  };
6994
9515
  }
@@ -7028,8 +9549,14 @@ function getLabelConfig(layerName, opts = {}) {
7028
9549
  textColor: DARK_BG,
7029
9550
  textHaloColor: "#d4c9b0",
7030
9551
  textHaloWidth: 1.5,
7031
- textOffset: [0, 1.2],
7032
- minzoom: 9
9552
+ textOffset: [0, 0],
9553
+ minzoom: 9,
9554
+ layout: {
9555
+ "text-variable-anchor": ["top", "bottom", "right", "left"],
9556
+ "text-radial-offset": 1.2,
9557
+ "text-justify": "auto",
9558
+ "symbol-sort-key": labelSortKey(400, ["length", ["coalesce", ["get", "OBJNAM"], ""]])
9559
+ }
7033
9560
  };
7034
9561
  case "BOYLAT":
7035
9562
  return {
@@ -7041,7 +9568,8 @@ function getLabelConfig(layerName, opts = {}) {
7041
9568
  textHaloColor: navHalo("WHITE", ct),
7042
9569
  textHaloWidth: 1.5,
7043
9570
  textOffset: [0, 1.5],
7044
- minzoom: 10
9571
+ minzoom: 10,
9572
+ layout: navAidLabelLayout(NAV_AID_SORT_KEY)
7045
9573
  };
7046
9574
  case "BCNLAT":
7047
9575
  return {
@@ -7053,7 +9581,8 @@ function getLabelConfig(layerName, opts = {}) {
7053
9581
  textHaloColor: navHalo("WHITE", ct),
7054
9582
  textHaloWidth: 1.5,
7055
9583
  textOffset: [0, 1.5],
7056
- minzoom: 10
9584
+ minzoom: 10,
9585
+ layout: navAidLabelLayout(NAV_AID_SORT_KEY)
7057
9586
  };
7058
9587
  case "BOYCAR":
7059
9588
  case "BCNCAR":
@@ -7066,7 +9595,8 @@ function getLabelConfig(layerName, opts = {}) {
7066
9595
  textHaloColor: navHalo("CREAM", ct),
7067
9596
  textHaloWidth: 1.5,
7068
9597
  textOffset: [0, 1.5],
7069
- minzoom: 10
9598
+ minzoom: 10,
9599
+ layout: navAidLabelLayout(NAV_AID_SORT_KEY)
7070
9600
  };
7071
9601
  case "BOYSPP":
7072
9602
  case "BCNSPP":
@@ -7079,7 +9609,8 @@ function getLabelConfig(layerName, opts = {}) {
7079
9609
  textHaloColor: navHalo("YELLOW", ct),
7080
9610
  textHaloWidth: 1.5,
7081
9611
  textOffset: [0, 1.5],
7082
- minzoom: 10
9612
+ minzoom: 10,
9613
+ layout: navAidLabelLayout(NAV_AID_SORT_KEY)
7083
9614
  };
7084
9615
  case "BOYISD":
7085
9616
  case "BOYSAW":
@@ -7092,7 +9623,8 @@ function getLabelConfig(layerName, opts = {}) {
7092
9623
  textHaloColor: navHalo("WHITE", ct),
7093
9624
  textHaloWidth: 1.5,
7094
9625
  textOffset: [0, 1.5],
7095
- minzoom: 10
9626
+ minzoom: 10,
9627
+ layout: navAidLabelLayout(NAV_AID_SORT_KEY)
7096
9628
  };
7097
9629
  case "LIGHTS":
7098
9630
  return {
@@ -7106,7 +9638,12 @@ function getLabelConfig(layerName, opts = {}) {
7106
9638
  textOffset: [2, 0],
7107
9639
  minzoom: 9,
7108
9640
  layout: {
7109
- "text-anchor": "left"
9641
+ "text-anchor": "left",
9642
+ "text-allow-overlap": false,
9643
+ "text-ignore-placement": false,
9644
+ "text-optional": true,
9645
+ "text-padding": 2,
9646
+ "symbol-sort-key": LIGHT_SORT_KEY
7110
9647
  }
7111
9648
  };
7112
9649
  case "FOGSIG": {
@@ -7144,7 +9681,8 @@ function getLabelConfig(layerName, opts = {}) {
7144
9681
  layout: {
7145
9682
  "text-anchor": "left",
7146
9683
  "text-allow-overlap": false,
7147
- "text-optional": true
9684
+ "text-optional": true,
9685
+ "symbol-sort-key": labelSortKey(250)
7148
9686
  }
7149
9687
  };
7150
9688
  }
@@ -7317,11 +9855,15 @@ function getLabelConfig(layerName, opts = {}) {
7317
9855
  textColor: "#bfa88a",
7318
9856
  textHaloColor: DARK_BG,
7319
9857
  textHaloWidth: 1.5,
7320
- textOffset: [0, 1],
9858
+ textOffset: [0, 0],
7321
9859
  minzoom: 12,
7322
9860
  layout: {
9861
+ "text-variable-anchor": ["top", "bottom", "right", "left"],
9862
+ "text-radial-offset": 1,
9863
+ "text-justify": "auto",
7323
9864
  "text-allow-overlap": false,
7324
- "text-optional": true
9865
+ "text-optional": true,
9866
+ "symbol-sort-key": labelSortKey(400, ["length", ["coalesce", ["get", "OBJNAM"], ""]])
7325
9867
  }
7326
9868
  };
7327
9869
  case "PILPNT":
@@ -7545,17 +10087,26 @@ function getLabelConfig(layerName, opts = {}) {
7545
10087
  textColor: resolveColor("CHBLK", ct2),
7546
10088
  textHaloColor: resolveColor("CHWHT", ct2),
7547
10089
  textHaloWidth: 1.2,
7548
- // Place to the upper-right of the symbol so it doesn't sit on top
7549
- // of the asterisk / cross. Anchor top-left so the offset grows
7550
- // away from the symbol as text-size grows.
7551
- textOffset: [0.8, -0.4],
10090
+ textOffset: [0, 0],
7552
10091
  textFont: ["Open Sans Regular"],
7553
10092
  minzoom: 12,
10093
+ // Variable anchors: the depth callout tries each corner of the
10094
+ // hazard symbol and takes the first free spot, so in a rocky
10095
+ // field the callouts fan out around their symbols instead of
10096
+ // overprinting or vanishing. Safe here (unlike nav-aids) because
10097
+ // UWTROC/OBSTRN/WRECKS are dominance-PARTICIPATING classes —
10098
+ // exactly one band's copy renders at any zoom, so anchor-shifting
10099
+ // can't split a cross-band duplicate pair into two visible labels.
10100
+ // Shallowest (most dangerous) hazard wins within-class contests;
10101
+ // VALSOU-less hazards (awash / unsounded) rank first of all.
7554
10102
  layout: {
7555
- "text-anchor": "top-left",
10103
+ "text-variable-anchor": ["top-left", "bottom-left", "top-right", "bottom-right"],
10104
+ "text-radial-offset": 0.9,
10105
+ "text-justify": "auto",
7556
10106
  "text-allow-overlap": false,
7557
10107
  "text-optional": true,
7558
- "text-padding": 2
10108
+ "text-padding": 2,
10109
+ "symbol-sort-key": labelSortKey(100, ["*", 10, valsou])
7559
10110
  }
7560
10111
  };
7561
10112
  }
@@ -7594,7 +10145,13 @@ function getLabelConfig(layerName, opts = {}) {
7594
10145
  "text-rotation-alignment": "map",
7595
10146
  "text-allow-overlap": false,
7596
10147
  "text-optional": true,
7597
- "text-letter-spacing": 0.02
10148
+ "text-letter-spacing": 0.02,
10149
+ // Lower clearance = more dangerous = places first.
10150
+ "symbol-sort-key": labelSortKey(100, [
10151
+ "*",
10152
+ 5,
10153
+ ["to-number", ["coalesce", ["get", "VERCLR"], 199], 199]
10154
+ ])
7598
10155
  }
7599
10156
  };
7600
10157
  }
@@ -7623,7 +10180,10 @@ function getLabelConfig(layerName, opts = {}) {
7623
10180
  "symbol-spacing": 250,
7624
10181
  "text-rotation-alignment": "map",
7625
10182
  "text-allow-overlap": false,
7626
- "text-optional": true
10183
+ "text-optional": true,
10184
+ // Shallower contours win within-class contests, mirroring the
10185
+ // SOUNDG shallowest-wins rule.
10186
+ "symbol-sort-key": labelSortKey(300, ["to-number", ["get", "VALDCO"], 999])
7627
10187
  }
7628
10188
  };
7629
10189
  default:
@@ -7796,9 +10356,8 @@ function preprocessNavAids(fc) {
7796
10356
  const props = f.properties ?? {};
7797
10357
  const objnam = props.OBJNAM;
7798
10358
  if (!objnam) return f;
7799
- const match = objnam.match(/(\d+[A-Z]?)\s*$/);
7800
- if (!match) return f;
7801
- const designation = match[1];
10359
+ const designation = extractNavAidDesignation(objnam);
10360
+ if (!designation) return f;
7802
10361
  const colourCode = firstListCode(props.COLOUR);
7803
10362
  const prefix = NAV_COLOUR_PREFIX[colourCode];
7804
10363
  const label = prefix ? `${prefix} "${designation}"` : `"${designation}"`;
@@ -9552,7 +12111,7 @@ function getS52SymbolStats() {
9552
12111
  }
9553
12112
 
9554
12113
  // src/atlas/tile-zoom.ts
9555
- var BUILD_MINZOOM = 3;
12114
+ var BUILD_MINZOOM = 0;
9556
12115
  var BUILD_MAXZOOM = 14;
9557
12116
 
9558
12117
  // src/atlas/source-registry.ts
@@ -9652,33 +12211,6 @@ var CHILD_ICON_OFFSETS = {
9652
12211
  LIGHTS: [14, 14]
9653
12212
  // lower-right, off the buoy hull so the flare is visible
9654
12213
  };
9655
- var SAFETY_CRITICAL_LABELS = /* @__PURE__ */ new Set([
9656
- "LIGHTS",
9657
- "SOUNDG",
9658
- "BOYLAT",
9659
- "BCNLAT",
9660
- "BOYCAR",
9661
- "BCNCAR",
9662
- "BOYSPP",
9663
- "BCNSPP",
9664
- "BOYISD",
9665
- "BOYSAW",
9666
- "FOGSIG",
9667
- "RTPBCN"
9668
- ]);
9669
- var HIGH_DENSITY_NAV_AIDS = /* @__PURE__ */ new Set([
9670
- "BOYLAT",
9671
- "BOYCAR",
9672
- "BOYSPP",
9673
- "BOYISD",
9674
- "BOYSAW",
9675
- "BOYINB",
9676
- "BCNLAT",
9677
- "BCNCAR",
9678
- "BCNSPP",
9679
- "BCNISD",
9680
- "BCNSAW"
9681
- ]);
9682
12214
  function buildNavAidLabelExpression() {
9683
12215
  const colourPrefix = colourCodeCase(
9684
12216
  [
@@ -9693,7 +12225,12 @@ function buildNavAidLabelExpression() {
9693
12225
  ["has", "_navAidLabel"],
9694
12226
  ["get", "_navAidLabel"],
9695
12227
  ["has", "OBJNAM"],
9696
- ["concat", colourPrefix, ["get", "OBJNAM"]],
12228
+ // Chart-convention abbreviation: `R "4"` instead of the full NOAA
12229
+ // name ("R Boston North Channel Lighted Buoy 4") — the tile pipeline
12230
+ // doesn't precompute `_navAidLabel`, so before this expression every
12231
+ // production buoy label carried its full 30-40 glyph OBJNAM, the
12232
+ // biggest single driver of Harbor/Approach label overprint (E3).
12233
+ navAidDesignationTextExpression(["get", "OBJNAM"], colourPrefix),
9697
12234
  ""
9698
12235
  ];
9699
12236
  }
@@ -9884,6 +12421,11 @@ function buildLayersForClass(objectClass, ct, sourceId = DEFAULT_REGION) {
9884
12421
  layer.paint["icon-color"] = lightFillExpression(ct);
9885
12422
  layer.paint["icon-halo-color"] = resolveColor("CHBLK", ct);
9886
12423
  layer.paint["icon-halo-width"] = 1;
12424
+ layer.layout = {
12425
+ ...layer.layout,
12426
+ "icon-allow-overlap": true,
12427
+ "icon-ignore-placement": true
12428
+ };
9887
12429
  }
9888
12430
  } else if (resolved.layout) {
9889
12431
  layer.layout = resolved.layout;
@@ -10754,7 +13296,7 @@ function buildLabelLayer2(objectClass, ct, sourceId = DEFAULT_REGION, depthUnits
10754
13296
  ];
10755
13297
  }
10756
13298
  }
10757
- const isSafetyCritical = SAFETY_CRITICAL_LABELS.has(objectClass);
13299
+ const labelPriority = getLabelPriority(objectClass);
10758
13300
  const layer = {
10759
13301
  id: `s52-${objectClass}-label`,
10760
13302
  type: "symbol",
@@ -10767,15 +13309,20 @@ function buildLabelLayer2(objectClass, ct, sourceId = DEFAULT_REGION, depthUnits
10767
13309
  "text-font": config.textFont ?? ["Open Sans Regular"],
10768
13310
  "text-offset": config.textOffset,
10769
13311
  "text-anchor": "top",
10770
- "text-allow-overlap": isSafetyCritical,
10771
- "text-optional": !isSafetyCritical,
10772
- "text-padding": isSafetyCritical ? 0 : 4,
13312
+ "text-allow-overlap": false,
13313
+ "text-optional": true,
13314
+ "text-padding": 4,
13315
+ "symbol-sort-key": labelPriority * 1e3,
10773
13316
  ...config.layout ?? {}
10774
13317
  },
10775
13318
  paint: {
10776
13319
  "text-color": config.textColor,
10777
13320
  "text-halo-color": config.textHaloColor,
10778
13321
  "text-halo-width": config.textHaloWidth
13322
+ },
13323
+ metadata: {
13324
+ "s52:objectClass": objectClass,
13325
+ "chrt:labelPriority": labelPriority
10779
13326
  }
10780
13327
  };
10781
13328
  if (config.minzoom) {
@@ -10827,13 +13374,24 @@ function getOrderedClasses() {
10827
13374
 
10828
13375
  // src/atlas/band-priority.ts
10829
13376
  var BAND_PRIORITY_RANGES = {
10830
- "1": { minzoom: 3 },
13377
+ "1": { minzoom: 0 },
10831
13378
  "2": { minzoom: 5 },
10832
13379
  "3": { minzoom: 7 },
10833
13380
  "4": { minzoom: 9 },
10834
13381
  "5": { minzoom: 11 },
10835
13382
  "6": { minzoom: 11 }
10836
13383
  };
13384
+ var BAND_DATA_MINZOOM = {
13385
+ "1": 0,
13386
+ "2": 5,
13387
+ "3": 7,
13388
+ "4": 9,
13389
+ "5": 11,
13390
+ "6": 13
13391
+ };
13392
+ function dominatedVariantMaxzoom(D) {
13393
+ return Math.max(BAND_PRIORITY_RANGES[D].minzoom, BAND_DATA_MINZOOM[D]);
13394
+ }
10837
13395
  var BAND_DRAW_ORDER = ["1", "2", "3", "4", "5", "6"];
10838
13396
  var CHART_BAND_CODES = {
10839
13397
  Overview: ["1"],
@@ -10994,12 +13552,26 @@ var DOMINANCE_EXEMPT_CLASSES = /* @__PURE__ */ new Set([
10994
13552
  "RTPBCN",
10995
13553
  "RDOSTA"
10996
13554
  ]);
13555
+ var DOMINANCE_EXEMPT_FILL_CLASSES = /* @__PURE__ */ new Set([
13556
+ "LNDARE",
13557
+ "DEPARE",
13558
+ "UNSARE",
13559
+ "LAKARE",
13560
+ "DRGARE",
13561
+ "SEAARE"
13562
+ ]);
10997
13563
  function isDominanceExempt(layer) {
10998
13564
  for (const cls of DOMINANCE_EXEMPT_CLASSES) {
10999
13565
  if (layer.id.startsWith(`s52-${cls}-`)) return true;
11000
13566
  }
13567
+ if (layer.type === "fill") {
13568
+ for (const cls of DOMINANCE_EXEMPT_FILL_CLASSES) {
13569
+ if (layer.id.startsWith(`s52-${cls}-`)) return true;
13570
+ }
13571
+ }
11001
13572
  return false;
11002
13573
  }
13574
+ var TOP_STACK_LIGHT_CLASSES = /* @__PURE__ */ new Set(["LIGHTS"]);
11003
13575
  function makeBandVariants(layer, B) {
11004
13576
  const range = BAND_PRIORITY_RANGES[B];
11005
13577
  const classMinzoom = layer.minzoom ?? 0;
@@ -11030,8 +13602,8 @@ function makeBandVariants(layer, B) {
11030
13602
  for (const D of BAND_DRAW_ORDER) {
11031
13603
  const dInt = parseInt(D, 10);
11032
13604
  if (dInt <= bInt) continue;
11033
- const dRange = BAND_PRIORITY_RANGES[D];
11034
- if (dRange.minzoom <= minzoom) continue;
13605
+ const dMaxzoom = dominatedVariantMaxzoom(D);
13606
+ if (dMaxzoom <= minzoom) continue;
11035
13607
  const domVariant = {
11036
13608
  ...layer,
11037
13609
  id: `${layer.id}-band${B}-domBy${D}`,
@@ -11040,7 +13612,7 @@ function makeBandVariants(layer, B) {
11040
13612
  ["all", bandFilterExpression(B), dominatedByMinFilter(D)]
11041
13613
  ),
11042
13614
  minzoom,
11043
- maxzoom: dRange.minzoom
13615
+ maxzoom: dMaxzoom
11044
13616
  };
11045
13617
  variants.push(domVariant);
11046
13618
  }
@@ -11087,46 +13659,26 @@ function fanOrientationLabelByCellBand(label) {
11087
13659
  paint
11088
13660
  });
11089
13661
  for (const D of BAND_DRAW_ORDER) {
11090
- const dom = BAND_PRIORITY_RANGES[D];
11091
- if (dom.minzoom <= minzoom) continue;
13662
+ const dMaxzoom = dominatedVariantMaxzoom(D);
13663
+ if (dMaxzoom <= minzoom) continue;
11092
13664
  variants.push({
11093
13665
  ...label,
11094
13666
  id: `${label.id}-cell${code}-domBy${D}`,
11095
13667
  filter: combineWithBandFilter(cellFiltered, dominatedByMinFilter(D)),
11096
13668
  minzoom,
11097
- maxzoom: dom.minzoom,
13669
+ // Data-aware ceiling: band 6 has no tile data until z13 despite
13670
+ // being style-eligible at z11 (see dominatedVariantMaxzoom).
13671
+ maxzoom: dMaxzoom,
11098
13672
  paint
11099
13673
  });
11100
13674
  }
11101
13675
  }
11102
13676
  return variants;
11103
13677
  }
11104
- function applyHarborLabelCollisionDiscipline(variant, objectClass, code) {
11105
- if (!HIGH_DENSITY_NAV_AIDS.has(objectClass)) return variant;
11106
- if (code !== "5" && code !== "6") return variant;
11107
- const oldLayout = variant.layout ?? {};
11108
- return {
11109
- ...variant,
11110
- layout: {
11111
- ...oldLayout,
11112
- "text-allow-overlap": false,
11113
- "text-optional": true,
11114
- // Wider padding so even allowed labels space visually instead of
11115
- // touching. 6 is enough to read individual buoy designations clearly
11116
- // at z12-14 over Boston Harbor.
11117
- "text-padding": 6,
11118
- // Deterministic collision arbitration: prefer features with
11119
- // longer-range nav-aid significance (VALNMR — visible nautical
11120
- // miles), then shorter OBJNAM (tighter labels win), then FIDN as
11121
- // tiebreaker. Lower sort-key wins in MapLibre.
11122
- "symbol-sort-key": [
11123
- "case",
11124
- ["has", "VALNMR"],
11125
- ["-", 0, ["coalesce", ["to-number", ["get", "VALNMR"]], 0]],
11126
- ["length", ["coalesce", ["get", "OBJNAM"], ""]]
11127
- ]
11128
- }
11129
- };
13678
+ function byLabelPriorityBottomUp(bundles) {
13679
+ return [...bundles].sort(
13680
+ (a, b) => getLabelPriority(b.objectClass) - getLabelPriority(a.objectClass)
13681
+ );
11130
13682
  }
11131
13683
  function chartModeFilterForBand(layer, B, higherCascadeBands, lockedBounds, isRequestedBand) {
11132
13684
  let filter = combineWithBandFilter(layer.filter, bandFilterExpression(B));
@@ -11162,37 +13714,47 @@ function generateStyle(palette = "DAY", opts = {}) {
11162
13714
  })
11163
13715
  });
11164
13716
  }
13717
+ const regularBundles = bundles.filter((b) => !TOP_STACK_LIGHT_CLASSES.has(b.objectClass));
13718
+ const topLightBundles = bundles.filter((b) => TOP_STACK_LIGHT_CLASSES.has(b.objectClass));
11165
13719
  if (chartCascade && chartRequestedCodes) {
11166
13720
  const requestedSet = new Set(chartRequestedCodes);
11167
- for (let i = 0; i < chartCascade.length; i++) {
11168
- const B = chartCascade[i];
11169
- const higher = chartCascade.slice(i + 1);
11170
- const isRequested = requestedSet.has(B);
11171
- for (const { featureLayers } of bundles) {
11172
- for (const base of featureLayers) {
11173
- const filter = chartModeFilterForBand(
11174
- base,
11175
- B,
11176
- higher,
11177
- lockedBounds,
11178
- isRequested
11179
- );
11180
- const variant = {
11181
- ...base,
11182
- id: `${base.id}-band${B}`,
11183
- filter
11184
- };
11185
- delete variant.maxzoom;
11186
- layers.push(variant);
13721
+ const emitChartFeatureVariants = (bundleSet) => {
13722
+ for (let i = 0; i < chartCascade.length; i++) {
13723
+ const B = chartCascade[i];
13724
+ const higher = chartCascade.slice(i + 1);
13725
+ const isRequested = requestedSet.has(B);
13726
+ for (const { featureLayers } of bundleSet) {
13727
+ for (const base of featureLayers) {
13728
+ const filter = chartModeFilterForBand(
13729
+ base,
13730
+ B,
13731
+ higher,
13732
+ lockedBounds,
13733
+ isRequested
13734
+ );
13735
+ const variant = {
13736
+ ...base,
13737
+ id: `${base.id}-band${B}`,
13738
+ filter
13739
+ };
13740
+ delete variant.maxzoom;
13741
+ layers.push(variant);
13742
+ }
11187
13743
  }
11188
13744
  }
11189
- }
11190
- for (let i = 0; i < chartCascade.length; i++) {
11191
- const B = chartCascade[i];
11192
- const higher = chartCascade.slice(i + 1);
11193
- const isRequested = requestedSet.has(B);
11194
- for (const { label } of bundles) {
11195
- if (!label) continue;
13745
+ };
13746
+ emitChartFeatureVariants(regularBundles);
13747
+ emitChartFeatureVariants(topLightBundles);
13748
+ const chartLabelBundles = byLabelPriorityBottomUp(
13749
+ bundles.filter(
13750
+ (b) => b.label !== null
13751
+ )
13752
+ );
13753
+ for (const { label } of chartLabelBundles) {
13754
+ for (let i = 0; i < chartCascade.length; i++) {
13755
+ const B = chartCascade[i];
13756
+ const higher = chartCascade.slice(i + 1);
13757
+ const isRequested = requestedSet.has(B);
11196
13758
  const filter = chartModeFilterForBand(
11197
13759
  label,
11198
13760
  B,
@@ -11212,7 +13774,14 @@ function generateStyle(palette = "DAY", opts = {}) {
11212
13774
  layers.push(...labelLayers);
11213
13775
  } else {
11214
13776
  for (const code of BAND_DRAW_ORDER) {
11215
- for (const { featureLayers } of bundles) {
13777
+ for (const { featureLayers } of regularBundles) {
13778
+ for (const base of featureLayers) {
13779
+ layers.push(...makeBandVariants(base, code));
13780
+ }
13781
+ }
13782
+ }
13783
+ for (const code of BAND_DRAW_ORDER) {
13784
+ for (const { featureLayers } of topLightBundles) {
11216
13785
  for (const base of featureLayers) {
11217
13786
  layers.push(...makeBandVariants(base, code));
11218
13787
  }
@@ -11224,13 +13793,14 @@ function generateStyle(palette = "DAY", opts = {}) {
11224
13793
  labelLayers.push(...fanOrientationLabelByCellBand(label));
11225
13794
  }
11226
13795
  }
11227
- for (const code of BAND_DRAW_ORDER) {
11228
- for (const { objectClass, label } of bundles) {
11229
- if (!label) continue;
11230
- if (getLabelBehavior(objectClass) === "orientation-cross-band") continue;
11231
- for (const variant of makeBandVariants(label, code)) {
11232
- labelLayers.push(applyHarborLabelCollisionDiscipline(variant, objectClass, code));
11233
- }
13796
+ const featureLabelBundles = byLabelPriorityBottomUp(
13797
+ bundles.filter(
13798
+ (b) => b.label !== null && getLabelBehavior(b.objectClass) !== "orientation-cross-band"
13799
+ )
13800
+ );
13801
+ for (const { label } of featureLabelBundles) {
13802
+ for (const code of BAND_DRAW_ORDER) {
13803
+ labelLayers.push(...makeBandVariants(label, code));
11234
13804
  }
11235
13805
  }
11236
13806
  layers.push(...labelLayers);
@@ -11296,6 +13866,363 @@ function getStyleStats(style) {
11296
13866
  };
11297
13867
  }
11298
13868
 
13869
+ // src/atlas/custom-style.ts
13870
+ var DEFAULT_NAV_AID_SETTINGS = {
13871
+ buoysVisible: true,
13872
+ beaconsVisible: true,
13873
+ lightsVisible: true,
13874
+ scaminOverride: 0,
13875
+ iconScale: 0.8,
13876
+ iconSizeLocked: false,
13877
+ allowOverlap: false,
13878
+ showSectorArcs: true,
13879
+ fogSignalsVisible: true,
13880
+ radarVisible: true
13881
+ };
13882
+ var DEFAULT_LABEL_SETTINGS = {
13883
+ navAidLabelsVisible: true,
13884
+ landLabelsVisible: true,
13885
+ structureLabelsVisible: true,
13886
+ depthContoursVisible: true,
13887
+ navAidFullNames: false,
13888
+ minzoomOverride: null,
13889
+ textScale: 1,
13890
+ allowOverlap: false
13891
+ };
13892
+ var DEFAULT_SOUNDING_SETTINGS = {
13893
+ visible: true,
13894
+ minzoomOverride: null,
13895
+ textScale: 1,
13896
+ allowOverlap: false,
13897
+ showUnits: false,
13898
+ safetyContour: 10,
13899
+ fontWeight: "semibold",
13900
+ twoShade: true
13901
+ };
13902
+ var DEFAULT_WATER_BODY_SETTINGS = {
13903
+ seaAreasVisible: true,
13904
+ lakesVisible: true,
13905
+ riversVisible: true,
13906
+ canalsVisible: true,
13907
+ minzoomOverride: null,
13908
+ textScale: 1,
13909
+ allowOverlap: false,
13910
+ uppercaseSeaAreas: true
13911
+ };
13912
+ var NAV_AID_LAYER_GROUPS = {
13913
+ buoys: ["BOYLAT", "BOYCAR", "BOYSPP", "BOYISD", "BOYSAW"],
13914
+ beacons: ["BCNLAT", "BCNCAR", "BCNSPP"],
13915
+ lights: ["LIGHTS"],
13916
+ fogSignals: ["FOGSIG"],
13917
+ radar: ["RTPBCN", "RDOSTA"]
13918
+ };
13919
+ var ALL_NAV_AID_LAYERS = new Set(
13920
+ Object.values(NAV_AID_LAYER_GROUPS).flat()
13921
+ );
13922
+ var LABEL_LAYER_GROUPS = {
13923
+ navAids: ["BOYLAT", "BCNLAT", "BOYCAR", "BOYSPP", "BOYISD", "BOYSAW", "BCNCAR", "BCNSPP", "LIGHTS", "FOGSIG", "RTPBCN", "RDOSTA"],
13924
+ land: ["LNDRGN", "LNDMRK", "LNDARE"],
13925
+ structures: ["BRIDGE", "PILPNT", "MORFAC", "SLCONS", "BUISGL", "AIRARE"],
13926
+ depthContours: ["DEPCNT"]
13927
+ };
13928
+ var SOUNDING_LAYERS = ["SOUNDG"];
13929
+ var WATER_BODY_LAYER_GROUPS = {
13930
+ seaAreas: ["SEAARE"],
13931
+ lakes: ["LAKARE"],
13932
+ rivers: ["RIVERS"],
13933
+ canals: ["CANALS"]
13934
+ };
13935
+ var ALL_WATER_BODY_LAYERS = new Set(
13936
+ Object.values(WATER_BODY_LAYER_GROUPS).flat()
13937
+ );
13938
+ function isNavAidLayerVisible(layerName, settings) {
13939
+ if (NAV_AID_LAYER_GROUPS.buoys.includes(layerName)) return settings.buoysVisible;
13940
+ if (NAV_AID_LAYER_GROUPS.beacons.includes(layerName)) return settings.beaconsVisible;
13941
+ if (NAV_AID_LAYER_GROUPS.lights.includes(layerName)) return settings.lightsVisible;
13942
+ if (NAV_AID_LAYER_GROUPS.fogSignals.includes(layerName)) return settings.fogSignalsVisible;
13943
+ if (NAV_AID_LAYER_GROUPS.radar.includes(layerName)) return settings.radarVisible;
13944
+ return true;
13945
+ }
13946
+ function isLabelLayerVisible(layerName, settings) {
13947
+ if (LABEL_LAYER_GROUPS.navAids.includes(layerName)) return settings.navAidLabelsVisible;
13948
+ if (LABEL_LAYER_GROUPS.land.includes(layerName)) return settings.landLabelsVisible;
13949
+ if (LABEL_LAYER_GROUPS.structures.includes(layerName)) return settings.structureLabelsVisible;
13950
+ if (LABEL_LAYER_GROUPS.depthContours.includes(layerName)) return settings.depthContoursVisible;
13951
+ return true;
13952
+ }
13953
+ function isSoundingLayerVisible(settings) {
13954
+ return settings.visible;
13955
+ }
13956
+ function isWaterBodyLayerVisible(layerName, settings) {
13957
+ if (WATER_BODY_LAYER_GROUPS.seaAreas.includes(layerName)) return settings.seaAreasVisible;
13958
+ if (WATER_BODY_LAYER_GROUPS.lakes.includes(layerName)) return settings.lakesVisible;
13959
+ if (WATER_BODY_LAYER_GROUPS.rivers.includes(layerName)) return settings.riversVisible;
13960
+ if (WATER_BODY_LAYER_GROUPS.canals.includes(layerName)) return settings.canalsVisible;
13961
+ return true;
13962
+ }
13963
+ var DEFAULT_CUSTOM_STYLE_CONFIG = {
13964
+ enabled_color_tables: ["DAY", "DUSK", "NIGHT"],
13965
+ default_color_table: "DAY",
13966
+ display_mode: "STANDARD",
13967
+ nav_aid_settings: DEFAULT_NAV_AID_SETTINGS,
13968
+ label_settings: DEFAULT_LABEL_SETTINGS,
13969
+ sounding_settings: DEFAULT_SOUNDING_SETTINGS,
13970
+ water_body_settings: DEFAULT_WATER_BODY_SETTINGS,
13971
+ visible_layers: null
13972
+ };
13973
+ var COLOR_TABLE_NAMES = /* @__PURE__ */ new Set(["DAY", "DUSK", "NIGHT"]);
13974
+ var DISPLAY_MODES = /* @__PURE__ */ new Set(["BASE", "STANDARD", "ALL"]);
13975
+ var LAYER_NAME_RE = /^[A-Za-z0-9_$]{2,16}$/;
13976
+ var MAX_VISIBLE_LAYERS = 512;
13977
+ function clampNumber(raw, min, max, fallback) {
13978
+ const n = typeof raw === "number" ? raw : NaN;
13979
+ if (!Number.isFinite(n)) return fallback;
13980
+ return Math.min(max, Math.max(min, n));
13981
+ }
13982
+ function intOrNull(raw, min, max, fallback) {
13983
+ if (raw === null) return null;
13984
+ const n = typeof raw === "number" ? raw : NaN;
13985
+ if (!Number.isFinite(n)) return fallback;
13986
+ return Math.round(Math.min(max, Math.max(min, n)));
13987
+ }
13988
+ function bool(raw, fallback) {
13989
+ return typeof raw === "boolean" ? raw : fallback;
13990
+ }
13991
+ function asRecord(raw) {
13992
+ return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
13993
+ }
13994
+ function sanitizeNavAid(raw) {
13995
+ const r = asRecord(raw);
13996
+ const d = DEFAULT_NAV_AID_SETTINGS;
13997
+ return {
13998
+ buoysVisible: bool(r.buoysVisible, d.buoysVisible),
13999
+ beaconsVisible: bool(r.beaconsVisible, d.beaconsVisible),
14000
+ lightsVisible: bool(r.lightsVisible, d.lightsVisible),
14001
+ scaminOverride: intOrNull(r.scaminOverride, 0, 14, d.scaminOverride),
14002
+ iconScale: clampNumber(r.iconScale, 0.2, 2, d.iconScale),
14003
+ iconSizeLocked: bool(r.iconSizeLocked, d.iconSizeLocked),
14004
+ allowOverlap: bool(r.allowOverlap, d.allowOverlap),
14005
+ showSectorArcs: bool(r.showSectorArcs, d.showSectorArcs),
14006
+ fogSignalsVisible: bool(r.fogSignalsVisible, d.fogSignalsVisible),
14007
+ radarVisible: bool(r.radarVisible, d.radarVisible)
14008
+ };
14009
+ }
14010
+ function sanitizeLabels(raw) {
14011
+ const r = asRecord(raw);
14012
+ const d = DEFAULT_LABEL_SETTINGS;
14013
+ return {
14014
+ navAidLabelsVisible: bool(r.navAidLabelsVisible, d.navAidLabelsVisible),
14015
+ landLabelsVisible: bool(r.landLabelsVisible, d.landLabelsVisible),
14016
+ structureLabelsVisible: bool(r.structureLabelsVisible, d.structureLabelsVisible),
14017
+ depthContoursVisible: bool(r.depthContoursVisible, d.depthContoursVisible),
14018
+ navAidFullNames: bool(r.navAidFullNames, d.navAidFullNames),
14019
+ minzoomOverride: intOrNull(r.minzoomOverride, 0, 14, d.minzoomOverride),
14020
+ textScale: clampNumber(r.textScale, 0.5, 2, d.textScale),
14021
+ allowOverlap: bool(r.allowOverlap, d.allowOverlap)
14022
+ };
14023
+ }
14024
+ function sanitizeSoundings(raw) {
14025
+ const r = asRecord(raw);
14026
+ const d = DEFAULT_SOUNDING_SETTINGS;
14027
+ return {
14028
+ visible: bool(r.visible, d.visible),
14029
+ minzoomOverride: intOrNull(r.minzoomOverride, 0, 14, d.minzoomOverride),
14030
+ textScale: clampNumber(r.textScale, 0.5, 2, d.textScale),
14031
+ allowOverlap: bool(r.allowOverlap, d.allowOverlap),
14032
+ showUnits: bool(r.showUnits, d.showUnits),
14033
+ safetyContour: clampNumber(r.safetyContour, 0, 100, d.safetyContour),
14034
+ fontWeight: r.fontWeight === "regular" || r.fontWeight === "semibold" ? r.fontWeight : d.fontWeight,
14035
+ twoShade: bool(r.twoShade, d.twoShade)
14036
+ };
14037
+ }
14038
+ function sanitizeWaterBody(raw) {
14039
+ const r = asRecord(raw);
14040
+ const d = DEFAULT_WATER_BODY_SETTINGS;
14041
+ return {
14042
+ seaAreasVisible: bool(r.seaAreasVisible, d.seaAreasVisible),
14043
+ lakesVisible: bool(r.lakesVisible, d.lakesVisible),
14044
+ riversVisible: bool(r.riversVisible, d.riversVisible),
14045
+ canalsVisible: bool(r.canalsVisible, d.canalsVisible),
14046
+ minzoomOverride: intOrNull(r.minzoomOverride, 0, 14, d.minzoomOverride),
14047
+ textScale: clampNumber(r.textScale, 0.5, 2, d.textScale),
14048
+ allowOverlap: bool(r.allowOverlap, d.allowOverlap),
14049
+ uppercaseSeaAreas: bool(r.uppercaseSeaAreas, d.uppercaseSeaAreas)
14050
+ };
14051
+ }
14052
+ function sanitizeCustomStyleConfig(raw) {
14053
+ const r = asRecord(raw);
14054
+ const enabledRaw = Array.isArray(r.enabled_color_tables) ? r.enabled_color_tables : [];
14055
+ const enabled = [...new Set(
14056
+ enabledRaw.filter((c) => typeof c === "string" && COLOR_TABLE_NAMES.has(c))
14057
+ )];
14058
+ const enabledFinal = enabled.length > 0 ? enabled : ["DAY", "DUSK", "NIGHT"];
14059
+ const defaultRaw = typeof r.default_color_table === "string" ? r.default_color_table : "";
14060
+ const defaultFinal = enabledFinal.includes(defaultRaw) ? defaultRaw : enabledFinal[0];
14061
+ const modeRaw = typeof r.display_mode === "string" ? r.display_mode : "";
14062
+ const mode = DISPLAY_MODES.has(modeRaw) ? modeRaw : "STANDARD";
14063
+ let visibleLayers = null;
14064
+ if (Array.isArray(r.visible_layers)) {
14065
+ visibleLayers = r.visible_layers.filter((l) => typeof l === "string" && LAYER_NAME_RE.test(l)).slice(0, MAX_VISIBLE_LAYERS).map((l) => l.toUpperCase());
14066
+ }
14067
+ return {
14068
+ enabled_color_tables: enabledFinal,
14069
+ default_color_table: defaultFinal,
14070
+ display_mode: mode,
14071
+ nav_aid_settings: sanitizeNavAid(r.nav_aid_settings),
14072
+ label_settings: sanitizeLabels(r.label_settings),
14073
+ sounding_settings: sanitizeSoundings(r.sounding_settings),
14074
+ water_body_settings: sanitizeWaterBody(r.water_body_settings),
14075
+ visible_layers: visibleLayers
14076
+ };
14077
+ }
14078
+ function normalizeTheme(raw) {
14079
+ if (!raw) return null;
14080
+ const upper = raw.trim().toUpperCase();
14081
+ return COLOR_TABLE_NAMES.has(upper) ? upper : null;
14082
+ }
14083
+ function resolveCustomTheme(config, requested) {
14084
+ if (!requested) return { palette: config.default_color_table, themeFellBack: false };
14085
+ if (config.enabled_color_tables.includes(requested)) {
14086
+ return { palette: requested, themeFellBack: false };
14087
+ }
14088
+ return { palette: config.default_color_table, themeFellBack: true };
14089
+ }
14090
+ function layerObjectClass(layer) {
14091
+ const meta = layer.metadata;
14092
+ const fromMeta = meta?.["s52:objectClass"];
14093
+ if (typeof fromMeta === "string" && fromMeta.length > 0) return fromMeta;
14094
+ const m = /^s52-([A-Za-z0-9_$]+)-/.exec(layer.id);
14095
+ return m ? m[1] : null;
14096
+ }
14097
+ function isLabelLayer(layer) {
14098
+ if (layer.type !== "symbol") return false;
14099
+ const layout = layer.layout;
14100
+ return layout?.["text-field"] !== void 0;
14101
+ }
14102
+ function isIconLayer(layer) {
14103
+ if (layer.type !== "symbol") return false;
14104
+ const layout = layer.layout;
14105
+ return layout?.["icon-image"] !== void 0 && layout?.["text-field"] === void 0;
14106
+ }
14107
+ function bandFloor(layerId) {
14108
+ const m = /-band(\d)/.exec(layerId);
14109
+ if (!m) return 0;
14110
+ const range = BAND_PRIORITY_RANGES[m[1]];
14111
+ return range ? range.minzoom : 0;
14112
+ }
14113
+ function scaleSizeValue(value, factor) {
14114
+ if (typeof value === "number") {
14115
+ return Math.round(value * factor * 100) / 100;
14116
+ }
14117
+ if (!Array.isArray(value) || value.length === 0) return value;
14118
+ const op = value[0];
14119
+ if (op === "interpolate") {
14120
+ const out = [...value];
14121
+ for (let i = 4; i < out.length; i += 2) out[i] = scaleSizeValue(out[i], factor);
14122
+ return out;
14123
+ }
14124
+ if (op === "step") {
14125
+ const out = [...value];
14126
+ for (let i = 2; i < out.length; i += 2) out[i] = scaleSizeValue(out[i], factor);
14127
+ return out;
14128
+ }
14129
+ if (op === "case") {
14130
+ const out = [...value];
14131
+ for (let i = 2; i < out.length - 1; i += 2) out[i] = scaleSizeValue(out[i], factor);
14132
+ out[out.length - 1] = scaleSizeValue(out[out.length - 1], factor);
14133
+ return out;
14134
+ }
14135
+ if (op === "match") {
14136
+ const out = [...value];
14137
+ for (let i = 3; i < out.length - 1; i += 2) out[i] = scaleSizeValue(out[i], factor);
14138
+ out[out.length - 1] = scaleSizeValue(out[out.length - 1], factor);
14139
+ return out;
14140
+ }
14141
+ return value;
14142
+ }
14143
+ function setLayout(layer, key, val) {
14144
+ const layout = { ...layer.layout ?? {} };
14145
+ layout[key] = val;
14146
+ layer.layout = layout;
14147
+ }
14148
+ function hideLayer(layer) {
14149
+ setLayout(layer, "visibility", "none");
14150
+ }
14151
+ function applyCustomStyleSettings(style, config) {
14152
+ const nav = config.nav_aid_settings;
14153
+ const labels = config.label_settings;
14154
+ const soundings = config.sounding_settings;
14155
+ const water = config.water_body_settings;
14156
+ const visibleSet = config.visible_layers ? new Set(config.visible_layers) : null;
14157
+ for (const layer of style.layers) {
14158
+ const cls = layerObjectClass(layer);
14159
+ if (!cls) continue;
14160
+ const label = isLabelLayer(layer);
14161
+ if (visibleSet && !visibleSet.has(cls)) {
14162
+ hideLayer(layer);
14163
+ continue;
14164
+ }
14165
+ if (label) {
14166
+ const hidden = SOUNDING_LAYERS.includes(cls) && !isSoundingLayerVisible(soundings) || ALL_WATER_BODY_LAYERS.has(cls) && !isWaterBodyLayerVisible(cls, water) || !SOUNDING_LAYERS.includes(cls) && !ALL_WATER_BODY_LAYERS.has(cls) && !isLabelLayerVisible(cls, labels);
14167
+ if (hidden) {
14168
+ hideLayer(layer);
14169
+ continue;
14170
+ }
14171
+ } else {
14172
+ const hidden = ALL_NAV_AID_LAYERS.has(cls) && !isNavAidLayerVisible(cls, nav) || ALL_WATER_BODY_LAYERS.has(cls) && !isWaterBodyLayerVisible(cls, water) || SOUNDING_LAYERS.includes(cls) && !isSoundingLayerVisible(soundings);
14173
+ if (hidden) {
14174
+ hideLayer(layer);
14175
+ continue;
14176
+ }
14177
+ }
14178
+ if (label) {
14179
+ const bundle = SOUNDING_LAYERS.includes(cls) ? { textScale: soundings.textScale, minzoom: soundings.minzoomOverride, allowOverlap: soundings.allowOverlap } : ALL_WATER_BODY_LAYERS.has(cls) ? { textScale: water.textScale, minzoom: water.minzoomOverride, allowOverlap: water.allowOverlap } : { textScale: labels.textScale, minzoom: labels.minzoomOverride, allowOverlap: labels.allowOverlap };
14180
+ if (bundle.textScale !== 1) {
14181
+ const layout = layer.layout;
14182
+ if (layout?.["text-size"] !== void 0) {
14183
+ setLayout(layer, "text-size", scaleSizeValue(layout["text-size"], bundle.textScale));
14184
+ }
14185
+ }
14186
+ if (bundle.allowOverlap === true) {
14187
+ setLayout(layer, "text-allow-overlap", true);
14188
+ setLayout(layer, "text-optional", false);
14189
+ }
14190
+ if (bundle.minzoom !== null) {
14191
+ layer.minzoom = Math.max(bundle.minzoom, bandFloor(layer.id));
14192
+ }
14193
+ if (cls === "SEAARE") {
14194
+ setLayout(layer, "text-transform", water.uppercaseSeaAreas ? "uppercase" : "none");
14195
+ }
14196
+ }
14197
+ if (!label && ALL_NAV_AID_LAYERS.has(cls) && isIconLayer(layer)) {
14198
+ if (nav.iconScale !== 1) {
14199
+ const layout = layer.layout;
14200
+ const current = layout?.["icon-size"];
14201
+ setLayout(layer, "icon-size", current === void 0 ? Math.round(nav.iconScale * 100) / 100 : scaleSizeValue(current, nav.iconScale));
14202
+ }
14203
+ if (nav.allowOverlap === true) {
14204
+ setLayout(layer, "icon-allow-overlap", true);
14205
+ }
14206
+ if (nav.scaminOverride !== null) {
14207
+ layer.minzoom = Math.max(nav.scaminOverride, bandFloor(layer.id));
14208
+ }
14209
+ }
14210
+ }
14211
+ return style;
14212
+ }
14213
+ function generateCustomStyle(config, opts = {}) {
14214
+ const { theme, ...generatorOpts } = opts;
14215
+ const { palette, themeFellBack } = resolveCustomTheme(config, theme ?? null);
14216
+ const style = generateStyle(palette, {
14217
+ ...generatorOpts,
14218
+ safetyContour: config.sounding_settings.safetyContour,
14219
+ twoShadeSoundings: config.sounding_settings.twoShade,
14220
+ soundingFontWeight: config.sounding_settings.fontWeight
14221
+ });
14222
+ applyCustomStyleSettings(style, config);
14223
+ return { style, palette, themeFellBack };
14224
+ }
14225
+
11299
14226
  // src/pagination.ts
11300
14227
  async function* paginate(fetchPage, getItems, getTotalCount, options) {
11301
14228
  const pageSize = options?.pageSize ?? 100;
@@ -11351,6 +14278,12 @@ var Chrt = class {
11351
14278
  * requires `manifestPath`.
11352
14279
  */
11353
14280
  offline;
14281
+ /**
14282
+ * CHRTinteract — click-to-inspect: `attach(map)` for wired popups/events,
14283
+ * `inspect(feature)` for headless decode. Included with the Dev plan and
14284
+ * above; enforced server-side at attach/inspect time.
14285
+ */
14286
+ interact;
11354
14287
  featuresEp;
11355
14288
  queryEp;
11356
14289
  http;
@@ -11363,13 +14296,14 @@ var Chrt = class {
11363
14296
  this.tileBaseUrl = config.tileBaseUrl ?? DEFAULT_TILE_BASE_URL;
11364
14297
  this.http = new HttpClient(config.apiKey, config.baseUrl, config.retries);
11365
14298
  const tileOpts = { region: this.region, tileBaseUrl: this.tileBaseUrl };
11366
- this.tiles = new TilesEndpoint(this.http, tileOpts);
14299
+ this.tileAuth = new TileAuth(this.http, tileOpts);
14300
+ this.tiles = new TilesEndpoint(this.http, this.tileAuth, tileOpts);
11367
14301
  this.cells = new CellsEndpoint(this.http);
11368
14302
  this.coverage = new CoverageEndpoint(this.http);
11369
14303
  this.featuresEp = new FeaturesEndpoint(this.http);
11370
14304
  this.queryEp = new QueryEndpoint(this.http);
11371
14305
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
11372
- this.tileAuth = new TileAuth(this.http, tileOpts);
14306
+ this.interact = new InteractClient(this.http);
11373
14307
  }
11374
14308
  /* ── Convenience shortcuts ───────────────────────── */
11375
14309
  /** Query GeoJSON features by bounding box */
@@ -11463,6 +14397,9 @@ var Chrt = class {
11463
14397
  * });
11464
14398
  *
11465
14399
  * @remarks S-101 portrayal is on the roadmap; today `style()` emits S-52.
14400
+ * Generated locally, so it never applies a key-bound CHRTstyle custom
14401
+ * style — use {@link styleUrl} / {@link fetchStyle} (the served Style API)
14402
+ * for that.
11466
14403
  */
11467
14404
  style(opts = {}) {
11468
14405
  const { palette = "DAY", ...styleOpts } = opts;
@@ -11473,12 +14410,33 @@ var Chrt = class {
11473
14410
  });
11474
14411
  }
11475
14412
  /**
11476
- * @deprecated CHRT serves no remote `style.json`. Use {@link style} to get a
11477
- * MapLibre style object instead. Throws to avoid silently pointing a map at a
11478
- * dead URL.
14413
+ * URL of the served S-52 style — the CHRTstyle Style API at
14414
+ * `tiles.chrt.co/v1/styles/{region}/style.json`. Unlike {@link style}
14415
+ * (generated locally), the served document honors the API key's bound
14416
+ * CHRTstyle custom style: palettes, layer visibility, label/icon scale,
14417
+ * and the rest of the platform's Map Styles settings, applied server-side
14418
+ * and tier-gated (custom styles are Dev+; Free keys get the default
14419
+ * themes). Async because it pre-mints the tile token, so the style
14420
+ * request is guaranteed to carry auth via `transformRequest()`.
14421
+ *
14422
+ * @example
14423
+ * const map = new maplibregl.Map({
14424
+ * container: 'map',
14425
+ * style: await chrt.styleUrl({ theme: 'day' }),
14426
+ * transformRequest: chrt.transformRequest(),
14427
+ * });
14428
+ */
14429
+ async styleUrl(opts) {
14430
+ return this.tiles.styleUrl(opts);
14431
+ }
14432
+ /**
14433
+ * Fetch the served S-52 style (see {@link styleUrl}) as a MapLibre style
14434
+ * object — inspect or mutate it before handing it to the map.
14435
+ * `chrt.style()` remains the local generator that needs no network
14436
+ * round-trip (and therefore applies no custom style).
11479
14437
  */
11480
- styleUrl() {
11481
- return this.tiles.styleUrl();
14438
+ async fetchStyle(opts) {
14439
+ return this.tiles.fetchStyle(opts);
11482
14440
  }
11483
14441
  /**
11484
14442
  * Returns a `transformRequest` for Mapbox GL JS / MapLibre GL JS that mints a
@@ -11506,8 +14464,12 @@ var Chrt = class {
11506
14464
  }
11507
14465
  };
11508
14466
  export {
14467
+ ALL_NAV_AID_LAYERS,
14468
+ ALL_WATER_BODY_LAYERS,
14469
+ AREA_QUERY_LAYERS,
11509
14470
  BAND_DRAW_ORDER,
11510
14471
  BAND_PRIORITY_RANGES,
14472
+ BRIDGE_QUERY_LAYERS,
11511
14473
  BUILD_MAXZOOM,
11512
14474
  BUILD_MINZOOM,
11513
14475
  CHART_BAND_CODES,
@@ -11515,12 +14477,25 @@ export {
11515
14477
  CHART_SECTIONS,
11516
14478
  Chrt,
11517
14479
  ChrtError,
14480
+ DEFAULT_CUSTOM_STYLE_CONFIG,
14481
+ DEFAULT_LABEL_SETTINGS,
14482
+ DEFAULT_NAV_AID_SETTINGS,
11518
14483
  DEFAULT_REGION,
14484
+ DEFAULT_SOUNDING_SETTINGS,
11519
14485
  DEFAULT_TILE_BASE_URL,
11520
14486
  DEFAULT_TILE_REGION,
14487
+ DEFAULT_WATER_BODY_SETTINGS,
14488
+ InteractClient,
14489
+ InteractError,
11521
14490
  LABELED_LAYERS,
11522
14491
  LABEL_BEHAVIOR,
14492
+ LABEL_LAYER_GROUPS,
14493
+ LABEL_PRIORITY,
11523
14494
  NAUTICAL_SYMBOLS,
14495
+ NAV_AID_LAYER_GROUPS,
14496
+ NAV_AID_QUERY_LAYERS,
14497
+ NON_SOLAS_DISCLAIMER,
14498
+ NON_SOLAS_DISCLAIMER_SHORT,
11524
14499
  OfflineError,
11525
14500
  OfflineStyleError,
11526
14501
  S101_CHART_SECTIONS,
@@ -11533,21 +14508,34 @@ export {
11533
14508
  S52_DUSK,
11534
14509
  S52_LOOKUP,
11535
14510
  S52_NIGHT,
14511
+ SOUNDING_LAYERS,
11536
14512
  SYMBOL_GROUPS,
11537
14513
  SYMBOL_MAP,
11538
14514
  TileAuth,
11539
14515
  TileTokenError,
14516
+ WATER_BODY_LAYER_GROUPS,
11540
14517
  applyColorTheme,
14518
+ applyCustomStyleSettings,
11541
14519
  bandFilterExpression,
14520
+ buildFlashPattern,
11542
14521
  buildLabelLayer,
11543
14522
  buildLayersForClass,
11544
14523
  buildOfflineStyle,
14524
+ buildSectorArcGeoJSON,
14525
+ buildSectorArcsFC,
14526
+ buildSectorsFromFeatures,
11545
14527
  buildSources,
11546
14528
  combineWithBandFilter,
11547
14529
  daymarkColor,
11548
14530
  decimateFeatures,
14531
+ decodeAreaFeatureProps,
14532
+ decodeBridgeProps,
14533
+ decodeNavAidProps,
11549
14534
  depareColor,
11550
14535
  depareFillExpression,
14536
+ deriveMarinerName,
14537
+ extractNavAidDesignation,
14538
+ generateCustomStyle,
11551
14539
  generateSpriteManifest,
11552
14540
  generateSpriteSheet,
11553
14541
  generateStyle,
@@ -11562,6 +14550,7 @@ export {
11562
14550
  getIconExpression,
11563
14551
  getLabelBehavior,
11564
14552
  getLabelConfig,
14553
+ getLabelPriority,
11565
14554
  getLookupEntry,
11566
14555
  getMaxCellsForZoom,
11567
14556
  getOrderedClasses,
@@ -11570,22 +14559,33 @@ export {
11570
14559
  getStyleStats,
11571
14560
  getSymbolSvgString,
11572
14561
  getThemedS52Svg,
14562
+ getTopmarkMeaning,
11573
14563
  hazardColor,
11574
14564
  hazardFillExpression,
14565
+ isLabelLayerVisible,
11575
14566
  isNavAidLayer,
14567
+ isNavAidLayerVisible,
14568
+ isSoundingLayerVisible,
11576
14569
  isVisibleInMode,
14570
+ isWaterBodyLayerVisible,
14571
+ labelSortKey,
11577
14572
  lateralColor,
11578
14573
  lateralFillExpression,
11579
14574
  lightColor,
11580
14575
  lightFillExpression,
14576
+ normalizeTheme,
11581
14577
  paginate,
14578
+ parseListCodes,
11582
14579
  parseWebhook,
11583
14580
  preprocessLights,
11584
14581
  preprocessNavAids,
11585
14582
  preprocessSoundings,
11586
14583
  preprocessWaterBodies,
11587
14584
  resolveColor,
14585
+ resolveCustomTheme,
11588
14586
  resolveStyle,
14587
+ sanitizeCustomStyleConfig,
14588
+ scaleSizeValue,
11589
14589
  scaminToMinZoom,
11590
14590
  withinBboxFilter,
11591
14591
  worldMinusBboxPolygon