@chrtco/sdk 0.9.1 → 0.10.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/CHANGELOG.md CHANGED
@@ -3,11 +3,85 @@
3
3
  All notable changes to `@chrtco/sdk` will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
+ - **`/v1/cells` now sends flat `bbox_north` / `bbox_south` / `bbox_east` / `bbox_west`, matching `CellDetail`.** This is a **wire change to `/v1/cells` and `/v1/cells/{id}`**, not just a type change — read it before upgrading a REST client.
7
+ - The bug: the SDK declared flat `bbox_*`, the endpoint returned a nested `bbox` object. `cell.bbox_north` typechecked and was `undefined` on **every** response — TypeScript vouching for a field that never arrived, which is worse than no type at all.
8
+ - **The endpoint was the outlier, so the endpoint moved.** `enc_cells`' own columns, `/v1/coverage/route`'s `CoverageRouteCell`, and every published `CellDetail` are flat; `formatCell()` alone nested. One shape across the API is the point.
9
+ - **REST clients reading `cell.bbox.north` must move to `cell.bbox_north`.** SDK callers need no change: this is the shape `@chrtco/sdk` has always declared, so **0.9.2 callers are fixed the moment the endpoint deploys** — the phantom field starts carrying real values without a release.
10
+ - Bounds are now passed through **independently** rather than gated on `bbox_west != null`, which would have discarded three real bounds had that one column ever been null alone.
11
+ - The shape is now pinned from both sides: `serve-cells/format-cell.test.ts` (Deno) asserts what the server emits, `endpoints/cells.test.ts` (vitest) asserts what the SDK expects. Neither existed, which is why the two drifted for months. They cannot share a fixture across runtimes — **change one, change the other.**
12
+ - New: **`cell.last_checked_at`** (ISO 8601 | null) — when CHRT last checked the issuing office's catalogue for the cell. It is the freshness of the *check*, not of the chart: `edition` / `update_number` / `issue_date` remain the data-currency fields.
13
+ - **CHRToffline is Pro-and-above, and `chrt.offline.*` now says so.** New typed error code `offline_requires_pro_plan` (403 from `POST /v1/mint/offline-token`). This refusal previously fell through to the generic 403 mapping and surfaced as **`coverage_denied`** — reporting a plan gate as a coverage problem, and pointing the user at the one thing that could not fix it. The per-key `offline:download` scope (`offline_not_enabled`) is a **separate, narrower gate that is checked first**: a key without the scope is refused before the plan is ever read.
14
+ - **`chrt.cells.list()` gains the `scaleBand` and `bbox` filters** that `/v1/cells` has always supported and the SDK could not reach:
15
+ - `scaleBand?: ScaleBand` — new exported union `'Overview' | 'General' | 'Coastal' | 'Approach' | 'Harbor'`, matched **case-sensitively** (`'harbor'` matches nothing). These are the S-57 usage bands CHRT derives from compilation scale; they are **words, not band numbers** — `scale_band: '4'` matches no cell.
16
+ - `bbox?: [west, south, east, north]` — cells whose bounds *intersect* the box (same tuple encoding as `chrt.features()`).
17
+ - **`chrt.query()` returns the new `QueryResponse`** instead of a bare `FeatureCollection` — a widening, so no caller breaks:
18
+ - `res.query` (`{ point, radius_m }`) is now declared. The server echoes back what it **actually applied after clamping**, which is the only way to learn that `radius: 90000` was cut to `50000`. It was previously invisible to typed callers.
19
+ - `res.features[].properties` is typed `QueryFeatureProperties` (`layer`, `cell_id`, `distance_m`, plus an open index signature) — `distance_m` no longer needs a cast, which is the whole point of a proximity search.
20
+ - Documented that `/v1/query` is **not paginated**: it returns no `offset`. Widen `radius` instead of paging.
21
+ - **Corrected docs that sent callers to empty results** (no code change, but they were wrong in the same way the types were):
22
+ - `source` takes the chart-source **id** (`'NOAA_ENC'`), not the agency name (`'NOAA'`). Passing the agency **returns an empty list rather than an error** — a silent zero. Affects `CellsOptions`, `CoverageOptions`, `CoverageSource.source`, and the README quick-start, which shipped the broken `{ source: 'NOAA' }`.
23
+ - `chrt.query()`'s `limit` default (50) and cap (500) are now documented on `QueryOptions`, alongside the `radius` default corrected in the README rewrite below.
24
+ - New exported types: `ScaleBand`, `QueryResponse`, `QueryFeatureProperties`.
25
+ - **`vessel` on `ChrtConfig` — one safety depth, read by every depth-aware call (ADR 0020 F2).** CHRT had grown four disagreeing notions of "how deep is safe"; this is the single place a client states it once.
26
+ ```ts
27
+ const chrt = new Chrt({ apiKey, vessel: { safetyDepthM: 3 } });
28
+ await chrt.ahead({ point, bearing }); // filters at 3 m
29
+ chrt.style(); // labels + danger rings at 3 m
30
+ ```
31
+ - Defaults `ahead({ safetyDepth })`, `alongRoute({ safetyDepth })` and `style({ safetyContour })`. An explicit per-call value always wins. One `safetyDepthM` drives **30 sites** in the generated style (SOUNDG two-shade + all four danger-ring classes) at a single value.
32
+ - **Client-side and per-instance, deliberately — never bound to your API key.** A consumer app has one key and many skippers with different boats; a key-scoped draft would give them all the same number, which is worse than none — it's a confidently wrong one. Construct a `Chrt` per user, or pass `safetyDepth` per call.
33
+ - **With no profile, nothing is sent.** CHRT never guesses a draft: filtering on an invented one silently drops real hazards.
34
+ - **Known gap:** `styleUrl()` (the *served* style — the only style path for non-JS clients) still takes `theme` + `symbols` only, so a **native app cannot yet give each user their own depth shading**. And `depareFillExpression` bins depth into fixed 0/2/5/10 tiers, so the profile does not reshade depth *areas* — only soundings and danger rings. Both are tracked in ADR 0020.
35
+ - New type: `VesselProfile`.
36
+ - **`chrt.alongRoute()` + `chrt.coverage.forRoute()` — the along-route pair (CHRTquery; ADR 0020 D1).** Completes the family the staged `routeQuery` / `routeFeatures` stubs never shipped, and **splits them by the question they answer**:
37
+ - **`chrt.alongRoute({ route, buffer | bufferNm, safetyDepth, layer, limit, offset })`** → `GET /v1/query/route`. What's charted in a corridor either side of a passage — the planning sibling of {@link ahead}. Each feature carries **`along_m`** (metres along the route to closest approach) alongside `distance_m`, `least_depth_m` and `depth_clearance_m`, and results are **ordered by `along_m`** — the order you actually meet them, not lateral offset. `chrt.alongRouteIter()` auto-paginates.
38
+ - **`chrt.coverage.forRoute({ route, buffer | bufferNm, scaleBand, limit })`** → `GET /v1/coverage/route`. Which ENC cells a passage needs, largest-scale first (S-57 says believe the finer chart), each with `edition` / `update_number` / `issue_date` so you can tell whether the copy you hold is stale. **It lives on `coverage`, not `query`, deliberately** — it returns *cells*, so it answers "which charts must I hold?", a download-planning question and CHRToffline's companion. Filing it with the hazard queries is what made the family look like two overlapping things.
39
+ - **`alongRoute` does not replace `ahead`, and `ahead` does not replace it.** A boat with a route still drifts, and COG rarely equals the route bearing — a passage app wants both: this once per route edit, `ahead` continuously against the real track.
40
+ - Same invariants as `ahead`: **facts that sort, never a verdict** (no severity, no `safe` flag), `safetyDepth` omits only what is *known* to be deeper, and **an empty result is not a clear passage** — every response carries `coverage` plus `attribution` / `disclaimer` / `notice`.
41
+ - Both validate locally before the round trip (≥2 waypoints; `buffer` and `bufferNm` are mutually exclusive).
42
+ - New types: `AlongRouteOptions`, `AlongRouteResponse`, `AlongRouteFeatureProperties`, `CoverageRouteOptions`, `CoverageRouteResponse`, `CoverageRouteCell`. **Removed** the never-shipped staged types `RouteQueryOptions`, `RouteQueryResponse`, `RouteFeatureQueryOptions`, `RouteFeatureQueryResponse` — they were exported but unreachable (their methods were commented out), so nothing could have depended on them.
43
+ - **`chrt.ahead()` — the fan-ahead query (CHRTquery; ADR 0020).** *"What's charted ahead of me?"* — the significant features in a sector from a point along a bearing. Built for the position-plus-COG case: a craft that has a heading but not a planned route.
44
+ - `chrt.ahead({ point, bearing, arc, range | rangeNm, safetyDepth, layer, limit })` → `GET /v1/query/ahead`. `bearing` is degrees true (a COG/heading or a fixed input; out-of-range values normalise, so a heading that wraps past north is fine). `arc` is the **full** sector width centred on the bearing — `arc: 60` sweeps ±30°. `arc: 360` degenerates to a full circle, making `ahead` a strict generalisation of `query()`.
45
+ - **Facts that sort, never a verdict.** Each feature carries `distance_m`, `bearing_deg`, `least_depth_m` and `depth_clearance_m` (negative = shallower than your declared `safetyDepth`). There is **no severity score and no `safe` flag, deliberately**: a risk model is a navigation-grade claim, and CHRT is situational-awareness only. You sort; CHRT ranks nothing.
46
+ - **`safetyDepth` filters out only what is *known* to be deeper.** Features with no charted depth are always kept — a wreck with no sounded value is still a wreck. Fail-safe by construction.
47
+ - **An empty result is not an all-clear**, so every response carries a `coverage` block (`covered`, `covered_fraction`, and `cells` with real `edition` / `update_number` / `issue_date` per source cell) alongside inline `attribution`, `disclaimer` and `notice`. Zero features in covered water and zero features because CHRT has no cells there are different answers; `coverage` is what tells them apart. **Check `coverage.covered` before telling a user anything.**
48
+ - **No `speed` parameter, by design** — deriving range from SOG means deciding how many minutes of lookahead a vessel needs, which is a navigational judgment that belongs to the client. Recipe: `rangeNm = SOG_knots × lookahead_hours`, narrowing `arc` as speed rises; re-fan on a meaningful course change rather than a timer.
49
+ - Defaults to CHRT's significant-feature set (S-52 viewing group 24020 — wrecks, obstructions, rocks, plus the depth-carrying classes). Pass `layer` for a different set, or use `query()` for an unfiltered point-radius search.
50
+ - New types: `AheadOptions`, `AheadResponse`, `AheadFeatureProperties`, `AheadCoverageCell`.
51
+ - **README rewritten against the real surface — three artifacts in the published tarball disagreed.**
52
+ The API Reference had not been updated past 0.8.0, so `README.md`, `CHANGELOG.md` and
53
+ `dist/index.d.ts` shipped inside `@chrtco/sdk@0.9.2` contradicting each other:
54
+ - **`styleUrl()` is no longer documented as deprecated.** The README still said *"CHRT serves no
55
+ remote `style.json` — use `chrt.style()`"*, three minor versions after the served Style API
56
+ shipped (`GET tiles.chrt.co/v1/styles/{region}/style.json`, live and auth-gated in production).
57
+ This was the severity-1 case: `chrt.style()` is a **JavaScript function**, so native iOS/Android,
58
+ Swift, Kotlin and Python clients cannot call it — the served style URL is the only style path
59
+ they have, and the README told them it did not exist.
60
+ - **Every method is now marked JS-only or HTTP-backed**, with the HTTP method and path for each, so
61
+ non-JS clients can see what they can actually reach. New section: *Using CHRT without
62
+ JavaScript* — the two-call mint → served-style recipe in `curl`, documenting the **wire** shape
63
+ (`expires_at`, `build_tag` — snake_case; the SDK's `TileTokenResponse` camelCases them).
64
+ - **`interact`, `fetchStyle()`, `coverage.list()` and `ahead()` are documented for the first time.**
65
+ `interact`, `fetchStyle` and `coverage` were all present in `0.9.2`'s `.d.ts` but absent from its
66
+ README — shipped and invisible.
67
+ - **`tiles.groups()` stays deprecated** — unlike `styleUrl()` it is genuinely gone (`/v1/tiles/*`
68
+ is absent from the gateway route table; it fails with `ChrtError` 404). The old heading's error
69
+ was bundling a live method and a dead one under one "deprecated" label.
70
+ - **Quick Start no longer hard-codes a secret**, and no longer uses `chrt_live_…` — a key format
71
+ CHRT has never generated. Real keys are `chrt_{pk|sk}_{live|test}_{team}_{random}`. New *API
72
+ keys* section documents the real `pk`/`sk` distinction: they differ in whether the plaintext can
73
+ be re-revealed, **not in privilege**. `pk` is not a capability boundary, so the README says to
74
+ treat any key embedded in a client as public rather than implying the prefix protects it.
75
+ - **The non-SOLAS disclaimer is now in the README.** The SDK enforces the notice on every
76
+ `InspectResult`, but the most-read CHRT artifact — the npm landing page — carried no disclaimer
77
+ at all.
78
+ - `QueryOptions.radius` JSDoc corrected: the server default is **500 m**, not 1000. `ChrtConfig.apiKey`
79
+ JSDoc corrected to the real key format. (JSDoc only — no behaviour change; both ship in `.d.ts`.)
6
80
  - **Symbol families (`symbolSet`)** — `chrt.style({ symbolSet })`, `styleUrl({ symbols })` and `fetchStyle({ symbols })` select the symbol artwork:
7
81
  - **`s52`** (default) — now **spec-exact for ATONs and LIGHTS**: buoy/beacon icon selection is compiled from the S-52 Presentation Library lookup tables (COLOUR-list keyed — preferred-channel junction marks render correctly; no IALA region hardcoding), and light flares follow the LIGHTS05/06 conditional-symbology procedure (LIGHTS11/12/13 colour-specific flares, LIGHTS81/82 strip/floodlight, LITDEF11 default).
8
82
  - **`s101`** — the official IHO S-101 Portrayal Catalogue ATON/LIGHTS artwork (vendored at a pinned commit, themed through the official S-101 day/dusk/night profiles). Load images via `loadS101SymbolImages` (`@chrtco/sdk/atlas/browser`).
9
83
  - **`chrt`** — the CHRT house set: simplified colour-true silhouettes (tinted at runtime from the feature's COLOUR — green marks render green, red marks red), fixed-palette identity marks (cardinals, isolated danger, safe water), and capability badges (sound for fog signals, radar for racons). Load via `loadChrtSymbolImages`.
10
- - Served Style API: `GET /v1/styles/{region}/style.json?symbols=s52|s101|chrt` (own cache-key slot, `X-Style-Symbols` response header); composes with Dev+ custom-style claims.
84
+ - Served Style API: `GET /v1/styles/{region}/style.json?symbols=s52|s101|chrt|raster` (own cache-key slot, `X-Style-Symbols` response header). All four families are **free on every plan** — the style route reads no `plan_id`, and the served document is byte-identical across tiers. Composes with a bound **custom style**, which is the one part of CHRTstyle that is paid (Dev and above).
11
85
  - **Behaviour change (S-52 correctness):** LIGHTS icons are no longer SDF-tinted by COLOUR; the procedure-selected flare carries its own colour. Blue/orange lights now render the spec's LITDEF11 default flare (use the `chrt` family for exact-colour flares).
12
86
 
13
87
  ## [0.9.1] — 2026-07-14
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 });
38
102
  ```
39
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
+ }
122
+ ```
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,146 @@ 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
+ Native iOS/Android, Swift, Kotlin, Python and plain `curl` clients cannot call `chrt.style()` — it is
214
+ a JavaScript function that builds a style object in-process. **Use the served style API instead.**
215
+ It is two HTTP calls, no SDK required.
216
+
217
+ **1. Mint a tile token** (valid for a short window; re-mint before `expires_at`):
218
+
219
+ ```bash
220
+ curl -X POST https://api.chrt.co/v1/mint/tile-token \
221
+ -H "x-api-key: $CHRT_API_KEY" \
222
+ -H "content-type: application/json" \
223
+ -d '{"region":"NOAA_ENC"}'
224
+ ```
225
+
226
+ ```jsonc
227
+ {
228
+ "token": "eyJhbGciOiJFUzI1NiIs…", // ES256 JWT — do not log
229
+ "expires_at": 1752600000, // unix seconds, matches the JWT `exp`
230
+ "kid": "chrt-tile-2026-04",
231
+ "build_tag": "…",
232
+ "region": "NOAA_ENC"
233
+ }
234
+ ```
235
+
236
+ **2. Fetch the style and the tiles** with that token as a Bearer credential:
237
+
238
+ ```bash
239
+ curl https://tiles.chrt.co/v1/styles/NOAA_ENC/style.json?theme=day \
240
+ -H "Authorization: Bearer $TOKEN"
241
+
242
+ curl https://tiles.chrt.co/v1/tiles/NOAA_ENC/12/1234/1543.mvt \
243
+ -H "Authorization: Bearer $TOKEN"
244
+ ```
245
+
246
+ The returned document is a standard MapLibre style — hand it to MapLibre Native (iOS/Android),
247
+ `maplibre-gl`, or any renderer that speaks the style spec. Point your renderer's request transformer
248
+ at the same `Authorization: Bearer` header so tile requests carry it too.
249
+
250
+ The REST endpoints in the table below (`/v1/features`, `/v1/query`, `/v1/query/ahead`, `/v1/cells`,
251
+ `/v1/coverage`) take the API key directly as `x-api-key` — no token minting needed.
252
+
253
+ ## Click-to-inspect (CHRTinteract)
254
+
255
+ Wires click → decode onto any MapLibre / Mapbox GL map showing CHRT tiles. **Requires the Dev plan or
256
+ above**; the entitlement is enforced server-side at attach time.
257
+
258
+ ```typescript
259
+ const handle = await chrt.interact.attach(map, {
260
+ onInspect: (result) => {
261
+ // result.kind: 'navaid' | 'bridge' | 'area'
262
+ // e.g. a light characteristic decoded to "Fl(2+1) W 10s", sector arcs,
263
+ // bridge clearances, restriction lists, coordinates
264
+ console.log(result);
265
+ },
266
+ });
267
+
268
+ handle.detach();
269
+ ```
270
+
271
+ With no `onInspect`/`onCandidates` callback, `attach()` renders a minimal built-in popup instead.
272
+ Every result carries a `notice` — the non-SOLAS disclaimer, issuing-HO attribution and data
273
+ currency. The built-in popup always renders them; **if you build your own UI, render them too.**
274
+
275
+ A chart click rarely hits exactly one feature (a buoy on a channel over a depth area), so interact
276
+ collects every candidate under the click, dedupes them across tile boundaries by S-57 identity, and
277
+ orders them point → line → area with safety-critical classes first.
278
+
279
+ ```typescript
280
+ // Headless candidate collection for any screen point or [lng, lat].
281
+ const { candidates, select } = await chrt.interact.inspectAll(map, [-70.86, 42.02]);
282
+ const result = await select(candidates[0]);
283
+
284
+ // Decode a single feature you already have.
285
+ const one = await chrt.interact.inspect(feature);
286
+
287
+ // Resolve full S-57 source properties + cell provenance (edition, update number,
288
+ // issue date). Metered against your `featureCalls` quota.
289
+ const enriched = await chrt.interact.inspect(feature, { enrich: true });
290
+ ```
80
291
 
