@chrtco/sdk 0.9.2 → 0.10.1

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/README.md CHANGED
@@ -4,6 +4,10 @@ Official TypeScript SDK for the [CHRT](https://chrt.co) nautical chart API.
4
4
 
5
5
  Zero dependencies · Works in Node 18+, browsers, Deno, Bun · Full TypeScript types
6
6
 
7
+ > **Situational awareness only — not for primary navigation.** CHRT is not an ECDIS and is not
8
+ > SOLAS-compliant. Do not use it as your primary means of navigation. Every chart CHRT serves comes
9
+ > from the issuing hydrographic office; keep the source attribution visible in anything you build.
10
+
7
11
  ## Install
8
12
 
9
13
  ```bash
@@ -15,7 +19,8 @@ npm install @chrtco/sdk
15
19
  ```typescript
16
20
  import { Chrt } from '@chrtco/sdk';
17
21
 
18
- const chrt = new Chrt('chrt_live_...');
22
+ // Read the key from the environment — never hard-code it in source.
23
+ const chrt = new Chrt(process.env.CHRT_API_KEY!);
19
24
 
20
25
  // Get depth areas in a bounding box
21
26
  const features = await chrt.features({
@@ -30,25 +35,119 @@ const nearby = await chrt.query({
30
35
  layer: 'BOYCAR,BOYLAT',
31
36
  });
32
37
 
33
- // List ENC cells
34
- const cells = await chrt.cells.list({ source: 'NOAA' });
38
+ // List ENC cells — `source` takes the chart-source id ('NOAA_ENC'), not the
39
+ // agency name ('NOAA'), which matches nothing. chrt.coverage.list() lists the ids.
40
+ const cells = await chrt.cells.list({ source: 'NOAA_ENC' });
35
41
 
36
42
  // Get a specific cell
37
43
  const cell = await chrt.cells.get('US5MA11M');
44
+ console.log(cell.bbox_north, cell.bbox_west); // → 42.38 -71.05
45
+ ```
46
+
47
+ ## API keys
48
+
49
+ Keys are issued in the CHRT platform under **Settings → API keys** and look like:
50
+
51
+ ```
52
+ chrt_pk_live_<team>_<random> chrt_sk_live_<team>_<random>
53
+ chrt_pk_test_<team>_<random> chrt_sk_test_<team>_<random>
54
+ ▲ ▲
55
+ │ └── environment: live | test
56
+ └── key type: pk (publishable) | sk (secret)
57
+ ```
58
+
59
+ | | `pk` — publishable | `sk` — secret |
60
+ |---|---|---|
61
+ | Intended for | client-side code (browser, mobile app) | servers, CI, backend jobs |
62
+ | Retrievable later | ✓ — re-reveal it in the dashboard | ✗ — shown once at creation; regenerate if lost |
63
+
64
+ > **`pk` and `sk` carry identical API privileges.** The prefix records intent and whether CHRT keeps
65
+ > the plaintext for you to re-reveal — it is **not** a capability boundary. Any key you ship to a
66
+ > client can be extracted from it and replayed with full access.
67
+ >
68
+ > So treat any key embedded in a browser bundle or mobile app as public: develop against a
69
+ > `test`-environment key, grant it only the scopes it needs, and rotate it if it leaks.
70
+ >
71
+ > For a key you ship to a browser, set **Allowed domains** on it (platform → API keys). A browser
72
+ > request whose `Origin` isn't on the list is refused, so a key lifted from your bundle won't work
73
+ > on someone else's site. It is a **browser-only deterrent, never a boundary** — a non-browser
74
+ > caller sends no `Origin` and is allowed through, by design. Do **not** set the **IP allowlist**:
75
+ > it does not work, and setting it bricks the key on every real request path.
76
+
77
+ ## Spatial queries
78
+
79
+ `query()` answers "what's around me". Underway, the question is narrower.
80
+
81
+ ```typescript
82
+ // What's charted AHEAD — position + bearing, no route needed.
83
+ const fan = await chrt.ahead({
84
+ point: [-70.9245, 42.33604],
85
+ bearing: gps.cog, // degrees true
86
+ arc: 60, // FULL sector width, so ±30°
87
+ rangeNm: 3,
88
+ safetyDepth: 3, // omit if you set `vessel`
89
+ });
90
+
91
+ // What's charted along a PLANNED PASSAGE — ordered by how far in you meet it.
92
+ const passage = await chrt.alongRoute({
93
+ route: [[-70.95, 42.33], [-71.0, 42.35], [-71.02, 42.36]],
94
+ bufferNm: 0.5,
95
+ });
96
+ for (const f of passage.features) {
97
+ const { along_m, distance_m, depth_clearance_m } = f.properties;
98
+ }
99
+
100
+ // Which CHARTS that passage needs — a different question, so a different call.
101
+ const { cells } = await chrt.coverage.forRoute({ route, bufferNm: 1 });
102
+ ```
103
+
104
+ Neither replaces the other: a boat with a route still drifts, and COG rarely equals the route
105
+ bearing — so a passage app wants `alongRoute()` once per route edit and `ahead()` continuously
106
+ against the real track.
107
+
108
+ **These tell you what is CHARTED, never whether it is safe.** There is no severity score and no
109
+ `safe` flag, deliberately — a risk model is a navigation-grade claim, and CHRT is
110
+ situational-awareness only. You get facts that sort (`distance_m`, `bearing_deg`, `along_m`,
111
+ `least_depth_m`, `depth_clearance_m` — negative means shallower than you declared safe) and your
112
+ application owns the judgment.
113
+
114
+ **An empty result is not an all-clear.** It can mean nothing is charted there, or that CHRT has no
115
+ coverage there at all. Every response carries a `coverage` block — check `coverage.covered` before
116
+ telling a user anything.
117
+
118
+ ```typescript
119
+ if (!fan.coverage.covered) {
120
+ // CHRT holds no chart for this sector. Say so — do not report "clear".
121
+ }
38
122
  ```
39
123
 
124
+ There is no `speed` parameter, by design: turning SOG into a lookahead distance is a navigational
125
+ judgment that belongs to you. The usual recipe is `rangeNm = SOG_knots × lookahead_hours`,
126
+ narrowing `arc` as speed rises. Re-fan on a meaningful course change rather than on a timer.
127
+
40
128
  ## Maps (MapLibre GL JS / Mapbox GL JS)
41
129
 
42
- `chrt.style()` returns a ready-to-use IHO **S-52** nautical style; `chrt.transformRequest()`
43
- mints a short-lived tile token from your API key and attaches it to tile requests
44
- automatically. Examples use MapLibre — substitute `mapbox-gl` if you're already on Mapbox.
130
+ CHRT gives you two ways to get an IHO **S-52** style. They are not interchangeable — pick by whether
131
+ your client runs JavaScript:
132
+
133
+ | | `chrt.style()` | `chrt.styleUrl()` / `chrt.fetchStyle()` |
134
+ |---|---|---|
135
+ | What it is | a JS function that **builds** the style locally | a **URL CHRT serves** (`tiles.chrt.co/v1/styles/…`) |
136
+ | Network | none — works offline | one authenticated request |
137
+ | Usable from | **JavaScript only** | any HTTP client (Swift, Kotlin, Python, curl) |
138
+ | Custom style bound to your key | not applied | **applied server-side** (Dev plan and above) |
139
+
140
+ `chrt.transformRequest()` mints a short-lived tile token from your API key and attaches it to tile
141
+ **and served-style** requests automatically. Examples use MapLibre — substitute `mapbox-gl` if you're
142
+ already on Mapbox.
45
143
 
46
144
  ```typescript
47
145
  import maplibregl from 'maplibre-gl';
48
146
  import { Chrt } from '@chrtco/sdk';
49
147
 
50
- const chrt = new Chrt('chrt_live_...');
148
+ const chrt = new Chrt(process.env.CHRT_API_KEY!);
51
149
 
150
+ // Locally generated style — no network, no custom style applied.
52
151
  const map = new maplibregl.Map({
53
152
  container: 'map',
54
153
  style: chrt.style({ palette: 'DAY' }), // 'DAY' | 'DUSK' | 'NIGHT'
@@ -58,14 +157,47 @@ const map = new maplibregl.Map({
58
157
  });
59
158
  ```
60
159
 
61
- Tiles are served from `tiles.chrt.co` and gated by a short-lived ES256 JWT. The SDK mints
62
- it from your key, attaches it as `Authorization: Bearer` on tile requests only, and refreshes
63
- it before expiry — you never handle tokens directly.
160
+ Or use the **served** style, which honours the CHRTstyle custom style bound to your key:
161
+
162
+ ```typescript
163
+ const map = new maplibregl.Map({
164
+ container: 'map',
165
+ style: await chrt.styleUrl({ theme: 'dusk' }), // 'day' | 'dusk' | 'night'
166
+ transformRequest: chrt.transformRequest(), // required — authenticates the style request too
167
+ });
168
+ ```
169
+
170
+ `styleUrl()` is `async`: it pre-mints the tile token so the style request — which MapLibre does
171
+ **not** retry — is guaranteed to carry auth. Always pair it with `transformRequest()`.
172
+
173
+ Tiles are served from `tiles.chrt.co` and gated by a short-lived ES256 JWT. The SDK mints it from
174
+ your key, attaches it as `Authorization: Bearer`, and refreshes it before expiry — you never handle
175
+ tokens directly.
176
+
177
+ ### Symbol families
178
+
179
+ `style()`, `styleUrl()` and `fetchStyle()` all select symbol artwork, but **the option is spelled
180
+ differently** on the local and served paths:
181
+
182
+ ```typescript
183
+ chrt.style({ symbolSet: 's101' }); // local — symbolSet, palette: 'DAY' | 'DUSK' | 'NIGHT'
184
+ await chrt.styleUrl({ symbols: 's101' }); // served — symbols, theme: 'day' | 'dusk' | 'night'
185
+ ```
186
+
187
+ | Value | Artwork |
188
+ |---|---|
189
+ | `s52` (default) | IHO S-52 Presentation Library — spec-exact ATON/LIGHTS selection |
190
+ | `s101` | official IHO S-101 Portrayal Catalogue artwork |
191
+ | `chrt` | CHRT house set — colour-true silhouettes, capability badges |
192
+ | `raster` | raster symbol set |
193
+
194
+ `s101` and `chrt` need their images loaded at runtime via `loadS101SymbolImages` /
195
+ `loadChrtSymbolImages` from `@chrtco/sdk/atlas/browser`.
64
196
 
65
197
  ### Custom styles
66
198
 
67
- `chrt.style()` returns a standard MapLibre style object you can mutate. For a fully custom
68
- look, build your own style over CHRT's vector source with `buildSources()` and keep
199
+ `chrt.style()` returns a standard MapLibre style object you can mutate. For a fully custom look,
200
+ build your own style over CHRT's vector source with `buildSources()` and keep
69
201
  `chrt.transformRequest()` for auth:
70
202
 
71
203
  ```typescript
@@ -76,22 +208,150 @@ const sources = buildSources({ region: 'NOAA_ENC' });
76
208
  // Each S-57 class (DEPARE, LIGHTS, BOYLAT, …) is a `source-layer` inside that one source.
77
209
  ```
78
210
 
79
- > S-101 portrayal is on the roadmap; today `chrt.style()` emits S-52.
211
+ ## Using CHRT without JavaScript
212
+
213
+ > **Prefer codegen?** The whole HTTP surface is described by an OpenAPI 3.1 spec at
214
+ > **https://api.chrt.co/openapi.json** — generate a typed Swift/Kotlin/Python/Go client
215
+ > from it instead of hand-rolling the calls below.
216
+
217
+ Native iOS/Android, Swift, Kotlin, Python and plain `curl` clients cannot call `chrt.style()` — it is
218
+ a JavaScript function that builds a style object in-process. **Use the served style API instead.**
219
+ It is two HTTP calls, no SDK required.
220
+
221
+ **1. Mint a tile token** (valid for a short window; re-mint before `expires_at`):
222
+
223
+ ```bash
224
+ curl -X POST https://api.chrt.co/v1/mint/tile-token \
225
+ -H "x-api-key: $CHRT_API_KEY" \
226
+ -H "content-type: application/json" \
227
+ -d '{"region":"NOAA_ENC"}'
228
+ ```
229
+
230
+ ```jsonc
231
+ {
232
+ "token": "eyJhbGciOiJFUzI1NiIs…", // ES256 JWT — do not log
233
+ "expires_at": 1752600000, // unix seconds, matches the JWT `exp`
234
+ "kid": "chrt-tile-2026-04",
235
+ "build_tag": "…",
236
+ "region": "NOAA_ENC"
237
+ }
238
+ ```
239
+
240
+ **2. Fetch the style and the tiles** with that token as a Bearer credential:
241
+
242
+ ```bash
243
+ curl https://tiles.chrt.co/v1/styles/NOAA_ENC/style.json?theme=day \
244
+ -H "Authorization: Bearer $TOKEN"
245
+
246
+ curl https://tiles.chrt.co/v1/tiles/NOAA_ENC/12/1234/1543.mvt \
247
+ -H "Authorization: Bearer $TOKEN"
248
+ ```
249
+
250
+ The returned document is a standard MapLibre style — hand it to MapLibre Native (iOS/Android),
251
+ `maplibre-gl`, or any renderer that speaks the style spec. Point your renderer's request transformer
252
+ at the same `Authorization: Bearer` header so tile requests carry it too.
253
+
254
+ The REST endpoints in the table below (`/v1/features`, `/v1/query`, `/v1/query/ahead`, `/v1/cells`,
255
+ `/v1/coverage`) take the API key directly as `x-api-key` — no token minting needed.
256
+
257
+ ## Click-to-inspect (CHRTinteract)
258
+
259
+ Wires click → decode onto any MapLibre / Mapbox GL map showing CHRT tiles. **Requires the Dev plan or
260
+ above**; the entitlement is enforced server-side at attach time.
261
+
262
+ ```typescript
263
+ const handle = await chrt.interact.attach(map, {
264
+ onInspect: (result) => {
265
+ // result.kind: 'navaid' | 'bridge' | 'area'
266
+ // e.g. a light characteristic decoded to "Fl(2+1) W 10s", sector arcs,
267
+ // bridge clearances, restriction lists, coordinates
268
+ console.log(result);
269
+ },
270
+ });
271
+
272
+ handle.detach();
273
+ ```
274
+
275
+ With no `onInspect`/`onCandidates` callback, `attach()` renders a minimal built-in popup instead.
276
+ Every result carries a `notice` — the non-SOLAS disclaimer, issuing-HO attribution and data
277
+ currency. The built-in popup always renders them; **if you build your own UI, render them too.**
278
+
279
+ A chart click rarely hits exactly one feature (a buoy on a channel over a depth area), so interact
280
+ collects every candidate under the click, dedupes them across tile boundaries by S-57 identity, and
281
+ orders them point → line → area with safety-critical classes first.
282
+
283
+ ```typescript
284
+ // Headless candidate collection for any screen point or [lng, lat].
285
+ const { candidates, select } = await chrt.interact.inspectAll(map, [-70.86, 42.02]);
286
+ const result = await select(candidates[0]);
287
+
288
+ // Decode a single feature you already have.
289
+ const one = await chrt.interact.inspect(feature);
290
+
291
+ // Resolve full S-57 source properties + cell provenance (edition, update number,
292
+ // issue date). Metered against your `featureCalls` quota.
293
+ const enriched = await chrt.interact.inspect(feature, { enrich: true });
294
+ ```
80
295
 
81
296
  ## Configuration
82
297
 
83
298
  ```typescript
84
299
  const chrt = new Chrt({
85
- apiKey: 'chrt_live_...',
86
- baseUrl: 'https://api.chrt.co/v1', // default
87
- retries: 2, // retry on 5xx, default 2
300
+ apiKey: process.env.CHRT_API_KEY!,
301
+ baseUrl: 'https://api.chrt.co/v1', // default
302
+ retries: 2, // retry on 5xx + network errors, default 2
88
303
  region: 'NOAA_ENC', // default tile region
89
304
  tileBaseUrl: 'https://tiles.chrt.co', // override for staging tiles
305
+ deviceId: 'my-app-install-uuid', // required for offline.* — see below
306
+ manifestPath: './chrt-offline-manifest.json', // optional — enables offline budget + list()
307
+ vessel: { safetyDepthM: 3 }, // one safety depth for every depth-aware call
90
308
  });
91
309
  ```
92
310
 
311
+ `new Chrt('chrt_sk_live_…')` is shorthand for `new Chrt({ apiKey: 'chrt_sk_live_…' })`.
312
+
313
+ ### `vessel` — one safety depth
314
+
315
+ Set your draft-plus-margin once and every depth-aware call inherits it: `ahead()` and
316
+ `alongRoute()` filter at it, and `style()` shades soundings and draws danger rings at it. An
317
+ explicit per-call `safetyDepth` / `safetyContour` always wins.
318
+
319
+ It is **client-side and per-instance, deliberately — not bound to your API key**. A consumer app
320
+ has one key and many skippers with different boats; a key-scoped draft would give them all the
321
+ same number. Construct a `Chrt` per user, or pass `safetyDepth` per call.
322
+
323
+ **With no `vessel`, CHRT sends no depth at all** rather than guessing a draft — filtering on an
324
+ invented one would silently drop real hazards.
325
+
326
+ > Not yet reachable from the *served* style: `styleUrl()` takes `theme` + `symbols` only, so a
327
+ > non-JS client cannot currently get per-user depth shading.
328
+
93
329
  ## API Reference
94
330
 
331
+ **Which methods can a non-JS client use?** Everything under *HTTP endpoint* below is a plain REST
332
+ call any language can make. Everything marked **JS-only** runs in-process and has no HTTP equivalent.
333
+
334
+ | Method | HTTP endpoint | Notes |
335
+ |---|---|---|
336
+ | `chrt.features(opts)` | `GET /v1/features` | |
337
+ | `chrt.query(opts)` | `GET /v1/query` | |
338
+ | `chrt.ahead(opts)` | `GET /v1/query/ahead` | what's charted in a sector ahead |
339
+ | `chrt.alongRoute(opts)` | `GET /v1/query/route` | same, for a planned passage |
340
+ | `chrt.alongRouteIter(opts)` | `GET /v1/query/route` | auto-paginates |
341
+ | `chrt.cells.list(opts?)` | `GET /v1/cells` | |
342
+ | `chrt.cells.get(id)` | `GET /v1/cells/{id}` | |
343
+ | `chrt.coverage.list(opts?)` | `GET /v1/coverage` | |
344
+ | `chrt.coverage.forRoute(opts)` | `GET /v1/coverage/route` | which cells a passage needs |
345
+ | `chrt.warmTiles(region?)` | `POST /v1/mint/tile-token` | |
346
+ | `chrt.styleUrl(opts?)` | `GET {tileBaseUrl}/v1/styles/{region}/style.json` | mints first |
347
+ | `chrt.fetchStyle(opts?)` | same, then fetches it | |
348
+ | `chrt.interact.entitlement()` | `POST /v1/interact/entitlement` | |
349
+ | `chrt.style(opts?)` | — | **JS-only** — local generator |
350
+ | `chrt.transformRequest(opts?)` | — | **JS-only** — returns a closure |
351
+ | `chrt.tiles.tileUrl(z, x, y)` | — | **JS-only** — string builder |
352
+ | `chrt.interact.attach/inspectAll` | — | **JS-only** — needs a GL JS map |
353
+ | `chrt.featuresIter` / `cellsIter` | — | **JS-only** — async iterators |
354
+
95
355
  ### `chrt.features(options)`
96
356
  Query GeoJSON features by bounding box.
97
357
 
@@ -99,8 +359,11 @@ Query GeoJSON features by bounding box.
99
359
  |--------|------|----------|-------------|
100
360
  | `bbox` | `[w, s, e, n]` | ✓ | Bounding box |
101
361
  | `layer` | `string` | | S-57 layer code (e.g. `DEPARE`) |
102
- | `limit` | `number` | | Max results (default 100) |
362
+ | `limit` | `number` | | Max results (default 100, max 1000) |
103
363
  | `offset` | `number` | | Pagination offset |
364
+ | `signal` | `AbortSignal` | | |
365
+
366
+ Returns a GeoJSON `FeatureCollection` with `total_count`, `limit` and `offset` alongside `features`.
104
367
 
105
368
  ### `chrt.query(options)`
106
369
  Spatial proximity search around a point.
@@ -108,37 +371,130 @@ Spatial proximity search around a point.
108
371
  | Option | Type | Required | Description |
109
372
  |--------|------|----------|-------------|
110
373
  | `point` | `[lng, lat]` | ✓ | Centre point |
111
- | `radius` | `number` | | Radius in metres (default 1000) |
374
+ | `radius` | `number` | | Radius in metres (default 500, clamped 1–50 000) |
112
375
  | `layer` | `string` | | Comma-separated layer codes |
113
- | `limit` | `number` | | Max results |
376
+ | `limit` | `number` | | Max results (default 50, max 500) |
377
+ | `signal` | `AbortSignal` | | |
378
+
379
+ Returns a `QueryResponse`: a GeoJSON `FeatureCollection` whose `properties` carry `layer`, `cell_id`
380
+ and `distance_m` (geodesic metres from `point`), plus a `query` block echoing what the server
381
+ **actually applied after clamping** — read `res.query.radius_m` to see that `radius: 90000` silently
382
+ became `50000`. Unlike `chrt.features()`, this endpoint is **not paginated**: it returns no `offset`,
383
+ so widen `radius` rather than paging.
384
+
385
+ ### `chrt.ahead(options)`
386
+ The fan-ahead query — the significant features in a sector from a point along a bearing. Built for
387
+ the position-plus-COG case: a craft with a heading but no planned route.
388
+
389
+ | Option | Type | Required | Description |
390
+ |--------|------|----------|-------------|
391
+ | `point` | `[lng, lat]` | ✓ | Origin |
392
+ | `bearing` | `number` | ✓ | Degrees true; out-of-range values normalise |
393
+ | `arc` | `number` | | **Full** sector width centred on `bearing` (default 90 — `arc: 60` sweeps ±30°) |
394
+ | `range` | `number` | | Metres (default 5000, clamped 1–50 000) |
395
+ | `rangeNm` | `number` | | Nautical miles — mutually exclusive with `range` (passing both throws) |
396
+ | `safetyDepth` | `number` | | Filters out only what is **known** to be deeper |
397
+ | `layer` | `string` | | Defaults to CHRT's significant-feature set |
398
+ | `limit` | `number` | | Default 50, max 500 |
399
+
400
+ Each feature carries `distance_m`, `bearing_deg`, `least_depth_m` and `depth_clearance_m` (negative =
401
+ shallower than your `safetyDepth`).
402
+
403
+ > **Facts that sort, never a verdict.** There is deliberately no severity score and no `safe` flag —
404
+ > a risk model is a navigation-grade claim. You sort; CHRT ranks nothing.
405
+ >
406
+ > **An empty result is not an all-clear.** Zero features in covered water and zero features because
407
+ > CHRT has no cells there are different answers. Every response carries a `coverage` block
408
+ > (`covered`, `covered_fraction`, `cells`) — **check `coverage.covered` before telling a user
409
+ > anything.**
114
410
 
115
411
  ### `chrt.cells.list(options?)`
116
412
  List available ENC cells.
117
413
 
118
- ### `chrt.cells.get(cellId)`
119
- Get metadata for a specific cell.
414
+ | Option | Type | Required | Description |
415
+ |--------|------|----------|-------------|
416
+ | `source` | `string` | | Chart-source **id**, e.g. `'NOAA_ENC'` — *not* the agency name `'NOAA'`, which matches nothing and returns an empty list rather than an error. `chrt.coverage.list()` enumerates the ids. |
417
+ | `status` | `string` | | e.g. `'CURRENT'`. Case-insensitive. |
418
+ | `scaleBand` | `ScaleBand` | | `'Overview'` \| `'General'` \| `'Coastal'` \| `'Approach'` \| `'Harbor'`. Matched **case-sensitively** — `'harbor'` matches nothing. |
419
+ | `bbox` | `[w, s, e, n]` | | Cells whose bounds *intersect* this box |
420
+ | `limit` | `number` | | Max results (default 100, max 1000) |
421
+ | `offset` | `number` | | Pagination offset |
422
+ | `signal` | `AbortSignal` | | |
120
423
 
121
- ### `chrt.style(opts?)`
122
- Returns a ready-to-use MapLibre/Mapbox GL **S-52** style object. Options: `palette`
123
- (`'DAY'` | `'DUSK'` | `'NIGHT'`), `region`, `depthUnits`, plus the other atlas style options.
424
+ Returns `{ cells, total_count, limit, offset }`. Use `chrt.cellsIter(options?)` to page automatically.
124
425
 
125
- ### `chrt.transformRequest(opts?)`
126
- Returns a `transformRequest` that mints a short-lived tile JWT from your API key and attaches
127
- it as `Authorization: Bearer` on `tiles.chrt.co` requests only. Pass `{ region }` to override
128
- the default region.
426
+ ### `chrt.cells.get(cellId)`
427
+ Get metadata for a specific cell. Takes the id positionally: `chrt.cells.get('US5MA11M')`.
428
+ Returns a `CellDetail`:
429
+
430
+ | Field | Type | Description |
431
+ |-------|------|-------------|
432
+ | `cell_id` | `string` | e.g. `'US5MA11M'` |
433
+ | `title` | `string \| null` | Chart title |
434
+ | `chart_source` | `string` | Source id, e.g. `'NOAA_ENC'` |
435
+ | `status` | `string` | e.g. `'CURRENT'` |
436
+ | `scale` | `number \| null` | Compilation scale denominator |
437
+ | `scale_band` | `string \| null` | Usage band — a `ScaleBand` name, e.g. `'Harbor'` |
438
+ | `edition` · `update_number` · `issue_date` | | **Data currency** — the fields that say how current the chart is |
439
+ | `bbox_north` · `bbox_south` · `bbox_east` · `bbox_west` | `number \| null` | Bounds in decimal degrees (WGS 84), `null` if the issuing office recorded none. Written as a group, so in practice all four are set or all four are `null`. |
440
+ | `last_checked_at` | `string \| null` | ISO 8601 — when CHRT last checked the issuing office's catalogue for this cell. Freshness of the *check*, not of the chart; use `edition` / `update_number` / `issue_date` for chart currency. |
441
+
442
+ > **Bounds are flat here and on `coverage.forRoute()` — one shape across the API.** Up to
443
+ > 0.9.2 the SDK declared these flat fields while `/v1/cells` alone returned a nested `bbox`
444
+ > object, so `cell.bbox_north` typechecked and read `undefined` on every response. The
445
+ > endpoint was the outlier; it now sends what this type has always declared, and reading
446
+ > `cell.bbox_north` works against any deployed CHRT API.
447
+
448
+ ### `chrt.coverage.list(options?)`
449
+ List the chart sources available to your key. Options: `country` (e.g. `'US'`), `source`.
450
+ Returns `{ sources, total_cells }`, where each source carries `source` (the **id** you filter cells
451
+ by, e.g. `'NOAA_ENC'`), `agency` (the issuing office's display name, e.g. `'NOAA'`), `cell_count` and
452
+ `scale_bands` (counts keyed by band name). This is the endpoint that tells you which `source` ids are
453
+ valid — passing an agency name anywhere else silently matches nothing.
454
+
455
+ ### `chrt.style(opts?)` — **JS-only**
456
+ Returns a ready-to-use MapLibre/Mapbox GL **S-52** style object. Generated locally: no network, works
457
+ offline, and **does not apply a custom style bound to your key**. Options: `palette`
458
+ (`'DAY' | 'DUSK' | 'NIGHT'`), `symbolSet`, `region`, `depthUnits`, `safetyContour`, `chartMode`, plus
459
+ the other atlas style options. Non-JS clients want `styleUrl()` instead.
460
+
461
+ ### `chrt.styleUrl(opts?)`
462
+ URL of the **served** S-52 style — the CHRTstyle Style API at
463
+ `{tileBaseUrl}/v1/styles/{region}/style.json`. `async`: it pre-mints the tile token so the style
464
+ request (which MapLibre does not retry) carries auth. Options: `theme`
465
+ (`'day' | 'dusk' | 'night'`), `symbols`. Always pair with `transformRequest()`.
466
+
467
+ The served document honours the CHRTstyle custom style bound to your key (a **Dev+** feature; Free
468
+ keys and keys with no bound style get the default themes). This is the style path for any client that
469
+ can't run `chrt.style()`.
470
+
471
+ ### `chrt.fetchStyle(opts?)`
472
+ Fetches the served style as a MapLibre style object. Same options as `styleUrl()`, plus `signal`.
473
+ Prefer it over `styleUrl()` when you want to inspect or mutate the document, or when you can't use
474
+ `transformRequest()` for the style request itself. Throws `ChrtError` on failure; does not retry.
475
+
476
+ ### `chrt.transformRequest(opts?)` — **JS-only**
477
+ Returns a `transformRequest` closure that attaches the tile JWT as `Authorization: Bearer` on
478
+ `tiles.chrt.co` **tile and served-style** requests only. Sprite, glyph and third-party URLs pass
479
+ through untouched. Pass `{ region }` to override the default region.
129
480
 
130
481
  ### `chrt.warmTiles(region?)`
131
- Pre-mints the tile token so you can surface auth errors (e.g. `region_not_licensed`,
132
- `tile_quota_exceeded`) before the map loads. Optional — `transformRequest()` warms the cache
133
- on its own.
482
+ Pre-mints the tile token so you can surface auth errors (`region_not_licensed`,
483
+ `tile_quota_exceeded`, …) before the map loads. Optional — `transformRequest()` warms the cache on
484
+ its own.
485
+
486
+ ### `chrt.tiles.tileUrl(z, x, y)` — **JS-only**
487
+ Concrete vector tile URL at `tiles.chrt.co` for one tile. Prefer `chrt.style()` / `chrt.styleUrl()`,
488
+ which wire the source for you.
134
489
 
135
- ### `chrt.tiles.tileUrl(z, x, y)`
136
- Single vector tile URL template at `tiles.chrt.co`. Prefer `chrt.style()`, which wires the
137
- source for you.
490
+ ### `chrt.interact.*`
491
+ See [Click-to-inspect](#click-to-inspect-chrtinteract). `attach(map, opts?)` and
492
+ `inspectAll(map, point, opts?)` need a GL JS map instance; `inspect(feature, opts?)` decodes locally
493
+ but checks entitlement over HTTP first.
138
494
 
139
- ### `chrt.styleUrl()` · `chrt.tiles.groups()` — deprecated
140
- CHRT serves no remote `style.json` — use `chrt.style()`. Tilesets were consolidated to one
141
- source per region (2026-04), so the per-group catalogue is legacy.
495
+ ### `chrt.tiles.groups()` — deprecated
496
+ Tilesets were consolidated to one source per region (2026-04). The route no longer exists — calling
497
+ it fails with `ChrtError` 404. Use `chrt.style()` or `chrt.styleUrl()`.
142
498
 
143
499
  ## Error Handling
144
500
 
@@ -154,10 +510,21 @@ try {
154
510
  }
155
511
  ```
156
512
 
513
+ Typed errors by area: `ChrtError` (REST), `TileTokenError` (minting — `invalid_api_key`,
514
+ `region_not_licensed`, `tile_quota_exceeded`, `region_not_available`, `feature_flag_disabled`,
515
+ `network_error`, `unexpected_response`), `InteractError` (`interact_not_enabled` — includes the
516
+ current `plan` and an upgrade hint — `invalid_api_key`, `missing_scope`, `enrich_failed`,
517
+ `unknown_layer`, `network_error`, `unexpected_response`), and `OfflineError` (below).
518
+
157
519
  ## Offline Downloads (CHRToffline)
158
520
 
159
521
  Download MBTiles bundles for offline rendering. Bundles are SHA-256 verified before commit. Interrupted downloads resume from a `.partial` sidecar file.
160
522
 
523
+ **Requires the Pro plan or above**, enforced server-side at mint time — Free and Dev keys get
524
+ `OfflineError` code `offline_requires_pro_plan`. The key also needs the `offline:download`
525
+ scope, which is checked **before** the plan: a key without it gets `offline_not_enabled`
526
+ regardless of tier, so add the scope first or the plan refusal never surfaces.
527
+
161
528
  **Runtime support in v1:** Node 18+, Bun, Deno. Browser and React Native are deferred to a follow-up.
162
529
 
163
530
  ```typescript
@@ -211,7 +578,8 @@ try {
211
578
  case 'download_corrupt': console.log('SHA mismatch'); break;
212
579
  case 'network_interrupted': console.log('resume later'); break;
213
580
  case 'coverage_denied': console.log('region not licensed'); break;
214
- case 'offline_not_enabled': console.log('enable use_offline on this API key'); break;
581
+ case 'offline_requires_pro_plan': console.log('CHRToffline needs Pro+ — chrt.co/pricing'); break;
582
+ case 'offline_not_enabled': console.log('add the offline:download scope to this API key'); break;
215
583
  }
216
584
  }
217
585
  }