@chrtco/sdk
v0.10.2
Published
Official TypeScript SDK for the CHRT nautical chart API
Downloads
1,075
Maintainers
Readme
@chrtco/sdk
Official TypeScript SDK for the CHRT nautical chart API.
Zero dependencies · Works in Node 18+, browsers, Deno, Bun · Full TypeScript types
Situational awareness only — not for primary navigation. CHRT is not an ECDIS and is not SOLAS-compliant. Do not use it as your primary means of navigation. Every chart CHRT serves comes from the issuing hydrographic office; keep the source attribution visible in anything you build.
Install
npm install @chrtco/sdkQuick Start
import { Chrt } from '@chrtco/sdk';
// Read the key from the environment — never hard-code it in source.
const chrt = new Chrt(process.env.CHRT_API_KEY!);
// Get depth areas in a bounding box
const features = await chrt.features({
bbox: [-71, 42, -70, 43],
layer: 'DEPARE',
});
// Find buoys near a position
const nearby = await chrt.query({
point: [-70.86, 42.02],
radius: 2000,
layer: 'BOYCAR,BOYLAT',
});
// List ENC cells — `source` takes the chart-source id ('NOAA_ENC'), not the
// agency name ('NOAA'), which matches nothing. chrt.coverage.list() lists the ids.
const cells = await chrt.cells.list({ source: 'NOAA_ENC' });
// Get a specific cell
const cell = await chrt.cells.get('US5MA11M');
console.log(cell.bbox_north, cell.bbox_west); // → 42.38 -71.05API keys
Keys are issued in the CHRT platform under Settings → API keys and look like:
chrt_pk_live_<team>_<random> chrt_sk_live_<team>_<random>
chrt_pk_test_<team>_<random> chrt_sk_test_<team>_<random>
▲ ▲
│ └── environment: live | test
└── key type: pk (publishable) | sk (secret)| | pk — publishable | sk — secret |
|---|---|---|
| Intended for | client-side code (browser, mobile app) | servers, CI, backend jobs |
| Retrievable later | ✓ — re-reveal it in the dashboard | ✗ — shown once at creation; regenerate if lost |
pkandskcarry identical API privileges. The prefix records intent and whether CHRT keeps the plaintext for you to re-reveal — it is not a capability boundary. Any key you ship to a client can be extracted from it and replayed with full access.So treat any key embedded in a browser bundle or mobile app as public: develop against a
test-environment key, grant it only the scopes it needs, and rotate it if it leaks.For a key you ship to a browser, set Allowed domains on it (platform → API keys). A browser request whose
Originisn't on the list is refused, so a key lifted from your bundle won't work on someone else's site. It is a browser-only deterrent, never a boundary — a non-browser caller sends noOriginand is allowed through, by design. Do not set the IP allowlist: it does not work, and setting it bricks the key on every real request path.
Spatial queries
query() answers "what's around me". Underway, the question is narrower.
// What's charted AHEAD — position + bearing, no route needed.
const fan = await chrt.ahead({
point: [-70.9245, 42.33604],
bearing: gps.cog, // degrees true
arc: 60, // FULL sector width, so ±30°
rangeNm: 3,
safetyDepth: 3, // omit if you set `vessel`
});
// What's charted along a PLANNED PASSAGE — ordered by how far in you meet it.
const passage = await chrt.alongRoute({
route: [[-70.95, 42.33], [-71.0, 42.35], [-71.02, 42.36]],
bufferNm: 0.5,
});
for (const f of passage.features) {
const { along_m, distance_m, depth_clearance_m } = f.properties;
}
// Which CHARTS that passage needs — a different question, so a different call.
const { cells } = await chrt.coverage.forRoute({ route, bufferNm: 1 });Neither replaces the other: a boat with a route still drifts, and COG rarely equals the route
bearing — so a passage app wants alongRoute() once per route edit and ahead() continuously
against the real track.
These tell you what is CHARTED, never whether it is safe. 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 get facts that sort (distance_m, bearing_deg, along_m,
least_depth_m, depth_clearance_m — negative means shallower than you declared safe) and your
application owns the judgment.
An empty result is not an all-clear. It can mean nothing is charted there, or that CHRT has no
coverage there at all. Every response carries a coverage block — check coverage.covered before
telling a user anything.
if (!fan.coverage.covered) {
// CHRT holds no chart for this sector. Say so — do not report "clear".
}There is no speed parameter, by design: turning SOG into a lookahead distance is a navigational
judgment that belongs to you. The usual recipe is rangeNm = SOG_knots × lookahead_hours,
narrowing arc as speed rises. Re-fan on a meaningful course change rather than on a timer.
Maps (MapLibre GL JS / Mapbox GL JS)
CHRT gives you two ways to get an IHO S-52 style. They are not interchangeable — pick by whether your client runs JavaScript:
| | chrt.style() | chrt.styleUrl() / chrt.fetchStyle() |
|---|---|---|
| What it is | a JS function that builds the style locally | a URL CHRT serves (tiles.chrt.co/v1/styles/…) |
| Network | none — works offline | one authenticated request |
| Usable from | JavaScript only | any HTTP client (Swift, Kotlin, Python, curl) |
| Custom style bound to your key | not applied | applied server-side (Dev plan and above) |
chrt.transformRequest() mints a short-lived tile token from your API key and attaches it to tile
and served-style requests automatically. Examples use MapLibre — substitute mapbox-gl if you're
already on Mapbox.
import maplibregl from 'maplibre-gl';
import { Chrt } from '@chrtco/sdk';
const chrt = new Chrt(process.env.CHRT_API_KEY!);
// Locally generated style — no network, no custom style applied.
const map = new maplibregl.Map({
container: 'map',
style: chrt.style({ palette: 'DAY' }), // 'DAY' | 'DUSK' | 'NIGHT'
center: [-70.25, 43.66],
zoom: 12,
transformRequest: chrt.transformRequest(),
});Or use the served style, which honours the CHRTstyle custom style bound to your key:
const map = new maplibregl.Map({
container: 'map',
style: await chrt.styleUrl({ theme: 'dusk' }), // 'day' | 'dusk' | 'night'
transformRequest: chrt.transformRequest(), // required — authenticates the style request too
});styleUrl() is async: it pre-mints the tile token so the style request — which MapLibre does
not retry — is guaranteed to carry auth. Always pair it with transformRequest().
Tiles are served from tiles.chrt.co and gated by a short-lived ES256 JWT. The SDK mints it from
your key, attaches it as Authorization: Bearer, and refreshes it before expiry — you never handle
tokens directly.
Symbol families
style(), styleUrl() and fetchStyle() all select symbol artwork, but the option is spelled
differently on the local and served paths:
chrt.style({ symbolSet: 's101' }); // local — symbolSet, palette: 'DAY' | 'DUSK' | 'NIGHT'
await chrt.styleUrl({ symbols: 's101' }); // served — symbols, theme: 'day' | 'dusk' | 'night'| Value | Artwork |
|---|---|
| s52 (default) | IHO S-52 Presentation Library — spec-exact ATON/LIGHTS selection |
| s101 | official IHO S-101 Portrayal Catalogue artwork |
| chrt | CHRT house set — colour-true silhouettes, capability badges |
| raster | raster symbol set |
s101 and chrt need their images loaded at runtime via loadS101SymbolImages /
loadChrtSymbolImages from @chrtco/sdk/atlas/browser.
Custom styles
chrt.style() returns a standard MapLibre style object you can mutate. For a fully custom look,
build your own style over CHRT's vector source with buildSources() and keep
chrt.transformRequest() for auth:
import { buildSources } from '@chrtco/sdk';
const sources = buildSources({ region: 'NOAA_ENC' });
// sources['NOAA_ENC'].tiles → ['https://tiles.chrt.co/v1/tiles/NOAA_ENC/{z}/{x}/{y}.mvt']
// Each S-57 class (DEPARE, LIGHTS, BOYLAT, …) is a `source-layer` inside that one source.Using CHRT without JavaScript
Prefer codegen? The whole HTTP surface is described by an OpenAPI 3.1 spec at https://api.chrt.co/openapi.json — generate a typed Swift/Kotlin/Python/Go client from it instead of hand-rolling the calls below.
Native iOS/Android, Swift, Kotlin, Python and plain curl clients cannot call chrt.style() — it is
a JavaScript function that builds a style object in-process. Use the served style API instead.
It is two HTTP calls, no SDK required.
1. Mint a tile token (valid for a short window; re-mint before expires_at):
curl -X POST https://api.chrt.co/v1/mint/tile-token \
-H "x-api-key: $CHRT_API_KEY" \
-H "content-type: application/json" \
-d '{"region":"NOAA_ENC"}'{
"token": "eyJhbGciOiJFUzI1NiIs…", // ES256 JWT — do not log
"expires_at": 1752600000, // unix seconds, matches the JWT `exp`
"kid": "chrt-tile-2026-04",
"build_tag": "…",
"region": "NOAA_ENC"
}2. Fetch the style and the tiles with that token as a Bearer credential:
curl https://tiles.chrt.co/v1/styles/NOAA_ENC/style.json?theme=day \
-H "Authorization: Bearer $TOKEN"
curl https://tiles.chrt.co/v1/tiles/NOAA_ENC/12/1234/1543.mvt \
-H "Authorization: Bearer $TOKEN"The returned document is a standard MapLibre style — hand it to MapLibre Native (iOS/Android),
maplibre-gl, or any renderer that speaks the style spec. Point your renderer's request transformer
at the same Authorization: Bearer header so tile requests carry it too.
The REST endpoints in the table below (/v1/features, /v1/query, /v1/query/ahead, /v1/cells,
/v1/coverage) take the API key directly as x-api-key — no token minting needed.
Click-to-inspect (CHRTinteract)
Wires click → decode onto any MapLibre / Mapbox GL map showing CHRT tiles. Requires the Dev plan or above; the entitlement is enforced server-side at attach time.
const handle = await chrt.interact.attach(map, {
onInspect: (result) => {
// result.kind: 'navaid' | 'bridge' | 'area'
// e.g. a light characteristic decoded to "Fl(2+1) W 10s", sector arcs,
// bridge clearances, restriction lists, coordinates
console.log(result);
},
});
handle.detach();With no onInspect/onCandidates callback, attach() renders a minimal built-in popup instead.
Every result carries a notice — the non-SOLAS disclaimer, issuing-HO attribution and data
currency. The built-in popup always renders them; if you build your own UI, render them too.
A chart click rarely hits exactly one feature (a buoy on a channel over a depth area), so interact collects every candidate under the click, dedupes them across tile boundaries by S-57 identity, and orders them point → line → area with safety-critical classes first.
// Headless candidate collection for any screen point or [lng, lat].
const { candidates, select } = await chrt.interact.inspectAll(map, [-70.86, 42.02]);
const result = await select(candidates[0]);
// Decode a single feature you already have.
const one = await chrt.interact.inspect(feature);
// Resolve full S-57 source properties + cell provenance (edition, update number,
// issue date). Metered against your `featureCalls` quota.
const enriched = await chrt.interact.inspect(feature, { enrich: true });Configuration
const chrt = new Chrt({
apiKey: process.env.CHRT_API_KEY!,
baseUrl: 'https://api.chrt.co/v1', // default
retries: 2, // retry on 5xx + network errors, default 2
region: 'NOAA_ENC', // default tile region
tileBaseUrl: 'https://tiles.chrt.co', // override for staging tiles
deviceId: 'my-app-install-uuid', // required for offline.* — see below
manifestPath: './chrt-offline-manifest.json', // optional — enables offline budget + list()
vessel: { safetyDepthM: 3 }, // one safety depth for every depth-aware call
});new Chrt('chrt_sk_live_…') is shorthand for new Chrt({ apiKey: 'chrt_sk_live_…' }).
vessel — one safety depth
Set your draft-plus-margin once and every depth-aware call inherits it: ahead() and
alongRoute() filter at it, and style() shades soundings and draws danger rings at it. An
explicit per-call safetyDepth / safetyContour always wins.
It is client-side and per-instance, deliberately — not 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. Construct a Chrt per user, or pass safetyDepth per call.
With no vessel, CHRT sends no depth at all rather than guessing a draft — filtering on an
invented one would silently drop real hazards.
Not yet reachable from the served style:
styleUrl()takestheme+symbolsonly, so a non-JS client cannot currently get per-user depth shading.
API Reference
Which methods can a non-JS client use? Everything under HTTP endpoint below is a plain REST call any language can make. Everything marked JS-only runs in-process and has no HTTP equivalent.
| Method | HTTP endpoint | Notes |
|---|---|---|
| chrt.features(opts) | GET /v1/features | |
| chrt.query(opts) | GET /v1/query | |
| chrt.ahead(opts) | GET /v1/query/ahead | what's charted in a sector ahead |
| chrt.alongRoute(opts) | GET /v1/query/route | same, for a planned passage |
| chrt.alongRouteIter(opts) | GET /v1/query/route | auto-paginates |
| chrt.cells.list(opts?) | GET /v1/cells | |
| chrt.cells.get(id) | GET /v1/cells/{id} | |
| chrt.coverage.list(opts?) | GET /v1/coverage | |
| chrt.coverage.forRoute(opts) | GET /v1/coverage/route | which cells a passage needs |
| chrt.warmTiles(region?) | POST /v1/mint/tile-token | |
| chrt.styleUrl(opts?) | GET {tileBaseUrl}/v1/styles/{region}/style.json | mints first |
| chrt.fetchStyle(opts?) | same, then fetches it | |
| chrt.interact.entitlement() | POST /v1/interact/entitlement | |
| chrt.style(opts?) | — | JS-only — local generator |
| chrt.transformRequest(opts?) | — | JS-only — returns a closure |
| chrt.tiles.tileUrl(z, x, y) | — | JS-only — string builder |
| chrt.interact.attach/inspectAll | — | JS-only — needs a GL JS map |
| chrt.featuresIter / cellsIter | — | JS-only — async iterators |
chrt.features(options)
Query GeoJSON features by bounding box.
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| bbox | [w, s, e, n] | ✓ | Bounding box |
| layer | string | | S-57 layer code (e.g. DEPARE) |
| limit | number | | Max results (default 100, max 1000) |
| offset | number | | Pagination offset |
| signal | AbortSignal | | |
Returns a GeoJSON FeatureCollection with total_count, limit and offset alongside features.
chrt.query(options)
Spatial proximity search around a point.
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| point | [lng, lat] | ✓ | Centre point |
| radius | number | | Radius in metres (default 500, clamped 1–50 000) |
| layer | string | | Comma-separated layer codes |
| limit | number | | Max results (default 50, max 500) |
| signal | AbortSignal | | |
Returns a QueryResponse: a GeoJSON FeatureCollection whose properties carry layer, cell_id
and distance_m (geodesic metres from point), plus a query block echoing what the server
actually applied after clamping — read res.query.radius_m to see that radius: 90000 silently
became 50000. Unlike chrt.features(), this endpoint is not paginated: it returns no offset,
so widen radius rather than paging.
chrt.ahead(options)
The fan-ahead query — the significant features in a sector from a point along a bearing. Built for the position-plus-COG case: a craft with a heading but no planned route.
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| point | [lng, lat] | ✓ | Origin |
| bearing | number | ✓ | Degrees true; out-of-range values normalise |
| arc | number | | Full sector width centred on bearing (default 90 — arc: 60 sweeps ±30°) |
| range | number | | Metres (default 5000, clamped 1–50 000) |
| rangeNm | number | | Nautical miles — mutually exclusive with range (passing both throws) |
| safetyDepth | number | | Filters out only what is known to be deeper |
| layer | string | | Defaults to CHRT's significant-feature set |
| limit | number | | Default 50, max 500 |
Each feature carries distance_m, bearing_deg, least_depth_m and depth_clearance_m (negative =
shallower than your safetyDepth).
Facts that sort, never a verdict. There is deliberately no severity score and no
safeflag — a risk model is a navigation-grade claim. You sort; CHRT ranks nothing.An empty result is not an all-clear. Zero features in covered water and zero features because CHRT has no cells there are different answers. Every response carries a
coverageblock (covered,covered_fraction,cells) — checkcoverage.coveredbefore telling a user anything.
chrt.cells.list(options?)
List available ENC cells.
| Option | Type | Required | Description |
|--------|------|----------|-------------|
| 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. |
| status | string | | e.g. 'CURRENT'. Case-insensitive. |
| scaleBand | ScaleBand | | 'Overview' | 'General' | 'Coastal' | 'Approach' | 'Harbor'. Matched case-sensitively — 'harbor' matches nothing. |
| bbox | [w, s, e, n] | | Cells whose bounds intersect this box |
| limit | number | | Max results (default 100, max 1000) |
| offset | number | | Pagination offset |
| signal | AbortSignal | | |
Returns { cells, total_count, limit, offset }. Use chrt.cellsIter(options?) to page automatically.
chrt.cells.get(cellId)
Get metadata for a specific cell. Takes the id positionally: chrt.cells.get('US5MA11M').
Returns a CellDetail:
| Field | Type | Description |
|-------|------|-------------|
| cell_id | string | e.g. 'US5MA11M' |
| title | string \| null | Chart title |
| chart_source | string | Source id, e.g. 'NOAA_ENC' |
| status | string | e.g. 'CURRENT' |
| scale | number \| null | Compilation scale denominator |
| scale_band | string \| null | Usage band — a ScaleBand name, e.g. 'Harbor' |
| edition · update_number · issue_date | | Data currency — the fields that say how current the chart is |
| 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. |
| 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. |
Bounds are flat here and on
coverage.forRoute()— one shape across the API. Up to 0.9.2 the SDK declared these flat fields while/v1/cellsalone returned a nestedbboxobject, socell.bbox_northtypechecked and readundefinedon every response. The endpoint was the outlier; it now sends what this type has always declared, and readingcell.bbox_northworks against any deployed CHRT API.
chrt.coverage.list(options?)
List the chart sources available to your key. Options: country (e.g. 'US'), source.
Returns { sources, total_cells }, where each source carries source (the id you filter cells
by, e.g. 'NOAA_ENC'), agency (the issuing office's display name, e.g. 'NOAA'), cell_count and
scale_bands (counts keyed by band name). This is the endpoint that tells you which source ids are
valid — passing an agency name anywhere else silently matches nothing.
chrt.style(opts?) — JS-only
Returns a ready-to-use MapLibre/Mapbox GL S-52 style object. Generated locally: no network, works
offline, and does not apply a custom style bound to your key. Options: palette
('DAY' | 'DUSK' | 'NIGHT'), symbolSet, region, depthUnits, safetyContour, chartMode, plus
the other atlas style options. Non-JS clients want styleUrl() instead.
chrt.styleUrl(opts?)
URL of the served S-52 style — the CHRTstyle Style API at
{tileBaseUrl}/v1/styles/{region}/style.json. async: it pre-mints the tile token so the style
request (which MapLibre does not retry) carries auth. Options: theme
('day' | 'dusk' | 'night'), symbols. Always pair with transformRequest().
The served document honours the CHRTstyle custom style bound to your key (a Dev+ feature; Free
keys and keys with no bound style get the default themes). This is the style path for any client that
can't run chrt.style().
chrt.fetchStyle(opts?)
Fetches the served style as a MapLibre style object. Same options as styleUrl(), plus signal.
Prefer it over styleUrl() when you want to inspect or mutate the document, or when you can't use
transformRequest() for the style request itself. Throws ChrtError on failure; does not retry.
chrt.transformRequest(opts?) — JS-only
Returns a transformRequest closure that attaches the tile JWT as Authorization: Bearer on
tiles.chrt.co tile and served-style requests only. Sprite, glyph and third-party URLs pass
through untouched. Pass { region } to override the default region.
chrt.warmTiles(region?)
Pre-mints the tile token so you can surface auth errors (region_not_licensed,
tile_quota_exceeded, …) before the map loads. Optional — transformRequest() warms the cache on
its own.
chrt.tiles.tileUrl(z, x, y) — JS-only
Concrete vector tile URL at tiles.chrt.co for one tile. Prefer chrt.style() / chrt.styleUrl(),
which wire the source for you.
chrt.interact.*
See Click-to-inspect. attach(map, opts?) and
inspectAll(map, point, opts?) need a GL JS map instance; inspect(feature, opts?) decodes locally
but checks entitlement over HTTP first.
chrt.tiles.groups() — deprecated
Tilesets were consolidated to one source per region (2026-04). The route no longer exists — calling
it fails with ChrtError 404. Use chrt.style() or chrt.styleUrl().
Error Handling
import { Chrt, ChrtError } from '@chrtco/sdk';
try {
await chrt.features({ bbox: [-71, 42, -70, 43] });
} catch (e) {
if (e instanceof ChrtError) {
console.error(e.status, e.code, e.message);
}
}Typed errors by area: ChrtError (REST), TileTokenError (minting — invalid_api_key,
region_not_licensed, tile_quota_exceeded, region_not_available, feature_flag_disabled,
network_error, unexpected_response), InteractError (interact_not_enabled — includes the
current plan and an upgrade hint — invalid_api_key, missing_scope, enrich_failed,
unknown_layer, network_error, unexpected_response), and OfflineError (below).
Offline Downloads (CHRToffline)
Download MBTiles bundles for offline rendering. Bundles are SHA-256 verified before commit. Interrupted downloads resume from a .partial sidecar file.
Requires the Pro plan or above, enforced server-side at mint time — Free and Dev keys get
OfflineError code offline_requires_pro_plan. The key also needs the offline:download
scope, which is checked before the plan: a key without it gets offline_not_enabled
regardless of tier, so add the scope first or the plan refusal never surfaces.
Runtime support in v1: Node 18+, Bun, Deno. Browser and React Native are deferred to a follow-up.
import { Chrt } from '@chrtco/sdk';
import { nodeFileSystemAdapter } from '@chrtco/sdk/node';
const chrt = new Chrt({
apiKey: process.env.CHRT_API_KEY!,
// Required for offline.* — generate once at first launch, persist per
// install. Echoed into the offline JWT + heartbeat for device-level usage
// tracking.
deviceId: 'my-app-install-uuid',
// Optional — enables budget enforcement + `list()` of local state. If
// unset, budget is not enforced and `setStorageBudget()` throws.
manifestPath: './chrt-offline-manifest.json',
});
const fs = nodeFileSystemAdapter();
// Optional: cap total local storage across all regions.
await chrt.offline.setStorageBudget(10 * 1024 ** 3, fs); // 10 GB
// List what you're licensed to download.
const regions = await chrt.offline.list();
// Download a region. Resumes from a partial if one exists.
await chrt.offline.download('NOAA_ENC', {
path: './noaa.mbtiles',
fs,
onProgress: (p) => console.log(`${p.fraction * 100}%`),
});
// Remove a region (deletes the file + updates manifest + heartbeats).
await chrt.offline.remove('NOAA_ENC', { path: './noaa.mbtiles', fs });
// Manual heartbeat — call at app launch + every 24h while running.
await chrt.offline.heartbeat({ fs }); // derives regions + total from manifestTyped offline errors
import { OfflineError } from '@chrtco/sdk';
try {
await chrt.offline.download('NOAA_ENC', { path: './noaa.mbtiles', fs });
} catch (e) {
if (e instanceof OfflineError) {
switch (e.code) {
case 'budget_exceeded': console.log(e.detail); break;
case 'download_corrupt': console.log('SHA mismatch'); break;
case 'network_interrupted': console.log('resume later'); break;
case 'coverage_denied': console.log('region not licensed'); break;
case 'offline_requires_pro_plan': console.log('CHRToffline needs Pro+ — chrt.co/pricing'); break;
case 'offline_not_enabled': console.log('add the offline:download scope to this API key'); break;
}
}
}Notes
- The SDK mints its own short-lived JWTs internally. You never see them.
- Heartbeats in v1 are manual — auto-scheduling comes in a later phase.
- A budget smaller than current usage will not auto-prune; it only refuses new downloads. Use
remove()explicitly.
License
MIT