81
292
  ## Configuration
82
293
 
83
294
  ```typescript
84
295
  const chrt = new Chrt({
85
- apiKey: 'chrt_live_...',
86
- baseUrl: 'https://api.chrt.co/v1', // default
87
- retries: 2, // retry on 5xx, default 2
296
+ apiKey: process.env.CHRT_API_KEY!,
297
+ baseUrl: 'https://api.chrt.co/v1', // default
298
+ retries: 2, // retry on 5xx + network errors, default 2
88
299
  region: 'NOAA_ENC', // default tile region
89
300
  tileBaseUrl: 'https://tiles.chrt.co', // override for staging tiles
301
+ deviceId: 'my-app-install-uuid', // required for offline.* — see below
302
+ manifestPath: './chrt-offline-manifest.json', // optional — enables offline budget + list()
303
+ vessel: { safetyDepthM: 3 }, // one safety depth for every depth-aware call
90
304
  });
91
305
  ```
92
306
 
307
+ `new Chrt('chrt_sk_live_…')` is shorthand for `new Chrt({ apiKey: 'chrt_sk_live_…' })`.
308
+
309
+ ### `vessel` — one safety depth
310
+
311
+ Set your draft-plus-margin once and every depth-aware call inherits it: `ahead()` and
312
+ `alongRoute()` filter at it, and `style()` shades soundings and draws danger rings at it. An
313
+ explicit per-call `safetyDepth` / `safetyContour` always wins.
314
+
315
+ It is **client-side and per-instance, deliberately — not bound to your API key**. A consumer app
316
+ has one key and many skippers with different boats; a key-scoped draft would give them all the
317
+ same number. Construct a `Chrt` per user, or pass `safetyDepth` per call.
318
+
319
+ **With no `vessel`, CHRT sends no depth at all** rather than guessing a draft — filtering on an
320
+ invented one would silently drop real hazards.
321
+
322
+ > Not yet reachable from the *served* style: `styleUrl()` takes `theme` + `symbols` only, so a
323
+ > non-JS client cannot currently get per-user depth shading.
324
+
93
325
  ## API Reference
