@chrtco/sdk 0.10.38 → 0.10.40

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}`;
@@ -413,14 +417,15 @@ function buildSources(opts = {}) {
413
417
  type: "vector",
414
418
  tiles: [tileTemplate(base, BASE_REGION, "mvt", buildFor(BASE_REGION))],
415
419
  minzoom: BASE_MINZOOM,
416
- maxzoom: BASE_MAXZOOM
420
+ maxzoom: BASE_MAXZOOM,
421
+ attribution: BASE_ATTRIBUTION
417
422
  };
418
423
  }
419
424
  if (opts.relief) {
420
425
  sources[RELIEF_REGION] = {
421
426
  type: "raster-dem",
422
427
  tiles: [tileTemplate(base, RELIEF_REGION, "png", buildFor(RELIEF_REGION))],
423
- encoding: "terrarium",
428
+ encoding: RELIEF_ENCODING,
424
429
  tileSize: RELIEF_TILE_SIZE,
425
430
  minzoom: RELIEF_MINZOOM,
426
431
  maxzoom: RELIEF_MAXZOOM
@@ -446,6 +451,7 @@ var TilesEndpoint = class {
446
451
  if (opts.mode) params.set("mode", opts.mode);
447
452
  if (opts.land) params.set("land", "1");
448
453
  if (opts.relief) params.set("relief", "1");
454
+ if (opts.units === "imperial") params.set("units", "imperial");
449
455
  const query2 = params.size > 0 ? `?${params.toString()}` : "";
450
456
  return `${this.tileBaseUrl}/v1/styles/${this.region}/style.json${query2}`;
451
457
  }
@@ -571,6 +577,9 @@ var WeatherEndpoint = class {
571
577
  *
572
578
  * `unavailable` names every field the station did not report, so absence is
573
579
  * stated rather than inferred from a missing key.
580
+ *
581
+ * Pass `hours` for the station's recent readings as `series` — NDBC's own
582
+ * readings at NDBC's own instants, never interpolated.
574
583
  */
575
584
  async conditions(options) {
576
585
  return this.http.request(
@@ -579,7 +588,8 @@ var WeatherEndpoint = class {
579
588
  lat: options.lat,
580
589
  lon: options.lon,
581
590
  units: options.units,
582
- station: options.station
591
+ station: options.station,
592
+ hours: options.hours
583
593
  },
584
594
  options.signal
585
595
  );
@@ -1043,10 +1053,10 @@ async function readManifest(manifestPath, fs) {
1043
1053
  if (!manifestPath) return emptyManifest();
1044
1054
  if (!await fs.exists(manifestPath)) return emptyManifest();
1045
1055
  const raw = await fs.readFile(manifestPath);
1046
- const text = new TextDecoder().decode(raw);
1056
+ const text2 = new TextDecoder().decode(raw);
1047
1057
  let parsed;
1048
1058
  try {
1049
- parsed = JSON.parse(text);
1059
+ parsed = JSON.parse(text2);
1050
1060
  } catch {
1051
1061
  throw new OfflineError(
1052
1062
  "unexpected_response",
@@ -1070,8 +1080,8 @@ async function readManifest(manifestPath, fs) {
1070
1080
  };
1071
1081
  }
1072
1082
  async function writeManifest(manifestPath, fs, manifest) {
1073
- const text = JSON.stringify(manifest, null, 2);
1074
- const bytes = new TextEncoder().encode(text);
1083
+ const text2 = JSON.stringify(manifest, null, 2);
1084
+ const bytes = new TextEncoder().encode(text2);
1075
1085
  await fs.writeFile(manifestPath, bytes);
1076
1086
  }
1077
1087
  function usedBytes(manifest, excludeRegion) {
@@ -2036,110 +2046,438 @@ function s57AbbrTable(acronym) {
2036
2046
  return cached;
2037
2047
  }
2038
2048
 
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"
2049
+ // src/atlas/s52-colors.ts
2050
+ var S52_DAY = {
2051
+ // Depth
2052
+ NODTA: "#a4a4a4",
2053
+ DEPIT: "#009678",
2054
+ DEPVS: "#7fbfdf",
2055
+ DEPMS: "#a7d4e8",
2056
+ DEPMD: "#cae7f3",
2057
+ DEPDW: "#ecf7fc",
2058
+ // Land / topography
2059
+ LANDA: "#e6d1a1",
2060
+ LANDF: "#c8b078",
2061
+ CHBRN: "#ab7948",
2062
+ CHGRF: "#878787",
2063
+ CHGRD: "#555555",
2064
+ // Chart symbology
2065
+ CHBLK: "#000000",
2066
+ CSTLN: "#000000",
2067
+ OUTLW: "#000000",
2068
+ BKAJ1: "#4d4d4d",
2069
+ // Navigation / traffic
2070
+ TRFCD: "#d200d2",
2071
+ TRFCF: "#e680e6",
2072
+ RESBL: "#0064b4",
2073
+ APTS1: "#d200d2",
2074
+ // Danger / hazard
2075
+ DNGHL: "#ff0000",
2076
+ ISHDG: "#cc0000",
2077
+ // Lights
2078
+ LITRD: "#ee0000",
2079
+ LITGN: "#00c800",
2080
+ LITYW: "#fddc00",
2081
+ LITWT: "#ffffff",
2082
+ LITBL: "#0064ff",
2083
+ LITOR: "#ff8c00",
2084
+ // Buoys / marks
2085
+ RADHI: "#68e456",
2086
+ RADLO: "#3f8a34",
2087
+ PLRTE: "#dc4025",
2088
+ ARPAT: "#3fa56f",
2089
+ ISDNG: "#c545c3",
2090
+ // Chart colours
2091
+ CHRED: "#f15469",
2092
+ CHGRN: "#68e456",
2093
+ CHYLW: "#f4da48",
2094
+ CHMGD: "#c545c3",
2095
+ CHMGF: "#d3a6e9",
2096
+ CHWHT: "#d4eaee",
2097
+ CHCOR: "#eb7d36",
2098
+ // Soundings
2099
+ SNDG1: "#7d898c",
2100
+ SNDG2: "#070707",
2101
+ DEPSC: "#525a5c",
2102
+ DEPCN: "#555555",
2103
+ // Utility / infrastructure
2104
+ UINFD: "#070707",
2105
+ UINFF: "#7d898c",
2106
+ UINFR: "#f15469",
2107
+ UINFG: "#68e456",
2108
+ UINFO: "#eb7d36",
2109
+ UINFB: "#3a78f0",
2110
+ UINFM: "#c545c3",
2111
+ UIBCK: "#d4eaee",
2112
+ UIBDR: "#7d898c",
2113
+ UIAFD: "#73b6ef",
2114
+ UIAFF: "#c9b97a",
2115
+ PIPLN: "#55aa00",
2116
+ // Outlines
2117
+ OUTLL: "#c9b97a",
2118
+ // Miscellaneous
2119
+ SCLBR: "#eb7d36",
2120
+ NINFO: "#eb7d36",
2121
+ ADINF: "#b29f34",
2122
+ RESGR: "#7d898c",
2123
+ SHIPS: "#070707",
2124
+ PSTRK: "#070707",
2125
+ SYTRK: "#7d898c",
2126
+ APLRT: "#eb7d36",
2127
+ CURSR: "#eb7d36",
2128
+ BKAJ2: "#232728",
2129
+ RES01: "#a3b4b7",
2130
+ RES02: "#a3b4b7",
2131
+ RES03: "#a3b4b7"
2064
2132
  };
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"
2133
+ var S52_DUSK = {
2134
+ NODTA: "#6b6b6b",
2135
+ DEPIT: "#006650",
2136
+ DEPVS: "#487085",
2137
+ DEPMS: "#608a9d",
2138
+ DEPMD: "#7ba6b8",
2139
+ DEPDW: "#95bfd0",
2140
+ LANDA: "#8a7d5e",
2141
+ LANDF: "#7a6a48",
2142
+ CHBRN: "#6e4e30",
2143
+ CHGRF: "#585858",
2144
+ CHGRD: "#3a3a3a",
2145
+ CHBLK: "#b0b0b0",
2146
+ CSTLN: "#b0b0b0",
2147
+ OUTLW: "#b0b0b0",
2148
+ BKAJ1: "#707070",
2149
+ TRFCD: "#a000a0",
2150
+ TRFCF: "#b060b0",
2151
+ RESBL: "#004a88",
2152
+ APTS1: "#a000a0",
2153
+ DNGHL: "#cc0000",
2154
+ ISHDG: "#990000",
2155
+ LITRD: "#bb0000",
2156
+ LITGN: "#009900",
2157
+ LITYW: "#c8b000",
2158
+ LITWT: "#d0d0d0",
2159
+ LITBL: "#004abb",
2160
+ LITOR: "#cc7000",
2161
+ RADHI: "#009940",
2162
+ RADLO: "#2d6330",
2163
+ PLRTE: "#a03020",
2164
+ ARPAT: "#306040",
2165
+ ISDNG: "#903090",
2166
+ CHRED: "#b05060",
2167
+ CHGRN: "#509040",
2168
+ CHYLW: "#b0a030",
2169
+ CHMGD: "#903090",
2170
+ CHMGF: "#905090",
2171
+ CHWHT: "#90a0a0",
2172
+ CHCOR: "#a06030",
2173
+ SNDG1: "#606060",
2174
+ SNDG2: "#b0b0b0",
2175
+ DEPSC: "#606060",
2176
+ DEPCN: "#2E2E2E",
2177
+ UINFD: "#b0b0b0",
2178
+ UINFF: "#606060",
2179
+ UINFR: "#b05060",
2180
+ UINFG: "#509040",
2181
+ UINFO: "#a06030",
2182
+ UINFB: "#304090",
2183
+ UINFM: "#903090",
2184
+ UIBCK: "#1a1a1a",
2185
+ UIBDR: "#606060",
2186
+ UIAFD: "#405060",
2187
+ UIAFF: "#605840",
2188
+ PIPLN: "#3d7a00",
2189
+ OUTLL: "#605840",
2190
+ SCLBR: "#a06030",
2191
+ NINFO: "#a06030",
2192
+ ADINF: "#807020",
2193
+ RESGR: "#606060",
2194
+ SHIPS: "#b0b0b0",
2195
+ PSTRK: "#b0b0b0",
2196
+ SYTRK: "#606060",
2197
+ APLRT: "#a06030",
2198
+ CURSR: "#cc0000",
2199
+ BKAJ2: "#252525",
2200
+ RES01: "#606060",
2201
+ RES02: "#606060",
2202
+ RES03: "#606060"
2111
2203
  };
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",
2204
+ var S52_NIGHT = {
2205
+ NODTA: "#3a3a32",
2206
+ DEPIT: "#003828",
2207
+ DEPVS: "#356577",
2208
+ DEPMS: "#265066",
2209
+ DEPMD: "#102e44",
2210
+ DEPDW: "#051a28",
2211
+ LANDA: "#3a3020",
2212
+ LANDF: "#302818",
2213
+ CHBRN: "#382410",
2214
+ CHGRF: "#2a2a22",
2215
+ CHGRD: "#1e1e18",
2216
+ CHBLK: "#c8aa78",
2217
+ CSTLN: "#c8aa78",
2218
+ OUTLW: "#c8aa78",
2219
+ BKAJ1: "#504830",
2220
+ TRFCD: "#6e006e",
2221
+ TRFCF: "#803880",
2222
+ RESBL: "#002a50",
2223
+ APTS1: "#6e006e",
2224
+ DNGHL: "#a00000",
2225
+ ISHDG: "#700000",
2226
+ LITRD: "#880000",
2227
+ LITGN: "#006600",
2228
+ LITYW: "#887800",
2229
+ LITWT: "#a09878",
2230
+ LITBL: "#003088",
2231
+ LITOR: "#884800",
2232
+ RADHI: "#005528",
2233
+ RADLO: "#1a3a20",
2234
+ PLRTE: "#6e2018",
2235
+ ARPAT: "#1a3028",
2236
+ ISDNG: "#481848",
2237
+ CHRED: "#682030",
2238
+ CHGRN: "#285020",
2239
+ CHYLW: "#685828",
2240
+ CHMGD: "#481848",
2241
+ CHMGF: "#482848",
2242
+ CHWHT: "#484840",
2243
+ CHCOR: "#683818",
2244
+ SNDG1: "#383028",
2245
+ SNDG2: "#c8aa78",
2246
+ DEPSC: "#383028",
2247
+ // ⚠ DELIBERATE DEVIATION from the published S-52 NIGHT value (#1f2223),
2248
+ // recorded here because `docs/validation/s52-spec-gap-matrix.json` tracks
2249
+ // this token as `value-differs` and the CHRTstyles programme is bringing
2250
+ // colours TO spec — this one moves further away, on purpose.
2251
+ //
2252
+ // Spec NIGHT DEPCN is near-black, which measures ~1.1:1 against this
2253
+ // palette's own deep water (#051a28). S-52's NIGHT table assumes a
2254
+ // dark-adapted eye in a darkened wheelhouse; on a phone or a laptop at
2255
+ // anchor it is not dim, it is absent — reported as "can't see bottom
2256
+ // contours in night mode only". A contour nobody can see fails the
2257
+ // standard's intent while matching its number.
2258
+ //
2259
+ // The palette already makes this trade elsewhere and in the same
2260
+ // direction: NIGHT's CHBLK/CSTLN/OUTLW are #c8aa78, light warm linework on
2261
+ // dark water. This value is that family, dimmed to stay subordinate to the
2262
+ // coastline. Revisit with the CHRTstyles programme, which owns the
2263
+ // spec-vs-legibility call across all 63 tokens.
2264
+ DEPCN: "#A68E84",
2265
+ UINFD: "#c8aa78",
2266
+ UINFF: "#383028",
2267
+ UINFR: "#682030",
2268
+ UINFG: "#285020",
2269
+ UINFO: "#683818",
2270
+ UINFB: "#182848",
2271
+ UINFM: "#481848",
2272
+ UIBCK: "#0a0a08",
2273
+ UIBDR: "#383028",
2274
+ UIAFD: "#182838",
2275
+ UIAFF: "#302818",
2276
+ PIPLN: "#284800",
2277
+ OUTLL: "#302818",
2278
+ SCLBR: "#683818",
2279
+ NINFO: "#683818",
2280
+ ADINF: "#483818",
2281
+ RESGR: "#383028",
2282
+ SHIPS: "#c8aa78",
2283
+ PSTRK: "#c8aa78",
2284
+ SYTRK: "#484030",
2285
+ APLRT: "#683818",
2286
+ CURSR: "#a00000",
2287
+ BKAJ2: "#0a0a08",
2288
+ RES01: "#383028",
2289
+ RES02: "#383028",
2290
+ RES03: "#383028"
2291
+ };
2292
+ var S52_COLORS = S52_DAY;
2293
+ var COLOR_TABLES = {
2294
+ DAY: S52_DAY,
2295
+ DUSK: S52_DUSK,
2296
+ NIGHT: S52_NIGHT
2297
+ };
2298
+ function getColorTable(name) {
2299
+ return COLOR_TABLES[name];
2300
+ }
2301
+ function resolveColor(token, table = "DAY") {
2302
+ const t = COLOR_TABLES[table];
2303
+ return t[token] ?? (table === "NIGHT" ? "#c8aa78" : "#000000");
2304
+ }
2305
+
2306
+ // src/interact/light-sigseq.ts
2307
+ var SIGSEQ_MAX_TERMS = 24;
2308
+ var SIGPER_TOLERANCE_SECONDS = 0.06;
2309
+ var LAMP_TICK_MS = 100;
2310
+ var TERM = /^\d+(?:\.\d+)?$/;
2311
+ function text(v) {
2312
+ if (v === null || v === void 0) return null;
2313
+ const s = String(v).trim();
2314
+ return s === "" ? null : s;
2315
+ }
2316
+ function parseSigseqTerms(sigseq, sigper) {
2317
+ const raw = text(sigseq);
2318
+ if (raw === null) return { refusal: "absent" };
2319
+ const terms = raw.split("+");
2320
+ if (terms.length === 0 || terms.length > SIGSEQ_MAX_TERMS) return { refusal: "malformed" };
2321
+ const phases = [];
2322
+ for (const term of terms) {
2323
+ const t = term.trim();
2324
+ if (t === "") return { refusal: "malformed" };
2325
+ const isEclipse = t.startsWith("(") && t.endsWith(")");
2326
+ const body = (isEclipse ? t.slice(1, -1) : t).trim();
2327
+ if (!TERM.test(body)) return { refusal: "malformed" };
2328
+ const seconds = Number(body);
2329
+ if (!(seconds > 0) || !Number.isFinite(seconds)) return { refusal: "nonPositiveDuration" };
2330
+ phases.push({ seconds, isOn: !isEclipse });
2331
+ }
2332
+ const sum = phases.reduce((t, p) => t + p.seconds, 0);
2333
+ if (!(sum > 0)) return { refusal: "nonPositiveDuration" };
2334
+ if (sigper !== null && sigper > 0) {
2335
+ if (Math.abs(sum - sigper) > SIGPER_TOLERANCE_SECONDS) return { refusal: "sigperMismatch" };
2336
+ return { phases, periodSeconds: sigper, checkedAgainstSigper: true };
2337
+ }
2338
+ return { phases, periodSeconds: sum, checkedAgainstSigper: false };
2339
+ }
2340
+ function rhythmProperties(parsed, onColoursHex) {
2341
+ if (parsed === null || parsed.phases.length === 0) return { rp: 0, rn: 0 };
2342
+ const out = { rp: parsed.periodSeconds, rn: parsed.phases.length };
2343
+ let end = 0;
2344
+ let onIndex = 0;
2345
+ parsed.phases.forEach((p, i) => {
2346
+ end += p.seconds;
2347
+ const k = i + 1;
2348
+ out[`r${k}`] = Math.round(end * 1e3) / 1e3;
2349
+ out[`o${k}`] = p.isOn ? 1 : 0;
2350
+ if (p.isOn) {
2351
+ out[`c${k}`] = onColoursHex[onIndex % Math.max(1, onColoursHex.length)] ?? "#ffffff";
2352
+ onIndex += 1;
2353
+ }
2354
+ });
2355
+ return out;
2356
+ }
2357
+ function phaseExpr(nowSeconds2) {
2358
+ return ["%", nowSeconds2, ["max", ["get", "rp"], 1e-3]];
2359
+ }
2360
+ function lampRhythmOpacity(nowSeconds2, base) {
2361
+ const cases = ["case", ["<=", ["get", "rn"], 0], base];
2362
+ for (let k = 1; k <= SIGSEQ_MAX_TERMS; k++) {
2363
+ cases.push(["<", ["var", "ph"], ["coalesce", ["get", `r${k}`], 1e9]], ["*", base, ["coalesce", ["get", `o${k}`], 1]]);
2364
+ }
2365
+ cases.push(0);
2366
+ return ["let", "ph", phaseExpr(nowSeconds2), cases];
2367
+ }
2368
+ function lampRhythmColour(nowSeconds2, fallback) {
2369
+ const cases = ["case", ["<=", ["get", "rn"], 0], fallback];
2370
+ for (let k = 1; k <= SIGSEQ_MAX_TERMS; k++) {
2371
+ cases.push(["<", ["var", "ph"], ["coalesce", ["get", `r${k}`], 1e9]], ["coalesce", ["get", `c${k}`], fallback]);
2372
+ }
2373
+ cases.push(fallback);
2374
+ return ["let", "ph", phaseExpr(nowSeconds2), cases];
2375
+ }
2376
+
2377
+ // src/interact/decode.ts
2378
+ function nameAbbrTable(acronym) {
2379
+ const names = s57NameTable(acronym);
2380
+ const abbrs = s57AbbrTable(acronym);
2381
+ const out = {};
2382
+ for (const [code, name] of Object.entries(names)) {
2383
+ const abbr = abbrs[Number(code)];
2384
+ if (abbr !== void 0) out[Number(code)] = { name, abbr };
2385
+ }
2386
+ return out;
2387
+ }
2388
+ var COLOUR_HEX = {
2389
+ 1: "#ffffff",
2390
+ 2: "#333333",
2391
+ 3: "#ef4444",
2392
+ 4: "#22c55e",
2393
+ 5: "#3b82f6",
2394
+ 6: "#eab308",
2395
+ 7: "#9ca3af",
2396
+ 8: "#92400e",
2397
+ 9: "#f59e0b",
2398
+ 10: "#8b5cf6",
2399
+ 11: "#f97316",
2400
+ 12: "#d946ef",
2401
+ 13: "#f472b6"
2402
+ };
2403
+ var LITCHR_TABLE = nameAbbrTable("LITCHR");
2404
+ var COLOUR_TABLE = Object.fromEntries(
2405
+ 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] }])
2406
+ );
2407
+ var BOYSHP_TABLE = s57NameTable("BOYSHP");
2408
+ var CATLAM_TABLE = s57NameTable("CATLAM");
2409
+ var CATCAM_TABLE = s57NameTable("CATCAM");
2410
+ var CATLMK_TABLE = s57NameTable("CATLMK");
2411
+ var CATFOG_TABLE = s57NameTable("CATFOG");
2412
+ var CATFOG_ABBR_TABLE = s57AbbrTable("CATFOG");
2413
+ var CATFOG_NAUTOPHONE = Object.values(CATFOG_CODES).find(
2414
+ (c) => c.name.toLowerCase() === "nautophone"
2415
+ )?.code;
2416
+ var CATLIT_TABLE = s57NameTable("CATLIT");
2417
+ var TOPSHP_TABLE = s57NameTable("TOPSHP");
2418
+ var STATUS_TABLE = s57NameTable("STATUS");
2419
+ var MARSYS_TABLE = s57NameTable("MARSYS");
2420
+ var LAYER_TYPE_MAP = {
2421
+ BOYLAT: "Lateral Buoy",
2422
+ BOYCAR: "Cardinal Buoy",
2423
+ BOYSPP: "Special Purpose Buoy",
2424
+ BOYISD: "Isolated Danger Buoy",
2425
+ BOYSAW: "Safe Water Buoy",
2426
+ BCNLAT: "Lateral Beacon",
2427
+ BCNCAR: "Cardinal Beacon",
2428
+ BCNSPP: "Special Purpose Beacon",
2429
+ BCNISD: "Isolated Danger Beacon",
2430
+ BCNSAW: "Safe Water Beacon",
2431
+ LIGHTS: "Light",
2432
+ DAYMAR: "Daymark",
2433
+ FOGSIG: "Fog Signal",
2434
+ RTPBCN: "Radar Transponder Beacon",
2435
+ RDOSTA: "Radio/Radar Station",
2436
+ TOPMAR: "Topmark",
2437
+ MORFAC: "Mooring Facility",
2438
+ LNDMRK: "Landmark",
2439
+ SISTAT: "Signal Station",
2440
+ PILBOP: "Pilot Boarding Place",
2441
+ HRBFAC: "Harbour Facility",
2442
+ SMCFAC: "Small Craft Facility",
2443
+ CRANES: "Crane",
2444
+ OFSPLF: "Offshore Platform",
2445
+ ACHBRT: "Anchor Berth",
2446
+ DISMAR: "Distance Mark",
2447
+ LITVES: "Light Vessel",
2448
+ LITFLT: "Light Float"
2449
+ };
2450
+ var NAV_AID_QUERY_LAYERS = [
2451
+ "enc-BOYLAT",
2452
+ "enc-BOYCAR",
2453
+ "enc-BOYSPP",
2454
+ "enc-BOYISD",
2455
+ "enc-BOYSAW",
2456
+ "enc-BCNLAT",
2457
+ "enc-BCNCAR",
2458
+ "enc-BCNSPP",
2459
+ "enc-BCNISD",
2460
+ "enc-BCNSAW",
2461
+ "enc-LIGHTS",
2462
+ "enc-LIGHTS-point",
2463
+ "enc-DAYMAR",
2464
+ "enc-FOGSIG",
2465
+ "enc-RTPBCN",
2466
+ "enc-RDOSTA",
2467
+ "enc-TOPMAR",
2468
+ "enc-MORFAC",
2469
+ "enc-MORFAC-point",
2470
+ "enc-LNDMRK",
2471
+ "enc-LNDMRK-point",
2472
+ "enc-SISTAT",
2473
+ "enc-SISTAT-point",
2474
+ "enc-PILBOP",
2475
+ "enc-PILBOP-point",
2476
+ "enc-HRBFAC",
2477
+ "enc-SMCFAC",
2478
+ "enc-CRANES",
2479
+ "enc-CRANES-point",
2480
+ "enc-OFSPLF",
2143
2481
  "enc-OFSPLF-point",
2144
2482
  "enc-ACHBRT",
2145
2483
  "enc-DISMAR",
@@ -2942,14 +3280,8 @@ function decodeNavAidProps(layerName, props, coords) {
2942
3280
  const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
2943
3281
  const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
2944
3282
  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
- });
3283
+ const sec = sectorFromProps(props, sectr1, sectr2, "DAY", false);
3284
+ if (sec) sectors.push({ ...sec, notation });
2953
3285
  }
2954
3286
  return {
2955
3287
  name,
@@ -2967,6 +3299,7 @@ function decodeNavAidProps(layerName, props, coords) {
2967
3299
  lightCharacterFull: litchrEntry?.name ?? null,
2968
3300
  period: sigper,
2969
3301
  group,
3302
+ sigseq: props.SIGSEQ != null && String(props.SIGSEQ).trim() !== "" ? String(props.SIGSEQ).trim() : null,
2970
3303
  fogSignal: fogSignal2,
2971
3304
  fogSignalAbbr,
2972
3305
  radarReflector,
@@ -2997,6 +3330,18 @@ function buildNotation(litchrAbbr, colourAbbrs, sigper, group) {
2997
3330
  function buildFlashPattern(decoded) {
2998
3331
  const litchr = decoded.lightCharacter;
2999
3332
  if (!litchr) return null;
3333
+ const charted = parseSigseqTerms(decoded.sigseq, decoded.period);
3334
+ if (!("refusal" in charted)) {
3335
+ const colours2 = decoded.colourHexList.length > 0 ? decoded.colourHexList : [decoded.lightColourHex];
3336
+ let onIndex = 0;
3337
+ const chartedPhases = charted.phases.map((p) => {
3338
+ if (!p.isOn) return { on: false, duration: p.seconds * 1e3 };
3339
+ const color = colours2[onIndex % colours2.length];
3340
+ onIndex += 1;
3341
+ return { on: true, duration: p.seconds * 1e3, color };
3342
+ });
3343
+ return { phases: chartedPhases, totalCycle: charted.periodSeconds * 1e3, notation: decoded.notation, timing: "charted" };
3344
+ }
3000
3345
  const period = decoded.period && decoded.period > 0 ? decoded.period * 1e3 : 6e3;
3001
3346
  const groups = decoded.group ?? [1];
3002
3347
  const totalFlashes = groups.reduce((a, b) => a + b, 0);
@@ -3165,7 +3510,7 @@ function buildFlashPattern(decoded) {
3165
3510
  }
3166
3511
  }
3167
3512
  const totalCycle = phases.reduce((sum, p) => sum + p.duration, 0);
3168
- return { phases, totalCycle, notation };
3513
+ return { timing: "invented", phases, totalCycle, notation };
3169
3514
  }
3170
3515
  function uncoveredSectorArcs(sectors) {
3171
3516
  const norm = (d) => (d % 360 + 360) % 360;
@@ -3204,7 +3549,70 @@ function uncoveredSectorArcs(sectors) {
3204
3549
  }
3205
3550
  return gaps.filter(([a, b]) => Math.abs(b - a) >= 0.05 || b < a).map(([a, b]) => ({ start: norm(a), end: norm(b) }));
3206
3551
  }
3207
- function buildSectorsFromFeatures(features) {
3552
+ function parseCodes(val) {
3553
+ if (val == null) return [];
3554
+ const str = String(val).trim();
3555
+ let parts;
3556
+ if (str.startsWith("[")) {
3557
+ try {
3558
+ const arr = JSON.parse(str);
3559
+ parts = Array.isArray(arr) ? arr.map(String) : [];
3560
+ } catch {
3561
+ parts = str.split(/[,;]/);
3562
+ }
3563
+ } else {
3564
+ parts = str.split(/[,;]/);
3565
+ }
3566
+ return parts.map((p) => Number(p.trim())).filter((n) => !isNaN(n));
3567
+ }
3568
+ var OBSCURED_LITVIS = /* @__PURE__ */ new Set([3, 7, 8]);
3569
+ function sectorArcColourToken(colourCodes, obscured) {
3570
+ if (obscured) return "CHBRN";
3571
+ const has = (c) => colourCodes.includes(c);
3572
+ if (colourCodes.length === 1) {
3573
+ if (has(3)) return "LITRD";
3574
+ if (has(4)) return "LITGN";
3575
+ if (has(1) || has(6) || has(11)) return "LITYW";
3576
+ return "CHMGD";
3577
+ }
3578
+ if (colourCodes.length === 2) {
3579
+ if (has(1) && has(3)) return "LITRD";
3580
+ if (has(1) && has(4)) return "LITGN";
3581
+ return "CHMGD";
3582
+ }
3583
+ return "CHMGD";
3584
+ }
3585
+ function sectorFromProps(props, s1, s2, ct, allRound) {
3586
+ const colourCodes = parseCodes(props.COLOUR);
3587
+ const entries = colourCodes.map((c) => COLOUR_TABLE[c]).filter((e) => e != null);
3588
+ if (allRound && entries.length === 0) return null;
3589
+ const obscured = parseCodes(props.LITVIS).some((v) => OBSCURED_LITVIS.has(v));
3590
+ const litchrCode = parseCode(props.LITCHR);
3591
+ const le = litchrCode ? LITCHR_TABLE[litchrCode] : null;
3592
+ const sigper = props.SIGPER != null ? Number(props.SIGPER) : null;
3593
+ const group = parseGroup(props.SIGGRP);
3594
+ const base = buildNotation(le?.abbr ?? null, entries.length > 0 ? [entries.map((e) => e.abbr).join("")] : [], sigper, group);
3595
+ const notation = obscured && base ? `${base} (obsc)` : base;
3596
+ const valnmr = props.VALNMR != null ? Number(props.VALNMR) : null;
3597
+ const nominalRange = valnmr != null && !isNaN(valnmr) && valnmr > 0 ? valnmr : null;
3598
+ const arcColourToken = sectorArcColourToken(colourCodes, obscured);
3599
+ return {
3600
+ sectorStart: s1,
3601
+ sectorEnd: s2,
3602
+ colour: entries[0]?.name ?? "White",
3603
+ colourHex: entries[0]?.hex ?? "#ffffff",
3604
+ notation,
3605
+ nominalRange,
3606
+ colourCodes,
3607
+ arcColourToken,
3608
+ arcColourHex: resolveColor(arcColourToken, ct),
3609
+ obscured,
3610
+ sigseq: props.SIGSEQ != null && String(props.SIGSEQ).trim() !== "" ? String(props.SIGSEQ).trim() : null,
3611
+ sigper: sigper != null && !isNaN(sigper) && sigper > 0 ? sigper : null,
3612
+ ...allRound ? { allRound: true } : {}
3613
+ };
3614
+ }
3615
+ function buildSectorsFromFeatures(features, ct = "DAY") {
3208
3616
  const sectors = [];
3209
3617
  const allRound = [];
3210
3618
  for (const f of features) {
@@ -3215,47 +3623,15 @@ function buildSectorsFromFeatures(features) {
3215
3623
  if (props.SECTR1 == null && props.SECTR2 == null && parseCode(props.COLOUR) != null) allRound.push(props);
3216
3624
  continue;
3217
3625
  }
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
- });
3626
+ const sec = sectorFromProps(props, s1, s2, ct, false);
3627
+ if (sec) sectors.push(sec);
3235
3628
  }
3236
3629
  if (sectors.length > 0 && allRound.length > 0) {
3237
3630
  const gaps = uncoveredSectorArcs(sectors);
3238
3631
  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
3632
  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
- });
3633
+ const sec = sectorFromProps(props, gap.start, gap.end, ct, true);
3634
+ if (sec) sectors.push(sec);
3259
3635
  }
3260
3636
  }
3261
3637
  }
@@ -3305,12 +3681,16 @@ function buildSectorArcGeoJSON(center, startBearing, endBearing, radiusNm = 0.5,
3305
3681
  }
3306
3682
  function buildSectorArcsFC(center, sectors, radiusNm = 0.4, innerRadiusFrac = 0.92) {
3307
3683
  const innerNm = innerRadiusFrac > 0 ? radiusNm * innerRadiusFrac : 0;
3308
- const features = sectors.map((sec, i) => {
3684
+ const drawn = sectors.filter((sec) => !sec.allRound);
3685
+ const features = drawn.map((sec, i) => {
3309
3686
  const arc = buildSectorArcGeoJSON(center, sec.sectorStart, sec.sectorEnd, radiusNm, innerNm);
3310
3687
  arc.properties = {
3311
3688
  sectorIndex: i,
3312
3689
  colour: sec.colour,
3313
3690
  colourHex: sec.colourHex,
3691
+ arcColourHex: sec.arcColourHex,
3692
+ arcColourToken: sec.arcColourToken,
3693
+ obscured: sec.obscured,
3314
3694
  notation: sec.notation,
3315
3695
  sectorStart: sec.sectorStart,
3316
3696
  sectorEnd: sec.sectorEnd,
@@ -3323,7 +3703,7 @@ function buildSectorArcsFC(center, sectors, radiusNm = 0.4, innerRadiusFrac = 0.
3323
3703
  const seenBearings = /* @__PURE__ */ new Set();
3324
3704
  const TICK_OUTER_OVERHANG = 1.05;
3325
3705
  const TICK_INNER_OVERHANG = 0.93;
3326
- for (const sec of sectors) {
3706
+ for (const sec of drawn) {
3327
3707
  for (const bearing of [sec.sectorStart, sec.sectorEnd]) {
3328
3708
  const rad = sectorLimitToArcBearing(bearing) * Math.PI / 180;
3329
3709
  const radiusDeg = radiusNm / 60;
@@ -4193,10 +4573,10 @@ function ensureStyles(doc) {
4193
4573
  doc.head.appendChild(style);
4194
4574
  styleInjected = true;
4195
4575
  }
4196
- function el(doc, tag, className, text) {
4576
+ function el(doc, tag, className, text2) {
4197
4577
  const node = doc.createElement(tag);
4198
4578
  node.className = className;
4199
- if (text != null) node.textContent = text;
4579
+ if (text2 != null) node.textContent = text2;
4200
4580
  return node;
4201
4581
  }
4202
4582
  function animateDot(dot, pattern) {
@@ -4362,460 +4742,203 @@ ${notice.dataCurrency}`;
4362
4742
  this.place();
