@chrtco/sdk 0.10.39 → 0.10.41

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
@@ -367,9 +367,13 @@ var RELIEF_REGION = "CHRT_RELIEF";
367
367
  var BASE_REGION = "CHRT_BASE";
368
368
  var BASE_MINZOOM = 0;
369
369
  var BASE_MAXZOOM = 11;
370
+ var BASE_ATTRIBUTION = "\xA9 CHRT \xB7 Made with Natural Earth \xB7 \xA9 OpenStreetMap contributors";
371
+ var LAND_ATTRIBUTION = "\xA9 OpenStreetMap contributors";
372
+ var RELIEF_ATTRIBUTION = "Elevation: USGS 3D Elevation Program";
370
373
  var RELIEF_MINZOOM = 9;
371
374
  var RELIEF_MAXZOOM = 13;
372
375
  var RELIEF_TILE_SIZE = 512;
376
+ var RELIEF_ENCODING = "terrarium";
373
377
  var BUILD_PARAM = "v";
374
378
  function tileTemplate(base, region, extension, buildId) {
375
379
  const url = `${base}/v1/tiles/${region}/{z}/{x}/{y}.${extension}`;
@@ -405,7 +409,8 @@ function buildSources(opts = {}) {
405
409
  type: "vector",
406
410
  tiles: [tileTemplate(base, LAND_REGION, "mvt", buildFor(LAND_REGION))],
407
411
  minzoom: BUILD_MINZOOM,
408
- maxzoom: LAND_BUILD_MAXZOOM
412
+ maxzoom: LAND_BUILD_MAXZOOM,
413
+ attribution: LAND_ATTRIBUTION
409
414
  };
410
415
  }
411
416
  if (opts.base) {
@@ -413,17 +418,19 @@ function buildSources(opts = {}) {
413
418
  type: "vector",
414
419
  tiles: [tileTemplate(base, BASE_REGION, "mvt", buildFor(BASE_REGION))],
415
420
  minzoom: BASE_MINZOOM,
416
- maxzoom: BASE_MAXZOOM
421
+ maxzoom: BASE_MAXZOOM,
422
+ attribution: BASE_ATTRIBUTION
417
423
  };
418
424
  }
419
425
  if (opts.relief) {
420
426
  sources[RELIEF_REGION] = {
421
427
  type: "raster-dem",
422
428
  tiles: [tileTemplate(base, RELIEF_REGION, "png", buildFor(RELIEF_REGION))],
423
- encoding: "terrarium",
429
+ encoding: RELIEF_ENCODING,
424
430
  tileSize: RELIEF_TILE_SIZE,
425
431
  minzoom: RELIEF_MINZOOM,
426
- maxzoom: RELIEF_MAXZOOM
432
+ maxzoom: RELIEF_MAXZOOM,
433
+ attribution: RELIEF_ATTRIBUTION
427
434
  };
428
435
  }
429
436
  return sources;
@@ -446,6 +453,7 @@ var TilesEndpoint = class {
446
453
  if (opts.mode) params.set("mode", opts.mode);
447
454
  if (opts.land) params.set("land", "1");
448
455
  if (opts.relief) params.set("relief", "1");
456
+ if (opts.units === "imperial") params.set("units", "imperial");
449
457
  const query2 = params.size > 0 ? `?${params.toString()}` : "";
450
458
  return `${this.tileBaseUrl}/v1/styles/${this.region}/style.json${query2}`;
451
459
  }