94
326
 
327
+ **Which methods can a non-JS client use?** Everything under *HTTP endpoint* below is a plain REST
328
+ call any language can make. Everything marked **JS-only** runs in-process and has no HTTP equivalent.
329
+
330
+ | Method | HTTP endpoint | Notes |
331
+ |---|---|---|
332
+ | `chrt.features(opts)` | `GET /v1/features` | |
333
+ | `chrt.query(opts)` | `GET /v1/query` | |
334
+ | `chrt.ahead(opts)` | `GET /v1/query/ahead` | what's charted in a sector ahead |
335
+ | `chrt.alongRoute(opts)` | `GET /v1/query/route` | same, for a planned passage |
336
+ | `chrt.alongRouteIter(opts)` | `GET /v1/query/route` | auto-paginates |
337
+ | `chrt.cells.list(opts?)` | `GET /v1/cells` | |
338
+ | `chrt.cells.get(id)` | `GET /v1/cells/{id}` | |
339
+ | `chrt.coverage.list(opts?)` | `GET /v1/coverage` | |
340
+ | `chrt.coverage.forRoute(opts)` | `GET /v1/coverage/route` | which cells a passage needs |
341
+ | `chrt.warmTiles(region?)` | `POST /v1/mint/tile-token` | |
342
+ | `chrt.styleUrl(opts?)` | `GET {tileBaseUrl}/v1/styles/{region}/style.json` | mints first |
343
+ | `chrt.fetchStyle(opts?)` | same, then fetches it | |
344
+ | `chrt.interact.entitlement()` | `POST /v1/interact/entitlement` | |
345
+ | `chrt.style(opts?)` | — | **JS-only** — local generator |
346
+ | `chrt.transformRequest(opts?)` | — | **JS-only** — returns a closure |
347
+ | `chrt.tiles.tileUrl(z, x, y)` | — | **JS-only** — string builder |
348
+ | `chrt.interact.attach/inspectAll` | — | **JS-only** — needs a GL JS map |
349
+ | `chrt.featuresIter` / `cellsIter` | — | **JS-only** — async iterators |
350
+
95
351
  ### `chrt.features(options)`
