@chrtco/sdk 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -121,20 +121,243 @@ function sleep(ms) {
121
121
  return new Promise((r) => setTimeout(r, ms));
122
122
  }
123
123
 
124
+ // src/tile-auth.ts
125
+ var DEFAULT_TILE_REGION = "NOAA_ENC";
126
+ var DEFAULT_TILE_BASE_URL = "https://tiles.chrt.co";
127
+ var TileTokenError = class extends Error {
128
+ code;
129
+ status;
130
+ constructor(code, message, status) {
131
+ super(message);
132
+ this.name = "TileTokenError";
133
+ this.code = code;
134
+ this.status = status;
135
+ }
136
+ };
137
+ var TILE_URL_PATH = /\/v1\/tiles\/[A-Za-z0-9_]+(?:\/[a-z][a-z0-9-]*)?\/\d+\/\d+\/\d+\.mvt(?:\?.*)?$/i;
138
+ var REFRESH_MARGIN_SECONDS = 30;
139
+ var STALE_GRACE_SECONDS = 1;
140
+ var MINT_RETRY_DELAYS_MS = [500, 1e3, 2e3];
141
+ function nowSeconds() {
142
+ return Math.floor(Date.now() / 1e3);
143
+ }
144
+ function sleep2(ms) {
145
+ return new Promise((r) => setTimeout(r, ms));
146
+ }
147
+ function mapErrorCode(raw, status) {
148
+ const known = [
149
+ "invalid_api_key",
150
+ "feature_flag_disabled",
151
+ "region_not_licensed",
152
+ "tile_quota_exceeded",
153
+ "region_not_available"
154
+ ];
155
+ if (typeof raw === "string" && known.includes(raw)) {
156
+ return raw;
157
+ }
158
+ if (status === 401) return "invalid_api_key";
159
+ if (status === 403) return "feature_flag_disabled";
160
+ if (status === 429) return "tile_quota_exceeded";
161
+ if (status === 503) return "region_not_available";
162
+ return "unexpected_response";
163
+ }
164
+ var TileAuth = class {
165
+ constructor(http, opts) {
166
+ this.http = http;
167
+ this.defaultRegion = opts?.region ?? DEFAULT_TILE_REGION;
168
+ this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
169
+ }
170
+ cache = /* @__PURE__ */ new Map();
171
+ pending = /* @__PURE__ */ new Map();
172
+ defaultRegion;
173
+ tileBaseUrl;
174
+ lifecycleRegistered = false;
175
+ /** Mint once via the HTTP client (POST `/mint/tile-token`, `x-api-key`). */
176
+ async mintOnce(region) {
177
+ let res;
178
+ try {
179
+ res = await this.http.postJson("/mint/tile-token", { region });
180
+ } catch (err) {
181
+ throw new TileTokenError(
182
+ "network_error",
183
+ err instanceof Error ? err.message : "mint request failed"
184
+ );
185
+ }
186
+ if (!res.ok) {
187
+ const code = mapErrorCode(res.body?.error, res.status);
188
+ const msg = typeof res.body?.error === "string" ? res.body.error : `HTTP ${res.status}`;
189
+ throw new TileTokenError(code, msg, res.status);
190
+ }
191
+ const b = res.body;
192
+ if (!b || typeof b.token !== "string" || typeof b.expires_at !== "number" || typeof b.kid !== "string" || typeof b.build_tag !== "string" || typeof b.region !== "string") {
193
+ throw new TileTokenError(
194
+ "unexpected_response",
195
+ "mint-tile-token returned a malformed body",
196
+ res.status
197
+ );
198
+ }
199
+ return {
200
+ token: b.token,
201
+ expiresAt: b.expires_at,
202
+ kid: b.kid,
203
+ buildTag: b.build_tag,
204
+ region: b.region
205
+ };
206
+ }
207
+ /** Mint with exponential backoff on network errors only. */
208
+ async mintWithRetry(region) {
209
+ let lastErr;
210
+ for (let attempt = 0; attempt <= MINT_RETRY_DELAYS_MS.length; attempt++) {
211
+ try {
212
+ return await this.mintOnce(region);
213
+ } catch (err) {
214
+ lastErr = err;
215
+ if (err instanceof TileTokenError && err.code !== "network_error") throw err;
216
+ if (attempt === MINT_RETRY_DELAYS_MS.length) break;
217
+ await sleep2(MINT_RETRY_DELAYS_MS[attempt]);
218
+ }
219
+ }
220
+ throw lastErr instanceof Error ? lastErr : new TileTokenError("network_error", "mint failed");
221
+ }
222
+ scheduleRefresh(region, expiresAt) {
223
+ const delaySeconds = expiresAt - nowSeconds() - REFRESH_MARGIN_SECONDS;
224
+ if (delaySeconds <= 0) return null;
225
+ const timer = setTimeout(() => {
226
+ void this.refreshInBackground(region);
227
+ }, delaySeconds * 1e3);
228
+ timer.unref?.();
229
+ return timer;
230
+ }
231
+ async refreshInBackground(region) {
232
+ try {
233
+ const response = await this.mintWithRetry(region);
234
+ this.store(region, response);
235
+ } catch {
236
+ this.invalidate(region);
237
+ }
238
+ }
239
+ store(region, response) {
240
+ const existing = this.cache.get(region);
241
+ if (existing?.refreshTimer) clearTimeout(existing.refreshTimer);
242
+ this.cache.set(region, {
243
+ response,
244
+ refreshTimer: this.scheduleRefresh(region, response.expiresAt)
245
+ });
246
+ this.registerLifecycleHandlers();
247
+ }
248
+ /**
249
+ * Fetch (or reuse a cached) tile token for `region`. Concurrent callers for
250
+ * the same region await the same in-flight mint.
251
+ */
252
+ async getToken(region) {
253
+ const r = region ?? this.defaultRegion;
254
+ const cached = this.cache.get(r);
255
+ if (cached && cached.response.expiresAt - nowSeconds() > REFRESH_MARGIN_SECONDS) {
256
+ return cached.response;
257
+ }
258
+ const inflight = this.pending.get(r);
259
+ if (inflight) return inflight;
260
+ const promise = (async () => {
261
+ try {
262
+ const response = await this.mintWithRetry(r);
263
+ this.store(r, response);
264
+ return response;
265
+ } finally {
266
+ this.pending.delete(r);
267
+ }
268
+ })();
269
+ this.pending.set(r, promise);
270
+ return promise;
271
+ }
272
+ /** Synchronous cache peek — null if absent or within the stale grace window. */
273
+ peek(region) {
274
+ const entry = this.cache.get(region ?? this.defaultRegion);
275
+ if (!entry) return null;
276
+ if (entry.response.expiresAt - nowSeconds() <= STALE_GRACE_SECONDS) return null;
277
+ return entry.response.token;
278
+ }
279
+ /**
280
+ * Produce a MapLibre / Mapbox GL `transformRequest`. Attaches
281
+ * `Authorization: Bearer <jwt>` only to CHRT tile URLs; style, sprite, and
282
+ * glyph URLs pass through untouched (never leak the token to third-party
283
+ * CDNs). Kicks off a background mint immediately so the cache warms within
284
+ * one round-trip; the first tile or two may go unauthenticated and be
285
+ * retried by the renderer.
286
+ */
287
+ transformRequest(region) {
288
+ const r = region ?? this.defaultRegion;
289
+ void this.getToken(r).catch(() => void 0);
290
+ return (url) => {
291
+ if (!url.startsWith(this.tileBaseUrl) || !TILE_URL_PATH.test(url)) return void 0;
292
+ const token = this.peek(r);
293
+ if (!token) return { url };
294
+ return { url, headers: { Authorization: `Bearer ${token}` } };
295
+ };
296
+ }
297
+ /** Clear the cached token + timer for a region. */
298
+ invalidate(region) {
299
+ const r = region ?? this.defaultRegion;
300
+ const entry = this.cache.get(r);
301
+ if (entry?.refreshTimer) clearTimeout(entry.refreshTimer);
302
+ this.cache.delete(r);
303
+ }
304
+ /**
305
+ * Browser-only defense against `setTimeout` freezing while a tab is
306
+ * suspended (laptop sleep, mobile background, bfcache restore): proactively
307
+ * re-mint near-expiry entries on wake. Inert under SSR / Node / Deno.
308
+ */
309
+ registerLifecycleHandlers() {
310
+ if (this.lifecycleRegistered) return;
311
+ if (typeof document === "undefined" || typeof window === "undefined") return;
312
+ const onWake = () => {
313
+ if (document.visibilityState !== "visible") return;
314
+ for (const [region, entry] of this.cache.entries()) {
315
+ if (entry.response.expiresAt - nowSeconds() >= REFRESH_MARGIN_SECONDS) continue;
316
+ void this.refreshInBackground(region);
317
+ }
318
+ };
319
+ document.addEventListener("visibilitychange", onWake);
320
+ window.addEventListener("pageshow", (e) => {
321
+ if (e.persisted) onWake();
322
+ });
323
+ this.lifecycleRegistered = true;
324
+ }
325
+ };
326
+
124
327
  // src/endpoints/tiles.ts