@@ -571,6 +579,9 @@ var WeatherEndpoint = class {
571
579
  *
572
580
  * `unavailable` names every field the station did not report, so absence is
573
581
  * stated rather than inferred from a missing key.
582
+ *
583
+ * Pass `hours` for the station's recent readings as `series` — NDBC's own
584
+ * readings at NDBC's own instants, never interpolated.
574
585
  */
575
586
  async conditions(options) {
576
587
  return this.http.request(
@@ -579,7 +590,8 @@ var WeatherEndpoint = class {
579
590
  lat: options.lat,
580
591
  lon: options.lon,
581
592
  units: options.units,
582
- station: options.station
593
+ station: options.station,
594
+ hours: options.hours
583
595
  },
584
596
  options.signal
585
597
  );
@@ -1043,10 +1055,10 @@ async function readManifest(manifestPath, fs) {
1043
1055
  if (!manifestPath) return emptyManifest();
1044
1056
  if (!await fs.exists(manifestPath)) return emptyManifest();
1045
1057
  const raw = await fs.readFile(manifestPath);
1046
- const text = new TextDecoder().decode(raw);
1058
+ const text2 = new TextDecoder().decode(raw);
1047
1059
  let parsed;
1048
1060
  try {
1049
- parsed = JSON.parse(text);
1061
+ parsed = JSON.parse(text2);
1050
1062
  } catch {
1051
1063
  throw new OfflineError(
1052
1064
  "unexpected_response",
@@ -1070,8 +1082,8 @@ async function readManifest(manifestPath, fs) {
1070
1082
  };
1071
1083
  }
1072
1084
  async function writeManifest(manifestPath, fs, manifest) {
1073
- const text = JSON.stringify(manifest, null, 2);
1074
- const bytes = new TextEncoder().encode(text);
1085
+ const text2 = JSON.stringify(manifest, null, 2);
1086
+ const bytes = new TextEncoder().encode(text2);
1075
1087
  await fs.writeFile(manifestPath, bytes);
1076
1088
  }
1077
1089
  function usedBytes(manifest, excludeRegion) {
@@ -2036,110 +2048,438 @@ function s57AbbrTable(acronym) {
2036
2048
  return cached;
2037
2049
  }
2038
2050
 
2039
- // src/interact/decode.ts
2040
- function nameAbbrTable(acronym) {
2041
- const names = s57NameTable(acronym);
2042
- const abbrs = s57AbbrTable(acronym);
2043
- const out = {};
2044
- for (const [code, name] of Object.entries(names)) {
2045
- const abbr = abbrs[Number(code)];
2046
- if (abbr !== void 0) out[Number(code)] = { name, abbr };
2047
- }
2048
- return out;
2049
- }
2050
- var COLOUR_HEX = {
2051
- 1: "#ffffff",
2052
- 2: "#333333",
2053
- 3: "#ef4444",
2054
- 4: "#22c55e",
2055
- 5: "#3b82f6",
2056
- 6: "#eab308",
2057
- 7: "#9ca3af",
2058
- 8: "#92400e",
2059
- 9: "#f59e0b",
2060
- 10: "#8b5cf6",
2061
- 11: "#f97316",
2062
- 12: "#d946ef",
2063
- 13: "#f472b6"
2051
+ // src/atlas/s52-colors.ts
2052
+ var S52_DAY = {
2053
+ // Depth
2054
+ NODTA: "#a4a4a4",
2055
+ DEPIT: "#009678",
2056
+ DEPVS: "#7fbfdf",
2057
+ DEPMS: "#a7d4e8",
2058
+ DEPMD: "#cae7f3",
2059
+ DEPDW: "#ecf7fc",
2060
+ // Land / topography
2061
+ LANDA: "#e6d1a1",
2062
+ LANDF: "#c8b078",
2063
+ CHBRN: "#ab7948",
2064
+ CHGRF: "#878787",
2065
+ CHGRD: "#555555",
2066
+ // Chart symbology
2067
+ CHBLK: "#000000",
2068
+ CSTLN: "#000000",
2069
+ OUTLW: "#000000",
2070
+ BKAJ1: "#4d4d4d",
2071
+ // Navigation / traffic
2072
+ TRFCD: "#d200d2",
2073
+ TRFCF: "#e680e6",
2074
+ RESBL: "#0064b4",
2075
+ APTS1: "#d200d2",
2076
+ // Danger / hazard
2077
+ DNGHL: "#ff0000",
2078
+ ISHDG: "#cc0000",
2079
+ // Lights
2080
+ LITRD: "#ee0000",
2081
+ LITGN: "#00c800",
2082
+ LITYW: "#fddc00",
2083
+ LITWT: "#ffffff",
2084
+ LITBL: "#0064ff",
2085
+ LITOR: "#ff8c00",
2086
+ // Buoys / marks
2087
+ RADHI: "#68e456",
2088
+ RADLO: "#3f8a34",
2089
+ PLRTE: "#dc4025",
2090
+ ARPAT: "#3fa56f",
2091
+ ISDNG: "#c545c3",
2092
+ // Chart colours
2093
+ CHRED: "#f15469",
2094
+ CHGRN: "#68e456",
2095
+ CHYLW: "#f4da48",
2096
+ CHMGD: "#c545c3",
2097
+ CHMGF: "#d3a6e9",
2098
+ CHWHT: "#d4eaee",
2099
+ CHCOR: "#eb7d36",
2100
+ // Soundings
2101
+ SNDG1: "#7d898c",
2102
+ SNDG2: "#070707",
2103
+ DEPSC: "#525a5c",
2104
+ DEPCN: "#555555",
2105
+ // Utility / infrastructure
2106
+ UINFD: "#070707",
2107
+ UINFF: "#7d898c",
2108
+ UINFR: "#f15469",
2109
+ UINFG: "#68e456",
2110
+ UINFO: "#eb7d36",
2111
+ UINFB: "#3a78f0",
2112
+ UINFM: "#c545c3",
2113
+ UIBCK: "#d4eaee",
2114
+ UIBDR: "#7d898c",
2115
+ UIAFD: "#73b6ef",
2116
+ UIAFF: "#c9b97a",
2117
+ PIPLN: "#55aa00",
2118
+ // Outlines
2119
+ OUTLL: "#c9b97a",
2120
+ // Miscellaneous
2121
+ SCLBR: "#eb7d36",
2122
+ NINFO: "#eb7d36",
2123
+ ADINF: "#b29f34",
2124
+ RESGR: "#7d898c",
2125
+ SHIPS: "#070707",
2126
+ PSTRK: "#070707",
2127
+ SYTRK: "#7d898c",
2128
+ APLRT: "#eb7d36",
2129
+ CURSR: "#eb7d36",
2130
+ BKAJ2: "#232728",
2131
+ RES01: "#a3b4b7",
2132
+ RES02: "#a3b4b7",
2133
+ RES03: "#a3b4b7"
2064
2134
  };
2065
- var LITCHR_TABLE = nameAbbrTable("LITCHR");
2066
- var COLOUR_TABLE = Object.fromEntries(
2067
- Object.values(COLOUR_CODES).filter((c) => c.abbr !== void 0 && COLOUR_HEX[c.code] !== void 0).map((c) => [c.code, { name: c.name, abbr: c.abbr, hex: COLOUR_HEX[c.code] }])
2068
- );
2069
- var BOYSHP_TABLE = s57NameTable("BOYSHP");
2070
- var CATLAM_TABLE = s57NameTable("CATLAM");
2071
- var CATCAM_TABLE = s57NameTable("CATCAM");
2072
- var CATLMK_TABLE = s57NameTable("CATLMK");
2073
- var CATFOG_TABLE = s57NameTable("CATFOG");
2074
- var CATFOG_ABBR_TABLE = s57AbbrTable("CATFOG");
2075
- var CATFOG_NAUTOPHONE = Object.values(CATFOG_CODES).find(
2076
- (c) => c.name.toLowerCase() === "nautophone"
2077
- )?.code;
2078
- var CATLIT_TABLE = s57NameTable("CATLIT");
2079
- var TOPSHP_TABLE = s57NameTable("TOPSHP");
2080
- var STATUS_TABLE = s57NameTable("STATUS");
2081
- var MARSYS_TABLE = s57NameTable("MARSYS");
2082
- var LAYER_TYPE_MAP = {
2083
- BOYLAT: "Lateral Buoy",
2084
- BOYCAR: "Cardinal Buoy",
2085
- BOYSPP: "Special Purpose Buoy",
2086
- BOYISD: "Isolated Danger Buoy",
2087
- BOYSAW: "Safe Water Buoy",
2088
- BCNLAT: "Lateral Beacon",
2089
- BCNCAR: "Cardinal Beacon",
2090
- BCNSPP: "Special Purpose Beacon",
2091
- BCNISD: "Isolated Danger Beacon",
2092
- BCNSAW: "Safe Water Beacon",
2093
- LIGHTS: "Light",
2094
- DAYMAR: "Daymark",
2095
- FOGSIG: "Fog Signal",
2096
- RTPBCN: "Radar Transponder Beacon",
2097
- RDOSTA: "Radio/Radar Station",
2098
- TOPMAR: "Topmark",
2099
- MORFAC: "Mooring Facility",
2100
- LNDMRK: "Landmark",
2101
- SISTAT: "Signal Station",
2102
- PILBOP: "Pilot Boarding Place",
2103
- HRBFAC: "Harbour Facility",
2104
- SMCFAC: "Small Craft Facility",
2105
- CRANES: "Crane",
2106
- OFSPLF: "Offshore Platform",
2107
- ACHBRT: "Anchor Berth",
2108
- DISMAR: "Distance Mark",
2109
- LITVES: "Light Vessel",
2110
- LITFLT: "Light Float"
2135
+ var S52_DUSK = {
2136
+ NODTA: "#6b6b6b",
2137
+ DEPIT: "#006650",
2138
+ DEPVS: "#487085",
2139
+ DEPMS: "#608a9d",
2140
+ DEPMD: "#7ba6b8",
2141
+ DEPDW: "#95bfd0",
2142
+ LANDA: "#8a7d5e",
2143
+ LANDF: "#7a6a48",
2144
+ CHBRN: "#6e4e30",
2145
+ CHGRF: "#585858",
2146
+ CHGRD: "#3a3a3a",
2147
+ CHBLK: "#b0b0b0",
2148
+ CSTLN: "#b0b0b0",
2149
+ OUTLW: "#b0b0b0",
2150
+ BKAJ1: "#707070",
2151
+ TRFCD: "#a000a0",
2152
+ TRFCF: "#b060b0",
2153
+ RESBL: "#004a88",
2154
+ APTS1: "#a000a0",
2155
+ DNGHL: "#cc0000",
2156
+ ISHDG: "#990000",
2157
+ LITRD: "#bb0000",
2158
+ LITGN: "#009900",
2159
+ LITYW: "#c8b000",
2160
+ LITWT: "#d0d0d0",
2161
+ LITBL: "#004abb",
2162
+ LITOR: "#cc7000",
2163
+ RADHI: "#009940",
2164
+ RADLO: "#2d6330",
2165
+ PLRTE: "#a03020",
2166
+ ARPAT: "#306040",
2167
+ ISDNG: "#903090",
2168
+ CHRED: "#b05060",
2169
+ CHGRN: "#509040",
2170
+ CHYLW: "#b0a030",
2171
+ CHMGD: "#903090",
2172
+ CHMGF: "#905090",
2173
+ CHWHT: "#90a0a0",
2174
+ CHCOR: "#a06030",
2175
+ SNDG1: "#606060",
2176
+ SNDG2: "#b0b0b0",
2177
+ DEPSC: "#606060",
2178
+ DEPCN: "#2E2E2E",
2179
+ UINFD: "#b0b0b0",
2180
+ UINFF: "#606060",
2181
+ UINFR: "#b05060",
2182
+ UINFG: "#509040",
2183
+ UINFO: "#a06030",
2184
+ UINFB: "#304090",
2185
+ UINFM: "#903090",
2186
+ UIBCK: "#1a1a1a",
2187
+ UIBDR: "#606060",
2188
+ UIAFD: "#405060",
2189
+ UIAFF: "#605840",
2190
+ PIPLN: "#3d7a00",
2191
+ OUTLL: "#605840",
2192
+ SCLBR: "#a06030",
2193
+ NINFO: "#a06030",
2194
+ ADINF: "#807020",
2195
+ RESGR: "#606060",
2196
+ SHIPS: "#b0b0b0",
2197
+ PSTRK: "#b0b0b0",
2198
+ SYTRK: "#606060",
2199
+ APLRT: "#a06030",
2200
+ CURSR: "#cc0000",
2201
+ BKAJ2: "#252525",
2202
+ RES01: "#606060",
2203
+ RES02: "#606060",
2204
+ RES03: "#606060"
2111
2205
  };
2112
- var NAV_AID_QUERY_LAYERS = [
2113
- "enc-BOYLAT",
2114
- "enc-BOYCAR",
2115
- "enc-BOYSPP",
2116
- "enc-BOYISD",
2117
- "enc-BOYSAW",
2118
- "enc-BCNLAT",
2119
- "enc-BCNCAR",
2120
- "enc-BCNSPP",
2121
- "enc-BCNISD",
2122
- "enc-BCNSAW",
2123
- "enc-LIGHTS",
2124
- "enc-LIGHTS-point",
2125
- "enc-DAYMAR",
2126
- "enc-FOGSIG",
2127
- "enc-RTPBCN",
2128
- "enc-RDOSTA",
2129
- "enc-TOPMAR",
2130
- "enc-MORFAC",
2131
- "enc-MORFAC-point",
2132
- "enc-LNDMRK",
2133
- "enc-LNDMRK-point",
2134
- "enc-SISTAT",
2135
- "enc-SISTAT-point",
2136
- "enc-PILBOP",
2137
- "enc-PILBOP-point",
2138
- "enc-HRBFAC",
2139
- "enc-SMCFAC",
2140
- "enc-CRANES",
2141
- "enc-CRANES-point",
2142
- "enc-OFSPLF",
2206
+ var S52_NIGHT = {
2207
+ NODTA: "#3a3a32",
2208
+ DEPIT: "#003828",
2209
+ DEPVS: "#356577",
2210
+ DEPMS: "#265066",
2211
+ DEPMD: "#102e44",
2212
+ DEPDW: "#051a28",
2213
+ LANDA: "#3a3020",
2214
+ LANDF: "#302818",
2215
+ CHBRN: "#382410",
2216
+ CHGRF: "#2a2a22",
2217
+ CHGRD: "#1e1e18",
2218
+ CHBLK: "#c8aa78",
2219
+ CSTLN: "#c8aa78",
2220
+ OUTLW: "#c8aa78",
2221
+ BKAJ1: "#504830",
2222
+ TRFCD: "#6e006e",
2223
+ TRFCF: "#803880",
2224
+ RESBL: "#002a50",
2225
+ APTS1: "#6e006e",
2226
+ DNGHL: "#a00000",
2227
+ ISHDG: "#700000",
2228
+ LITRD: "#880000",
2229
+ LITGN: "#006600",
2230
+ LITYW: "#887800",
2231
+ LITWT: "#a09878",
2232
+ LITBL: "#003088",
2233
+ LITOR: "#884800",
2234
+ RADHI: "#005528",
2235
+ RADLO: "#1a3a20",
2236
+ PLRTE: "#6e2018",
2237
+ ARPAT: "#1a3028",
2238
+ ISDNG: "#481848",
2239
+ CHRED: "#682030",
2240
+ CHGRN: "#285020",
2241
+ CHYLW: "#685828",
2242
+ CHMGD: "#481848",
2243
+ CHMGF: "#482848",
2244
+ CHWHT: "#484840",
2245
+ CHCOR: "#683818",
2246
+ SNDG1: "#383028",
2247
+ SNDG2: "#c8aa78",
2248
+ DEPSC: "#383028",
2249
+ // ⚠ DELIBERATE DEVIATION from the published S-52 NIGHT value (#1f2223),
2250
+ // recorded here because `docs/validation/s52-spec-gap-matrix.json` tracks
2251
+ // this token as `value-differs` and the CHRTstyles programme is bringing
2252
+ // colours TO spec — this one moves further away, on purpose.
2253
+ //
2254
+ // Spec NIGHT DEPCN is near-black, which measures ~1.1:1 against this
2255
+ // palette's own deep water (#051a28). S-52's NIGHT table assumes a
2256
+ // dark-adapted eye in a darkened wheelhouse; on a phone or a laptop at
2257
+ // anchor it is not dim, it is absent — reported as "can't see bottom
2258
+ // contours in night mode only". A contour nobody can see fails the
2259
+ // standard's intent while matching its number.
2260
+ //
2261
+ // The palette already makes this trade elsewhere and in the same
2262
+ // direction: NIGHT's CHBLK/CSTLN/OUTLW are #c8aa78, light warm linework on
2263
+ // dark water. This value is that family, dimmed to stay subordinate to the
2264
+ // coastline. Revisit with the CHRTstyles programme, which owns the
2265
+ // spec-vs-legibility call across all 63 tokens.
2266
+ DEPCN: "#A68E84",
2267
+ UINFD: "#c8aa78",
2268
+ UINFF: "#383028",
2269
+ UINFR: "#682030",
2270
+ UINFG: "#285020",
2271
+ UINFO: "#683818",
2272
+ UINFB: "#182848",
2273
+ UINFM: "#481848",
2274
+ UIBCK: "#0a0a08",
2275
+ UIBDR: "#383028",
2276
+ UIAFD: "#182838",
2277
+ UIAFF: "#302818",
2278
+ PIPLN: "#284800",
2279
+ OUTLL: "#302818",
2280
+ SCLBR: "#683818",
2281
+ NINFO: "#683818",
2282
+ ADINF: "#483818",
2283
+ RESGR: "#383028",
2284
+ SHIPS: "#c8aa78",
2285
+ PSTRK: "#c8aa78",
2286
+ SYTRK: "#484030",
2287
+ APLRT: "#683818",
2288
+ CURSR: "#a00000",
2289
+ BKAJ2: "#0a0a08",
2290
+ RES01: "#383028",
2291
+ RES02: "#383028",
2292
+ RES03: "#383028"
2293
+ };
2294
+ var S52_COLORS = S52_DAY;
2295
+ var COLOR_TABLES = {
2296
+ DAY: S52_DAY,
2297
+ DUSK: S52_DUSK,
2298
+ NIGHT: S52_NIGHT
2299
+ };
2300
+ function getColorTable(name) {
2301
+ return COLOR_TABLES[name];
2302
+ }
2303
+ function resolveColor(token, table = "DAY") {
2304
+ const t = COLOR_TABLES[table];
2305
+ return t[token] ?? (table === "NIGHT" ? "#c8aa78" : "#000000");
2306
+ }
2307
+
2308
+ // src/interact/light-sigseq.ts
2309
+ var SIGSEQ_MAX_TERMS = 24;
2310
+ var SIGPER_TOLERANCE_SECONDS = 0.06;
2311
+ var LAMP_TICK_MS = 100;
2312
+ var TERM = /^\d+(?:\.\d+)?$/;
2313
+ function text(v) {
2314
+ if (v === null || v === void 0) return null;
2315
+ const s = String(v).trim();
2316
+ return s === "" ? null : s;
2317
+ }
2318
+ function parseSigseqTerms(sigseq, sigper) {
2319
+ const raw = text(sigseq);
2320
+ if (raw === null) return { refusal: "absent" };
2321
+ const terms = raw.split("+");
2322
+ if (terms.length === 0 || terms.length > SIGSEQ_MAX_TERMS) return { refusal: "malformed" };
2323
+ const phases = [];
2324
+ for (const term of terms) {
2325
+ const t = term.trim();
2326
+ if (t === "") return { refusal: "malformed" };
2327
+ const isEclipse = t.startsWith("(") && t.endsWith(")");
2328
+ const body = (isEclipse ? t.slice(1, -1) : t).trim();
2329
+ if (!TERM.test(body)) return { refusal: "malformed" };
2330
+ const seconds = Number(body);
2331
+ if (!(seconds > 0) || !Number.isFinite(seconds)) return { refusal: "nonPositiveDuration" };
2332
+ phases.push({ seconds, isOn: !isEclipse });
2333
+ }
2334
+ const sum = phases.reduce((t, p) => t + p.seconds, 0);
2335
+ if (!(sum > 0)) return { refusal: "nonPositiveDuration" };
2336
+ if (sigper !== null && sigper > 0) {
2337
+ if (Math.abs(sum - sigper) > SIGPER_TOLERANCE_SECONDS) return { refusal: "sigperMismatch" };
2338
+ return { phases, periodSeconds: sigper, checkedAgainstSigper: true };
2339
+ }
2340
+ return { phases, periodSeconds: sum, checkedAgainstSigper: false };
2341
+ }
2342
+ function rhythmProperties(parsed, onColoursHex) {
2343
+ if (parsed === null || parsed.phases.length === 0) return { rp: 0, rn: 0 };
2344
+ const out = { rp: parsed.periodSeconds, rn: parsed.phases.length };
2345
+ let end = 0;
2346
+ let onIndex = 0;
2347
+ parsed.phases.forEach((p, i) => {
2348
+ end += p.seconds;
2349
+ const k = i + 1;
2350
+ out[`r${k}`] = Math.round(end * 1e3) / 1e3;
2351
+ out[`o${k}`] = p.isOn ? 1 : 0;
2352
+ if (p.isOn) {
2353
+ out[`c${k}`] = onColoursHex[onIndex % Math.max(1, onColoursHex.length)] ?? "#ffffff";
2354
+ onIndex += 1;
2355
+ }
2356
+ });
2357
+ return out;
2358
+ }
2359
+ function phaseExpr(nowSeconds2) {
2360
+ return ["%", nowSeconds2, ["max", ["get", "rp"], 1e-3]];
2361
+ }
2362
+ function lampRhythmOpacity(nowSeconds2, base) {
2363
+ const cases = ["case", ["<=", ["get", "rn"], 0], base];
2364
+ for (let k = 1; k <= SIGSEQ_MAX_TERMS; k++) {
2365
+ cases.push(["<", ["var", "ph"], ["coalesce", ["get", `r${k}`], 1e9]], ["*", base, ["coalesce", ["get", `o${k}`], 1]]);
2366
+ }
2367
+ cases.push(0);
2368
+ return ["let", "ph", phaseExpr(nowSeconds2), cases];
2369
+ }
2370
+ function lampRhythmColour(nowSeconds2, fallback) {
2371
+ const cases = ["case", ["<=", ["get", "rn"], 0], fallback];
2372
+ for (let k = 1; k <= SIGSEQ_MAX_TERMS; k++) {
2373
+ cases.push(["<", ["var", "ph"], ["coalesce", ["get", `r${k}`], 1e9]], ["coalesce", ["get", `c${k}`], fallback]);
2374
+ }
2375
+ cases.push(fallback);
2376
+ return ["let", "ph", phaseExpr(nowSeconds2), cases];
2377
+ }
2378
+
2379
+ // src/interact/decode.ts
2380
+ function nameAbbrTable(acronym) {
2381
+ const names = s57NameTable(acronym);
2382
+ const abbrs = s57AbbrTable(acronym);
2383
+ const out = {};
2384
+ for (const [code, name] of Object.entries(names)) {
2385
+ const abbr = abbrs[Number(code)];
2386
+ if (abbr !== void 0) out[Number(code)] = { name, abbr };
2387
+ }
2388
+ return out;
2389
+ }
2390
+ var COLOUR_HEX = {
2391
+ 1: "#ffffff",
2392
+ 2: "#333333",
2393
+ 3: "#ef4444",
2394
+ 4: "#22c55e",
2395
+ 5: "#3b82f6",
2396
+ 6: "#eab308",
2397
+ 7: "#9ca3af",
2398
+ 8: "#92400e",
2399
+ 9: "#f59e0b",
2400
+ 10: "#8b5cf6",
2401
+ 11: "#f97316",
2402
+ 12: "#d946ef",
2403
+ 13: "#f472b6"
2404
+ };
2405
+ var LITCHR_TABLE = nameAbbrTable("LITCHR");
2406
+ var COLOUR_TABLE = Object.fromEntries(
2407
+ Object.values(COLOUR_CODES).filter((c) => c.abbr !== void 0 && COLOUR_HEX[c.code] !== void 0).map((c) => [c.code, { name: c.name, abbr: c.abbr, hex: COLOUR_HEX[c.code] }])
2408
+ );
2409
+ var BOYSHP_TABLE = s57NameTable("BOYSHP");
2410
+ var CATLAM_TABLE = s57NameTable("CATLAM");
2411
+ var CATCAM_TABLE = s57NameTable("CATCAM");
2412
+ var CATLMK_TABLE = s57NameTable("CATLMK");
2413
+ var CATFOG_TABLE = s57NameTable("CATFOG");
2414
+ var CATFOG_ABBR_TABLE = s57AbbrTable("CATFOG");
2415
+ var CATFOG_NAUTOPHONE = Object.values(CATFOG_CODES).find(
2416
+ (c) => c.name.toLowerCase() === "nautophone"
2417
+ )?.code;
2418
+ var CATLIT_TABLE = s57NameTable("CATLIT");
2419
+ var TOPSHP_TABLE = s57NameTable("TOPSHP");
2420
+ var STATUS_TABLE = s57NameTable("STATUS");
2421
+ var MARSYS_TABLE = s57NameTable("MARSYS");
2422
+ var LAYER_TYPE_MAP = {
2423
+ BOYLAT: "Lateral Buoy",
2424
+ BOYCAR: "Cardinal Buoy",
2425
+ BOYSPP: "Special Purpose Buoy",
2426
+ BOYISD: "Isolated Danger Buoy",
2427
+ BOYSAW: "Safe Water Buoy",
2428
+ BCNLAT: "Lateral Beacon",
2429
+ BCNCAR: "Cardinal Beacon",
2430
+ BCNSPP: "Special Purpose Beacon",
2431
+ BCNISD: "Isolated Danger Beacon",
2432
+ BCNSAW: "Safe Water Beacon",
2433
+ LIGHTS: "Light",
2434
+ DAYMAR: "Daymark",
2435
+ FOGSIG: "Fog Signal",
2436
+ RTPBCN: "Radar Transponder Beacon",
2437
+ RDOSTA: "Radio/Radar Station",
2438
+ TOPMAR: "Topmark",
2439
+ MORFAC: "Mooring Facility",
2440
+ LNDMRK: "Landmark",
2441
+ SISTAT: "Signal Station",
2442
+ PILBOP: "Pilot Boarding Place",
2443
+ HRBFAC: "Harbour Facility",
2444
+ SMCFAC: "Small Craft Facility",
2445
+ CRANES: "Crane",
2446
+ OFSPLF: "Offshore Platform",
2447
+ ACHBRT: "Anchor Berth",
2448
+ DISMAR: "Distance Mark",
2449
+ LITVES: "Light Vessel",
2450
+ LITFLT: "Light Float"
2451
+ };
2452
+ var NAV_AID_QUERY_LAYERS = [
2453
+ "enc-BOYLAT",
2454
+ "enc-BOYCAR",
2455
+ "enc-BOYSPP",
2456
+ "enc-BOYISD",
2457
+ "enc-BOYSAW",
2458
+ "enc-BCNLAT",
2459
+ "enc-BCNCAR",
2460
+ "enc-BCNSPP",
2461
+ "enc-BCNISD",
2462
+ "enc-BCNSAW",
2463
+ "enc-LIGHTS",
2464
+ "enc-LIGHTS-point",
2465
+ "enc-DAYMAR",
2466
+ "enc-FOGSIG",
2467
+ "enc-RTPBCN",
2468
+ "enc-RDOSTA",
2469
+ "enc-TOPMAR",
2470
+ "enc-MORFAC",
2471
+ "enc-MORFAC-point",
2472
+ "enc-LNDMRK",
2473
+ "enc-LNDMRK-point",
2474
+ "enc-SISTAT",
2475
+ "enc-SISTAT-point",
2476
+ "enc-PILBOP",
2477
+ "enc-PILBOP-point",
2478
+ "enc-HRBFAC",
2479
+ "enc-SMCFAC",
2480
+ "enc-CRANES",
2481
+ "enc-CRANES-point",
2482
+ "enc-OFSPLF",
2143
2483
  "enc-OFSPLF-point",
2144
2484
  "enc-ACHBRT",
2145
2485
  "enc-DISMAR",
@@ -2942,14 +3282,8 @@ function decodeNavAidProps(layerName, props, coords) {
2942
3282
  const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
2943
3283
  const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
2944
3284
  if (isSectorLight) {
2945
- sectors.push({
2946
- sectorStart: sectr1,
2947
- sectorEnd: sectr2,
2948
- colour: colourEntry?.name ?? "White",
2949
- colourHex: colourEntry?.hex ?? "#ffffff",
2950
- notation,
2951
- nominalRange
2952
- });
3285
+ const sec = sectorFromProps(props, sectr1, sectr2, "DAY", false);
3286
+ if (sec) sectors.push({ ...sec, notation });
2953
3287
  }
2954
3288
  return {
2955
3289
  name,
@@ -2967,6 +3301,7 @@ function decodeNavAidProps(layerName, props, coords) {
2967
3301
  lightCharacterFull: litchrEntry?.name ?? null,
2968
3302
  period: sigper,
2969
3303
  group,
3304
+ sigseq: props.SIGSEQ != null && String(props.SIGSEQ).trim() !== "" ? String(props.SIGSEQ).trim() : null,
2970
3305
  fogSignal: fogSignal2,
2971
3306
  fogSignalAbbr,
2972
3307
  radarReflector,
@@ -2997,6 +3332,18 @@ function buildNotation(litchrAbbr, colourAbbrs, sigper, group) {
2997
3332
  function buildFlashPattern(decoded) {
2998
3333
  const litchr = decoded.lightCharacter;
2999
3334
  if (!litchr) return null;
3335
+ const charted = parseSigseqTerms(decoded.sigseq, decoded.period);
3336
+ if (!("refusal" in charted)) {
3337
+ const colours2 = decoded.colourHexList.length > 0 ? decoded.colourHexList : [decoded.lightColourHex];
3338
+ let onIndex = 0;
3339
+ const chartedPhases = charted.phases.map((p) => {
3340
+ if (!p.isOn) return { on: false, duration: p.seconds * 1e3 };
3341
+ const color = colours2[onIndex % colours2.length];
3342
+ onIndex += 1;
3343
+ return { on: true, duration: p.seconds * 1e3, color };
3344
+ });
3345
+ return { phases: chartedPhases, totalCycle: charted.periodSeconds * 1e3, notation: decoded.notation, timing: "charted" };
3346
+ }
3000
3347
  const period = decoded.period && decoded.period > 0 ? decoded.period * 1e3 : 6e3;
3001
3348
  const groups = decoded.group ?? [1];
3002
3349
  const totalFlashes = groups.reduce((a, b) => a + b, 0);
@@ -3165,7 +3512,7 @@ function buildFlashPattern(decoded) {
3165
3512
  }
3166
3513
  }
3167
3514
  const totalCycle = phases.reduce((sum, p) => sum + p.duration, 0);
3168
- return { phases, totalCycle, notation };
3515
+ return { timing: "invented", phases, totalCycle, notation };
3169
3516
  }
3170
3517
  function uncoveredSectorArcs(sectors) {
3171
3518
  const norm = (d) => (d % 360 + 360) % 360;
@@ -3204,7 +3551,70 @@ function uncoveredSectorArcs(sectors) {
3204
3551
  }
3205
3552
  return gaps.filter(([a, b]) => Math.abs(b - a) >= 0.05 || b < a).map(([a, b]) => ({ start: norm(a), end: norm(b) }));
3206
3553
  }
3207
- function buildSectorsFromFeatures(features) {
3554
+ function parseCodes(val) {
3555
+ if (val == null) return [];
3556
+ const str = String(val).trim();
3557
+ let parts;
3558
+ if (str.startsWith("[")) {
3559
+ try {
3560
+ const arr = JSON.parse(str);
3561
+ parts = Array.isArray(arr) ? arr.map(String) : [];
3562
+ } catch {
3563
+ parts = str.split(/[,;]/);
3564
+ }
3565
+ } else {
3566
+ parts = str.split(/[,;]/);
3567
+ }
3568
+ return parts.map((p) => Number(p.trim())).filter((n) => !isNaN(n));
3569
+ }
3570
+ var OBSCURED_LITVIS = /* @__PURE__ */ new Set([3, 7, 8]);
3571
+ function sectorArcColourToken(colourCodes, obscured) {
3572
+ if (obscured) return "CHBRN";
3573
+ const has = (c) => colourCodes.includes(c);
3574
+ if (colourCodes.length === 1) {
3575
+ if (has(3)) return "LITRD";
3576
+ if (has(4)) return "LITGN";
3577
+ if (has(1) || has(6) || has(11)) return "LITYW";
3578
+ return "CHMGD";
3579
+ }
3580
+ if (colourCodes.length === 2) {
3581
+ if (has(1) && has(3)) return "LITRD";
3582
+ if (has(1) && has(4)) return "LITGN";
3583
+ return "CHMGD";
3584
+ }
3585
+ return "CHMGD";
3586
+ }
3587
+ function sectorFromProps(props, s1, s2, ct, allRound) {
3588
+ const colourCodes = parseCodes(props.COLOUR);
3589
+ const entries = colourCodes.map((c) => COLOUR_TABLE[c]).filter((e) => e != null);
3590
+ if (allRound && entries.length === 0) return null;
3591
+ const obscured = parseCodes(props.LITVIS).some((v) => OBSCURED_LITVIS.has(v));
3592
+ const litchrCode = parseCode(props.LITCHR);
3593
+ const le = litchrCode ? LITCHR_TABLE[litchrCode] : null;
3594
+ const sigper = props.SIGPER != null ? Number(props.SIGPER) : null;
3595
+ const group = parseGroup(props.SIGGRP);
3596
+ const base = buildNotation(le?.abbr ?? null, entries.length > 0 ? [entries.map((e) => e.abbr).join("")] : [], sigper, group);
3597
+ const notation = obscured && base ? `${base} (obsc)` : base;
3598
+ const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
3599
+ const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
3600
+ const arcColourToken = sectorArcColourToken(colourCodes, obscured);
3601
+ return {
3602
+ sectorStart: s1,
3603
+ sectorEnd: s2,
3604
+ colour: entries[0]?.name ?? "White",
3605
+ colourHex: entries[0]?.hex ?? "#ffffff",
3606
+ notation,
3607
+ nominalRange,
3608
+ colourCodes,
3609
+ arcColourToken,
3610
+ arcColourHex: resolveColor(arcColourToken, ct),
3611
+ obscured,
3612
+ sigseq: props.SIGSEQ != null && String(props.SIGSEQ).trim() !== "" ? String(props.SIGSEQ).trim() : null,
3613
+ sigper: sigper != null && !isNaN(sigper) && sigper > 0 ? sigper : null,
3614
+ ...allRound ? { allRound: true } : {}
3615
+ };
3616
+ }
3617
+ function buildSectorsFromFeatures(features, ct = "DAY") {
3208
3618
  const sectors = [];
3209
3619
  const allRound = [];
3210
3620
  for (const f of features) {
@@ -3215,47 +3625,15 @@ function buildSectorsFromFeatures(features) {
3215
3625
  if (props.SECTR1 == null && props.SECTR2 == null && parseCode(props.COLOUR) != null) allRound.push(props);
3216
3626
  continue;
3217
3627
  }
3218
- const colourCode = parseCode(props.COLOUR);
3219
- const ce = colourCode ? COLOUR_TABLE[colourCode] : null;
3220
- const litchrCode = parseCode(props.LITCHR);
3221
- const le = litchrCode ? LITCHR_TABLE[litchrCode] : null;
3222
- const sigper = props.SIGPER != null ? Number(props.SIGPER) : null;
3223
- const group = parseGroup(props.SIGGRP);
3224
- const notation = buildNotation(le?.abbr ?? null, ce ? [ce.abbr] : [], sigper, group);
3225
- const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
3226
- const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
3227
- sectors.push({
3228
- sectorStart: s1,
3229
- sectorEnd: s2,
3230
- colour: ce?.name ?? "White",
3231
- colourHex: ce?.hex ?? "#ffffff",
3232
- notation,
3233
- nominalRange
3234
- });
3628
+ const sec = sectorFromProps(props, s1, s2, ct, false);
3629
+ if (sec) sectors.push(sec);
3235
3630
  }
3236
3631
  if (sectors.length > 0 && allRound.length > 0) {
3237
3632
  const gaps = uncoveredSectorArcs(sectors);
3238
3633
  for (const props of allRound) {
3239
- const colourCode = parseCode(props.COLOUR);
3240
- const ce = colourCode ? COLOUR_TABLE[colourCode] : null;
3241
- if (!ce) continue;
3242
- const litchrCode = parseCode(props.LITCHR);
3243
- const le = litchrCode ? LITCHR_TABLE[litchrCode] : null;
3244
- const sigper = props.SIGPER != null ? Number(props.SIGPER) : null;
3245
- const group = parseGroup(props.SIGGRP);
3246
- const notation = buildNotation(le?.abbr ?? null, [ce.abbr], sigper, group);
3247
- const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
3248
- const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
3249
3634
  for (const gap of gaps) {
3250
- sectors.push({
3251
- sectorStart: gap.start,
3252
- sectorEnd: gap.end,
3253
- colour: ce.name,
3254
- colourHex: ce.hex,
3255
- notation,
3256
- nominalRange,
3257
- allRound: true
3258
- });
3635
+ const sec = sectorFromProps(props, gap.start, gap.end, ct, true);
3636
+ if (sec) sectors.push(sec);
3259
3637
  }
3260
3638
  }
3261
3639
  }
@@ -3305,12 +3683,16 @@ function buildSectorArcGeoJSON(center, startBearing, endBearing, radiusNm = 0.5,
3305
3683
  }
3306
3684
  function buildSectorArcsFC(center, sectors, radiusNm = 0.4, innerRadiusFrac = 0.92) {
3307
3685
  const innerNm = innerRadiusFrac > 0 ? radiusNm * innerRadiusFrac : 0;
3308
- const features = sectors.map((sec, i) => {
3686
+ const drawn = sectors.filter((sec) => !sec.allRound);
3687
+ const features = drawn.map((sec, i) => {
3309
3688
  const arc = buildSectorArcGeoJSON(center, sec.sectorStart, sec.sectorEnd, radiusNm, innerNm);
3310
3689
  arc.properties = {
3311
3690
  sectorIndex: i,
3312
3691
  colour: sec.colour,
3313
3692
  colourHex: sec.colourHex,
3693
+ arcColourHex: sec.arcColourHex,
3694
+ arcColourToken: sec.arcColourToken,
3695
+ obscured: sec.obscured,
3314
3696
  notation: sec.notation,
3315
3697
  sectorStart: sec.sectorStart,
3316
3698
  sectorEnd: sec.sectorEnd,
@@ -3323,7 +3705,7 @@ function buildSectorArcsFC(center, sectors, radiusNm = 0.4, innerRadiusFrac = 0.
3323
3705
  const seenBearings = /* @__PURE__ */ new Set();
3324
3706
  const TICK_OUTER_OVERHANG = 1.05;
3325
3707
  const TICK_INNER_OVERHANG = 0.93;
3326
- for (const sec of sectors) {
3708
+ for (const sec of drawn) {
3327
3709
  for (const bearing of [sec.sectorStart, sec.sectorEnd]) {
3328
3710
  const rad = sectorLimitToArcBearing(bearing) * Math.PI / 180;
3329
3711
  const radiusDeg = radiusNm / 60;
@@ -4193,10 +4575,10 @@ function ensureStyles(doc) {
4193
4575
  doc.head.appendChild(style);
4194
4576
  styleInjected = true;
4195
4577
  }
4196
- function el(doc, tag, className, text) {
4578
+ function el(doc, tag, className, text2) {
4197
4579
  const node = doc.createElement(tag);
4198
4580
  node.className = className;
4199
- if (text != null) node.textContent = text;
4581
+ if (text2 != null) node.textContent = text2;
4200
4582
  return node;
4201
4583
  }
4202
4584
  function animateDot(dot, pattern) {
@@ -4356,466 +4738,209 @@ ${notice.dataCurrency}`;
4356
4738
  if (position === "static" || position === "") {
4357
4739
  container.style.position = "relative";
4358
4740
  }
4359
- container.appendChild(root);
4360
- this.root = root;
4361
- this.lngLat = lngLat;
4362
- this.place();
4363
- this.map.on("move", this.reposition);
4364
- }
4365
- place() {
4366
- if (!this.root || !this.lngLat) return;
4367
- const { x, y } = this.map.project(this.lngLat);
4368
- this.root.style.left = `${x}px`;
4369
- this.root.style.top = `${y}px`;
4370
- }
4371
- close() {
4372
- if (this.stopAnimation) {
4373
- this.stopAnimation();
4374
- this.stopAnimation = null;
4375
- }
4376
- if (this.root) {
4377
- this.map.off("move", this.reposition);
4378
- this.root.remove();
4379
- this.root = null;
4380
- }
4381
- this.lngLat = null;
4382
- }
4383
- };
4384
-
4385
- // src/interact/index.ts
4386
- var InteractClient = class {
4387
- constructor(http) {
4388
- this.http = http;
4389
- this.entitlementClient = new EntitlementClient(http);
4390
- }
4391
- entitlementClient;
4392
- /**
4393
- * Check the CHRTinteract entitlement for this API key (server-side, plan
4394
- * derived, cached per client). Throws `InteractError` with code
4395
- * `interact_not_enabled` for Free-plan teams.
4396
- */
4397
- async entitlement() {
4398
- return this.entitlementClient.check();
4399
- }
4400
- /**
4401
- * Decode a chart feature (from `queryRenderedFeatures`, the features API,
4402
- * or plain `{ layer, properties }`) into a typed {@link InspectResult}.
4403
- *
4404
- * The first call performs the server-side entitlement check. With
4405
- * `{ enrich: true }` the result is upgraded with full S-57 properties and
4406
- * source-cell provenance (edition / update / issue date) via the features
4407
- * API — metered against the key's `featureCalls` quota.
4408
- *
4409
- * @example
4410
- * const result = await chrt.interact.inspect(feature);
4411
- * if (result.kind === "navaid") console.log(result.characteristic); // "Fl R 4s"
4412
- */
4413
- async inspect(feature, opts) {
4414
- await this.entitlementClient.check();
4415
- const result = buildInspectResult(feature, opts);
4416
- if (opts?.enrich) {
4417
- return enrichResult(this.http, result, opts.signal);
4418
- }
4419
- return result;
4420
- }
4421
- /**
4422
- * Collect EVERY inspectable feature under a point on a CHRT map —
4423
- * deduplicated across tile boundaries by S-57 identity (`LNAM`) and
4424
- * ordered by class priority (point targets > lines > areas, safety
4425
- * classes first within each band). Area features never preempt the buoy
4426
- * on top of them but always stay selectable: a click on open water
4427
- * resolves to its DEPARE.
4428
- *
4429
- * `point` is a screen pixel point (`e.point` from a map click) or
4430
- * `[lng, lat]`. Point/line targets are picked up within
4431
- * `opts.tolerance` px (default 8); polygon candidates require the exact
4432
- * point to fall inside them.
4433
- *
4434
- * The first call performs the server-side entitlement check.
4435
- *
4436
- * @example
4437
- * const { candidates, select } = await chrt.interact.inspectAll(map, e.point);
4438
- * const result = await select(candidates[0]); // full decode (+ merge)
4439
- */
4440
- async inspectAll(map, point, opts = {}) {
4441
- if (!map || typeof map.queryRenderedFeatures !== "function") {
4442
- throw new InteractError(
4443
- "unexpected_response",
4444
- "inspectAll(): expected a MapLibre / Mapbox GL map instance"
4445
- );
4446
- }
4447
- await this.entitlementClient.check();
4448
- const screen = Array.isArray(point) ? map.project([point[0], point[1]]) : point;
4449
- const lngLat = Array.isArray(point) ? { lng: point[0], lat: point[1] } : map.unproject?.([point.x, point.y]) ?? null;
4450
- const at = { point: screen, lngLat };
4451
- const candidates = collectClickCandidates(map, screen, opts);
4452
- const http = this.http;
4453
- return {
4454
- candidates,
4455
- select: async (candidate, selectOpts) => {
4456
- const result = decodeCandidate(map, candidate, at, opts);
4457
- if (selectOpts?.enrich) {
4458
- return enrichResult(http, result, selectOpts.signal);
4459
- }
4460
- return result;
4461
- }
4462
- };
4463
- }
4464
- /**
4465
- * Attach click-to-inspect to a MapLibre / Mapbox GL map rendering CHRT
4466
- * tiles. Resolves once the server-side entitlement check passes; rejects
4467
- * with `InteractError` (`interact_not_enabled` on Free plans) otherwise —
4468
- * no listeners are wired on denial.
4469
- *
4470
- * Headless-first: pass `onInspect` to receive every decoded
4471
- * {@link InspectResult} and render your own UI. Without callbacks, the
4472
- * built-in minimal popup is shown (opt back in explicitly with
4473
- * `popup: true` to have both).
4474
- *
4475
- * A click that hits SEVERAL features (deduplicated by S-57 identity)
4476
- * disambiguates instead of silently taking the topmost:
4477
- * - `onCandidates` provided → it receives the priority-ordered list;
4478
- * resolve a choice with `ctx.select(candidate)`.
4479
- * - built-in popup → shows a candidate list; picking a row opens the
4480
- * result card.
4481
- * - `onInspect` only → the top candidate is inspected immediately, and
4482
- * clicking the SAME spot again cycles to the next candidate — so an
4483
- * area feature under a buoy is reachable with a second click.
4484
- *
4485
- * @example
4486
- * const handle = await chrt.interact.attach(map, {
4487
- * onInspect: (result) => console.log(result.title, result.characteristic),
4488
- * });
4489
- * // later: handle.detach();
4490
- */
4491
- async attach(map, opts = {}) {
4492
- if (!map || typeof map.queryRenderedFeatures !== "function" || typeof map.on !== "function") {
4493
- throw new InteractError(
4494
- "unexpected_response",
4495
- "attach(): expected a MapLibre / Mapbox GL map instance"
4496
- );
4497
- }
4498
- await this.entitlementClient.check();
4499
- const showPopup = opts.popup ?? (!opts.onInspect && !opts.onCandidates);
4500
- const popup = showPopup ? new InteractPopup(map) : null;
4501
- const http = this.http;
4502
- let cycle = null;
4503
- const samePoint = (a, b) => {
4504
- const pad = opts.tolerance ?? DEFAULT_CLICK_TOLERANCE;
4505
- return Math.abs(a.x - b.x) <= pad && Math.abs(a.y - b.y) <= pad;
4506
- };
4507
- const select = (candidate, e) => {
4508
- const result = decodeCandidate(map, candidate, e, opts);
4509
- const finish = (r) => {
4510
- popup?.show(r, r.coordinates ?? [e.lngLat.lng, e.lngLat.lat]);
4511
- opts.onInspect?.(r, { lngLat: e.lngLat, point: e.point, feature: candidate.feature });
4512
- return r;
4513
- };
4514
- if (opts.enrich) {
4515
- return enrichResult(http, result).then(finish).catch(() => finish(result));
4516
- }
4517
- return Promise.resolve(finish(result));
4518
- };
4519
- const clickHandler = (e) => {
4520
- const candidates = collectClickCandidates(map, e.point, opts);
4521
- if (candidates.length === 0) {
4522
- cycle = null;
4523
- popup?.close();
4524
- opts.onMiss?.({ lngLat: e.lngLat });
4525
- return;
4526
- }
4527
- if (candidates.length === 1) {
4528
- cycle = null;
4529
- void select(candidates[0], e);
4530
- return;
4531
- }
4532
- if (opts.onCandidates || popup) {
4533
- cycle = null;
4534
- if (popup) {
4535
- popup.showList(candidates, [e.lngLat.lng, e.lngLat.lat], (c) => void select(c, e));
4536
- }
4537
- opts.onCandidates?.(candidates, {
4538
- lngLat: e.lngLat,
4539
- point: e.point,
4540
- select: (c) => select(c, e)
4541
- });
4542
- return;
4543
- }
4544
- const key = candidates.map((c) => c.identity ?? c.layerId).join("/");
4545
- if (cycle && cycle.key === key && samePoint(cycle.point, e.point)) {
4546
- cycle.index = (cycle.index + 1) % candidates.length;
4547
- cycle.point = e.point;
4548
- } else {
4549
- cycle = { key, point: e.point, index: 0 };
4550
- }
4551
- void select(candidates[cycle.index], e);
4552
- };
4553
- map.on("click", clickHandler);
4554
- return {
4555
- detach: () => {
4556
- map.off("click", clickHandler);
4557
- popup?.close();
4558
- }
4559
- };
4560
- }
4561
- };
4562
-
4563
- // src/atlas/s52-colors.ts
4564
- var S52_DAY = {
4565
- // Depth
4566
- NODTA: "#a4a4a4",
4567
- DEPIT: "#009678",
4568
- DEPVS: "#7fbfdf",
4569
- DEPMS: "#a7d4e8",
4570
- DEPMD: "#cae7f3",
4571
- DEPDW: "#ecf7fc",
4572
- // Land / topography
4573
- LANDA: "#e6d1a1",
4574
- LANDF: "#c8b078",
4575
- CHBRN: "#ab7948",
4576
- CHGRF: "#878787",
4577
- CHGRD: "#555555",
4578
- // Chart symbology
4579
- CHBLK: "#000000",
4580
- CSTLN: "#000000",
4581
- OUTLW: "#000000",
4582
- BKAJ1: "#4d4d4d",
4583
- // Navigation / traffic
4584
- TRFCD: "#d200d2",
4585
- TRFCF: "#e680e6",
4586
- RESBL: "#0064b4",
4587
- APTS1: "#d200d2",
4588
- // Danger / hazard
4589
- DNGHL: "#ff0000",
4590
- ISHDG: "#cc0000",
4591
- // Lights
4592
- LITRD: "#ee0000",
4593
- LITGN: "#00c800",
4594
- LITYW: "#fddc00",
4595
- LITWT: "#ffffff",
4596
- LITBL: "#0064ff",
4597
- LITOR: "#ff8c00",
4598
- // Buoys / marks
4599
- RADHI: "#68e456",
4600
- RADLO: "#3f8a34",
4601
- PLRTE: "#dc4025",
4602
- ARPAT: "#3fa56f",
4603
- ISDNG: "#c545c3",
4604
- // Chart colours
4605
- CHRED: "#f15469",
4606
- CHGRN: "#68e456",
4607
- CHYLW: "#f4da48",
4608
- CHMGD: "#c545c3",
4609
- CHMGF: "#d3a6e9",
4610
- CHWHT: "#d4eaee",
4611
- CHCOR: "#eb7d36",
4612
- // Soundings
4613
- SNDG1: "#7d898c",
4614
- SNDG2: "#070707",
4615
- DEPSC: "#525a5c",
4616
- DEPCN: "#555555",
4617
- // Utility / infrastructure
4618
- UINFD: "#070707",
4619
- UINFF: "#7d898c",
4620
- UINFR: "#f15469",
4621
- UINFG: "#68e456",
4622
- UINFO: "#eb7d36",
4623
- UINFB: "#3a78f0",
4624
- UINFM: "#c545c3",
4625
- UIBCK: "#d4eaee",
4626
- UIBDR: "#7d898c",
4627
- UIAFD: "#73b6ef",
4628
- UIAFF: "#c9b97a",
4629
- PIPLN: "#55aa00",
4630
- // Outlines
4631
- OUTLL: "#c9b97a",
4632
- // Miscellaneous
4633
- SCLBR: "#eb7d36",
4634
- NINFO: "#eb7d36",
4635
- ADINF: "#b29f34",
4636
- RESGR: "#7d898c",
4637
- SHIPS: "#070707",
4638
- PSTRK: "#070707",
4639
- SYTRK: "#7d898c",
4640
- APLRT: "#eb7d36",
4641
- CURSR: "#eb7d36",
4642
- BKAJ2: "#232728",
4643
- RES01: "#a3b4b7",
4644
- RES02: "#a3b4b7",
4645
- RES03: "#a3b4b7"
4646
- };
4647
- var S52_DUSK = {
4648
- NODTA: "#6b6b6b",
4649
- DEPIT: "#006650",
4650
- DEPVS: "#487085",
4651
- DEPMS: "#608a9d",
4652
- DEPMD: "#7ba6b8",
4653
- DEPDW: "#95bfd0",
4654
- LANDA: "#8a7d5e",
4655
- LANDF: "#7a6a48",
4656
- CHBRN: "#6e4e30",
4657
- CHGRF: "#585858",
4658
- CHGRD: "#3a3a3a",
4659
- CHBLK: "#b0b0b0",
4660
- CSTLN: "#b0b0b0",
4661
- OUTLW: "#b0b0b0",
4662
- BKAJ1: "#707070",
4663
- TRFCD: "#a000a0",
4664
- TRFCF: "#b060b0",
4665
- RESBL: "#004a88",
4666
- APTS1: "#a000a0",
4667
- DNGHL: "#cc0000",
4668
- ISHDG: "#990000",
4669
- LITRD: "#bb0000",
4670
- LITGN: "#009900",
4671
- LITYW: "#c8b000",
4672
- LITWT: "#d0d0d0",
4673
- LITBL: "#004abb",
4674
- LITOR: "#cc7000",
4675
- RADHI: "#009940",
4676
- RADLO: "#2d6330",
4677
- PLRTE: "#a03020",
4678
- ARPAT: "#306040",
4679
- ISDNG: "#903090",
4680
- CHRED: "#b05060",
4681
- CHGRN: "#509040",
4682
- CHYLW: "#b0a030",
4683
- CHMGD: "#903090",
4684
- CHMGF: "#905090",
4685
- CHWHT: "#90a0a0",
4686
- CHCOR: "#a06030",
4687
- SNDG1: "#606060",
4688
- SNDG2: "#b0b0b0",
4689
- DEPSC: "#606060",
4690
- DEPCN: "#2E2E2E",
4691
- UINFD: "#b0b0b0",
4692
- UINFF: "#606060",
4693
- UINFR: "#b05060",
4694
- UINFG: "#509040",
4695
- UINFO: "#a06030",
4696
- UINFB: "#304090",
4697
- UINFM: "#903090",
4698
- UIBCK: "#1a1a1a",
4699
- UIBDR: "#606060",
4700
- UIAFD: "#405060",
4701
- UIAFF: "#605840",
4702
- PIPLN: "#3d7a00",
4703
- OUTLL: "#605840",
4704
- SCLBR: "#a06030",
4705
- NINFO: "#a06030",
4706
- ADINF: "#807020",
4707
- RESGR: "#606060",
4708
- SHIPS: "#b0b0b0",
4709
- PSTRK: "#b0b0b0",
4710
- SYTRK: "#606060",
4711
- APLRT: "#a06030",
4712
- CURSR: "#cc0000",
4713
- BKAJ2: "#252525",
4714
- RES01: "#606060",
4715
- RES02: "#606060",
4716
- RES03: "#606060"
4717
- };
4718
- var S52_NIGHT = {
4719
- NODTA: "#3a3a32",
4720
- DEPIT: "#003828",
4721
- DEPVS: "#356577",
4722
- DEPMS: "#265066",
4723
- DEPMD: "#102e44",
4724
- DEPDW: "#051a28",
4725
- LANDA: "#3a3020",
4726
- LANDF: "#302818",
4727
- CHBRN: "#382410",
4728
- CHGRF: "#2a2a22",
4729
- CHGRD: "#1e1e18",
4730
- CHBLK: "#c8aa78",
4731
- CSTLN: "#c8aa78",
4732
- OUTLW: "#c8aa78",
4733
- BKAJ1: "#504830",
4734
- TRFCD: "#6e006e",
4735
- TRFCF: "#803880",
4736
- RESBL: "#002a50",
4737
- APTS1: "#6e006e",
4738
- DNGHL: "#a00000",
4739
- ISHDG: "#700000",
4740
- LITRD: "#880000",
4741
- LITGN: "#006600",
4742
- LITYW: "#887800",
4743
- LITWT: "#a09878",
4744
- LITBL: "#003088",
4745
- LITOR: "#884800",
4746
- RADHI: "#005528",
4747
- RADLO: "#1a3a20",
4748
- PLRTE: "#6e2018",
4749
- ARPAT: "#1a3028",
4750
- ISDNG: "#481848",
4751
- CHRED: "#682030",
4752
- CHGRN: "#285020",
4753
- CHYLW: "#685828",
4754
- CHMGD: "#481848",
4755
- CHMGF: "#482848",
4756
- CHWHT: "#484840",
4757
- CHCOR: "#683818",
4758
- SNDG1: "#383028",
4759
- SNDG2: "#c8aa78",
4760
- DEPSC: "#383028",
4761
- // ⚠ DELIBERATE DEVIATION from the published S-52 NIGHT value (#1f2223),
4762
- // recorded here because `docs/validation/s52-spec-gap-matrix.json` tracks
4763
- // this token as `value-differs` and the CHRTstyles programme is bringing
4764
- // colours TO spec — this one moves further away, on purpose.
4765
- //
4766
- // Spec NIGHT DEPCN is near-black, which measures ~1.1:1 against this
4767
- // palette's own deep water (#051a28). S-52's NIGHT table assumes a
4768
- // dark-adapted eye in a darkened wheelhouse; on a phone or a laptop at
4769
- // anchor it is not dim, it is absent — reported as "can't see bottom
4770
- // contours in night mode only". A contour nobody can see fails the
4771
- // standard's intent while matching its number.
4772
- //
4773
- // The palette already makes this trade elsewhere and in the same
4774
- // direction: NIGHT's CHBLK/CSTLN/OUTLW are #c8aa78, light warm linework on
4775
- // dark water. This value is that family, dimmed to stay subordinate to the
4776
- // coastline. Revisit with the CHRTstyles programme, which owns the
4777
- // spec-vs-legibility call across all 63 tokens.
4778
- DEPCN: "#A68E84",
4779
- UINFD: "#c8aa78",
4780
- UINFF: "#383028",
4781
- UINFR: "#682030",
4782
- UINFG: "#285020",
4783
- UINFO: "#683818",
4784
- UINFB: "#182848",
4785
- UINFM: "#481848",
4786
- UIBCK: "#0a0a08",
4787
- UIBDR: "#383028",
4788
- UIAFD: "#182838",
4789
- UIAFF: "#302818",
4790
- PIPLN: "#284800",
4791
- OUTLL: "#302818",
4792
- SCLBR: "#683818",
4793
- NINFO: "#683818",
4794
- ADINF: "#483818",
4795
- RESGR: "#383028",
4796
- SHIPS: "#c8aa78",
4797
- PSTRK: "#c8aa78",
4798
- SYTRK: "#484030",
4799
- APLRT: "#683818",
4800
- CURSR: "#a00000",
4801
- BKAJ2: "#0a0a08",
4802
- RES01: "#383028",
4803
- RES02: "#383028",
4804
- RES03: "#383028"
4741
+ container.appendChild(root);
4742
+ this.root = root;
4743
+ this.lngLat = lngLat;
4744
+ this.place();
4745
+ this.map.on("move", this.reposition);
4746
+ }
4747
+ place() {
4748
+ if (!this.root || !this.lngLat) return;
4749
+ const { x, y } = this.map.project(this.lngLat);
4750
+ this.root.style.left = `${x}px`;
4751
+ this.root.style.top = `${y}px`;
4752
+ }
4753
+ close() {
4754
+ if (this.stopAnimation) {
4755
+ this.stopAnimation();
4756
+ this.stopAnimation = null;
4757
+ }
4758
+ if (this.root) {
4759
+ this.map.off("move", this.reposition);
4760
+ this.root.remove();
4761
+ this.root = null;
4762
+ }
4763
+ this.lngLat = null;
4764
+ }
4805
4765
  };
4806
- var S52_COLORS = S52_DAY;
4807
- var COLOR_TABLES = {
4808
- DAY: S52_DAY,
4809
- DUSK: S52_DUSK,
4810
- NIGHT: S52_NIGHT
4766
+
4767
+ // src/interact/index.ts
4768
+ var InteractClient = class {
4769
+ constructor(http) {
4770
+ this.http = http;
4771
+ this.entitlementClient = new EntitlementClient(http);
4772
+ }
4773
+ entitlementClient;
4774
+ /**
4775
+ * Check the CHRTinteract entitlement for this API key (server-side, plan
4776
+ * derived, cached per client). Throws `InteractError` with code
4777
+ * `interact_not_enabled` for Free-plan teams.
4778
+ */
4779
+ async entitlement() {
4780
+ return this.entitlementClient.check();
4781
+ }
4782
+ /**
4783
+ * Decode a chart feature (from `queryRenderedFeatures`, the features API,
4784
+ * or plain `{ layer, properties }`) into a typed {@link InspectResult}.
4785
+ *
4786
+ * The first call performs the server-side entitlement check. With
4787
+ * `{ enrich: true }` the result is upgraded with full S-57 properties and
4788
+ * source-cell provenance (edition / update / issue date) via the features
4789
+ * API — metered against the key's `featureCalls` quota.
4790
+ *
4791
+ * @example
4792
+ * const result = await chrt.interact.inspect(feature);
4793
+ * if (result.kind === "navaid") console.log(result.characteristic); // "Fl R 4s"
4794
+ */
4795
+ async inspect(feature, opts) {
4796
+ await this.entitlementClient.check();
4797
+ const result = buildInspectResult(feature, opts);
4798
+ if (opts?.enrich) {
4799
+ return enrichResult(this.http, result, opts.signal);
4800
+ }
4801
+ return result;
4802
+ }
4803
+ /**
4804
+ * Collect EVERY inspectable feature under a point on a CHRT map —
4805
+ * deduplicated across tile boundaries by S-57 identity (`LNAM`) and
4806
+ * ordered by class priority (point targets > lines > areas, safety
4807
+ * classes first within each band). Area features never preempt the buoy
4808
+ * on top of them but always stay selectable: a click on open water
4809
+ * resolves to its DEPARE.
4810
+ *
4811
+ * `point` is a screen pixel point (`e.point` from a map click) or
4812
+ * `[lng, lat]`. Point/line targets are picked up within
4813
+ * `opts.tolerance` px (default 8); polygon candidates require the exact
4814
+ * point to fall inside them.
4815
+ *
4816
+ * The first call performs the server-side entitlement check.
4817
+ *
4818
+ * @example
4819
+ * const { candidates, select } = await chrt.interact.inspectAll(map, e.point);
4820
+ * const result = await select(candidates[0]); // full decode (+ merge)
4821
+ */
4822
+ async inspectAll(map, point, opts = {}) {
4823
+ if (!map || typeof map.queryRenderedFeatures !== "function") {
4824
+ throw new InteractError(
4825
+ "unexpected_response",
4826
+ "inspectAll(): expected a MapLibre / Mapbox GL map instance"
4827
+ );
4828
+ }
4829
+ await this.entitlementClient.check();
4830
+ const screen = Array.isArray(point) ? map.project([point[0], point[1]]) : point;
4831
+ const lngLat = Array.isArray(point) ? { lng: point[0], lat: point[1] } : map.unproject?.([point.x, point.y]) ?? null;
4832
+ const at = { point: screen, lngLat };
4833
+ const candidates = collectClickCandidates(map, screen, opts);
4834
+ const http = this.http;
4835
+ return {
4836
+ candidates,
4837
+ select: async (candidate, selectOpts) => {
4838
+ const result = decodeCandidate(map, candidate, at, opts);
4839
+ if (selectOpts?.enrich) {
4840
+ return enrichResult(http, result, selectOpts.signal);
4841
+ }
4842
+ return result;
4843
+ }
4844
+ };
4845
+ }
4846
+ /**
4847
+ * Attach click-to-inspect to a MapLibre / Mapbox GL map rendering CHRT
4848
+ * tiles. Resolves once the server-side entitlement check passes; rejects
4849
+ * with `InteractError` (`interact_not_enabled` on Free plans) otherwise —
4850
+ * no listeners are wired on denial.
4851
+ *
4852
+ * Headless-first: pass `onInspect` to receive every decoded
4853
+ * {@link InspectResult} and render your own UI. Without callbacks, the
4854
+ * built-in minimal popup is shown (opt back in explicitly with
4855
+ * `popup: true` to have both).
4856
+ *
4857
+ * A click that hits SEVERAL features (deduplicated by S-57 identity)
4858
+ * disambiguates instead of silently taking the topmost:
4859
+ * - `onCandidates` provided → it receives the priority-ordered list;
4860
+ * resolve a choice with `ctx.select(candidate)`.
4861
+ * - built-in popup → shows a candidate list; picking a row opens the
4862
+ * result card.
4863
+ * - `onInspect` only → the top candidate is inspected immediately, and
4864
+ * clicking the SAME spot again cycles to the next candidate — so an
4865
+ * area feature under a buoy is reachable with a second click.
4866
+ *
4867
+ * @example
4868
+ * const handle = await chrt.interact.attach(map, {
4869
+ * onInspect: (result) => console.log(result.title, result.characteristic),
4870
+ * });
4871
+ * // later: handle.detach();
4872
+ */
4873
+ async attach(map, opts = {}) {
4874
+ if (!map || typeof map.queryRenderedFeatures !== "function" || typeof map.on !== "function") {
4875
+ throw new InteractError(
4876
+ "unexpected_response",
4877
+ "attach(): expected a MapLibre / Mapbox GL map instance"
4878
+ );
4879
+ }
4880
+ await this.entitlementClient.check();
4881
+ const showPopup = opts.popup ?? (!opts.onInspect && !opts.onCandidates);
4882
+ const popup = showPopup ? new InteractPopup(map) : null;
4883
+ const http = this.http;
4884
+ let cycle = null;
4885
+ const samePoint = (a, b) => {
4886
+ const pad = opts.tolerance ?? DEFAULT_CLICK_TOLERANCE;
4887
+ return Math.abs(a.x - b.x) <= pad && Math.abs(a.y - b.y) <= pad;
4888
+ };
4889
+ const select = (candidate, e) => {
4890
+ const result = decodeCandidate(map, candidate, e, opts);
4891
+ const finish = (r) => {
4892
+ popup?.show(r, r.coordinates ?? [e.lngLat.lng, e.lngLat.lat]);
4893
+ opts.onInspect?.(r, { lngLat: e.lngLat, point: e.point, feature: candidate.feature });
4894
+ return r;
4895
+ };
4896
+ if (opts.enrich) {
4897
+ return enrichResult(http, result).then(finish).catch(() => finish(result));
4898
+ }
4899
+ return Promise.resolve(finish(result));
4900
+ };
4901
+ const clickHandler = (e) => {
4902
+ const candidates = collectClickCandidates(map, e.point, opts);
4903
+ if (candidates.length === 0) {
4904
+ cycle = null;
4905
+ popup?.close();
4906
+ opts.onMiss?.({ lngLat: e.lngLat });
4907
+ return;
4908
+ }
4909
+ if (candidates.length === 1) {
4910
+ cycle = null;
4911
+ void select(candidates[0], e);
4912
+ return;
4913
+ }
4914
+ if (opts.onCandidates || popup) {
4915
+ cycle = null;
4916
+ if (popup) {
4917
+ popup.showList(candidates, [e.lngLat.lng, e.lngLat.lat], (c) => void select(c, e));
4918
+ }
4919
+ opts.onCandidates?.(candidates, {
4920
+ lngLat: e.lngLat,
4921
+ point: e.point,
4922
+ select: (c) => select(c, e)
4923
+ });
4924
+ return;
4925
+ }
4926
+ const key = candidates.map((c) => c.identity ?? c.layerId).join("/");
4927
+ if (cycle && cycle.key === key && samePoint(cycle.point, e.point)) {
4928
+ cycle.index = (cycle.index + 1) % candidates.length;
4929
+ cycle.point = e.point;
4930
+ } else {
4931
+ cycle = { key, point: e.point, index: 0 };
4932
+ }
4933
+ void select(candidates[cycle.index], e);
4934
+ };
4935
+ map.on("click", clickHandler);
4936
+ return {
4937
+ detach: () => {
4938
+ map.off("click", clickHandler);
4939
+ popup?.close();
4940
+ }
4941
+ };
4942
+ }
4811
4943
  };
4812
- function getColorTable(name) {
4813
- return COLOR_TABLES[name];
4814
- }
4815
- function resolveColor(token, table = "DAY") {
4816
- const t = COLOR_TABLES[table];
4817
- return t[token] ?? (table === "NIGHT" ? "#c8aa78" : "#000000");
4818
- }
4819
4944
 
4820
4945
  // src/atlas/s52-linestyles-generated.ts
4821
4946
  var S52_LINESTYLES = {
@@ -27466,6 +27591,35 @@ function iconExpressionForFamily(objectClass, family) {
27466
27591
  }
27467
27592
  }
27468
27593
 
27594
+ // src/atlas/base-bundle.ts
27595
+ var BASE_BUNDLE_ID = BASE_REGION;
27596
+ var BASE_BUNDLE_SCHEMA = "chrt.basemap-bundle/1";
27597
+ var BASE_BUNDLE_MAXZOOM = 6;
27598
+ var BASE_BUNDLE_SOURCE_LAYERS = [
27599
+ "base_earth",
27600
+ "base_water",
27601
+ "base_boundary",
27602
+ "country_boundary"
27603
+ ];
27604
+ var BASE_BUNDLE_ATTRIBUTION = "\xA9 CHRT \xB7 Made with Natural Earth";
27605
+ var BASE_CLIENT_RASTER_BEFORE_ID = "chrtbase-nocoverage";
27606
+ function applyBaseBundle(style, opts) {
27607
+ const keep = new Set(BASE_BUNDLE_SOURCE_LAYERS);
27608
+ style.layers = style.layers.filter((raw) => {
27609
+ const layer = raw;
27610
+ if (layer.source !== BASE_REGION) return true;
27611
+ return typeof layer["source-layer"] === "string" && keep.has(layer["source-layer"]);
27612
+ });
27613
+ style.sources[BASE_REGION] = {
27614
+ type: "vector",
27615
+ tiles: [opts.tiles],
27616
+ minzoom: BASE_MINZOOM,
27617
+ maxzoom: BASE_BUNDLE_MAXZOOM,
27618
+ attribution: BASE_BUNDLE_ATTRIBUTION
27619
+ };
27620
+ return style;
27621
+ }
27622
+
27469
27623
  // src/atlas/layer-builder.ts
27470
27624
  var SCAMIN_DECLUTTER_CLASSES = /* @__PURE__ */ new Set([
27471
27625
  "OFSPLF",
@@ -28806,6 +28960,99 @@ function getOrderedClasses() {
28806
28960
  return ordered;
28807
28961
  }
28808
28962
 
28963
+ // src/atlas/terrain-order.ts
28964
+ var DRAPEABLE_LAYER_TYPES = /* @__PURE__ */ new Set([
28965
+ "background",
28966
+ "fill",
28967
+ "line",
28968
+ "raster",
28969
+ "hillshade",
28970
+ "color-relief"
28971
+ ]);
28972
+ function isDrapeable(layer) {
28973
+ return DRAPEABLE_LAYER_TYPES.has(layer.type);
28974
+ }
28975
+ function drapedFirst(layers) {
28976
+ const draped = [];
28977
+ const direct = [];
28978
+ for (const l of layers) (isDrapeable(l) ? draped : direct).push(l);
28979
+ return [...draped, ...direct];
28980
+ }
28981
+ function terrainStackCount(layers) {
28982
+ let stacks = 0;
28983
+ let prevDraped = false;
28984
+ for (const l of layers) {
28985
+ const d = isDrapeable(l);
28986
+ if (d && !prevDraped) stacks++;
28987
+ prevDraped = d;
28988
+ }
28989
+ return stacks;
28990
+ }
28991
+ function drapeHalves(layers) {
28992
+ const draped = [];
28993
+ const direct = [];
28994
+ for (const l of layers) (isDrapeable(l) ? draped : direct).push(l);
28995
+ return { draped, direct };
28996
+ }
28997
+
28998
+ // src/atlas/lamps.ts
28999
+ var LAMP_GLOW_LAYER_ID = "s52-LIGHTS-lamp-glow";
29000
+ var LAMP_CORE_LAYER_ID = "s52-LIGHTS-lamp-core";
29001
+ var LAMP_LAYER_IDS = [LAMP_GLOW_LAYER_ID, LAMP_CORE_LAYER_ID];
29002
+ var RANGE_NM = ["to-number", ["coalesce", ["get", "VALNMR"], ["get", "valnmr"]], 6];
29003
+ var UNSECTORED_POINTS = [
29004
+ "all",
29005
+ ["any", ["==", ["geometry-type"], "Point"], ["==", ["geometry-type"], "MultiPoint"]],
29006
+ ["!", ["has", "SECTR1"]],
29007
+ // …and not lights with a charted rhythm either: those flash, and the
29008
+ // flashing is computed by the app from `SIGSEQ` (light-sigseq.ts). What is
29009
+ // left here is exactly the set that is steady from every bearing.
29010
+ ["!", ["has", "SIGSEQ"]]
29011
+ ];
29012
+ function buildLampLayers(sourceId, ct) {
29013
+ const colour = lightFillExpression(ct);
29014
+ const common = {
29015
+ type: "circle",
29016
+ source: sourceId,
29017
+ "source-layer": "LIGHTS",
29018
+ filter: UNSECTORED_POINTS,
29019
+ layout: { visibility: "none" }
29020
+ };
29021
+ const metadata = { "chrt:role": "lamp", "s52:objectClass": "LIGHTS" };
29022
+ return [
29023
+ {
29024
+ ...common,
29025
+ id: LAMP_GLOW_LAYER_ID,
29026
+ paint: {
29027
+ "circle-color": colour,
29028
+ // 4 nm → 7 px, 12 nm → 12 px, 25 nm → 20 px; capped, because a
29029
+ // primary seacoast light is not a searchlight.
29030
+ "circle-radius": ["interpolate", ["linear"], RANGE_NM, 0, 5, 4, 7, 12, 12, 25, 20],
29031
+ "circle-blur": 1,
29032
+ "circle-opacity": 0.55,
29033
+ // A lamp is a point of light: it does not grow as it comes near, and
29034
+ // it does not lie down with the chart.
29035
+ "circle-pitch-alignment": "viewport",
29036
+ "circle-pitch-scale": "viewport"
29037
+ },
29038
+ metadata: { ...metadata, "chrt:lamp": "glow" }
29039
+ },
29040
+ {
29041
+ ...common,
29042
+ id: LAMP_CORE_LAYER_ID,
29043
+ paint: {
29044
+ "circle-color": colour,
29045
+ "circle-radius": ["interpolate", ["linear"], RANGE_NM, 0, 1.5, 12, 2.5, 25, 3.5],
29046
+ "circle-blur": 0.25,
29047
+ "circle-opacity": 1,
29048
+ "circle-pitch-alignment": "viewport",
29049
+ "circle-pitch-scale": "viewport"
29050
+ },
29051
+ metadata: { ...metadata, "chrt:lamp": "core" }
29052
+ }
29053
+ ];
29054
+ }
29055
+
28809
29056
  // src/atlas/band-priority.ts
28810
29057
  var BAND_PRIORITY_RANGES = {
28811
29058
  "1": { minzoom: 0 },
@@ -28921,11 +29168,21 @@ function styleModeBand(mode) {
28921
29168
  return mode === "continuous" ? null : mode.slice("chart:".length);
28922
29169
  }
28923
29170
  function bandFilterExpression(codes) {
29171
+ return cellBandMatch(["slice", ["get", "_cell_id"], 2, 3], codes);
29172
+ }
29173
+ var BAND_CODE_ALIASES = {
29174
+ "5": ["7"]
29175
+ };
29176
+ function cellBandCharacters(code) {
29177
+ return [code, ...BAND_CODE_ALIASES[code] ?? []];
29178
+ }
29179
+ function cellBandMatch(cellBandExpr, codes) {
28924
29180
  const list = Array.isArray(codes) ? codes : [codes];
28925
- if (list.length === 1) {
28926
- return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
29181
+ const chars = [...new Set(list.flatMap((c) => cellBandCharacters(c)))];
29182
+ if (chars.length === 1) {
29183
+ return ["==", cellBandExpr, chars[0]];
28927
29184
  }
28928
- return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
29185
+ return ["in", cellBandExpr, ["literal", chars]];
28929
29186
  }
28930
29187
  function combineWithBandFilter(baseFilter, bandFilter) {
28931
29188
  if (!baseFilter) return bandFilter;
@@ -30009,7 +30266,8 @@ function fanOrientationLabelByCellBand(label, bandFan, objectClass) {
30009
30266
  }
30010
30267
  const cellFiltered = combineWithBandFilter(
30011
30268
  label.filter,
30012
- ["==", cellBandExpr, code]
30269
+ // Aliases included (USACE `7` rides with band 5 — #269).
30270
+ cellBandMatch(cellBandExpr, code)
30013
30271
  );
30014
30272
  const minzoom = Math.max(
30015
30273
  label.minzoom ?? range.minzoom,
@@ -30214,6 +30472,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30214
30472
  (b) => !TOP_STACK_LIGHT_CLASSES.has(b.objectClass) && !BOTTOM_STACK_WATER_BASE_CLASSES.has(b.objectClass)
30215
30473
  );
30216
30474
  const topLightBundles = bundles.filter((b) => TOP_STACK_LIGHT_CLASSES.has(b.objectClass));
30475
+ const lampLayers = buildLampLayers(sourceId, palette);
30217
30476
  const waterBaseBundles = bundles.filter(
30218
30477
  (b) => BOTTOM_STACK_WATER_BASE_CLASSES.has(b.objectClass)
30219
30478
  );
@@ -30247,6 +30506,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30247
30506
  emitChartFeatureVariants(waterBaseBundles);
30248
30507
  emitChartFeatureVariants(regularBundles);
30249
30508
  emitChartFeatureVariants(topLightBundles);
30509
+ layers.push(...lampLayers);
30250
30510
  const chartLabelBundles = byLabelPriorityBottomUp(
30251
30511
  bundles.filter(
30252
30512
  (b) => b.label !== null && (opts.labels !== "placename" || getLabelBehavior(b.objectClass) !== "orientation-cross-band")
@@ -30324,6 +30584,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30324
30584
  }
30325
30585
  }
30326
30586
  }
30587
+ layers.push(...lampLayers);
30327
30588
  {
30328
30589
  const TERMINAL_FILL_RE = new RegExp(
30329
30590
  `^s52-(?:${[...DOMINANCE_EXEMPT_FILL_CLASSES].join("|")})-fill-band[456]$`
@@ -30412,13 +30673,18 @@ function generateStyle(palette = "DAY", opts = {}) {
30412
30673
  const glyphsBase = `${opts.tileBaseUrl ?? "https://tiles.chrt.co"}/v1/glyphs`;
30413
30674
  const isLndareFill = (l) => /^s52-LNDARE-fill-band\d+$/.test(l.id);
30414
30675
  const isWaterBaseFill = (l) => /^s52-(DEPARE|DRGARE|UNSARE)-fill-band\d+$/.test(l.id);
30676
+ const isLakareFill = (l) => /^s52-LAKARE-fill-band\d+$/.test(l.id);
30415
30677
  const bandOf = (l) => {
30416
30678
  const m = /-band(\d+)$/.exec(l.id);
30417
30679
  return m ? Number(m[1]) : 0;
30418
30680
  };
30419
30681
  const emitted = opts.land ? (() => {
30420
- const relocated = layers.filter((l) => isLndareFill(l) || isWaterBaseFill(l));
30421
- const others = layers.filter((l) => !isLndareFill(l) && !isWaterBaseFill(l));
30682
+ const relocated = layers.filter(
30683
+ (l) => isLndareFill(l) || isWaterBaseFill(l) || isLakareFill(l)
30684
+ );
30685
+ const others = layers.filter(
30686
+ (l) => !isLndareFill(l) && !isWaterBaseFill(l) && !isLakareFill(l)
30687
+ );
30422
30688
  const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
30423
30689
  const seaare = others.filter(isSeaareFill);
30424
30690
  const rest = others.filter((l) => !isSeaareFill(l));
@@ -30442,40 +30708,43 @@ function generateStyle(palette = "DAY", opts = {}) {
30442
30708
  for (const b of bands) {
30443
30709
  ordered.push(...relocated.filter((l) => bandOf(l) === b && isWaterBaseFill(l)));
30444
30710
  ordered.push(...relocated.filter((l) => bandOf(l) === b && isLndareFill(l)).map(asBaseTan));
30711
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isLakareFill(l)));
30445
30712
  }
30446
30713
  const findRest = (id) => restAbove.findIndex((l) => l.id === id);
30447
- const shoreAt = findRest("chrtbase-inland-shore");
30448
- const pairAt = shoreAt !== -1 ? shoreAt : findRest("chrtbase-inland-hatch");
30714
+ const pairAt = findRest("chrtbase-inland-water");
30449
30715
  let restFinal = restAbove;
30450
30716
  if (pairAt !== -1) {
30451
30717
  const repaint = [];
30452
- for (const b of bands) {
30453
- for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
30454
- const src = l;
30455
- const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
30456
- const notDom = {
30718
+ const pushCascadeClones = (l, b) => {
30719
+ const src = l;
30720
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
30721
+ const notDom = {
30722
+ ...l,
30723
+ id: `${src.id}-overpair`,
30724
+ filter: ["all", src.filter, NOT_DOMINATED_FILTER],
30725
+ metadata: meta
30726
+ };
30727
+ delete notDom.maxzoom;
30728
+ delete notDom.minzoom;
30729
+ repaint.push(notDom);
30730
+ for (const D of BAND_DRAW_ORDER) {
30731
+ if (parseInt(D, 10) <= b) continue;
30732
+ const dMaxzoom = dominatedVariantMaxzoom(D);
30733
+ if (dMaxzoom <= 0) continue;
30734
+ const domVariant = {
30457
30735
  ...l,
30458
- id: `${src.id}-overpair`,
30459
- filter: ["all", src.filter, NOT_DOMINATED_FILTER],
30460
- metadata: meta
30736
+ id: `${src.id}-overpair-domBy${D}`,
30737
+ filter: ["all", src.filter, dominatedByMinFilter(D)],
30738
+ metadata: meta,
30739
+ maxzoom: dMaxzoom
30461
30740
  };
30462
- delete notDom.maxzoom;
30463
- delete notDom.minzoom;
30464
- repaint.push(notDom);
30465
- for (const D of BAND_DRAW_ORDER) {
30466
- if (parseInt(D, 10) <= b) continue;
30467
- const dMaxzoom = dominatedVariantMaxzoom(D);
30468
- if (dMaxzoom <= 0) continue;
30469
- const domVariant = {
30470
- ...l,
30471
- id: `${src.id}-overpair-domBy${D}`,
30472
- filter: ["all", src.filter, dominatedByMinFilter(D)],
30473
- metadata: meta,
30474
- maxzoom: dMaxzoom
30475
- };
30476
- delete domVariant.minzoom;
30477
- repaint.push(domVariant);
30478
- }
30741
+ delete domVariant.minzoom;
30742
+ repaint.push(domVariant);
30743
+ }
30744
+ };
30745
+ for (const b of bands) {
30746
+ for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
30747
+ pushCascadeClones(l, b);
30479
30748
  }
30480
30749
  if (parseInt(b, 10) >= FINE_LAND_REPAINT_MIN_BAND) {
30481
30750
  for (const l of relocated.filter(
@@ -30491,6 +30760,9 @@ function generateStyle(palette = "DAY", opts = {}) {
30491
30760
  repaint.push(landClone);
30492
30761
  }
30493
30762
  }
30763
+ for (const l of relocated.filter((x) => bandOf(x) === b && isLakareFill(x))) {
30764
+ pushCascadeClones(l, b);
30765
+ }
30494
30766
  }
30495
30767
  const isCoalneLine = (l) => /^s52-COALNE-line-band\d(-domBy\d)?$/.test(l.id);
30496
30768
  for (const l of layers.filter(isCoalneLine)) {
@@ -30514,9 +30786,9 @@ function generateStyle(palette = "DAY", opts = {}) {
30514
30786
  repaint.push(terminal);
30515
30787
  }
30516
30788
  restFinal = [
30517
- ...restAbove.slice(0, pairAt + 1),
30789
+ ...restAbove.slice(0, pairAt),
30518
30790
  ...repaint,
30519
- ...restAbove.slice(pairAt + 1)
30791
+ ...restAbove.slice(pairAt)
30520
30792
  ];
30521
30793
  }
30522
30794
  {
@@ -30565,6 +30837,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30565
30837
  emitted.push(layer);
30566
30838
  }
30567
30839
  }
30840
+ const finalLayers = drapedFirst(emitted);
30568
30841
  const style = {
30569
30842
  version: 8,
30570
30843
  name: `CHRT ${SYMBOL_SET_LABELS[symbolSet]} ${PALETTE_NAMES[palette]}${opts.chartMode ? ` \u2014 chart:${opts.chartMode.band}` : ""}`,
@@ -30573,7 +30846,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30573
30846
  "chrt:symbol-set": symbolSet,
30574
30847
  "chrt:color-table": palette,
30575
30848
  "chrt:generated-at": (/* @__PURE__ */ new Date()).toISOString(),
30576
- "chrt:layer-count": emitted.length,
30849
+ "chrt:layer-count": finalLayers.length,
30577
30850
  "chrt:land": Boolean(opts.land),
30578
30851
  "chrt:relief": Boolean(opts.relief),
30579
30852
  "chrt:base": Boolean(opts.base),
@@ -30613,7 +30886,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30613
30886
  // upstream glyph byte-identical and composite only those six in from
30614
30887
  // Noto Sans; see scripts/generate-glyphs.mjs.
30615
30888
  glyphs: `${glyphsBase}/{fontstack}/{range}.pbf`,
30616
- layers: emitted
30889
+ layers: finalLayers
30617
30890
  };
30618
30891
  if (symbolSet === "s101") return applyS101SymbolSet(style);
30619
30892
  if (symbolSet === "chrt") return applyChrtSymbolSet(style, palette);
@@ -31441,8 +31714,16 @@ export {
31441
31714
  ALL_WATER_BODY_LAYERS,
31442
31715
  AREA_QUERY_LAYERS,
31443
31716
  ATON_EXPRESSION_CLASSES,
31717
+ BAND_CODE_ALIASES,
31444
31718
  BAND_DRAW_ORDER,
31445
31719
  BAND_PRIORITY_RANGES,
31720
+ BASE_ATTRIBUTION,
31721
+ BASE_BUNDLE_ATTRIBUTION,
31722
+ BASE_BUNDLE_ID,
31723
+ BASE_BUNDLE_MAXZOOM,
31724
+ BASE_BUNDLE_SCHEMA,
31725
+ BASE_BUNDLE_SOURCE_LAYERS,
31726
+ BASE_CLIENT_RASTER_BEFORE_ID,
31446
31727
  BASE_MAXZOOM,
31447
31728
  BASE_MINZOOM,
31448
31729
  BASE_REGION,
@@ -31475,6 +31756,7 @@ export {
31475
31756
  DEFAULT_TILE_BASE_URL,
31476
31757
  DEFAULT_TILE_REGION,
31477
31758
  DEFAULT_WATER_BODY_SETTINGS,
31759
+ DRAPEABLE_LAYER_TYPES,
31478
31760
  INSPECT_CLASS_RANK,
31479
31761
  INSPECT_RANK_DEVIATIONS,
31480
31762
  InteractClient,
@@ -31483,6 +31765,11 @@ export {
31483
31765
  LABEL_BEHAVIOR,
31484
31766
  LABEL_LAYER_GROUPS,
31485
31767
  LABEL_PRIORITY,
31768
+ LAMP_CORE_LAYER_ID,
31769
+ LAMP_GLOW_LAYER_ID,
31770
+ LAMP_LAYER_IDS,
31771
+ LAMP_TICK_MS,
31772
+ LAND_ATTRIBUTION,
31486
31773
  LAND_BUILD_MAXZOOM,
31487
31774
  LAND_REGION,
31488
31775
  NAUTICAL_SYMBOLS,
@@ -31498,7 +31785,10 @@ export {
31498
31785
  RASTER_ATON_CLASSES,
31499
31786
  RASTER_GLYPH_NAMES,
31500
31787
  RASTER_ICON_PREFIX,
31788
+ RELIEF_ATTRIBUTION,
31789
+ RELIEF_ENCODING,
31501
31790
  RELIEF_REGION,
31791
+ RELIEF_TILE_SIZE,
31502
31792
  S101_AREA_FILL_NAMES,
31503
31793
  S101_CHART_SECTIONS,
31504
31794
  S101_FEATURES,
@@ -31518,6 +31808,8 @@ export {
31518
31808
  S52_LOOKUP,
31519
31809
  S52_NIGHT,
31520
31810
  S52_PATTERN_SPRITE_PREFIX,
31811
+ SIGPER_TOLERANCE_SECONDS,
31812
+ SIGSEQ_MAX_TERMS,
31521
31813
  SOUNDING_LAYERS,
31522
31814
  SYMBOL_FAMILIES,
31523
31815
  SYMBOL_FAMILY_META,
@@ -31527,6 +31819,7 @@ export {
31527
31819
  TileTokenError,
31528
31820
  WATER_BODY_LAYER_GROUPS,
31529
31821
  WeatherEndpoint,
31822
+ applyBaseBundle,
31530
31823
  applyChrtSymbolSet,
31531
31824
  applyColorTheme,
31532
31825
  applyCustomStyleSettings,
@@ -31539,6 +31832,7 @@ export {
31539
31832
  buildFlashPattern,
31540
31833
  buildLabelLayer,
31541
31834
  buildLakeTierExpression,
31835
+ buildLampLayers,
31542
31836
  buildLandAreaTierExpression,
31543
31837
  buildLandRegionTierExpression,
31544
31838
  buildLayersForClass,
@@ -31551,6 +31845,8 @@ export {
31551
31845
  buildSectorsFromFeatures,
31552
31846
  buildSources,
31553
31847
  candidateRank,
31848
+ cellBandCharacters,
31849
+ cellBandMatch,
31554
31850
  chrtAtonTintExpression,
31555
31851
  chrtLightTintExpression,
31556
31852
  collectCandidates,
@@ -31565,6 +31861,8 @@ export {
31565
31861
  depareColor,
31566
31862
  depareFillExpression,
31567
31863
  deriveMarinerName,
31864
+ drapeHalves,
31865
+ drapedFirst,
31568
31866
  extractNavAidDesignation,
31569
31867
  featureIdentity,
31570
31868
  generateCustomStyle,
@@ -31597,6 +31895,7 @@ export {
31597
31895
  hazardColor,
31598
31896
  hazardFillExpression,
31599
31897
  iconExpressionForFamily,
31898
+ isDrapeable,
31600
31899
  isLabelLayerVisible,
31601
31900
  isNavAidLayer,
31602
31901
  isNavAidLayerVisible,
@@ -31604,6 +31903,8 @@ export {
31604
31903
  isVisibleInMode,
31605
31904
  isWaterBodyLayerVisible,
31606
31905
  labelSortKey,
31906
+ lampRhythmColour,
31907
+ lampRhythmOpacity,
31607
31908
  lateralColor,
31608
31909
  lateralFillExpression,
31609
31910
  lightColor,
@@ -31613,6 +31914,7 @@ export {
31613
31914
  normalizeTheme,
31614
31915
  paginate,
31615
31916
  parseListCodes,
31917
+ parseSigseqTerms,
31616
31918
  parseSymbolFamily,
31617
31919
  parseWebhook,
31618
31920
  patternSpriteId,
@@ -31627,12 +31929,14 @@ export {
31627
31929
  resolveS101Color,
31628
31930
  resolveStyle,
31629
31931
  resolveTargetClass,
31932
+ rhythmProperties,
31630
31933
  s101ArtworkProvenance,
31631
31934
  sanitizeCustomStyleConfig,
31632
31935
  scaleSizeValue,
31633
31936
  scaminToMinZoom,
31634
31937
  sectorLimitToArcBearing,
31635
31938
  styleModeBand,
31939
+ terrainStackCount,
31636
31940
  tileTemplate,
31637
31941
  uncoveredSectorArcs,
31638
31942
  withinBboxFilter,