96
352
  Query GeoJSON features by bounding box.
97
353
 
@@ -99,8 +355,11 @@ Query GeoJSON features by bounding box.
99
355
  |--------|------|----------|-------------|
100
356
  | `bbox` | `[w, s, e, n]` | ✓ | Bounding box |
101
357
  | `layer` | `string` | | S-57 layer code (e.g. `DEPARE`) |
102
- | `limit` | `number` | | Max results (default 100) |
358
+ | `limit` | `number` | | Max results (default 100, max 1000) |
103
359
  | `offset` | `number` | | Pagination offset |
360
+ | `signal` | `AbortSignal` | | |
361
+
362
+ Returns a GeoJSON `FeatureCollection` with `total_count`, `limit` and `offset` alongside `features`.
104
363
 
105
364
  ### `chrt.query(options)`
106
365
  Spatial proximity search around a point.
@@ -108,37 +367,130 @@ Spatial proximity search around a point.
108
367
  | Option | Type | Required | Description |
109
368
  |--------|------|----------|-------------|
110
369
  | `point` | `[lng, lat]` | ✓ | Centre point |
111
- | `radius` | `number` | | Radius in metres (default 1000) |
370
+ | `radius` | `number` | | Radius in metres (default 500, clamped 1–50 000) |
112
371
  | `layer` | `string` | | Comma-separated layer codes |