4363
4743
  this.map.on("move", this.reposition);
4364
4744
  }
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"
4745
+ place() {
4746
+ if (!this.root || !this.lngLat) return;
4747
+ const { x, y } = this.map.project(this.lngLat);
4748
+ this.root.style.left = `${x}px`;
4749
+ this.root.style.top = `${y}px`;
4750
+ }
4751
+ close() {
4752
+ if (this.stopAnimation) {
4753
+ this.stopAnimation();
4754
+ this.stopAnimation = null;
4755
+ }
4756
+ if (this.root) {
4757
+ this.map.off("move", this.reposition);
4758
+ this.root.remove();
4759
+ this.root = null;
4760
+ }
4761
+ this.lngLat = null;
4762
+ }
4805
4763
  };
4806
- var S52_COLORS = S52_DAY;
4807
- var COLOR_TABLES = {
4808
- DAY: S52_DAY,
4809
- DUSK: S52_DUSK,
4810
- NIGHT: S52_NIGHT
4764
+
4765
+ // src/interact/index.ts
4766
+ var InteractClient = class {
4767
+ constructor(http) {
4768
+ this.http = http;
4769
+ this.entitlementClient = new EntitlementClient(http);
4770
+ }
4771
+ entitlementClient;
4772
+ /**
4773
+ * Check the CHRTinteract entitlement for this API key (server-side, plan
4774
+ * derived, cached per client). Throws `InteractError` with code
4775
+ * `interact_not_enabled` for Free-plan teams.
4776
+ */
4777
+ async entitlement() {
4778
+ return this.entitlementClient.check();
4779
+ }
4780
+ /**
4781
+ * Decode a chart feature (from `queryRenderedFeatures`, the features API,
4782
+ * or plain `{ layer, properties }`) into a typed {@link InspectResult}.
4783
+ *
4784
+ * The first call performs the server-side entitlement check. With
4785
+ * `{ enrich: true }` the result is upgraded with full S-57 properties and
4786
+ * source-cell provenance (edition / update / issue date) via the features
4787
+ * API — metered against the key's `featureCalls` quota.
4788
+ *
4789
+ * @example
4790
+ * const result = await chrt.interact.inspect(feature);
4791
+ * if (result.kind === "navaid") console.log(result.characteristic); // "Fl R 4s"
4792
+ */
4793
+ async inspect(feature, opts) {
4794
+ await this.entitlementClient.check();
4795
+ const result = buildInspectResult(feature, opts);
4796
+ if (opts?.enrich) {
4797
+ return enrichResult(this.http, result, opts.signal);
4798
+ }
4799
+ return result;
4800
+ }
4801
+ /**
4802
+ * Collect EVERY inspectable feature under a point on a CHRT map —
4803
+ * deduplicated across tile boundaries by S-57 identity (`LNAM`) and
4804
+ * ordered by class priority (point targets > lines > areas, safety
4805
+ * classes first within each band). Area features never preempt the buoy
4806
+ * on top of them but always stay selectable: a click on open water
4807
+ * resolves to its DEPARE.
4808
+ *
4809
+ * `point` is a screen pixel point (`e.point` from a map click) or
4810
+ * `[lng, lat]`. Point/line targets are picked up within
4811
+ * `opts.tolerance` px (default 8); polygon candidates require the exact
4812
+ * point to fall inside them.
4813
+ *
4814
+ * The first call performs the server-side entitlement check.
4815
+ *
4816
+ * @example
4817
+ * const { candidates, select } = await chrt.interact.inspectAll(map, e.point);
4818
+ * const result = await select(candidates[0]); // full decode (+ merge)
4819
+ */
4820
+ async inspectAll(map, point, opts = {}) {
4821
+ if (!map || typeof map.queryRenderedFeatures !== "function") {
4822
+ throw new InteractError(
4823
+ "unexpected_response",
4824
+ "inspectAll(): expected a MapLibre / Mapbox GL map instance"
4825
+ );
4826
+ }
4827
+ await this.entitlementClient.check();
4828
+ const screen = Array.isArray(point) ? map.project([point[0], point[1]]) : point;
4829
+ const lngLat = Array.isArray(point) ? { lng: point[0], lat: point[1] } : map.unproject?.([point.x, point.y]) ?? null;
4830
+ const at = { point: screen, lngLat };
4831
+ const candidates = collectClickCandidates(map, screen, opts);
4832
+ const http = this.http;
4833
+ return {
4834
+ candidates,
4835
+ select: async (candidate, selectOpts) => {
4836
+ const result = decodeCandidate(map, candidate, at, opts);
4837
+ if (selectOpts?.enrich) {
4838
+ return enrichResult(http, result, selectOpts.signal);
4839
+ }
4840
+ return result;
4841
+ }
4842
+ };
4843
+ }
4844
+ /**
4845
+ * Attach click-to-inspect to a MapLibre / Mapbox GL map rendering CHRT
4846
+ * tiles. Resolves once the server-side entitlement check passes; rejects
4847
+ * with `InteractError` (`interact_not_enabled` on Free plans) otherwise —
4848
+ * no listeners are wired on denial.
4849
+ *
4850
+ * Headless-first: pass `onInspect` to receive every decoded
4851
+ * {@link InspectResult} and render your own UI. Without callbacks, the
4852
+ * built-in minimal popup is shown (opt back in explicitly with
4853
+ * `popup: true` to have both).
4854
+ *
4855
+ * A click that hits SEVERAL features (deduplicated by S-57 identity)
4856
+ * disambiguates instead of silently taking the topmost:
4857
+ * - `onCandidates` provided → it receives the priority-ordered list;
4858
+ * resolve a choice with `ctx.select(candidate)`.
4859
+ * - built-in popup → shows a candidate list; picking a row opens the
4860
+ * result card.
4861
+ * - `onInspect` only → the top candidate is inspected immediately, and
4862
+ * clicking the SAME spot again cycles to the next candidate — so an
4863
+ * area feature under a buoy is reachable with a second click.
4864
+ *
4865
+ * @example
4866
+ * const handle = await chrt.interact.attach(map, {
4867
+ * onInspect: (result) => console.log(result.title, result.characteristic),
4868
+ * });
4869
+ * // later: handle.detach();
4870
+ */
4871
+ async attach(map, opts = {}) {
4872
+ if (!map || typeof map.queryRenderedFeatures !== "function" || typeof map.on !== "function") {
4873
+ throw new InteractError(
4874
+ "unexpected_response",
4875
+ "attach(): expected a MapLibre / Mapbox GL map instance"
4876
+ );
4877
+ }
4878
+ await this.entitlementClient.check();
4879
+ const showPopup = opts.popup ?? (!opts.onInspect && !opts.onCandidates);
4880
+ const popup = showPopup ? new InteractPopup(map) : null;
4881
+ const http = this.http;
4882
+ let cycle = null;
4883
+ const samePoint = (a, b) => {
4884
+ const pad = opts.tolerance ?? DEFAULT_CLICK_TOLERANCE;
4885
+ return Math.abs(a.x - b.x) <= pad && Math.abs(a.y - b.y) <= pad;
4886
+ };
4887
+ const select = (candidate, e) => {
4888
+ const result = decodeCandidate(map, candidate, e, opts);
4889
+ const finish = (r) => {
4890
+ popup?.show(r, r.coordinates ?? [e.lngLat.lng, e.lngLat.lat]);
4891
+ opts.onInspect?.(r, { lngLat: e.lngLat, point: e.point, feature: candidate.feature });
4892
+ return r;
4893
+ };
4894
+ if (opts.enrich) {
4895
+ return enrichResult(http, result).then(finish).catch(() => finish(result));
4896
+ }
4897
+ return Promise.resolve(finish(result));
4898
+ };
4899
+ const clickHandler = (e) => {
4900
+ const candidates = collectClickCandidates(map, e.point, opts);
4901
+ if (candidates.length === 0) {
4902
+ cycle = null;
4903
+ popup?.close();
4904
+ opts.onMiss?.({ lngLat: e.lngLat });
4905
+ return;
4906
+ }
4907
+ if (candidates.length === 1) {
4908
+ cycle = null;
4909
+ void select(candidates[0], e);
4910
+ return;
4911
+ }
4912
+ if (opts.onCandidates || popup) {
4913
+ cycle = null;
4914
+ if (popup) {
4915
+ popup.showList(candidates, [e.lngLat.lng, e.lngLat.lat], (c) => void select(c, e));
4916
+ }
4917
+ opts.onCandidates?.(candidates, {
4918
+ lngLat: e.lngLat,
4919
+ point: e.point,
4920
+ select: (c) => select(c, e)
4921
+ });
4922
+ return;
4923
+ }
4924
+ const key = candidates.map((c) => c.identity ?? c.layerId).join("/");
4925
+ if (cycle && cycle.key === key && samePoint(cycle.point, e.point)) {
4926
+ cycle.index = (cycle.index + 1) % candidates.length;
4927
+ cycle.point = e.point;
4928
+ } else {
4929
+ cycle = { key, point: e.point, index: 0 };
4930
+ }
4931
+ void select(candidates[cycle.index], e);
4932
+ };
4933
+ map.on("click", clickHandler);
4934
+ return {
4935
+ detach: () => {
4936
+ map.off("click", clickHandler);
4937
+ popup?.close();
4938
+ }
4939
+ };
4940
+ }
4811
4941
  };
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
4942
 
4820
4943
  // src/atlas/s52-linestyles-generated.ts
4821
4944
  var S52_LINESTYLES = {
@@ -27466,6 +27589,35 @@ function iconExpressionForFamily(objectClass, family) {
27466
27589
  }
27467
27590
  }
27468
27591
 
27592
+ // src/atlas/base-bundle.ts
27593
+ var BASE_BUNDLE_ID = BASE_REGION;
27594
+ var BASE_BUNDLE_SCHEMA = "chrt.basemap-bundle/1";
27595
+ var BASE_BUNDLE_MAXZOOM = 6;
27596
+ var BASE_BUNDLE_SOURCE_LAYERS = [
27597
+ "base_earth",
27598
+ "base_water",
27599
+ "base_boundary",
27600
+ "country_boundary"
27601
+ ];
27602
+ var BASE_BUNDLE_ATTRIBUTION = "\xA9 CHRT \xB7 Made with Natural Earth";
27603
+ var BASE_CLIENT_RASTER_BEFORE_ID = "chrtbase-nocoverage";
27604
+ function applyBaseBundle(style, opts) {
27605
+ const keep = new Set(BASE_BUNDLE_SOURCE_LAYERS);
27606
+ style.layers = style.layers.filter((raw) => {
27607
+ const layer = raw;
27608
+ if (layer.source !== BASE_REGION) return true;
27609
+ return typeof layer["source-layer"] === "string" && keep.has(layer["source-layer"]);
27610
+ });
27611
+ style.sources[BASE_REGION] = {
27612
+ type: "vector",
27613
+ tiles: [opts.tiles],
27614
+ minzoom: BASE_MINZOOM,
27615
+ maxzoom: BASE_BUNDLE_MAXZOOM,
27616
+ attribution: BASE_BUNDLE_ATTRIBUTION
27617
+ };
27618
+ return style;
27619
+ }
27620
+
27469
27621
  // src/atlas/layer-builder.ts
27470
27622
  var SCAMIN_DECLUTTER_CLASSES = /* @__PURE__ */ new Set([
27471
27623
  "OFSPLF",
@@ -28806,6 +28958,99 @@ function getOrderedClasses() {
28806
28958
  return ordered;
28807
28959
  }
28808
28960
 
28961
+ // src/atlas/terrain-order.ts
28962
+ var DRAPEABLE_LAYER_TYPES = /* @__PURE__ */ new Set([
28963
+ "background",
28964
+ "fill",
28965
+ "line",
28966
+ "raster",
28967
+ "hillshade",
28968
+ "color-relief"
28969
+ ]);
28970
+ function isDrapeable(layer) {
28971
+ return DRAPEABLE_LAYER_TYPES.has(layer.type);
28972
+ }
28973
+ function drapedFirst(layers) {
28974
+ const draped = [];
28975
+ const direct = [];
28976
+ for (const l of layers) (isDrapeable(l) ? draped : direct).push(l);
28977
+ return [...draped, ...direct];
28978
+ }
28979
+ function terrainStackCount(layers) {
28980
+ let stacks = 0;
28981
+ let prevDraped = false;
28982
+ for (const l of layers) {
28983
+ const d = isDrapeable(l);
28984
+ if (d && !prevDraped) stacks++;
28985
+ prevDraped = d;
28986
+ }
28987
+ return stacks;
28988
+ }
28989
+ function drapeHalves(layers) {
28990
+ const draped = [];
28991
+ const direct = [];
28992
+ for (const l of layers) (isDrapeable(l) ? draped : direct).push(l);
28993
+ return { draped, direct };
28994
+ }
28995
+
28996
+ // src/atlas/lamps.ts
28997
+ var LAMP_GLOW_LAYER_ID = "s52-LIGHTS-lamp-glow";
28998
+ var LAMP_CORE_LAYER_ID = "s52-LIGHTS-lamp-core";
28999
+ var LAMP_LAYER_IDS = [LAMP_GLOW_LAYER_ID, LAMP_CORE_LAYER_ID];
29000
+ var RANGE_NM = ["to-number", ["coalesce", ["get", "VALNMR"], ["get", "valnmr"]], 6];
29001
+ var UNSECTORED_POINTS = [
29002
+ "all",
29003
+ ["any", ["==", ["geometry-type"], "Point"], ["==", ["geometry-type"], "MultiPoint"]],
29004
+ ["!", ["has", "SECTR1"]],
29005
+ // …and not lights with a charted rhythm either: those flash, and the
29006
+ // flashing is computed by the app from `SIGSEQ` (light-sigseq.ts). What is
29007
+ // left here is exactly the set that is steady from every bearing.
29008
+ ["!", ["has", "SIGSEQ"]]
29009
+ ];
29010
+ function buildLampLayers(sourceId, ct) {
29011
+ const colour = lightFillExpression(ct);
29012
+ const common = {
29013
+ type: "circle",
29014
+ source: sourceId,
29015
+ "source-layer": "LIGHTS",
29016
+ filter: UNSECTORED_POINTS,
29017
+ layout: { visibility: "none" }
29018
+ };
29019
+ const metadata = { "chrt:role": "lamp", "s52:objectClass": "LIGHTS" };
29020
+ return [
29021
+ {
29022
+ ...common,
29023
+ id: LAMP_GLOW_LAYER_ID,
29024
+ paint: {
29025
+ "circle-color": colour,
29026
+ // 4 nm → 7 px, 12 nm → 12 px, 25 nm → 20 px; capped, because a
29027
+ // primary seacoast light is not a searchlight.
29028
+ "circle-radius": ["interpolate", ["linear"], RANGE_NM, 0, 5, 4, 7, 12, 12, 25, 20],
29029
+ "circle-blur": 1,
29030
+ "circle-opacity": 0.55,
29031
+ // A lamp is a point of light: it does not grow as it comes near, and
29032
+ // it does not lie down with the chart.
29033
+ "circle-pitch-alignment": "viewport",
29034
+ "circle-pitch-scale": "viewport"
29035
+ },
29036
+ metadata: { ...metadata, "chrt:lamp": "glow" }
29037
+ },
29038
+ {
29039
+ ...common,
29040
+ id: LAMP_CORE_LAYER_ID,
29041
+ paint: {
29042
+ "circle-color": colour,
29043
+ "circle-radius": ["interpolate", ["linear"], RANGE_NM, 0, 1.5, 12, 2.5, 25, 3.5],
29044
+ "circle-blur": 0.25,
29045
+ "circle-opacity": 1,
29046
+ "circle-pitch-alignment": "viewport",
29047
+ "circle-pitch-scale": "viewport"
29048
+ },
29049
+ metadata: { ...metadata, "chrt:lamp": "core" }
29050
+ }
29051
+ ];
29052
+ }
29053
+
28809
29054
  // src/atlas/band-priority.ts
28810
29055
  var BAND_PRIORITY_RANGES = {
28811
29056
  "1": { minzoom: 0 },
@@ -28841,6 +29086,17 @@ function isExemptOpaqueFill(layer) {
28841
29086
  }
28842
29087
  return false;
28843
29088
  }
29089
+ var GROUND_FILL_CLASSES = /* @__PURE__ */ new Set([
29090
+ ...DOMINANCE_EXEMPT_FILL_CLASSES,
29091
+ "RIVERS"
29092
+ ]);
29093
+ function isGroundFill(layer) {
29094
+ if (layer.type !== "fill") return false;
29095
+ for (const cls of GROUND_FILL_CLASSES) {
29096
+ if (layer.id.startsWith(`s52-${cls}-`)) return true;
29097
+ }
29098
+ return false;
29099
+ }
28844
29100
  var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28845
29101
  "1",
28846
29102
  "2",
@@ -28848,10 +29104,10 @@ var FILL_FLOOR_FREE_BANDS = /* @__PURE__ */ new Set([
28848
29104
  ]);
28849
29105
  function bandVariantMinzoom(layer, B) {
28850
29106
  const classMinzoom = layer.minzoom ?? 0;
28851
- if (isExemptOpaqueFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
28852
- return classMinzoom;
29107
+ if (isGroundFill(layer) && FILL_FLOOR_FREE_BANDS.has(B)) {
29108
+ return 0;
28853
29109
  }
28854
- if (isExemptOpaqueFill(layer)) {
29110
+ if (isGroundFill(layer)) {
28855
29111
  return BAND_PRIORITY_RANGES[B].minzoom;
28856
29112
  }
28857
29113
  return Math.max(classMinzoom, BAND_PRIORITY_RANGES[B].minzoom);
@@ -28910,11 +29166,21 @@ function styleModeBand(mode) {
28910
29166
  return mode === "continuous" ? null : mode.slice("chart:".length);
28911
29167
  }
28912
29168
  function bandFilterExpression(codes) {
29169
+ return cellBandMatch(["slice", ["get", "_cell_id"], 2, 3], codes);
29170
+ }
29171
+ var BAND_CODE_ALIASES = {
29172
+ "5": ["7"]
29173
+ };
29174
+ function cellBandCharacters(code) {
29175
+ return [code, ...BAND_CODE_ALIASES[code] ?? []];
29176
+ }
29177
+ function cellBandMatch(cellBandExpr, codes) {
28913
29178
  const list = Array.isArray(codes) ? codes : [codes];
28914
- if (list.length === 1) {
28915
- return ["==", ["slice", ["get", "_cell_id"], 2, 3], list[0]];
29179
+ const chars = [...new Set(list.flatMap((c) => cellBandCharacters(c)))];
29180
+ if (chars.length === 1) {
29181
+ return ["==", cellBandExpr, chars[0]];
28916
29182
  }
28917
- return ["in", ["slice", ["get", "_cell_id"], 2, 3], ["literal", [...list]]];
29183
+ return ["in", cellBandExpr, ["literal", chars]];
28918
29184
  }
28919
29185
  function combineWithBandFilter(baseFilter, bandFilter) {
28920
29186
  if (!baseFilter) return bandFilter;
@@ -29998,7 +30264,8 @@ function fanOrientationLabelByCellBand(label, bandFan, objectClass) {
29998
30264
  }
29999
30265
  const cellFiltered = combineWithBandFilter(
30000
30266
  label.filter,
30001
- ["==", cellBandExpr, code]
30267
+ // Aliases included (USACE `7` rides with band 5 — #269).
30268
+ cellBandMatch(cellBandExpr, code)
30002
30269
  );
30003
30270
  const minzoom = Math.max(
30004
30271
  label.minzoom ?? range.minzoom,
@@ -30203,6 +30470,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30203
30470
  (b) => !TOP_STACK_LIGHT_CLASSES.has(b.objectClass) && !BOTTOM_STACK_WATER_BASE_CLASSES.has(b.objectClass)
30204
30471
  );
30205
30472
  const topLightBundles = bundles.filter((b) => TOP_STACK_LIGHT_CLASSES.has(b.objectClass));
30473
+ const lampLayers = buildLampLayers(sourceId, palette);
30206
30474
  const waterBaseBundles = bundles.filter(
30207
30475
  (b) => BOTTOM_STACK_WATER_BASE_CLASSES.has(b.objectClass)
30208
30476
  );
@@ -30236,6 +30504,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30236
30504
  emitChartFeatureVariants(waterBaseBundles);
30237
30505
  emitChartFeatureVariants(regularBundles);
30238
30506
  emitChartFeatureVariants(topLightBundles);
30507
+ layers.push(...lampLayers);
30239
30508
  const chartLabelBundles = byLabelPriorityBottomUp(
30240
30509
  bundles.filter(
30241
30510
  (b) => b.label !== null && (opts.labels !== "placename" || getLabelBehavior(b.objectClass) !== "orientation-cross-band")
@@ -30313,6 +30582,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30313
30582
  }
30314
30583
  }
30315
30584
  }
30585
+ layers.push(...lampLayers);
30316
30586
  {
30317
30587
  const TERMINAL_FILL_RE = new RegExp(
30318
30588
  `^s52-(?:${[...DOMINANCE_EXEMPT_FILL_CLASSES].join("|")})-fill-band[456]$`
@@ -30401,13 +30671,18 @@ function generateStyle(palette = "DAY", opts = {}) {
30401
30671
  const glyphsBase = `${opts.tileBaseUrl ?? "https://tiles.chrt.co"}/v1/glyphs`;
30402
30672
  const isLndareFill = (l) => /^s52-LNDARE-fill-band\d+$/.test(l.id);
30403
30673
  const isWaterBaseFill = (l) => /^s52-(DEPARE|DRGARE|UNSARE)-fill-band\d+$/.test(l.id);
30674
+ const isLakareFill = (l) => /^s52-LAKARE-fill-band\d+$/.test(l.id);
30404
30675
  const bandOf = (l) => {
30405
30676
  const m = /-band(\d+)$/.exec(l.id);
30406
30677
  return m ? Number(m[1]) : 0;
30407
30678
  };
30408
30679
  const emitted = opts.land ? (() => {
30409
- const relocated = layers.filter((l) => isLndareFill(l) || isWaterBaseFill(l));
30410
- const others = layers.filter((l) => !isLndareFill(l) && !isWaterBaseFill(l));
30680
+ const relocated = layers.filter(
30681
+ (l) => isLndareFill(l) || isWaterBaseFill(l) || isLakareFill(l)
30682
+ );
30683
+ const others = layers.filter(
30684
+ (l) => !isLndareFill(l) && !isWaterBaseFill(l) && !isLakareFill(l)
30685
+ );
30411
30686
  const isSeaareFill = (l) => /^s52-SEAARE-fill-band\d+$/.test(l.id);
30412
30687
  const seaare = others.filter(isSeaareFill);
30413
30688
  const rest = others.filter((l) => !isSeaareFill(l));
@@ -30431,40 +30706,43 @@ function generateStyle(palette = "DAY", opts = {}) {
30431
30706
  for (const b of bands) {
30432
30707
  ordered.push(...relocated.filter((l) => bandOf(l) === b && isWaterBaseFill(l)));
30433
30708
  ordered.push(...relocated.filter((l) => bandOf(l) === b && isLndareFill(l)).map(asBaseTan));
30709
+ ordered.push(...relocated.filter((l) => bandOf(l) === b && isLakareFill(l)));
30434
30710
  }
30435
30711
  const findRest = (id) => restAbove.findIndex((l) => l.id === id);
30436
- const shoreAt = findRest("chrtbase-inland-shore");
30437
- const pairAt = shoreAt !== -1 ? shoreAt : findRest("chrtbase-inland-hatch");
30712
+ const pairAt = findRest("chrtbase-inland-water");
30438
30713
  let restFinal = restAbove;
30439
30714
  if (pairAt !== -1) {
30440
30715
  const repaint = [];
30441
- for (const b of bands) {
30442
- for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
30443
- const src = l;
30444
- const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
30445
- const notDom = {
30716
+ const pushCascadeClones = (l, b) => {
30717
+ const src = l;
30718
+ const meta = { ...src.metadata ?? {}, "chrt:role": "charted-water-repaint" };
30719
+ const notDom = {
30720
+ ...l,
30721
+ id: `${src.id}-overpair`,
30722
+ filter: ["all", src.filter, NOT_DOMINATED_FILTER],
30723
+ metadata: meta
30724
+ };
30725
+ delete notDom.maxzoom;
30726
+ delete notDom.minzoom;
30727
+ repaint.push(notDom);
30728
+ for (const D of BAND_DRAW_ORDER) {
30729
+ if (parseInt(D, 10) <= b) continue;
30730
+ const dMaxzoom = dominatedVariantMaxzoom(D);
30731
+ if (dMaxzoom <= 0) continue;
30732
+ const domVariant = {
30446
30733
  ...l,
30447
- id: `${src.id}-overpair`,
30448
- filter: ["all", src.filter, NOT_DOMINATED_FILTER],
30449
- metadata: meta
30734
+ id: `${src.id}-overpair-domBy${D}`,
30735
+ filter: ["all", src.filter, dominatedByMinFilter(D)],
30736
+ metadata: meta,
30737
+ maxzoom: dMaxzoom
30450
30738
  };
30451
- delete notDom.maxzoom;
30452
- delete notDom.minzoom;
30453
- repaint.push(notDom);
30454
- for (const D of BAND_DRAW_ORDER) {
30455
- if (parseInt(D, 10) <= b) continue;
30456
- const dMaxzoom = dominatedVariantMaxzoom(D);
30457
- if (dMaxzoom <= 0) continue;
30458
- const domVariant = {
30459
- ...l,
30460
- id: `${src.id}-overpair-domBy${D}`,
30461
- filter: ["all", src.filter, dominatedByMinFilter(D)],
30462
- metadata: meta,
30463
- maxzoom: dMaxzoom
30464
- };
30465
- delete domVariant.minzoom;
30466
- repaint.push(domVariant);
30467
- }
30739
+ delete domVariant.minzoom;
30740
+ repaint.push(domVariant);
30741
+ }
30742
+ };
30743
+ for (const b of bands) {
30744
+ for (const l of relocated.filter((x) => bandOf(x) === b && isWaterBaseFill(x))) {
30745
+ pushCascadeClones(l, b);
30468
30746
  }
30469
30747
  if (parseInt(b, 10) >= FINE_LAND_REPAINT_MIN_BAND) {
30470
30748
  for (const l of relocated.filter(
@@ -30480,6 +30758,9 @@ function generateStyle(palette = "DAY", opts = {}) {
30480
30758
  repaint.push(landClone);
30481
30759
  }
30482
30760
  }
30761
+ for (const l of relocated.filter((x) => bandOf(x) === b && isLakareFill(x))) {
30762
+ pushCascadeClones(l, b);
30763
+ }
30483
30764
  }
30484
30765
  const isCoalneLine = (l) => /^s52-COALNE-line-band\d(-domBy\d)?$/.test(l.id);
30485
30766
  for (const l of layers.filter(isCoalneLine)) {
@@ -30503,9 +30784,9 @@ function generateStyle(palette = "DAY", opts = {}) {
30503
30784
  repaint.push(terminal);
30504
30785
  }
30505
30786
  restFinal = [
30506
- ...restAbove.slice(0, pairAt + 1),
30787
+ ...restAbove.slice(0, pairAt),
30507
30788
  ...repaint,
30508
- ...restAbove.slice(pairAt + 1)
30789
+ ...restAbove.slice(pairAt)
30509
30790
  ];
30510
30791
  }
30511
30792
  {
@@ -30554,6 +30835,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30554
30835
  emitted.push(layer);
30555
30836
  }
30556
30837
  }
30838
+ const finalLayers = drapedFirst(emitted);
30557
30839
  const style = {
30558
30840
  version: 8,
30559
30841
  name: `CHRT ${SYMBOL_SET_LABELS[symbolSet]} ${PALETTE_NAMES[palette]}${opts.chartMode ? ` \u2014 chart:${opts.chartMode.band}` : ""}`,
@@ -30562,7 +30844,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30562
30844
  "chrt:symbol-set": symbolSet,
30563
30845
  "chrt:color-table": palette,
30564
30846
  "chrt:generated-at": (/* @__PURE__ */ new Date()).toISOString(),
30565
- "chrt:layer-count": emitted.length,
30847
+ "chrt:layer-count": finalLayers.length,
30566
30848
  "chrt:land": Boolean(opts.land),
30567
30849
  "chrt:relief": Boolean(opts.relief),
30568
30850
  "chrt:base": Boolean(opts.base),
@@ -30602,7 +30884,7 @@ function generateStyle(palette = "DAY", opts = {}) {
30602
30884
  // upstream glyph byte-identical and composite only those six in from
30603
30885
  // Noto Sans; see scripts/generate-glyphs.mjs.
30604
30886
  glyphs: `${glyphsBase}/{fontstack}/{range}.pbf`,
30605
- layers: emitted
30887
+ layers: finalLayers
30606
30888
  };
30607
30889
  if (symbolSet === "s101") return applyS101SymbolSet(style);
30608
30890
  if (symbolSet === "chrt") return applyChrtSymbolSet(style, palette);
@@ -31430,8 +31712,16 @@ export {
31430
31712
  ALL_WATER_BODY_LAYERS,
31431
31713
  AREA_QUERY_LAYERS,
31432
31714
  ATON_EXPRESSION_CLASSES,
31715
+ BAND_CODE_ALIASES,
31433
31716
  BAND_DRAW_ORDER,
31434
31717
  BAND_PRIORITY_RANGES,
31718
+ BASE_ATTRIBUTION,
31719
+ BASE_BUNDLE_ATTRIBUTION,
31720
+ BASE_BUNDLE_ID,
31721
+ BASE_BUNDLE_MAXZOOM,
31722
+ BASE_BUNDLE_SCHEMA,
31723
+ BASE_BUNDLE_SOURCE_LAYERS,
31724
+ BASE_CLIENT_RASTER_BEFORE_ID,
31435
31725
  BASE_MAXZOOM,
31436
31726
  BASE_MINZOOM,
31437
31727
  BASE_REGION,
@@ -31464,6 +31754,7 @@ export {
31464
31754
  DEFAULT_TILE_BASE_URL,
31465
31755
  DEFAULT_TILE_REGION,
31466
31756
  DEFAULT_WATER_BODY_SETTINGS,
31757
+ DRAPEABLE_LAYER_TYPES,
31467
31758
  INSPECT_CLASS_RANK,
31468
31759
  INSPECT_RANK_DEVIATIONS,
31469
31760
  InteractClient,
@@ -31472,6 +31763,11 @@ export {
31472
31763
  LABEL_BEHAVIOR,
31473
31764
  LABEL_LAYER_GROUPS,
31474
31765
  LABEL_PRIORITY,
31766
+ LAMP_CORE_LAYER_ID,
31767
+ LAMP_GLOW_LAYER_ID,
31768
+ LAMP_LAYER_IDS,
31769
+ LAMP_TICK_MS,
31770
+ LAND_ATTRIBUTION,
31475
31771
  LAND_BUILD_MAXZOOM,
31476
31772
  LAND_REGION,
31477
31773
  NAUTICAL_SYMBOLS,
@@ -31487,7 +31783,10 @@ export {
31487
31783
  RASTER_ATON_CLASSES,
31488
31784
  RASTER_GLYPH_NAMES,
31489
31785
  RASTER_ICON_PREFIX,
31786
+ RELIEF_ATTRIBUTION,
31787
+ RELIEF_ENCODING,
31490
31788
  RELIEF_REGION,
31789
+ RELIEF_TILE_SIZE,
31491
31790
  S101_AREA_FILL_NAMES,
31492
31791
  S101_CHART_SECTIONS,
31493
31792
  S101_FEATURES,
@@ -31507,6 +31806,8 @@ export {
31507
31806
  S52_LOOKUP,
31508
31807
  S52_NIGHT,
31509
31808
  S52_PATTERN_SPRITE_PREFIX,
31809
+ SIGPER_TOLERANCE_SECONDS,
31810
+ SIGSEQ_MAX_TERMS,
31510
31811
  SOUNDING_LAYERS,
31511
31812
  SYMBOL_FAMILIES,
31512
31813
  SYMBOL_FAMILY_META,
@@ -31516,6 +31817,7 @@ export {
31516
31817
  TileTokenError,
31517
31818
  WATER_BODY_LAYER_GROUPS,
31518
31819
  WeatherEndpoint,
31820
+ applyBaseBundle,
31519
31821
  applyChrtSymbolSet,
31520
31822
  applyColorTheme,
31521
31823
  applyCustomStyleSettings,
@@ -31528,6 +31830,7 @@ export {
31528
31830
  buildFlashPattern,
31529
31831
  buildLabelLayer,
31530
31832
  buildLakeTierExpression,
31833
+ buildLampLayers,
31531
31834
  buildLandAreaTierExpression,
31532
31835
  buildLandRegionTierExpression,
31533
31836
  buildLayersForClass,
@@ -31540,6 +31843,8 @@ export {
31540
31843
  buildSectorsFromFeatures,
31541
31844
  buildSources,
31542
31845
  candidateRank,
31846
+ cellBandCharacters,
31847
+ cellBandMatch,
31543
31848
  chrtAtonTintExpression,
31544
31849
  chrtLightTintExpression,
31545
31850
  collectCandidates,
@@ -31554,6 +31859,8 @@ export {
31554
31859
  depareColor,
31555
31860
  depareFillExpression,
31556
31861
  deriveMarinerName,
31862
+ drapeHalves,
31863
+ drapedFirst,
31557
31864
  extractNavAidDesignation,
31558
31865
  featureIdentity,
31559
31866
  generateCustomStyle,
@@ -31586,6 +31893,7 @@ export {
31586
31893
  hazardColor,
31587
31894
  hazardFillExpression,
31588
31895
  iconExpressionForFamily,
31896
+ isDrapeable,
31589
31897
  isLabelLayerVisible,
31590
31898
  isNavAidLayer,
31591
31899
  isNavAidLayerVisible,
@@ -31593,6 +31901,8 @@ export {
31593
31901
  isVisibleInMode,
31594
31902
  isWaterBodyLayerVisible,
31595
31903
  labelSortKey,
31904
+ lampRhythmColour,
31905
+ lampRhythmOpacity,
31596
31906
  lateralColor,
31597
31907
  lateralFillExpression,
31598
31908
  lightColor,
@@ -31602,6 +31912,7 @@ export {
31602
31912
  normalizeTheme,
31603
31913
  paginate,
31604
31914
  parseListCodes,
31915
+ parseSigseqTerms,
31605
31916
  parseSymbolFamily,
31606
31917
  parseWebhook,
31607
31918
  patternSpriteId,
@@ -31616,12 +31927,14 @@ export {
31616
31927
  resolveS101Color,
31617
31928
  resolveStyle,
31618
31929
  resolveTargetClass,
31930
+ rhythmProperties,
31619
31931
  s101ArtworkProvenance,
31620
31932
  sanitizeCustomStyleConfig,
31621
31933
  scaleSizeValue,
31622
31934
  scaminToMinZoom,
31623
31935
  sectorLimitToArcBearing,
31624
31936
  styleModeBand,
31937
+ terrainStackCount,
31625
31938
  tileTemplate,
31626
31939
  uncoveredSectorArcs,
31627
31940
  withinBboxFilter,