125
328
  var TilesEndpoint = class {
126
- constructor(http) {
329
+ constructor(http, opts) {
127
330
  this.http = http;
331
+ this.region = opts?.region ?? DEFAULT_TILE_REGION;
332
+ this.tileBaseUrl = (opts?.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
128
333
  }
129
- /** Full style.json URL with API key as query param */
334
+ region;
335
+ tileBaseUrl;
336
+ /**
337
+ * @deprecated CHRT has no remote `style.json` endpoint — the S-52 style is
338
+ * generated client-side. Use `chrt.style(opts)` to get a ready-to-use
339
+ * MapLibre style object instead. Calling this throws to prevent silently
340
+ * pointing a map at a dead URL.
341
+ */
130
342
  styleUrl() {
131
- return `${this.http.getBaseUrl()}/tiles/style.json?key=${this.http.getApiKey()}`;
343
+ throw new Error(
344
+ "chrt.tiles.styleUrl() is no longer supported: CHRT serves no remote style.json. Use chrt.style(opts) to generate an S-52 MapLibre style object, and chrt.transformRequest() for tile auth. See https://chrt.co/docs/vector-tiles"
345
+ );
132
346
  }
133
- /** Individual tile URL template */
134
- tileUrl(z, x, y, format = "mvt") {
135
- return `${this.http.getBaseUrl()}/tiles/${z}/${x}/${y}.${format}?key=${this.http.getApiKey()}`;
347
+ /**
348
+ * Vector tile URL template for the configured region, served by the
349
+ * tile-worker at `tiles.chrt.co`. Tiles are JWT-gated — pair this with
350
+ * `chrt.transformRequest()`, which attaches the `Authorization: Bearer`
351
+ * header. Prefer `chrt.style()`, which wires the source for you.
352
+ */
353
+ tileUrl(z, x, y) {
354
+ return `${this.tileBaseUrl}/v1/tiles/${this.region}/${z}/${x}/${y}.mvt`;
136
355
  }
137
- /** List available tileset groups */
356
+ /**
357
+ * @deprecated Tilesets were consolidated to one PMTiles per region (2026-04);
358
+ * the per-group catalogue endpoint is legacy and no longer served. Render via
359
+ * `chrt.style()` instead, which emits a single vector source per region.
360
+ */
138
361
  async groups() {
139
362
  return this.http.request("/tiles/groups");
140
363
  }
@@ -326,7 +549,7 @@ function mapServerError(body, status) {
326
549
  }
327
550
 
328
551
  // src/endpoints/offline-token-manager.ts
329
- var REFRESH_MARGIN_SECONDS = 30;
552
+ var REFRESH_MARGIN_SECONDS2 = 30;
330
553
  function now() {
331
554
  return Math.floor(Date.now() / 1e3);
332
555
  }
@@ -347,7 +570,7 @@ var OfflineTokenManager = class {
347
570
  }
348
571
  const key = this.cacheKey(deviceId, regions);
349
572
  const cached = this.cache.get(key);
350
- if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS) {
573
+ if (cached && cached.expiresAt - now() > REFRESH_MARGIN_SECONDS2) {
351
574
  return cached;
352
575
  }
353
576
  const inflight = this.pending.get(key);
@@ -969,47 +1192,6 @@ function dirname(p) {
969
1192
  return p.slice(0, k);
970
1193
  }
971
1194
 
972
- // src/pagination.ts
973
- async function* paginate(fetchPage, getItems, getTotalCount, options) {
974
- const pageSize = options?.pageSize ?? 100;
975
- const signal = options?.signal;
976
- let offset = 0;
977
- while (true) {
978
- if (signal?.aborted) return;
979
- const response = await fetchPage(offset, signal);
980
- const items = getItems(response);
981
- for (const item of items) {
982
- yield item;
983
- }
984
- if (items.length === 0) return;
985
- offset += items.length;
986
- const total = getTotalCount(response);
987
- if (total > 0 && offset >= total) return;
988
- }
989
- }
990
-
991
- // src/webhooks.ts
992
- var VALID_TYPES = /* @__PURE__ */ new Set(["cell.updated", "cell.new", "cell.deleted"]);
993
- function parseWebhook(body) {
994
- if (typeof body !== "object" || body === null) {
995
- throw new Error("Invalid webhook payload: expected an object");
996
- }
997
- const obj = body;
998
- if (typeof obj.id !== "string") {
999
- throw new Error("Invalid webhook payload: missing 'id'");
1000
- }
1001
- if (typeof obj.timestamp !== "string") {
1002
- throw new Error("Invalid webhook payload: missing 'timestamp'");
1003
- }
1004
- if (typeof obj.type !== "string" || !VALID_TYPES.has(obj.type)) {
1005
- throw new Error(`Invalid webhook payload: unknown type '${String(obj.type)}'`);
1006
- }
1007
- if (typeof obj.data !== "object" || obj.data === null) {
1008
- throw new Error("Invalid webhook payload: missing 'data'");
1009
- }
1010
- return obj;
1011
- }
1012
-
1013
1195
  // src/atlas/s52-colors.ts
1014
1196
  var S52_DAY = {
1015
1197
  // Depth
@@ -5210,7 +5392,10 @@ var NAUTICAL_SYMBOLS = [
5210
5392
  getSvg: landmarkSpire,
5211
5393
  group: "landmarks",
5212
5394
  description: "Church spire or steeple, conspicuous from seaward. Useful for visual fixing.",
5213
- s52PlRef: "LNDMRK17",
5395
+ // CATLMK 20 alone has no dedicated PL symbol (renders POSGEN01/03); church spires carry
5396
+ // FUNCTN 20/21 and render as the religious-building symbol BUIREL01 (BUIREL13 when conspicuous)
5397
+ s52PlRef: "BUIREL01",
5398
+ s57Class: "LNDMRK",
5214
5399
  attributeKey: "CATLMK",
5215
5400
  attributeValue: 20,
5216
5401
  renderSize: "32x32",
@@ -5227,9 +5412,11 @@ var NAUTICAL_SYMBOLS = [
5227
5412
  getSvg: landmarkTower,
5228
5413
  group: "landmarks",
5229
5414
  description: "Tower conspicuous from seaward. General-purpose landmark symbol.",
5230
- s52PlRef: "LNDMRK20",
5415
+ s52PlRef: "TOWERS01",
5416
+ s57Class: "LNDMRK",
5231
5417
  attributeKey: "CATLMK",
5232
5418
  attributeValue: 17,
5419
+ // TOWERS03 = conspicuous variant
5233
5420
  renderSize: "32x32",
5234
5421
  colors: [{ token: "CHBLK", hex: "#000000", usage: "Tower body, cap, position circle" }],
5235
5422
  lineStyles: [{ element: "Tower outline", width: 0.8, color: "#000000" }, { element: "Position circle", width: 0.6, pattern: "2, 2", color: "#000000" }],
@@ -5244,7 +5431,8 @@ var NAUTICAL_SYMBOLS = [
5244
5431
  getSvg: landmarkChimney,
5245
5432
  group: "landmarks",
5246
5433
  description: "Industrial chimney or smokestack visible from the sea.",
5247
- s52PlRef: "LNDMRK01",
5434
+ s52PlRef: "CHIMNY01",
5435
+ s57Class: "LNDMRK",
5248
5436
  attributeKey: "CATLMK",
5249
5437
  attributeValue: 3,
5250
5438
  renderSize: "32x32",
@@ -5261,7 +5449,8 @@ var NAUTICAL_SYMBOLS = [
5261
5449
  getSvg: landmarkFlagstaff,
5262
5450
  group: "landmarks",
5263
5451
  description: "Flagstaff or flagpole conspicuous from seaward.",
5264
- s52PlRef: "LNDMRK03",
5452
+ s52PlRef: "FLGSTF01",
5453
+ s57Class: "LNDMRK",
5265
5454
  attributeKey: "CATLMK",
5266
5455
  attributeValue: 5,
5267
5456
  renderSize: "32x32",
@@ -5281,9 +5470,11 @@ var NAUTICAL_SYMBOLS = [
5281
5470
  getSvg: landmarkMonument,
5282
5471
  group: "landmarks",
5283
5472
  description: "Monument or memorial conspicuous from the sea.",
5284
- s52PlRef: "LNDMRK05",
5473
+ s52PlRef: "MONUMT12",
5474
+ s57Class: "LNDMRK",
5285
5475
  attributeKey: "CATLMK",
5286
5476
  attributeValue: 9,
5477
+ // MONUMT02 = non-conspicuous variant
5287
5478
  renderSize: "32x32",
5288
5479
  colors: [{ token: "CHBLK", hex: "#000000", usage: "Monument body, base, position circle" }],
5289
5480
  lineStyles: [{ element: "Body outline", width: 0.8, color: "#000000" }, { element: "Position circle", width: 0.6, pattern: "2, 2", color: "#000000" }],
@@ -5299,6 +5490,7 @@ var NAUTICAL_SYMBOLS = [
5299
5490
  group: "landmarks",
5300
5491
  description: "Traditional windmill with rotating vanes. Conspicuous landmark.",
5301
5492
  s52PlRef: "WNDMIL12",
5493
+ s57Class: "LNDMRK",
5302
5494
  attributeKey: "CATLMK",
5303
5495
  attributeValue: 18,
5304
5496
  renderSize: "32x32",
@@ -5316,6 +5508,7 @@ var NAUTICAL_SYMBOLS = [
5316
5508
  group: "landmarks",
5317
5509
  description: "Modern 3-blade wind turbine. Increasingly common offshore landmark.",
5318
5510
  s52PlRef: "WIMCON11",
5511
+ s57Class: "LNDMRK",
5319
5512
  attributeKey: "CATLMK",
5320
5513
  attributeValue: 19,
5321
5514
  renderSize: "32x32",
@@ -5332,9 +5525,11 @@ var NAUTICAL_SYMBOLS = [
5332
5525
  getSvg: landmarkDome,
5333
5526
  group: "landmarks",
5334
5527
  description: "Dome or cupola (e.g., observatory, mosque) visible from the sea.",
5335
- s52PlRef: "LNDMRK03",
5528
+ s52PlRef: "DOMES011",
5529
+ s57Class: "LNDMRK",
5336
5530
  attributeKey: "CATLMK",
5337
5531
  attributeValue: 15,
5532
+ // DOMES001 = non-conspicuous variant
5338
5533
  renderSize: "32x32",
5339
5534
  colors: [{ token: "CHBLK", hex: "#000000", usage: "Dome, base, position circle" }],
5340
5535
  lineStyles: [{ element: "Base outline", width: 0.8, color: "#000000" }, { element: "Dome curve", width: 0.8, color: "#000000" }, { element: "Position circle", width: 0.6, pattern: "2, 2", color: "#000000" }],
@@ -5349,7 +5544,8 @@ var NAUTICAL_SYMBOLS = [
5349
5544
  getSvg: landmarkRadarScanner,
5350
5545
  group: "landmarks",
5351
5546
  description: "Radar scanner antenna conspicuous from seaward.",
5352
- s52PlRef: "LNDMRK12",
5547
+ s52PlRef: "RASCAN01",
5548
+ s57Class: "LNDMRK",
5353
5549
  attributeKey: "CATLMK",
5354
5550
  attributeValue: 16,
5355
5551
  renderSize: "32x32",
@@ -5366,7 +5562,8 @@ var NAUTICAL_SYMBOLS = [
5366
5562
  getSvg: landmarkDishAerial,
5367
5563
  group: "landmarks",
5368
5564
  description: "Satellite dish or parabolic antenna visible from the sea.",
5369
- s52PlRef: "LNDMRK12",
5565
+ s52PlRef: "DSHAER01",
5566
+ s57Class: "LNDMRK",
5370
5567
  attributeKey: "CATLMK",
5371
5568
  attributeValue: 4,
5372
5569
  renderSize: "32x32",
@@ -5384,6 +5581,7 @@ var NAUTICAL_SYMBOLS = [
5384
5581
  group: "landmarks",
5385
5582
  description: "Water tower. Elevated tank on supporting structure, conspicuous landmark.",
5386
5583
  s52PlRef: "TOWERS12",
5584
+ s57Class: "SILTNK",
5387
5585
  attributeKey: "CATSIL",
5388
5586
  attributeValue: 4,
5389
5587
  renderSize: "32x32",
@@ -5400,7 +5598,9 @@ var NAUTICAL_SYMBOLS = [
5400
5598
  getSvg: landmarkRadioTvMast,
5401
5599
  group: "landmarks",
5402
5600
  description: "Radio or television transmission mast with horizontal cross-arms.",
5403
- s52PlRef: "LNDMRK10",
5601
+ // Radio/TV masts are CATLMK 7 (mast) + FUNCTN in S-57, so both mast entries share MSTCON04
5602
+ s52PlRef: "MSTCON04",
5603
+ s57Class: "LNDMRK",
5404
5604
  attributeKey: "CATLMK",
5405
5605
  attributeValue: 7,
5406
5606
  renderSize: "32x32",
@@ -5417,7 +5617,10 @@ var NAUTICAL_SYMBOLS = [
5417
5617
  getSvg: landmarkCross,
5418
5618
  group: "landmarks",
5419
5619
  description: "Religious cross or calvary conspicuous from seaward.",
5420
- s52PlRef: "LNDMRK05",
5620
+ // The PL has no dedicated symbol for CATLMK 14 (cross/calvary) — S-52 falls back to the
5621
+ // generic position symbol POSGEN01 (POSGEN03 when conspicuous)
5622
+ s52PlRef: "POSGEN01",
5623
+ s57Class: "LNDMRK",
5421
5624
  attributeKey: "CATLMK",
5422
5625
  attributeValue: 14,
5423
5626
  renderSize: "32x32",
@@ -5434,9 +5637,11 @@ var NAUTICAL_SYMBOLS = [
5434
5637
  getSvg: landmarkMast,
5435
5638
  group: "landmarks",
5436
5639
  description: "General mast (not radio/TV). Pole with spherical top.",
5437
- s52PlRef: "LNDMRK10",
5640
+ s52PlRef: "MSTCON04",
5641
+ s57Class: "LNDMRK",
5438
5642
  attributeKey: "CATLMK",
5439
5643
  attributeValue: 7,
5644
+ // MSTCON14 = conspicuous variant
5440
5645
  renderSize: "32x32",
5441
5646
  colors: [{ token: "CHBLK", hex: "#000000", usage: "Mast pole, top ball, position circle" }],
5442
5647
  lineStyles: [{ element: "Mast pole", width: 2, color: "#000000" }, { element: "Position circle", width: 0.6, pattern: "2, 2", color: "#000000" }],
@@ -5452,6 +5657,7 @@ var NAUTICAL_SYMBOLS = [
5452
5657
  group: "landmarks",
5453
5658
  description: "Silo, grain elevator, or storage tank conspicuous from the sea.",
5454
5659
  s52PlRef: "SILBUI01",
5660
+ s57Class: "SILTNK",
5455
5661
  attributeKey: "CATSIL",
5456
5662
  attributeValue: 1,
5457
5663
  renderSize: "32x32",
@@ -8162,6 +8368,18 @@ var S101_FEATURES = [
8162
8368
  isNewInS101: false,
8163
8369
  attributes: ["categoryOfLandmark", "function", "visuallyConspicuous"]
8164
8370
  },
8371
+ {
8372
+ featureClass: "SiloStorageTank",
8373
+ name: "Silo / Storage Tank",
8374
+ s57Equivalent: "SILTNK",
8375
+ section: "E",
8376
+ group: "landmarks",
8377
+ category: "geo",
8378
+ description: "An enclosed structure for storing bulk solids or liquids (silo, grain elevator, water tower, tank). Often conspicuous and useful for position fixing.",
8379
+ portrayalRef: "SILTNK_S101",
8380
+ isNewInS101: false,
8381
+ attributes: ["categoryOfSiloTank", "colour", "visuallyConspicuous"]
8382
+ },
8165
8383
  // ── Section D: Bridges ──
8166
8384
  {
8167
8385
  featureClass: "Bridge",
@@ -9338,7 +9556,7 @@ var BUILD_MINZOOM = 3;
9338
9556
  var BUILD_MAXZOOM = 14;
9339
9557
 
9340
9558
  // src/atlas/source-registry.ts
9341
- var DEFAULT_TILE_BASE_URL = "https://tiles.chrt.co";
9559
+ var DEFAULT_TILE_BASE_URL2 = "https://tiles.chrt.co";
9342
9560
  var DEFAULT_REGION = "NOAA_ENC";
9343
9561
  function buildSources(opts = {}) {
9344
9562
  const mode = opts.mode ?? "pmtiles";
@@ -9351,7 +9569,7 @@ function buildSources(opts = {}) {
9351
9569
  }
9352
9570
  };
9353
9571
  }
9354
- const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL).replace(/\/+$/, "");
9572
+ const base = (opts.tileBaseUrl ?? DEFAULT_TILE_BASE_URL2).replace(/\/+$/, "");
9355
9573
  return {
9356
9574
  [region]: {
9357
9575
  type: "vector",
@@ -11078,6 +11296,47 @@ function getStyleStats(style) {
11078
11296
  };
11079
11297
  }
11080
11298
 
11299
+ // src/pagination.ts
11300
+ async function* paginate(fetchPage, getItems, getTotalCount, options) {
11301
+ const pageSize = options?.pageSize ?? 100;
11302
+ const signal = options?.signal;
11303
+ let offset = 0;
11304
+ while (true) {
11305
+ if (signal?.aborted) return;
11306
+ const response = await fetchPage(offset, signal);
11307
+ const items = getItems(response);
11308
+ for (const item of items) {
11309
+ yield item;
11310
+ }
11311
+ if (items.length === 0) return;
11312
+ offset += items.length;
11313
+ const total = getTotalCount(response);
11314
+ if (total > 0 && offset >= total) return;
11315
+ }
11316
+ }
11317
+
11318
+ // src/webhooks.ts
11319
+ var VALID_TYPES = /* @__PURE__ */ new Set(["cell.updated", "cell.new", "cell.deleted"]);
11320
+ function parseWebhook(body) {
11321
+ if (typeof body !== "object" || body === null) {
11322
+ throw new Error("Invalid webhook payload: expected an object");
11323
+ }
11324
+ const obj = body;
11325
+ if (typeof obj.id !== "string") {
11326
+ throw new Error("Invalid webhook payload: missing 'id'");
11327
+ }
11328
+ if (typeof obj.timestamp !== "string") {
11329
+ throw new Error("Invalid webhook payload: missing 'timestamp'");
11330
+ }
11331
+ if (typeof obj.type !== "string" || !VALID_TYPES.has(obj.type)) {
11332
+ throw new Error(`Invalid webhook payload: unknown type '${String(obj.type)}'`);
11333
+ }
11334
+ if (typeof obj.data !== "object" || obj.data === null) {
11335
+ throw new Error("Invalid webhook payload: missing 'data'");
11336
+ }
11337
+ return obj;
11338
+ }
11339
+
11081
11340
  // src/index.ts
11082
11341
  var Chrt = class {
11083
11342
  /** Tile endpoints — styleUrl, tileUrl, groups */
@@ -11095,15 +11354,22 @@ var Chrt = class {
11095
11354
  featuresEp;
11096
11355
  queryEp;
11097
11356
  http;
11357
+ tileAuth;
11358
+ region;
11359
+ tileBaseUrl;
11098
11360
  constructor(apiKeyOrConfig) {
11099
11361
  const config = typeof apiKeyOrConfig === "string" ? { apiKey: apiKeyOrConfig } : apiKeyOrConfig;
11362
+ this.region = config.region ?? DEFAULT_TILE_REGION;
11363
+ this.tileBaseUrl = config.tileBaseUrl ?? DEFAULT_TILE_BASE_URL;
11100
11364
  this.http = new HttpClient(config.apiKey, config.baseUrl, config.retries);
11101
- this.tiles = new TilesEndpoint(this.http);
11365
+ const tileOpts = { region: this.region, tileBaseUrl: this.tileBaseUrl };
11366
+ this.tiles = new TilesEndpoint(this.http, tileOpts);
11102
11367
  this.cells = new CellsEndpoint(this.http);
11103
11368
  this.coverage = new CoverageEndpoint(this.http);
11104
11369
  this.featuresEp = new FeaturesEndpoint(this.http);
11105
11370
  this.queryEp = new QueryEndpoint(this.http);
11106
11371
  this.offline = new OfflineEndpoint(this.http, config.deviceId, config.manifestPath);
11372
+ this.tileAuth = new TileAuth(this.http, tileOpts);
11107
11373
  }
11108
11374
  /* ── Convenience shortcuts ───────────────────────── */
11109
11375
  /** Query GeoJSON features by bounding box */
@@ -11180,28 +11446,63 @@ var Chrt = class {
11180
11446
  );
11181
11447
  }
11182
11448
  /* ── Map integration helpers ─────────────────────── */
11183
- /** Returns a style.json URL ready for Mapbox GL / MapLibre */
11449
+ /**
11450
+ * Build a ready-to-use MapLibre / Mapbox GL style object for CHRT's nautical
11451
+ * charts. The default is the full IHO **S-52** portrayal; pick a palette and
11452
+ * tweak rendering via `opts`. The returned object is a standard MapLibre
11453
+ * style — mutate it freely, or build your own from scratch over the CHRT
11454
+ * vector source (see `buildSources`) for a fully custom look.
11455
+ *
11456
+ * Pair with `transformRequest()` so tile requests carry their auth token.
11457
+ *
11458
+ * @example
11459
+ * const map = new maplibregl.Map({
11460
+ * container: 'map',
11461
+ * style: chrt.style({ palette: 'DAY' }),
11462
+ * transformRequest: chrt.transformRequest(),
11463
+ * });
11464
+ *
11465
+ * @remarks S-101 portrayal is on the roadmap; today `style()` emits S-52.
11466
+ */
11467
+ style(opts = {}) {
11468
+ const { palette = "DAY", ...styleOpts } = opts;
11469
+ return generateStyle(palette, {
11470
+ region: this.region,
11471
+ tileBaseUrl: this.tileBaseUrl,
11472
+ ...styleOpts
11473
+ });
11474
+ }
11475
+ /**
11476
+ * @deprecated CHRT serves no remote `style.json`. Use {@link style} to get a
11477
+ * MapLibre style object instead. Throws to avoid silently pointing a map at a
11478
+ * dead URL.
11479
+ */
11184
11480
  styleUrl() {
11185
11481
  return this.tiles.styleUrl();
11186
11482
  }
11187
11483
  /**
11188
- * Returns a `transformRequest` function for Mapbox GL JS / MapLibre GL JS.
11189
- * Injects the API key header for requests to api.chrt.co.
11484
+ * Returns a `transformRequest` for Mapbox GL JS / MapLibre GL JS that mints a
11485
+ * short-lived tile JWT from your API key and attaches it as
11486
+ * `Authorization: Bearer` on requests to `tiles.chrt.co` only. Style, sprite,
11487
+ * and glyph URLs pass through untouched. The cache warms in the background on
11488
+ * first call, so the first tile or two may be retried by the renderer.
11190
11489
  *
11191
11490
  * @example
11192
- * const map = new mapboxgl.Map({
11193
- * style: chrt.styleUrl(),
11491
+ * const map = new maplibregl.Map({
11492
+ * style: chrt.style(),
11194
11493
  * transformRequest: chrt.transformRequest(),
11195
11494
  * });
11196
11495
  */
11197
- transformRequest() {
11198
- const apiKey = this.http.getApiKey();
11199
- return (url) => {
11200
- if (url.includes("api.chrt.co")) {
11201
- return { url, headers: { "x-api-key": apiKey } };
11202
- }
11203
- return void 0;
11204
- };
11496
+ transformRequest(opts) {
11497
+ return this.tileAuth.transformRequest(opts?.region);
11498
+ }
11499
+ /**
11500
+ * Pre-mint the tile token for a region. Optional — `transformRequest()` warms
11501
+ * the cache automatically — but useful if you want to surface a mint error
11502
+ * (e.g. `region_not_licensed`, `tile_quota_exceeded`) before the map loads.
11503
+ */
11504
+ async warmTiles(region) {
11505
+ await this.tileAuth.getToken(region);
11205
11506
  }
11206
11507
  };
11207
11508
  export {
@@ -11216,6 +11517,7 @@ export {
11216
11517
  ChrtError,
11217
11518
  DEFAULT_REGION,
11218
11519
  DEFAULT_TILE_BASE_URL,
11520
+ DEFAULT_TILE_REGION,
11219
11521
  LABELED_LAYERS,
11220
11522
  LABEL_BEHAVIOR,
11221
11523
  NAUTICAL_SYMBOLS,
@@ -11233,6 +11535,8 @@ export {
11233
11535
  S52_NIGHT,
11234
11536
  SYMBOL_GROUPS,
11235
11537
  SYMBOL_MAP,
11538
+ TileAuth,
11539
+ TileTokenError,
11236
11540
  applyColorTheme,
11237
11541
  bandFilterExpression,
11238
11542
  buildLabelLayer,