113
- | `limit` | `number` | | Max results |
372
+ | `limit` | `number` | | Max results (default 50, max 500) |
373
+ | `signal` | `AbortSignal` | | |
374
+
375
+ Returns a `QueryResponse`: a GeoJSON `FeatureCollection` whose `properties` carry `layer`, `cell_id`
376
+ and `distance_m` (geodesic metres from `point`), plus a `query` block echoing what the server
377
+ **actually applied after clamping** — read `res.query.radius_m` to see that `radius: 90000` silently
378
+ became `50000`. Unlike `chrt.features()`, this endpoint is **not paginated**: it returns no `offset`,
379
+ so widen `radius` rather than paging.
380
+
381
+ ### `chrt.ahead(options)`
382
+ The fan-ahead query — the significant features in a sector from a point along a bearing. Built for
383
+ the position-plus-COG case: a craft with a heading but no planned route.
384
+
385
+ | Option | Type | Required | Description |
386
+ |--------|------|----------|-------------|
387
+ | `point` | `[lng, lat]` | ✓ | Origin |
388
+ | `bearing` | `number` | ✓ | Degrees true; out-of-range values normalise |
389
+ | `arc` | `number` | | **Full** sector width centred on `bearing` (default 90 — `arc: 60` sweeps ±30°) |
390
+ | `range` | `number` | | Metres (default 5000, clamped 1–50 000) |
391
+ | `rangeNm` | `number` | | Nautical miles — mutually exclusive with `range` (passing both throws) |
392
+ | `safetyDepth` | `number` | | Filters out only what is **known** to be deeper |
393
+ | `layer` | `string` | | Defaults to CHRT's significant-feature set |
394
+ | `limit` | `number` | | Default 50, max 500 |
395
+
396
+ Each feature carries `distance_m`, `bearing_deg`, `least_depth_m` and `depth_clearance_m` (negative =
397
+ shallower than your `safetyDepth`).
398
+
399
+ > **Facts that sort, never a verdict.** There is deliberately no severity score and no `safe` flag —
400
+ > a risk model is a navigation-grade claim. You sort; CHRT ranks nothing.
401
+ >
402
+ > **An empty result is not an all-clear.** Zero features in covered water and zero features because
403
+ > CHRT has no cells there are different answers. Every response carries a `coverage` block
404
+ > (`covered`, `covered_fraction`, `cells`) — **check `coverage.covered` before telling a user
405
+ > anything.**
114
406
 
