diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 34226c81..76bd7b4d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -34,6 +34,9 @@ jobs:
# Debug keeps the tests' safety checks. The matrix below covers ReleaseFast.
- name: Build and test
run: zig build install test
+ # The full-engine wasm reactor (docs.yml ships it as the live demo).
+ - name: Build wasm engine
+ run: zig build wasm-engine
cross-compile:
runs-on: ubuntu-latest
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 19f7e62b..5e054f05 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -3,8 +3,14 @@ name: Docs
on:
push:
branches: [main]
+ # The site ships the live wasm chartplotter demo, so engine changes
+ # redeploy it too — the demo always runs the current engine.
paths:
- "docs/**"
+ - "bindings/js/**"
+ - "src/**"
+ - "build.zig"
+ - "build.zig.zon"
- ".github/workflows/docs.yml"
workflow_dispatch:
@@ -22,14 +28,56 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
+ # Recursive: the wasm engine embeds the portrayal catalogue submodule.
- uses: actions/checkout@v7
+ with:
+ submodules: recursive
- uses: actions/setup-node@v7
with:
node-version: "20"
- # No committed lockfile, so `npm install` (not `npm ci`). The site is
- # content-only — no submodules, no app build needed.
+ - name: Install Zig
+ uses: mlugg/setup-zig@v2
+ with:
+ version: 0.16.0
+
+ # The live demo: the full engine as wasm plus the demo page, staged
+ # under static/demo-app/ and embedded by src/pages/demo.jsx at /demo.
+ # GitHub Pages is https, so WebGPU is available to it. ReleaseSmall:
+ # one third the download, and this workload runs just as fast.
+ - name: Build wasm engine
+ run: zig build wasm-engine -Doptimize=ReleaseSmall
+
+ - name: Stage demo
+ run: |
+ mkdir -p docs/static/demo-app
+ cp bindings/js/demo.html docs/static/demo-app/index.html
+ cp bindings/js/wasi-shim.mjs bindings/js/tile57.mjs \
+ bindings/js/gpu-renderer.mjs bindings/js/engine-worker.mjs \
+ bindings/js/worker-rpc.mjs bindings/js/bake-pool.mjs \
+ bindings/js/chart-library.mjs \
+ docs/static/demo-app/
+ cp -r bindings/js/demo docs/static/demo-app/demo
+ cp zig-out/bin/tile57-engine.wasm docs/static/demo-app/
+
+ # A first visit with no charts offers a sample: Annapolis (public
+ # domain NOAA cells) - the harbor at 1:12,000, the Severn and bay
+ # approaches at 1:40,000, and the band-3/4 context so zooming out
+ # still shows chart. Best effort: without it the welcome card just
+ # keeps the download link alone.
+ - name: Fetch the sample charts
+ continue-on-error: true
+ run: |
+ tmp=$(mktemp -d)
+ for c in US5MD1MB US5MD1MC US5MD1MD US5MD1LC US5MD13M US5MD12M US4MD1DD US3EC08M; do
+ curl -fsSL -o "$tmp/$c.zip" "https://charts.noaa.gov/ENCs/$c.zip"
+ unzip -q -o "$tmp/$c.zip" -d "$tmp/enc"
+ done
+ (cd "$tmp/enc" && zip -qr "$tmp/sample.zip" .)
+ cp "$tmp/sample.zip" docs/static/demo-app/sample.zip
+
+ # No committed lockfile, so `npm install` (not `npm ci`).
- name: Install
working-directory: docs
run: npm install
diff --git a/.gitignore b/.gitignore
index 0f6bff8e..39a43f4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -52,3 +52,6 @@ zig-pkg/
# Local work in progress.
scratchpad/
+
+# The full-engine wasm in the JS package is a build artifact (npm run build)
+bindings/js/tile57-engine.wasm
diff --git a/README.md b/README.md
index 44908985..d51f9873 100644
--- a/README.md
+++ b/README.md
@@ -2,237 +2,89 @@
⚓ Official nautical charts, ready to draw.
- tile57 reads IHO S-101 and S-57 charts and gives a renderer what it
- needs: vector tiles with a matching MapLibre S-52 style, a draw-ready GPU scene,
- pixel draw calls, or finished PNG and PDF. It also reads raster charts —
- satellite photos and RNC sheets — and draws the official chart on top of them.
- It reports the objects under a point, and the text and pictures a chart carries.
- One Zig library with a C ABI.
+ tile57 reads the electronic charts hydrographic offices publish and draws
+ them the way the standard says they should look: in your app, on your
+ server, or right in the browser.
-
+ 🌊 Live demo
+ ·
+ 📚 Docs
·
- 📚 Docs →
+
---
-> [!WARNING]
-> **Not for navigation.** This is not a certified navigation product. Do not use it
-> to navigate. Refer to [Known limitations](docs/docs/limitations.md).
+## Try it in your browser
----
+
+
+
-Hydrographic offices publish charts as S-101 and S-57 datasets. Those formats
-carry the survey, not a picture of it. tile57 reads them and produces the picture,
-in the form your renderer wants: vector tiles, a draw-ready GPU scene, pixel draw
-calls, or a finished page. It also answers questions about the chart — which
-objects sit under a point, what the chart states about them, and the notes and
-diagrams it carries.
-
-## Why it is different
-
-- **The portrayal is official, not an imitation.** tile57 runs the IHO **S-101
- Portrayal Catalogue** against the feature records in the chart. No symbol is a
- look-alike. You get depth areas and contours, buoys and beacons with correct
- symbols, lights with sector lines, soundings, and place names.
-- **It reads the format the world is moving to.** A native S-101 chart feeds the
- portrayal engine directly. tile57 converts an S-57 chart to the same S-101 model
- first, so everything above the conversion sees one model.
-- **It renders more than tiles.** The same engine writes PNG, PDF, and a callback
- stream your own renderer can paint. It also answers cursor picks.
-- **It feeds a GPU directly.** tile57 portrays a whole view into one draw-ready
- scene: vertex and quad buffers, a uniform block, and ranges already sorted into
- paint order. A host uploads it once, then walks the ranges. Geometry stays
- north-up in world space and the host applies the view rotation, so a course-up
- view that turns continuously never rebuilds its scene. The repo ships reference
- shaders for Metal, Direct3D and Vulkan.
-- **It combines raster charts and satellite photos with the official chart.**
- Add your own satellite photos as MBTiles, or an RNC sheet as BSB/KAP. tile57
- draws them below the official chart. The official chart then removes its solid
- blue and yellow areas, so you can see the photo through them. All the depth
- contours, buoys, lights and soundings stay on top. If your chart is old, you
- see the place as it is today, and you read the official marks over it. On a
- GPU this happens pixel by pixel, so the chart keeps its colors everywhere the
- photo does not reach. tile57 also quilts many RNC sheets into one map. For
- each area it uses the sheet with the correct scale for your zoom. Where two
- sheets cover the same water at that scale, it uses the newer edition. This is
- the rule it uses for official charts. See [Raster charts](docs/docs/raster-charts.md).
-- **It embeds anywhere.** The core is pure Zig with a C ABI.
+The **[live demo](https://beetlebugorg.github.io/tile57/demo)** is an
+S-57/S-101 chart viewer in one page. Grab a free chart zip from
+[NOAA](https://charts.noaa.gov/ENCs/ENCs.shtml), drop it on the map, and the
+page does the rest: it bakes the charts, keeps them in your browser, and draws
+them with WebGPU. Pan, zoom, and rotate a whole chart library. Nothing is
+uploaded and there is no server.
+
+> [!WARNING]
+> **Not for navigation.** This is not a certified navigation product. Do not use
+> it to navigate. Refer to [Known limitations](docs/docs/limitations.md).
+
+## What you can do with it
+
+- **Build a chartplotter** for desktop, mobile, embedded, or pure web. Point
+ tile57 at a folder of charts and it becomes one seamless, queryable map.
+- **Quilt a whole library.** Charts at every scale stitch into one chart: the
+ most detailed chart wins each stretch of water, the general chart fills
+ around it, and a newer edition wins an overlap. Harbor to ocean is one
+ continuous map, the way an ECDIS quilts.
+- **Serve charts to any map client.** Bake once and serve standard vector
+ tiles with a matching style; MapLibre draws them out of the box.
+- **Draw at full speed.** tile57 hands your GPU a ready-to-draw scene. Pan,
+ zoom, and rotate without rebuilding anything.
+- **Put charts on paper.** Finished PNG images and vector PDF pages, straight
+ from the source data.
+- **See the world under the chart.** Satellite photos (MBTiles) and scanned
+ raster charts (BSB/KAP) draw beneath the official one, which opens up so you
+ can see through it. Every buoy, light, and depth stays on top.
+- **Ask the chart questions.** What is under the cursor, what the chart says
+ about it, and the notes and diagrams it carries.
+
+The portrayal is official, not a look-alike: tile57 runs the IHO's own
+portrayal rules against the chart's own records. It reads today's charts
+(S-57) and tomorrow's (S-101), and it embeds anywhere: one small library
+with no runtime dependencies.
## Start here
```sh
-brew install beetlebugorg/tap/tile57 # or grab a binary from Releases
+brew install beetlebugorg/tap/tile57 # or grab a binary from Releases
-tile57 bake ENC_ROOT -o out/ # every chart -> its own archive
+tile57 bake ENC_ROOT -o out/ # every chart -> its own archive
tile57 png ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.png
-
-tile57 raster info photos.mbtiles # what the file really contains
-tile57 bake harbour.KAP -o out/ # an RNC sheet -> the same archive
-tile57 png ENC_ROOT --over-image --view -76.48,38.974,15 -o over.png
-```
-
-`bake` turns a catalogue into per-chart archives. `png` draws a chart straight to
-an image. `--over-image` removes the chart's solid areas, so you can draw it over
-a photo.
-
-Binaries for macOS, Linux and Windows are attached to every
-[release](https://github.com/beetlebugorg/tile57/releases), alongside a `.deb`
-and the static library. To build it yourself you need Zig 0.16 and the
-submodules — see [Installation](docs/docs/installation.md).
-
-## What you can get
-
-| Output | Call | What it is |
-|---|---|---|
-| **Vector tiles** | `tile57_compose_tile` | MapLibre Tiles (MLT) or Mapbox Vector Tiles, by `(z, x, y)` |
-| **Style + assets** | `tile57_style_build`, `tile57_bake_assets` | A MapLibre GL style, colour tables, line styles, sprite and pattern atlases |
-| **PNG** | `tile57_chart_png` | A finished raster view |
-| **PDF** | `tile57_chart_pdf` | A vector page, 1 px = 1 pt |
-| **Draw calls** | `tile57_chart_canvas` | Pixel-space paint calls for your own rasterizer |
-| **Tagged geometry** | `tile57_chart_surface` | World-space geometry, each call tagged with its S-57 class |
-| **GPU scene** | `tile57_chart_gpu_scene` | Draw-ready vertex, quad and range buffers, plus the sprite and SDF atlases |
-| **Pick** | `tile57_chart_query` | The objects under a point, with their attributes |
-| **Notes and diagrams** | `tile57_aux_get` | The text and picture files a chart's features point at |
-| **Raster charts** | `tile57_raster_chart_*` | Tiles from a satellite photo file or a BSB/KAP RNC sheet |
-| **Quilted RNC** | `tile57_compose_rasters` | Many RNC sheets as one quilted map |
-
-MLT is the default tile encoding. MapLibre GL JS 5.12 and later decode it natively.
-
----
-
-# Technical reference
-
-## How it works
-
-tile57 reads two source formats. Both are `.000` files — a *cell*, in the spec's
-vocabulary — and tile57 detects which one it holds from the file itself. Both
-converge on the same S-101 feature records.
-
-```
-S-101 ENC (.000) S-57 ENC chart (.000)
- │ ISO 8211 decode │ ISO 8211 decode src/iso8211/
- ▼ ▼
-S-100 spatial + feature S-57 feature + geometry src/s57/ · src/s101/
- records model
- │ assemble to S-101 │ adapt S-57 → S-101 (native.zig / adapter.zig)
- └──────────────┬──────────────┘
- ▼
-S-101 feature records
- │ S-101 portrayal (embedded Lua) src/portray/ + rules
- ▼
-portrayal instruction stream src/s101/ (instructions)
- │ scene generation src/scene/ (project + clip + draw calls)
- ▼
-render Surface ──► MVT / MLT tiles (src/tiles/) + MapLibre style.json + assets
- ├───► PNG raster / vector PDF / terminal text (src/render/)
- └───► draw-ready GPU scene (src/render/gpu.zig) + shaders/
```
-tile57 reads a native S-101 chart straight into the S-101 model. That chart's
-in-band code tables already carry the S-101 class and attribute names, so no
-conversion runs. tile57 applies the chart's update files on load. tile57 reads an
-S-57 chart into the S-57 model and adapts it. The adaptation follows S-65 and is
-best-effort. Refer to [limitations](docs/docs/limitations.md).
-
-Each stage is a separate Zig module: `iso8211`, `s57`, `s101`, `tiles`, `render`,
-`scene`, and `style`. Those modules need no libc. Only the Lua portrayal
-(`portray`) and the sprite rasterizer (`sprite`) use C. Refer to
-[the architecture docs](docs/docs/architecture.md).
-
-## How it holds memory
-
-- **Work is lazy, and it is per chart.** tile57 indexes a multi-chart ENC_ROOT by
- band and bounding box. It parses and portrays a chart only when a requested tile
- needs it. An LRU bound caps how many it holds. A streaming open reads a chart's
- bytes on demand and frees them on eviction.
-- **Each chart bakes on its own.** Each chart bakes to its own PMTiles archive at
- its compilation scale. A bake holds one chart at a time. The runtime compositor
- stitches the archives by `(z, x, y)` on demand. There is no merged archive.
+Binaries for macOS, Linux, and Windows are attached to every
+[release](https://github.com/beetlebugorg/tile57/releases). Use the engine
+from [C](https://beetlebugorg.github.io/tile57/c-api),
+[Zig](https://beetlebugorg.github.io/tile57/zig-api),
+[JavaScript](https://beetlebugorg.github.io/tile57/wasm), or
+[Go](https://github.com/beetlebugorg/tile57/tree/main/bindings/go), or through
+the [CLI](https://beetlebugorg.github.io/tile57/cli). The
+[docs](https://beetlebugorg.github.io/tile57/) cover everything else.
-## Use it from Zig
-
-Add tile57 as a dependency, then import it:
-
-```zig
-const tile57 = @import("tile57");
-
-// Open an ENC_ROOT directory, or a single .000 file, as a streaming chart.
-var chart = try tile57.Chart.openPath("ENC_ROOT/", null, true);
-defer chart.deinit();
-
-const bbox = chart.bounds(); // geographic extent [w, s, e, n], or null
-// … render a view (chart.renderView), query features, or bake an archive …
-```
-
-`Chart` renders views, queries features, and reads metadata. Refer to
-[the Zig API docs](docs/docs/zig-api.md).
-
-## Use it from C
-
-The same engine sits behind a thin C ABI
-([`include/tile57.h`](include/tile57.h)). `tile57 bake ENC_ROOT -o out/` writes one
-directory per chart. `tile57_compose_tree` opens that whole tree in one call and
-serves any tile on demand.
-
-```c
-// out/ holds /.pmtiles per chart, with the files that chart
-// references, plus out/partition.tpart.
-tile57_compose *c = NULL;
-uint32_t charts = 0;
-if (tile57_compose_tree("out/", &c, &charts, NULL) != TILE57_OK)
- return 1;
-
-uint8_t *tile = NULL;
-size_t len = 0;
-if (tile57_compose_tile(c, z, x, y, &tile, &len, NULL, NULL) == TILE57_OK && tile) {
- /* … hand the decompressed MLT tile to your renderer … */
- tile57_free(tile);
-}
-tile57_compose_close(c);
-```
-
-A `tile57_chart` handle renders PNG and PDF views and answers metadata and object
-queries. `libtile57.a` also exposes the MapLibre style builder and the asset
-generators. Refer to [the C API docs](docs/docs/c-api.md).
-
-## The `tile57` CLI
-
-The offline tool bakes charts and emits portrayal assets. Every command takes a
-native S-101 or an S-57 `.000` file, and tile57 detects the format.
-
-```sh
-tile57 bake CELL.000 -o out/ # one chart -> out//.pmtiles
-tile57 bake ENC_ROOT -o out/ # a catalogue -> one directory per chart
-tile57 assets -o assets/ # colortables + linestyles + sprite + patterns
-tile57 png ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.png
-tile57 pdf ENC_ROOT --view -76.48,38.974,15 --size 1600x1200 -o chart.pdf
-tile57 ascii CELL.000 --view -76.48,38.974,13 --ansi --tui # the chart in your terminal
-tile57 s101 CELL.000 # inspect a native S-101 dataset
-```
-
-## Build
-
-The Zig engine and the CLI need Zig 0.16 only:
+## Build from source
```sh
git submodule update --init --recursive # the vendored S-101 catalogue
-zig build && zig build test
+zig build && zig build test # needs Zig 0.16 only
```
-Refer to [docs/installation](docs/docs/installation.md) for full instructions.
-
-## Documentation
-
-The docs source is in [`docs/`](docs/): [intro](docs/docs/intro.md),
-[getting started](docs/docs/getting-started.md), the
-[Zig API](docs/docs/zig-api.md), the [C API](docs/docs/c-api.md),
-the [architecture](docs/docs/architecture.md), and the
-[tile schema](docs/docs/tile-schema.md).
-
-## AI-First Development
+## AI-first development
This project is built with AI assistance. Use AI tools freely. The most useful
contribution is a clear set of requirements, or a rough prototype of what you
@@ -242,5 +94,6 @@ want, rather than a patch. Refer to the
## License
tile57's own code is [MIT](LICENSE) © Jeremy Collins. It embeds the IHO S-101
-Portrayal Catalogue (© IHO). It vendors nanosvg (zlib) and stb_image_write (public
-domain). NOAA ENC charts are U.S. public domain and **not for navigation**.
+Portrayal Catalogue (© IHO). It vendors nanosvg (zlib) and stb_image_write
+(public domain). NOAA ENC charts are U.S. public domain and **not for
+navigation**.
diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md
index fcdbdc51..8ce2e38e 100644
--- a/THIRD_PARTY_LICENSES.md
+++ b/THIRD_PARTY_LICENSES.md
@@ -20,6 +20,7 @@ the same change (see the polylabel entry as the worked example).
| Noto Sans Regular | 2026.05.01 (Google) | `vendor/fonts/NotoSans-Regular.ttf` | SIL Open Font License 1.1 |
| Noto Sans Bold | 2.000 (Google) | `vendor/fonts/NotoSans-Bold.ttf` | SIL Open Font License 1.1 |
| Noto Sans Italic | 2.000 (Google) | `vendor/fonts/NotoSans-Italic.ttf` | SIL Open Font License 1.1 |
+| wasi-libc (sjlj runtime) | wasi-libc as bundled with Zig 0.16 | `src/portray/wasm_sjlj_rt.c` | Apache-2.0 / MIT (dual) |
- **Lua** is built from source and driven through `src/portray/lua_shim.c` to run
the S-101 portrayal rules engine.
@@ -35,6 +36,13 @@ the same change (see the polylabel entry as the worked example).
mariner left them. Built read-only from the amalgamation; see `addSqlite` in
build.zig for the trimmed feature set. The authors have dedicated it to the
public domain — no attribution is required, and this entry is a courtesy.
+- **wasi-libc sjlj runtime**: the wasm engine build (`zig build wasm-engine`)
+ compiles Lua's and libtess2's setjmp/longjmp error paths through clang's
+ wasm sjlj lowering, and `src/portray/wasm_sjlj_rt.c` is the runtime that
+ lowering calls into — a verbatim copy of wasi-libc's
+ `libc-top-half/musl/src/setjmp/wasm32/rt.c` (the file's header comment
+ states why it must be vendored). wasi-libc is dual-licensed Apache-2.0 /
+ MIT; the license texts ship with Zig under `lib/libc/wasi/`.
`vendor/lua/LICENSE.html` carries Lua's full notice; the nanosvg, stb and SQLite
licenses are in the headers themselves.
diff --git a/bindings/go/README.md b/bindings/go/README.md
index e37a7880..e7961de8 100644
--- a/bindings/go/README.md
+++ b/bindings/go/README.md
@@ -1,7 +1,7 @@
# tile57 — Go binding
The canonical Go (cgo) binding to **libtile57**, the native Zig chart engine in this
-repo. It lives next to the engine (`bindings/go`, alongside `bindings/wasm`) so it
+repo. It lives next to the engine (`bindings/go`, alongside `bindings/js`) so it
tracks the C ABI in [`include/tile57.h`](../../include/tile57.h) as that ABI evolves —
a host imports it and works in **Go only**, never touching cgo, the header, or the
Zig build.
diff --git a/bindings/js/README.md b/bindings/js/README.md
index 331ce4f9..39dae08d 100644
--- a/bindings/js/README.md
+++ b/bindings/js/README.md
@@ -1,179 +1,73 @@
-# @beetlebug/tile57-style-engine
+# tile57 for JavaScript
-Generate a [MapLibre GL](https://maplibre.org) `style.json` for nautical (S-57 /
-ENC) charts from **S-52 "mariner settings"** — colour scheme, depth units, safety
-contour, display category, and the rest — **entirely client-side**.
+The full [tile57](https://github.com/beetlebugorg/tile57) chart engine,
+compiled to WebAssembly, with JavaScript bindings for the browser and node.
+It bakes official S-57/S-101 charts to PMTiles archives, composes them, and
+renders vector tiles, PNG views, and draw-ready WebGPU scenes, all with no
+server.
-Under the hood it runs the chartplotter **`tile57` style engine** (pure Zig)
-compiled to a ~145 KB WebAssembly module. The same engine ships in the native
-`libtile57` C ABI (`tile57_style_build`) and the `tile57` CLI, so the style your
-front-end produces is **byte-for-byte identical** to the native build (see
-[Parity](#parity)). No server round-trip, no second implementation to keep in
-sync.
+**See it run:** the [live demo](https://beetlebugorg.github.io/tile57/demo)
+is a chart viewer in one page, built entirely from this package's modules.
+[`demo.html`](./demo.html) is its source and the reference host.
-```
- mariner settings (JS object)
- │ JSON
- ▼
- ┌──────────────────────────────────────────────┐
- │ WebAssembly (style-engine.wasm) │
- │ │
- │ settings.parse ──► style.buildFromTemplate ─┐ │
- │ ▲ ▲ ▲ │ │
- │ embedded embedded embedded │ │
- │ template.json colortables (S-52) │ │
- └─────────────────────────────────────────────┼─┘
- │ UTF-8 style.json bytes
- ▼
- MapLibre style object → map.setStyle(...)
-```
-
-`buildStyle` *patches* an embedded base template: it recolours every
-palette-driven property for the chosen scheme, rewrites the SEABED01 depth shading
-/ SNDFRM04 sounding / danger-symbol / contour-label expressions from the contour
-settings, and AND-s the client-side display filters (category, boundary & point
-style, date validity, text groups, …) onto every `source:"chart"` layer.
-
-## Install
-
-```sh
-npm install @beetlebug/tile57-style-engine
-```
-
-The package bundles `style-engine.wasm` and has **zero runtime dependencies**.
+> **Not for navigation.** This is not a certified navigation product.
-## Usage
+## Quick start
```js
-import { loadStyleEngine } from '@beetlebug/tile57-style-engine';
-
-// Load once, reuse. generateStyle() is synchronous and cheap.
-const engine = await loadStyleEngine();
+import { createEngine } from "tile57";
-const style = engine.generateStyle({
- scheme: 'night',
- depth_unit: 'feet',
- safety_contour: 15,
- deep_contour: 40,
- boundary_style: 'plain',
-});
+const { engine, fs } = await createEngine("./tile57-engine.wasm");
+fs.add("US5BDRAB/US5BDRAB.000", cellBytes); // an S-57 cell
-map.setStyle(style); // a MapLibre GL JS map
+const archive = engine.bakeChartBytes("/enc/US5BDRAB/US5BDRAB.000");
+const chart = engine.chartOpenBytes(archive); // open from bytes
+const png = engine.chartPng(chart, -73.18, 41.16, 14, 1600, 1200);
```
-Only the fields you pass are overridden; everything else uses the engine's
-canonical default (see `DEFAULT_SETTINGS`). Re-call `generateStyle` and
-`map.setStyle` whenever the mariner changes a setting.
+`Tile57` wraps the whole C API (bake, chart, compose, GPU scenes, style),
+one call per method. See the
+[WebAssembly docs](https://beetlebugorg.github.io/tile57/wasm) for the host
+contract and the full surface.
-### Browsers / bundlers
+## The pieces
-`loadStyleEngine()` auto-loads the bundled `.wasm` (via `fs` in Node, `fetch` in
-the browser, resolved relative to the module). If your bundler doesn't emit the
-`.wasm` as an asset, pass the bytes yourself:
-
-```js
-const wasmBytes = await fetch(
- new URL('@beetlebug/tile57-style-engine/style-engine.wasm', import.meta.url),
-).then((r) => r.arrayBuffer());
-const engine = await loadStyleEngine({ wasmBytes });
-```
+| Module | What it is |
+|---|---|
+| `tile57.mjs` | The engine wrapper over the wasm exports |
+| `wasi-shim.mjs` | A dependency-free WASI host with a writable in-memory file tree |
+| `gpu-renderer.mjs` | WebGPU over the engine's draw-ready scenes; live pan/zoom/rotate |
+| `engine-worker.mjs` | The engine in a Web Worker, one call per RPC message |
+| `bake-pool.mjs` | Parallel cell bakes across a pool of engine workers |
+| `chart-library.mjs` | Baked archives persisted in the browser (OPFS) |
-### Source / sprite / glyph URLs
+## Building the wasm
-The embedded template's `sources`, `sprite` and `glyphs` URLs are placeholders.
-Point them at your tile source and asset server before loading:
-
-```js
-const style = engine.generateStyle(settings);
-style.sources.chart = { type: 'vector', tiles: ['https://…/{z}/{x}/{y}.pbf'], minzoom: 5, maxzoom: 16 };
-style.sprite = 'https://…/sprite';
-style.glyphs = 'https://…/{fontstack}/{range}.pbf';
-map.setStyle(style);
-```
-
-## API
-
-- **`loadStyleEngine(opts?) → Promise`** — instantiate the wasm.
- `opts.wasmBytes` / `opts.wasmModule` override the bundled file.
-- **`StyleEngine#generateStyle(settings?, opts?) → object | string`** — build a
- style. `settings` is a `Partial`. `opts.nowUnix` fixes the
- "today" date (epoch seconds) for deterministic output; `opts.asString: true`
- returns the raw JSON string.
-- **`StyleEngine#template() → object`** — the embedded base template (debug/diff).
-- **`generateStyle(settings?, opts?) → Promise`** — one-shot
- convenience (loads the engine each call; prefer the class for repeated use).
-- **`DEFAULT_SETTINGS`** — the canonical defaults, for seeding a settings UI.
-
-Full types are in [`index.d.ts`](./index.d.ts). The `MarinerSettings` fields
-mirror the Zig `style.mariner.Settings` struct and the C `tile57_mariner`.
-
-### Mariner settings
-
-| field | type | default | meaning |
-|---|---|---|---|
-| `scheme` | `'day'\|'dusk'\|'night'` | `'day'` | S-52 colour palette |
-| `depth_unit` | `'meters'\|'feet'` | `'meters'` | contour-label units |
-| `shallow_contour` | number (m) | `2` | SEABED01 shallow band |
-| `safety_contour` | number (m) | `10` | own-ship safety contour |
-| `deep_contour` | number (m) | `30` | SEABED01 deep band |
-| `safety_depth` | number (m) | `10` | SNDFRM04 bold/faint sounding split |
-| `four_shade_water` | boolean | `true` | 4-shade vs 2-shade water |
-| `boundary_style` | `'symbolized'\|'plain'` | `'symbolized'` | area boundaries (§8.6.1) |
-| `simplified_points` | boolean | `false` | simplified vs paper-chart symbols |
-| `display_base/standard/other` | boolean | `true/true/false` | S-52 display category |
-| `data_quality` | boolean | `false` | M_QUAL overlay |
-| `show_inform_callouts` | boolean | `false` | INFORM01 callouts |
-| `show_meta_bounds` | boolean | `false` | meta coverage/scale bounds |
-| `show_isolated_dangers_shallow` | boolean | `false` | ISODGR01 in shallow water |
-| `show_full_sector_lines` | boolean | `false` | full light-sector legs |
-| `text_names` / `show_light_descriptions` / `text_other` | boolean | `true` | text groups (§14.5) |
-| `date_dependent` | boolean | `true` | apply date-dependent display |
-| `highlight_date_dependent` | boolean | `false` | highlight CHDATD01 features |
-| `date_view` | string | `''` | pinned date `"YYYYMMDD"` (`''` = today) |
-
-## Example / smoke test
+The engine wasm is a build artifact, not committed:
```sh
-node examples/generate.mjs
+npm run build # zig build wasm-engine + copy (needs Zig 0.16)
+npm run smoke # bake + compose + render under node's WASI host
```
-Generates styles for several setting combinations and asserts the output is a
-valid MapLibre style with the expected layers and mariner patches.
-
-## Parity
-
-The wasm engine and the native build are the **same Zig source** compiled for two
-targets. `bindings/scripts/parity-check.sh` generates a style with both for a
-range of settings (and a fixed `nowUnix`) and asserts they are **byte-identical**:
+## The style-only engine
-- native: `zig-out/bin/style-parity` (`style.buildFromTemplate`, host target)
-- wasm: this package (`style.buildFromTemplate`, `wasm32-freestanding`)
+`tile57/style` is a much smaller module for front-ends that render baked
+tiles themselves: it turns S-52 mariner settings into a complete MapLibre
+style.json, client-side.
-> Note: the `tile57 style` CLI is **not** the right oracle — it generates the base
-> *template* (`style.json`), it does not apply mariner settings. The builder that
-> bakes the mariner settings in is `style.buildFromTemplate`, which both this
-> module and the `style-parity` oracle call, hence the dedicated parity tool.
-
-## Building from source
-
-The committed `style-engine.wasm` is reproducible from the repo (Zig 0.16):
+```js
+import { loadStyleEngine } from "tile57/style";
-```sh
-bindings/scripts/gen-assets.sh # regenerate embedded template + colortables (optional)
-bindings/scripts/build-wasm.sh # zig build wasm → bindings/js/style-engine.wasm
-bindings/scripts/parity-check.sh # verify wasm == native, byte-for-byte
+const styleEngine = await loadStyleEngine();
+const style = styleEngine.buildStyle({ colorScheme: "dusk", safetyContour: 5 });
```
-## How it works / why WASM
+Types are in [`style.d.ts`](./style.d.ts); `examples/` shows it standalone
+and inside a MapLibre page.
-The S-52 portrayal logic is intricate and must **never drift** from the byte-exact
-Zig source of truth shared by the tile generator and the native renderer.
+## License
-- **Pure-TS port** — would duplicate that logic in a second language and inevitably
- drift; every S-52 fix would need porting twice.
-- **Native addon (N-API)** — ties the package to a platform/ABI, needs prebuilds,
- and doesn't run in the browser (the whole point: client-side styling).
-- **WASM (this approach)** — reuses the *exact* Zig `buildStyle`, runs in both Node
- and the browser, is tiny (~145 KB), needs no native toolchain at install time,
- and is provably identical to the native output.
-```
+MIT, per the [repository license](../../LICENSE). The engine embeds the IHO
+S-101 Portrayal Catalogue (© IHO). NOAA ENC charts are U.S. public domain and
+**not for navigation**.
diff --git a/bindings/wasm/assets/colortables.json b/bindings/js/assets/colortables.json
similarity index 100%
rename from bindings/wasm/assets/colortables.json
rename to bindings/js/assets/colortables.json
diff --git a/bindings/wasm/assets/template.json b/bindings/js/assets/template.json
similarity index 100%
rename from bindings/wasm/assets/template.json
rename to bindings/js/assets/template.json
diff --git a/bindings/js/bake-pool.mjs b/bindings/js/bake-pool.mjs
new file mode 100644
index 00000000..3c6a0c1b
--- /dev/null
+++ b/bindings/js/bake-pool.mjs
@@ -0,0 +1,67 @@
+// A pool of engine workers for parallel cell bakes. One wasm instance is
+// single-threaded, so parallel baking means N instances - each pool slot runs
+// its own engine-worker.mjs with its own engine and file tree. A cell bake is
+// pure (cell bytes in, archive bytes out), so the slots share nothing; chart
+// handles, the compositor, and rendering stay on the page's PRIMARY engine
+// worker.
+//
+// The pool is sized for a batch and closed after it: each engine instance
+// holds tens of megabytes of linear memory that wasm never returns, so slots
+// are cheap to respawn (~a few hundred ms) and expensive to keep.
+//
+// usage:
+// const pool = new BakePool(workerUrl, wasmUrl, 4);
+// const archive = await pool.bake("US5BDRAB", files); // {name, bytes}[]
+// pool.close();
+
+import { makeRpc } from "./worker-rpc.mjs";
+
+export class BakePool {
+ constructor(workerUrl, wasmUrl, size) {
+ this.slots = [];
+ this.idle = [];
+ this.waiters = [];
+ for (let i = 0; i < size; i++) {
+ const w = new Worker(workerUrl, { type: "module" });
+ const rpc = makeRpc(w);
+ // init in flight now; the first bake on the slot awaits it.
+ const slot = { w, rpc, ready: rpc("init", { wasmUrl }) };
+ this.slots.push(slot);
+ this.idle.push(slot);
+ }
+ }
+
+ acquire() {
+ if (this.idle.length) return Promise.resolve(this.idle.pop());
+ return new Promise((r) => this.waiters.push(r));
+ }
+ release(slot) {
+ const waiter = this.waiters.shift();
+ if (waiter) waiter(slot);
+ else this.idle.push(slot);
+ }
+
+ /** Bake one cell on the next free slot: `files` are the cell's .000 plus
+ * its update and text files ({name, bytes}; the bytes transfer out). Returns
+ * the archive bytes, or null when the cell produced nothing. */
+ async bake(stem, files) {
+ const slot = await this.acquire();
+ try {
+ await slot.ready;
+ for (const f of files)
+ await slot.rpc("addFile", { path: `drops/${stem}/${f.name}`, bytes: f.bytes }, [f.bytes.buffer]);
+ return await slot.rpc("bakeCell", { path: `/enc/drops/${stem}/${stem}.000` });
+ } finally {
+ // Free the cell's files before the next cell lands on this slot. The
+ // worker runs its queue in order, so no await is needed here.
+ slot.rpc("remove", { path: `drops/${stem}` }).catch(() => {});
+ this.release(slot);
+ }
+ }
+
+ close() {
+ for (const s of this.slots) s.w.terminate();
+ this.slots = [];
+ this.idle = [];
+ }
+}
diff --git a/bindings/js/chart-library.mjs b/bindings/js/chart-library.mjs
new file mode 100644
index 00000000..42163ec1
--- /dev/null
+++ b/bindings/js/chart-library.mjs
@@ -0,0 +1,115 @@
+// A persistent chart library over OPFS (the browser's origin-private file
+// system): baked PMTiles archives are written here as cells finish, and a
+// page load opens the library instead of re-baking - baking is the expensive
+// step, and it should happen once per chart, not once per session.
+//
+// Layout: one OPFS directory, `charts/`, holding `.pmtiles` plus a
+// `.json` metadata sidecar (the tile57_info: bounds, scale, zooms) so
+// a page load can catalog the library without opening a single archive.
+// OPFS needs a secure context and main-thread `createWritable` support
+// (current Firefox and Chrome; open() resolves null where any of that is
+// missing, and the caller runs session-only).
+
+export class ChartLibrary {
+ static async open() {
+ if (!navigator.storage?.getDirectory) return null;
+ try {
+ const root = await navigator.storage.getDirectory();
+ return new ChartLibrary(root, await root.getDirectoryHandle("charts", { create: true }));
+ } catch (e) {
+ console.warn("chart library unavailable:", e);
+ return null;
+ }
+ }
+ constructor(root, dir) {
+ this.root = root;
+ this.dir = dir;
+ }
+
+ /** Bytes this origin stores (the library dominates it). One instant call -
+ * per-file sizing crawls once a library holds thousands of charts. */
+ async usage() {
+ try {
+ return (await navigator.storage.estimate())?.usage ?? 0;
+ } catch {
+ return 0;
+ }
+ }
+
+ /** The catalog: [{name, info|null}], sorted by name. `info` is null only
+ * for archives saved before metadata rode along. */
+ async list() {
+ const stems = [], metas = new Map();
+ for await (const [name, handle] of this.dir.entries()) {
+ if (handle.kind !== "file") continue;
+ if (name.endsWith(".pmtiles")) stems.push(name.slice(0, -".pmtiles".length));
+ else if (name.endsWith(".json")) {
+ try {
+ metas.set(name.slice(0, -".json".length), JSON.parse(await (await handle.getFile()).text()));
+ } catch { /* a bad sidecar reads as missing */ }
+ }
+ }
+ return stems.sort().map((name) => ({ name, info: metas.get(name) ?? null }));
+ }
+
+ /** One archive's bytes, or null when absent. */
+ async get(stem) {
+ try {
+ const f = await (await this.dir.getFileHandle(`${stem}.pmtiles`)).getFile();
+ return new Uint8Array(await f.arrayBuffer());
+ } catch {
+ return null;
+ }
+ }
+
+ /** Write (or replace) one archive and its metadata sidecar. Reads `bytes`
+ * without consuming it. */
+ async put(stem, bytes, info) {
+ const h = await this.dir.getFileHandle(`${stem}.pmtiles`, { create: true });
+ const w = await h.createWritable();
+ await w.write(bytes);
+ await w.close();
+ if (info) await this.putInfo(stem, info);
+ }
+
+ /** Write the metadata sidecar alone (backfilling a legacy save). */
+ async putInfo(stem, info) {
+ const h = await this.dir.getFileHandle(`${stem}.json`, { create: true });
+ const w = await h.createWritable();
+ await w.write(JSON.stringify(info));
+ await w.close();
+ }
+
+ async remove(stem) {
+ await this.dir.removeEntry(`${stem}.pmtiles`).catch(() => {});
+ await this.dir.removeEntry(`${stem}.json`).catch(() => {});
+ await this.dir.removeEntry(`${stem}.aux`, { recursive: true }).catch(() => {});
+ }
+
+ /** Store one aux file (the text and pictures a chart's features point at,
+ * TXTDSC / PICREP) beside the chart's archive. */
+ async putAux(stem, name, bytes) {
+ const dir = await this.dir.getDirectoryHandle(`${stem}.aux`, { create: true });
+ const h = await dir.getFileHandle(name, { create: true });
+ const w = await h.createWritable();
+ await w.write(bytes);
+ await w.close();
+ }
+
+ /** One aux file's bytes, or null when the chart never carried it. */
+ async getAux(stem, name) {
+ try {
+ const dir = await this.dir.getDirectoryHandle(`${stem}.aux`);
+ const f = await (await dir.getFileHandle(name)).getFile();
+ return new Uint8Array(await f.arrayBuffer());
+ } catch {
+ return null;
+ }
+ }
+
+ /** Delete every archive - one recursive remove, not thousands of calls. */
+ async clear() {
+ await this.root.removeEntry("charts", { recursive: true });
+ this.dir = await this.root.getDirectoryHandle("charts", { create: true });
+ }
+}
diff --git a/bindings/js/demo.html b/bindings/js/demo.html
new file mode 100644
index 00000000..a67347d7
--- /dev/null
+++ b/bindings/js/demo.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+tile57 wasm chartplotter
+
+
+
+
+
+
+
+
+
diff --git a/bindings/js/demo/app.mjs b/bindings/js/demo/app.mjs
new file mode 100644
index 00000000..10eb5880
--- /dev/null
+++ b/bindings/js/demo/app.mjs
@@ -0,0 +1,345 @@
+// The demo app: boot the engine worker, wire the chrome, and run the render
+// loop. Everything stateful lives in the focused modules - camera.mjs (the
+// view), chart-store.mjs (charts on disk / resident in the engine),
+// import.mjs (drops -> bakes), mariner.mjs (S-52 settings), pick-*.mjs (the
+// cursor pick) - this file is the wiring between them and the DOM.
+
+import { GpuRenderer } from "../gpu-renderer.mjs";
+import { makeRpc } from "../worker-rpc.mjs";
+import { STYLE, CHROME } from "./view.mjs";
+import { PICK_STYLE, PICK_CHROME, PickReport } from "./pick-report.mjs";
+import {
+ cam, viewW, viewH, screenToWorld, worldToScreen, worldToLonLat, lonLatToWorld,
+ scaleDenom, zoomAt, fitTo, restoreView, saveView,
+} from "./camera.mjs";
+import { wireGestures } from "./gestures.mjs";
+import { ChartStore } from "./chart-store.mjs";
+import { ChartImporter } from "./import.mjs";
+import { loadStored, saveStored, SCHEMES } from "./mariner.mjs";
+import { renderSettings } from "./settings-panel.mjs";
+
+const q = new URLSearchParams(location.search);
+
+// ---- chrome ---------------------------------------------------------------
+const root = document.getElementById("root");
+root.innerHTML = `${CHROME}${PICK_CHROME}`;
+const $ = (id) => root.querySelector(`#${id}`);
+const canvas = $("map"), img = $("mapimg");
+
+function toast(msg, error = false) {
+ const el = document.createElement("div");
+ el.className = `toast${error ? " error" : ""}`;
+ el.textContent = msg;
+ $("toasts").append(el);
+ setTimeout(() => { el.classList.add("out"); setTimeout(() => el.remove(), 350); }, 5000);
+}
+const sub = (msg, bad = false) => {
+ $("db-sub").textContent = msg;
+ $("db-sub").classList.toggle("bad", bad);
+};
+const splash = (label) => { $("splash-label").textContent = label; };
+const splashDone = () => { $("splash").classList.add("hide"); setTimeout(() => $("splash").remove(), 500); };
+setTimeout(splashDone, 30000); // never trap the user behind the splash
+
+// ---- the engine, in its worker --------------------------------------------
+const workerUrl = new URL("./engine-worker.mjs", location.href);
+const wasmUrl = new URL(q.get("wasm") || "./tile57-engine.wasm", location.href).href;
+const worker = new Worker(workerUrl, { type: "module" });
+const rpc = makeRpc(worker);
+worker.onerror = (e) => { console.error(e); toast(`engine worker failed: ${e.message ?? "see console"}`, true); };
+
+const dpr = Math.min(devicePixelRatio || 1, 2);
+const SCENE_MARGIN = Math.min(3, Math.max(1, parseFloat(q.get("margin")) || 1.6));
+const store = new ChartStore(rpc, {
+ margin: SCENE_MARGIN,
+ maxOpen: Math.min(128, Math.max(4, parseInt(q.get("open")) || 64)),
+});
+const importer = new ChartImporter(rpc, store, {
+ workerUrl, wasmUrl,
+ workers: Math.min(8, Math.max(1, parseInt(q.get("workers")) || Math.min(4, (navigator.hardwareConcurrency || 2) - 1))),
+});
+
+const initPromise = rpc("init", { wasmUrl }); // engine downloads while we set up
+await store.openLibrary();
+
+let version = "";
+try {
+ ({ version } = await initPromise);
+} catch (e) {
+ console.error(e);
+ splash(`The engine failed to start: ${e.message}`);
+ throw e;
+}
+
+// ---- mariner settings + renderer ------------------------------------------
+let mariner = loadStored(await rpc("marinerDefaults"));
+const schemeIdx = () => Math.max(0, SCHEMES.indexOf(mariner.scheme));
+const applySchemeChrome = () => {
+ if (mariner.scheme === "day") delete root.dataset.scheme;
+ else root.dataset.scheme = mariner.scheme;
+};
+applySchemeChrome();
+
+let gpu = null, gpuWhy = "";
+if (q.get("png")) gpuWhy = "?png=1";
+else if (!isSecureContext) gpuWhy = "insecure context - WebGPU needs https or localhost";
+else if (!GpuRenderer.supported()) gpuWhy = "navigator.gpu is not exposed";
+else {
+ try {
+ splash("Baking symbol and glyph atlases…");
+ gpu = await GpuRenderer.create(canvas, dpr, await rpc("gpuAssets", { pixelRatio: dpr, scheme: schemeIdx() }));
+ } catch (e) {
+ console.error(e);
+ gpuWhy = e.message;
+ }
+}
+const surface = gpu ? canvas : img;
+if (!gpu) {
+ canvas.style.display = "none";
+ img.style.display = "block";
+ if (gpuWhy !== "?png=1") toast(`PNG fallback: ${gpuWhy}`, false);
+}
+$("attr-engine").textContent = `tile57 ${version} · ${gpu ? "WebGPU" : "PNG"}`;
+
+function sizeCanvas() {
+ canvas.width = Math.max(1, Math.round(viewW() * dpr));
+ canvas.height = Math.max(1, Math.round(viewH() * dpr));
+}
+sizeCanvas();
+
+// ---- the render loop ------------------------------------------------------
+let sceneCam = null, lastSet = { compose: 0, chart: 0 };
+let rebuildTimer = 0, rebuilding = false, rebuildAgain = false, lastLive = 0;
+
+function renderDims() {
+ const w = Math.round(viewW() * dpr), h = Math.round(viewH() * dpr);
+ const c = Math.abs(Math.cos(cam.rot)), sn = Math.abs(Math.sin(cam.rot));
+ return [
+ Math.min(4096, Math.round((w * c + h * sn) * SCENE_MARGIN)),
+ Math.min(4096, Math.round((w * sn + h * c) * SCENE_MARGIN)),
+ ];
+}
+function pngPlace() {
+ if (!sceneCam) return;
+ const [px, py] = worldToScreen(...lonLatToWorld(sceneCam.lon, sceneCam.lat));
+ const k = 2 ** (cam.zoom - sceneCam.zoom);
+ img.style.transform = `translate(${px - viewW() / 2}px, ${py - viewH() / 2}px) rotate(${cam.rot}rad) scale(${k})`;
+}
+let rafPending = false;
+function redraw() {
+ hud();
+ if (!gpu || !store.catalog.length || rafPending) return;
+ rafPending = true;
+ requestAnimationFrame(() => { rafPending = false; gpu.draw(cam); });
+}
+function afterCamera() {
+ hud();
+ if (gpu) { redraw(); liveRebuild(); }
+ else pngPlace();
+}
+function liveRebuild() {
+ if (!gpu || rebuilding) return;
+ const now = performance.now();
+ if (now - lastLive < 250) return;
+ lastLive = now;
+ rebuild();
+}
+function scheduleRebuild(ms = 250) {
+ clearTimeout(rebuildTimer);
+ rebuildTimer = setTimeout(rebuild, ms);
+}
+async function rebuild() {
+ if (!store.catalog.length) return;
+ if (rebuilding) { rebuildAgain = true; return; }
+ rebuilding = true;
+ const t0 = performance.now();
+ const [w, h] = renderDims();
+ try {
+ const set = await store.ensureView();
+ lastSet = set;
+ if (!set.compose && !set.chart) {
+ if (gpu) { gpu.disposeScene(); gpu.draw(cam); } else img.removeAttribute("src");
+ sceneCam = { ...cam };
+ sub("no charts cover this view");
+ } else {
+ const view = { compose: set.compose, chart: set.chart, lon: cam.lon, lat: cam.lat, zoom: cam.zoom, w, h, mariner };
+ if (gpu) {
+ const scene = await rpc("gpuScene", { ...view, pixelRatio: dpr, atlasHave: gpu.atlasHave, halo: gpu.halo });
+ gpu.setScene(scene);
+ gpu.draw(cam);
+ } else {
+ const png = await rpc("png", view);
+ if (img.dataset.url) URL.revokeObjectURL(img.dataset.url);
+ img.dataset.url = URL.createObjectURL(new Blob([png], { type: "image/png" }));
+ img.src = img.dataset.url;
+ img.style.width = `${w / dpr}px`;
+ img.style.height = `${h / dpr}px`;
+ img.style.left = `${(viewW() - w / dpr) / 2}px`;
+ img.style.top = `${(viewH() - h / dpr) / 2}px`;
+ }
+ sceneCam = { ...cam };
+ if (!gpu) pngPlace();
+ if ($("db-sub").textContent.startsWith("no charts")) sub("");
+ saveView();
+ }
+ } catch (e) {
+ console.error(e);
+ sub(`render failed: ${e.message}`, true);
+ }
+ rebuilding = false;
+ splashDone();
+ if (rebuildAgain) { rebuildAgain = false; scheduleRebuild(0); }
+ hud();
+}
+
+// ---- the data card --------------------------------------------------------
+let cursorLL = null;
+function hud() {
+ const on = store.catalog.length > 0;
+ $("databox").hidden = !on;
+ $("welcome").hidden = on;
+ if (!on) return;
+ $("hud-scale").textContent = `1:${Math.round(scaleDenom(cam.zoom)).toLocaleString()}`;
+ $("hud-z").textContent = `z${cam.zoom.toFixed(1)}`;
+ const [lon, lat] = cursorLL ?? [cam.lon, cam.lat];
+ $("hud-coord").textContent = `${lat.toFixed(4)}, ${lon.toFixed(4)}`;
+ const hdg = Math.round((((-cam.rot * 180) / Math.PI) % 360 + 360) % 360);
+ $("hud-hdg").textContent = hdg ? ` ↑${String(hdg).padStart(3, "0")}°` : "";
+ $("needle").style.transform = `rotate(${cam.rot}rad)`;
+}
+function progress(done, total, label) {
+ const box = $("db-prog");
+ if (total === -1) { box.hidden = true; return; }
+ box.hidden = false;
+ $("databox").hidden = false;
+ $("welcome").hidden = true;
+ $("db-prog-title").textContent = "Importing charts";
+ $("db-prog-action").textContent = label ?? "";
+ $("db-prog-count").textContent = total ? `${done} of ${total}` : "";
+ const fill = $("db-prog-fill");
+ fill.classList.toggle("indet", !total);
+ if (total) fill.style.width = `${Math.round((done / total) * 100)}%`;
+}
+
+// ---- importing ------------------------------------------------------------
+async function importFiles(files) {
+ const { added, failed, skipped } = await importer.loadFiles(files, {
+ progress,
+ onChart: () => hud(),
+ });
+ progress(0, -1);
+ if (failed.length) toast(failed.slice(0, 2).join("; ") + (failed.length > 2 ? ` (+${failed.length - 2} more)` : ""), true);
+ if (skipped) sub(`${skipped} chart${skipped > 1 ? "s" : ""} already in the library`);
+ if (!added.length) { hud(); return; }
+ fitTo(added);
+ sub(`${store.catalog.length} chart${store.catalog.length > 1 ? "s" : ""} in the library`);
+ await rebuild();
+}
+window.tile57Drops.handler = (files) => importFiles(files);
+if (window.tile57Drops.pending.length) importFiles(window.tile57Drops.pending.splice(0));
+addEventListener("t57-dragover", () => root.classList.add("droptarget"));
+addEventListener("t57-dragleave", () => root.classList.remove("droptarget"));
+
+// The bundled sample (staged by the docs workflow; absent locally is fine).
+fetch("./sample.zip", { method: "HEAD" }).then((r) => {
+ if (!r.ok) return;
+ const btn = document.createElement("button");
+ btn.className = "cta";
+ btn.textContent = "⛵ Or try a sample harbor";
+ btn.style.marginTop = "8px";
+ btn.addEventListener("click", async () => {
+ btn.disabled = true;
+ const blob = await (await fetch("./sample.zip")).blob();
+ await importFiles([new File([blob], "sample.zip")]);
+ });
+ root.querySelector("#welcome .card .cta").after(document.createElement("br"), btn);
+}).catch(() => {});
+
+// ---- gestures + controls --------------------------------------------------
+wireGestures(surface, root, {
+ enabled: () => store.catalog.length > 0,
+ onMove: (mx, my) => {
+ cursorLL = worldToLonLat(...screenToWorld(mx, my));
+ hud();
+ },
+ onChange: afterCamera,
+ onSettle: (ms) => scheduleRebuild(gpu ? Math.max(ms, 200) : Math.max(ms, 300)),
+ onTap: async (mx, my) => {
+ if (!lastSet.compose && !lastSet.chart) return;
+ const [lon, lat] = worldToLonLat(...screenToWorld(mx, my));
+ try {
+ const features = await rpc("pick", { compose: lastSet.compose, chart: lastSet.chart, lon, lat, zoom: cam.zoom });
+ if (!pick.show(features)) sub("nothing charted here");
+ } catch (e) {
+ console.error(e);
+ sub(`pick failed: ${e.message}`, true);
+ }
+ },
+});
+const pick = new PickReport(root, { getAux: (chart, name) => store.getAux(chart, name) });
+
+$("zi").addEventListener("click", () => { zoomAt(viewW() / 2, viewH() / 2, 1); afterCamera(); scheduleRebuild(0); });
+$("zo").addEventListener("click", () => { zoomAt(viewW() / 2, viewH() / 2, -1); afterCamera(); scheduleRebuild(0); });
+$("north").addEventListener("click", () => { cam.rot = 0; afterCamera(); scheduleRebuild(0); });
+$("fs").addEventListener("click", () =>
+ document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen());
+addEventListener("resize", () => { sizeCanvas(); if (gpu) redraw(); scheduleRebuild(200); });
+
+$("lib").addEventListener("click", async () => {
+ if (!confirm("Clear the chart library and reload the page?")) return;
+ await store.clear();
+ location.reload();
+});
+
+// ---- scheme + settings ----------------------------------------------------
+async function applyMariner(patch) {
+ const schemeChanged = patch.scheme && patch.scheme !== mariner.scheme;
+ mariner = { ...mariner, ...patch };
+ saveStored(mariner);
+ if (schemeChanged) {
+ applySchemeChrome();
+ if (gpu) {
+ try {
+ // Await the swap: the rebuild below reads gpu.halo for the SDF text
+ // pass and the clear colour, and an un-awaited swap left them one
+ // scheme behind until the next camera move rebuilt again.
+ await gpu.setScheme(mariner.scheme, await rpc("spriteAtlas", { pixelRatio: dpr, scheme: schemeIdx() }));
+ } catch (e) {
+ console.warn("scheme atlas:", e);
+ }
+ }
+ }
+ scheduleRebuild(0);
+}
+$("scheme").addEventListener("click", () =>
+ applyMariner({ scheme: SCHEMES[(schemeIdx() + 1) % SCHEMES.length] }));
+
+const drawer = $("drawer");
+const renderPanel = () => renderSettings($("settings-body"), mariner, (key, value) => {
+ applyMariner({ [key]: value });
+ renderPanel(); // groups are unit-aware; re-render keeps rows current
+});
+$("settings").addEventListener("click", () => {
+ drawer.classList.toggle("open");
+ if (drawer.classList.contains("open")) renderPanel();
+});
+$("drawer-close").addEventListener("click", () => drawer.classList.remove("open"));
+
+// Esc closes the topmost surface: settings first, then the pick report.
+addEventListener("keydown", (e) => {
+ if (e.key !== "Escape") return;
+ if (drawer.classList.contains("open")) drawer.classList.remove("open");
+ else if (pick.open) pick.hide();
+});
+
+// ---- land on the library --------------------------------------------------
+splash("Opening the chart library…");
+await store.loadSaved((n, total) => splash(`Indexing the library - ${n} of ${total}…`));
+hud();
+if (store.catalog.length) {
+ if (!restoreView()) fitTo(store.catalog);
+ sub(`${store.catalog.length} chart${store.catalog.length > 1 ? "s" : ""} in the library`);
+ await rebuild();
+} else {
+ splashDone();
+}
diff --git a/bindings/js/demo/camera.mjs b/bindings/js/demo/camera.mjs
new file mode 100644
index 00000000..2422dac2
--- /dev/null
+++ b/bindings/js/demo/camera.mjs
@@ -0,0 +1,96 @@
+// The camera: centre, zoom, and view rotation, with the screen/world
+// transforms every consumer shares. Geometry stays north-up in world space
+// (web mercator, [0,1], y down); the camera turns, and every transform here
+// honours that turn - the cursor readout, pan, anchored zoom and rotation,
+// and the PNG placement all go through these.
+
+import { lonLatToWorld, worldToLonLat, scaleDenom } from "../gpu-renderer.mjs";
+
+export { lonLatToWorld, worldToLonLat, scaleDenom };
+
+export const cam = { lon: -76.4875, lat: 38.975, zoom: 11, rot: 0 };
+
+export const viewW = () => innerWidth;
+export const viewH = () => innerHeight;
+export const cssWorld = () => 256 * 2 ** cam.zoom; // CSS px per world unit
+const rotCS = () => [Math.cos(cam.rot), Math.sin(cam.rot)];
+const clampY = (y) => Math.min(0.9999, Math.max(0.0001, y));
+
+export function screenToWorld(mx, my) {
+ const S = cssWorld(), [c, sn] = rotCS();
+ const dx = mx - viewW() / 2, dy = my - viewH() / 2;
+ const [cx, cy] = lonLatToWorld(cam.lon, cam.lat);
+ return [cx + (c * dx + sn * dy) / S, cy + (-sn * dx + c * dy) / S];
+}
+export function worldToScreen(wx, wy) {
+ const S = cssWorld(), [c, sn] = rotCS();
+ const [cx, cy] = lonLatToWorld(cam.lon, cam.lat);
+ const rx = (wx - cx) * S, ry = (wy - cy) * S;
+ return [viewW() / 2 + c * rx - sn * ry, viewH() / 2 + sn * rx + c * ry];
+}
+/** Re-centre so world point `p` lands at screen (mx, my). */
+export function centerOn(p, mx, my) {
+ const S = cssWorld(), [c, sn] = rotCS();
+ const dx = mx - viewW() / 2, dy = my - viewH() / 2;
+ [cam.lon, cam.lat] = worldToLonLat(p[0] - (c * dx + sn * dy) / S, clampY(p[1] - (-sn * dx + c * dy) / S));
+}
+export function panBy(dxCss, dyCss) {
+ const S = cssWorld(), [c, sn] = rotCS();
+ const [cx, cy] = lonLatToWorld(cam.lon, cam.lat);
+ [cam.lon, cam.lat] = worldToLonLat(cx - (c * dxCss + sn * dyCss) / S, clampY(cy - (-sn * dxCss + c * dyCss) / S));
+}
+export function zoomAt(mx, my, dz) {
+ const p = screenToWorld(mx, my);
+ cam.zoom = Math.min(18, Math.max(2, cam.zoom + dz));
+ centerOn(p, mx, my);
+}
+/** Zoom and rotate together, anchored at (mx, my) - the pinch gesture. */
+export function pinchAt(mx, my, dz, dRot) {
+ const p = screenToWorld(mx, my);
+ cam.zoom = Math.min(18, Math.max(2, cam.zoom + dz));
+ cam.rot += dRot;
+ centerOn(p, mx, my);
+}
+
+/** Fit the camera to a chart list ({info} entries). One overview chart can
+ * span an ocean and drag the union's centre off the detailed cluster, so
+ * charts with a footprint over ~8x the median stay out of the fit. */
+export function fitTo(list) {
+ const bounded = list.filter((c) => c.info?.hasBounds);
+ if (!bounded.length) return;
+ const area = (c) => Math.max(0, c.info.east - c.info.west) * Math.max(0, c.info.north - c.info.south);
+ const sorted = bounded.map(area).sort((a, b) => a - b);
+ const median = sorted[Math.floor(sorted.length / 2)];
+ let fit = bounded.filter((c) => area(c) <= median * 8);
+ if (!fit.length) fit = bounded;
+ let west = 180, south = 90, east = -180, north = -90;
+ for (const c of fit) {
+ west = Math.min(west, c.info.west); south = Math.min(south, c.info.south);
+ east = Math.max(east, c.info.east); north = Math.max(north, c.info.north);
+ }
+ const [wx0, wy0] = lonLatToWorld(west, north), [wx1, wy1] = lonLatToWorld(east, south);
+ const z = Math.log2(Math.min((viewW() * 0.9) / (256 * (wx1 - wx0)), (viewH() * 0.9) / (256 * (wy1 - wy0))));
+ cam.zoom = Math.min(16, Math.max(3, z));
+ [cam.lon, cam.lat] = worldToLonLat((wx0 + wx1) / 2, (wy0 + wy1) / 2);
+}
+
+// ---- persistence: the last view survives a reload -------------------------
+const KEY = "tile57.view";
+export function restoreView() {
+ try {
+ const v = JSON.parse(localStorage.getItem(KEY));
+ if (!v || ![v.lat, v.lon, v.zoom].every(Number.isFinite)) return false;
+ cam.lat = Math.max(-85, Math.min(85, v.lat));
+ cam.lon = Math.max(-180, Math.min(180, v.lon));
+ cam.zoom = Math.max(2, Math.min(18, v.zoom));
+ cam.rot = Number.isFinite(v.rot) ? v.rot : 0;
+ return true;
+ } catch {
+ return false;
+ }
+}
+export function saveView() {
+ try {
+ localStorage.setItem(KEY, JSON.stringify({ lat: cam.lat, lon: cam.lon, zoom: cam.zoom, rot: cam.rot }));
+ } catch { /* private mode; the view just does not persist */ }
+}
diff --git a/bindings/js/demo/chart-store.mjs b/bindings/js/demo/chart-store.mjs
new file mode 100644
index 00000000..4aac5f2d
--- /dev/null
+++ b/bindings/js/demo/chart-store.mjs
@@ -0,0 +1,174 @@
+// The chart store: the CATALOG of every known chart ({name, info}, no open
+// handles), the persistent library behind it (OPFS via chart-library.mjs, or
+// page memory without OPFS), and the view-windowed RESIDENT set - only the
+// charts the current view needs are open in the engine, and a whole district
+// on disk stays a handful of charts in memory.
+
+import { ChartLibrary } from "../chart-library.mjs";
+import { cam, cssWorld, viewW, viewH, lonLatToWorld, worldToLonLat, scaleDenom } from "./camera.mjs";
+
+export class ChartStore {
+ /** `rpc` is the primary engine worker's RPC; `margin` the scene prefetch
+ * factor (selection covers the same box the scene request does). */
+ constructor(rpc, { margin = 1.6, maxOpen = 64 } = {}) {
+ this.rpc = rpc;
+ this.margin = margin;
+ this.maxOpen = maxOpen;
+ this.catalog = []; // {name, info}
+ this.library = null;
+ this.sessionStore = new Map(); // archives when OPFS is unavailable
+ this.auxSession = new Map(); // aux files when OPFS is unavailable
+ this.openMap = new Map(); // name -> engine chart handle (the resident set)
+ this.lastUsed = new Map(); // name -> viewSeq, for eviction
+ this.compose = 0;
+ this.composeKey = null;
+ this.viewSeq = 0;
+ this.storedBytes = 0;
+ }
+
+ async openLibrary() {
+ this.library = await ChartLibrary.open();
+ if (this.library) this.storedBytes = await this.library.usage();
+ return this.library != null;
+ }
+
+ /** Catalog the stored library (metadata sidecars only - no archive opens).
+ * Archives saved before metadata rode along are indexed once through the
+ * engine; `onProgress(done, total)` reports that backfill. */
+ async loadSaved(onProgress) {
+ if (!this.library) return [];
+ const saved = await this.library.list();
+ const legacy = saved.filter((c) => !c.info);
+ let n = 0;
+ for (const c of legacy) {
+ onProgress?.(++n, legacy.length);
+ const bytes = await this.library.get(c.name);
+ if (!bytes) continue;
+ try {
+ const { handle, info } = await this.rpc("openChartBytes", { bytes }, [bytes.buffer]);
+ await this.rpc("closeChart", { handle });
+ c.info = info;
+ await this.library.putInfo(c.name, info);
+ } catch (e) {
+ console.warn(`library index ${c.name}:`, e);
+ }
+ }
+ for (const c of saved) if (c.info) this.catalog.push(c);
+ return this.catalog;
+ }
+
+ has(name) {
+ return this.catalog.some((c) => c.name === name);
+ }
+
+ /** Save + catalog a freshly baked chart. Consumes `archive`'s buffer. */
+ async register(name, archive, info) {
+ if (this.has(name)) return null;
+ if (this.library) {
+ await this.library.put(name, archive, info).catch((e) => console.warn(`library save ${name}:`, e));
+ } else this.sessionStore.set(name, archive);
+ this.storedBytes += archive.length;
+ const entry = { name, info };
+ this.catalog.push(entry);
+ return entry;
+ }
+
+ async refreshUsage() {
+ if (this.library) this.storedBytes = await this.library.usage();
+ }
+
+ async clear() {
+ if (this.library) await this.library.clear();
+ this.sessionStore.clear();
+ }
+
+ /** Store an aux file (TXTDSC text, PICREP pictures) for a chart. */
+ async putAux(stem, name, bytes) {
+ try {
+ if (this.library) await this.library.putAux(stem, name, bytes);
+ else this.auxSession.set(`${stem}/${name}`, bytes);
+ } catch (e) {
+ console.warn(`aux save ${stem}/${name}:`, e);
+ }
+ }
+ /** An aux file's bytes, or null. */
+ async getAux(stem, name) {
+ if (this.library) return this.library.getAux(stem, name);
+ return this.auxSession.get(`${stem}/${name}`) ?? null;
+ }
+
+ async archiveBytes(name) {
+ if (this.library) return this.library.get(name);
+ const b = this.sessionStore.get(name);
+ return b ? b.slice() : null; // a copy: opening transfers the buffer away
+ }
+
+ // The box the scene request covers: the rotated viewport's bounding box,
+ // inflated by the prefetch margin - a chart is resident before the scene
+ // needs it.
+ viewBounds() {
+ const s = cssWorld();
+ const [cx, cy] = lonLatToWorld(cam.lon, cam.lat);
+ const c = Math.abs(Math.cos(cam.rot)), sn = Math.abs(Math.sin(cam.rot));
+ const hw = ((viewW() * c + viewH() * sn) * this.margin) / 2 / s;
+ const hh = ((viewW() * sn + viewH() * c) * this.margin) / 2 / s;
+ const [west, north] = worldToLonLat(cx - hw, Math.max(0.0001, cy - hh));
+ const [east, south] = worldToLonLat(cx + hw, Math.min(0.9999, cy + hh));
+ return { west, south, east, north };
+ }
+
+ selectCharts() {
+ const vb = this.viewBounds();
+ const denom = scaleDenom(cam.zoom);
+ const hits = this.catalog.filter(({ info }) => info?.hasBounds
+ && info.west < vb.east && info.east > vb.west
+ && info.south < vb.north && info.north > vb.south);
+ // g > 0: the chart is more GENERAL than the view's scale. Suitable charts
+ // first, most general leading - over the cap that order decides who
+ // draws, and generals cover the view in the fewest cells (the engine's
+ // partition gives detailed charts precedence on overlap). Slots left over
+ // go to out-of-window charts nearest the view's scale: an area only a
+ // detailed chart covers shows that chart overscaled, never a hole.
+ const scored = hits.map((c) => ({ c, g: Math.log2((c.info.nativeScale || denom) / denom) }));
+ const inWin = scored.filter((sc) => Math.abs(sc.g) <= 3.5).sort((a, b) => b.g - a.g);
+ const outWin = scored.filter((sc) => Math.abs(sc.g) > 3.5).sort((a, b) => Math.abs(a.g) - Math.abs(b.g));
+ return inWin.concat(outWin).slice(0, this.maxOpen).map((sc) => sc.c);
+ }
+
+ /** Make the view's charts resident and composed; evict beyond the cap.
+ * Returns {compose, chart} - one nonzero when anything covers the view. */
+ async ensureView() {
+ const sel = this.selectCharts();
+ this.viewSeq++;
+ for (const c of sel) this.lastUsed.set(c.name, this.viewSeq);
+ const key = sel.map((c) => c.name).sort().join(",");
+ if (key !== this.composeKey) {
+ if (this.compose) {
+ await this.rpc("composeClose", { handle: this.compose });
+ this.compose = 0;
+ }
+ for (const c of sel) {
+ if (this.openMap.has(c.name)) continue;
+ const bytes = await this.archiveBytes(c.name);
+ if (!bytes) continue;
+ const { handle } = await this.rpc("openChartBytes", { bytes }, [bytes.buffer]);
+ this.openMap.set(c.name, handle);
+ }
+ const handles = sel.map((c) => this.openMap.get(c.name)).filter((h) => h !== undefined);
+ this.compose = handles.length > 1 ? await this.rpc("composeOpen", { handles }) : 0;
+ this.composeKey = key;
+ if (this.openMap.size > this.maxOpen) {
+ const inUse = new Set(sel.map((c) => c.name));
+ const victims = [...this.openMap.keys()].filter((n) => !inUse.has(n))
+ .sort((a, b) => (this.lastUsed.get(a) || 0) - (this.lastUsed.get(b) || 0));
+ while (this.openMap.size > this.maxOpen && victims.length) {
+ const n = victims.shift();
+ this.rpc("closeChart", { handle: this.openMap.get(n) }).catch(() => {});
+ this.openMap.delete(n);
+ }
+ }
+ }
+ const sole = sel.length === 1 ? (this.openMap.get(sel[0].name) ?? 0) : 0;
+ return { compose: this.compose, chart: this.compose ? 0 : sole };
+ }
+}
diff --git a/bindings/js/demo/gestures.mjs b/bindings/js/demo/gestures.mjs
new file mode 100644
index 00000000..4586f6de
--- /dev/null
+++ b/bindings/js/demo/gestures.mjs
@@ -0,0 +1,150 @@
+// Map gestures over one surface element: drag pan (Shift-drag rotates about
+// the screen centre), wheel zoom at the cursor, double-click zoom, two-pointer
+// pinch (zoom + twist + pan about the midpoint), a velocity flick on a fast
+// pan release, and keyboard arrows / +/-.
+//
+// The camera work happens in camera.mjs; this module only turns events into
+// camera changes and reports them:
+// onMove(mx, my) every pointer move (the cursor readout)
+// onChange() the camera moved (redraw the standing scene, live rebuild)
+// onSettle(ms) a gesture ended or paused (schedule a scene rebuild)
+// onTap(mx, my) a click/tap that never became a drag (the cursor pick)
+
+import { cam, panBy, zoomAt, pinchAt, viewW, viewH } from "./camera.mjs";
+
+export function wireGestures(surface, root, { enabled, onMove, onChange, onSettle, onTap }) {
+ const pointers = new Map(); // pointerId -> {x, y}
+ let drag = null, pinch = null, flick = 0;
+
+ const stopFlick = () => {
+ if (flick) cancelAnimationFrame(flick);
+ flick = 0;
+ };
+ const startFlick = (vx, vy) => {
+ let last = performance.now();
+ const step = (now) => {
+ const dt = Math.min(64, now - last);
+ last = now;
+ panBy(vx * dt, vy * dt);
+ const f = Math.exp(-dt / 280);
+ vx *= f; vy *= f;
+ onChange();
+ if (Math.hypot(vx, vy) > 0.02) flick = requestAnimationFrame(step);
+ else { flick = 0; onSettle(0); }
+ };
+ stopFlick();
+ flick = requestAnimationFrame(step);
+ };
+
+ const pinchState = () => {
+ const [p1, p2] = [...pointers.values()];
+ return {
+ dist: Math.max(1, Math.hypot(p2.x - p1.x, p2.y - p1.y)),
+ ang: Math.atan2(p2.y - p1.y, p2.x - p1.x),
+ mx: (p1.x + p2.x) / 2, my: (p1.y + p2.y) / 2,
+ };
+ };
+
+ surface.addEventListener("pointerdown", (e) => {
+ if (!enabled()) return;
+ stopFlick();
+ surface.setPointerCapture(e.pointerId);
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (pointers.size === 2) {
+ pinch = pinchState();
+ drag = null;
+ } else if (pointers.size === 1) {
+ root.classList.add("dragging");
+ drag = { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, moved: 0,
+ mode: e.shiftKey ? "rotate" : "pan", vx: 0, vy: 0, t: performance.now(), t0: performance.now() };
+ }
+ });
+
+ surface.addEventListener("pointermove", (e) => {
+ onMove(e.clientX, e.clientY);
+ if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (pinch && pointers.size === 2) {
+ const now = pinchState();
+ panBy(now.mx - pinch.mx, now.my - pinch.my);
+ pinchAt(now.mx, now.my, Math.log2(now.dist / pinch.dist), now.ang - pinch.ang);
+ pinch = now;
+ onChange();
+ onSettle(300);
+ return;
+ }
+ if (!drag) return;
+ const now = performance.now(), dt = Math.max(1, now - drag.t);
+ if (drag.mode === "rotate") {
+ const a0 = Math.atan2(drag.y - viewH() / 2, drag.x - viewW() / 2);
+ const a1 = Math.atan2(e.clientY - viewH() / 2, e.clientX - viewW() / 2);
+ cam.rot += a1 - a0;
+ } else {
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
+ panBy(dx, dy);
+ drag.vx = 0.8 * drag.vx + 0.2 * (dx / dt); // px/ms, smoothed for the flick
+ drag.vy = 0.8 * drag.vy + 0.2 * (dy / dt);
+ }
+ drag.moved = Math.max(drag.moved, Math.hypot(e.clientX - drag.sx, e.clientY - drag.sy));
+ drag.x = e.clientX; drag.y = e.clientY; drag.t = now;
+ onChange();
+ });
+
+ const endPointer = (e) => {
+ pointers.delete(e.pointerId);
+ if (pointers.size < 2) pinch = null;
+ if (pointers.size === 1) {
+ // A pinch collapsed to one finger: continue as a pan from it.
+ const [p] = pointers.values();
+ drag = { x: p.x, y: p.y, mode: "pan", vx: 0, vy: 0, t: performance.now() };
+ return;
+ }
+ if (!drag) { onSettle(200); return; }
+ root.classList.remove("dragging");
+ const { mode, vx, vy, moved, t0 } = drag;
+ drag = null;
+ // A press that never travelled is a TAP - the cursor pick.
+ if (mode === "pan" && moved < 6 && performance.now() - t0 < 400) {
+ onTap?.(e.clientX, e.clientY);
+ return;
+ }
+ if (mode === "pan" && Math.hypot(vx, vy) > 0.15) startFlick(vx, vy);
+ else onSettle(200);
+ };
+ surface.addEventListener("pointerup", endPointer);
+ surface.addEventListener("pointercancel", endPointer);
+
+ surface.addEventListener("wheel", (e) => {
+ if (!enabled()) return;
+ e.preventDefault();
+ zoomAt(e.clientX, e.clientY, -e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0022));
+ onChange();
+ onSettle(300);
+ }, { passive: false });
+
+ surface.addEventListener("dblclick", (e) => {
+ if (!enabled()) return;
+ zoomAt(e.clientX, e.clientY, 1);
+ onChange();
+ onSettle(0);
+ });
+
+ addEventListener("keydown", (e) => {
+ if (!enabled()) return;
+ const pan = 120;
+ const moves = {
+ ArrowLeft: () => panBy(pan, 0), ArrowRight: () => panBy(-pan, 0),
+ ArrowUp: () => panBy(0, pan), ArrowDown: () => panBy(0, -pan),
+ "+": () => zoomAt(viewW() / 2, viewH() / 2, 1),
+ "=": () => zoomAt(viewW() / 2, viewH() / 2, 1),
+ "-": () => zoomAt(viewW() / 2, viewH() / 2, -1),
+ };
+ const move = moves[e.key];
+ if (!move) return;
+ e.preventDefault();
+ move();
+ onChange();
+ onSettle(250);
+ });
+
+ return { stopFlick };
+}
diff --git a/bindings/js/demo/import.mjs b/bindings/js/demo/import.mjs
new file mode 100644
index 00000000..c27f4bd1
--- /dev/null
+++ b/bindings/js/demo/import.mjs
@@ -0,0 +1,155 @@
+// Chart import: dropped .000 cells (with their update files) and exchange-set
+// zips, baked in parallel across a pool of engine workers and registered in
+// the chart store as each cell finishes.
+//
+// The zip stays in the PRIMARY engine (which lists and extracts it); each
+// cell's extracted files shuttle to a pool slot for the bake, so extraction
+// streams while bakes run wide. Extracted files and the zips free as soon as
+// they are done with, so a big batch stays flat in memory.
+
+import { BakePool } from "../bake-pool.mjs";
+
+export class ChartImporter {
+ /** `progress(done, total, label)` drives the data card's job row;
+ * `onChart(entry)` fires as each chart lands in the store. */
+ constructor(rpc, store, { workerUrl, wasmUrl, workers = 4 } = {}) {
+ this.rpc = rpc;
+ this.store = store;
+ this.workerUrl = workerUrl;
+ this.wasmUrl = wasmUrl;
+ this.workers = workers;
+ this.loading = false;
+ this.dropSeq = 0;
+ }
+
+ /** Import dropped File objects. Returns {added, failed, skipped}. */
+ async loadFiles(files, { progress, onChart } = {}) {
+ if (this.loading) return { added: [], failed: ["still loading the previous drop"], skipped: 0 };
+ this.loading = true;
+ const added = [], failed = [];
+ let skipped = 0;
+ const zipPaths = [];
+ try {
+ // Classify the drop: zips, and .000 cells grouped with their updates.
+ const groups = new Map();
+ const zips = [];
+ for (const f of files) {
+ if (/\.zip$/i.test(f.name)) zips.push(f);
+ else if (/\.\d{3}$/.test(f.name)) {
+ const stem = f.name.replace(/\.\d{3}$/, "");
+ if (!groups.has(stem)) groups.set(stem, []);
+ groups.get(stem).push(f);
+ }
+ }
+
+ const work = []; // {stem, run: async (pool|null) => {archive, info}|null}
+ for (const [stem, cellFiles] of groups) {
+ if (!cellFiles.some((f) => /\.000$/.test(f.name))) {
+ failed.push(`${stem}: update files without the .000 base`);
+ continue;
+ }
+ if (this.store.has(stem)) { skipped++; continue; }
+ work.push({
+ stem,
+ run: async (pool) => {
+ const bytes = await Promise.all(cellFiles.map(async (f) => ({
+ name: f.name,
+ bytes: new Uint8Array(await f.arrayBuffer()),
+ })));
+ if (pool) return pool.bake(stem, bytes);
+ try {
+ for (const f of bytes)
+ await this.rpc("addFile", { path: `drops/${stem}/${f.name}`, bytes: f.bytes }, [f.bytes.buffer]);
+ return await this.rpc("bakeCell", { path: `/enc/drops/${stem}/${stem}.000` });
+ } finally {
+ this.rpc("remove", { path: `drops/${stem}` }).catch(() => {});
+ }
+ },
+ });
+ }
+
+ for (const zf of zips) {
+ const id = `z${this.dropSeq++}`;
+ const bytes = new Uint8Array(await zf.arrayBuffer());
+ await this.rpc("addFile", { path: `zips/${id}.zip`, bytes }, [bytes.buffer]);
+ zipPaths.push(`zips/${id}.zip`);
+ progress?.(0, 0, `reading ${zf.name}…`);
+ const entries = await this.rpc("zipList", { path: `/enc/zips/${id}.zip` });
+ const byDir = new Map();
+ for (const en of entries) {
+ const dir = en.name.replace(/[^/]*$/, "");
+ if (!byDir.has(dir)) byDir.set(dir, []);
+ byDir.get(dir).push(en.name);
+ }
+ for (const en of entries) {
+ if (!/\.000$/.test(en.name)) continue;
+ const stem = en.name.replace(/^.*\//, "").replace(/\.000$/, "");
+ if (this.store.has(stem)) { skipped++; continue; }
+ const dir = en.name.replace(/[^/]*$/, "");
+ // The cell and everything beside it (updates, referenced text).
+ const names = byDir.get(dir).filter((n) => !n.endsWith("/"));
+ const outPaths = names.map((n) => `/enc/drops/${id}/${stem}/${n.replace(/^.*\//, "")}`);
+ work.push({
+ stem,
+ run: async (pool) => {
+ try {
+ await this.rpc("zipExtract", { path: `/enc/zips/${id}.zip`, names, outPaths });
+ // The cell files (.000 + .NNN updates) go to the bake; the
+ // rest (TXTDSC text, PICREP pictures) go to the library so
+ // the pick report can show them later.
+ const cellFiles = [];
+ for (const p of outPaths) {
+ const name = p.replace(/^.*\//, "");
+ if (/\.\d{3}$/.test(name)) {
+ if (pool) cellFiles.push({ name, bytes: await this.rpc("readFile", { path: p }) });
+ } else {
+ await this.store.putAux(stem, name, await this.rpc("readFile", { path: p }));
+ }
+ }
+ if (!pool) return await this.rpc("bakeCell", { path: `/enc/drops/${id}/${stem}/${stem}.000` });
+ return pool.bake(stem, cellFiles);
+ } finally {
+ this.rpc("remove", { path: `/enc/drops/${id}/${stem}` }).catch(() => {});
+ }
+ },
+ });
+ }
+ }
+
+ // Bake, `lanes` cells at a time. Registrations land as bakes finish.
+ const lanes = Math.min(this.workers, work.length);
+ const pool = lanes > 1 ? new BakePool(this.workerUrl, this.wasmUrl, lanes) : null;
+ let done = 0, next = 0;
+ progress?.(0, work.length);
+ const lane = async () => {
+ while (next < work.length) {
+ const { stem, run } = work[next++];
+ try {
+ const res = await run(pool);
+ if (!res) { failed.push(`${stem}: produced no archive`); continue; }
+ const entry = await this.store.register(stem, res.archive, res.info);
+ if (entry) { added.push(entry); onChart?.(entry); }
+ } catch (e) {
+ console.error(e);
+ failed.push(`${stem}: ${e.message}`);
+ } finally {
+ done++;
+ progress?.(done, work.length, stem);
+ }
+ }
+ };
+ try {
+ await Promise.all(Array.from({ length: Math.max(1, lanes) }, lane));
+ } finally {
+ pool?.close();
+ }
+ } finally {
+ // The batch is over: the zips (and anything left under drops/) free.
+ for (const p of zipPaths) this.rpc("remove", { path: p }).catch(() => {});
+ this.rpc("remove", { path: "drops" }).catch(() => {});
+ await this.store.refreshUsage();
+ this.loading = false;
+ }
+ return { added, failed, skipped };
+ }
+}
diff --git a/bindings/js/demo/mariner.mjs b/bindings/js/demo/mariner.mjs
new file mode 100644
index 00000000..6a249327
--- /dev/null
+++ b/bindings/js/demo/mariner.mjs
@@ -0,0 +1,118 @@
+// The S-52 mariner settings model: the JS mirror of tile57_mariner, its
+// persistence, and the declarative rows the settings panel renders. The
+// engine's own canonical defaults (tile57_mariner_defaults, fetched from the
+// worker at boot) seed the model; localStorage carries the mariner's changes.
+//
+// Keys are the wrapper's (tile57.mjs allocMariner): the three display_*
+// booleans collapse into one cumulative `detailLevel` here (S-52 §10.2 - each
+// level implies the ones below it), and `soundings` is the tri-state override
+// every ECDIS gives its own switch.
+
+const KEY = "tile57.mariner";
+const M_TO_FT = 3.28084;
+
+// Recreational defaults over the engine's ship-scale canon. The S-52
+// defaults (safety contour 10 m, metres) suit SOLAS drafts; a sailboat draws
+// about 2 m, and a 10 m safety contour paints most of a harbor as unsafe.
+// Depths display in feet, the recreational convention on US charts (the
+// stored values stay metric under the hood). The mariner's own stored
+// settings still win over these.
+const RECREATIONAL = { shallowContour: 2, safetyContour: 3, deepContour: 10, safetyDepth: 3, depthUnit: "ft" };
+
+export function loadStored(defaults) {
+ let stored = {};
+ try {
+ stored = JSON.parse(localStorage.getItem(KEY)) || {};
+ } catch { /* first visit */ }
+ return { ...defaults, ...RECREATIONAL, ...stored };
+}
+export function saveStored(m) {
+ try {
+ localStorage.setItem(KEY, JSON.stringify(m));
+ } catch { /* private mode */ }
+}
+
+export const SCHEMES = ["day", "dusk", "night"];
+
+// The settings rows, grouped the way the spec groups them. Each item:
+// {key, type, label, desc?, options?, unit?, transform?} - the same shapes the
+// chartplotter settings dialog renders.
+export function settingsGroups(m) {
+ const ft = m.depthUnit === "ft";
+ const depth = (key, label) => ({
+ key, type: "number", label,
+ unit: ft ? "ft" : "m",
+ step: ft ? "1" : "0.1",
+ transform: {
+ toView: (v) => (ft ? Math.round(v * M_TO_FT) : v),
+ fromView: (v) => (ft ? v / M_TO_FT : v),
+ },
+ });
+ return [
+ {
+ group: "Detail level",
+ items: [{
+ key: "detailLevel", type: "segmented", label: "Detail level",
+ desc: "Display Base is always shown - Standard adds normal chart content, Other adds every remaining feature",
+ options: [["base", "Base"], ["standard", "Standard"], ["other", "Other"]],
+ }],
+ },
+ {
+ group: "Water & depths",
+ items: [
+ { key: "fourShadeWater", type: "toggle", label: "Four-shade water", desc: "Use four depth shades instead of two" },
+ {
+ key: "soundings", type: "segmented", label: "Spot soundings",
+ desc: "Individual depth soundings, independent of the detail level",
+ options: [["auto", "Auto"], ["on", "On"], ["off", "Off"]],
+ },
+ { key: "depthUnit", type: "segmented", label: "Depth unit", options: [["m", "Metres"], ["ft", "Feet"]] },
+ depth("shallowContour", "Shallow contour"),
+ depth("safetyContour", "Safety contour"),
+ depth("deepContour", "Deep contour"),
+ depth("safetyDepth", "Safety depth"),
+ ],
+ },
+ {
+ group: "Symbols & lines",
+ items: [
+ {
+ key: "boundaryStyle", type: "segmented", label: "Area boundaries", desc: "Line style for area edges",
+ options: [["plain", "Plain"], ["symbolized", "Symbolized"]],
+ },
+ {
+ key: "simplifiedPoints", type: "segmented", label: "Point symbols", desc: "Buoy & beacon symbol style",
+ options: [["paper", "Paper-chart"], ["simplified", "Simplified"]],
+ transform: { toView: (b) => (b ? "simplified" : "paper"), fromView: (s) => s === "simplified" },
+ },
+ { key: "showFullSectorLines", type: "toggle", label: "Full sector lines", desc: "Draw light sectors to full range, not short stubs" },
+ ],
+ },
+ {
+ group: "Text",
+ items: [
+ { key: "showLightDescriptions", type: "toggle", label: "Light descriptions", desc: "Light characteristics, e.g. Fl(2)R 10s" },
+ { key: "textNames", type: "toggle", label: "Names", desc: "Buoy, beacon & place names, berth numbers" },
+ { key: "textOther", type: "toggle", label: "Other text", desc: "Notes, seabed, magnetic variation, heights" },
+ ],
+ },
+ {
+ group: "Dangers & boundaries",
+ items: [
+ { key: "showIsolatedDangersShallow", type: "toggle", label: "Isolated dangers (shallow)", desc: "Also flag isolated dangers in shallow water" },
+ { key: "dataQuality", type: "toggle", label: "Data quality", desc: "Survey zones-of-confidence overlay" },
+ { key: "showInformCallouts", type: "toggle", label: "Information callouts", desc: "“Additional information available” markers on features that carry notes" },
+ { key: "showMetaBounds", type: "toggle", label: "Metadata boundaries", desc: "Chart coverage & region indicator lines" },
+ { key: "showOverscale", type: "toggle", label: "Overscale pattern", desc: "Hatch areas displayed beyond their chart's compilation scale" },
+ ],
+ },
+ {
+ group: "Dates",
+ items: [
+ { key: "dateDependent", type: "toggle", label: "Hide out-of-date features", desc: "Hide seasonal or expired features outside their validity dates" },
+ { key: "highlightDateDependent", type: "toggle", label: "Highlight date-dependent", desc: "Mark features that carry date conditions with the “d” symbol" },
+ { key: "dateView", type: "date", label: "Viewing date", desc: "Evaluate date-dependent features against this date (blank = today)" },
+ ],
+ },
+ ];
+}
diff --git a/bindings/js/demo/pick-model.mjs b/bindings/js/demo/pick-model.mjs
new file mode 100644
index 00000000..3fc57516
--- /dev/null
+++ b/bindings/js/demo/pick-model.mjs
@@ -0,0 +1,87 @@
+// The pick model - a JS port of lookout-marine's src/pick.zig: what a cursor
+// pick reports, and in what order. The engine returns the features under the
+// cursor in DRAW order, which puts the land area before the light that was
+// tapped, so:
+// 1. A meta object stays only when it carries something to read.
+// 2. A feature with no attributes never leads.
+// 3. The most SPECIFIC object wins: point, then line, then area - and what
+// the object IS decides within that (aids first, dangers, water, ground).
+// Hold every change against the Zig original.
+
+const INFORMATIONAL = ["INFORM", "NINFOM", "TXTDSC", "NTXTDS", "PICREP", "fileReference"];
+
+const LINES = new Set([
+ "DEPCNT", "COALNE", "SLCONS", "NAVLNE", "RECTRC", "CBLSUB", "PIPSOL",
+ "TSELNE", "RIVERS", "FERYRT", "DWRTCL", "LNDELV", "CANALS",
+]);
+const AREAS = new Set([
+ "DEPARE", "DRGARE", "SBDARE", "LNDARE", "BUAARE", "SEAARE", "ACHARE",
+ "RESARE", "FAIRWY", "CBLARE", "PIPARE", "MIPARE", "DWRTPT", "TSSLPT",
+ "UNSARE", "LNDRGN", "VEGATN", "HRBFAC", "BERTHS", "ADMARE", "CTNARE",
+ "OSPARE", "SPLARE", "MARCUL", "DMPGRD",
+]);
+// What you steer by, then what can hurt you, then the water, then the ground.
+const KINDS = [
+ ["LIGHTS", "LITVES", "LITFLT"],
+ ["BOYLAT", "BOYCAR", "BOYSAW", "BOYISD", "BOYSPP", "BOYINB", "BCNLAT", "BCNCAR", "BCNSAW", "BCNISD", "BCNSPP", "DAYMAR", "TOPMAR"],
+ ["WRECKS", "OBSTRN", "UWTROC", "ROCKS", "MORFAC", "PILPNT"],
+ ["SOUNDG", "DEPCNT", "DEPARE", "DRGARE", "SBDARE"],
+ ["ACHARE", "RESARE", "TSSLPT", "TSELNE", "FAIRWY", "NAVLNE", "RECTRC", "CBLARE", "PIPARE", "CBLSUB", "PIPSOL", "DWRTPT", "MIPARE"],
+ ["COALNE", "SLCONS", "PONTON", "HRBFAC", "BERTHS", "LNDMRK", "BUISGL"],
+ ["LNDARE", "BUAARE", "SEAARE", "LNDRGN", "VEGATN"],
+].map((g) => new Set(g));
+
+const attrsOf = (f) => (typeof f.s57 === "object" && f.s57 !== null ? f.s57 : {});
+
+function carriesInformation(f) {
+ const a = attrsOf(f);
+ return INFORMATIONAL.some((k) => a[k] !== undefined && a[k] !== "");
+}
+const isEmpty = (f) => Object.keys(attrsOf(f)).length === 0;
+const isMeta = (f) => f.cls.startsWith("M_") || f.cls.startsWith("C_");
+
+/** True when the pick should report the feature at all. */
+export function keep(f) {
+ // A sounding's depth is the figure on the chart; the rest is provenance.
+ if (f.cls === "SOUNDG") return carriesInformation(f);
+ if (!isMeta(f)) return true;
+ return carriesInformation(f);
+}
+
+/** True when two picked features read as the same object - one feature draws
+ * several times (fill, boundary, symbol) and every drawing answers. */
+export function same(a, b) {
+ return a.cls === b.cls && a.chart === b.chart
+ && JSON.stringify(a.s57) === JSON.stringify(b.s57);
+}
+
+function primitive(cls) {
+ if (LINES.has(cls)) return 1;
+ if (AREAS.has(cls) || cls.endsWith("ARE")) return 2;
+ return 0;
+}
+function kind(cls) {
+ for (let i = 0; i < KINDS.length; i++) if (KINDS[i].has(cls)) return i;
+ return 8;
+}
+function rank(f) {
+ if (isEmpty(f)) return 10000; // nothing to read: never the answer
+ if (isMeta(f)) return 900; // a note, but not what was aimed at
+ return primitive(f.cls) * 100 + kind(f.cls);
+}
+
+/** Filter, dedup, and order a raw pick for presentation. */
+export function rankPick(features) {
+ const seen = [];
+ const kept = [];
+ for (const f of features) {
+ if (!keep(f)) continue;
+ if (seen.some((s) => same(s, f))) continue;
+ seen.push(f);
+ kept.push(f);
+ }
+ return kept
+ .map((f, i) => ({ f, i, r: rank(f) }))
+ .sort((a, b) => a.r - b.r || a.i - b.i) // stable between equals
+ .map((x) => x.f);
+}
diff --git a/bindings/js/demo/pick-report.mjs b/bindings/js/demo/pick-report.mjs
new file mode 100644
index 00000000..7e921f42
--- /dev/null
+++ b/bindings/js/demo/pick-report.mjs
@@ -0,0 +1,271 @@
+// The cursor pick report, presented lookout-marine's way (PickReport.swift):
+// two columns. The pick's objects stay in sight on the left as a column, the
+// main data in each row, the object on show held selected; there is no pager
+// to walk blind. The right column is the decoded report: the operative fact
+// as the title, the attributes in chart language, the provenance as one
+// muted line at the floor, and the raw S-57 rows one fold away. The chart's
+// notes (M_* objects) pin at the list column's floor.
+//
+// The ENGINE composes each report (tile57_s57_report via the worker's pick
+// op); this module only ranks the set (pick-model.mjs) and renders it.
+
+import { rankPick } from "./pick-model.mjs";
+
+const esc = (s) => String(s).replace(/&/g, "&").replace(/ `;
+const COPY_ICON = ` `;
+const DOC_ICON = ` `;
+
+const isNote = (f) => f.cls.startsWith("M_") || f.cls.startsWith("C_");
+
+export class PickReport {
+ constructor(root, { getAux } = {}) {
+ this.el = root.querySelector("#pick");
+ this.getAux = getAux;
+ this.features = [];
+ this.sel = 0;
+ this.fold = false;
+ this.el.addEventListener("click", (e) => {
+ const row = e.target.closest("[data-pick]");
+ if (row) {
+ this.sel = +row.dataset.pick;
+ this.fold = false;
+ this.render();
+ return;
+ }
+ const aux = e.target.closest("[data-aux]");
+ if (aux) this.toggleAux(aux);
+ else if (e.target.closest("#pick-close")) this.hide();
+ else if (e.target.closest("#pick-copy")) this.copy();
+ else if (e.target.closest("#pick-fold")) {
+ this.fold = !this.fold;
+ this.render();
+ }
+ });
+ }
+
+ // The text and pictures a feature points at (TXTDSC, PICREP), stored with
+ // the chart at import and opened inline under their row.
+ async toggleAux(btn) {
+ const row = btn.closest(".pd-row");
+ const open = row.nextElementSibling?.classList.contains("pd-aux") ? row.nextElementSibling : null;
+ if (open) { open.remove(); return; }
+ const f = this.features[this.sel];
+ const name = btn.dataset.aux;
+ const box = document.createElement("div");
+ box.className = "pd-aux";
+ const bytes = this.getAux ? await Promise.resolve(this.getAux(f.chart, name)).catch(() => null) : null;
+ if (!bytes) {
+ box.innerHTML = `${esc(name)} is not stored with this chart. `;
+ } else if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(name)) {
+ const img = document.createElement("img");
+ img.src = URL.createObjectURL(new Blob([bytes]));
+ img.alt = name;
+ box.append(img);
+ } else if (/\.tiff?$/i.test(name)) {
+ box.innerHTML = `${esc(name)}: TIFF pictures cannot display in a browser. `;
+ } else {
+ const pre = document.createElement("pre");
+ pre.textContent = new TextDecoder().decode(bytes);
+ box.append(pre);
+ }
+ row.after(box);
+ }
+
+ /** Show a raw pick result (the worker's pick op output). */
+ show(features) {
+ this.features = rankPick(features);
+ this.sel = 0;
+ this.fold = false;
+ if (!this.features.length) {
+ this.hide();
+ return false;
+ }
+ this.el.hidden = false;
+ this.render();
+ return true;
+ }
+
+ hide() {
+ this.el.hidden = true;
+ this.features = [];
+ }
+ get open() {
+ return !this.el.hidden;
+ }
+
+ copy() {
+ const f = this.features[this.sel];
+ if (f) navigator.clipboard?.writeText(JSON.stringify({ cls: f.cls, chart: f.chart, s57: f.s57 }, null, 2)).catch(() => {});
+ }
+
+ listRow(f, i) {
+ const r = f.report || {};
+ const sel = i === this.sel ? " sel" : "";
+ if (isNote(f)) {
+ return `
+ ${BOOK_ICON} ${esc(r.chip || f.cls)} `;
+ }
+ return `
+ ${esc(r.title || f.cls)}
+ ${r.subtitle ? `${esc(r.subtitle)} ` : ""} `;
+ }
+
+ // The raw S-57 rows, as the cell states them (one level flattened).
+ rawRows(f) {
+ const a = typeof f.s57 === "object" && f.s57 !== null ? f.s57 : {};
+ return Object.entries(a).map(([k, v]) =>
+ `${esc(k)}: ${esc(
+ typeof v === "object" ? JSON.stringify(v) : v)} `).join("");
+ }
+
+ render() {
+ const many = this.features.length > 1;
+ this.el.classList.toggle("solo", !many);
+ const main = this.features.map((f, i) => (isNote(f) ? "" : this.listRow(f, i))).join("");
+ const notes = this.features.map((f, i) => (isNote(f) ? this.listRow(f, i) : "")).join("");
+ this.el.querySelector("#pick-list").innerHTML = `
+ ${this.features.length} OBJECT${this.features.length > 1 ? "S" : ""}
+ ${main}
+ ${notes ? `${notes} ` : ""}`;
+
+ const f = this.features[this.sel];
+ const r = f.report || {};
+ const rows = (r.rows || []).map((row) =>
+ `
+ ${esc(row.label)}
+ ${row.file
+ ? `${DOC_ICON}${esc(row.value)} `
+ : `${esc(row.value)} `}
+ `).join("");
+ const noteBlocks = (r.notes || []).map((n) => `${esc(n)} `).join("");
+ const empty = r.empty
+ ? `${r.empty === "none"
+ ? "The cell carries no attributes for this object."
+ : "The cell carries only source data for this object."} `
+ : "";
+ const rawCount = typeof f.s57 === "object" && f.s57 !== null ? Object.keys(f.s57).length : 0;
+ this.el.querySelector("#pick-detail").innerHTML = `
+
+
+ ${esc(r.title || f.cls)}
+ ${r.subtitle ? ` ${esc(r.subtitle)} ` : ""}
+
+ ${esc(r.chip || f.cls)}
+ ${COPY_ICON}
+ ✕
+
+
+
+
+
+ ›
+ S-57 source attributes (${rawCount})
+
+ `;
+ }
+}
+
+export const PICK_STYLE = `
+ /* Pick report: lookout-marine's two-column card. The list keeps the whole
+ pick in sight; the detail holds the object on show. */
+ #pick { position:absolute; left:calc(12px + env(safe-area-inset-left,0px));
+ top:calc(12px + env(safe-area-inset-top,0px)); z-index:8;
+ display:flex; align-items:stretch;
+ width:min(560px, calc(100vw - 24px));
+ max-height:calc(100dvh - 120px);
+ background:var(--ui-bg); color:var(--ui-text); border:1px solid var(--ui-border);
+ border-radius:14px; box-shadow:0 12px 38px var(--ui-shadow); overflow:hidden; }
+ #pick[hidden] { display:none; }
+ #pick.solo { width:min(400px, calc(100vw - 24px)); }
+ #pick.solo #pick-list { display:none; }
+
+ #pick-list { flex:0 0 180px; min-width:0; display:flex; flex-direction:column;
+ border-right:1px solid var(--ui-border-2); overflow-y:auto; overscroll-behavior:contain; }
+ .pl-head { flex:none; font:600 10.5px/1 system-ui,sans-serif; letter-spacing:.08em;
+ color:var(--ui-text-faint); padding:15px 14px 8px; }
+ .pl-rows { flex:1 1 auto; }
+ .pl-row { display:flex; flex-direction:column; align-items:flex-start; gap:2px; width:calc(100% - 12px);
+ margin:1px 6px; padding:8px 9px; border:none; border-radius:7px; background:none;
+ font:inherit; text-align:left; cursor:pointer; }
+ @media (hover:hover) { .pl-row:hover { background:var(--ui-hover); } }
+ .pl-row.sel { background:color-mix(in srgb, var(--ui-accent) 12%, transparent); }
+ .pl-row .pl-title { font-weight:600; font-size:12.5px; color:var(--ui-text);
+ max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .pl-row.sel .pl-title { color:var(--ui-accent); }
+ .pl-row .pl-sub { font-size:11px; color:var(--ui-text-dim);
+ max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ /* The chart's notes, pinned at the column's floor under a hairline. */
+ .pl-notes { flex:none; border-top:1px solid var(--ui-border-2); padding:5px 0 6px; }
+ .pl-note { flex-direction:row; align-items:center; gap:7px; }
+ .pl-note .pl-glyph { flex:none; display:inline-flex; color:var(--ui-text-dim); }
+ .pl-note .pl-title { font-weight:500; color:var(--ui-text-dim); }
+ .pl-note.sel .pl-glyph, .pl-note.sel .pl-title { color:var(--ui-accent); }
+
+ #pick-detail { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; }
+ .pd-head { flex:none; display:flex; align-items:flex-start; gap:8px;
+ padding:12px 12px 10px 16px; border-bottom:1px solid var(--ui-border-2); }
+ .pd-head-main { flex:1 1 auto; min-width:0; }
+ .pd-title { font:700 15px/1.3 system-ui,sans-serif; }
+ .pd-sub { color:var(--ui-text-dim); font-size:12px; margin-top:1px; }
+ .pd-chip { flex:none; margin-top:1px; font:600 10px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace;
+ color:var(--ui-text-dim); border:1px solid var(--ui-border-strong); border-radius:999px; padding:0 8px; }
+ #pick-copy, #pick-close { flex:none; cursor:pointer; border:none; background:none;
+ color:var(--ui-text-dim); font:600 13px system-ui,sans-serif; padding:3px 5px; border-radius:6px; }
+ @media (hover:hover) { #pick-copy:hover, #pick-close:hover { background:var(--ui-hover); color:var(--ui-text); } }
+
+ .pd-scroll { flex:1 1 auto; min-height:0; overflow-y:auto; overscroll-behavior:contain;
+ padding:8px 16px 10px; }
+ .pd-note { color:var(--ui-text); font-size:12.5px; line-height:1.5; margin:8px 0 2px;
+ padding:8px 10px; background:var(--ui-surface-2); border-radius:8px; }
+ .pd-empty { color:var(--ui-text-dim); font-size:12.5px; padding:12px 0; }
+ .pd-row { display:flex; align-items:baseline; gap:12px; padding:6px 0; font-size:12.5px; }
+ .pd-row .pd-l { flex:0 0 108px; color:var(--ui-text-dim); }
+ .pd-row .pd-v { flex:1 1 auto; min-width:0; font-weight:600; font-variant-numeric:tabular-nums;
+ overflow-wrap:anywhere; }
+ .pd-file { display:inline-flex; align-items:center; gap:6px; border:none; background:none;
+ padding:0; font:inherit; font-weight:600; color:var(--ui-accent); cursor:pointer;
+ text-decoration:underline; text-decoration-color:color-mix(in srgb, var(--ui-accent) 40%, transparent);
+ text-underline-offset:3px; }
+ @media (hover:hover) { .pd-file:hover { color:var(--ui-accent-hover); } }
+ .pd-aux { margin:2px 0 8px; }
+ .pd-aux pre { margin:0; padding:10px 12px; background:var(--ui-surface-2); border-radius:8px;
+ font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--ui-text);
+ white-space:pre-wrap; overflow-wrap:anywhere; max-height:260px; overflow-y:auto;
+ overscroll-behavior:contain; }
+ .pd-aux img { max-width:100%; border-radius:8px; border:1px solid var(--ui-border-2); }
+ .pd-raw { margin-top:8px; padding-top:6px; border-top:1px solid var(--ui-border-2); }
+ .pd-raw-row { display:flex; align-items:baseline; gap:10px; padding:3px 0;
+ font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; }
+ .pd-raw-k { flex:0 0 92px; color:var(--ui-text-dim); overflow-wrap:anywhere; }
+ .pd-raw-v { flex:1 1 auto; min-width:0; color:var(--ui-text); overflow-wrap:anywhere; }
+
+ /* The floor: provenance as one muted line, then the fold's control. Both
+ keep their place; what the fold opens scrolls above. */
+ .pd-floor { flex:none; border-top:1px solid var(--ui-border-2); }
+ .pd-foot { color:var(--ui-text-faint); font:11.5px/1.4 system-ui,sans-serif;
+ font-variant-numeric:tabular-nums; padding:9px 16px 0; }
+ .pd-fold { display:flex; align-items:center; gap:6px; width:100%; border:none; background:none;
+ color:var(--ui-text-dim); font:12px system-ui,sans-serif; padding:8px 16px 11px;
+ cursor:pointer; text-align:left; }
+ @media (hover:hover) { .pd-fold:hover { color:var(--ui-text); } }
+ .pd-chev { display:inline-block; font-weight:700; transition:transform .12s; }
+ .pd-chev.open { transform:rotate(90deg); }
+
+ @media (max-width:560px) {
+ #pick { flex-direction:column; width:min(400px, calc(100vw - 24px)); }
+ #pick-list { flex:0 0 auto; max-height:160px; border-right:none; border-bottom:1px solid var(--ui-border-2); }
+ }
+`;
+
+export const PICK_CHROME = `
+
+`;
diff --git a/bindings/js/demo/settings-panel.mjs b/bindings/js/demo/settings-panel.mjs
new file mode 100644
index 00000000..60c7cf48
--- /dev/null
+++ b/bindings/js/demo/settings-panel.mjs
@@ -0,0 +1,62 @@
+// The settings panel: renders the mariner model's groups into the drawer and
+// routes every control change back through one callback. Control markup and
+// behaviour follow the chartplotter settings dialog (settings-dialog.view.mjs):
+// toggle switch, segmented buttons, number + unit, date.
+
+import { settingsGroups } from "./mariner.mjs";
+
+const esc = (s) => String(s).replace(/&/g, "&").replace(/
+ /^\d{8}$/.test(String(v || "")) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : "";
+
+function control(item, value) {
+ const k = `data-key="${esc(item.key)}"`;
+ const view = item.transform ? item.transform.toView(value) : value;
+ switch (item.type) {
+ case "toggle":
+ return ` `;
+ case "segmented":
+ return `${(item.options || []).map(([v, lbl]) =>
+ `${esc(lbl)} `).join("")} `;
+ case "number":
+ return ` ${item.unit ? `${esc(item.unit)} ` : ""}`;
+ case "date":
+ return ` `;
+ default:
+ return "";
+ }
+}
+
+function row(item, value) {
+ const desc = item.desc ? `${esc(item.desc)} ` : "";
+ return `${esc(item.label)}
+ ${control(item, value)} ${desc} `;
+}
+
+/** Render the whole panel for the current settings `m` into `body`, and wire
+ * every control to `onChange(key, value)`. Re-rendered wholesale after each
+ * change (the groups are unit-aware, so rows can change with the value). */
+export function renderSettings(body, m, onChange) {
+ const items = new Map();
+ body.innerHTML = settingsGroups(m).map((g) => {
+ for (const it of g.items) items.set(it.key, it);
+ return `${esc(g.group)} ` + g.items.map((it) => row(it, m[it.key])).join("");
+ }).join("");
+
+ body.querySelectorAll("[data-key]").forEach((el) => {
+ const item = items.get(el.dataset.key);
+ const commit = (view) => {
+ const value = item.transform ? item.transform.fromView(view) : view;
+ onChange(item.key, value);
+ };
+ if (el.dataset.type === "toggle") el.addEventListener("change", () => commit(el.checked));
+ else if (el.dataset.type === "segmented") el.addEventListener("click", () => commit(el.dataset.val));
+ else if (el.dataset.type === "number") el.addEventListener("change", () => {
+ const n = parseFloat(el.value);
+ if (Number.isFinite(n)) commit(n);
+ });
+ else if (el.dataset.type === "date") el.addEventListener("change", () =>
+ onChange(item.key, el.value ? el.value.replaceAll("-", "") : ""));
+ });
+}
diff --git a/bindings/js/demo/view.mjs b/bindings/js/demo/view.mjs
new file mode 100644
index 00000000..390ec87a
--- /dev/null
+++ b/bindings/js/demo/view.mjs
@@ -0,0 +1,270 @@
+// Demo VIEW - the render chrome: the whole ${CHROME}` into the page and wires the ids.
+//
+// The look follows the chartplotter shell (chartplotter.view.mjs): the map IS
+// the UI, chrome floats over it as round buttons and one bottom-centre data
+// card, panels are caret popovers, and the --ui-* tokens re-skin everything
+// for day / dusk / night via data-scheme on the root.
+
+export const STYLE = `
+ #root { position:fixed; inset:0; overflow:hidden; font:13px/1.4 system-ui,sans-serif;
+ --tap-min:44px;
+ --ui-bg:#fafafa; --ui-surface:#fff; --ui-surface-2:#eef1f4; --ui-text:#2a2f35;
+ --ui-text-dim:#7a828b; --ui-text-faint:#9aa0a8; --ui-border:#e2e2e2; --ui-border-2:#ededed;
+ --ui-border-strong:#cfcfcf; --ui-hover:#f0f3f6; --ui-accent:#1565c0; --ui-accent-hover:#1257a8;
+ --ui-accent-text:#fff; --ui-shadow:rgba(0,0,0,.2); }
+ #root[data-scheme="dusk"] {
+ --ui-bg:#20262b; --ui-surface:#2a3137; --ui-surface-2:#333b42; --ui-text:#cdd6dc;
+ --ui-text-dim:#9aa6ae; --ui-text-faint:#7d8990; --ui-border:#3a434a; --ui-border-2:#333b42;
+ --ui-border-strong:#4a555d; --ui-hover:#353f47; --ui-accent:#4f9be6; --ui-accent-hover:#69abe9;
+ --ui-accent-text:#0c1318; --ui-shadow:rgba(0,0,0,.5); }
+ #root[data-scheme="night"] {
+ --ui-bg:#14181b; --ui-surface:#1b2024; --ui-surface-2:#232a2f; --ui-text:#aeb8be;
+ --ui-text-dim:#7e898f; --ui-text-faint:#626c72; --ui-border:#2a3137; --ui-border-2:#232a2f;
+ --ui-border-strong:#38424a; --ui-hover:#232a30; --ui-accent:#3f7fb5; --ui-accent-hover:#4d8cc2;
+ --ui-accent-text:#0a0e11; --ui-shadow:rgba(0,0,0,.6); }
+
+ /* Full-bleed map; everything else floats over it. */
+ /* Crosshair, the pick cursor - the hand only while actually grabbing. */
+ #map, #mapimg { position:absolute; inset:0; width:100%; height:100%;
+ touch-action:none; cursor:crosshair; user-select:none; }
+ #mapimg { display:none; }
+ #root.dragging #map, #root.dragging #mapimg { cursor:grabbing; }
+
+ /* Round floating buttons (44px, translucent surface, blur). */
+ .rbtn { flex:none; width:44px; height:44px; border-radius:50%; cursor:pointer; padding:0;
+ display:flex; align-items:center; justify-content:center; color:var(--ui-text);
+ background:color-mix(in srgb, var(--ui-surface) 90%, transparent); border:1px solid var(--ui-border);
+ box-shadow:0 2px 10px rgba(0,0,0,.18); backdrop-filter:blur(6px);
+ font:600 17px/1 system-ui,sans-serif;
+ touch-action:manipulation; -webkit-user-select:none; user-select:none;
+ transition:background .12s, color .12s, box-shadow .12s, transform .08s; }
+ @media (hover:hover) { .rbtn:hover { color:var(--ui-accent); border-color:var(--ui-accent); box-shadow:0 3px 14px rgba(0,0,0,.24); } }
+ .rbtn:active { transform:scale(.94); }
+ .rbtn.on { background:var(--ui-accent); color:var(--ui-accent-text); border-color:var(--ui-accent); }
+ .rbtn svg { width:21px; height:21px; display:block; }
+
+ /* Compass, top-right: the needle tracks the view rotation. */
+ #tr-controls { position:absolute; top:calc(12px + env(safe-area-inset-top,0px));
+ right:calc(12px + env(safe-area-inset-right,0px)); z-index:7; display:flex; gap:8px; }
+ #needle { display:block; transition:transform .12s ease; }
+ /* Right-edge vertical stack: zoom + fullscreen, above the corner cluster. */
+ #mr-controls { position:absolute; right:calc(12px + env(safe-area-inset-right,0px));
+ bottom:calc(env(safe-area-inset-bottom,0px) + 130px); z-index:7;
+ display:flex; flex-direction:column; gap:8px; }
+ /* Bottom-right cluster: scheme · settings · clear-library. */
+ #br-controls { position:absolute; right:calc(12px + env(safe-area-inset-right,0px));
+ bottom:calc(env(safe-area-inset-bottom,0px) + 12px); z-index:7;
+ display:flex; align-items:center; gap:8px; }
+
+ /* Bottom-centre DATA CARD: the live readout (scale · zoom · position ·
+ heading), job progress while a batch bakes, and warnings. One surface. */
+ #databox { position:absolute; left:50%; bottom:calc(env(safe-area-inset-bottom,0px) + 14px);
+ transform:translateX(-50%); z-index:6; box-sizing:border-box;
+ display:flex; flex-direction:column; align-items:center; gap:6px; padding:8px 14px;
+ width:min(94vw, 460px);
+ background:color-mix(in srgb, var(--ui-surface) 92%, transparent); border:1px solid var(--ui-border);
+ border-radius:13px; backdrop-filter:blur(7px); overflow:hidden;
+ box-shadow:0 4px 18px rgba(0,0,0,.18);
+ font:11px system-ui,sans-serif; color:var(--ui-text); }
+ .db-readout { display:flex; align-items:center; justify-content:center; flex-wrap:wrap;
+ gap:6px; row-gap:5px; width:100%;
+ font-weight:600; font-size:12px; white-space:nowrap; font-variant-numeric:tabular-nums; }
+ .db-readout .hud-scale { color:var(--ui-accent); }
+ .db-readout .hud-z, .db-readout .hud-coord, .db-readout .hud-hdg { color:var(--ui-text-dim); }
+ .db-readout .hud-sep { color:var(--ui-text-faint); }
+ .db-sub { width:100%; text-align:center; color:var(--ui-text-dim); font-size:11px;
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .db-sub:empty { display:none; }
+ .db-sub.bad { color:#c0392b; font-weight:600; }
+ /* Job progress: title, action + count, track. Grows above the readout. */
+ .db-prog { width:100%; box-sizing:border-box; display:flex; flex-direction:column; gap:7px;
+ padding-bottom:9px; margin-bottom:2px; border-bottom:1px solid var(--ui-border); }
+ .db-prog[hidden] { display:none; }
+ .db-prog-title { font:600 12.5px/1.25 system-ui,sans-serif; color:var(--ui-text);
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .db-prog-status { display:flex; align-items:baseline; gap:10px;
+ font:500 11.5px/1.3 system-ui,sans-serif; color:var(--ui-text-dim); }
+ .db-prog-action { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .db-prog-count { flex:none; text-align:right; font-variant-numeric:tabular-nums; }
+ .db-prog-track { position:relative; width:100%; height:6px; border-radius:3px; overflow:hidden; background:var(--ui-surface-2); }
+ .db-prog-fill { position:absolute; left:0; top:0; bottom:0; width:0; border-radius:3px;
+ background:var(--ui-accent); transition:width .3s ease; }
+ .db-prog-fill.indet { width:30% !important; animation:db-sweep 1.9s ease-in-out infinite; }
+ @keyframes db-sweep { 0% { left:-30%; } 100% { left:100%; } }
+ @media (prefers-reduced-motion: reduce) { .db-prog-fill.indet { animation:none; left:0; width:100% !important; } }
+
+ /* Toasts: bottom-centre stack above the data card (errors and notices). */
+ #toasts { position:absolute; left:50%; bottom:calc(env(safe-area-inset-bottom,0px) + 96px);
+ transform:translateX(-50%); z-index:9; display:flex; flex-direction:column; gap:8px;
+ align-items:center; pointer-events:none; }
+ .toast { pointer-events:auto; max-width:80vw; padding:9px 14px; border-radius:8px;
+ font:600 12.5px/1.3 system-ui,sans-serif; color:var(--ui-text); background:var(--ui-surface);
+ border:1px solid var(--ui-border-2); box-shadow:0 4px 16px rgba(0,0,0,.28);
+ transition:opacity .3s ease, transform .3s ease; }
+ .toast.error { border-color:#c0392b; color:#e06b5c; }
+ .toast.out { opacity:0; transform:translateY(6px); }
+
+ /* Attribution: one subtle line, bottom-left, with a soft halo over the chart. */
+ #attr { position:absolute; left:calc(12px + env(safe-area-inset-left,0px));
+ bottom:calc(env(safe-area-inset-bottom,0px) + 12px); z-index:5;
+ font:500 10px/1.35 system-ui,sans-serif; letter-spacing:.01em; white-space:nowrap;
+ color:var(--ui-text-dim);
+ text-shadow:0 0 3px var(--ui-surface), 0 0 3px var(--ui-surface), 0 1px 1px var(--ui-surface); }
+ #attr a { color:inherit; text-decoration:underline; text-decoration-color:var(--ui-text-faint); text-underline-offset:2px; }
+ @media (hover:hover) { #attr a:hover { color:var(--ui-accent); } }
+
+ /* Welcome card: the empty state (no charts yet). */
+ #welcome { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
+ z-index:4; pointer-events:none; }
+ #welcome[hidden] { display:none; }
+ #welcome .card { pointer-events:auto; background:var(--ui-surface); color:var(--ui-text);
+ border-radius:16px; padding:30px 30px 24px; max-width:380px; text-align:center;
+ box-shadow:0 8px 34px rgba(0,0,0,.22); }
+ #welcome svg { width:44px; height:44px; margin-bottom:10px; color:var(--ui-accent); }
+ #welcome h2 { margin:0 0 8px; font-size:21px; }
+ #welcome p { color:var(--ui-text-dim); margin:0 0 14px; line-height:1.5; }
+ #welcome .cta { display:inline-flex; align-items:center; gap:8px; background:var(--ui-accent);
+ color:var(--ui-accent-text); border:none; border-radius:8px; padding:11px 22px;
+ font:600 15px system-ui,sans-serif; cursor:pointer; text-decoration:none; }
+ @media (hover:hover) { #welcome .cta:hover { background:var(--ui-accent-hover); } }
+ #welcome .sub { margin-top:12px; font-size:12.5px; color:var(--ui-text-faint); line-height:1.5; }
+ #root.droptarget #welcome .card { outline:2px dashed var(--ui-accent); outline-offset:6px; }
+
+ /* Settings popover: pops UP from the bottom-right cluster with a caret
+ pointing down at the settings button. */
+ #drawer { --caret:9px; position:absolute; right:calc(12px + env(safe-area-inset-right,0px));
+ bottom:calc(env(safe-area-inset-bottom,0px) + 66px);
+ width:min(420px, calc(100vw - 24px)); max-height:calc(100dvh - 120px); z-index:9;
+ background:var(--ui-bg); color:var(--ui-text); border:1px solid var(--ui-border); border-radius:14px;
+ box-shadow:0 12px 38px rgba(0,0,0,.30); display:flex; flex-direction:column;
+ transform-origin:bottom right; transform:translateY(6px) scale(.97); opacity:0; visibility:hidden;
+ transition:opacity .15s ease, transform .15s ease, visibility 0s linear .15s; }
+ #drawer.open { opacity:1; transform:none; visibility:visible; transition:opacity .15s ease, transform .15s ease; }
+ #drawer::after { content:""; position:absolute; bottom:calc(-1 * var(--caret)); left:var(--caret-left,85%);
+ transform:translateX(-50%); width:0; height:0;
+ border-left:var(--caret) solid transparent; border-right:var(--caret) solid transparent;
+ border-top:var(--caret) solid var(--ui-bg); filter:drop-shadow(0 2px 1px rgba(0,0,0,.08)); }
+ .dhead { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--ui-border); }
+ .dhead strong { flex:1; font-size:14px; }
+ .dhead .close { cursor:pointer; border:1px solid var(--ui-border-strong); background:var(--ui-surface);
+ border-radius:6px; padding:5px 10px; font:inherit; color:var(--ui-text); }
+ #drawer .body { overflow-y:auto; overscroll-behavior:contain; -webkit-overflow-scrolling:touch;
+ padding:0 16px 16px; flex:1; border-radius:0 0 13px 13px; }
+
+ /* Settings rows + controls (from settings-dialog.view.mjs). */
+ .set-group { position:sticky; top:0; z-index:2; margin:0 -16px; padding:12px 16px 6px;
+ font-size:11px; font-weight:700; letter-spacing:.06em; text-transform:uppercase;
+ color:var(--ui-text-dim); background:var(--ui-bg); border-bottom:1px solid var(--ui-border-2); }
+ .set-row { display:flex; flex-direction:column; padding:11px 0; border-bottom:1px solid var(--ui-border-2); }
+ .set-row:last-child { border-bottom:none; }
+ .set-row .set-head { display:flex; align-items:center; gap:16px; }
+ .set-row .t { font-weight:600; font-size:13.5px; flex:1 1 auto; min-width:0; }
+ .set-row .d { font-size:12px; color:var(--ui-text-faint); margin-top:4px; line-height:1.5; max-width:56ch; }
+ .set-row .ctl { flex:none; margin-left:auto; display:flex; align-items:center; gap:6px; }
+ .set-row .ctl input[type=number] { width:64px; text-align:right; border:1px solid var(--ui-border-strong);
+ border-radius:7px; padding:6px 8px; font:inherit; font-size:14px; background:var(--ui-surface); color:var(--ui-text); }
+ .set-row .ctl input[type=date] { border:1px solid var(--ui-border-strong); border-radius:7px;
+ padding:5px 8px; font:inherit; font-size:13px; background:var(--ui-surface); color:var(--ui-text); }
+ .set-row .ctl .unit { color:var(--ui-text-faint); font-size:12px; min-width:14px; }
+ .switch { position:relative; width:38px; height:22px; display:inline-block; flex:none; }
+ .switch input { opacity:0; width:0; height:0; }
+ .switch .sl { position:absolute; inset:0; background:var(--ui-border-strong); border-radius:22px;
+ cursor:pointer; transition:.15s; }
+ .switch .sl:before { content:""; position:absolute; width:16px; height:16px; left:3px; top:3px;
+ background:#fff; border-radius:50%; transition:.15s; box-shadow:0 1px 2px rgba(0,0,0,.3); }
+ .switch input:checked + .sl { background:var(--ui-accent); }
+ .switch input:checked + .sl:before { transform:translateX(16px); }
+ .seg { display:inline-flex; border:1px solid var(--ui-border-strong); border-radius:8px; overflow:hidden; }
+ .seg button { border:none; background:var(--ui-surface); padding:6px 12px; font:inherit; font-size:13px;
+ cursor:pointer; border-left:1px solid var(--ui-border-2); color:var(--ui-text); }
+ .seg button:first-child { border-left:none; }
+ .seg button.sel { background:var(--ui-accent); color:var(--ui-accent-text); }
+
+ /* Splash: painted before the engine downloads; faded out when it is up. */
+ #splash { position:absolute; inset:0; z-index:9999; display:flex; flex-direction:column;
+ align-items:center; justify-content:center; gap:14px;
+ background:var(--ui-bg); color:var(--ui-text); font:15px/1.4 system-ui,sans-serif;
+ transition:opacity .4s ease; }
+ #splash.hide { opacity:0; pointer-events:none; }
+ #splash .spinner { width:40px; height:40px; border-radius:50%;
+ border:4px solid var(--ui-border-strong); border-top-color:var(--ui-accent);
+ animation:spin .9s linear infinite; }
+ #splash .note { color:var(--ui-text-dim); font-size:13px; }
+ @keyframes spin { to { transform:rotate(360deg); } }
+ @media (prefers-reduced-motion: reduce) { #splash .spinner { animation:none; } }
+`;
+
+const COMPASS_ICON = ` `;
+const SETTINGS_ICON = ` `;
+const SCHEME_ICON = ` `;
+const TRASH_ICON = ` `;
+const EXPAND_ICON = ` `;
+const ANCHOR_ICON = ` `;
+
+export const NOAA_ENC_URL = "https://charts.noaa.gov/ENCs/ENCs.shtml";
+
+export const CHROME = `
+
+
+
+
+ ${COMPASS_ICON}
+
+
+ +
+ −
+ ${EXPAND_ICON}
+
+
+ ${SCHEME_ICON}
+ ${SETTINGS_ICON}
+ ${TRASH_ICON}
+
+
+
+
+
+
+
+
+
+ ${ANCHOR_ICON}
+ Welcome aboard
+ Drop official NOAA charts anywhere on this page - an exchange-set
+ .zip , or .000 cells with their update files. They are baked
+ and rendered right here in your browser; nothing is uploaded.
+ ⚓ Get free NOAA charts
+ Charts you drop are stored in this browser and load
+ instantly next time.
+
+
+
+
+
+
+ Loading the chart engine…
+
+`;
diff --git a/bindings/js/engine-smoke.mjs b/bindings/js/engine-smoke.mjs
new file mode 100644
index 00000000..249417a9
--- /dev/null
+++ b/bindings/js/engine-smoke.mjs
@@ -0,0 +1,141 @@
+// Smoke test for the full-engine wasm reactor (zig build wasm-engine).
+//
+// Runs the real chartplotter pipeline inside node's WASI host: bake S-57
+// cells to per-chart PMTiles archives, open each archive from bytes, and -
+// with two or more cells - compose them and serve tiles from the composite.
+// This is the same call sequence a browser chartplotter makes; only the WASI
+// shim differs.
+//
+// usage: node engine-smoke.mjs ... [--png out.png]
+// e.g. node engine-smoke.mjs ~/Charts/enc-src/ALL/ENC_ROOT \
+// US5BDRAB/US5BDRAB.000 US5BDRBB/US5BDRBB.000 --png smoke.png
+
+import { WASI } from "node:wasi";
+import fs from "node:fs";
+
+const args = process.argv.slice(2);
+let pngOut = null;
+const pngFlag = args.indexOf("--png");
+if (pngFlag !== -1) {
+ pngOut = args[pngFlag + 1];
+ args.splice(pngFlag, 2);
+}
+const [encRoot, ...cells] = args;
+if (!encRoot || cells.length === 0) {
+ console.error("usage: node engine-smoke.mjs ... [--png out.png]");
+ process.exit(2);
+}
+
+const wasmPath = new URL("../../zig-out/bin/tile57-engine.wasm", import.meta.url);
+const wasi = new WASI({ version: "preview1", preopens: { "/enc": encRoot } });
+const mod = await WebAssembly.compile(fs.readFileSync(wasmPath));
+const inst = await WebAssembly.instantiate(mod, wasi.getImportObject());
+wasi.initialize(inst); // reactor: run the wasi/libc constructors once
+const E = inst.exports;
+
+// memory.buffer detaches on growth - always re-view.
+const u8 = () => new Uint8Array(E.memory.buffer);
+const dv = () => new DataView(E.memory.buffer);
+
+function cstr(ptr) {
+ const m = u8();
+ let end = ptr;
+ while (m[end] !== 0) end++;
+ return new TextDecoder().decode(m.subarray(ptr, end));
+}
+function allocCString(s) {
+ const b = new TextEncoder().encode(s);
+ const p = E.tile57_wasm_alloc(b.length + 1);
+ u8().set(b, p);
+ u8()[p + b.length] = 0;
+ return p;
+}
+
+// One scratch block for out-params + the tile57_error (status i32 + 256 msg).
+const scratch = E.tile57_wasm_alloc(16 + 260);
+const outPtr = scratch, outLen = scratch + 4, errPtr = scratch + 16;
+function check(name, status) {
+ if (status !== 0) throw new Error(`${name}: status ${status}: ${cstr(errPtr + 4)}`);
+}
+const readOut = () => [dv().getUint32(outPtr, true), dv().getUint32(outLen, true)];
+
+console.log("version:", cstr(E.tile57_version()));
+E.tile57_warmup();
+
+// ---- bake each cell, open each archive from bytes ------------------------
+const charts = [];
+for (const cell of cells) {
+ const t0 = performance.now();
+ check("bake_chart_bytes", E.tile57_bake_chart_bytes(allocCString("/enc/" + cell), outPtr, outLen, errPtr));
+ const [arcPtr, arcLen] = readOut();
+ if (arcLen === 0) throw new Error(`${cell}: bake produced no archive`);
+ check("chart_open_bytes", E.tile57_chart_open_bytes(arcPtr, arcLen, outPtr, errPtr));
+ charts.push(dv().getUint32(outPtr, true));
+ E.tile57_free(arcPtr);
+ console.log(`${cell}: baked ${arcLen} bytes in ${(performance.now() - t0).toFixed(0)} ms`);
+}
+
+// ---- union bounds -> the view ---------------------------------------------
+const info = E.tile57_wasm_alloc(96);
+let west = 180, south = 90, east = -180, north = -90, maxz = 0;
+for (const chart of charts) {
+ E.tile57_chart_get_info(chart, info);
+ const d = dv();
+ if (d.getUint8(info + 8)) {
+ west = Math.min(west, d.getFloat64(info + 16, true));
+ south = Math.min(south, d.getFloat64(info + 24, true));
+ east = Math.max(east, d.getFloat64(info + 32, true));
+ north = Math.max(north, d.getFloat64(info + 40, true));
+ }
+ maxz = Math.max(maxz, d.getUint8(info + 1));
+}
+const lat = (south + north) / 2, lon = (west + east) / 2;
+const zoom = Math.min(14, maxz);
+console.log(`view ${lat.toFixed(4)},${lon.toFixed(4)} @z${zoom}`);
+
+// ---- serve: one chart directly, or the composite over all of them --------
+const composed = charts.length > 1;
+let compose = 0;
+if (composed) {
+ const list = E.tile57_wasm_alloc(4 * charts.length);
+ charts.forEach((c, i) => dv().setUint32(list + 4 * i, c, true));
+ const t0 = performance.now();
+ check("compose_open", E.tile57_compose_open(list, charts.length, outPtr, errPtr));
+ compose = dv().getUint32(outPtr, true);
+ console.log(`composed ${charts.length} charts in ${(performance.now() - t0).toFixed(0)} ms`);
+}
+
+const z = zoom;
+const n = 2 ** z;
+const tx = Math.floor(((lon + 180) / 360) * n);
+const latR = (lat * Math.PI) / 180;
+const ty = Math.floor(((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n);
+let t0 = performance.now();
+if (composed) {
+ const ownedPtr = E.tile57_wasm_alloc(1);
+ check("compose_tile", E.tile57_compose_tile(compose, z, tx, ty, outPtr, outLen, ownedPtr, errPtr));
+} else {
+ check("chart_tile", E.tile57_chart_tile(charts[0], z, tx, ty, outPtr, outLen, errPtr));
+}
+const [tilePtr, tileLen] = readOut();
+console.log(`tile ${z}/${tx}/${ty}: ${tileLen} bytes in ${(performance.now() - t0).toFixed(0)} ms`);
+if (tilePtr) E.tile57_free(tilePtr);
+
+t0 = performance.now();
+if (composed) {
+ check("compose_png", E.tile57_compose_png(compose, lon, lat, zoom, 800, 600, 0, outPtr, outLen, errPtr));
+} else {
+ check("chart_png", E.tile57_chart_png(charts[0], lon, lat, zoom, 800, 600, 0, outPtr, outLen, errPtr));
+}
+const [pngPtr, pngLen] = readOut();
+console.log(`png: ${pngLen} bytes in ${(performance.now() - t0).toFixed(0)} ms`);
+if (pngLen === 0) throw new Error("png render produced no bytes");
+if (pngOut) {
+ fs.writeFileSync(pngOut, u8().slice(pngPtr, pngPtr + pngLen));
+ console.log("wrote", pngOut);
+}
+E.tile57_free(pngPtr);
+
+if (composed) E.tile57_compose_close(compose); // before the charts it borrows
+for (const chart of charts) E.tile57_chart_close(chart);
+console.log("OK");
diff --git a/bindings/js/engine-worker.mjs b/bindings/js/engine-worker.mjs
new file mode 100644
index 00000000..2c004399
--- /dev/null
+++ b/bindings/js/engine-worker.mjs
@@ -0,0 +1,153 @@
+// The engine, off the main thread. Every tile57 call is synchronous wasm and
+// a bake can hold the CPU for seconds - run here, the page stays live and a
+// loader can actually animate. The page talks RPC: {id, op, args} in,
+// {id, ok, result} | {id, ok: false, error} out, with large byte buffers
+// transferred rather than copied.
+//
+// One op is one engine call. The page orchestrates multi-cell work (bake this
+// cell, then that one) so progress falls out of the message flow itself.
+
+import { MemFS, WasiShim } from "./wasi-shim.mjs";
+import { Tile57 } from "./tile57.mjs";
+
+let t = null;
+const fsys = new MemFS("/enc");
+
+const ops = {
+ async init({ wasmUrl }) {
+ const mod = await WebAssembly.compileStreaming(fetch(wasmUrl));
+ const wasi = new WasiShim(fsys);
+ const inst = await WebAssembly.instantiate(mod, wasi.imports());
+ wasi.start(inst);
+ t = new Tile57(inst.exports);
+ t.warmup();
+ return { version: t.version() };
+ },
+
+ // Everything the WebGPU renderer needs, baked once per scheme: the ABI
+ // layout, the four atlas PNGs at the page's pixel ratio, and the
+ // colortables the halo and clear colours come from. Symbols carry their
+ // OWN colours, so a scheme change re-bakes the sprite atlas; the SDF glyph
+ // atlases are colourless and scheme-independent.
+ gpuAssets({ pixelRatio, scheme = 0 }) {
+ const r = {
+ layout: t.abiGpuLayout(),
+ spritePng: t.bakeSpriteMln(pixelRatio, scheme).png,
+ glyphPng: t.bakeGlyphSdf(0).png,
+ glyphBoldPng: t.bakeGlyphSdf(1).png,
+ glyphItalicPng: t.bakeGlyphSdf(2).png,
+ colortables: t.colortablesDefault(),
+ };
+ return [r, [r.spritePng.buffer, r.glyphPng.buffer, r.glyphBoldPng.buffer, r.glyphItalicPng.buffer]];
+ },
+
+ // The sprite atlas alone, for a scheme change on a standing renderer.
+ spriteAtlas({ pixelRatio, scheme = 0 }) {
+ const png = t.bakeSpriteMln(pixelRatio, scheme).png;
+ return [png, [png.buffer]];
+ },
+
+ // The engine's canonical default mariner settings.
+ marinerDefaults() { return t.marinerDefaults(); },
+
+ // The cursor pick + the decoded report for each feature, in one round trip.
+ pick({ compose, chart, lon, lat, zoom }) {
+ return t.pick({ compose, chart, lon, lat, zoom }).map((f) => ({
+ ...f,
+ report: (() => {
+ try { return t.s57Report(f.cls, f.chart, f.s57); } catch { return null; }
+ })(),
+ }));
+ },
+
+ // The S-52 colour tables ({day, dusk, night} token maps) - the page themes
+ // its own chrome from them, so the UI colours are the spec's, not ours.
+ palette() { return JSON.parse(t.colortablesDefault()); },
+
+ addFile({ path, bytes }) { fsys.add(path, bytes); },
+
+ // Drop a file or subtree from the tree - a zip or a cell's extracted files
+ // free as soon as their bake is done, so a big batch stays flat in memory.
+ remove({ path }) {
+ fsys.remove(path.startsWith(fsys.root + "/") ? path.slice(fsys.root.length + 1) : path);
+ },
+
+ // Read one file back out of the tree (transferred) - how the page shuttles
+ // zip-extracted cell files from this worker to a bake-pool worker.
+ readFile({ path }) {
+ const rel = path.startsWith(fsys.root + "/") ? path.slice(fsys.root.length + 1) : path;
+ const data = fsys.read(rel);
+ if (!data) throw new Error(`${path}: not found`);
+ const bytes = data.slice();
+ return [bytes, [bytes.buffer]];
+ },
+
+ zipList({ path }) { return t.zipList(path); },
+ zipExtract({ path, names, outPaths }) {
+ // zip_extract writes to the CALLER's paths and creates no directories.
+ for (const p of outPaths) {
+ const rel = p.startsWith(fsys.root + "/") ? p.slice(fsys.root.length + 1) : p;
+ fsys.mkdirs(rel.replace(/\/[^/]*$/, ""));
+ }
+ return t.zipExtract(path, names, outPaths);
+ },
+
+ // Bake one cell and describe it: the info (bounds, scale, zooms) rides
+ // along so the page can catalog the chart without opening the archive.
+ bakeCell({ path }) {
+ const arc = t.bakeChartBytes(path);
+ if (!arc) return null;
+ const handle = t.chartOpenBytes(arc);
+ const info = t.chartGetInfo(handle);
+ t.chartClose(handle);
+ return [{ archive: arc, info }, [arc.buffer]];
+ },
+
+ openChartBytes({ bytes }) {
+ const handle = t.chartOpenBytes(bytes);
+ return { handle, info: t.chartGetInfo(handle) };
+ },
+ closeChart({ handle }) { t.chartClose(handle); },
+ composeOpen({ handles }) { return t.composeOpen(handles); },
+ composeClose({ handle }) { t.composeClose(handle); },
+
+ png({ compose, chart, lon, lat, zoom, w, h, mariner }) {
+ const png = compose
+ ? t.composePng(compose, lon, lat, zoom, w, h, mariner)
+ : t.chartPng(chart, lon, lat, zoom, w, h, mariner);
+ return [png, [png.buffer]];
+ },
+
+ // Build a scene, batch it, and hand the page plain draw-ready data: the
+ // three buffers and the pattern cells COPIED out of wasm memory (and
+ // transferred), the draw list as objects.
+ gpuScene({ compose, chart, lon, lat, zoom, w, h, pixelRatio, atlasHave, halo, mariner }) {
+ const scene = compose
+ ? t.composeGpuScene(compose, lon, lat, zoom, w, h, pixelRatio, mariner)
+ : t.chartGpuScene(chart, lon, lat, zoom, w, h, pixelRatio, mariner);
+ const r = {
+ vertex: scene.vertexBytes().slice(),
+ index: scene.indexBytes().slice(),
+ quad: scene.quadBytes().slice(),
+ patterns: scene.patternList().map(({ w, h, rgba }) => ({ w, h, rgba: rgba.slice() })),
+ draws: t.gpuBatch(scene, { atlasHave, halo }),
+ };
+ scene.free();
+ const transfer = [r.vertex.buffer, r.index.buffer, r.quad.buffer, ...r.patterns.map((p) => p.rgba.buffer)];
+ return [r, transfer];
+ },
+};
+
+onmessage = async (e) => {
+ const { id, op, args } = e.data;
+ try {
+ let result = await ops[op](args ?? {});
+ let transfer = [];
+ if (Array.isArray(result) && result.length === 2 && Array.isArray(result[1])) {
+ [result, transfer] = result;
+ }
+ postMessage({ id, ok: true, result }, transfer);
+ } catch (err) {
+ postMessage({ id, ok: false, error: String(err?.message ?? err) });
+ }
+};
diff --git a/bindings/js/examples/generate.mjs b/bindings/js/examples/generate.mjs
index 94aa19aa..dd97f796 100644
--- a/bindings/js/examples/generate.mjs
+++ b/bindings/js/examples/generate.mjs
@@ -9,7 +9,7 @@
// Exits non-zero if any assertion fails (usable as a CI smoke test).
import assert from 'node:assert/strict';
-import { loadStyleEngine, DEFAULT_SETTINGS } from '../index.js';
+import { loadStyleEngine, DEFAULT_SETTINGS } from '../style.js';
const engine = await loadStyleEngine();
diff --git a/bindings/js/examples/web/serve.sh b/bindings/js/examples/web/serve.sh
index 11db20fe..46998f4d 100755
--- a/bindings/js/examples/web/serve.sh
+++ b/bindings/js/examples/web/serve.sh
@@ -8,7 +8,8 @@ PORT="${PORT:-3000}"
# Vendor the npm module (index.js + .wasm) into ./engine so the demo loads it from
# the served root. In a real app you'd `npm install @beetlebug/tile57-style-engine`.
mkdir -p engine
-cp -f ../../index.js ../../index.d.ts ../../style-engine.wasm engine/
+cp -f ../../style.js ../../style.d.ts ../../style-engine.wasm engine/
+mv -f engine/style.js engine/index.js
if [ ! -f chart-mlt/tiles/chart.pmtiles ] && [ ! -f chart/tiles/chart.pmtiles ]; then
echo "no baked tiles yet — run e.g.:" >&2
diff --git a/bindings/js/gpu-renderer.mjs b/bindings/js/gpu-renderer.mjs
new file mode 100644
index 00000000..1141b58f
--- /dev/null
+++ b/bindings/js/gpu-renderer.mjs
@@ -0,0 +1,492 @@
+// WebGPU renderer for tile57 GPU scenes - the browser sibling of the
+// reference shaders in shaders/ (lookout.metal, vk/*.vert|frag). The WGSL
+// below is a port of those programs over the same tile57_gpu_vertex /
+// tile57_gpu_quad / tile57_gpu_uniforms layouts; hold every change against
+// them.
+//
+// The engine hands over triangulated, paint-ordered buffers
+// (tile57_*_gpu_scene) and batches them into draw calls (tile57_gpu_batch).
+// This renderer uploads the buffers once per scene and redraws every frame
+// from uniforms alone, so pan and zoom are live between scene rebuilds.
+//
+// The renderer holds no engine handle - it consumes plain data, so the
+// engine can live in a Web Worker while the device and buffers live here.
+//
+// usage:
+// const r = await GpuRenderer.create(canvas, pixelRatio, assets);
+// // assets: {layout, spritePng, glyphPng, glyphBoldPng, glyphItalicPng,
+// // colortables} - the engine-worker's gpuAssets op
+// r.setScene(data); // {vertex, index, quad, patterns, draws} - its gpuScene op
+// r.draw(camera); // {lon, lat, zoom}, any frame
+
+export const ATLAS = { NONE: 0, SPRITE: 1, GLYPH: 2, GLYPH_BOLD: 3, GLYPH_ITALIC: 4 };
+const NO_PATTERN = 0xffffffff;
+const PIPE = { CHART: 0, PATTERN: 1, SPRITE: 2, SDF: 3 };
+const UNIFORM_SLOT = 256; // minUniformBufferOffsetAlignment
+
+const WGSL = /* wgsl */ `
+// tile57_gpu_uniforms, byte for byte (color at 96, block 128).
+struct U {
+ mvp: mat4x4f,
+ px_to_clip: vec2f,
+ size_scale: f32,
+ current_scale: f32,
+ cat_mask: u32,
+ wrap_x: f32,
+ rot_sin: f32,
+ rot_cos: f32,
+ color: vec4f, // SDF halo background; unused elsewhere
+ anchor_px: vec2f, // pattern phase origin, framebuffer px
+ cell_px: vec2f, // pattern cell period, framebuffer px
+}
+@group(0) @binding(0) var u: U;
+@group(0) @binding(1) var samp: sampler;
+@group(0) @binding(2) var tex: texture_2d;
+
+fn rotate_local(local: vec2f) -> vec2f {
+ return vec2f(local.x * u.rot_cos - local.y * u.rot_sin,
+ local.x * u.rot_sin + local.y * u.rot_cos);
+}
+fn visible(disp_cat: u32, scamin: f32) -> bool {
+ var vis = (u.cat_mask & (1u << disp_cat)) != 0u;
+ if scamin > 0.0 && disp_cat != 0u && u.current_scale > scamin { vis = false; }
+ return vis;
+}
+const HIDDEN = vec4f(0.0, 0.0, 2.0, 1.0); // z=2 -> clipped
+
+// ---- chart: flat-colour triangles (chart.vert/frag) -----------------------
+struct ChartIn {
+ @location(0) world: vec2f,
+ @location(1) local: vec2f,
+ @location(2) scamin: f32,
+ @location(3) packed: vec2u, // disp_cat, map_align
+ @location(4) color: vec4f,
+ @location(5) depth: f32,
+}
+struct ChartOut { @builtin(position) pos: vec4f, @location(0) color: vec4f }
+
+@vertex fn chart_vs(in: ChartIn) -> ChartOut {
+ // Longitude is cyclic: draw this vertex at the world instance nearest the
+ // camera, so a view straddling the antimeridian is seamless.
+ let world = vec2f(in.world.x + round(u.wrap_x - in.world.x), in.world.y);
+ var clip = u.mvp * vec4f(world, 0.0, 1.0);
+ var local = in.local;
+ if in.packed.y != 0u { local = rotate_local(local); }
+ clip = vec4f(clip.xy + local * u.px_to_clip * u.size_scale * clip.w,
+ in.depth * clip.w, clip.w);
+ var out: ChartOut;
+ out.pos = select(HIDDEN, clip, visible(in.packed.x, in.scamin));
+ out.color = in.color;
+ return out;
+}
+@fragment fn chart_fs(in: ChartOut) -> @location(0) vec4f { return in.color; }
+
+// ---- pattern: area fill tiled from a cell (pattern.vert/frag) -------------
+struct PatOut { @builtin(position) pos: vec4f }
+
+@vertex fn pattern_vs(in: ChartIn) -> PatOut {
+ let world = vec2f(in.world.x + round(u.wrap_x - in.world.x), in.world.y);
+ var clip = u.mvp * vec4f(world, 0.0, 1.0);
+ clip = vec4f(clip.xy, in.depth * clip.w, clip.w);
+ var out: PatOut;
+ out.pos = select(HIDDEN, clip, visible(in.packed.x, in.scamin));
+ return out;
+}
+@fragment fn pattern_fs(in: PatOut) -> @location(0) vec4f {
+ // Phase = (fragment - world-origin) / cell, both framebuffer px, so the
+ // pattern rides the chart under a pan instead of swimming across it.
+ let sz = max(u.cell_px, vec2f(1.0));
+ let uv = fract((in.pos.xy - u.anchor_px) / sz);
+ let c = textureSample(tex, samp, uv);
+ if c.a < 0.02 { discard; }
+ return c;
+}
+
+// ---- textured quads: sprites and SDF text (sprite.vert, sprite/sdf.frag) --
+struct QuadIn {
+ @location(0) world: vec2f,
+ @location(1) local: vec2f,
+ @location(2) uv: vec2f,
+ @location(3) color: vec4f,
+ @location(4) weight: f32,
+ @location(5) scamin: f32,
+ @location(6) packed: vec4u, // disp_cat, map_align, flip, tangent_q
+ @location(7) depth: f32,
+}
+struct QuadOut {
+ @builtin(position) pos: vec4f,
+ @location(0) uv: vec2f,
+ @location(1) color: vec4f,
+ @location(2) weight: f32,
+}
+
+@vertex fn quad_vs(in: QuadIn) -> QuadOut {
+ let tangent = f32(in.packed.w) / 256.0 * 6.283185307179586;
+ let world = vec2f(in.world.x + round(u.wrap_x - in.world.x), in.world.y);
+ var clip = u.mvp * vec4f(world, 0.0, 1.0);
+ var local = in.local;
+ // Keep a tangent-rotated run (a depth-contour value) upright: if the run,
+ // once the view rotation is added, would read into the screen's left
+ // half-plane, turn it 180 degrees about the anchor.
+ if in.packed.z != 0u && (cos(tangent) * u.rot_cos - sin(tangent) * u.rot_sin) < 0.0 {
+ local = -local;
+ }
+ if in.packed.y != 0u { local = rotate_local(local); }
+ clip = vec4f(clip.xy + local * u.px_to_clip * u.size_scale * clip.w,
+ in.depth * clip.w, clip.w);
+ var out: QuadOut;
+ out.pos = select(HIDDEN, clip, visible(in.packed.x, in.scamin));
+ out.uv = in.uv;
+ out.color = in.color;
+ out.weight = in.weight;
+ return out;
+}
+@fragment fn sprite_fs(in: QuadOut) -> @location(0) vec4f {
+ let c = textureSample(tex, samp, in.uv) * in.color;
+ if c.a < (1.0 / 255.0) { discard; }
+ return c;
+}
+@fragment fn sdf_fs(in: QuadOut) -> @location(0) vec4f {
+ let d = textureSample(tex, samp, in.uv).r;
+ let w = fwidth(d);
+ let a = smoothstep(0.5 - w, 0.5 + w, d);
+ if in.weight > 0.0 {
+ let halo_a = smoothstep(0.5 - in.weight - w, 0.5 - in.weight + w, d);
+ let cov = max(a, halo_a);
+ if cov <= 0.0 { discard; }
+ let col = mix(u.color.rgb, in.color.rgb, a);
+ return vec4f(col, cov * in.color.a);
+ }
+ if a <= 0.0 { discard; }
+ return vec4f(in.color.rgb, in.color.a * a);
+}
+`;
+
+// tile57_gpu_vertex (32 B) - see chart.vert's layout comment.
+const VERTEX_LAYOUT = {
+ arrayStride: 32,
+ attributes: [
+ { shaderLocation: 0, offset: 0, format: "float32x2" },
+ { shaderLocation: 1, offset: 8, format: "float32x2" },
+ { shaderLocation: 2, offset: 16, format: "float32" },
+ { shaderLocation: 3, offset: 20, format: "uint8x2" },
+ { shaderLocation: 4, offset: 24, format: "unorm8x4" },
+ { shaderLocation: 5, offset: 28, format: "float32" },
+ ],
+};
+// tile57_gpu_quad (44 B) - see sprite.vert's layout comment.
+const QUAD_LAYOUT = {
+ arrayStride: 44,
+ attributes: [
+ { shaderLocation: 0, offset: 0, format: "float32x2" },
+ { shaderLocation: 1, offset: 8, format: "float32x2" },
+ { shaderLocation: 2, offset: 16, format: "float32x2" },
+ { shaderLocation: 3, offset: 24, format: "unorm8x4" },
+ { shaderLocation: 4, offset: 28, format: "float32" },
+ { shaderLocation: 5, offset: 32, format: "float32" },
+ { shaderLocation: 6, offset: 36, format: "uint8x4" },
+ { shaderLocation: 7, offset: 40, format: "float32" },
+ ],
+};
+
+const BLEND = {
+ color: { srcFactor: "src-alpha", dstFactor: "one-minus-src-alpha", operation: "add" },
+ alpha: { srcFactor: "one", dstFactor: "one-minus-src-alpha", operation: "add" },
+};
+
+export const lonLatToWorld = (lon, lat) => {
+ const r = (lat * Math.PI) / 180;
+ return [(lon + 180) / 360, (1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2];
+};
+export const worldToLonLat = (x, y) => [
+ x * 360 - 180,
+ (180 / Math.PI) * Math.atan(Math.sinh(Math.PI * (1 - 2 * y))),
+];
+
+// The engine's zoom -> 1:N convention (render/resolve.zig DENOM_Z0), the value
+// the shaders test against per-vertex SCAMIN.
+export const scaleDenom = (zoom) => 279541132 / 2 ** zoom;
+
+async function texFromPng(device, png) {
+ const bmp = await createImageBitmap(new Blob([png], { type: "image/png" }));
+ const tex = device.createTexture({
+ size: [bmp.width, bmp.height],
+ format: "rgba8unorm",
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
+ });
+ device.queue.copyExternalImageToTexture({ source: bmp }, { texture: tex }, [bmp.width, bmp.height]);
+ bmp.close();
+ return tex;
+}
+
+// The active palette's NODTA (no-data) colour: the SDF halo background and the
+// clear colour. The colortables JSON is {tables:{DAY:{NODTA:[r,g,b],...},...}}
+// -shaped; walk it tolerantly and fall back to the S-52 day value.
+function nodataColor(colortablesJson, scheme = "DAY") {
+ try {
+ const root = JSON.parse(colortablesJson);
+ const walk = (o) => {
+ if (!o || typeof o !== "object") return null;
+ for (const [k, v] of Object.entries(o)) {
+ if (k.toUpperCase() === "NODTA") {
+ if (Array.isArray(v) && v.length >= 3) return [v[0] / 255, v[1] / 255, v[2] / 255, 1];
+ if (typeof v === "string" && v[0] === "#")
+ return [1, 3, 5].map((i) => parseInt(v.slice(i, i + 2), 16) / 255).concat(1);
+ }
+ }
+ for (const [k, v] of Object.entries(o)) {
+ if (k.toUpperCase().includes(scheme)) { const c = walk(v); if (c) return c; }
+ }
+ for (const v of Object.values(o)) { const c = walk(v); if (c) return c; }
+ return null;
+ };
+ const c = walk(root);
+ if (c) return c;
+ } catch { /* fall through */ }
+ return [163 / 255, 180 / 255, 183 / 255, 1]; // S-52 day NODTA
+}
+
+export class GpuRenderer {
+ static supported() { return typeof navigator !== "undefined" && !!navigator.gpu; }
+
+ /** Build the device, pipelines, and atlas textures from the engine's
+ * gpuAssets. `pixelRatio` must match the pixel ratio the assets were baked
+ * at AND every later gpu-scene call, or the sprite UVs will not index the
+ * atlas. */
+ static async create(canvas, pixelRatio, assets) {
+ const l = assets.layout;
+ if (l.vertex !== 32 || l.quad !== 44 || l.range !== 24 || l.uniforms !== 128)
+ throw new Error(`gpu ABI skew: engine says vertex=${l.vertex} quad=${l.quad} range=${l.range} uniforms=${l.uniforms}`);
+
+ const adapter = await navigator.gpu.requestAdapter();
+ if (!adapter) throw new Error("WebGPU: no adapter");
+ const device = await adapter.requestDevice();
+ const r = new GpuRenderer();
+ r.device = device;
+ r.canvas = canvas;
+ r.pixelRatio = pixelRatio;
+ r.format = navigator.gpu.getPreferredCanvasFormat();
+ r.context = canvas.getContext("webgpu");
+ r.context.configure({ device, format: r.format, alphaMode: "opaque" });
+
+ const module = device.createShaderModule({ code: WGSL });
+ r.bgl = device.createBindGroupLayout({
+ entries: [
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: "uniform", hasDynamicOffset: true } },
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: {} },
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: {} },
+ ],
+ });
+ const layout = device.createPipelineLayout({ bindGroupLayouts: [r.bgl] });
+ const pipeline = (vs, fs, buffers) =>
+ device.createRenderPipeline({
+ layout,
+ vertex: { module, entryPoint: vs, buffers },
+ fragment: { module, entryPoint: fs, targets: [{ format: r.format, blend: BLEND }] },
+ primitive: { topology: "triangle-list" },
+ multisample: { count: 4 },
+ });
+ r.pipelines = [
+ pipeline("chart_vs", "chart_fs", [VERTEX_LAYOUT]),
+ pipeline("pattern_vs", "pattern_fs", [VERTEX_LAYOUT]),
+ pipeline("quad_vs", "sprite_fs", [QUAD_LAYOUT]),
+ pipeline("quad_vs", "sdf_fs", [QUAD_LAYOUT]),
+ ];
+ r.sampler = device.createSampler({ magFilter: "linear", minFilter: "linear", addressModeU: "repeat", addressModeV: "repeat" });
+ r.dummyTex = device.createTexture({ size: [1, 1], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
+
+ // The four atlases, baked once by the engine at this density.
+ r.atlases = new Array(5).fill(null);
+ r.atlases[ATLAS.SPRITE] = await texFromPng(device, assets.spritePng);
+ r.atlases[ATLAS.GLYPH] = await texFromPng(device, assets.glyphPng);
+ r.atlases[ATLAS.GLYPH_BOLD] = await texFromPng(device, assets.glyphBoldPng);
+ r.atlases[ATLAS.GLYPH_ITALIC] = await texFromPng(device, assets.glyphItalicPng);
+ r.atlasHave = (1 << ATLAS.SPRITE) | (1 << ATLAS.GLYPH) | (1 << ATLAS.GLYPH_BOLD) | (1 << ATLAS.GLYPH_ITALIC);
+ r.colortables = assets.colortables;
+ r.halo = nodataColor(assets.colortables);
+ r.msaa = null;
+ r.buffers = null;
+ r.draws = [];
+ return r;
+ }
+
+ /** Swap to another colour scheme: the sprite atlas re-baked for it (the
+ * engine-worker's spriteAtlas op) and the halo/clear colour from that
+ * scheme's NODTA. Scenes rebuilt with the new mariner bring the rest. */
+ async setScheme(scheme, spritePng) {
+ const old = this.atlases[ATLAS.SPRITE];
+ this.atlases[ATLAS.SPRITE] = await texFromPng(this.device, spritePng);
+ old?.destroy();
+ this.halo = nodataColor(this.colortables, scheme.toUpperCase());
+ }
+
+ // Upload one buffer (padded to 4 bytes) or null when empty.
+ upload(bytes, usage) {
+ if (bytes.length === 0) return null;
+ const buf = this.device.createBuffer({ size: Math.ceil(bytes.length / 4) * 4, usage: usage | GPUBufferUsage.COPY_DST });
+ this.device.queue.writeBuffer(buf, 0, bytes);
+ return buf;
+ }
+
+ /** Upload one scene's draw-ready data (the engine-worker's gpuScene op):
+ * the three buffers, the pattern cells, and the batched draw list. */
+ setScene({ vertex, index, quad, patterns, draws }) {
+ this.disposeScene();
+ this.buffers = {
+ vertex: this.upload(vertex, GPUBufferUsage.VERTEX),
+ index: this.upload(index, GPUBufferUsage.INDEX),
+ quad: this.upload(quad, GPUBufferUsage.VERTEX),
+ };
+ this.patternTex = patterns.map(({ w, h, rgba }) => {
+ if (!w || !h) return null; // a cell that never rasterized: drop its draws
+ const tex = this.device.createTexture({ size: [w, h], format: "rgba8unorm", usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST });
+ this.device.queue.writeTexture({ texture: tex }, rgba, { bytesPerRow: w * 4 }, [w, h]);
+ return tex;
+ });
+ this.draws = draws;
+
+ // One uniform slot per draw; one bind group per distinct texture.
+ const n = Math.max(1, this.draws.length);
+ this.uniforms = this.device.createBuffer({ size: n * UNIFORM_SLOT, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
+ const groups = new Map();
+ this.bindGroup = (tex) => {
+ let g = groups.get(tex);
+ if (!g) {
+ g = this.device.createBindGroup({
+ layout: this.bgl,
+ entries: [
+ { binding: 0, resource: { buffer: this.uniforms, size: 128 } },
+ { binding: 1, resource: this.sampler },
+ { binding: 2, resource: tex.createView() },
+ ],
+ });
+ groups.set(tex, g);
+ }
+ return g;
+ };
+ }
+
+ disposeScene() {
+ if (this.buffers) for (const b of Object.values(this.buffers)) b?.destroy();
+ if (this.patternTex) for (const p of this.patternTex) p?.destroy();
+ this.uniforms?.destroy();
+ this.buffers = null;
+ this.draws = [];
+ }
+
+ drawTexture(d) {
+ if (d.pipeline === PIPE.PATTERN) return this.patternTex[d.pattern] ?? null;
+ if (d.pipeline === PIPE.SPRITE || d.pipeline === PIPE.SDF) return this.atlases[d.atlas] ?? null;
+ return this.dummyTex;
+ }
+
+ /** Redraw the uploaded scene for `cam` ({lon, lat, zoom}). Geometry is
+ * world-anchored, so any camera renders correctly - a pan or zoom between
+ * scene rebuilds is just new uniforms. */
+ draw(cam) {
+ const w = this.canvas.width, h = this.canvas.height;
+ if (!this.msaa || this.msaa.width !== w || this.msaa.height !== h) {
+ this.msaa?.destroy();
+ this.msaa = this.device.createTexture({ size: [w, h], format: this.format, sampleCount: 4, usage: GPUTextureUsage.RENDER_ATTACHMENT });
+ }
+
+ // No scene uploaded (or the last rebuild failed): paint the NODTA ground
+ // alone rather than touching buffers that are not there.
+ if (!this.buffers || !this.uniforms) {
+ const enc = this.device.createCommandEncoder();
+ enc.beginRenderPass({
+ colorAttachments: [{
+ view: this.msaa.createView(),
+ resolveTarget: this.context.getCurrentTexture().createView(),
+ loadOp: "clear",
+ clearValue: { r: this.halo[0], g: this.halo[1], b: this.halo[2], a: 1 },
+ storeOp: "discard",
+ }],
+ }).end();
+ this.device.queue.submit([enc.finish()]);
+ return;
+ }
+
+ const S = 256 * 2 ** cam.zoom * this.pixelRatio; // framebuffer px per world unit
+ const [cx, cy] = lonLatToWorld(cam.lon, cam.lat);
+ // View rotation (cam.rot, radians): the scene stays north-up in world
+ // space - the camera turns, and the shaders turn the map-aligned local
+ // offsets by the same angle (that is the whole GPU-scene contract).
+ const rot = cam.rot || 0;
+ const rc = Math.cos(rot), rs = Math.sin(rot);
+ const a = (2 * S) / w, b = (2 * S) / h;
+ // Longitude wrap: each vertex draws at the world copy nearest the camera,
+ // which keeps a zoomed-in view seamless across the antimeridian. In a
+ // WIDE view the seam meridian (half a world from the camera) falls onto
+ // geometry, and a primitive straddling it gets its vertices wrapped to
+ // OPPOSITE copies - it tears into full-width streaks. Once the viewport
+ // spans a large share of a world, draw everything in its home copy
+ // instead: wrap_x = 0.5 makes the shader's round() zero for all x.
+ const wrapX = w / S > 0.4 ? 0.5 : cx;
+ const slots = new ArrayBuffer(Math.max(1, this.draws.length) * UNIFORM_SLOT);
+ for (let i = 0; i < this.draws.length; i++) {
+ const d = this.draws[i];
+ const f = new Float32Array(slots, i * UNIFORM_SLOT, 32);
+ const u = new Uint32Array(slots, i * UNIFORM_SLOT, 32);
+ // mvp = P·R·T: world, centered on the camera, rotated in y-down screen
+ // space, scaled to clip.
+ f.set([
+ a * rc, -b * rs, 0, 0,
+ -a * rs, -b * rc, 0, 0,
+ 0, 0, 0, 0,
+ -a * (rc * cx - rs * cy), b * (rs * cx + rc * cy), 0, 1,
+ ]);
+ f[16] = 2 / w; f[17] = -2 / h; // px_to_clip
+ f[18] = this.pixelRatio; // size_scale
+ f[19] = scaleDenom(cam.zoom); // current_scale
+ u[20] = 7 | d.catMaskOr; // cat_mask
+ f[21] = wrapX; // wrap_x
+ f[22] = rs; f[23] = rc; // rot_sin, rot_cos
+ f[24] = d.color[0]; f[25] = d.color[1]; f[26] = d.color[2]; f[27] = d.color[3];
+ if (d.pipeline === PIPE.PATTERN) {
+ const tex = this.patternTex[d.pattern];
+ if (tex) {
+ // Phase origin = world (0,0) in framebuffer px, reduced mod the cell
+ // so f32 keeps the phase exact far from the origin.
+ const dx0 = -cx * S, dy0 = -cy * S;
+ const ox = rc * dx0 - rs * dy0 + w / 2, oy = rs * dx0 + rc * dy0 + h / 2;
+ f[28] = ((ox % tex.width) + tex.width) % tex.width;
+ f[29] = ((oy % tex.height) + tex.height) % tex.height;
+ f[30] = tex.width; f[31] = tex.height; // cell_px
+ }
+ }
+ }
+ this.device.queue.writeBuffer(this.uniforms, 0, slots);
+
+ const enc = this.device.createCommandEncoder();
+ const [br, bg, bb] = this.halo;
+ const pass = enc.beginRenderPass({
+ colorAttachments: [{
+ view: this.msaa.createView(),
+ resolveTarget: this.context.getCurrentTexture().createView(),
+ loadOp: "clear",
+ clearValue: { r: br, g: bg, b: bb, a: 1 },
+ storeOp: "discard",
+ }],
+ });
+ for (let i = 0; i < this.draws.length; i++) {
+ const d = this.draws[i];
+ const tex = this.drawTexture(d);
+ if (!tex) continue;
+ pass.setPipeline(this.pipelines[d.pipeline]);
+ pass.setBindGroup(0, this.bindGroup(tex), [i * UNIFORM_SLOT]);
+ if (d.prim === 0) { // TRIANGLES: first/count index the index buffer
+ if (!this.buffers?.vertex || !this.buffers?.index) continue;
+ pass.setVertexBuffer(0, this.buffers.vertex);
+ pass.setIndexBuffer(this.buffers.index, "uint32");
+ pass.drawIndexed(d.count, 1, d.first);
+ } else { // QUADS: first/count are quad-buffer vertices
+ if (!this.buffers?.quad) continue;
+ pass.setVertexBuffer(0, this.buffers.quad);
+ pass.draw(d.count, 1, d.first);
+ }
+ }
+ pass.end();
+ this.device.queue.submit([enc.finish()]);
+ }
+}
diff --git a/bindings/js/index.mjs b/bindings/js/index.mjs
new file mode 100644
index 00000000..27a83fa7
--- /dev/null
+++ b/bindings/js/index.mjs
@@ -0,0 +1,45 @@
+// tile57 for JavaScript: the full chart engine, compiled to WebAssembly.
+//
+// The engine bakes S-57/S-101 charts to PMTiles archives, composes them, and
+// renders tiles, PNG views, and draw-ready WebGPU scenes - in the browser or
+// in node. `createEngine` stands one up in the current context; the other
+// exports are the pieces a real app composes:
+//
+// - Tile57 the C-API wrapper (bake, chart, compose, gpu, style)
+// - MemFS, WasiShim the WASI host with an in-memory file tree
+// - GpuRenderer WebGPU over the engine's draw-ready scenes
+// - makeRpc, BakePool run the engine in Web Workers, bake cells in parallel
+// - ChartLibrary persist baked archives in the browser (OPFS)
+//
+// bindings/js/demo.html is the reference host: a chartplotter in one page.
+// The style-only engine (a much smaller wasm) lives under "tile57/style".
+
+export { Tile57 } from "./tile57.mjs";
+export { MemFS, WasiShim } from "./wasi-shim.mjs";
+export { GpuRenderer, ATLAS, lonLatToWorld, worldToLonLat, scaleDenom } from "./gpu-renderer.mjs";
+export { makeRpc } from "./worker-rpc.mjs";
+export { BakePool } from "./bake-pool.mjs";
+export { ChartLibrary } from "./chart-library.mjs";
+
+import { Tile57 } from "./tile57.mjs";
+import { MemFS, WasiShim } from "./wasi-shim.mjs";
+
+/** Instantiate the engine on the current thread. `wasm` is the module bytes,
+ * a compiled WebAssembly.Module, or a URL/path string; the returned `fs` is
+ * the engine's file tree (add source cells there, under `root`). For long
+ * bakes, prefer engine-worker.mjs so the engine runs off the main thread. */
+export async function createEngine(wasm, { root = "/enc" } = {}) {
+ let module = wasm;
+ if (typeof wasm === "string" || wasm instanceof URL) {
+ module = typeof process !== "undefined" && process.versions?.node
+ ? await WebAssembly.compile(await (await import("node:fs/promises")).readFile(wasm))
+ : await WebAssembly.compileStreaming(fetch(wasm));
+ } else if (!(wasm instanceof WebAssembly.Module)) {
+ module = await WebAssembly.compile(wasm);
+ }
+ const fs = new MemFS(root);
+ const wasi = new WasiShim(fs);
+ const instance = await WebAssembly.instantiate(module, wasi.imports());
+ wasi.start(instance);
+ return { engine: new Tile57(instance.exports), fs };
+}
diff --git a/bindings/js/package.json b/bindings/js/package.json
index 77e70bca..5d9bacd4 100644
--- a/bindings/js/package.json
+++ b/bindings/js/package.json
@@ -1,38 +1,64 @@
{
- "name": "@beetlebug/tile57-style-engine",
- "version": "0.1.0",
- "description": "Generate a MapLibre style.json from S-52 mariner settings, client-side, via the tile57 chartstyle engine compiled to WebAssembly.",
+ "name": "tile57",
+ "version": "0.3.0",
+ "description": "Official nautical charts, ready to draw — the tile57 engine compiled to WebAssembly, with JavaScript bindings for the browser and node.",
"type": "module",
- "main": "index.js",
- "module": "index.js",
- "types": "index.d.ts",
+ "main": "index.mjs",
+ "types": "style.d.ts",
"exports": {
- ".": {
- "types": "./index.d.ts",
- "import": "./index.js"
+ ".": "./index.mjs",
+ "./style": {
+ "types": "./style.d.ts",
+ "import": "./style.js"
},
+ "./tile57.mjs": "./tile57.mjs",
+ "./wasi-shim.mjs": "./wasi-shim.mjs",
+ "./gpu-renderer.mjs": "./gpu-renderer.mjs",
+ "./engine-worker.mjs": "./engine-worker.mjs",
+ "./worker-rpc.mjs": "./worker-rpc.mjs",
+ "./bake-pool.mjs": "./bake-pool.mjs",
+ "./chart-library.mjs": "./chart-library.mjs",
+ "./tile57-engine.wasm": "./tile57-engine.wasm",
"./style-engine.wasm": "./style-engine.wasm"
},
"files": [
- "index.js",
- "index.d.ts",
+ "index.mjs",
+ "tile57.mjs",
+ "wasi-shim.mjs",
+ "gpu-renderer.mjs",
+ "engine-worker.mjs",
+ "worker-rpc.mjs",
+ "bake-pool.mjs",
+ "chart-library.mjs",
+ "style.js",
+ "style.d.ts",
+ "tile57-engine.wasm",
"style-engine.wasm",
"README.md"
],
"scripts": {
- "example": "node examples/generate.mjs",
- "test": "node examples/generate.mjs"
+ "build": "cd ../.. && zig build wasm-engine -Doptimize=ReleaseSmall && cp zig-out/bin/tile57-engine.wasm bindings/js/ && bindings/scripts/build-wasm.sh",
+ "prepack": "npm run build",
+ "smoke": "node engine-smoke.mjs",
+ "example": "node examples/generate.mjs"
},
"keywords": [
- "maplibre",
- "s-52",
- "s-57",
- "enc",
"nautical-chart",
"chartplotter",
- "wasm",
- "style"
+ "enc",
+ "s-57",
+ "s-101",
+ "s-52",
+ "maplibre",
+ "webgpu",
+ "wasm"
],
"license": "SEE LICENSE IN ../../LICENSE",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/beetlebugorg/tile57",
+ "directory": "bindings/js"
+ },
+ "homepage": "https://beetlebugorg.github.io/tile57/wasm",
"sideEffects": false
}
diff --git a/bindings/js/index.d.ts b/bindings/js/style.d.ts
similarity index 100%
rename from bindings/js/index.d.ts
rename to bindings/js/style.d.ts
diff --git a/bindings/js/index.js b/bindings/js/style.js
similarity index 100%
rename from bindings/js/index.js
rename to bindings/js/style.js
diff --git a/bindings/wasm/style_wasm.zig b/bindings/js/style_wasm.zig
similarity index 100%
rename from bindings/wasm/style_wasm.zig
rename to bindings/js/style_wasm.zig
diff --git a/bindings/js/tile57.mjs b/bindings/js/tile57.mjs
new file mode 100644
index 00000000..0781790b
--- /dev/null
+++ b/bindings/js/tile57.mjs
@@ -0,0 +1,446 @@
+// A thin JS wrapper over the tile57-engine.wasm exports. It mirrors the C API
+// one-to-one (see include/tile57.h for semantics) and only handles the
+// boundary work: linear-memory allocation, C strings, out-parameters, and the
+// tile57_error decode. Browser and node both run it; pair it with any WASI
+// preview1 host (node:wasi, or wasi-shim.mjs in a browser).
+//
+// usage:
+// const engine = new Tile57(instance.exports); // after _initialize ran
+// engine.warmup();
+// const archive = engine.bakeChartBytes("/enc/US5BDRAB/US5BDRAB.000");
+// const chart = engine.chartOpenBytes(archive);
+// const png = engine.chartPng(chart, lon, lat, zoom, 800, 600);
+
+export class Tile57 {
+ constructor(exports) {
+ this.e = exports;
+ // One scratch block: two 4-byte out-slots, one flag byte, the error
+ // struct (status i32 + 256-byte message).
+ this.scratch = this.walloc(16 + 260);
+ this.outPtr = this.scratch;
+ this.outLen = this.scratch + 4;
+ this.outFlag = this.scratch + 8;
+ this.errPtr = this.scratch + 16;
+ }
+
+ // memory.buffer detaches on growth - always re-view.
+ bytes() { return new Uint8Array(this.e.memory.buffer); }
+ view() { return new DataView(this.e.memory.buffer); }
+
+ cstr(ptr) {
+ const m = this.bytes();
+ let end = ptr;
+ while (m[end] !== 0) end++;
+ return new TextDecoder().decode(m.subarray(ptr, end));
+ }
+ /** Allocate `len` bytes of linear memory. The mask matters: wasm i32
+ * return values arrive SIGNED, so past 2 GiB of memory every pointer
+ * looks negative without it. */
+ walloc(len) {
+ const p = this.e.tile57_wasm_alloc(len) >>> 0;
+ if (!p) throw new Error("out of wasm memory");
+ return p;
+ }
+ /** Copy `bytes` into linear memory. Release with `wasmFree`. */
+ alloc(bytes) {
+ const p = this.walloc(bytes.length);
+ this.bytes().set(bytes, p);
+ return p;
+ }
+ allocCString(s) {
+ const b = new TextEncoder().encode(s + "\0");
+ return this.alloc(b);
+ }
+ wasmFree(ptr) { this.e.tile57_wasm_free(ptr); }
+
+ check(name, status) {
+ if (status !== 0) throw new Error(`${name}: status ${status}: ${this.cstr(this.errPtr + 4)}`);
+ }
+ /** Copy an engine output buffer out of linear memory and tile57_free it. */
+ takeOut() {
+ const d = this.view();
+ const ptr = d.getUint32(this.outPtr, true);
+ const len = d.getUint32(this.outLen, true);
+ if (!ptr) return null;
+ const copy = this.bytes().slice(ptr, ptr + len);
+ this.e.tile57_free(ptr);
+ return copy;
+ }
+
+ version() { return this.cstr(this.e.tile57_version() >>> 0); }
+ warmup() { this.e.tile57_warmup(); }
+
+ // ---- mariner settings (tile57_mariner, 144 B on wasm32) -----------------
+ // Offsets mirror include/tile57.h field by field; marinerDefaults() decodes
+ // the struct the ENGINE fills, so a layout skew shows up immediately as
+ // absurd defaults (the node test asserts the canonical values).
+ //
+ // The JS shape folds the three display_* booleans into one cumulative
+ // `detailLevel` (base | standard | other) and the soundings tri-state into
+ // auto | on | off. Viewing groups, size scales, and the host debug valves
+ // stay at the engine defaults.
+
+ static SCHEMES = ["day", "dusk", "night"];
+
+ decodeMariner(p) {
+ const d = this.view(), m = this.bytes();
+ let dateView = "";
+ for (let i = 0; i < 8; i++) {
+ const b = m[p + 67 + i];
+ if (!b) break;
+ dateView += String.fromCharCode(b);
+ }
+ return {
+ scheme: Tile57.SCHEMES[d.getUint32(p, true)] ?? "day",
+ shallowContour: d.getFloat64(p + 8, true),
+ safetyContour: d.getFloat64(p + 16, true),
+ deepContour: d.getFloat64(p + 24, true),
+ safetyDepth: d.getFloat64(p + 32, true),
+ fourShadeWater: !!m[p + 40],
+ depthUnit: d.getUint32(p + 44, true) === 1 ? "ft" : "m",
+ detailLevel: m[p + 50] ? "other" : m[p + 49] ? "standard" : "base",
+ dataQuality: !!m[p + 51],
+ showInformCallouts: !!m[p + 52],
+ showMetaBounds: !!m[p + 53],
+ showIsolatedDangersShallow: !!m[p + 54],
+ boundaryStyle: d.getUint32(p + 56, true) === 1 ? "plain" : "symbolized",
+ simplifiedPoints: !!m[p + 60],
+ showFullSectorLines: !!m[p + 61],
+ textNames: !!m[p + 62],
+ showLightDescriptions: !!m[p + 63],
+ textOther: !!m[p + 64],
+ dateDependent: !!m[p + 65],
+ highlightDateDependent: !!m[p + 66],
+ dateView,
+ showOverscale: !!m[p + 97],
+ soundings: ["auto", "on", "off"][m[p + 120]] ?? "auto",
+ };
+ }
+
+ /** The engine's canonical default mariner settings, as a JS object. */
+ marinerDefaults() {
+ const p = this.walloc(144);
+ this.bytes().fill(0, p, p + 144);
+ this.e.tile57_mariner_defaults(p);
+ const out = this.decodeMariner(p);
+ this.wasmFree(p);
+ return out;
+ }
+
+ /** Encode settings over the engine defaults into a tile57_mariner in
+ * linear memory. Release with wasmFree. Null/undefined settings -> 0
+ * (the calls treat NULL as canonical defaults). */
+ encodeMariner(s) {
+ if (!s) return 0;
+ const p = this.walloc(144);
+ this.bytes().fill(0, p, p + 144);
+ this.e.tile57_mariner_defaults(p);
+ const d = this.view(), m = this.bytes();
+ const has = (k) => s[k] !== undefined;
+ if (has("scheme")) d.setUint32(p, Math.max(0, Tile57.SCHEMES.indexOf(s.scheme)), true);
+ if (has("shallowContour")) d.setFloat64(p + 8, s.shallowContour, true);
+ if (has("safetyContour")) d.setFloat64(p + 16, s.safetyContour, true);
+ if (has("deepContour")) d.setFloat64(p + 24, s.deepContour, true);
+ if (has("safetyDepth")) d.setFloat64(p + 32, s.safetyDepth, true);
+ if (has("fourShadeWater")) m[p + 40] = s.fourShadeWater ? 1 : 0;
+ if (has("depthUnit")) d.setUint32(p + 44, s.depthUnit === "ft" ? 1 : 0, true);
+ if (has("detailLevel")) {
+ m[p + 48] = 1; // display_base is the permanent minimum
+ m[p + 49] = s.detailLevel !== "base" ? 1 : 0;
+ m[p + 50] = s.detailLevel === "other" ? 1 : 0;
+ }
+ if (has("dataQuality")) m[p + 51] = s.dataQuality ? 1 : 0;
+ if (has("showInformCallouts")) m[p + 52] = s.showInformCallouts ? 1 : 0;
+ if (has("showMetaBounds")) m[p + 53] = s.showMetaBounds ? 1 : 0;
+ if (has("showIsolatedDangersShallow")) m[p + 54] = s.showIsolatedDangersShallow ? 1 : 0;
+ if (has("boundaryStyle")) d.setUint32(p + 56, s.boundaryStyle === "plain" ? 1 : 0, true);
+ if (has("simplifiedPoints")) m[p + 60] = s.simplifiedPoints ? 1 : 0;
+ if (has("showFullSectorLines")) m[p + 61] = s.showFullSectorLines ? 1 : 0;
+ if (has("textNames")) m[p + 62] = s.textNames ? 1 : 0;
+ if (has("showLightDescriptions")) m[p + 63] = s.showLightDescriptions ? 1 : 0;
+ if (has("textOther")) m[p + 64] = s.textOther ? 1 : 0;
+ if (has("dateDependent")) m[p + 65] = s.dateDependent ? 1 : 0;
+ if (has("highlightDateDependent")) m[p + 66] = s.highlightDateDependent ? 1 : 0;
+ if (has("dateView")) {
+ m.fill(0, p + 67, p + 76);
+ const v = String(s.dateView || "").slice(0, 8);
+ for (let i = 0; i < v.length; i++) m[p + 67 + i] = v.charCodeAt(i);
+ }
+ if (has("showOverscale")) m[p + 97] = s.showOverscale ? 1 : 0;
+ if (has("soundings")) m[p + 120] = { auto: 0, on: 1, off: 2 }[s.soundings] ?? 0;
+ return p;
+ }
+
+ /** Bake one S-57 cell (a path in the WASI file tree) to archive bytes. */
+ bakeChartBytes(cellPath) {
+ const p = this.allocCString(cellPath);
+ this.check("bake_chart_bytes", this.e.tile57_bake_chart_bytes(p, this.outPtr, this.outLen, this.errPtr));
+ this.wasmFree(p);
+ return this.takeOut();
+ }
+
+ /** Bake every chart in an exchange-set zip to //.pmtiles
+ * in the WASI file tree (updates applied from the archive). Returns how many
+ * charts were baked. One call for the whole set - a host that wants per-cell
+ * progress lists the zip, extracts each cell, and bakes it itself. */
+ bakeZip(zipPath, outDir) {
+ const zp = this.allocCString(zipPath);
+ const op = this.allocCString(outDir);
+ this.check("bake_zip", this.e.tile57_bake_zip(zp, op, 1, 0, 0, this.outPtr, this.errPtr));
+ this.wasmFree(zp);
+ this.wasmFree(op);
+ return this.view().getUint32(this.outPtr, true);
+ }
+
+ /** List a zip's entries: [{name, size, packed}, ...] in central-directory
+ * order. */
+ zipList(zipPath) {
+ const zp = this.allocCString(zipPath);
+ this.check("zip_list", this.e.tile57_zip_list(zp, this.outPtr, this.outLen, this.errPtr));
+ this.wasmFree(zp);
+ return JSON.parse(new TextDecoder().decode(this.takeOut()));
+ }
+
+ /** Extract named zip entries to paths in the WASI file tree. `names[i]`
+ * lands at `outPaths[i]`. Returns how many were written. */
+ zipExtract(zipPath, names, outPaths) {
+ const zp = this.allocCString(zipPath);
+ const strs = names.concat(outPaths).map((s) => this.allocCString(s));
+ const list = this.walloc(4 * strs.length);
+ const d = this.view();
+ strs.forEach((p, i) => d.setUint32(list + 4 * i, p, true));
+ this.check("zip_extract", this.e.tile57_zip_extract(zp, list, list + 4 * names.length, names.length, 0, 0, this.outPtr, this.errPtr));
+ const done = this.view().getUint32(this.outPtr, true);
+ for (const p of strs) this.wasmFree(p);
+ this.wasmFree(list);
+ this.wasmFree(zp);
+ return done;
+ }
+
+ /** Open a baked archive from bytes; returns the chart handle. */
+ chartOpenBytes(archive) {
+ const p = this.alloc(archive);
+ this.check("chart_open_bytes", this.e.tile57_chart_open_bytes(p, archive.length, this.outPtr, this.errPtr));
+ this.wasmFree(p);
+ return this.view().getUint32(this.outPtr, true);
+ }
+ chartClose(chart) { this.e.tile57_chart_close(chart); }
+
+ /** Decode tile57_info for a chart. */
+ chartGetInfo(chart) {
+ const info = this.walloc(96);
+ this.e.tile57_chart_get_info(chart, info);
+ const d = this.view();
+ const out = {
+ minZoom: d.getUint8(info), maxZoom: d.getUint8(info + 1),
+ bands: d.getUint32(info + 4, true),
+ hasBounds: !!d.getUint8(info + 8),
+ west: d.getFloat64(info + 16, true), south: d.getFloat64(info + 24, true),
+ east: d.getFloat64(info + 32, true), north: d.getFloat64(info + 40, true),
+ hasAnchor: !!d.getUint8(info + 48),
+ anchorLat: d.getFloat64(info + 56, true), anchorLon: d.getFloat64(info + 64, true),
+ anchorZoom: d.getFloat64(info + 72, true),
+ tileType: d.getUint8(info + 80),
+ nativeScale: d.getInt32(info + 84, true),
+ isRaster: !!d.getUint8(info + 88),
+ };
+ this.wasmFree(info);
+ return out;
+ }
+
+ /** One vector tile from an open chart, or null where the archive has none. */
+ chartTile(chart, z, x, y) {
+ this.check("chart_tile", this.e.tile57_chart_tile(chart, z, x, y, this.outPtr, this.outLen, this.errPtr));
+ return this.takeOut();
+ }
+ /** A PNG view render from an open chart. `mariner` (optional) is the JS
+ * settings object encodeMariner takes; absent -> canonical defaults. */
+ chartPng(chart, lon, lat, zoom, width, height, mariner) {
+ const mp = this.encodeMariner(mariner);
+ this.check("chart_png", this.e.tile57_chart_png(chart, lon, lat, zoom, width, height, mp, this.outPtr, this.outLen, this.errPtr));
+ if (mp) this.wasmFree(mp);
+ return this.takeOut();
+ }
+
+ // ---- draw-ready GPU scenes (see the tile57.h GPU section) ---------------
+
+ /** {vertex, quad, range, uniforms} struct sizes the engine compiled with.
+ * Compare against the constants the renderer assumes. */
+ abiGpuLayout() {
+ const v = this.e.tile57_abi_gpu_layout();
+ return { vertex: v & 0xff, quad: (v >>> 8) & 0xff, range: (v >>> 16) & 0xff, uniforms: (v >>> 24) & 0xff };
+ }
+
+ // Decode a tile57_gpu_scene struct into wasm-memory views. The views BORROW
+ // linear memory: use them before free(), and re-take them after any call
+ // that can grow the memory.
+ sceneView(sp) {
+ const d = this.view();
+ const s = {
+ vertices: d.getUint32(sp, true), vertexCount: d.getUint32(sp + 4, true),
+ indices: d.getUint32(sp + 8, true), indexCount: d.getUint32(sp + 12, true),
+ quads: d.getUint32(sp + 16, true), quadCount: d.getUint32(sp + 20, true),
+ ranges: d.getUint32(sp + 24, true), rangeCount: d.getUint32(sp + 28, true),
+ patterns: d.getUint32(sp + 32, true), patternCount: d.getUint32(sp + 36, true),
+ };
+ const self = this;
+ return {
+ ...s,
+ vertexBytes: () => self.bytes().subarray(s.vertices, s.vertices + s.vertexCount * 32),
+ indexBytes: () => self.bytes().subarray(s.indices, s.indices + s.indexCount * 4),
+ quadBytes: () => self.bytes().subarray(s.quads, s.quads + s.quadCount * 44),
+ patternList: () => {
+ const dv = self.view(), out = [];
+ for (let i = 0; i < s.patternCount; i++) {
+ const p = s.patterns + 16 * i;
+ const w = dv.getUint32(p, true), h = dv.getUint32(p + 4, true);
+ const rgba = dv.getUint32(p + 8, true), len = dv.getUint32(p + 12, true);
+ out.push({ w, h, rgba: self.bytes().subarray(rgba, rgba + len) });
+ }
+ return out;
+ },
+ free: () => { self.e.tile57_gpu_scene_free(sp); self.wasmFree(sp); },
+ };
+ }
+
+ /** Portray a chart view into draw-ready GPU buffers. Call .free() on the
+ * result once uploaded. `mariner` as chartPng. */
+ chartGpuScene(chart, lon, lat, zoom, width, height, pixelRatio, mariner) {
+ const sp = this.walloc(44);
+ const mp = this.encodeMariner(mariner);
+ this.check("chart_gpu_scene", this.e.tile57_chart_gpu_scene(chart, lon, lat, zoom, width, height, mp, pixelRatio, sp, this.errPtr));
+ if (mp) this.wasmFree(mp);
+ return this.sceneView(sp);
+ }
+ /** The composed twin of chartGpuScene. */
+ composeGpuScene(compose, lon, lat, zoom, width, height, pixelRatio, mariner) {
+ const sp = this.walloc(44);
+ const mp = this.encodeMariner(mariner);
+ this.check("compose_gpu_scene", this.e.tile57_compose_gpu_scene(compose, lon, lat, zoom, width, height, mp, pixelRatio, sp, this.errPtr));
+ if (mp) this.wasmFree(mp);
+ return this.sceneView(sp);
+ }
+
+ /** Batch a scene's ranges into draw calls (tile57_gpu_batch). `atlasHave`
+ * is a bitmask over the tile57_gpu_atlas ids the host uploaded; `halo` is
+ * the palette background RGBA (0..1) for SDF label halos. */
+ gpuBatch(scene, { textOn = true, soundOn = true, excludeOpaque = false, atlasHave = 0, halo = [1, 1, 1, 1] } = {}) {
+ const op = this.walloc(20);
+ {
+ const d = this.view();
+ d.setUint8(op, textOn ? 1 : 0);
+ d.setUint8(op + 1, soundOn ? 1 : 0);
+ d.setUint8(op + 2, excludeOpaque ? 1 : 0);
+ d.setUint8(op + 3, atlasHave);
+ for (let i = 0; i < 4; i++) d.setFloat32(op + 4 + 4 * i, halo[i], true);
+ }
+ const cap = scene.rangeCount;
+ const dp = this.walloc(Math.max(1, cap * 36));
+ const n = this.e.tile57_gpu_batch(scene.ranges, scene.rangeCount, op, dp, cap);
+ if (n > cap) throw new Error("gpu_batch: draw buffer too small");
+ const d = this.view(), draws = [];
+ for (let i = 0; i < n; i++) {
+ const p = dp + 36 * i;
+ draws.push({
+ first: d.getUint32(p, true), count: d.getUint32(p + 4, true),
+ prim: d.getUint8(p + 8), pipeline: d.getUint8(p + 9), atlas: d.getUint8(p + 10),
+ pattern: d.getUint32(p + 12, true), catMaskOr: d.getUint32(p + 16, true),
+ color: [0, 1, 2, 3].map((j) => d.getFloat32(p + 20 + 4 * j, true)),
+ });
+ }
+ this.wasmFree(op);
+ this.wasmFree(dp);
+ return draws;
+ }
+
+ // Read a tile57_assets struct field pair; copy out of linear memory.
+ assetField(ap, off) {
+ const d = this.view();
+ const ptr = d.getUint32(ap + off, true), len = d.getUint32(ap + off + 4, true);
+ return ptr ? this.bytes().slice(ptr, ptr + len) : null;
+ }
+
+ /** The MapLibre-style symbol atlas {json, png} for a scheme (0 day, 1 dusk,
+ * 2 night), rasterized at pixelRatio. Pass the SAME pixelRatio to the
+ * gpu-scene calls, or the UVs will not index the texture. */
+ bakeSpriteMln(pixelRatio, scheme = 0) {
+ const ap = this.walloc(48);
+ this.check("bake_sprite_mln", this.e.tile57_bake_sprite_mln(0, pixelRatio, scheme, ap, this.errPtr));
+ const out = { json: this.assetField(ap, 16), png: this.assetField(ap, 24) };
+ this.e.tile57_assets_free(ap);
+ this.wasmFree(ap);
+ return out;
+ }
+
+ /** The SDF label-glyph atlas {json, png} for a face: 0 regular, 1 bold,
+ * 2 italic. The png is the RGBA signed-distance field the SDF pipeline
+ * samples. */
+ bakeGlyphSdf(face = 0) {
+ const ap = this.walloc(48);
+ this.check("bake_glyph_sdf", this.e.tile57_bake_glyph_sdf_face(ap, face, this.errPtr));
+ const out = { json: this.assetField(ap, 16), png: this.assetField(ap, 24) };
+ this.e.tile57_assets_free(ap);
+ this.wasmFree(ap);
+ return out;
+ }
+
+ /** The embedded S-52 colortables JSON (all three palettes). */
+ colortablesDefault() {
+ this.check("colortables_default", this.e.tile57_colortables_default(this.outPtr, this.outLen, this.errPtr));
+ return new TextDecoder().decode(this.takeOut());
+ }
+
+ /** The cursor pick at (lon, lat): the features under the point, as
+ * [{cls, s57, chart}] - s57 is the attribute object. Pass a compose handle
+ * OR a chart handle (compose wins when both). `zoom` is the view's zoom, so
+ * the pick reads what is actually displayed. */
+ pick({ compose = 0, chart = 0, lon, lat, zoom }) {
+ const st = this.e.tile57_wasm_query(compose, chart, lon, lat, zoom, this.outPtr, this.outLen);
+ if (st !== 0) throw new Error(`wasm_query: status ${st}`);
+ const d = this.view();
+ const ptr = d.getUint32(this.outPtr, true), len = d.getUint32(this.outLen, true);
+ if (!ptr) return [];
+ const text = new TextDecoder().decode(this.bytes().subarray(ptr, ptr + len));
+ this.wasmFree(ptr);
+ return JSON.parse(text);
+ }
+
+ /** The decoded pick report for one queried feature: {title, subtitle, chip,
+ * notes, rows, footnote, empty?} plus the raw payload under `s57`. */
+ s57Report(cls, cell, attrs) {
+ const clsB = new TextEncoder().encode(cls);
+ const cellB = new TextEncoder().encode(cell);
+ const attrsB = new TextEncoder().encode(typeof attrs === "string" ? attrs : JSON.stringify(attrs ?? {}));
+ const p = this.alloc(new Uint8Array([...clsB, ...cellB, ...attrsB]));
+ this.check("s57_report", this.e.tile57_s57_report(
+ p, clsB.length, p + clsB.length, cellB.length, p + clsB.length + cellB.length, attrsB.length,
+ this.outPtr, this.outLen, this.errPtr));
+ this.wasmFree(p);
+ const out = this.takeOut();
+ return out ? JSON.parse(new TextDecoder().decode(out)) : null;
+ }
+
+ /** Compose open charts (BORROWED: close the compositor before them). */
+ composeOpen(charts) {
+ const list = this.walloc(4 * charts.length);
+ const d = this.view();
+ charts.forEach((c, i) => d.setUint32(list + 4 * i, c, true));
+ this.check("compose_open", this.e.tile57_compose_open(list, charts.length, this.outPtr, this.errPtr));
+ this.wasmFree(list);
+ return this.view().getUint32(this.outPtr, true);
+ }
+ composeClose(compose) { this.e.tile57_compose_close(compose); }
+
+ /** One composed vector tile, or null where no chart owns ground. */
+ composeTile(compose, z, x, y) {
+ this.check("compose_tile", this.e.tile57_compose_tile(compose, z, x, y, this.outPtr, this.outLen, this.outFlag, this.errPtr));
+ return this.takeOut();
+ }
+ /** A PNG view render from the composite. `mariner` as chartPng. */
+ composePng(compose, lon, lat, zoom, width, height, mariner) {
+ const mp = this.encodeMariner(mariner);
+ this.check("compose_png", this.e.tile57_compose_png(compose, lon, lat, zoom, width, height, mp, this.outPtr, this.outLen, this.errPtr));
+ if (mp) this.wasmFree(mp);
+ return this.takeOut();
+ }
+}
diff --git a/bindings/js/wasi-shim.mjs b/bindings/js/wasi-shim.mjs
new file mode 100644
index 00000000..1da9cd9c
--- /dev/null
+++ b/bindings/js/wasi-shim.mjs
@@ -0,0 +1,434 @@
+// A minimal WASI preview1 shim for the browser (no dependencies; node also
+// runs it). It covers what tile57-engine.wasm uses: an in-memory file tree
+// preopened at one path (writable, so the engine's zip bake can write
+// per-chart archives into it), the clock, randomness, and stdout/stderr to
+// the console. Sockets and polling return ENOSYS.
+//
+// usage:
+// const fsys = new MemFS("/enc");
+// fsys.add("US5BDRAB/US5BDRAB.000", bytes); // Uint8Array
+// const wasi = new WasiShim(fsys);
+// const inst = await WebAssembly.instantiate(mod, wasi.imports());
+// wasi.start(inst); // reactor _initialize
+
+const E = {
+ SUCCESS: 0, BADF: 8, EXIST: 20, INVAL: 28, IO: 29, ISDIR: 31,
+ NOENT: 44, NOSYS: 52, NOTDIR: 54, NOTEMPTY: 55, NOTSUP: 58,
+};
+const FILETYPE = { DIR: 3, REGULAR: 4 };
+
+/** A growable in-memory file. `data()` is the live content view. */
+class FileNode {
+ constructor(bytes) {
+ this.buf = bytes ?? new Uint8Array(0);
+ this.len = this.buf.length;
+ }
+ data() { return this.buf.subarray(0, this.len); }
+ grow(need) {
+ if (need <= this.buf.length) return;
+ const next = new Uint8Array(Math.max(need, this.buf.length * 2, 4096));
+ next.set(this.buf);
+ this.buf = next;
+ }
+ write(pos, src) {
+ this.grow(pos + src.length);
+ this.buf.set(src, pos);
+ this.len = Math.max(this.len, pos + src.length);
+ }
+ truncate(size) {
+ this.grow(size);
+ if (size > this.len) this.buf.fill(0, this.len, size);
+ this.len = size;
+ }
+}
+
+const parts = (rel) => rel.split("/").filter((p) => p && p !== ".");
+
+/** An in-memory file tree, preopened at `root` (e.g. "/enc"). Directories are
+ * Maps(name -> node); files are FileNodes. */
+export class MemFS {
+ constructor(root) {
+ this.root = root;
+ this.tree = new Map();
+ }
+ /** Add one file under the preopen root. `rel` uses "/" separators;
+ * intermediate directories are created. */
+ add(rel, bytes) {
+ const p = parts(rel);
+ let dir = this.tree;
+ for (const part of p.slice(0, -1)) {
+ if (!dir.has(part)) dir.set(part, new Map());
+ dir = dir.get(part);
+ if (!(dir instanceof Map)) throw new Error(`${part}: file where a directory is needed`);
+ }
+ dir.set(p[p.length - 1], new FileNode(bytes));
+ }
+ /** Remove the file or subtree at `rel`. A missing path is fine. */
+ remove(rel) {
+ const at = this.parent(rel);
+ if (at) at[0].delete(at[1]);
+ }
+ /** Create directory `rel` and its parents. */
+ mkdirs(rel) {
+ let dir = this.tree;
+ for (const part of parts(rel)) {
+ if (!dir.has(part)) dir.set(part, new Map());
+ dir = dir.get(part);
+ if (!(dir instanceof Map)) throw new Error(`${part}: file where a directory is needed`);
+ }
+ }
+ /** The node at `rel` ("" or "." -> the root dir), or null. */
+ lookup(rel) {
+ let node = this.tree;
+ for (const part of parts(rel)) {
+ if (!(node instanceof Map)) return null;
+ node = node.get(part);
+ if (node === undefined) return null;
+ }
+ return node;
+ }
+ /** File content at `rel`, or null. */
+ read(rel) {
+ const node = this.lookup(rel);
+ return node instanceof FileNode ? node.data() : null;
+ }
+ /** [dirMap, name] for `rel`, or null when the parent path is missing. */
+ parent(rel) {
+ const p = parts(rel);
+ if (p.length === 0) return null;
+ const dir = this.lookup(p.slice(0, -1).join("/"));
+ return dir instanceof Map ? [dir, p[p.length - 1]] : null;
+ }
+ /** Yield [path, FileNode] for every file under `rel` (default: all). */
+ *files(rel = "") {
+ const start = this.lookup(rel);
+ if (!(start instanceof Map)) return;
+ const stack = [[rel, start]];
+ while (stack.length) {
+ const [prefix, dir] = stack.pop();
+ for (const [name, node] of dir) {
+ const path = prefix ? `${prefix}/${name}` : name;
+ if (node instanceof Map) stack.push([path, node]);
+ else yield [path, node];
+ }
+ }
+ }
+}
+
+export class WasiShim {
+ constructor(fsys) {
+ this.fsys = fsys;
+ this.memory = null;
+ // fd table: 0/1/2 stdio, 3 = the preopen dir, others opened nodes.
+ this.fds = new Map([[3, { node: fsys.tree, path: "" }]]);
+ this.nextFd = 4;
+ this.lines = ["", ""]; // buffered stdout/stderr up to newline
+ }
+
+ start(instance) {
+ this.memory = instance.exports.memory;
+ instance.exports._initialize();
+ }
+
+ view() { return new DataView(this.memory.buffer); }
+ bytes() { return new Uint8Array(this.memory.buffer); }
+ str(ptr, len) { return new TextDecoder().decode(this.bytes().subarray(ptr, ptr + len)); }
+
+ // The tree path a (dirfd, path string) pair names, or null on a bad dirfd.
+ // Paths arrive relative to the dirfd OR absolute ("/enc/x" - Zig's std
+ // resolves some opens that way); an absolute path resolves against the
+ // preopen root, and one outside it stays unresolvable (NOENT at lookup).
+ at(dirfd, ptr, len) {
+ const dir = this.fds.get(dirfd);
+ if (!dir || dir.node instanceof FileNode) return null;
+ const p = this.str(ptr, len);
+ if (p.startsWith("/")) {
+ const root = this.fsys.root;
+ if (p === root || p.startsWith(root + "/")) return p.slice(root.length).replace(/^\/+/, "");
+ return p.replace(/^\/+/, "");
+ }
+ return (dir.path ? dir.path + "/" : "") + p;
+ }
+
+ filestat(buf, node) {
+ const d = this.view();
+ const file = node instanceof FileNode;
+ d.setBigUint64(buf, 0n, true); // dev
+ d.setBigUint64(buf + 8, 0n, true); // ino
+ d.setUint8(buf + 16, file ? FILETYPE.REGULAR : FILETYPE.DIR);
+ d.setBigUint64(buf + 24, 1n, true); // nlink
+ d.setBigUint64(buf + 32, BigInt(file ? node.len : 0), true); // size
+ d.setBigUint64(buf + 40, 0n, true); // atim
+ d.setBigUint64(buf + 48, 0n, true); // mtim
+ d.setBigUint64(buf + 56, 0n, true); // ctim
+ }
+
+ // Copy out of `node` at `pos` through an iovec list; returns bytes copied.
+ readv(node, pos, iovs, iovsLen) {
+ const d = this.view(), m = this.bytes(), data = node.data();
+ let total = 0;
+ for (let i = 0; i < iovsLen; i++) {
+ const buf = d.getUint32(iovs + 8 * i, true);
+ const len = d.getUint32(iovs + 8 * i + 4, true);
+ const n = Math.min(len, data.length - pos);
+ if (n <= 0) break;
+ m.set(data.subarray(pos, pos + n), buf);
+ pos += n; total += n;
+ }
+ return total;
+ }
+
+ // Write into `node` at `pos` from an iovec list; returns bytes written.
+ writev(node, pos, iovs, iovsLen) {
+ const d = this.view(), m = this.bytes();
+ let total = 0;
+ for (let i = 0; i < iovsLen; i++) {
+ const buf = d.getUint32(iovs + 8 * i, true);
+ const len = d.getUint32(iovs + 8 * i + 4, true);
+ node.write(pos, m.subarray(buf, buf + len));
+ pos += len; total += len;
+ }
+ return total;
+ }
+
+ imports() {
+ const nosys = () => E.NOSYS;
+ const shim = this;
+ const file = (fd) => {
+ const f = shim.fds.get(fd);
+ return f && f.node instanceof FileNode ? f : null;
+ };
+ // wasm i32 arguments arrive SIGNED: past 2 GiB of linear memory every
+ // pointer looks negative. Mask every numeric argument back to u32 before
+ // an op touches memory (BigInt i64 arguments pass through untouched).
+ const mask = (fn) => (...a) => fn(...a.map((x) => (typeof x === "number" ? x >>> 0 : x)));
+ const masked = (ops) => Object.fromEntries(Object.entries(ops).map(([k, f]) => [k, mask(f)]));
+ return {
+ wasi_snapshot_preview1: masked({
+ environ_sizes_get: (count, size) => {
+ shim.view().setUint32(count, 0, true);
+ shim.view().setUint32(size, 0, true);
+ return E.SUCCESS;
+ },
+ environ_get: () => E.SUCCESS,
+ clock_res_get: (_id, out) => {
+ shim.view().setBigUint64(out, 1000n, true);
+ return E.SUCCESS;
+ },
+ clock_time_get: (_id, _prec, out) => {
+ shim.view().setBigUint64(out, BigInt(Date.now()) * 1000000n, true);
+ return E.SUCCESS;
+ },
+ random_get: (buf, len) => {
+ const m = shim.bytes();
+ for (let off = 0; off < len; off += 65536)
+ crypto.getRandomValues(m.subarray(buf + off, buf + Math.min(len, off + 65536)));
+ return E.SUCCESS;
+ },
+ proc_exit: (code) => { throw new Error(`proc_exit(${code})`); },
+
+ fd_write: (fd, iovs, iovsLen, nwritten) => {
+ const d = shim.view();
+ if (fd === 1 || fd === 2) {
+ let total = 0, text = "";
+ for (let i = 0; i < iovsLen; i++) {
+ const buf = d.getUint32(iovs + 8 * i, true);
+ const len = d.getUint32(iovs + 8 * i + 4, true);
+ text += shim.str(buf, len);
+ total += len;
+ }
+ const slot = fd - 1;
+ shim.lines[slot] += text;
+ for (let nl; (nl = shim.lines[slot].indexOf("\n")) !== -1; ) {
+ (fd === 2 ? console.error : console.log)(shim.lines[slot].slice(0, nl));
+ shim.lines[slot] = shim.lines[slot].slice(nl + 1);
+ }
+ d.setUint32(nwritten, total, true);
+ return E.SUCCESS;
+ }
+ const f = file(fd);
+ if (!f) return E.BADF;
+ const n = shim.writev(f.node, f.pos, iovs, iovsLen);
+ f.pos += n;
+ d.setUint32(nwritten, n, true);
+ return E.SUCCESS;
+ },
+ fd_pwrite: (fd, iovs, iovsLen, offset, nwritten) => {
+ const f = file(fd);
+ if (!f) return E.BADF;
+ const n = shim.writev(f.node, Number(offset), iovs, iovsLen);
+ shim.view().setUint32(nwritten, n, true);
+ return E.SUCCESS;
+ },
+
+ fd_prestat_get: (fd, buf) => {
+ if (fd !== 3) return E.BADF;
+ const name = new TextEncoder().encode(shim.fsys.root);
+ shim.view().setUint8(buf, 0); // preopen dir
+ shim.view().setUint32(buf + 4, name.length, true);
+ return E.SUCCESS;
+ },
+ fd_prestat_dir_name: (fd, path, len) => {
+ if (fd !== 3) return E.BADF;
+ const name = new TextEncoder().encode(shim.fsys.root);
+ shim.bytes().set(name.subarray(0, len), path);
+ return E.SUCCESS;
+ },
+
+ path_open: (dirfd, _dirflags, path, pathLen, oflags, _rb, _ri, _fdflags, outFd) => {
+ const rel = shim.at(dirfd, path, pathLen);
+ if (rel === null) return E.BADF;
+ let node = shim.fsys.lookup(rel);
+ if (node !== null && oflags & 0b100) return E.EXIST; // O_EXCL
+ if (node === null) {
+ if (!(oflags & 0b1)) return E.NOENT; // no O_CREAT
+ const at = shim.fsys.parent(rel);
+ if (!at) return E.NOENT;
+ node = new FileNode();
+ at[0].set(at[1], node);
+ }
+ if (oflags & 0b10 && node instanceof FileNode) return E.NOTDIR; // O_DIRECTORY
+ if (oflags & 0b1000 && node instanceof FileNode) node.truncate(0); // O_TRUNC
+ const fd = shim.nextFd++;
+ shim.fds.set(fd, { node, path: rel, pos: 0 });
+ shim.view().setUint32(outFd, fd, true);
+ return E.SUCCESS;
+ },
+ fd_close: (fd) => (shim.fds.delete(fd) ? E.SUCCESS : E.BADF),
+
+ fd_read: (fd, iovs, iovsLen, nread) => {
+ const f = file(fd);
+ if (!f) return E.BADF;
+ const n = shim.readv(f.node, f.pos, iovs, iovsLen);
+ f.pos += n;
+ shim.view().setUint32(nread, n, true);
+ return E.SUCCESS;
+ },
+ fd_pread: (fd, iovs, iovsLen, offset, nread) => {
+ const f = file(fd);
+ if (!f) return E.BADF;
+ const n = shim.readv(f.node, Number(offset), iovs, iovsLen);
+ shim.view().setUint32(nread, n, true);
+ return E.SUCCESS;
+ },
+ fd_seek: (fd, offset, whence, out) => {
+ const f = file(fd);
+ if (!f) return E.BADF;
+ const base = whence === 0 ? 0 : whence === 1 ? f.pos : f.node.len;
+ const pos = base + Number(offset);
+ if (pos < 0) return E.INVAL;
+ f.pos = pos;
+ shim.view().setBigUint64(out, BigInt(pos), true);
+ return E.SUCCESS;
+ },
+
+ fd_filestat_get: (fd, buf) => {
+ const f = shim.fds.get(fd);
+ if (!f) return E.BADF;
+ shim.filestat(buf, f.node);
+ return E.SUCCESS;
+ },
+ fd_filestat_set_size: (fd, size) => {
+ const f = file(fd);
+ if (!f) return E.BADF;
+ f.node.truncate(Number(size));
+ return E.SUCCESS;
+ },
+ fd_fdstat_get: (fd, buf) => {
+ const f = shim.fds.get(fd);
+ const d = shim.view();
+ if (fd <= 2) {
+ d.setUint8(buf, 2); // character device
+ } else if (f) {
+ d.setUint8(buf, f.node instanceof FileNode ? FILETYPE.REGULAR : FILETYPE.DIR);
+ } else return E.BADF;
+ d.setUint16(buf + 2, 0, true);
+ d.setBigUint64(buf + 8, 0xffffffffffffffffn, true); // all rights
+ d.setBigUint64(buf + 16, 0xffffffffffffffffn, true);
+ return E.SUCCESS;
+ },
+ path_filestat_get: (dirfd, _flags, path, pathLen, buf) => {
+ const rel = shim.at(dirfd, path, pathLen);
+ if (rel === null) return E.BADF;
+ const node = shim.fsys.lookup(rel);
+ if (node === null) return E.NOENT;
+ shim.filestat(buf, node);
+ return E.SUCCESS;
+ },
+
+ fd_readdir: (fd, buf, bufLen, cookie, used) => {
+ const f = shim.fds.get(fd);
+ if (!f) return E.BADF;
+ if (f.node instanceof FileNode) return E.NOTDIR;
+ const names = [...f.node.keys()];
+ const d = shim.view(), m = shim.bytes();
+ let off = 0;
+ for (let i = Number(cookie); i < names.length; i++) {
+ const name = new TextEncoder().encode(names[i]);
+ const need = 24 + name.length;
+ if (off + need > bufLen) { off = bufLen; break; } // truncated: host retries
+ d.setBigUint64(buf + off, BigInt(i + 1), true); // d_next
+ d.setBigUint64(buf + off + 8, 0n, true); // d_ino
+ d.setUint32(buf + off + 16, name.length, true);
+ d.setUint8(buf + off + 20, f.node.get(names[i]) instanceof FileNode ? FILETYPE.REGULAR : FILETYPE.DIR);
+ m.set(name, buf + off + 24);
+ off += need;
+ }
+ d.setUint32(used, off, true);
+ return E.SUCCESS;
+ },
+
+ path_create_directory: (dirfd, path, pathLen) => {
+ const rel = shim.at(dirfd, path, pathLen);
+ if (rel === null) return E.BADF;
+ if (shim.fsys.lookup(rel) !== null) return E.EXIST;
+ const at = shim.fsys.parent(rel);
+ if (!at) return E.NOENT;
+ at[0].set(at[1], new Map());
+ return E.SUCCESS;
+ },
+ path_rename: (dirfd, path, pathLen, newDirfd, newPath, newPathLen) => {
+ const from = shim.at(dirfd, path, pathLen);
+ const to = shim.at(newDirfd, newPath, newPathLen);
+ if (from === null || to === null) return E.BADF;
+ const src = shim.fsys.parent(from), dst = shim.fsys.parent(to);
+ if (!src || !dst || !src[0].has(src[1])) return E.NOENT;
+ dst[0].set(dst[1], src[0].get(src[1]));
+ src[0].delete(src[1]);
+ return E.SUCCESS;
+ },
+ path_unlink_file: (dirfd, path, pathLen) => {
+ const rel = shim.at(dirfd, path, pathLen);
+ if (rel === null) return E.BADF;
+ const at = shim.fsys.parent(rel);
+ if (!at || !at[0].has(at[1])) return E.NOENT;
+ if (at[0].get(at[1]) instanceof Map) return E.ISDIR;
+ at[0].delete(at[1]);
+ return E.SUCCESS;
+ },
+ path_remove_directory: (dirfd, path, pathLen) => {
+ const rel = shim.at(dirfd, path, pathLen);
+ if (rel === null) return E.BADF;
+ const at = shim.fsys.parent(rel);
+ if (!at || !at[0].has(at[1])) return E.NOENT;
+ const node = at[0].get(at[1]);
+ if (!(node instanceof Map)) return E.NOTDIR;
+ if (node.size !== 0) return E.NOTEMPTY;
+ at[0].delete(at[1]);
+ return E.SUCCESS;
+ },
+
+ // Timestamps are not kept; syncing memory is a no-op.
+ fd_filestat_set_times: () => E.SUCCESS,
+ path_filestat_set_times: () => E.SUCCESS,
+ fd_sync: () => E.SUCCESS,
+ fd_fdstat_set_flags: () => E.SUCCESS,
+ fd_renumber: nosys,
+ path_link: nosys,
+ path_readlink: nosys,
+ path_symlink: nosys,
+ poll_oneoff: nosys,
+ }),
+ };
+ }
+}
diff --git a/bindings/js/worker-rpc.mjs b/bindings/js/worker-rpc.mjs
new file mode 100644
index 00000000..abaf727a
--- /dev/null
+++ b/bindings/js/worker-rpc.mjs
@@ -0,0 +1,20 @@
+// Promise RPC over a Worker running engine-worker.mjs: {id, op, args} out,
+// {id, ok, result | error} back. One shared helper so the page's primary
+// engine and the bake pool speak the same protocol.
+export function makeRpc(worker) {
+ const inflight = new Map();
+ let nextId = 0;
+ worker.onmessage = (e) => {
+ const { id, ok, result, error } = e.data;
+ const p = inflight.get(id);
+ inflight.delete(id);
+ if (ok) p.resolve(result);
+ else p.reject(new Error(error));
+ };
+ return (op, args, transfer = []) =>
+ new Promise((resolve, reject) => {
+ const id = nextId++;
+ inflight.set(id, { resolve, reject });
+ worker.postMessage({ id, op, args }, transfer);
+ });
+}
diff --git a/bindings/parity/parity.zig b/bindings/parity/parity.zig
index aff1b196..abd0fe13 100644
--- a/bindings/parity/parity.zig
+++ b/bindings/parity/parity.zig
@@ -1,6 +1,6 @@
//! style-parity — the NATIVE oracle for the wasm style engine.
//!
-//! It embeds the SAME template + colortables as bindings/wasm/style_wasm.zig and
+//! It embeds the SAME template + colortables as bindings/js/style_wasm.zig and
//! drives the SAME `the mariner builders` through the SAME shared settings
//! parser — only the compilation target differs (native vs wasm32). So a diff of
//! this tool's output against the wasm/JS output for identical settings + now_unix
diff --git a/bindings/scripts/gen-assets.sh b/bindings/scripts/gen-assets.sh
index bda39ed2..be13f6bd 100755
--- a/bindings/scripts/gen-assets.sh
+++ b/bindings/scripts/gen-assets.sh
@@ -2,7 +2,7 @@
# Regenerate the embedded base template + S-52 colortables for the wasm style
# engine. Run this whenever the catalogue or the style/colortable generators
# change. Outputs are committed (so the npm package is self-contained) into
-# bindings/wasm/assets/. They are then @embedFile'd by build.zig into the wasm.
+# bindings/js/assets/. They are then @embedFile'd by build.zig into the wasm.
#
# The template is the output of `tile57 style` (assets.styleJson) — the base style
# that chartstyle.buildStyle then PATCHES with the mariner settings. We generate it
@@ -12,7 +12,7 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
export PATH="$HOME/.local/bin:$PATH"
-ASSETS="$ROOT/bindings/wasm/assets"
+ASSETS="$ROOT/bindings/js/assets"
BAKE="$ROOT/zig-out/bin/tile57"
[[ -x "$BAKE" ]] || ( cd "$ROOT" && zig build )
diff --git a/bindings/scripts/parity-check.sh b/bindings/scripts/parity-check.sh
index 43c5d8c0..9d93521f 100755
--- a/bindings/scripts/parity-check.sh
+++ b/bindings/scripts/parity-check.sh
@@ -2,7 +2,7 @@
# Parity check: for several mariner-setting combinations, generate the MapLibre
# style.json via BOTH backends and assert they are byte-identical:
# - native: zig-out/bin/style-parity (chartstyle.buildStyle, native target)
-# - wasm/JS: bindings/js/index.js (chartstyle.buildStyle, wasm32 target)
+# - wasm/JS: bindings/js/style.js (chartstyle.buildStyle, wasm32 target)
# Both embed the SAME template + colortables and share the SAME settings parser
# (bindings/shared/settings.zig), so any difference is a real backend divergence.
#
@@ -35,7 +35,7 @@ for c in "${cases[@]}"; do
echo "$c" > "$TMP/settings.json"
"$ROOT/zig-out/bin/style-parity" "$TMP/settings.json" "$NOW" "$TMP/native.json" >/dev/null
node --input-type=module -e '
- import { loadStyleEngine } from "'"$ROOT"'/bindings/js/index.js";
+ import { loadStyleEngine } from "'"$ROOT"'/bindings/js/style.js";
import { readFileSync, writeFileSync } from "node:fs";
const s = JSON.parse(readFileSync("'"$TMP"'/settings.json","utf8"));
const engine = await loadStyleEngine();
diff --git a/bindings/shared/settings.zig b/bindings/shared/settings.zig
index 1d492f63..fedf1a63 100644
--- a/bindings/shared/settings.zig
+++ b/bindings/shared/settings.zig
@@ -1,6 +1,6 @@
//! settings — parse a mariner-settings JSON blob into `mariner.Settings`.
//!
-//! Shared by the wasm entry point (bindings/wasm/style_wasm.zig) and the native
+//! Shared by the wasm entry point (bindings/js/style_wasm.zig) and the native
//! parity harness (bindings/parity/parity.zig) so the two CANNOT drift: a parity
//! diff then exercises the identical settings->buildStyle path on both targets.
//!
diff --git a/build.zig b/build.zig
index 3813445c..a495bf58 100644
--- a/build.zig
+++ b/build.zig
@@ -49,15 +49,41 @@ fn addSysrootIncludes(b: *std.Build, mod: *std.Build.Module) void {
mod.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ sysroot, "usr/include" }) });
}
-fn addTess(b: *std.Build, mod: *std.Build.Module) void {
+// setjmp/longjmp on wasm: the exception-handling feature + clang's sjlj
+// lowering pass, PER FILE. wasm-use-legacy-eh=false emits the STANDARDIZED
+// instructions (try_table over exnref — browsers deprecate the legacy `try`),
+// which also need the reference-types feature. The raw -Xclang pairs
+// re-enable both features at the cc1 level — zig appends its own disabling
+// `-target-feature` flags (derived from the module target, which keeps
+// default features so Zig's wasi-libc build never sees them and never
+// compiles its broken sjlj runtime) after the driver-level -m flags, and the
+// LAST cc1 flag wins; raw -Xclang pairs in the file flags land after zig's
+// and win the features back. The driver-level -m flags still matter: they
+// define __wasm_exception_handling__, which wasi's setjmp.h gates on.
+const wasm_sjlj_flags = [_][]const u8{
+ "-mexception-handling", "-mreference-types",
+ "-mllvm", "-wasm-enable-sjlj",
+ "-mllvm", "-wasm-use-legacy-eh=false",
+ "-Xclang", "-target-feature",
+ "-Xclang", "+exception-handling",
+ "-Xclang", "-target-feature",
+ "-Xclang", "+reference-types",
+};
+
+// `wasm`: sweep.c/tess.c bail out of the tessellation on OOM via
+// setjmp/longjmp, which wasm only has through the sjlj lowering above.
+fn addTess(b: *std.Build, mod: *std.Build.Module, wasm: bool) void {
mod.link_libc = true; // libtess2 uses assert.h/stdio.h/stdlib.h
addSysrootIncludes(b, mod);
mod.addIncludePath(b.path("vendor/libtess2/Include"));
mod.addIncludePath(b.path("vendor/libtess2/Source"));
+ var flags = std.ArrayList([]const u8).empty;
+ flags.appendSlice(b.allocator, &.{ "-std=gnu99", "-O2", "-fno-sanitize=undefined" }) catch @panic("OOM");
+ if (wasm) flags.appendSlice(b.allocator, &wasm_sjlj_flags) catch @panic("OOM");
mod.addCSourceFiles(.{
.root = b.path("vendor/libtess2/Source"),
.files = &tess_sources,
- .flags = &.{ "-std=gnu99", "-O2", "-fno-sanitize=undefined" },
+ .flags = flags.items,
});
}
@@ -87,18 +113,48 @@ fn addCatalogueJson(b: *std.Build, mod: *std.Build.Module) void {
// `posix`: define LUA_USE_POSIX (Unix). On Windows it must stay OFF — forcing it
// pulls in /dlopen; without it luaconf.h auto-selects LUA_USE_WINDOWS
// from _WIN32. lua_shim.c is already portable (only getenv + ANSI stdio).
-fn addLua(b: *std.Build, mod: *std.Build.Module, posix: bool, ios: bool) void {
+//
+// `wasm`: Lua's error path is setjmp/longjmp, and wasm has that only through
+// the exception-handling proposal. Compile every Lua object (and the vendored
+// sjlj runtime, src/portray/wasm_sjlj_rt.c) with the EH feature + clang's sjlj
+// lowering pass, PER FILE — the target's own feature set stays default, so
+// Zig's wasi-libc build never sees the feature (enabling it target-wide makes
+// zig 0.16 add wasi-libc's sjlj runtime to libc.a and crash compiling it).
+// wasi has no process spawn, so l_system is stubbed exactly as on iOS.
+const LuaTarget = struct { posix: bool = false, ios: bool = false, wasm: bool = false };
+fn addLua(b: *std.Build, mod: *std.Build.Module, lt: LuaTarget) void {
addSysrootIncludes(b, mod);
mod.addIncludePath(b.path("vendor/lua/src"));
- const shim_flags: []const []const u8 = if (posix) &.{ "-DLUA_USE_POSIX", "-fno-sanitize=undefined" } else &.{"-fno-sanitize=undefined"};
- mod.addCSourceFile(.{ .file = b.path("src/portray/lua_shim.c"), .flags = shim_flags });
+ var shim_flags = std.ArrayList([]const u8).empty;
+ shim_flags.append(b.allocator, "-fno-sanitize=undefined") catch @panic("OOM");
+ if (lt.posix) shim_flags.append(b.allocator, "-DLUA_USE_POSIX") catch @panic("OOM");
+ mod.addCSourceFile(.{ .file = b.path("src/portray/lua_shim.c"), .flags = shim_flags.items });
var lua_flags = std.ArrayList([]const u8).empty;
lua_flags.appendSlice(b.allocator, &.{ "-std=gnu99", "-O2", "-fno-sanitize=undefined" }) catch @panic("OOM");
- if (posix) lua_flags.append(b.allocator, "-DLUA_USE_POSIX") catch @panic("OOM");
- // iOS forbids system(3) (marked unavailable in the SDK). Stub loslib's
- // l_system hook to "no shell": os.execute() reports no shell available,
- // os.execute(cmd) fails — nothing in the portrayal path shells out anyway.
- if (ios) lua_flags.append(b.allocator, "-Dl_system(cmd)=((cmd)==0?0:-1)") catch @panic("OOM");
+ if (lt.posix) lua_flags.append(b.allocator, "-DLUA_USE_POSIX") catch @panic("OOM");
+ // iOS forbids system(3) (marked unavailable in the SDK); wasi has no
+ // process spawn at all. Stub loslib's l_system hook to "no shell":
+ // os.execute() reports no shell available, os.execute(cmd) fails —
+ // nothing in the portrayal path shells out anyway.
+ if (lt.ios or lt.wasm) lua_flags.append(b.allocator, "-Dl_system(cmd)=((cmd)==0?0:-1)") catch @panic("OOM");
+ if (lt.wasm) {
+ lua_flags.appendSlice(b.allocator, &wasm_sjlj_flags) catch @panic("OOM");
+ // lstate.h includes for sig_atomic_t (the debug-hook trap
+ // flags). wasi's signal.h is gated; the emulation define provides the
+ // types, and nothing in the embedded Lua raises a signal.
+ lua_flags.append(b.allocator, "-D_WASI_EMULATED_SIGNAL") catch @panic("OOM");
+ // wasi has no tmpnam: stub loslib's hook so os.tmpname raises a clean
+ // Lua error. Nothing in the portrayal path names temp files.
+ lua_flags.append(b.allocator, "-DLUA_TMPNAMBUFSIZE=32") catch @panic("OOM");
+ lua_flags.append(b.allocator, "-Dlua_tmpnam(b,e)={(void)(b);(e)=1;}") catch @panic("OOM");
+ // os.clock uses clock(3); wasi emulates it over the wall clock. The
+ // ROOT wasm module links the emulated lib (linkSystemLibrary needs a
+ // module with a known target; this one is target-agnostic).
+ lua_flags.append(b.allocator, "-D_WASI_EMULATED_PROCESS_CLOCKS") catch @panic("OOM");
+ mod.addCSourceFile(.{ .file = b.path("src/portray/wasm_sjlj_rt.c"), .flags = &wasm_sjlj_flags });
+ // Libc definitions wasi-libc declares but does not ship (tmpfile).
+ mod.addCSourceFile(.{ .file = b.path("src/portray/wasi_stubs.c"), .flags = &.{"-fno-sanitize=undefined"} });
+ }
mod.addCSourceFiles(.{
.root = b.path("vendor/lua/src"),
.files = &lua_sources,
@@ -123,7 +179,11 @@ fn addSvgRaster(b: *std.Build, mod: *std.Build.Module) void {
// deprecated surface. THREADSAFE=1 (serialized) because a host streams tiles
// from a worker while its UI thread reads metadata, and a per-call mutex is
// nothing beside a JPEG decode.
-fn addSqlite(b: *std.Build, mod: *std.Build.Module) void {
+// `wasm`: SQLite carries native wasi support (SQLITE_WASI, set from __wasi__),
+// but our explicit THREADSAFE=1 would override its single-thread default and
+// pull in pthread symbols wasi-libc does not have — so it drops to 0 there
+// (the wasm engine is single-threaded end to end).
+fn addSqlite(b: *std.Build, mod: *std.Build.Module, wasm: bool) void {
addSysrootIncludes(b, mod);
mod.addIncludePath(b.path("vendor/sqlite"));
mod.addCSourceFile(.{
@@ -132,7 +192,7 @@ fn addSqlite(b: *std.Build, mod: *std.Build.Module) void {
"-std=gnu99",
"-O2",
"-fno-sanitize=undefined",
- "-DSQLITE_THREADSAFE=1",
+ if (wasm) "-DSQLITE_THREADSAFE=0" else "-DSQLITE_THREADSAFE=1",
"-DSQLITE_DQS=0",
"-DSQLITE_DEFAULT_MEMSTATUS=0",
"-DSQLITE_OMIT_LOAD_EXTENSION",
@@ -366,7 +426,7 @@ pub fn build(b: *std.Build) void {
}
}.f;
addFont(b, render_mod);
- addTess(b, render_mod);
+ addTess(b, render_mod, false);
// Integer computational geometry (src/geometry/): the Martinez polygon boolean +
// the coverage-clipped best-available partition. Pure (std-only); the scene
@@ -430,11 +490,14 @@ pub fn build(b: *std.Build) void {
.{ .name = "s101", .module = s101_mod },
},
});
- addLua(b, portray_mod, lua_posix, target.result.os.tag == .ios);
+ addLua(b, portray_mod, .{ .posix = lua_posix, .ios = target.result.os.tag == .ios });
// Embed the S-101 Lua rules (216 framework + feature-class files) so the Lua
// `require` searcher in lua_shim.c can load them from memory — tile57 portrays
// S-57 cells with no on-disk catalogue. An explicit rules dir still overrides.
- portray_mod.addImport("rules_registry", embedDir(catalog.b, "rules_registry", catalog.b.pathJoin(&.{ catalog.root, "Rules" }), ".lua"));
+ // ONE registry module, shared with the wasm portray variant below (a second
+ // embedDir for the same dir would make a second same-named module).
+ const rules_registry = embedDir(catalog.b, "rules_registry", catalog.b.pathJoin(&.{ catalog.root, "Rules" }), ".lua");
+ portray_mod.addImport("rules_registry", rules_registry);
// MapLibre style generation (src/style/): color tables, line styles, the
// style.json layer set (maplibre.zig), and the S-52 mariner settings model +
@@ -480,7 +543,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "s57", .module = s57_mod },
},
});
- addSqlite(b, raster_mod);
+ addSqlite(b, raster_mod, false);
// All pure packages, imported by name into engine / libtile57.a / the baker.
// (portray is libc, wired separately into the lib + baker only.)
@@ -612,13 +675,15 @@ pub fn build(b: *std.Build) void {
// The engine's own git commit, embedded so the RUNTIME can state which
// engine a process actually linked (tile57_warmup logs it once): build
// provenance that survives any amount of checkout / link confusion.
- {
+ // One options module, shared with the wasm engine build below.
+ const buildinfo_mod = blk: {
const buildinfo = b.addOptions();
var code: u8 = 0;
const raw = b.runAllowFail(&.{ "git", "describe", "--always", "--dirty" }, &code, .ignore) catch "unknown";
buildinfo.addOption([]const u8, "commit", std.mem.trim(u8, raw, " \n\r\t"));
- lib_mod.addImport("buildinfo", buildinfo.createModule());
- }
+ break :blk buildinfo.createModule();
+ };
+ lib_mod.addImport("buildinfo", buildinfo_mod);
const lib = b.addLibrary(.{ .name = "tile57", .linkage = .static, .root_module = lib_mod });
// Android cross-compile: point the C deps at the NDK sysroot (see -Dandroid-ndk).
if (android_libc) |libc| lib.setLibCFile(libc);
@@ -761,14 +826,14 @@ pub fn build(b: *std.Build) void {
// Attach the embedded template + colortables to a bindings consumer module.
const addStyleAssets = struct {
fn f(bb: *std.Build, m: *std.Build.Module) void {
- m.addAnonymousImport("template_json", .{ .root_source_file = bb.path("bindings/wasm/assets/template.json") });
- m.addAnonymousImport("colortables_json", .{ .root_source_file = bb.path("bindings/wasm/assets/colortables.json") });
+ m.addAnonymousImport("template_json", .{ .root_source_file = bb.path("bindings/js/assets/template.json") });
+ m.addAnonymousImport("colortables_json", .{ .root_source_file = bb.path("bindings/js/assets/colortables.json") });
}
}.f;
const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding });
const wasm_mod = b.createModule(.{
- .root_source_file = b.path("bindings/wasm/style_wasm.zig"),
+ .root_source_file = b.path("bindings/js/style_wasm.zig"),
.target = wasm_target,
.optimize = .ReleaseSmall, // smallest wasm; this isn't a hot path
.imports = &.{
@@ -783,6 +848,142 @@ pub fn build(b: *std.Build) void {
const wasm_step = b.step("wasm", "Build the wasm style engine (bindings/)");
wasm_step.dependOn(&b.addInstallArtifact(wasm, .{}).step);
+ // ---- Full-engine wasm (wasm32-wasi reactor) -----------------------------
+ //
+ // The complete C ABI — bake, chart, compose, style, raster — as ONE wasm
+ // module (`zig build wasm-engine`), so a browser chartplotter can bake
+ // charts and serve tiles with no server. wasm32-wasi-musl: the C deps
+ // (Lua, SQLite, libtess2, nanosvg/stb) need a libc, and Zig bundles
+ // wasi-libc for this target; the JS host supplies the small WASI import
+ // set. Reactor model: no _start — the host calls _initialize once, then
+ // the tile57_* exports (rdynamic puts every `export fn` in the export
+ // table). Single-threaded end to end: the thread users (bake_enc
+ // parallelFor, the capi raster workers, the pmtiles reader lock) all gate
+ // on builtin.single_threaded and run serial here.
+ //
+ // portray, raster, and render get their own module instances: their C
+ // flags differ on wasm (Lua and libtess2 need the sjlj lowering, SQLite
+ // drops to THREADSAFE=0), and the native portray/raster carry pic=true,
+ // which wasm must not. scene + sprite fork only to point at the wasm
+ // render. The pure packages and the embedded registries are the SAME
+ // singletons the native artifacts use.
+ const wasi_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .wasi, .abi = .musl });
+ const portray_wasm = b.createModule(.{
+ .root_source_file = b.path("src/portray/portray.zig"),
+ .link_libc = true,
+ .imports = &.{
+ .{ .name = "s57", .module = s57_mod },
+ .{ .name = "s101", .module = s101_mod },
+ },
+ });
+ addLua(b, portray_wasm, .{ .wasm = true });
+ portray_wasm.addImport("rules_registry", rules_registry);
+
+ const raster_wasm = b.createModule(.{
+ .root_source_file = b.path("src/raster/raster.zig"),
+ .link_libc = true,
+ .imports = &.{
+ .{ .name = "tiles", .module = tiles_mod },
+ .{ .name = "coverage", .module = coverage_mod },
+ .{ .name = "s57", .module = s57_mod },
+ },
+ });
+ addSqlite(b, raster_wasm, true);
+
+ const render_wasm = b.createModule(.{
+ .root_source_file = b.path("src/render/render.zig"),
+ .imports = &.{
+ .{ .name = "tiles", .module = tiles_mod },
+ .{ .name = "style", .module = style_mod },
+ },
+ });
+ addFont(b, render_wasm);
+ addTess(b, render_wasm, true);
+
+ const scene_wasm = b.createModule(.{
+ .root_source_file = b.path("src/scene/scene.zig"),
+ .imports = &.{
+ .{ .name = "s57", .module = s57_mod },
+ .{ .name = "s101", .module = s101_mod },
+ .{ .name = "tiles", .module = tiles_mod },
+ .{ .name = "render", .module = render_wasm },
+ .{ .name = "geometry", .module = geometry_mod },
+ .{ .name = "coverage", .module = coverage_mod },
+ .{ .name = "style", .module = style_mod },
+ },
+ });
+
+ const sprite_wasm = b.createModule(.{
+ .root_source_file = b.path("src/sprite/sprite.zig"),
+ .link_libc = true,
+ .imports = &.{.{ .name = "render", .module = render_wasm }},
+ });
+ addSvgRaster(b, sprite_wasm);
+
+ // pure_pkgs with the render/scene edges swapped to the wasm instances.
+ const pure_pkgs_wasm = [_]std.Build.Module.Import{
+ .{ .name = "zipsrc", .module = zipsrc_mod },
+ .{ .name = "auxfiles", .module = auxfiles_mod },
+ .{ .name = "s57", .module = s57_mod },
+ .{ .name = "s101", .module = s101_mod },
+ .{ .name = "tiles", .module = tiles_mod },
+ .{ .name = "scene", .module = scene_wasm },
+ .{ .name = "render", .module = render_wasm },
+ .{ .name = "style", .module = style_mod },
+ .{ .name = "geometry", .module = geometry_mod },
+ };
+
+ const engine_full_wasm = b.createModule(.{
+ .root_source_file = b.path("src/bake_root.zig"),
+ .link_libc = true,
+ });
+ addPkgs(engine_full_wasm, &pure_pkgs_wasm);
+ engine_full_wasm.addImport("portray", portray_wasm);
+
+ const bundle_wasm = b.createModule(.{
+ .root_source_file = b.path("src/bundle.zig"),
+ .link_libc = true,
+ .imports = &.{
+ .{ .name = "engine", .module = engine_full_wasm },
+ .{ .name = "style", .module = style_mod },
+ .{ .name = "sprite", .module = sprite_wasm },
+ .{ .name = "catalog", .module = catalog_embed },
+ .{ .name = "compose", .module = compose_mod },
+ },
+ });
+
+ const engine_wasm_mod = b.createModule(.{
+ .root_source_file = b.path("src/wasm_root.zig"),
+ .target = wasi_target,
+ .optimize = optimize,
+ .single_threaded = true,
+ .link_libc = true,
+ });
+ addPkgs(engine_wasm_mod, &pure_pkgs_wasm);
+ engine_wasm_mod.addImport("portray", portray_wasm);
+ engine_wasm_mod.addImport("sprite", sprite_wasm);
+ engine_wasm_mod.addImport("bundle", bundle_wasm);
+ engine_wasm_mod.addImport("compose", compose_mod);
+ engine_wasm_mod.addImport("coverage", coverage_mod);
+ engine_wasm_mod.addImport("errors", errors_mod);
+ engine_wasm_mod.addImport("raster", raster_wasm);
+ engine_wasm_mod.addImport("engine", engine_full_wasm);
+ engine_wasm_mod.addImport("colorprofile_registry", colorprofile_registry);
+ engine_wasm_mod.addImport("catalog", catalog_embed);
+ engine_wasm_mod.addImport("buildinfo", buildinfo_mod);
+ // Lua's os.clock: clock(3) lives in wasi-libc's emulated process-clocks
+ // lib (addLua defines _WASI_EMULATED_PROCESS_CLOCKS on the Lua objects).
+ engine_wasm_mod.linkSystemLibrary("wasi-emulated-process-clocks", .{});
+
+ const engine_wasm = b.addExecutable(.{ .name = "tile57-engine", .root_module = engine_wasm_mod });
+ engine_wasm.wasi_exec_model = .reactor;
+ engine_wasm.rdynamic = true; // export the `export fn`s into the wasm export table
+ // A chart render works down a deep call stack (portrayal -> scene ->
+ // tessellation); the wasm default (1 MB) is not enough headroom.
+ engine_wasm.stack_size = 32 * 1024 * 1024;
+ const engine_wasm_step = b.step("wasm-engine", "Build the full-engine wasm reactor (bindings/)");
+ engine_wasm_step.dependOn(&b.addInstallArtifact(engine_wasm, .{}).step);
+
// Native parity oracle: same engine + same template/colortables/settings,
// native target. `zig build style-parity` builds it; the parity script diffs
// its output against the wasm/JS output.
@@ -839,7 +1040,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "s57", .module = s57_mod },
});
raster_test.link_libc = true;
- addSqlite(b, raster_test);
+ addSqlite(b, raster_test, false);
_ = addPkgTest(b, test_step, "src/scene/scene.zig", target, optimize, &.{
.{ .name = "s57", .module = s57_mod },
.{ .name = "s101", .module = s101_mod },
@@ -903,7 +1104,7 @@ pub fn build(b: *std.Build) void {
.{ .name = "style", .module = style_mod },
});
addFont(b, render_test);
- addTess(b, render_test);
+ addTess(b, render_test, false);
// Golden portrayal-instruction test (assertion #5): drives the real embedded Lua
// rules end-to-end. It rides its own artifact because `portray` links libc + Lua +
// the rule registry (those settings + C sources propagate from portray_mod), unlike
diff --git a/docs/.gitignore b/docs/.gitignore
index 4bbd4fe6..1160dd7f 100644
--- a/docs/.gitignore
+++ b/docs/.gitignore
@@ -8,6 +8,10 @@
.docusaurus
.cache-loader
+# The wasm demo app is STAGED here by .github/workflows/docs.yml (and by hand
+# for local testing) — the engine wasm is a build artifact, never committed.
+/static/demo-app
+
# Misc
.DS_Store
.env.local
diff --git a/docs/docs/img/wasm-chartplotter.webp b/docs/docs/img/wasm-chartplotter.webp
new file mode 100644
index 00000000..2715c966
Binary files /dev/null and b/docs/docs/img/wasm-chartplotter.webp differ
diff --git a/docs/docs/wasm.md b/docs/docs/wasm.md
new file mode 100644
index 00000000..29fb16a5
--- /dev/null
+++ b/docs/docs/wasm.md
@@ -0,0 +1,144 @@
+---
+id: wasm
+title: WebAssembly
+sidebar_position: 10
+---
+
+# WebAssembly
+
+Try it first: the [live demo](/demo) is the browser demo below, embedded in
+these docs. Drop a NOAA ENC zip on it and the page bakes and renders the
+charts itself.
+
+The full engine compiles to one wasm module:
+
+```sh
+zig build wasm-engine
+# -> zig-out/bin/tile57-engine.wasm
+```
+
+The module carries the complete [C API](c-api.md) — bake, chart, compose,
+style, raster — plus the embedded Lua portrayal engine, the S-101 catalogue,
+and the label fonts. A JS host can bake charts and serve tiles fully
+client-side: a chartplotter with no server.
+
+## The host contract
+
+- **Target**: `wasm32-wasi`. The module imports only `wasi_snapshot_preview1`
+ functions. In node, the built-in `node:wasi` host provides them. In a
+ browser, `bindings/js/wasi-shim.mjs` provides them: a dependency-free shim
+ with an in-memory file tree for the source cells.
+- **Reactor model**: the module has no `_start`. Call the exported
+ `_initialize` once after instantiation, then call the `tile57_*` exports.
+- **Exception handling**: Lua and libtess2 keep their setjmp/longjmp error
+ paths through the wasm exception-handling instructions. The engine that runs
+ the module must implement the exception-handling proposal. All current
+ browsers and node do.
+- **Input buffers**: two wasm-only exports move bytes across the boundary.
+ `tile57_wasm_alloc(len)` returns an offset in linear memory; the host writes
+ input bytes there and passes the offset to a `tile57_*` call.
+ `tile57_wasm_free(ptr)` releases it. Engine *outputs* still go through
+ `tile57_free`, like every other host.
+
+## Differences from a native host
+
+- The engine is single-threaded. Calls that accept a `workers` count run
+ serial.
+- Open-by-path copies the file into linear memory. There is no mmap, so a
+ browser host with large chart libraries opens archives with
+ `tile57_chart_open_bytes` and keeps residency under its own control.
+- SQLite (raster charts) is built single-thread (`SQLITE_THREADSAFE=0`).
+
+## The JS package
+
+`bindings/js` is the JavaScript face of the engine: an npm-style package
+named `tile57` whose main export is the full engine. `tile57.mjs` wraps the
+exports one-to-one (linear-memory allocation, C strings, out-parameters, the
+`tile57_error` decode); `createEngine` in `index.mjs` stands one up in a
+browser or node; the worker, bake-pool, GPU-renderer, and chart-library
+modules are the pieces the demo composes. The style-only engine ships as the
+`tile57/style` subpath.
+
+## Smoke test
+
+`bindings/js/engine-smoke.mjs` drives the real pipeline under node's WASI
+host — bake S-57 cells, open the archives from bytes, compose them, fetch a
+vector tile, render a PNG view:
+
+```sh
+zig build wasm-engine
+node bindings/js/engine-smoke.mjs \
+ US5BDRAB/US5BDRAB.000 US5BDRBB/US5BDRBB.000 --png out.png
+```
+
+## WebGPU
+
+`bindings/js/gpu-renderer.mjs` renders the engine's draw-ready GPU scenes
+(`tile57_*_gpu_scene`) with WebGPU. Its WGSL is a port of the reference
+shaders in `shaders/` over the same vertex, quad, and uniform layouts; hold
+every change against them. The engine batches the ranges
+(`tile57_gpu_batch`), the renderer uploads the buffers once per scene, and a
+pan or zoom redraws from uniforms alone — the view stays live between scene
+rebuilds.
+
+## Browser demo
+
+`bindings/js/demo.html` is a complete in-page chartplotter, embedded in
+these docs as the [live demo](/demo) (the docs workflow builds the engine
+and stages the app; `src/pages/demo.jsx` frames it). Drop S-57 charts on it —
+`.000` cells with their update files, or an exchange-set `.zip` straight
+from [NOAA's ENC downloads](https://charts.noaa.gov/ENCs/ENCs.shtml). The
+charts never leave the page: baking and rendering happen in the browser.
+
+Drag to pan (a fast release flicks), wheel to zoom, double-click to zoom
+in; Shift-drag or a two-finger twist rotates the view and the compass
+control resets north-up; arrow keys pan and `+`/`-` zoom. Scenes build
+larger than the viewport (`?margin=K`), so a pan, zoom, or turn shows chart
+from the standing scene while a sharper one streams in. It renders with
+WebGPU where the browser has it, and falls back to PNG views (`?png=1`
+forces the fallback; the HUD names the reason when the fallback engages).
+WebGPU needs a secure context — `https://`, or `localhost` for a local
+server.
+
+The demo runs the engine in a Web Worker (`engine-worker.mjs`): a bake holds
+the CPU for seconds, and off the main thread the map and the loader stay
+live. The page drives one engine call per RPC message — a dropped zip is
+listed, then extracted and baked cell by cell, so the loader shows real
+per-cell progress.
+
+Batches bake in parallel: one wasm instance is single-threaded, so the page
+spins up a small pool of extra engine workers (`bake-pool.mjs`, sized from
+the machine's cores; `?workers=N` overrides) and fans the cells across them.
+The primary engine worker keeps the charts, the compositor, and rendering;
+pool slots only turn cell bytes into archive bytes, and close when the batch
+ends.
+
+Baked archives persist: each cell lands in the browser's origin-private file
+system as it finishes (`chart-library.mjs`, with a metadata sidecar), so a
+page load catalogs the library instead of re-baking, and the 🗑 control
+clears it. The engine keeps only the charts the current view needs resident:
+each rebuild picks the charts whose bounds intersect the view at a suitable
+compilation scale, opens them from the library, composes that subset, and
+evicts least-recently-used charts beyond a small cap — a whole district on
+disk stays a handful of charts in memory.
+
+Serve a directory that holds the page, the `.mjs` modules, and the engine:
+
+```sh
+zig build wasm-engine
+mkdir demo && cd demo
+ln -s ../bindings/js/{demo.html,demo,tile57.mjs,wasi-shim.mjs,gpu-renderer.mjs,engine-worker.mjs,worker-rpc.mjs,bake-pool.mjs,chart-library.mjs} .
+ln -s ../zig-out/bin/tile57-engine.wasm .
+python3 -m http.server 8080
+# open http://localhost:8080/demo.html and drop charts on it
+```
+
+`?cells=US5BDRAB/US5BDRAB.000` preloads cells from an `enc/` tree beside the
+page.
+
+## The style-only module
+
+`zig build wasm` still builds the separate, much smaller style engine
+(`style-engine.wasm`): only `tile57_style_build` for turning S-52 mariner
+settings into a MapLibre style.json, with no WASI dependency. A front-end that
+renders baked tiles itself needs only that module.
diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js
index 5e0b4a77..e43eab0a 100644
--- a/docs/docusaurus.config.js
+++ b/docs/docusaurus.config.js
@@ -50,6 +50,12 @@ const config = {
navbar: {
title: 'tile57',
items: [
+ {
+ // The wasm chartplotter, embedded by src/pages/demo.jsx.
+ to: '/demo',
+ label: 'Live Demo',
+ position: 'right',
+ },
{
to: '/contributing',
label: 'Contributing',
diff --git a/docs/src/pages/demo.jsx b/docs/src/pages/demo.jsx
new file mode 100644
index 00000000..b20ef8f8
--- /dev/null
+++ b/docs/src/pages/demo.jsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import Layout from '@theme/Layout';
+import useBaseUrl from '@docusaurus/useBaseUrl';
+
+// The wasm chartplotter (bindings/wasm/demo.html), embedded full-bleed under
+// the navbar. The app itself is staged by the docs workflow under
+// static/demo-app/ — the engine wasm plus the demo page — so this page only
+// frames it. Same origin, so drag-and-drop, the worker, and WebGPU all work
+// inside the frame; allowFullScreen lets its ⛶ button work.
+export default function Demo() {
+ return (
+
+
+
+ );
+}
diff --git a/src/capi.zig b/src/capi.zig
index 8c5b6156..cbdd1097 100644
--- a/src/capi.zig
+++ b/src/capi.zig
@@ -8,6 +8,7 @@
//! the opaque `tile57_compose` is a `*compose.ComposeSource`.
const std = @import("std");
+const builtin = @import("builtin");
const chart = @import("chart.zig");
const auxfiles = @import("engine").auxfiles; // via the named module: engine owns the file
const scene = @import("engine").scene; // tile surface + the complex-linestyle walk
@@ -62,7 +63,12 @@ fn sharedIo() std.Io {
// Wall-clock time for "today" date resolution in tile57_style_build. Zig 0.16
// keeps the clock behind Io; the lib links libc, so call time(3) directly.
-extern fn time(tloc: ?*c_long) callconv(.c) c_long;
+// std.c.time_t where the target defines it — wasi's is 64-bit while wasm32's
+// c_long is 32-bit, and wasm-ld rejects the signature mismatch against libc.
+// Windows leaves std.c.time_t void, so the binding keeps the c_long it always
+// used there (mingw maps time() onto its 64-bit variant itself).
+const CTimeT = if (std.c.time_t == void) c_long else std.c.time_t;
+extern fn time(tloc: ?*CTimeT) callconv(.c) CTimeT;
// Keep in sync with the TILE57_VERSION_* macros in tile57.h.
const version_string = "0.3.0";
@@ -557,10 +563,14 @@ export fn tile57_bake_rasters(
// only way to know it is there.
const stack = 16 * 1024 * 1024;
var threads: [8]std.Thread = undefined;
- const want = @min(@max(workers, 1), @min(threads.len, n));
var spawned: usize = 0;
- while (spawned < want) : (spawned += 1) {
- threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break;
+ // Single-threaded build (wasm): spawn is a compile error, so the fan-out
+ // is comptime-gated and the caller's thread does all the work below.
+ if (!builtin.single_threaded) {
+ const want = @min(@max(workers, 1), @min(threads.len, n));
+ while (spawned < want) : (spawned += 1) {
+ threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break;
+ }
}
if (spawned == 0) rasterWorker(&job); // nothing would run otherwise
for (threads[0..spawned]) |t| t.join();
@@ -773,10 +783,13 @@ export fn tile57_bake_zip_rasters(
// only the bytes arrive from the archive instead of a file.
const stack = 16 * 1024 * 1024;
var threads: [8]std.Thread = undefined;
- const want = @min(@max(workers, 1), @min(threads.len, n));
var spawned: usize = 0;
- while (spawned < want) : (spawned += 1) {
- threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break;
+ // Same comptime gate as tile57_bake_rasters: serial on a single-threaded build.
+ if (!builtin.single_threaded) {
+ const want = @min(@max(workers, 1), @min(threads.len, n));
+ while (spawned < want) : (spawned += 1) {
+ threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break;
+ }
}
if (spawned == 0) rasterWorker(&job);
for (threads[0..spawned]) |t| t.join();
diff --git a/src/chart.zig b/src/chart.zig
index c5b6f611..d738e7a7 100644
--- a/src/chart.zig
+++ b/src/chart.zig
@@ -964,7 +964,9 @@ pub fn bakeChartsParallel(paths: []const []const u8, rules_dir: ?[]const u8, wor
var ctx = BakeCtx{ .next = std.atomic.Value(usize).init(0), .paths = paths, .rules_dir = rules_dir, .out = out };
var n = @min(@max(workers, 1), paths.len);
if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS;
- if (n <= 1) return bakeCellWorker(&ctx);
+ // Single-threaded build (wasm): spawn is a compile error, so the comptime
+ // condition prunes the fan-out and this thread bakes every cell.
+ if (@import("builtin").single_threaded or n <= 1) return bakeCellWorker(&ctx);
var threads: [MAX_BAKE_WORKERS]std.Thread = undefined;
var spawned: usize = 0;
while (spawned < n - 1) : (spawned += 1) {
@@ -1117,7 +1119,8 @@ fn bakeToFiles(io: std.Io, zip: ?*const zipsrc.Archive, in_paths: []const []cons
var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .zip = zip, .io = io, .ok = ok, .ms = cell_ms, .progress = progress, .progress_ctx = progress_ctx, .label = label, .done = std.atomic.Value(u32).init(0), .cancel = std.atomic.Value(bool).init(false), .aux = aux };
var n = @min(@max(workers, 1), in_paths.len);
if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS;
- if (n <= 1) {
+ // The comptime lhs prunes the spawn branch on a single-threaded build (wasm).
+ if (@import("builtin").single_threaded or n <= 1) {
bakeFileWorker(&ctx);
} else {
var threads: [MAX_BAKE_WORKERS]std.Thread = undefined;
@@ -1947,7 +1950,8 @@ fn composeTileWorker(ctx: *ComposeTileCtx) void {
}
fn runComposeTileWorkers(ctx: *ComposeTileCtx, n: usize) void {
- if (n <= 1) return composeTileWorker(ctx);
+ // The comptime lhs prunes the spawn code on a single-threaded build (wasm).
+ if (@import("builtin").single_threaded or n <= 1) return composeTileWorker(ctx);
var threads: [MAX_COMPOSE_WORKERS]std.Thread = undefined;
var spawned: usize = 0;
while (spawned < n - 1) : (spawned += 1) {
diff --git a/src/geometry/plane.zig b/src/geometry/plane.zig
index 8ae91838..0d098c8f 100644
--- a/src/geometry/plane.zig
+++ b/src/geometry/plane.zig
@@ -329,6 +329,7 @@ fn workerCount(m: usize) usize {
if (std.fmt.parseInt(usize, std.mem.sliceTo(w, 0), 10) catch null) |n|
return @max(1, @min(n, 64));
}
+ if (@import("builtin").single_threaded) return 1; // wasm: no threads at all
if (m < 64) return 1; // not worth the threads
const cpus = std.Thread.getCpuCount() catch 1;
return @max(1, @min(cpus, 8));
@@ -401,8 +402,12 @@ pub fn buildCoverageIndex(gpa: Allocator, cells: []const Cell) !CoverageIndex {
var threads: [63]std.Thread = undefined;
var spawned: usize = 0;
defer for (threads[0..spawned]) |t| t.join();
- while (spawned < workers - 1) : (spawned += 1) {
- threads[spawned] = std.Thread.spawn(.{}, Job.run, .{ &job, spawned + 1 }) catch break;
+ // Comptime-gated: spawn is a compile error on a single-threaded build
+ // (wasm), where workerCount() already pinned workers to 1.
+ if (!@import("builtin").single_threaded) {
+ while (spawned < workers - 1) : (spawned += 1) {
+ threads[spawned] = std.Thread.spawn(.{}, Job.run, .{ &job, spawned + 1 }) catch break;
+ }
}
job.run(0); // this thread takes a share too
}
@@ -729,8 +734,11 @@ fn ownedAtTierImpl(gpa: Allocator, cells: []const Cell, tier: u8, idx: *const Co
var threads = try sa.alloc(std.Thread, workers - 1);
var spawned: usize = 0;
defer for (threads[0..spawned]) |t| t.join();
- while (spawned < workers - 1) : (spawned += 1) {
- threads[spawned] = std.Thread.spawn(.{}, Sweep.run, .{ &sweep, spawned + 1 }) catch break;
+ // Same comptime gate as the coverage-index fan-out above.
+ if (!@import("builtin").single_threaded) {
+ while (spawned < workers - 1) : (spawned += 1) {
+ threads[spawned] = std.Thread.spawn(.{}, Sweep.run, .{ &sweep, spawned + 1 }) catch break;
+ }
}
sweep.run(0); // this thread takes a share too
}
diff --git a/src/portray/wasi_stubs.c b/src/portray/wasi_stubs.c
new file mode 100644
index 00000000..ced8a851
--- /dev/null
+++ b/src/portray/wasi_stubs.c
@@ -0,0 +1,19 @@
+/*
+ * Libc definitions wasi-libc declares but does not ship, needed to LINK the
+ * embedded Lua on wasm32-wasi. Compiled only into the wasm engine (build.zig
+ * addLua, wasm branch).
+ */
+
+#include
+#include
+
+/*
+ * wasi has no temp-file directory, so wasi-libc's stdio.h declares tmpfile()
+ * without a definition. Lua's io.tmpfile links against it; a NULL return with
+ * errno set becomes a clean `nil, "..."` result at the Lua level. Nothing in
+ * the portrayal path opens temp files.
+ */
+FILE *tmpfile(void) {
+ errno = ENOTSUP;
+ return NULL;
+}
diff --git a/src/portray/wasm_sjlj_rt.c b/src/portray/wasm_sjlj_rt.c
new file mode 100644
index 00000000..c686911a
--- /dev/null
+++ b/src/portray/wasm_sjlj_rt.c
@@ -0,0 +1,102 @@
+/*
+ * The setjmp/longjmp runtime for wasm, vendored from wasi-libc
+ * (libc-top-half/musl/src/setjmp/wasm32/rt.c, MIT/Apache-2.0 - see
+ * THIRD_PARTY_LICENSES.md).
+ *
+ * Lua's error path is setjmp/longjmp. On wasm, clang lowers those calls to
+ * __wasm_setjmp / __wasm_setjmp_test / __wasm_longjmp plus exception-handling
+ * instructions when a file is compiled with:
+ *
+ * -mexception-handling -mllvm -wasm-enable-sjlj
+ *
+ * This file supplies those three helpers. It is compiled with the SAME flags
+ * as the Lua objects (the sjlj pass also defines the __c_longjmp exception
+ * tag the helpers throw with). It must be vendored: the copy inside Zig's
+ * bundled wasi-libc only enters libc.a when the exception-handling feature is
+ * enabled TARGET-wide, and that build crashes in zig 0.16 (zig compiles it
+ * without the sjlj pass, which leaves the tag undefined-weak - rejected by
+ * the wasm object writer). Per-file flags on our own objects sidestep the
+ * libc build entirely.
+ *
+ * a runtime implementation for
+ * https://github.com/llvm/llvm-project/pull/84137
+ * https://docs.google.com/document/d/1ZvTPT36K5jjiedF8MCXbEmYjULJjI723aOAks1IdLLg/edit
+ */
+
+#include
+#include
+
+/*
+ * function prototypes
+ */
+void __wasm_setjmp(void *env, uint32_t label, void *func_invocation_id);
+uint32_t __wasm_setjmp_test(void *env, void *func_invocation_id);
+void __wasm_longjmp(void *env, int val);
+
+/*
+ * jmp_buf should have large enough size and alignment to contain
+ * this structure.
+ */
+struct jmp_buf_impl {
+ void *func_invocation_id;
+ uint32_t label;
+
+ /*
+ * this is a temorary storage used by the communication between
+ * __wasm_sjlj_longjmp and WebAssemblyLowerEmscriptenEHSjL-generated
+ * logic.
+ * ideally, this can be replaced with multivalue.
+ */
+ struct arg {
+ void *env;
+ int val;
+ } arg;
+};
+
+void
+__wasm_setjmp(void *env, uint32_t label, void *func_invocation_id)
+{
+ struct jmp_buf_impl *jb = env;
+ if (label == 0) { /* ABI contract */
+ __builtin_trap();
+ }
+ if (func_invocation_id == NULL) { /* sanity check */
+ __builtin_trap();
+ }
+ jb->func_invocation_id = func_invocation_id;
+ jb->label = label;
+}
+
+uint32_t
+__wasm_setjmp_test(void *env, void *func_invocation_id)
+{
+ struct jmp_buf_impl *jb = env;
+ if (jb->label == 0) { /* ABI contract */
+ __builtin_trap();
+ }
+ if (func_invocation_id == NULL) { /* sanity check */
+ __builtin_trap();
+ }
+ if (jb->func_invocation_id == func_invocation_id) {
+ return jb->label;
+ }
+ return 0;
+}
+
+void
+__wasm_longjmp(void *env, int val)
+{
+ struct jmp_buf_impl *jb = env;
+ struct arg *arg = &jb->arg;
+ /*
+ * C standard says:
+ * The longjmp function cannot cause the setjmp macro to return
+ * the value 0; if val is 0, the setjmp macro returns the value 1.
+ */
+ if (val == 0) {
+ val = 1;
+ }
+ arg->env = env;
+ arg->val = val;
+ __builtin_wasm_throw(1, arg); /* 1 == C_LONGJMP */
+}
diff --git a/src/render/resolve.zig b/src/render/resolve.zig
index 23d0d448..36ad09e4 100644
--- a/src/render/resolve.zig
+++ b/src/render/resolve.zig
@@ -236,6 +236,12 @@ pub fn visible(meta: *const rs.FeatureMeta, symbol_name: ?[]const u8, zoom: f64,
if (meta.bnd != 2 and meta.bnd != bnd_rank) return false;
const pts_rank: i64 = if (m.simplified_points) 1 else 0;
if (meta.pts != 2 and meta.pts != pts_rank) return false;
+ // Sector-leg length (S-52 §12.2.4, mirrors mariner.sectorFilter): a
+ // sectored light portrays its legs twice — sect 0 (the 25 mm stubs) and
+ // sect 1 (the full-length pass) — and without this gate both drew, so
+ // full sector lines showed whichever way the switch stood.
+ const sect_rank: i64 = if (m.show_full_sector_lines) 1 else 0;
+ if (meta.sect != 2 and meta.sect != sect_rank) return false;
return true;
}
diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig
index 00237a95..35795392 100644
--- a/src/scene/bake_enc.zig
+++ b/src/scene/bake_enc.zig
@@ -581,6 +581,9 @@ pub fn serialFor(gpa: std.mem.Allocator, n: usize, user: *anyopaque, func: *cons
pub fn parallelFor(gpa: std.mem.Allocator, n: usize, user: *anyopaque, func: *const fn (*anyopaque, usize, std.mem.Allocator) void) void {
if (n == 0) return;
var pc = ParCtx{ .next = std.atomic.Value(usize).init(0), .n = n, .user = user, .func = func, .gpa = gpa };
+ // Single-threaded build (wasm): spawn is a compile error, so the whole
+ // fan-out is comptime-gated and every item runs on the calling thread.
+ if (@import("builtin").single_threaded) return parWorker(&pc);
const cpus = std.Thread.getCpuCount() catch 1;
var nthreads = @min(@max(cpus, 1), n);
if (nthreads > 64) nthreads = 64;
diff --git a/src/scene/replay.zig b/src/scene/replay.zig
index 2819e664..4ccb04c7 100644
--- a/src/scene/replay.zig
+++ b/src/scene/replay.zig
@@ -73,6 +73,7 @@ fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta {
.band = @intCast(std.math.clamp(propInt(props, "band", rs.BAND_UNKNOWN), 0, 255)),
.bnd = propInt(props, "bnd", 2),
.pts = propInt(props, "pts", 2),
+ .sect = propInt(props, "sect", 2),
.masked = propInt(props, "masked", 0) != 0,
.date_start = propStr(props, "date_start"),
.date_end = propStr(props, "date_end"),
diff --git a/src/tiles/filemap.zig b/src/tiles/filemap.zig
index 577ac295..664f9e91 100644
--- a/src/tiles/filemap.zig
+++ b/src/tiles/filemap.zig
@@ -34,6 +34,10 @@ extern "kernel32" fn UnmapViewOfFile(lpBaseAddress: windows.LPCVOID) callconv(.w
/// Map the first `len` bytes of `handle` read-only (`len` must be > 0). Release with
/// `unmap`. The file handle may be closed once this returns — the view keeps the
/// underlying data alive on both POSIX (mmap) and Windows (MapViewOfFile).
+///
+/// wasi has no mmap, so there the "map" is a plain read: the bytes are copied
+/// into wasm linear memory (page_allocator) and `unmap` frees them. Same
+/// contract, no lazy paging — a browser host's file system is memory anyway.
pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(page) const u8 {
if (builtin.os.tag == .windows) {
const h = CreateFileMappingW(handle, null, PAGE_READONLY, 0, 0, null) orelse return error.IoFailed;
@@ -43,6 +47,21 @@ pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(p
const base: [*]align(page) const u8 = @ptrCast(@alignCast(p));
return base[0..len];
}
+ if (builtin.os.tag == .wasi) {
+ const buf = std.heap.page_allocator.alignedAlloc(u8, .fromByteUnits(page), len) catch
+ return error.IoFailed;
+ errdefer std.heap.page_allocator.free(buf);
+ var off: usize = 0;
+ while (off < len) {
+ var iov = [1]std.os.wasi.iovec_t{.{ .base = buf.ptr + off, .len = len - off }};
+ var nread: usize = 0;
+ if (std.os.wasi.fd_pread(handle, &iov, 1, off, &nread) != .SUCCESS)
+ return error.IoFailed;
+ if (nread == 0) return error.IoFailed; // shorter than `len`
+ off += nread;
+ }
+ return buf;
+ }
return std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, handle, 0) catch
return error.IoFailed;
}
@@ -51,6 +70,8 @@ pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(p
pub fn unmap(m: []align(page) const u8) void {
if (builtin.os.tag == .windows) {
_ = UnmapViewOfFile(@ptrCast(m.ptr));
+ } else if (builtin.os.tag == .wasi) {
+ std.heap.page_allocator.free(@constCast(m));
} else {
std.posix.munmap(m);
}
diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig
index c00b8f40..ca0f01dd 100644
--- a/src/tiles/pmtiles.zig
+++ b/src/tiles/pmtiles.zig
@@ -287,7 +287,13 @@ pub fn deserializeDir(a: Allocator, buf: []const u8) ![]Entry {
/// - Windows: SRWLOCK (SRWLOCK_INIT is 0), the OS's own kernel-blocking lock —
/// there is no pthread to link against on an MSVC/mingw target.
/// - Linux/Android: a zeroed pthread_mutex_t is PTHREAD_MUTEX_INITIALIZER.
+/// - wasi/freestanding: a no-op. The wasm engine is single-threaded (no
+/// wasi-threads), so the lazy directory state has exactly one reader.
const Lock = switch (@import("builtin").os.tag) {
+ .wasi, .freestanding => struct {
+ fn lock(_: *@This()) void {}
+ fn unlock(_: *@This()) void {}
+ },
.macos, .ios, .tvos, .watchos, .visionos => struct {
const Handle = extern struct { v: u32 = 0 };
extern "c" fn os_unfair_lock_lock(l: *Handle) void;
diff --git a/src/wasm_root.zig b/src/wasm_root.zig
new file mode 100644
index 00000000..328f1e81
--- /dev/null
+++ b/src/wasm_root.zig
@@ -0,0 +1,121 @@
+//! Wasm reactor root for the full engine (`zig build wasm-engine`).
+//!
+//! The same surface as libtile57.a - the whole C ABI, with the embedded Lua
+//! portrayal engine - compiled to one wasm32-wasi module. A JS host (browser
+//! page or node) supplies the WASI imports and calls the tile57_* exports, so
+//! a chartplotter can bake charts and serve tiles fully client-side.
+//!
+//! The two helpers below exist only on this target. The C ABI's byte-buffer
+//! calls allocate their OUTPUTS (released with tile57_free), but a C caller
+//! provides its own INPUT buffers - and a JS host has no allocator inside the
+//! wasm linear memory. These give it one.
+
+const std = @import("std");
+
+pub const lib = @import("lib_root.zig");
+
+comptime {
+ _ = lib; // force the C ABI exports into the wasm export table
+}
+
+/// Allocate `len` bytes of wasm linear memory for an input buffer (chart
+/// bytes, settings JSON, ...). The JS host writes the bytes at the returned
+/// offset, passes it to a tile57_* call, then releases it with
+/// tile57_wasm_free. Returns 0 when out of memory.
+export fn tile57_wasm_alloc(len: usize) ?[*]u8 {
+ const p = std.c.malloc(len) orelse return null;
+ return @ptrCast(p);
+}
+
+/// Release a buffer from tile57_wasm_alloc. Only for those buffers - engine
+/// outputs still go through tile57_free.
+export fn tile57_wasm_free(ptr: ?*anyopaque) void {
+ std.c.free(ptr);
+}
+
+// ---- the cursor pick, flattened for a JS host -----------------------------
+//
+// tile57_chart_query / tile57_compose_query report through a C callback, and
+// a JS host cannot provide one (a JS function is not a wasm funcref). The
+// callback must live INSIDE the module, so this export runs the query with an
+// internal accumulator and returns the features as ONE JSON array:
+// [{"cls":"LIGHTS","s57":{...},"chart":"US5BDRAB"}, ...]
+// Release *out with tile57_wasm_free. The C exports are extern-declared here
+// (same module, resolved at link) to keep this file POD-only.
+
+const QueryCb = extern struct {
+ ctx: ?*anyopaque,
+ feature: ?*const fn (?*anyopaque, [*c]const u8, usize, [*c]const u8, usize, [*c]const u8, usize) callconv(.c) void,
+};
+extern fn tile57_chart_query(chart: ?*anyopaque, lon: f64, lat: f64, zoom: f64, cb: *const QueryCb, err: ?*anyopaque) c_int;
+extern fn tile57_compose_query(c: ?*anyopaque, lon: f64, lat: f64, zoom: f64, cb: *const QueryCb, err: ?*anyopaque) c_int;
+
+const QueryAcc = struct {
+ list: std.ArrayList(u8) = .empty,
+ first: bool = true,
+ failed: bool = false,
+};
+
+fn accAppend(acc: *QueryAcc, bytes: []const u8) void {
+ acc.list.appendSlice(std.heap.c_allocator, bytes) catch {
+ acc.failed = true;
+ };
+}
+
+// Escape a plain string (a class acronym, a chart name) into JSON.
+fn accAppendJsonString(acc: *QueryAcc, s: []const u8) void {
+ accAppend(acc, "\"");
+ for (s) |ch| switch (ch) {
+ '"' => accAppend(acc, "\\\""),
+ '\\' => accAppend(acc, "\\\\"),
+ 0x00...0x1f => {
+ var buf: [8]u8 = undefined;
+ accAppend(acc, std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{ch}) catch "?");
+ },
+ else => accAppend(acc, &.{ch}),
+ };
+ accAppend(acc, "\"");
+}
+
+fn onQueryFeature(ctx: ?*anyopaque, cls: [*c]const u8, cls_len: usize, s57: [*c]const u8, s57_len: usize, chart: [*c]const u8, chart_len: usize) callconv(.c) void {
+ const acc: *QueryAcc = @ptrCast(@alignCast(ctx orelse return));
+ if (acc.failed) return;
+ if (!acc.first) accAppend(acc, ",");
+ acc.first = false;
+ accAppend(acc, "{\"cls\":");
+ accAppendJsonString(acc, if (cls) |p| p[0..cls_len] else "");
+ // The attribute payload is already JSON - embed it raw (empty -> {}).
+ accAppend(acc, ",\"s57\":");
+ const raw = if (s57) |p| p[0..s57_len] else "";
+ accAppend(acc, if (raw.len == 0) "{}" else raw);
+ accAppend(acc, ",\"chart\":");
+ accAppendJsonString(acc, if (chart) |p| p[0..chart_len] else "");
+ accAppend(acc, "}");
+}
+
+/// The pick at (lon, lat) as JSON. `compose` when nonzero, else `chart`.
+export fn tile57_wasm_query(compose: ?*anyopaque, chart: ?*anyopaque, lon: f64, lat: f64, zoom: f64, out: ?*?[*]u8, out_len: ?*usize) i32 {
+ const o = out orelse return 1;
+ const ol = out_len orelse return 1;
+ o.* = null;
+ ol.* = 0;
+ var acc = QueryAcc{};
+ accAppend(&acc, "[");
+ const cb = QueryCb{ .ctx = &acc, .feature = &onQueryFeature };
+ const status = if (compose != null)
+ tile57_compose_query(compose, lon, lat, zoom, &cb, null)
+ else
+ tile57_chart_query(chart, lon, lat, zoom, &cb, null);
+ accAppend(&acc, "]");
+ if (status != 0 or acc.failed) {
+ acc.list.deinit(std.heap.c_allocator);
+ return if (status != 0) status else 4; // 4 = TILE57_ERR_NOMEM
+ }
+ const slice = acc.list.toOwnedSlice(std.heap.c_allocator) catch {
+ acc.list.deinit(std.heap.c_allocator);
+ return 4;
+ };
+ o.* = slice.ptr;
+ ol.* = slice.len;
+ return 0;
+}
| | | | | |