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.

- License + 🌊 Live demo +  ·  + 📚 Docs  ·  - 📚 Docs → + License

--- -> [!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 ---- + + Annapolis Harbor drawn by tile57 + -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 ``; + } + return ``; + } + + // 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 + ? `` + : `${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)} + + +
+
+ ${noteBlocks}${empty}${rows} + ${this.fold ? `
${this.rawRows(f)}
` : ""} +
+
+
${esc(r.footnote || f.chart)}
+ +
`; + } +} + +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]) => + ``).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 = ` + + chart view + +
+ +
+
+ + + +
+
+ + + +
+ +
+ +
+ · + · + +
+
+
+ +
+ +
+ NOAA ENC® + · not for navigation · +
+ + + +
+
Chart display
+
+
+ +
+
+
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 ( + +