115
407
  ### `chrt.cells.list(options?)`
116
408
  List available ENC cells.
117
409
 
118
- ### `chrt.cells.get(cellId)`
119
- Get metadata for a specific cell.
410
+ | Option | Type | Required | Description |
411
+ |--------|------|----------|-------------|
412
+ | `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. |
413
+ | `status` | `string` | | e.g. `'CURRENT'`. Case-insensitive. |
414
+ | `scaleBand` | `ScaleBand` | | `'Overview'` \| `'General'` \| `'Coastal'` \| `'Approach'` \| `'Harbor'`. Matched **case-sensitively** — `'harbor'` matches nothing. |
415
+ | `bbox` | `[w, s, e, n]` | | Cells whose bounds *intersect* this box |
416
+ | `limit` | `number` | | Max results (default 100, max 1000) |
417
+ | `offset` | `number` | | Pagination offset |
418
+ | `signal` | `AbortSignal` | | |
120
419
 
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.
420
+ Returns `{ cells, total_count, limit, offset }`. Use `chrt.cellsIter(options?)` to page automatically.
124
421
 
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.
422
+ ### `chrt.cells.get(cellId)`
423
+ Get metadata for a specific cell. Takes the id positionally: `chrt.cells.get('US5MA11M')`.
424
+ Returns a `CellDetail`:
425
+
426
+ | Field | Type | Description |
427
+ |-------|------|-------------|
428
+ | `cell_id` | `string` | e.g. `'US5MA11M'` |
429
+ | `title` | `string \| null` | Chart title |
430
+ | `chart_source` | `string` | Source id, e.g. `'NOAA_ENC'` |
431
+ | `status` | `string` | e.g. `'CURRENT'` |
432
+ | `scale` | `number \| null` | Compilation scale denominator |
433
+ | `scale_band` | `string \| null` | Usage band — a `ScaleBand` name, e.g. `'Harbor'` |
434
+ | `edition` · `update_number` · `issue_date` | | **Data currency** — the fields that say how current the chart is |
435
+ | `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`. |
436
+ | `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. |
437
+
438
+ > **Bounds are flat here and on `coverage.forRoute()` — one shape across the API.** Up to
439
+ > 0.9.2 the SDK declared these flat fields while `/v1/cells` alone returned a nested `bbox`
440
+ > object, so `cell.bbox_north` typechecked and read `undefined` on every response. The
441
+ > endpoint was the outlier; it now sends what this type has always declared, and reading
442
+ > `cell.bbox_north` works against any deployed CHRT API.
443
+
444
+ ### `chrt.coverage.list(options?)`
445
+ List the chart sources available to your key. Options: `country` (e.g. `'US'`), `source`.
446
+ Returns `{ sources, total_cells }`, where each source carries `source` (the **id** you filter cells
447
+ by, e.g. `'NOAA_ENC'`), `agency` (the issuing office's display name, e.g. `'NOAA'`), `cell_count` and
448
+ `scale_bands` (counts keyed by band name). This is the endpoint that tells you which `source` ids are
449
+ valid — passing an agency name anywhere else silently matches nothing.
450
+
451
+ ### `chrt.style(opts?)` — **JS-only**
452
+ Returns a ready-to-use MapLibre/Mapbox GL **S-52** style object. Generated locally: no network, works
453
+ offline, and **does not apply a custom style bound to your key**. Options: `palette`
454
+ (`'DAY' | 'DUSK' | 'NIGHT'`), `symbolSet`, `region`, `depthUnits`, `safetyContour`, `chartMode`, plus
455
+ the other atlas style options. Non-JS clients want `styleUrl()` instead.
456
+
457
+ ### `chrt.styleUrl(opts?)`
458
+ URL of the **served** S-52 style — the CHRTstyle Style API at
459
+ `{tileBaseUrl}/v1/styles/{region}/style.json`. `async`: it pre-mints the tile token so the style
460
+ request (which MapLibre does not retry) carries auth. Options: `theme`
461
+ (`'day' | 'dusk' | 'night'`), `symbols`. Always pair with `transformRequest()`.
462
+
463
+ The served document honours the CHRTstyle custom style bound to your key (a **Dev+** feature; Free
464
+ keys and keys with no bound style get the default themes). This is the style path for any client that
465
+ can't run `chrt.style()`.
466
+
467
+ ### `chrt.fetchStyle(opts?)`
468
+ Fetches the served style as a MapLibre style object. Same options as `styleUrl()`, plus `signal`.
469
+ Prefer it over `styleUrl()` when you want to inspect or mutate the document, or when you can't use
470
+ `transformRequest()` for the style request itself. Throws `ChrtError` on failure; does not retry.
471
+
472
+ ### `chrt.transformRequest(opts?)` — **JS-only**
473
+ Returns a `transformRequest` closure that attaches the tile JWT as `Authorization: Bearer` on
474
+ `tiles.chrt.co` **tile and served-style** requests only. Sprite, glyph and third-party URLs pass
475
+ through untouched. Pass `{ region }` to override the default region.
129
476
 
130
477
  ### `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.
478
+ Pre-mints the tile token so you can surface auth errors (`region_not_licensed`,
479
+ `tile_quota_exceeded`, …) before the map loads. Optional — `transformRequest()` warms the cache on
480
+ its own.
481
+
482
+ ### `chrt.tiles.tileUrl(z, x, y)` — **JS-only**
483
+ Concrete vector tile URL at `tiles.chrt.co` for one tile. Prefer `chrt.style()` / `chrt.styleUrl()`,
484
+ which wire the source for you.
134
485
 
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.
486
+ ### `chrt.interact.*`
487
+ See [Click-to-inspect](#click-to-inspect-chrtinteract). `attach(map, opts?)` and
488
+ `inspectAll(map, point, opts?)` need a GL JS map instance; `inspect(feature, opts?)` decodes locally
489
+ but checks entitlement over HTTP first.
138
490
 
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.
491
+ ### `chrt.tiles.groups()` — deprecated
492
+ Tilesets were consolidated to one source per region (2026-04). The route no longer exists — calling
493
+ it fails with `ChrtError` 404. Use `chrt.style()` or `chrt.styleUrl()`.
142
494
 
143
495
  ## Error Handling
144
496
 
@@ -154,10 +506,21 @@ try {
154
506
  }
155
507
  ```
156
508
 
509
+ Typed errors by area: `ChrtError` (REST), `TileTokenError` (minting — `invalid_api_key`,
510
+ `region_not_licensed`, `tile_quota_exceeded`, `region_not_available`, `feature_flag_disabled`,
511
+ `network_error`, `unexpected_response`), `InteractError` (`interact_not_enabled` — includes the
512
+ current `plan` and an upgrade hint — `invalid_api_key`, `missing_scope`, `enrich_failed`,
513
+ `unknown_layer`, `network_error`, `unexpected_response`), and `OfflineError` (below).
514
+
157
515
  ## Offline Downloads (CHRToffline)
158
516
 
159
517
  Download MBTiles bundles for offline rendering. Bundles are SHA-256 verified before commit. Interrupted downloads resume from a `.partial` sidecar file.
160
518
 
519
+ **Requires the Pro plan or above**, enforced server-side at mint time — Free and Dev keys get
520
+ `OfflineError` code `offline_requires_pro_plan`. The key also needs the `offline:download`
521
+ scope, which is checked **before** the plan: a key without it gets `offline_not_enabled`
522
+ regardless of tier, so add the scope first or the plan refusal never surfaces.
523
+
161
524
  **Runtime support in v1:** Node 18+, Bun, Deno. Browser and React Native are deferred to a follow-up.
162
525
 
163
526
  ```typescript
@@ -211,7 +574,8 @@ try {
211
574
  case 'download_corrupt': console.log('SHA mismatch'); break;
212
575
  case 'network_interrupted': console.log('resume later'); break;
213
576
  case 'coverage_denied': console.log('region not licensed'); break;
214
- case 'offline_not_enabled': console.log('enable use_offline on this API key'); break;
577
+ case 'offline_requires_pro_plan': console.log('CHRToffline needs Pro+ — chrt.co/pricing'); break;
578
+ case 'offline_not_enabled': console.log('add the offline:download scope to this API key'); break;
215
579
  }
216
580
  }
217
581
  }