From 16e0e6d9f0440822a01d660168d55e3c2c4fa53d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:05:53 -0400 Subject: [PATCH 01/35] Compile the full engine to wasm32-wasi (zig build wasm-engine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The complete C ABI — bake, chart, compose, style, raster — builds as one wasm reactor module, so a browser chartplotter can bake charts and serve tiles with no server. Lua and libtess2 keep their setjmp error paths: those objects compile per-file with -mexception-handling plus clang's sjlj lowering, and the vendored wasi-libc runtime (src/portray/wasm_sjlj_rt.c) supplies the helpers. The target's own feature set stays default, so Zig's wasi-libc build never compiles its own (broken in zig 0.16) copy. Portability gates: filemap reads instead of mmap on wasi, the pmtiles reader lock is a no-op there, every thread fan-out comptime-gates on builtin.single_threaded, SQLite drops to THREADSAFE=0, and capi's time(3) binding uses time_t (64-bit on wasi). bindings/wasm/engine-smoke.mjs drives the real pipeline under node's WASI: bake one S-57 cell, open the archive from bytes, fetch a vector tile, render a PNG view. --- bindings/wasm/engine-smoke.mjs | 114 ++++++++++++++++ build.zig | 238 ++++++++++++++++++++++++++++++--- src/capi.zig | 24 +++- src/chart.zig | 10 +- src/geometry/plane.zig | 16 ++- src/portray/wasi_stubs.c | 19 +++ src/portray/wasm_sjlj_rt.c | 102 ++++++++++++++ src/scene/bake_enc.zig | 3 + src/tiles/filemap.zig | 21 +++ src/tiles/pmtiles.zig | 6 + src/wasm_root.zig | 34 +++++ 11 files changed, 552 insertions(+), 35 deletions(-) create mode 100644 bindings/wasm/engine-smoke.mjs create mode 100644 src/portray/wasi_stubs.c create mode 100644 src/portray/wasm_sjlj_rt.c create mode 100644 src/wasm_root.zig diff --git a/bindings/wasm/engine-smoke.mjs b/bindings/wasm/engine-smoke.mjs new file mode 100644 index 00000000..6a58e045 --- /dev/null +++ b/bindings/wasm/engine-smoke.mjs @@ -0,0 +1,114 @@ +// Smoke test for the full-engine wasm reactor (zig build wasm-engine). +// +// Runs the real pipeline inside node's WASI host: bake one S-57 cell to a +// PMTiles archive, open the archive from bytes, fetch one vector tile, and +// render one PNG view. This is the same call sequence a browser chartplotter +// makes; only the WASI shim differs. +// +// usage: node engine-smoke.mjs [out.png] +// e.g. node engine-smoke.mjs ~/Charts/enc-src/ALL/ENC_ROOT US5BDRAB/US5BDRAB.000 + +import { WASI } from "node:wasi"; +import fs from "node:fs"; + +const [encRoot, cellRel, pngOut] = process.argv.slice(2); +if (!encRoot || !cellRel) { + console.error("usage: node engine-smoke.mjs [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 allocBytes(bytes) { + const p = E.tile57_wasm_alloc(bytes.length); + if (!p) throw new Error("tile57_wasm_alloc failed"); + u8().set(bytes, p); + return p; +} +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: S-57 cell -> per-chart PMTiles archive ------------------------ +const cellPath = allocCString("/enc/" + cellRel); +let t0 = performance.now(); +check("bake_chart_bytes", E.tile57_bake_chart_bytes(cellPath, outPtr, outLen, errPtr)); +const [arcPtr, arcLen] = readOut(); +console.log(`baked: ${arcLen} bytes in ${(performance.now() - t0).toFixed(0)} ms`); +if (arcLen === 0) throw new Error("bake produced no archive"); + +// ---- open the archive from bytes (no file system involved) --------------- +check("chart_open_bytes", E.tile57_chart_open_bytes(arcPtr, arcLen, outPtr, errPtr)); +const chart = dv().getUint32(outPtr, true); +E.tile57_free(arcPtr); + +// ---- info -> pick the anchor view ---------------------------------------- +const info = E.tile57_wasm_alloc(96); +E.tile57_chart_get_info(chart, info); +const d = dv(); +const minz = d.getUint8(info), maxz = d.getUint8(info + 1); +const hasAnchor = d.getUint8(info + 48) !== 0; +// No anchor in the archive -> view the bounds center at a harbor-ish zoom. +const anchorLat = hasAnchor ? d.getFloat64(info + 56, true) : (d.getFloat64(info + 24, true) + d.getFloat64(info + 40, true)) / 2; +const anchorLon = hasAnchor ? d.getFloat64(info + 64, true) : (d.getFloat64(info + 16, true) + d.getFloat64(info + 32, true)) / 2; +const anchorZoom = hasAnchor ? d.getFloat64(info + 72, true) : Math.min(14, maxz); +console.log(`info: z${minz}-${maxz} scale 1:${d.getInt32(info + 84, true)} view ${anchorLat.toFixed(4)},${anchorLon.toFixed(4)} @z${anchorZoom.toFixed(1)}`); + +// ---- one vector tile at the anchor --------------------------------------- +const z = Math.max(minz, Math.min(maxz, Math.round(anchorZoom))); +const n = 2 ** z; +const tx = Math.floor(((anchorLon + 180) / 360) * n); +const latR = (anchorLat * Math.PI) / 180; +const ty = Math.floor(((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n); +t0 = performance.now(); +check("chart_tile", E.tile57_chart_tile(chart, 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); + +// ---- one PNG view at the anchor ------------------------------------------ +t0 = performance.now(); +check("chart_png", E.tile57_chart_png(chart, anchorLon, anchorLat, anchorZoom, 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); + +E.tile57_chart_close(chart); +console.log("OK"); diff --git a/build.zig b/build.zig index 3813445c..7890175e 100644 --- a/build.zig +++ b/build.zig @@ -49,15 +49,36 @@ 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. The raw -Xclang pair re-enables the feature at the +// cc1 level — zig appends its own `-target-feature -exception-handling` +// (derived from the module target, which keeps default features so Zig's +// wasi-libc build never sees the feature and never compiles its broken sjlj +// runtime) after the driver-level -m flag, and the LAST cc1 flag wins; a raw +// -Xclang pair in the file flags lands after zig's and wins it back. The +// driver-level -mexception-handling still matters: it defines +// __wasm_exception_handling__, which wasi's setjmp.h gates on. +const wasm_sjlj_flags = [_][]const u8{ + "-mexception-handling", "-mllvm", + "-wasm-enable-sjlj", "-Xclang", + "-target-feature", "-Xclang", + "+exception-handling", +}; + +// `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 +108,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 +174,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 +187,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 +421,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 +485,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 +538,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 +670,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); @@ -783,6 +843,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 +1035,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 +1099,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/src/capi.zig b/src/capi.zig index 8c5b6156..712392be 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -8,6 +8,7 @@ //! the opaque `tile57_compose` is a `*compose.ComposeSource`. const std = @import("std"); +const builtin = @import("builtin"); const chart = @import("chart.zig"); const auxfiles = @import("engine").auxfiles; // via the named module: engine owns the file const scene = @import("engine").scene; // tile surface + the complex-linestyle walk @@ -62,7 +63,9 @@ fn sharedIo() std.Io { // Wall-clock time for "today" date resolution in tile57_style_build. Zig 0.16 // keeps the clock behind Io; the lib links libc, so call time(3) directly. -extern fn time(tloc: ?*c_long) callconv(.c) c_long; +// time_t, not c_long: wasm32's c_long is 32-bit while wasi's time_t is 64-bit, +// and wasm-ld rejects the signature mismatch against libc's definition. +extern fn time(tloc: ?*std.c.time_t) callconv(.c) std.c.time_t; // Keep in sync with the TILE57_VERSION_* macros in tile57.h. const version_string = "0.3.0"; @@ -557,10 +560,14 @@ export fn tile57_bake_rasters( // only way to know it is there. const stack = 16 * 1024 * 1024; var threads: [8]std.Thread = undefined; - const want = @min(@max(workers, 1), @min(threads.len, n)); var spawned: usize = 0; - while (spawned < want) : (spawned += 1) { - threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break; + // Single-threaded build (wasm): spawn is a compile error, so the fan-out + // is comptime-gated and the caller's thread does all the work below. + if (!builtin.single_threaded) { + const want = @min(@max(workers, 1), @min(threads.len, n)); + while (spawned < want) : (spawned += 1) { + threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break; + } } if (spawned == 0) rasterWorker(&job); // nothing would run otherwise for (threads[0..spawned]) |t| t.join(); @@ -773,10 +780,13 @@ export fn tile57_bake_zip_rasters( // only the bytes arrive from the archive instead of a file. const stack = 16 * 1024 * 1024; var threads: [8]std.Thread = undefined; - const want = @min(@max(workers, 1), @min(threads.len, n)); var spawned: usize = 0; - while (spawned < want) : (spawned += 1) { - threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break; + // Same comptime gate as tile57_bake_rasters: serial on a single-threaded build. + if (!builtin.single_threaded) { + const want = @min(@max(workers, 1), @min(threads.len, n)); + while (spawned < want) : (spawned += 1) { + threads[spawned] = std.Thread.spawn(.{ .stack_size = stack }, rasterWorker, .{&job}) catch break; + } } if (spawned == 0) rasterWorker(&job); for (threads[0..spawned]) |t| t.join(); diff --git a/src/chart.zig b/src/chart.zig index c5b6f611..d738e7a7 100644 --- a/src/chart.zig +++ b/src/chart.zig @@ -964,7 +964,9 @@ pub fn bakeChartsParallel(paths: []const []const u8, rules_dir: ?[]const u8, wor var ctx = BakeCtx{ .next = std.atomic.Value(usize).init(0), .paths = paths, .rules_dir = rules_dir, .out = out }; var n = @min(@max(workers, 1), paths.len); if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS; - if (n <= 1) return bakeCellWorker(&ctx); + // Single-threaded build (wasm): spawn is a compile error, so the comptime + // condition prunes the fan-out and this thread bakes every cell. + if (@import("builtin").single_threaded or n <= 1) return bakeCellWorker(&ctx); var threads: [MAX_BAKE_WORKERS]std.Thread = undefined; var spawned: usize = 0; while (spawned < n - 1) : (spawned += 1) { @@ -1117,7 +1119,8 @@ fn bakeToFiles(io: std.Io, zip: ?*const zipsrc.Archive, in_paths: []const []cons var ctx = BakeFileCtx{ .next = std.atomic.Value(usize).init(0), .in_paths = in_paths, .out_paths = out_paths, .rules_dir = rules_dir, .zip = zip, .io = io, .ok = ok, .ms = cell_ms, .progress = progress, .progress_ctx = progress_ctx, .label = label, .done = std.atomic.Value(u32).init(0), .cancel = std.atomic.Value(bool).init(false), .aux = aux }; var n = @min(@max(workers, 1), in_paths.len); if (n > MAX_BAKE_WORKERS) n = MAX_BAKE_WORKERS; - if (n <= 1) { + // The comptime lhs prunes the spawn branch on a single-threaded build (wasm). + if (@import("builtin").single_threaded or n <= 1) { bakeFileWorker(&ctx); } else { var threads: [MAX_BAKE_WORKERS]std.Thread = undefined; @@ -1947,7 +1950,8 @@ fn composeTileWorker(ctx: *ComposeTileCtx) void { } fn runComposeTileWorkers(ctx: *ComposeTileCtx, n: usize) void { - if (n <= 1) return composeTileWorker(ctx); + // The comptime lhs prunes the spawn code on a single-threaded build (wasm). + if (@import("builtin").single_threaded or n <= 1) return composeTileWorker(ctx); var threads: [MAX_COMPOSE_WORKERS]std.Thread = undefined; var spawned: usize = 0; while (spawned < n - 1) : (spawned += 1) { diff --git a/src/geometry/plane.zig b/src/geometry/plane.zig index 8ae91838..0d098c8f 100644 --- a/src/geometry/plane.zig +++ b/src/geometry/plane.zig @@ -329,6 +329,7 @@ fn workerCount(m: usize) usize { if (std.fmt.parseInt(usize, std.mem.sliceTo(w, 0), 10) catch null) |n| return @max(1, @min(n, 64)); } + if (@import("builtin").single_threaded) return 1; // wasm: no threads at all if (m < 64) return 1; // not worth the threads const cpus = std.Thread.getCpuCount() catch 1; return @max(1, @min(cpus, 8)); @@ -401,8 +402,12 @@ pub fn buildCoverageIndex(gpa: Allocator, cells: []const Cell) !CoverageIndex { var threads: [63]std.Thread = undefined; var spawned: usize = 0; defer for (threads[0..spawned]) |t| t.join(); - while (spawned < workers - 1) : (spawned += 1) { - threads[spawned] = std.Thread.spawn(.{}, Job.run, .{ &job, spawned + 1 }) catch break; + // Comptime-gated: spawn is a compile error on a single-threaded build + // (wasm), where workerCount() already pinned workers to 1. + if (!@import("builtin").single_threaded) { + while (spawned < workers - 1) : (spawned += 1) { + threads[spawned] = std.Thread.spawn(.{}, Job.run, .{ &job, spawned + 1 }) catch break; + } } job.run(0); // this thread takes a share too } @@ -729,8 +734,11 @@ fn ownedAtTierImpl(gpa: Allocator, cells: []const Cell, tier: u8, idx: *const Co var threads = try sa.alloc(std.Thread, workers - 1); var spawned: usize = 0; defer for (threads[0..spawned]) |t| t.join(); - while (spawned < workers - 1) : (spawned += 1) { - threads[spawned] = std.Thread.spawn(.{}, Sweep.run, .{ &sweep, spawned + 1 }) catch break; + // Same comptime gate as the coverage-index fan-out above. + if (!@import("builtin").single_threaded) { + while (spawned < workers - 1) : (spawned += 1) { + threads[spawned] = std.Thread.spawn(.{}, Sweep.run, .{ &sweep, spawned + 1 }) catch break; + } } sweep.run(0); // this thread takes a share too } diff --git a/src/portray/wasi_stubs.c b/src/portray/wasi_stubs.c new file mode 100644 index 00000000..ced8a851 --- /dev/null +++ b/src/portray/wasi_stubs.c @@ -0,0 +1,19 @@ +/* + * Libc definitions wasi-libc declares but does not ship, needed to LINK the + * embedded Lua on wasm32-wasi. Compiled only into the wasm engine (build.zig + * addLua, wasm branch). + */ + +#include +#include + +/* + * wasi has no temp-file directory, so wasi-libc's stdio.h declares tmpfile() + * without a definition. Lua's io.tmpfile links against it; a NULL return with + * errno set becomes a clean `nil, "..."` result at the Lua level. Nothing in + * the portrayal path opens temp files. + */ +FILE *tmpfile(void) { + errno = ENOTSUP; + return NULL; +} diff --git a/src/portray/wasm_sjlj_rt.c b/src/portray/wasm_sjlj_rt.c new file mode 100644 index 00000000..b4e31116 --- /dev/null +++ b/src/portray/wasm_sjlj_rt.c @@ -0,0 +1,102 @@ +/* + * The setjmp/longjmp runtime for wasm, vendored from wasi-libc + * (libc-top-half/musl/src/setjmp/wasm32/rt.c, MIT/Apache-2.0 — see + * THIRD_PARTY_LICENSES.md). + * + * Lua's error path is setjmp/longjmp. On wasm, clang lowers those calls to + * __wasm_setjmp / __wasm_setjmp_test / __wasm_longjmp plus exception-handling + * instructions when a file is compiled with: + * + * -mexception-handling -mllvm -wasm-enable-sjlj + * + * This file supplies those three helpers. It is compiled with the SAME flags + * as the Lua objects (the sjlj pass also defines the __c_longjmp exception + * tag the helpers throw with). It must be vendored: the copy inside Zig's + * bundled wasi-libc only enters libc.a when the exception-handling feature is + * enabled TARGET-wide, and that build crashes in zig 0.16 (zig compiles it + * without the sjlj pass, which leaves the tag undefined-weak — rejected by + * the wasm object writer). Per-file flags on our own objects sidestep the + * libc build entirely. + * + * a runtime implementation for + * https://github.com/llvm/llvm-project/pull/84137 + * https://docs.google.com/document/d/1ZvTPT36K5jjiedF8MCXbEmYjULJjI723aOAks1IdLLg/edit + */ + +#include +#include + +/* + * function prototypes + */ +void __wasm_setjmp(void *env, uint32_t label, void *func_invocation_id); +uint32_t __wasm_setjmp_test(void *env, void *func_invocation_id); +void __wasm_longjmp(void *env, int val); + +/* + * jmp_buf should have large enough size and alignment to contain + * this structure. + */ +struct jmp_buf_impl { + void *func_invocation_id; + uint32_t label; + + /* + * this is a temorary storage used by the communication between + * __wasm_sjlj_longjmp and WebAssemblyLowerEmscriptenEHSjL-generated + * logic. + * ideally, this can be replaced with multivalue. + */ + struct arg { + void *env; + int val; + } arg; +}; + +void +__wasm_setjmp(void *env, uint32_t label, void *func_invocation_id) +{ + struct jmp_buf_impl *jb = env; + if (label == 0) { /* ABI contract */ + __builtin_trap(); + } + if (func_invocation_id == NULL) { /* sanity check */ + __builtin_trap(); + } + jb->func_invocation_id = func_invocation_id; + jb->label = label; +} + +uint32_t +__wasm_setjmp_test(void *env, void *func_invocation_id) +{ + struct jmp_buf_impl *jb = env; + if (jb->label == 0) { /* ABI contract */ + __builtin_trap(); + } + if (func_invocation_id == NULL) { /* sanity check */ + __builtin_trap(); + } + if (jb->func_invocation_id == func_invocation_id) { + return jb->label; + } + return 0; +} + +void +__wasm_longjmp(void *env, int val) +{ + struct jmp_buf_impl *jb = env; + struct arg *arg = &jb->arg; + /* + * C standard says: + * The longjmp function cannot cause the setjmp macro to return + * the value 0; if val is 0, the setjmp macro returns the value 1. + */ + if (val == 0) { + val = 1; + } + arg->env = env; + arg->val = val; + __builtin_wasm_throw(1, arg); /* 1 == C_LONGJMP */ +} diff --git a/src/scene/bake_enc.zig b/src/scene/bake_enc.zig index 00237a95..35795392 100644 --- a/src/scene/bake_enc.zig +++ b/src/scene/bake_enc.zig @@ -581,6 +581,9 @@ pub fn serialFor(gpa: std.mem.Allocator, n: usize, user: *anyopaque, func: *cons pub fn parallelFor(gpa: std.mem.Allocator, n: usize, user: *anyopaque, func: *const fn (*anyopaque, usize, std.mem.Allocator) void) void { if (n == 0) return; var pc = ParCtx{ .next = std.atomic.Value(usize).init(0), .n = n, .user = user, .func = func, .gpa = gpa }; + // Single-threaded build (wasm): spawn is a compile error, so the whole + // fan-out is comptime-gated and every item runs on the calling thread. + if (@import("builtin").single_threaded) return parWorker(&pc); const cpus = std.Thread.getCpuCount() catch 1; var nthreads = @min(@max(cpus, 1), n); if (nthreads > 64) nthreads = 64; diff --git a/src/tiles/filemap.zig b/src/tiles/filemap.zig index 577ac295..664f9e91 100644 --- a/src/tiles/filemap.zig +++ b/src/tiles/filemap.zig @@ -34,6 +34,10 @@ extern "kernel32" fn UnmapViewOfFile(lpBaseAddress: windows.LPCVOID) callconv(.w /// Map the first `len` bytes of `handle` read-only (`len` must be > 0). Release with /// `unmap`. The file handle may be closed once this returns — the view keeps the /// underlying data alive on both POSIX (mmap) and Windows (MapViewOfFile). +/// +/// wasi has no mmap, so there the "map" is a plain read: the bytes are copied +/// into wasm linear memory (page_allocator) and `unmap` frees them. Same +/// contract, no lazy paging — a browser host's file system is memory anyway. pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(page) const u8 { if (builtin.os.tag == .windows) { const h = CreateFileMappingW(handle, null, PAGE_READONLY, 0, 0, null) orelse return error.IoFailed; @@ -43,6 +47,21 @@ pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(p const base: [*]align(page) const u8 = @ptrCast(@alignCast(p)); return base[0..len]; } + if (builtin.os.tag == .wasi) { + const buf = std.heap.page_allocator.alignedAlloc(u8, .fromByteUnits(page), len) catch + return error.IoFailed; + errdefer std.heap.page_allocator.free(buf); + var off: usize = 0; + while (off < len) { + var iov = [1]std.os.wasi.iovec_t{.{ .base = buf.ptr + off, .len = len - off }}; + var nread: usize = 0; + if (std.os.wasi.fd_pread(handle, &iov, 1, off, &nread) != .SUCCESS) + return error.IoFailed; + if (nread == 0) return error.IoFailed; // shorter than `len` + off += nread; + } + return buf; + } return std.posix.mmap(null, len, .{ .READ = true }, .{ .TYPE = .PRIVATE }, handle, 0) catch return error.IoFailed; } @@ -51,6 +70,8 @@ pub fn mapReadonly(handle: std.posix.fd_t, len: usize) error{IoFailed}![]align(p pub fn unmap(m: []align(page) const u8) void { if (builtin.os.tag == .windows) { _ = UnmapViewOfFile(@ptrCast(m.ptr)); + } else if (builtin.os.tag == .wasi) { + std.heap.page_allocator.free(@constCast(m)); } else { std.posix.munmap(m); } diff --git a/src/tiles/pmtiles.zig b/src/tiles/pmtiles.zig index c00b8f40..ca0f01dd 100644 --- a/src/tiles/pmtiles.zig +++ b/src/tiles/pmtiles.zig @@ -287,7 +287,13 @@ pub fn deserializeDir(a: Allocator, buf: []const u8) ![]Entry { /// - Windows: SRWLOCK (SRWLOCK_INIT is 0), the OS's own kernel-blocking lock — /// there is no pthread to link against on an MSVC/mingw target. /// - Linux/Android: a zeroed pthread_mutex_t is PTHREAD_MUTEX_INITIALIZER. +/// - wasi/freestanding: a no-op. The wasm engine is single-threaded (no +/// wasi-threads), so the lazy directory state has exactly one reader. const Lock = switch (@import("builtin").os.tag) { + .wasi, .freestanding => struct { + fn lock(_: *@This()) void {} + fn unlock(_: *@This()) void {} + }, .macos, .ios, .tvos, .watchos, .visionos => struct { const Handle = extern struct { v: u32 = 0 }; extern "c" fn os_unfair_lock_lock(l: *Handle) void; diff --git a/src/wasm_root.zig b/src/wasm_root.zig new file mode 100644 index 00000000..28f9413f --- /dev/null +++ b/src/wasm_root.zig @@ -0,0 +1,34 @@ +//! Wasm reactor root for the full engine (`zig build wasm-engine`). +//! +//! The same surface as libtile57.a — the whole C ABI, with the embedded Lua +//! portrayal engine — compiled to one wasm32-wasi module. A JS host (browser +//! page or node) supplies the WASI imports and calls the tile57_* exports, so +//! a chartplotter can bake charts and serve tiles fully client-side. +//! +//! The two helpers below exist only on this target. The C ABI's byte-buffer +//! calls allocate their OUTPUTS (released with tile57_free), but a C caller +//! provides its own INPUT buffers — and a JS host has no allocator inside the +//! wasm linear memory. These give it one. + +const std = @import("std"); + +pub const lib = @import("lib_root.zig"); + +comptime { + _ = lib; // force the C ABI exports into the wasm export table +} + +/// Allocate `len` bytes of wasm linear memory for an input buffer (chart +/// bytes, settings JSON, ...). The JS host writes the bytes at the returned +/// offset, passes it to a tile57_* call, then releases it with +/// tile57_wasm_free. Returns 0 when out of memory. +export fn tile57_wasm_alloc(len: usize) ?[*]u8 { + const p = std.c.malloc(len) orelse return null; + return @ptrCast(p); +} + +/// Release a buffer from tile57_wasm_alloc. Only for those buffers — engine +/// outputs still go through tile57_free. +export fn tile57_wasm_free(ptr: ?*anyopaque) void { + std.c.free(ptr); +} From 5448843a86a4555d2a3547a94b4df64d19b75a5d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:12:47 -0400 Subject: [PATCH 02/35] smoke: compose two baked cells and serve from the composite --- bindings/wasm/engine-smoke.mjs | 125 ++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 49 deletions(-) diff --git a/bindings/wasm/engine-smoke.mjs b/bindings/wasm/engine-smoke.mjs index 6a58e045..d9b979c0 100644 --- a/bindings/wasm/engine-smoke.mjs +++ b/bindings/wasm/engine-smoke.mjs @@ -1,19 +1,28 @@ // Smoke test for the full-engine wasm reactor (zig build wasm-engine). // -// Runs the real pipeline inside node's WASI host: bake one S-57 cell to a -// PMTiles archive, open the archive from bytes, fetch one vector tile, and -// render one PNG view. This is the same call sequence a browser chartplotter -// makes; only the WASI shim differs. +// 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 [out.png] -// e.g. node engine-smoke.mjs ~/Charts/enc-src/ALL/ENC_ROOT US5BDRAB/US5BDRAB.000 +// 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 [encRoot, cellRel, pngOut] = process.argv.slice(2); -if (!encRoot || !cellRel) { - console.error("usage: node engine-smoke.mjs [out.png]"); +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); } @@ -34,12 +43,6 @@ function cstr(ptr) { while (m[end] !== 0) end++; return new TextDecoder().decode(m.subarray(ptr, end)); } -function allocBytes(bytes) { - const p = E.tile57_wasm_alloc(bytes.length); - if (!p) throw new Error("tile57_wasm_alloc failed"); - u8().set(bytes, p); - return p; -} function allocCString(s) { const b = new TextEncoder().encode(s); const p = E.tile57_wasm_alloc(b.length + 1); @@ -52,55 +55,78 @@ function allocCString(s) { 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)}`); - } + 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: S-57 cell -> per-chart PMTiles archive ------------------------ -const cellPath = allocCString("/enc/" + cellRel); -let t0 = performance.now(); -check("bake_chart_bytes", E.tile57_bake_chart_bytes(cellPath, outPtr, outLen, errPtr)); -const [arcPtr, arcLen] = readOut(); -console.log(`baked: ${arcLen} bytes in ${(performance.now() - t0).toFixed(0)} ms`); -if (arcLen === 0) throw new Error("bake produced no archive"); - -// ---- open the archive from bytes (no file system involved) --------------- -check("chart_open_bytes", E.tile57_chart_open_bytes(arcPtr, arcLen, outPtr, errPtr)); -const chart = dv().getUint32(outPtr, true); -E.tile57_free(arcPtr); +// ---- 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`); +} -// ---- info -> pick the anchor view ---------------------------------------- +// ---- union bounds -> the view --------------------------------------------- const info = E.tile57_wasm_alloc(96); -E.tile57_chart_get_info(chart, info); -const d = dv(); -const minz = d.getUint8(info), maxz = d.getUint8(info + 1); -const hasAnchor = d.getUint8(info + 48) !== 0; -// No anchor in the archive -> view the bounds center at a harbor-ish zoom. -const anchorLat = hasAnchor ? d.getFloat64(info + 56, true) : (d.getFloat64(info + 24, true) + d.getFloat64(info + 40, true)) / 2; -const anchorLon = hasAnchor ? d.getFloat64(info + 64, true) : (d.getFloat64(info + 16, true) + d.getFloat64(info + 32, true)) / 2; -const anchorZoom = hasAnchor ? d.getFloat64(info + 72, true) : Math.min(14, maxz); -console.log(`info: z${minz}-${maxz} scale 1:${d.getInt32(info + 84, true)} view ${anchorLat.toFixed(4)},${anchorLon.toFixed(4)} @z${anchorZoom.toFixed(1)}`); +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`); +} -// ---- one vector tile at the anchor --------------------------------------- -const z = Math.max(minz, Math.min(maxz, Math.round(anchorZoom))); +const z = zoom; const n = 2 ** z; -const tx = Math.floor(((anchorLon + 180) / 360) * n); -const latR = (anchorLat * Math.PI) / 180; +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); -t0 = performance.now(); -check("chart_tile", E.tile57_chart_tile(chart, z, tx, ty, outPtr, outLen, errPtr)); +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); -// ---- one PNG view at the anchor ------------------------------------------ t0 = performance.now(); -check("chart_png", E.tile57_chart_png(chart, anchorLon, anchorLat, anchorZoom, 800, 600, 0, outPtr, outLen, errPtr)); +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"); @@ -110,5 +136,6 @@ if (pngOut) { } E.tile57_free(pngPtr); -E.tile57_chart_close(chart); +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"); From 750cef23d282593acc663a2530dcecfe6a72d1cb Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:14:16 -0400 Subject: [PATCH 03/35] docs: the wasm engine build + wasi-libc sjlj runtime notice --- THIRD_PARTY_LICENSES.md | 8 +++++ docs/docs/wasm.md | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 docs/docs/wasm.md 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/docs/docs/wasm.md b/docs/docs/wasm.md new file mode 100644 index 00000000..ef707c4c --- /dev/null +++ b/docs/docs/wasm.md @@ -0,0 +1,65 @@ +--- +id: wasm +title: WebAssembly +sidebar_position: 10 +--- + +# WebAssembly + +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, a small WASI shim provides them (for example + `@bjorn3/browser_wasi_shim`, with an in-memory file system). +- **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`). + +## Smoke test + +`bindings/wasm/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/wasm/engine-smoke.mjs \ + US5BDRAB/US5BDRAB.000 US5BDRBB/US5BDRBB.000 --png out.png +``` + +## 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. From ebc4185bc0021a98f47957d7947b3b8ee976249a Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:20:24 -0400 Subject: [PATCH 04/35] bindings: browser WASI shim, JS wrapper, and an in-page chartplotter demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wasi-shim.mjs is a dependency-free wasi_snapshot_preview1 host for the browser: a read-only in-memory file tree preopened at one path, the clock, randomness, and stdout/stderr to the console — the full import surface of tile57-engine.wasm. tile57.mjs wraps the exports one-to-one (linear-memory allocation, C strings, out-params, tile57_error decode). demo.html fetches S-57 cells over HTTP, bakes them, composes them, and renders every pan/zoom view inside the page. The shim path is exercised end to end under node (same code, no node:wasi): bake two cells, compose, tile, PNG. --- bindings/wasm/demo.html | 110 +++++++++++++++ bindings/wasm/tile57.mjs | 137 ++++++++++++++++++ bindings/wasm/wasi-shim.mjs | 270 ++++++++++++++++++++++++++++++++++++ docs/docs/wasm.md | 28 +++- 4 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 bindings/wasm/demo.html create mode 100644 bindings/wasm/tile57.mjs create mode 100644 bindings/wasm/wasi-shim.mjs diff --git a/bindings/wasm/demo.html b/bindings/wasm/demo.html new file mode 100644 index 00000000..30657cf3 --- /dev/null +++ b/bindings/wasm/demo.html @@ -0,0 +1,110 @@ + + + + + +tile57 wasm chartplotter + + + +

tile57 — charts baked and rendered in this page

+
+ + + + +
+chart view +
+ + + diff --git a/bindings/wasm/tile57.mjs b/bindings/wasm/tile57.mjs new file mode 100644 index 00000000..a910091e --- /dev/null +++ b/bindings/wasm/tile57.mjs @@ -0,0 +1,137 @@ +// 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 = exports.tile57_wasm_alloc(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)); + } + /** Copy `bytes` into linear memory. Release with `wasmFree`. */ + alloc(bytes) { + const p = this.e.tile57_wasm_alloc(bytes.length); + if (!p) throw new Error("tile57_wasm_alloc failed"); + 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()); } + warmup() { this.e.tile57_warmup(); } + + /** 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(); + } + + /** 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.e.tile57_wasm_alloc(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 (canonical mariner settings). */ + chartPng(chart, lon, lat, zoom, width, height) { + this.check("chart_png", this.e.tile57_chart_png(chart, lon, lat, zoom, width, height, 0, this.outPtr, this.outLen, this.errPtr)); + return this.takeOut(); + } + + /** Compose open charts (BORROWED: close the compositor before them). */ + composeOpen(charts) { + const list = this.e.tile57_wasm_alloc(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 (canonical mariner settings). */ + composePng(compose, lon, lat, zoom, width, height) { + this.check("compose_png", this.e.tile57_compose_png(compose, lon, lat, zoom, width, height, 0, this.outPtr, this.outLen, this.errPtr)); + return this.takeOut(); + } +} diff --git a/bindings/wasm/wasi-shim.mjs b/bindings/wasm/wasi-shim.mjs new file mode 100644 index 00000000..547904b3 --- /dev/null +++ b/bindings/wasm/wasi-shim.mjs @@ -0,0 +1,270 @@ +// A minimal WASI preview1 shim for the browser (no dependencies; node also +// runs it). It covers exactly what tile57-engine.wasm imports: a read-only +// in-memory file tree preopened at one path, the clock, randomness, and +// stdout/stderr to the console. Everything else returns 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, INVAL: 28, IO: 29, ISDIR: 31, + NOENT: 44, NOSYS: 52, NOTDIR: 54, NOTSUP: 58, ROFS: 69, +}; +const FILETYPE = { DIR: 3, REGULAR: 4 }; + +/** A read-only in-memory file tree, preopened at `root` (e.g. "/enc"). */ +export class MemFS { + constructor(root) { + this.root = root; + this.tree = new Map(); // dir node: Map(name -> node); file node: Uint8Array + } + /** Add one file under the preopen root. `rel` uses "/" separators. */ + add(rel, bytes) { + const parts = rel.split("/").filter(Boolean); + let dir = this.tree; + for (const part of parts.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(parts[parts.length - 1], bytes); + } + /** The node at `rel` ("" or "." -> the root dir), or null. */ + lookup(rel) { + let node = this.tree; + for (const part of rel.split("/").filter((p) => p && p !== ".")) { + if (!(node instanceof Map)) return null; + node = node.get(part); + if (node === undefined) return null; + } + return node; + } +} + +export class WasiShim { + constructor(fsys) { + this.fsys = fsys; + this.memory = null; + // fd table: 0/1/2 stdio, 3 = the preopen dir, others opened files. + 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)); } + + filestat(buf, node) { + const d = this.view(); + const file = node instanceof Uint8Array; + 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.length : 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(); + 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, node.length - pos); + if (n <= 0) break; + m.set(node.subarray(pos, pos + n), buf); + pos += n; total += n; + } + return total; + } + + imports() { + const nosys = () => E.NOSYS; + const shim = this; + const file = (fd) => { + const f = shim.fds.get(fd); + return f && f.node instanceof Uint8Array ? f : null; + }; + return { + wasi_snapshot_preview1: { + 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) => { + if (fd !== 1 && fd !== 2) return E.BADF; + const d = shim.view(); + 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; + }, + + 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 dir = shim.fds.get(dirfd); + if (!dir || dir.node instanceof Uint8Array) return E.BADF; + if (oflags & 0b1101) return E.ROFS; // creat / excl / trunc: read-only tree + const rel = (dir.path ? dir.path + "/" : "") + shim.str(path, pathLen); + const node = shim.fsys.lookup(rel); + if (node === null) return E.NOENT; + if (oflags & 0b10 && node instanceof Uint8Array) return E.NOTDIR; // O_DIRECTORY + 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.length; + 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_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 Uint8Array ? FILETYPE.REGULAR : FILETYPE.DIR); + } else return E.BADF; + d.setUint16(buf + 2, 0, true); + d.setBigUint64(buf + 8, ~0n & 0xffffffffffffffffn, true); // all rights + d.setBigUint64(buf + 16, ~0n & 0xffffffffffffffffn, true); + return E.SUCCESS; + }, + path_filestat_get: (dirfd, _flags, path, pathLen, buf) => { + const dir = shim.fds.get(dirfd); + if (!dir || dir.node instanceof Uint8Array) return E.BADF; + const node = shim.fsys.lookup((dir.path ? dir.path + "/" : "") + shim.str(path, pathLen)); + 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 Uint8Array) 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 Uint8Array ? FILETYPE.REGULAR : FILETYPE.DIR); + m.set(name, buf + off + 24); + off += need; + } + d.setUint32(used, off, true); + return E.SUCCESS; + }, + + // The engine never reaches these on the read-only browser path. + fd_fdstat_set_flags: nosys, + fd_filestat_set_size: nosys, + fd_filestat_set_times: nosys, + fd_pwrite: nosys, + fd_renumber: nosys, + fd_sync: () => E.SUCCESS, + path_create_directory: nosys, + path_filestat_set_times: nosys, + path_link: nosys, + path_readlink: nosys, + path_remove_directory: nosys, + path_rename: nosys, + path_symlink: nosys, + path_unlink_file: nosys, + poll_oneoff: nosys, + }, + }; + } +} diff --git a/docs/docs/wasm.md b/docs/docs/wasm.md index ef707c4c..c7feeeeb 100644 --- a/docs/docs/wasm.md +++ b/docs/docs/wasm.md @@ -22,8 +22,8 @@ client-side: a chartplotter with no server. - **Target**: `wasm32-wasi`. The module imports only `wasi_snapshot_preview1` functions. In node, the built-in `node:wasi` host provides them. In a - browser, a small WASI shim provides them (for example - `@bjorn3/browser_wasi_shim`, with an in-memory file system). + browser, `bindings/wasm/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 @@ -45,6 +45,13 @@ client-side: a chartplotter with no server. `tile57_chart_open_bytes` and keeps residency under its own control. - SQLite (raster charts) is built single-thread (`SQLITE_THREADSAFE=0`). +## The JS wrapper + +`bindings/wasm/tile57.mjs` wraps the exports one-to-one for a JS host: it +handles linear-memory allocation, C strings, out-parameters, and the +`tile57_error` decode, and returns engine outputs as `Uint8Array` copies. +Browser and node both run it. + ## Smoke test `bindings/wasm/engine-smoke.mjs` drives the real pipeline under node's WASI @@ -57,6 +64,23 @@ node bindings/wasm/engine-smoke.mjs \ US5BDRAB/US5BDRAB.000 US5BDRBB/US5BDRBB.000 --png out.png ``` +## Browser demo + +`bindings/wasm/demo.html` is a complete in-page chartplotter: it fetches S-57 +cells over HTTP, bakes them, composes them, and renders every pan/zoom view — +all inside the page. Serve a directory that holds the page, the two `.mjs` +modules, `tile57-engine.wasm`, and an `enc/` tree with the cells: + +```sh +zig build wasm-engine +mkdir demo && cd demo +ln -s ../bindings/wasm/{demo.html,tile57.mjs,wasi-shim.mjs} . +ln -s ../zig-out/bin/tile57-engine.wasm . +ln -s enc +python3 -m http.server 8080 +# open http://localhost:8080/demo.html?cells=US5BDRAB/US5BDRAB.000 +``` + ## The style-only module `zig build wasm` still builds the separate, much smaller style engine From f788e2d52dd6f00e7e7609308b383d587af2203d Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:32:06 -0400 Subject: [PATCH 05/35] bindings: writable MemFS in the shim; bakeZip in the wrapper The engine's zip bake writes per-chart archives, so the shim's file tree now supports create, write, rename, and directory ops. bakeZip wraps tile57_bake_zip: one call bakes a dropped exchange set into the tree. --- bindings/wasm/tile57.mjs | 12 ++ bindings/wasm/wasi-shim.mjs | 256 +++++++++++++++++++++++++++--------- 2 files changed, 208 insertions(+), 60 deletions(-) diff --git a/bindings/wasm/tile57.mjs b/bindings/wasm/tile57.mjs index a910091e..ed9e102d 100644 --- a/bindings/wasm/tile57.mjs +++ b/bindings/wasm/tile57.mjs @@ -71,6 +71,18 @@ export class Tile57 { 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. */ + 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); + } + /** Open a baked archive from bytes; returns the chart handle. */ chartOpenBytes(archive) { const p = this.alloc(archive); diff --git a/bindings/wasm/wasi-shim.mjs b/bindings/wasm/wasi-shim.mjs index 547904b3..894a38a7 100644 --- a/bindings/wasm/wasi-shim.mjs +++ b/bindings/wasm/wasi-shim.mjs @@ -1,7 +1,8 @@ // A minimal WASI preview1 shim for the browser (no dependencies; node also -// runs it). It covers exactly what tile57-engine.wasm imports: a read-only -// in-memory file tree preopened at one path, the clock, randomness, and -// stdout/stderr to the console. Everything else returns ENOSYS. +// 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"); @@ -11,45 +12,100 @@ // wasi.start(inst); // reactor _initialize const E = { - SUCCESS: 0, BADF: 8, INVAL: 28, IO: 29, ISDIR: 31, - NOENT: 44, NOSYS: 52, NOTDIR: 54, NOTSUP: 58, ROFS: 69, + 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 read-only in-memory file tree, preopened at `root` (e.g. "/enc"). */ +/** 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(); // dir node: Map(name -> node); file node: Uint8Array + this.tree = new Map(); } - /** Add one file under the preopen root. `rel` uses "/" separators. */ + /** Add one file under the preopen root. `rel` uses "/" separators; + * intermediate directories are created. */ add(rel, bytes) { - const parts = rel.split("/").filter(Boolean); + const p = parts(rel); let dir = this.tree; - for (const part of parts.slice(0, -1)) { + 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(parts[parts.length - 1], bytes); + dir.set(p[p.length - 1], new FileNode(bytes)); } /** The node at `rel` ("" or "." -> the root dir), or null. */ lookup(rel) { let node = this.tree; - for (const part of rel.split("/").filter((p) => p && p !== ".")) { + 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 files. + // 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 @@ -64,14 +120,21 @@ export class WasiShim { 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. + at(dirfd, ptr, len) { + const dir = this.fds.get(dirfd); + if (!dir || dir.node instanceof FileNode) return null; + return (dir.path ? dir.path + "/" : "") + this.str(ptr, len); + } + filestat(buf, node) { const d = this.view(); - const file = node instanceof Uint8Array; + 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.length : 0), true); // size + 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 @@ -79,25 +142,38 @@ export class WasiShim { // 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(); + 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, node.length - pos); + const n = Math.min(len, data.length - pos); if (n <= 0) break; - m.set(node.subarray(pos, pos + n), buf); + 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 Uint8Array ? f : null; + return f && f.node instanceof FileNode ? f : null; }; return { wasi_snapshot_preview1: { @@ -124,22 +200,36 @@ export class WasiShim { proc_exit: (code) => { throw new Error(`proc_exit(${code})`); }, fd_write: (fd, iovs, iovsLen, nwritten) => { - if (fd !== 1 && fd !== 2) return E.BADF; const d = shim.view(); - 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); + 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; } - d.setUint32(nwritten, total, true); + 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; }, @@ -158,13 +248,19 @@ export class WasiShim { }, path_open: (dirfd, _dirflags, path, pathLen, oflags, _rb, _ri, _fdflags, outFd) => { - const dir = shim.fds.get(dirfd); - if (!dir || dir.node instanceof Uint8Array) return E.BADF; - if (oflags & 0b1101) return E.ROFS; // creat / excl / trunc: read-only tree - const rel = (dir.path ? dir.path + "/" : "") + shim.str(path, pathLen); - const node = shim.fsys.lookup(rel); - if (node === null) return E.NOENT; - if (oflags & 0b10 && node instanceof Uint8Array) return E.NOTDIR; // O_DIRECTORY + 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); @@ -190,7 +286,7 @@ export class WasiShim { 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.length; + 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; @@ -204,23 +300,29 @@ export class WasiShim { 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 Uint8Array ? FILETYPE.REGULAR : FILETYPE.DIR); + 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, ~0n & 0xffffffffffffffffn, true); // all rights - d.setBigUint64(buf + 16, ~0n & 0xffffffffffffffffn, 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 dir = shim.fds.get(dirfd); - if (!dir || dir.node instanceof Uint8Array) return E.BADF; - const node = shim.fsys.lookup((dir.path ? dir.path + "/" : "") + shim.str(path, pathLen)); + 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; @@ -229,7 +331,7 @@ export class WasiShim { fd_readdir: (fd, buf, bufLen, cookie, used) => { const f = shim.fds.get(fd); if (!f) return E.BADF; - if (f.node instanceof Uint8Array) return E.NOTDIR; + if (f.node instanceof FileNode) return E.NOTDIR; const names = [...f.node.keys()]; const d = shim.view(), m = shim.bytes(); let off = 0; @@ -240,7 +342,7 @@ export class WasiShim { 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 Uint8Array ? FILETYPE.REGULAR : FILETYPE.DIR); + d.setUint8(buf + off + 20, f.node.get(names[i]) instanceof FileNode ? FILETYPE.REGULAR : FILETYPE.DIR); m.set(name, buf + off + 24); off += need; } @@ -248,21 +350,55 @@ export class WasiShim { return E.SUCCESS; }, - // The engine never reaches these on the read-only browser path. - fd_fdstat_set_flags: nosys, - fd_filestat_set_size: nosys, - fd_filestat_set_times: nosys, - fd_pwrite: nosys, - fd_renumber: nosys, + 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, - path_create_directory: nosys, - path_filestat_set_times: nosys, + fd_fdstat_set_flags: () => E.SUCCESS, + fd_renumber: nosys, path_link: nosys, path_readlink: nosys, - path_remove_directory: nosys, - path_rename: nosys, path_symlink: nosys, - path_unlink_file: nosys, poll_oneoff: nosys, }, }; From 4f274acedfb4a5e6e02148444565bbe1f73075dd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:49:18 -0400 Subject: [PATCH 06/35] bindings: WebGPU renderer and a full chartplotter demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gpu-renderer.mjs draws the engine's GPU scenes with WebGPU. Its WGSL is a port of the reference shaders in shaders/ over the same vertex, quad, and uniform layouts. The engine batches the ranges (tile57_gpu_batch); the renderer uploads the buffers once per scene and redraws from uniforms alone, so pan and zoom stay live between scene rebuilds. tile57.mjs grows the GPU surface: scene build and decode, batch, the sprite and SDF-glyph atlas bakes, the colortables, and the ABI layout check. demo.html is now a full-screen chartplotter: drop .000 cells or an exchange-set .zip, drag to pan, wheel to zoom, HUD, zoom and fullscreen controls over the map. WebGPU when the browser has it; PNG views otherwise (?png=1 forces the fallback). A pre-module script catches drops from first paint — an uncancelled drop navigates the tab while the engine is still compiling. The shim resolves absolute paths against the preopen root: the engine passes some opens that way, and the zip bake's archive writes hit it. --- bindings/wasm/demo.html | 412 +++++++++++++++++++++++++------ bindings/wasm/gpu-renderer.mjs | 437 +++++++++++++++++++++++++++++++++ bindings/wasm/tile57.mjs | 124 ++++++++++ bindings/wasm/wasi-shim.mjs | 11 +- docs/docs/wasm.md | 29 ++- 5 files changed, 930 insertions(+), 83 deletions(-) create mode 100644 bindings/wasm/gpu-renderer.mjs diff --git a/bindings/wasm/demo.html b/bindings/wasm/demo.html index 30657cf3..80837231 100644 --- a/bindings/wasm/demo.html +++ b/bindings/wasm/demo.html @@ -1,110 +1,372 @@ + tile57 wasm chartplotter -

tile57 — charts baked and rendered in this page

-
- - - - + +chart view +
+
tile57 loading…
+
no charts
+
+
+
+
starting engine…
+
+
+ + +
-chart view -
+
Drop S-57 charts here
+ .000 cells (with their update files), or an exchange-set .zip
+ + diff --git a/bindings/wasm/gpu-renderer.mjs b/bindings/wasm/gpu-renderer.mjs new file mode 100644 index 00000000..67279b73 --- /dev/null +++ b/bindings/wasm/gpu-renderer.mjs @@ -0,0 +1,437 @@ +// 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. +// +// usage: +// const r = await GpuRenderer.create(canvas, t, pixelRatio); // t: Tile57 +// r.setScene(t, t.composeGpuScene(...)); // takes ownership, frees it +// 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. `pixelRatio` must match + * every later gpu-scene call, or the sprite UVs will not index the atlas. */ + static async create(canvas, t, pixelRatio) { + const l = t.abiGpuLayout(); + 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, t.bakeSpriteMln(pixelRatio, 0).png); + r.atlases[ATLAS.GLYPH] = await texFromPng(device, t.bakeGlyphSdf(0).png); + r.atlases[ATLAS.GLYPH_BOLD] = await texFromPng(device, t.bakeGlyphSdf(1).png); + r.atlases[ATLAS.GLYPH_ITALIC] = await texFromPng(device, t.bakeGlyphSdf(2).png); + r.atlasHave = (1 << ATLAS.SPRITE) | (1 << ATLAS.GLYPH) | (1 << ATLAS.GLYPH_BOLD) | (1 << ATLAS.GLYPH_ITALIC); + r.halo = nodataColor(t.colortablesDefault()); + r.msaa = null; + r.buffers = null; + r.draws = []; + return r; + } + + // 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; + } + + /** Take a scene (from Tile57.chartGpuScene / composeGpuScene): upload its + * buffers, batch its ranges, build the bind groups, release it. */ + setScene(t, scene) { + this.disposeScene(); + this.buffers = { + vertex: this.upload(scene.vertexBytes(), GPUBufferUsage.VERTEX), + index: this.upload(scene.indexBytes(), GPUBufferUsage.INDEX), + quad: this.upload(scene.quadBytes(), GPUBufferUsage.VERTEX), + }; + this.patternTex = scene.patternList().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 = t.gpuBatch(scene, { atlasHave: this.atlasHave, halo: this.halo }); + scene.free(); + + // 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 }); + } + + const S = 256 * 2 ** cam.zoom * this.pixelRatio; // framebuffer px per world unit + const [cx, cy] = lonLatToWorld(cam.lon, cam.lat); + const sx = (S * 2) / w, sy = (-S * 2) / h; + 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); + f.set([sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, 0, 0, -cx * sx, -cy * sy, 0, 1]); // mvp + 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] = cx; // wrap_x + f[22] = 0; f[23] = 1; // rot_sin, rot_cos (north-up) + 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 ox = -cx * S + w / 2, oy = -cy * S + 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/wasm/tile57.mjs b/bindings/wasm/tile57.mjs index ed9e102d..263535c4 100644 --- a/bindings/wasm/tile57.mjs +++ b/bindings/wasm/tile57.mjs @@ -125,6 +125,130 @@ export class Tile57 { 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. */ + chartGpuScene(chart, lon, lat, zoom, width, height, pixelRatio) { + const sp = this.e.tile57_wasm_alloc(44); + this.check("chart_gpu_scene", this.e.tile57_chart_gpu_scene(chart, lon, lat, zoom, width, height, 0, pixelRatio, sp, this.errPtr)); + return this.sceneView(sp); + } + /** The composed twin of chartGpuScene. */ + composeGpuScene(compose, lon, lat, zoom, width, height, pixelRatio) { + const sp = this.e.tile57_wasm_alloc(44); + this.check("compose_gpu_scene", this.e.tile57_compose_gpu_scene(compose, lon, lat, zoom, width, height, 0, pixelRatio, sp, this.errPtr)); + 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.e.tile57_wasm_alloc(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.e.tile57_wasm_alloc(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.e.tile57_wasm_alloc(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.e.tile57_wasm_alloc(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()); + } + /** Compose open charts (BORROWED: close the compositor before them). */ composeOpen(charts) { const list = this.e.tile57_wasm_alloc(4 * charts.length); diff --git a/bindings/wasm/wasi-shim.mjs b/bindings/wasm/wasi-shim.mjs index 894a38a7..ae3830a3 100644 --- a/bindings/wasm/wasi-shim.mjs +++ b/bindings/wasm/wasi-shim.mjs @@ -121,10 +121,19 @@ export class WasiShim { 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; - return (dir.path ? dir.path + "/" : "") + this.str(ptr, len); + 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) { diff --git a/docs/docs/wasm.md b/docs/docs/wasm.md index c7feeeeb..18fc06c3 100644 --- a/docs/docs/wasm.md +++ b/docs/docs/wasm.md @@ -64,23 +64,38 @@ node bindings/wasm/engine-smoke.mjs \ US5BDRAB/US5BDRAB.000 US5BDRBB/US5BDRBB.000 --png out.png ``` +## WebGPU + +`bindings/wasm/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/wasm/demo.html` is a complete in-page chartplotter: it fetches S-57 -cells over HTTP, bakes them, composes them, and renders every pan/zoom view — -all inside the page. Serve a directory that holds the page, the two `.mjs` -modules, `tile57-engine.wasm`, and an `enc/` tree with the cells: +`bindings/wasm/demo.html` is a complete in-page chartplotter. Drop S-57 +charts on it — `.000` cells with their update files, or an exchange-set +`.zip` (the engine bakes the whole set through the shim's writable file +tree). Drag to pan, wheel to zoom, double-click to zoom in. It renders with +WebGPU where the browser has it, and falls back to PNG views (`?png=1` +forces the fallback). 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/wasm/{demo.html,tile57.mjs,wasi-shim.mjs} . +ln -s ../bindings/wasm/{demo.html,tile57.mjs,wasi-shim.mjs,gpu-renderer.mjs} . ln -s ../zig-out/bin/tile57-engine.wasm . -ln -s enc python3 -m http.server 8080 -# open http://localhost:8080/demo.html?cells=US5BDRAB/US5BDRAB.000 +# 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 From 3803585defe9c3f083ab9ed5b18dd9e105679b67 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:55:56 -0400 Subject: [PATCH 07/35] demo: run the engine in a Web Worker, with a real loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bake holds the CPU for seconds and a district zip for minutes; on the main thread the page froze and no loader could even animate. The engine now lives in engine-worker.mjs and the page drives it one call per RPC message, so the map, the HUD, and the loader stay live while it works. The loader shows determinate progress: a dropped zip is listed (tile57_zip_list), then extracted (tile57_zip_extract) and baked one cell at a time — N of M with the cell name, for plain cells and zip members alike. zip_extract creates no directories, so the worker pre-creates each out path's parents in the shim tree (MemFS.mkdirs). The GPU renderer no longer holds an engine handle: it takes the atlas assets at create and plain draw-ready data per scene, so the engine can stay in the worker while the device and buffers live on the page. When WebGPU is unavailable the HUD now names the reason for the PNG fallback instead of switching silently. --- bindings/wasm/demo.html | 288 +++++++++++++++++++++----------- bindings/wasm/engine-worker.mjs | 106 ++++++++++++ bindings/wasm/gpu-renderer.mjs | 48 +++--- bindings/wasm/tile57.mjs | 28 +++- bindings/wasm/wasi-shim.mjs | 9 + docs/docs/wasm.md | 16 +- 6 files changed, 371 insertions(+), 124 deletions(-) create mode 100644 bindings/wasm/engine-worker.mjs diff --git a/bindings/wasm/demo.html b/bindings/wasm/demo.html index 80837231..4b65e3f2 100644 --- a/bindings/wasm/demo.html +++ b/bindings/wasm/demo.html @@ -7,8 +7,13 @@ Drop cells on the page: .000 files (with their .001.. updates), or a .zip exchange set. Drag to pan, wheel to zoom, double-click to zoom in. + The engine runs in a Web Worker (engine-worker.mjs): a bake holds the CPU + for seconds and a district zip for minutes, and off the main thread the map + and the loader stay live the whole time. + Rendering: WebGPU when the browser has it (the engine emits draw-ready GPU - scenes; pan/zoom redraws live from uniforms). PNG views otherwise. + scenes; pan/zoom redraws live from uniforms). PNG views otherwise; the HUD + says which, and why, when it fell back. Serve a directory that holds this page, the .mjs modules beside it, and tile57-engine.wasm (zig build wasm-engine). Optional: @@ -33,7 +38,7 @@ #hud { position: fixed; top: 10px; left: 10px; z-index: 3; background: rgba(12, 16, 22, 0.78); color: #dde3ea; border-radius: 8px; padding: 8px 12px; font-family: ui-monospace, monospace; font-size: 12px; - line-height: 1.5; pointer-events: none; max-width: 44ch; } + line-height: 1.5; pointer-events: none; max-width: 46ch; } #hud b { color: #fff; font-weight: 600; } #status { color: #8fd3a7; } @@ -50,6 +55,24 @@ border: 2px dashed #4a5561; border-radius: 12px; padding: 28px 40px; font-size: 16px; text-align: center; } body.droptarget #hint div { border-color: #8fd3a7; color: #8fd3a7; } + + /* The loader: alive while the worker bakes. */ + #loader { position: fixed; inset: 0; z-index: 4; display: none; + align-items: center; justify-content: center; + background: rgba(8, 10, 14, 0.45); pointer-events: none; } + #loader.show { display: flex; } + #loader .box { background: rgba(12, 16, 22, 0.92); color: #dde3ea; + border: 1px solid #3a4450; border-radius: 12px; + padding: 22px 30px; min-width: 34ch; text-align: center; } + #spin { width: 28px; height: 28px; margin: 0 auto 12px; + border: 3px solid #3a4450; border-top-color: #8fd3a7; + border-radius: 50%; animation: spin 0.9s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + #loadlabel { margin-bottom: 10px; } + #bar { height: 6px; background: #232a33; border-radius: 3px; overflow: hidden; } + #bar div { height: 100%; width: 0; background: #8fd3a7; transition: width 0.15s; } + #bar.indeterminate div { width: 30%; animation: slide 1.1s ease-in-out infinite alternate; } + @keyframes slide { from { margin-left: 0; } to { margin-left: 70%; } } @@ -70,6 +93,11 @@
Drop S-57 charts here
.000 cells (with their update files), or an exchange-set .zip
+
+
+
starting engine…
+
+
diff --git a/bindings/wasm/engine-worker.mjs b/bindings/wasm/engine-worker.mjs new file mode 100644 index 00000000..e0e87f13 --- /dev/null +++ b/bindings/wasm/engine-worker.mjs @@ -0,0 +1,106 @@ +// 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: the ABI layout, the + // four atlas PNGs at the page's pixel ratio, and the colortables the halo + // and clear colours come from. + gpuAssets({ pixelRatio }) { + const r = { + layout: t.abiGpuLayout(), + spritePng: t.bakeSpriteMln(pixelRatio, 0).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]]; + }, + + addFile({ path, bytes }) { fsys.add(path, bytes); }, + + 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); + }, + + bakeCell({ path }) { + const arc = t.bakeChartBytes(path); + return arc ? [arc, [arc.buffer]] : null; + }, + + 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 }) { + const png = compose + ? t.composePng(compose, lon, lat, zoom, w, h) + : t.chartPng(chart, lon, lat, zoom, w, h); + 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 }) { + const scene = compose + ? t.composeGpuScene(compose, lon, lat, zoom, w, h, pixelRatio) + : t.chartGpuScene(chart, lon, lat, zoom, w, h, pixelRatio); + 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/wasm/gpu-renderer.mjs b/bindings/wasm/gpu-renderer.mjs index 67279b73..4dbfae89 100644 --- a/bindings/wasm/gpu-renderer.mjs +++ b/bindings/wasm/gpu-renderer.mjs @@ -9,10 +9,15 @@ // 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, t, pixelRatio); // t: Tile57 -// r.setScene(t, t.composeGpuScene(...)); // takes ownership, frees it -// r.draw(camera); // {lon, lat, zoom}, any frame +// 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; @@ -244,10 +249,12 @@ function nodataColor(colortablesJson, scheme = "DAY") { export class GpuRenderer { static supported() { return typeof navigator !== "undefined" && !!navigator.gpu; } - /** Build the device, pipelines, and atlas textures. `pixelRatio` must match - * every later gpu-scene call, or the sprite UVs will not index the atlas. */ - static async create(canvas, t, pixelRatio) { - const l = t.abiGpuLayout(); + /** 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}`); @@ -290,12 +297,12 @@ export class GpuRenderer { // 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, t.bakeSpriteMln(pixelRatio, 0).png); - r.atlases[ATLAS.GLYPH] = await texFromPng(device, t.bakeGlyphSdf(0).png); - r.atlases[ATLAS.GLYPH_BOLD] = await texFromPng(device, t.bakeGlyphSdf(1).png); - r.atlases[ATLAS.GLYPH_ITALIC] = await texFromPng(device, t.bakeGlyphSdf(2).png); + 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.halo = nodataColor(t.colortablesDefault()); + r.halo = nodataColor(assets.colortables); r.msaa = null; r.buffers = null; r.draws = []; @@ -310,23 +317,22 @@ export class GpuRenderer { return buf; } - /** Take a scene (from Tile57.chartGpuScene / composeGpuScene): upload its - * buffers, batch its ranges, build the bind groups, release it. */ - setScene(t, scene) { + /** 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(scene.vertexBytes(), GPUBufferUsage.VERTEX), - index: this.upload(scene.indexBytes(), GPUBufferUsage.INDEX), - quad: this.upload(scene.quadBytes(), GPUBufferUsage.VERTEX), + vertex: this.upload(vertex, GPUBufferUsage.VERTEX), + index: this.upload(index, GPUBufferUsage.INDEX), + quad: this.upload(quad, GPUBufferUsage.VERTEX), }; - this.patternTex = scene.patternList().map(({ w, h, rgba }) => { + 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 = t.gpuBatch(scene, { atlasHave: this.atlasHave, halo: this.halo }); - scene.free(); + this.draws = draws; // One uniform slot per draw; one bind group per distinct texture. const n = Math.max(1, this.draws.length); diff --git a/bindings/wasm/tile57.mjs b/bindings/wasm/tile57.mjs index 263535c4..72b7d070 100644 --- a/bindings/wasm/tile57.mjs +++ b/bindings/wasm/tile57.mjs @@ -73,7 +73,8 @@ export class Tile57 { /** 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. */ + * 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); @@ -83,6 +84,31 @@ export class Tile57 { 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.e.tile57_wasm_alloc(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); diff --git a/bindings/wasm/wasi-shim.mjs b/bindings/wasm/wasi-shim.mjs index ae3830a3..32384722 100644 --- a/bindings/wasm/wasi-shim.mjs +++ b/bindings/wasm/wasi-shim.mjs @@ -63,6 +63,15 @@ export class MemFS { } dir.set(p[p.length - 1], new FileNode(bytes)); } + /** 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; diff --git a/docs/docs/wasm.md b/docs/docs/wasm.md index 18fc06c3..f9206f2f 100644 --- a/docs/docs/wasm.md +++ b/docs/docs/wasm.md @@ -78,16 +78,22 @@ rebuilds. `bindings/wasm/demo.html` is a complete in-page chartplotter. Drop S-57 charts on it — `.000` cells with their update files, or an exchange-set -`.zip` (the engine bakes the whole set through the shim's writable file -tree). Drag to pan, wheel to zoom, double-click to zoom in. It renders with +`.zip`. Drag to pan, wheel to zoom, double-click to zoom in. It renders with WebGPU where the browser has it, and falls back to PNG views (`?png=1` -forces the fallback). Serve a directory that holds the page, the `.mjs` -modules, and the engine: +forces the fallback; the HUD names the reason when the fallback engages). + +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 one cell at a time, so the loader shows real +per-cell progress. + +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/wasm/{demo.html,tile57.mjs,wasi-shim.mjs,gpu-renderer.mjs} . +ln -s ../bindings/wasm/{demo.html,tile57.mjs,wasi-shim.mjs,gpu-renderer.mjs,engine-worker.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 From 04a30a9d35f2e3c15f43c412e8430bf06194ea41 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Sun, 23 Aug 2026 23:56:48 -0400 Subject: [PATCH 08/35] demo: name the insecure-context cause when WebGPU is hidden navigator.gpu exists only in a secure context; a LAN IP over plain http hides it with the same symptom as a browser without WebGPU. --- bindings/wasm/demo.html | 1 + 1 file changed, 1 insertion(+) diff --git a/bindings/wasm/demo.html b/bindings/wasm/demo.html index 4b65e3f2..f73ca81c 100644 --- a/bindings/wasm/demo.html +++ b/bindings/wasm/demo.html @@ -163,6 +163,7 @@ const canvas = $("map"), img = $("mapimg"); 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 { From eff424ca43ad11ddd22561426422a3b583e4e8af Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 00:09:33 -0400 Subject: [PATCH 09/35] docs: embed the wasm chartplotter as the site's live demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs workflow builds the engine (ReleaseSmall: one third the download, this workload runs just as fast), stages the demo app under static/demo-app/, and src/pages/demo.jsx embeds it at /demo — GitHub Pages is https, so WebGPU is available to it. A Live Demo item joins the navbar, and engine changes now redeploy the site so the demo always runs the current engine. CI builds the wasm engine too, so a break surfaces on every push, not just docs deploys. Demo polish: the drop hint links NOAA's free ENC downloads and says charts never leave the page, arrow keys pan and +/- zoom, an engine that fails to start reports itself instead of spinning, and a batch's failures land in one status line. --- .github/workflows/ci.yml | 3 ++ .github/workflows/docs.yml | 33 ++++++++++++++++++++-- bindings/wasm/demo.html | 57 ++++++++++++++++++++++++++++++++------ docs/.gitignore | 4 +++ docs/docs/wasm.md | 21 ++++++++++---- docs/docusaurus.config.js | 6 ++++ docs/src/pages/demo.jsx | 30 ++++++++++++++++++++ 7 files changed, 139 insertions(+), 15 deletions(-) create mode 100644 docs/src/pages/demo.jsx 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..9c8c7980 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/wasm/**" + - "src/**" + - "build.zig" + - "build.zig.zon" - ".github/workflows/docs.yml" workflow_dispatch: @@ -22,14 +28,37 @@ 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/wasm/demo.html docs/static/demo-app/index.html + cp bindings/wasm/wasi-shim.mjs bindings/wasm/tile57.mjs \ + bindings/wasm/gpu-renderer.mjs bindings/wasm/engine-worker.mjs \ + docs/static/demo-app/ + cp zig-out/bin/tile57-engine.wasm docs/static/demo-app/ + + # No committed lockfile, so `npm install` (not `npm ci`). - name: Install working-directory: docs run: npm install diff --git a/bindings/wasm/demo.html b/bindings/wasm/demo.html index f73ca81c..60aa1f85 100644 --- a/bindings/wasm/demo.html +++ b/bindings/wasm/demo.html @@ -26,7 +26,9 @@ + tile57 wasm chartplotter + @@ -129,7 +145,10 @@ const $ = (id) => document.getElementById(id); const q = new URLSearchParams(location.search); -const status = (s) => { $("status").textContent = s; }; +const status = (s, bad = false) => { + $("status").textContent = s; + $("status").classList.toggle("bad", bad); +}; const loader = { show(label) { @@ -166,7 +185,7 @@ worker.onerror = (e) => { console.error(e); - status(`engine worker failed: ${e.message ?? "see console"}`); + status(`engine worker failed: ${e.message ?? "see console"}`, true); }; loader.show("downloading the engine (a few MB on first visit)…"); @@ -178,10 +197,24 @@ $("loadlabel").textContent = `engine failed to start: ${e.message}`; $("spin").style.display = "none"; $("bar").style.display = "none"; - status("engine failed to start"); + status("engine failed to start", true); throw e; } +// Theme the chrome from the engine's own S-52 colour table (day). The CSS +// fallbacks carry the same values, so this only matters if they drift. +try { + const day = (await rpc("palette")).day; + const root = document.documentElement.style; + for (const [token, cssVar] of [ + ["NODTA", "--nodta"], ["UIBCK", "--uibck"], ["UIBDR", "--uibdr"], + ["UINFD", "--uinfd"], ["UINFF", "--uinff"], ["UINFB", "--uinfb"], + ["UINFR", "--uinfr"], ["CURSR", "--cursr"], + ]) if (day?.[token]) root.setProperty(cssVar, day[token]); +} catch (e) { + console.warn("palette theming skipped:", e); +} + // ---- renderer: WebGPU, or PNG views --------------------------------------- const dpr = Math.min(devicePixelRatio || 1, 2); const canvas = $("map"), img = $("mapimg"); @@ -257,7 +290,7 @@ sceneCam = { ...cam }; } catch (e) { console.error(e); - status(`render failed: ${e.message}`); + status(`render failed: ${e.message}`, true); } rebuilding = false; if (rebuildAgain) { rebuildAgain = false; scheduleRebuild(0); } @@ -475,7 +508,7 @@ loading = false; } - if (failed.length) status(failed.slice(0, 2).join("; ") + (failed.length > 2 ? ` (+${failed.length - 2} more, see console)` : "")); + if (failed.length) status(failed.slice(0, 2).join("; ") + (failed.length > 2 ? ` (+${failed.length - 2} more, see console)` : ""), true); if (!added.length) { hud(); return; } // Rebuild the composite over everything loaded (it borrows the charts). @@ -498,7 +531,7 @@ for (const cell of preload.split(",")) { status(`fetching ${cell}…`); const r = await fetch(`${encBase}/${cell}`); - if (!r.ok) { status(`fetch ${cell}: ${r.status}`); continue; } + if (!r.ok) { status(`fetch ${cell}: ${r.status}`, true); continue; } files.push(new File([await r.blob()], cell.replace(/^.*\//, ""))); } if (files.length) await loadFiles(files); diff --git a/bindings/wasm/engine-worker.mjs b/bindings/wasm/engine-worker.mjs index e0e87f13..d5ed82a9 100644 --- a/bindings/wasm/engine-worker.mjs +++ b/bindings/wasm/engine-worker.mjs @@ -39,6 +39,10 @@ const ops = { return [r, [r.spritePng.buffer, r.glyphPng.buffer, r.glyphBoldPng.buffer, r.glyphItalicPng.buffer]]; }, + // 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); }, zipList({ path }) { return t.zipList(path); }, From 2f9a8eaa05c605febeb511f41ba5661df8fc7ab7 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 00:26:06 -0400 Subject: [PATCH 11/35] ci: format build.zig; keep the time(3) binding portable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zig fmt re-columns the sjlj flag table. The time binding uses std.c.time_t only where the target defines it — Windows leaves it void in std.c, and the wasi fix had broken both Windows cross-compiles. --- build.zig | 6 +++--- src/capi.zig | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/build.zig b/build.zig index 7890175e..db0652ed 100644 --- a/build.zig +++ b/build.zig @@ -59,9 +59,9 @@ fn addSysrootIncludes(b: *std.Build, mod: *std.Build.Module) void { // driver-level -mexception-handling still matters: it defines // __wasm_exception_handling__, which wasi's setjmp.h gates on. const wasm_sjlj_flags = [_][]const u8{ - "-mexception-handling", "-mllvm", - "-wasm-enable-sjlj", "-Xclang", - "-target-feature", "-Xclang", + "-mexception-handling", "-mllvm", + "-wasm-enable-sjlj", "-Xclang", + "-target-feature", "-Xclang", "+exception-handling", }; diff --git a/src/capi.zig b/src/capi.zig index 712392be..cbdd1097 100644 --- a/src/capi.zig +++ b/src/capi.zig @@ -63,9 +63,12 @@ fn sharedIo() std.Io { // Wall-clock time for "today" date resolution in tile57_style_build. Zig 0.16 // keeps the clock behind Io; the lib links libc, so call time(3) directly. -// time_t, not c_long: wasm32's c_long is 32-bit while wasi's time_t is 64-bit, -// and wasm-ld rejects the signature mismatch against libc's definition. -extern fn time(tloc: ?*std.c.time_t) callconv(.c) std.c.time_t; +// std.c.time_t where the target defines it — wasi's is 64-bit while wasm32's +// c_long is 32-bit, and wasm-ld rejects the signature mismatch against libc. +// Windows leaves std.c.time_t void, so the binding keeps the c_long it always +// used there (mingw maps time() onto its 64-bit variant itself). +const CTimeT = if (std.c.time_t == void) c_long else std.c.time_t; +extern fn time(tloc: ?*CTimeT) callconv(.c) CTimeT; // Keep in sync with the TILE57_VERSION_* macros in tile57.h. const version_string = "0.3.0"; From a4fd821eefaa62c74dd5841049bd71706d620333 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 07:05:01 -0400 Subject: [PATCH 12/35] demo: bake cells in parallel across a pool of engine workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One wasm instance is single-threaded, so parallel baking means more instances: bake-pool.mjs spins up N extra engine workers (sized from the machine's cores, ?workers=N overrides) and a batch's cells fan out across them, several at a time. A cell bake is pure — bytes in, archive bytes out — so pool slots share nothing; the primary engine worker keeps the charts, the compositor, and rendering, and archives open there as bakes finish. A dropped zip stays in the primary (it lists and extracts); each cell's extracted files shuttle out through a new readFile op to a pool slot, so extraction streams while bakes run wide. Pool slots close when the batch ends — an engine instance holds linear memory wasm never returns, and respawning one costs far less than keeping it. worker-rpc.mjs carries the shared RPC protocol for both the primary and the pool. --- .github/workflows/docs.yml | 1 + bindings/wasm/bake-pool.mjs | 64 ++++++++++++++++++ bindings/wasm/demo.html | 113 ++++++++++++++++++-------------- bindings/wasm/engine-worker.mjs | 10 +++ bindings/wasm/worker-rpc.mjs | 20 ++++++ docs/docs/wasm.md | 9 ++- 6 files changed, 167 insertions(+), 50 deletions(-) create mode 100644 bindings/wasm/bake-pool.mjs create mode 100644 bindings/wasm/worker-rpc.mjs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9c8c7980..0fbf24f3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -55,6 +55,7 @@ jobs: cp bindings/wasm/demo.html docs/static/demo-app/index.html cp bindings/wasm/wasi-shim.mjs bindings/wasm/tile57.mjs \ bindings/wasm/gpu-renderer.mjs bindings/wasm/engine-worker.mjs \ + bindings/wasm/worker-rpc.mjs bindings/wasm/bake-pool.mjs \ docs/static/demo-app/ cp zig-out/bin/tile57-engine.wasm docs/static/demo-app/ diff --git a/bindings/wasm/bake-pool.mjs b/bindings/wasm/bake-pool.mjs new file mode 100644 index 00000000..f21523c3 --- /dev/null +++ b/bindings/wasm/bake-pool.mjs @@ -0,0 +1,64 @@ +// 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 { + this.release(slot); + } + } + + close() { + for (const s of this.slots) s.w.terminate(); + this.slots = []; + this.idle = []; + } +} diff --git a/bindings/wasm/demo.html b/bindings/wasm/demo.html index f120e587..779bf91c 100644 --- a/bindings/wasm/demo.html +++ b/bindings/wasm/demo.html @@ -142,6 +142,8 @@ - + diff --git a/bindings/js/demo/app.mjs b/bindings/js/demo/app.mjs new file mode 100644 index 00000000..bf06fa5a --- /dev/null +++ b/bindings/js/demo/app.mjs @@ -0,0 +1,335 @@ +// 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); + +$("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 { + 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")); + +// ---- 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..e4a616e8 --- /dev/null +++ b/bindings/js/demo/chart-store.mjs @@ -0,0 +1,158 @@ +// 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.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(); + } + + 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..91880f53 --- /dev/null +++ b/bindings/js/demo/import.mjs @@ -0,0 +1,147 @@ +// 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 }); + if (!pool) return await this.rpc("bakeCell", { path: `/enc/drops/${id}/${stem}/${stem}.000` }); + const cellFiles = await Promise.all(outPaths.map(async (p) => ({ + name: p.replace(/^.*\//, ""), + bytes: await this.rpc("readFile", { path: p }), + }))); + 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..2125162a --- /dev/null +++ b/bindings/js/demo/mariner.mjs @@ -0,0 +1,110 @@ +// 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; + +export function loadStored(defaults) { + let stored = {}; + try { + stored = JSON.parse(localStorage.getItem(KEY)) || {}; + } catch { /* first visit */ } + return { ...defaults, ...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..4963af7e --- /dev/null +++ b/bindings/js/demo/pick-report.mjs @@ -0,0 +1,115 @@ +// The cursor pick report, presented lookout-marine's way (PickReport.swift): +// one object at a time, decoded for the mariner - the operative fact as the +// title, the attributes in chart language, the raw S-57 rows one fold away - +// with the whole pick set in sight as chips, never a blind pager. +// +// 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(/ this.hide()); + this.el.addEventListener("click", (e) => { + const chip = e.target.closest("[data-pick]"); + if (chip) { + this.sel = +chip.dataset.pick; + this.renderBody(); + } + }); + } + + /** Show a raw pick result (the worker's pick op output). */ + show(features) { + this.features = rankPick(features); + this.sel = 0; + if (!this.features.length) { + this.hide(); + return false; + } + this.el.hidden = false; + this.renderBody(); + return true; + } + + hide() { + this.el.hidden = true; + this.features = []; + } + get open() { + return !this.el.hidden; + } + + renderBody() { + const chips = this.features.map((f, i) => + ``).join(""); + const f = this.features[this.sel]; + const r = f.report || {}; + const rows = (r.rows || []).map((row) => + `
+ ${esc(row.label)}${esc(row.value)}${row.file ? " 📄" : ""}${row.picture ? " 🖼" : ""} +
`).join(""); + const notes = (r.notes || []).map((n) => `

${esc(n)}

`).join(""); + const empty = r.empty + ? `
${r.empty === "none" ? "The chart states nothing further about this object." : "Only source metadata is recorded."}
` + : ""; + const raw = `
As the cell states it
${esc(JSON.stringify(f.s57, null, 1))}
`; + this.el.querySelector("#pick-chips").innerHTML = chips; + this.el.querySelector("#pick-body").innerHTML = ` +
${esc(r.title || f.cls)}
+ ${r.subtitle ? `
${esc(r.subtitle)}
` : ""} + ${notes}${rows}${empty}${raw} +
${esc(r.footnote || f.chart)}
`; + } +} + +export const PICK_STYLE = ` + /* Pick report: a callout card on the left, the pick set as chips on top, + one decoded object below (lookout-marine's presentation). */ + #pick { position:absolute; left:calc(12px + env(safe-area-inset-left,0px)); + top:calc(12px + env(safe-area-inset-top,0px)); z-index:8; + width:min(340px, calc(100vw - 24px)); + max-height:calc(100dvh - 120px); display:flex; flex-direction:column; + 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); overflow:hidden; } + #pick[hidden] { display:none; } + #pick .phead { display:flex; align-items:center; gap:6px; padding:10px 12px 8px; + border-bottom:1px solid var(--ui-border-2); } + #pick-chips { flex:1; display:flex; flex-wrap:wrap; gap:5px; min-width:0; } + .pick-chip { border:1px solid var(--ui-border-strong); background:var(--ui-surface); + color:var(--ui-text-dim); border-radius:999px; padding:3px 10px; + font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; cursor:pointer; } + .pick-chip.sel { background:var(--ui-accent); color:var(--ui-accent-text); border-color:var(--ui-accent); } + #pick-close { flex:none; cursor:pointer; border:none; background:none; color:var(--ui-text-dim); + font:600 14px system-ui,sans-serif; padding:4px 6px; } + #pick-body { overflow-y:auto; overscroll-behavior:contain; padding:12px 14px 12px; } + .pick-title { font:700 15px/1.3 system-ui,sans-serif; } + .pick-sub { color:var(--ui-text-dim); font-size:12.5px; margin-top:2px; } + .pick-note { color:var(--ui-text); font-size:12.5px; line-height:1.5; margin:10px 0 0; + padding:8px 10px; background:var(--ui-surface-2); border-radius:8px; } + .pick-row { display:flex; align-items:baseline; gap:12px; padding:6px 0; + border-bottom:1px solid var(--ui-border-2); font-size:12.5px; } + .pick-row:first-of-type { margin-top:8px; } + .pick-row .pk-l { flex:1; min-width:0; color:var(--ui-text-dim); } + .pick-row .pk-v { flex:none; max-width:60%; text-align:right; font-weight:600; overflow-wrap:anywhere; } + .pick-empty { color:var(--ui-text-faint); font-size:12.5px; padding:12px 0 4px; } + .pick-raw { margin-top:10px; } + .pick-raw summary { cursor:pointer; color:var(--ui-text-dim); font-size:12px; padding:4px 0; } + .pick-raw pre { margin:6px 0 0; padding:8px 10px; background:var(--ui-surface-2); border-radius:8px; + font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; overflow-x:auto; color:var(--ui-text); } + .pick-foot { margin-top:10px; padding-top:8px; border-top:1px solid var(--ui-border-2); + color:var(--ui-text-faint); font-size:11px; } +`; + +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..f447746f --- /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. */ + #map, #mapimg { position:absolute; inset:0; width:100%; height:100%; + touch-action:none; cursor:grab; 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…
+
a few MB on first visit
+
+`; diff --git a/bindings/js/engine-smoke.mjs b/bindings/js/engine-smoke.mjs index d9b979c0..249417a9 100644 --- a/bindings/js/engine-smoke.mjs +++ b/bindings/js/engine-smoke.mjs @@ -1,8 +1,8 @@ // 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. +// 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. // @@ -33,7 +33,7 @@ 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. +// memory.buffer detaches on growth - always re-view. const u8 = () => new Uint8Array(E.memory.buffer); const dv = () => new DataView(E.memory.buffer); diff --git a/bindings/js/engine-worker.mjs b/bindings/js/engine-worker.mjs index 65595764..2c004399 100644 --- a/bindings/js/engine-worker.mjs +++ b/bindings/js/engine-worker.mjs @@ -1,5 +1,5 @@ // 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 +// 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. @@ -24,13 +24,15 @@ const ops = { return { version: t.version() }; }, - // Everything the WebGPU renderer needs, baked once: the ABI layout, the - // four atlas PNGs at the page's pixel ratio, and the colortables the halo - // and clear colours come from. - gpuAssets({ pixelRatio }) { + // 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, 0).png, + spritePng: t.bakeSpriteMln(pixelRatio, scheme).png, glyphPng: t.bakeGlyphSdf(0).png, glyphBoldPng: t.bakeGlyphSdf(1).png, glyphItalicPng: t.bakeGlyphSdf(2).png, @@ -39,19 +41,38 @@ const ops = { return [r, [r.spritePng.buffer, r.glyphPng.buffer, r.glyphBoldPng.buffer, r.glyphItalicPng.buffer]]; }, - // The S-52 colour tables ({day, dusk, night} token maps) — the page themes + // 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 + // 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 + // 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; @@ -90,20 +111,20 @@ const ops = { composeOpen({ handles }) { return t.composeOpen(handles); }, composeClose({ handle }) { t.composeClose(handle); }, - png({ compose, chart, lon, lat, zoom, w, h }) { + png({ compose, chart, lon, lat, zoom, w, h, mariner }) { const png = compose - ? t.composePng(compose, lon, lat, zoom, w, h) - : t.chartPng(chart, lon, lat, zoom, w, h); + ? 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 }) { + gpuScene({ compose, chart, lon, lat, zoom, w, h, pixelRatio, atlasHave, halo, mariner }) { const scene = compose - ? t.composeGpuScene(compose, lon, lat, zoom, w, h, pixelRatio) - : t.chartGpuScene(chart, lon, lat, zoom, w, h, pixelRatio); + ? 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(), diff --git a/bindings/js/gpu-renderer.mjs b/bindings/js/gpu-renderer.mjs index d7d39440..1141b58f 100644 --- a/bindings/js/gpu-renderer.mjs +++ b/bindings/js/gpu-renderer.mjs @@ -1,4 +1,4 @@ -// WebGPU renderer for tile57 GPU scenes — the browser sibling of the +// 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 @@ -9,14 +9,14 @@ // 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 +// 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 +// // 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 }; @@ -162,7 +162,7 @@ struct QuadOut { } `; -// tile57_gpu_vertex (32 B) — see chart.vert's layout comment. +// tile57_gpu_vertex (32 B) - see chart.vert's layout comment. const VERTEX_LAYOUT = { arrayStride: 32, attributes: [ @@ -174,7 +174,7 @@ const VERTEX_LAYOUT = { { shaderLocation: 5, offset: 28, format: "float32" }, ], }; -// tile57_gpu_quad (44 B) — see sprite.vert's layout comment. +// tile57_gpu_quad (44 B) - see sprite.vert's layout comment. const QUAD_LAYOUT = { arrayStride: 44, attributes: [ @@ -302,6 +302,7 @@ export class GpuRenderer { 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; @@ -309,6 +310,16 @@ export class GpuRenderer { 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; @@ -370,7 +381,7 @@ export class GpuRenderer { } /** Redraw the uploaded scene for `cam` ({lon, lat, zoom}). Geometry is - * world-anchored, so any camera renders correctly — a pan or zoom between + * 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; @@ -399,7 +410,7 @@ export class GpuRenderer { 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 + // 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); @@ -408,7 +419,7 @@ export class GpuRenderer { // 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 + // 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; diff --git a/bindings/js/index.mjs b/bindings/js/index.mjs index 3810b424..27a83fa7 100644 --- a/bindings/js/index.mjs +++ b/bindings/js/index.mjs @@ -1,7 +1,7 @@ // 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 +// 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: // diff --git a/bindings/js/tile57.mjs b/bindings/js/tile57.mjs index b2c9cae0..0781790b 100644 --- a/bindings/js/tile57.mjs +++ b/bindings/js/tile57.mjs @@ -23,7 +23,7 @@ export class Tile57 { this.errPtr = this.scratch + 16; } - // memory.buffer detaches on growth — always re-view. + // memory.buffer detaches on growth - always re-view. bytes() { return new Uint8Array(this.e.memory.buffer); } view() { return new DataView(this.e.memory.buffer); } @@ -70,6 +70,107 @@ export class Tile57 { 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); @@ -80,7 +181,7 @@ export class Tile57 { /** 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 + * 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); @@ -152,9 +253,12 @@ export class Tile57 { 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 (canonical mariner settings). */ - chartPng(chart, lon, lat, zoom, width, height) { - this.check("chart_png", this.e.tile57_chart_png(chart, lon, lat, zoom, width, height, 0, this.outPtr, this.outLen, this.errPtr)); + /** 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(); } @@ -200,16 +304,20 @@ export class Tile57 { } /** Portray a chart view into draw-ready GPU buffers. Call .free() on the - * result once uploaded. */ - chartGpuScene(chart, lon, lat, zoom, width, height, pixelRatio) { + * result once uploaded. `mariner` as chartPng. */ + chartGpuScene(chart, lon, lat, zoom, width, height, pixelRatio, mariner) { const sp = this.walloc(44); - this.check("chart_gpu_scene", this.e.tile57_chart_gpu_scene(chart, lon, lat, zoom, width, height, 0, pixelRatio, sp, this.errPtr)); + 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) { + composeGpuScene(compose, lon, lat, zoom, width, height, pixelRatio, mariner) { const sp = this.walloc(44); - this.check("compose_gpu_scene", this.e.tile57_compose_gpu_scene(compose, lon, lat, zoom, width, height, 0, pixelRatio, sp, this.errPtr)); + 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); } @@ -282,6 +390,36 @@ export class Tile57 { 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); @@ -298,9 +436,11 @@ export class Tile57 { 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 (canonical mariner settings). */ - composePng(compose, lon, lat, zoom, width, height) { - this.check("compose_png", this.e.tile57_compose_png(compose, lon, lat, zoom, width, height, 0, this.outPtr, this.outLen, this.errPtr)); + /** 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 index a5acc862..1da9cd9c 100644 --- a/bindings/js/wasi-shim.mjs +++ b/bindings/js/wasi-shim.mjs @@ -135,7 +135,7 @@ export class WasiShim { 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 + // 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) { diff --git a/docs/docs/wasm.md b/docs/docs/wasm.md index 213a15e3..29fb16a5 100644 --- a/docs/docs/wasm.md +++ b/docs/docs/wasm.md @@ -127,7 +127,7 @@ 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,tile57.mjs,wasi-shim.mjs,gpu-renderer.mjs,engine-worker.mjs} . +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 diff --git a/src/portray/wasm_sjlj_rt.c b/src/portray/wasm_sjlj_rt.c index b4e31116..c686911a 100644 --- a/src/portray/wasm_sjlj_rt.c +++ b/src/portray/wasm_sjlj_rt.c @@ -1,6 +1,6 @@ /* * The setjmp/longjmp runtime for wasm, vendored from wasi-libc - * (libc-top-half/musl/src/setjmp/wasm32/rt.c, MIT/Apache-2.0 — see + * (libc-top-half/musl/src/setjmp/wasm32/rt.c, MIT/Apache-2.0 - see * THIRD_PARTY_LICENSES.md). * * Lua's error path is setjmp/longjmp. On wasm, clang lowers those calls to @@ -14,7 +14,7 @@ * tag the helpers throw with). It must be vendored: the copy inside Zig's * bundled wasi-libc only enters libc.a when the exception-handling feature is * enabled TARGET-wide, and that build crashes in zig 0.16 (zig compiles it - * without the sjlj pass, which leaves the tag undefined-weak — rejected by + * without the sjlj pass, which leaves the tag undefined-weak - rejected by * the wasm object writer). Per-file flags on our own objects sidestep the * libc build entirely. * diff --git a/src/wasm_root.zig b/src/wasm_root.zig index 28f9413f..328f1e81 100644 --- a/src/wasm_root.zig +++ b/src/wasm_root.zig @@ -1,13 +1,13 @@ //! Wasm reactor root for the full engine (`zig build wasm-engine`). //! -//! The same surface as libtile57.a — the whole C ABI, with the embedded Lua -//! portrayal engine — compiled to one wasm32-wasi module. A JS host (browser +//! The same surface as libtile57.a - the whole C ABI, with the embedded Lua +//! portrayal engine - compiled to one wasm32-wasi module. A JS host (browser //! page or node) supplies the WASI imports and calls the tile57_* exports, so //! a chartplotter can bake charts and serve tiles fully client-side. //! //! The two helpers below exist only on this target. The C ABI's byte-buffer //! calls allocate their OUTPUTS (released with tile57_free), but a C caller -//! provides its own INPUT buffers — and a JS host has no allocator inside the +//! provides its own INPUT buffers - and a JS host has no allocator inside the //! wasm linear memory. These give it one. const std = @import("std"); @@ -27,8 +27,95 @@ export fn tile57_wasm_alloc(len: usize) ?[*]u8 { return @ptrCast(p); } -/// Release a buffer from tile57_wasm_alloc. Only for those buffers — engine +/// Release a buffer from tile57_wasm_alloc. Only for those buffers - engine /// outputs still go through tile57_free. export fn tile57_wasm_free(ptr: ?*anyopaque) void { std.c.free(ptr); } + +// ---- the cursor pick, flattened for a JS host ----------------------------- +// +// tile57_chart_query / tile57_compose_query report through a C callback, and +// a JS host cannot provide one (a JS function is not a wasm funcref). The +// callback must live INSIDE the module, so this export runs the query with an +// internal accumulator and returns the features as ONE JSON array: +// [{"cls":"LIGHTS","s57":{...},"chart":"US5BDRAB"}, ...] +// Release *out with tile57_wasm_free. The C exports are extern-declared here +// (same module, resolved at link) to keep this file POD-only. + +const QueryCb = extern struct { + ctx: ?*anyopaque, + feature: ?*const fn (?*anyopaque, [*c]const u8, usize, [*c]const u8, usize, [*c]const u8, usize) callconv(.c) void, +}; +extern fn tile57_chart_query(chart: ?*anyopaque, lon: f64, lat: f64, zoom: f64, cb: *const QueryCb, err: ?*anyopaque) c_int; +extern fn tile57_compose_query(c: ?*anyopaque, lon: f64, lat: f64, zoom: f64, cb: *const QueryCb, err: ?*anyopaque) c_int; + +const QueryAcc = struct { + list: std.ArrayList(u8) = .empty, + first: bool = true, + failed: bool = false, +}; + +fn accAppend(acc: *QueryAcc, bytes: []const u8) void { + acc.list.appendSlice(std.heap.c_allocator, bytes) catch { + acc.failed = true; + }; +} + +// Escape a plain string (a class acronym, a chart name) into JSON. +fn accAppendJsonString(acc: *QueryAcc, s: []const u8) void { + accAppend(acc, "\""); + for (s) |ch| switch (ch) { + '"' => accAppend(acc, "\\\""), + '\\' => accAppend(acc, "\\\\"), + 0x00...0x1f => { + var buf: [8]u8 = undefined; + accAppend(acc, std.fmt.bufPrint(&buf, "\\u{x:0>4}", .{ch}) catch "?"); + }, + else => accAppend(acc, &.{ch}), + }; + accAppend(acc, "\""); +} + +fn onQueryFeature(ctx: ?*anyopaque, cls: [*c]const u8, cls_len: usize, s57: [*c]const u8, s57_len: usize, chart: [*c]const u8, chart_len: usize) callconv(.c) void { + const acc: *QueryAcc = @ptrCast(@alignCast(ctx orelse return)); + if (acc.failed) return; + if (!acc.first) accAppend(acc, ","); + acc.first = false; + accAppend(acc, "{\"cls\":"); + accAppendJsonString(acc, if (cls) |p| p[0..cls_len] else ""); + // The attribute payload is already JSON - embed it raw (empty -> {}). + accAppend(acc, ",\"s57\":"); + const raw = if (s57) |p| p[0..s57_len] else ""; + accAppend(acc, if (raw.len == 0) "{}" else raw); + accAppend(acc, ",\"chart\":"); + accAppendJsonString(acc, if (chart) |p| p[0..chart_len] else ""); + accAppend(acc, "}"); +} + +/// The pick at (lon, lat) as JSON. `compose` when nonzero, else `chart`. +export fn tile57_wasm_query(compose: ?*anyopaque, chart: ?*anyopaque, lon: f64, lat: f64, zoom: f64, out: ?*?[*]u8, out_len: ?*usize) i32 { + const o = out orelse return 1; + const ol = out_len orelse return 1; + o.* = null; + ol.* = 0; + var acc = QueryAcc{}; + accAppend(&acc, "["); + const cb = QueryCb{ .ctx = &acc, .feature = &onQueryFeature }; + const status = if (compose != null) + tile57_compose_query(compose, lon, lat, zoom, &cb, null) + else + tile57_chart_query(chart, lon, lat, zoom, &cb, null); + accAppend(&acc, "]"); + if (status != 0 or acc.failed) { + acc.list.deinit(std.heap.c_allocator); + return if (status != 0) status else 4; // 4 = TILE57_ERR_NOMEM + } + const slice = acc.list.toOwnedSlice(std.heap.c_allocator) catch { + acc.list.deinit(std.heap.c_allocator); + return 4; + }; + o.* = slice.ptr; + ol.* = slice.len; + return 0; +} From 995f0a838f611ba34f302c1ed7d2bec0098616b3 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:34:32 -0400 Subject: [PATCH 25/35] demo: recreational depth defaults The S-52 canon (safety contour 10 m) suits SOLAS drafts; a sailboat draws about 2 m, and a 10 m safety contour paints most of a harbor as unsafe. The demo defaults to shallow 2 m, safety 3 m, deep 10 m, safety depth 3 m; stored settings still win. --- bindings/js/demo/mariner.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bindings/js/demo/mariner.mjs b/bindings/js/demo/mariner.mjs index 2125162a..9777f8a3 100644 --- a/bindings/js/demo/mariner.mjs +++ b/bindings/js/demo/mariner.mjs @@ -11,12 +11,18 @@ const KEY = "tile57.mariner"; const M_TO_FT = 3.28084; +// Recreational depth defaults over the engine's ship-scale canon. The S-52 +// defaults (safety contour 10 m) suit SOLAS drafts; a sailboat draws about +// 2 m, and a 10 m safety contour paints most of a harbor as unsafe. The +// mariner's own stored settings still win over these. +const RECREATIONAL = { shallowContour: 2, safetyContour: 3, deepContour: 10, safetyDepth: 3 }; + export function loadStored(defaults) { let stored = {}; try { stored = JSON.parse(localStorage.getItem(KEY)) || {}; } catch { /* first visit */ } - return { ...defaults, ...stored }; + return { ...defaults, ...RECREATIONAL, ...stored }; } export function saveStored(m) { try { From 82ba012834b1ab6ba59a7be7f6ddc0119a64982f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:35:03 -0400 Subject: [PATCH 26/35] demo: depths display in feet by default The recreational convention on US charts; stored values stay metric under the hood and the mariner's own setting still wins. --- bindings/js/demo/mariner.mjs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bindings/js/demo/mariner.mjs b/bindings/js/demo/mariner.mjs index 9777f8a3..6a249327 100644 --- a/bindings/js/demo/mariner.mjs +++ b/bindings/js/demo/mariner.mjs @@ -11,11 +11,13 @@ const KEY = "tile57.mariner"; const M_TO_FT = 3.28084; -// Recreational depth defaults over the engine's ship-scale canon. The S-52 -// defaults (safety contour 10 m) suit SOLAS drafts; a sailboat draws about -// 2 m, and a 10 m safety contour paints most of a harbor as unsafe. The -// mariner's own stored settings still win over these. -const RECREATIONAL = { shallowContour: 2, safetyContour: 3, deepContour: 10, safetyDepth: 3 }; +// 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 = {}; From 428544e28212d9809a350858cbc1b167ca13551b Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:36:56 -0400 Subject: [PATCH 27/35] demo: await the scheme swap before the rebuild The rebuild reads gpu.halo for the SDF text pass and the clear colour; an un-awaited setScheme left both one scheme behind, so labels held the old palette until the next camera move rebuilt again. --- bindings/js/demo/app.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bindings/js/demo/app.mjs b/bindings/js/demo/app.mjs index bf06fa5a..4653439d 100644 --- a/bindings/js/demo/app.mjs +++ b/bindings/js/demo/app.mjs @@ -300,7 +300,10 @@ async function applyMariner(patch) { applySchemeChrome(); if (gpu) { try { - gpu.setScheme(mariner.scheme, await rpc("spriteAtlas", { pixelRatio: dpr, scheme: schemeIdx() })); + // 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); } From 232c3246038fb80152e7970d3ca5bfc54264bd7f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:37:37 -0400 Subject: [PATCH 28/35] demo: crosshair cursor over the chart The pick cursor; the grabbing hand shows only during an actual drag. --- bindings/js/demo/view.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bindings/js/demo/view.mjs b/bindings/js/demo/view.mjs index f447746f..8937c347 100644 --- a/bindings/js/demo/view.mjs +++ b/bindings/js/demo/view.mjs @@ -26,8 +26,9 @@ export const STYLE = ` --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:grab; user-select:none; } + touch-action:none; cursor:crosshair; user-select:none; } #mapimg { display:none; } #root.dragging #map, #root.dragging #mapimg { cursor:grabbing; } From 964c05e875fa03824d1e406d2d7555dee40e437f Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:37:49 -0400 Subject: [PATCH 29/35] demo: drop the download-size note from the splash --- bindings/js/demo/view.mjs | 1 - 1 file changed, 1 deletion(-) diff --git a/bindings/js/demo/view.mjs b/bindings/js/demo/view.mjs index 8937c347..390ec87a 100644 --- a/bindings/js/demo/view.mjs +++ b/bindings/js/demo/view.mjs @@ -266,6 +266,5 @@ export const CHROME = `
Loading the chart engine…
-
a few MB on first visit
`; From 69919b35ed7d9e7dd77e3e886ae18b870213dec8 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:39:16 -0400 Subject: [PATCH 30/35] demo: US5MD13M as the sample chart Annapolis and the bay approaches at 1:40,000. The previous cell was a thin harbor slice that did not even contain the Annapolis point. --- .github/workflows/docs.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9a1f5974..f5579c75 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -62,12 +62,13 @@ jobs: cp zig-out/bin/tile57-engine.wasm docs/static/demo-app/ # A first visit with no charts offers this NOAA cell (public domain, - # ~120 kB) as "try a sample harbor". Best effort: without it the - # welcome card just keeps the download link alone. + # Annapolis and the bay approaches at 1:40,000) as "try a sample + # harbor". Best effort: without it the welcome card just keeps the + # download link alone. - name: Fetch the sample harbor run: | curl -fsSL -o docs/static/demo-app/sample.zip \ - https://charts.noaa.gov/ENCs/US5MD12M.zip || true + https://charts.noaa.gov/ENCs/US5MD13M.zip || true # No committed lockfile, so `npm install` (not `npm ci`). - name: Install From 69374e5c05f3cf7b4164e30f9be672c23215bc59 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:46:15 -0400 Subject: [PATCH 31/35] demo: a real Annapolis sample, five charts deep The harbor at 1:12,000 (US5MD1MC), the Severn and the bay approaches at 1:40,000 (US5MD13M, US5MD12M), and the band-3/4 context (US4MD1DD, US3EC08M) so zooming out still shows chart. One NOAA cell was a slice; this set quilts the whole area at every zoom. --- .github/workflows/docs.yml | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f5579c75..415d7c53 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -61,14 +61,21 @@ jobs: 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 this NOAA cell (public domain, - # Annapolis and the bay approaches at 1:40,000) as "try a sample - # harbor". Best effort: without it the welcome card just keeps the - # download link alone. - - name: Fetch the sample harbor + # 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: | - curl -fsSL -o docs/static/demo-app/sample.zip \ - https://charts.noaa.gov/ENCs/US5MD13M.zip || true + tmp=$(mktemp -d) + for c in US5MD1MC 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 From 731b5748d4aab04af85ae80974399ab701be59aa Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:57:59 -0400 Subject: [PATCH 32/35] render: gate the sector-leg variant in the resolver The FullLightLines bake portrays a sectored light's legs twice (sect 0, the 25 mm stubs; sect 1, the full-length pass) and filters them in the MapLibre style. The engine's own render paths resolve display variants in resolve.zig, which gated bnd and pts but not sect, so both passes drew and full sector lines showed whichever way the switch stood. The composed replay also dropped the sect tag on decode. Validated on US5MD1LC: Thomas Point Shoal Light draws stubs with the switch off and full legs with it on. --- src/render/resolve.zig | 6 ++++++ src/scene/replay.zig | 1 + 2 files changed, 7 insertions(+) diff --git a/src/render/resolve.zig b/src/render/resolve.zig index 23d0d448..36ad09e4 100644 --- a/src/render/resolve.zig +++ b/src/render/resolve.zig @@ -236,6 +236,12 @@ pub fn visible(meta: *const rs.FeatureMeta, symbol_name: ?[]const u8, zoom: f64, if (meta.bnd != 2 and meta.bnd != bnd_rank) return false; const pts_rank: i64 = if (m.simplified_points) 1 else 0; if (meta.pts != 2 and meta.pts != pts_rank) return false; + // Sector-leg length (S-52 §12.2.4, mirrors mariner.sectorFilter): a + // sectored light portrays its legs twice — sect 0 (the 25 mm stubs) and + // sect 1 (the full-length pass) — and without this gate both drew, so + // full sector lines showed whichever way the switch stood. + const sect_rank: i64 = if (m.show_full_sector_lines) 1 else 0; + if (meta.sect != 2 and meta.sect != sect_rank) return false; return true; } diff --git a/src/scene/replay.zig b/src/scene/replay.zig index 2819e664..4ccb04c7 100644 --- a/src/scene/replay.zig +++ b/src/scene/replay.zig @@ -73,6 +73,7 @@ fn metaFromProps(props: []const mvt.Prop) rs.FeatureMeta { .band = @intCast(std.math.clamp(propInt(props, "band", rs.BAND_UNKNOWN), 0, 255)), .bnd = propInt(props, "bnd", 2), .pts = propInt(props, "pts", 2), + .sect = propInt(props, "sect", 2), .masked = propInt(props, "masked", 0) != 0, .date_start = propStr(props, "date_start"), .date_end = propStr(props, "date_end"), From 81a74277720cb62b05c4bd0b3cf271cd2ff9d769 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 09:57:59 -0400 Subject: [PATCH 33/35] demo: eight-cell Annapolis sample; README names quilting US5MD1MD carries the eastern half of the deep-draft anchorage and was missing, so the sample cut it at the harbor cell's seam; the bundle now holds the central 1:12,000 row (MB, MC, MD), the Thomas Point cell (LC), and the 1:40,000/45,000/200,000 context. The anchorage view now renders byte-identical to the full Maryland library. The README lists quilting as its own capability. --- .github/workflows/docs.yml | 2 +- README.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 415d7c53..5e054f05 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -70,7 +70,7 @@ jobs: continue-on-error: true run: | tmp=$(mktemp -d) - for c in US5MD1MC US5MD13M US5MD12M US4MD1DD US3EC08M; do + 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 diff --git a/README.md b/README.md index 08425066..d51f9873 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,10 @@ uploaded and there is no server. - **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, From 9f4709dae750d736ab1e97c4c042dea7f98eece2 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 10:03:30 -0400 Subject: [PATCH 34/35] demo: two-column pick report; Esc closes surfaces The pick report follows lookout-marine's callout: the pick's objects stay in sight as a left column (the main data in each row, the object on show held selected, the chart's M_* notes pinned at the column's floor), and the detail holds the decoded report with the provenance line and the S-57 source fold at ITS floor, so the controls keep their place while the rows scroll. A copy control puts the raw payload on the clipboard. Esc closes the settings drawer first, then the report. --- bindings/js/demo/app.mjs | 7 + bindings/js/demo/pick-report.mjs | 217 +++++++++++++++++++++++-------- 2 files changed, 169 insertions(+), 55 deletions(-) diff --git a/bindings/js/demo/app.mjs b/bindings/js/demo/app.mjs index 4653439d..1c353c02 100644 --- a/bindings/js/demo/app.mjs +++ b/bindings/js/demo/app.mjs @@ -325,6 +325,13 @@ $("settings").addEventListener("click", () => { }); $("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}…`)); diff --git a/bindings/js/demo/pick-report.mjs b/bindings/js/demo/pick-report.mjs index 4963af7e..36029657 100644 --- a/bindings/js/demo/pick-report.mjs +++ b/bindings/js/demo/pick-report.mjs @@ -1,7 +1,10 @@ // The cursor pick report, presented lookout-marine's way (PickReport.swift): -// one object at a time, decoded for the mariner - the operative fact as the -// title, the attributes in chart language, the raw S-57 rows one fold away - -// with the whole pick set in sight as chips, never a blind pager. +// 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. @@ -10,17 +13,27 @@ import { rankPick } from "./pick-model.mjs"; const esc = (s) => String(s).replace(/&/g, "&").replace(/ f.cls.startsWith("M_") || f.cls.startsWith("C_"); + export class PickReport { constructor(root) { this.el = root.querySelector("#pick"); this.features = []; this.sel = 0; - this.el.querySelector("#pick-close").addEventListener("click", () => this.hide()); + this.fold = false; this.el.addEventListener("click", (e) => { - const chip = e.target.closest("[data-pick]"); - if (chip) { - this.sel = +chip.dataset.pick; - this.renderBody(); + const row = e.target.closest("[data-pick]"); + if (row) { + this.sel = +row.dataset.pick; + this.fold = false; + this.render(); + return; + } + 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(); } }); } @@ -29,12 +42,13 @@ export class PickReport { 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.renderBody(); + this.render(); return true; } @@ -46,70 +60,163 @@ export class PickReport { return !this.el.hidden; } - renderBody() { - const chips = this.features.map((f, i) => - ``).join(""); + 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)}${esc(row.value)}${row.file ? " 📄" : ""}${row.picture ? " 🖼" : ""} + `
+ ${esc(row.label)} + ${esc(row.value)}${row.file ? " 📄" : ""}${row.picture ? " 🖼" : ""}
`).join(""); - const notes = (r.notes || []).map((n) => `

${esc(n)}

`).join(""); + const noteBlocks = (r.notes || []).map((n) => `

${esc(n)}

`).join(""); const empty = r.empty - ? `
${r.empty === "none" ? "The chart states nothing further about this object." : "Only source metadata is recorded."}
` + ? `
${r.empty === "none" + ? "The cell carries no attributes for this object." + : "The cell carries only source data for this object."}
` : ""; - const raw = `
As the cell states it
${esc(JSON.stringify(f.s57, null, 1))}
`; - this.el.querySelector("#pick-chips").innerHTML = chips; - this.el.querySelector("#pick-body").innerHTML = ` -
${esc(r.title || f.cls)}
- ${r.subtitle ? `
${esc(r.subtitle)}
` : ""} - ${notes}${rows}${empty}${raw} -
${esc(r.footnote || f.chart)}
`; + 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: a callout card on the left, the pick set as chips on top, - one decoded object below (lookout-marine's presentation). */ + /* 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; - width:min(340px, calc(100vw - 24px)); - max-height:calc(100dvh - 120px); display:flex; flex-direction:column; + 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 rgba(0,0,0,.30); overflow:hidden; } + border-radius:14px; box-shadow:0 12px 38px var(--ui-shadow); overflow:hidden; } #pick[hidden] { display:none; } - #pick .phead { display:flex; align-items:center; gap:6px; padding:10px 12px 8px; - border-bottom:1px solid var(--ui-border-2); } - #pick-chips { flex:1; display:flex; flex-wrap:wrap; gap:5px; min-width:0; } - .pick-chip { border:1px solid var(--ui-border-strong); background:var(--ui-surface); - color:var(--ui-text-dim); border-radius:999px; padding:3px 10px; - font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace; cursor:pointer; } - .pick-chip.sel { background:var(--ui-accent); color:var(--ui-accent-text); border-color:var(--ui-accent); } - #pick-close { flex:none; cursor:pointer; border:none; background:none; color:var(--ui-text-dim); - font:600 14px system-ui,sans-serif; padding:4px 6px; } - #pick-body { overflow-y:auto; overscroll-behavior:contain; padding:12px 14px 12px; } - .pick-title { font:700 15px/1.3 system-ui,sans-serif; } - .pick-sub { color:var(--ui-text-dim); font-size:12.5px; margin-top:2px; } - .pick-note { color:var(--ui-text); font-size:12.5px; line-height:1.5; margin:10px 0 0; + #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; font-size:11px; 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; } - .pick-row { display:flex; align-items:baseline; gap:12px; padding:6px 0; - border-bottom:1px solid var(--ui-border-2); font-size:12.5px; } - .pick-row:first-of-type { margin-top:8px; } - .pick-row .pk-l { flex:1; min-width:0; color:var(--ui-text-dim); } - .pick-row .pk-v { flex:none; max-width:60%; text-align:right; font-weight:600; overflow-wrap:anywhere; } - .pick-empty { color:var(--ui-text-faint); font-size:12.5px; padding:12px 0 4px; } - .pick-raw { margin-top:10px; } - .pick-raw summary { cursor:pointer; color:var(--ui-text-dim); font-size:12px; padding:4px 0; } - .pick-raw pre { margin:6px 0 0; padding:8px 10px; background:var(--ui-surface-2); border-radius:8px; - font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; overflow-x:auto; color:var(--ui-text); } - .pick-foot { margin-top:10px; padding-top:8px; border-top:1px solid var(--ui-border-2); - color:var(--ui-text-faint); font-size:11px; } + .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-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 = ` `; From 173c2efe7986c951e84adf992cd3cce0df894695 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Mon, 24 Aug 2026 10:19:57 -0400 Subject: [PATCH 35/35] demo: show TXTDSC files in the pick report; inline SVG icons The text and pictures a chart's features point at now survive import: the zip path stores every non-cell sibling (the TXTDSC notes, PICREP pictures) in the library beside the chart's archive (OPFS, with a session fallback), and a file row in the pick report opens its content inline - text as a scrollable block, web-displayable pictures as an image, TIFF named as such. Clearing a chart clears its files. The book, copy, and document glyphs become inline SVGs: the exotic codepoints read as tofu on fonts that lack them, which is what the notes rows and data-quality entries were showing. --- bindings/js/chart-library.mjs | 22 ++++++++++++ bindings/js/demo/app.mjs | 2 +- bindings/js/demo/chart-store.mjs | 16 +++++++++ bindings/js/demo/import.mjs | 16 ++++++--- bindings/js/demo/pick-report.mjs | 61 ++++++++++++++++++++++++++++---- 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/bindings/js/chart-library.mjs b/bindings/js/chart-library.mjs index 28bfb609..42163ec1 100644 --- a/bindings/js/chart-library.mjs +++ b/bindings/js/chart-library.mjs @@ -83,6 +83,28 @@ export class ChartLibrary { 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. */ diff --git a/bindings/js/demo/app.mjs b/bindings/js/demo/app.mjs index 1c353c02..10eb5880 100644 --- a/bindings/js/demo/app.mjs +++ b/bindings/js/demo/app.mjs @@ -276,7 +276,7 @@ wireGestures(surface, root, { } }, }); -const pick = new PickReport(root); +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); }); diff --git a/bindings/js/demo/chart-store.mjs b/bindings/js/demo/chart-store.mjs index e4a616e8..4aac5f2d 100644 --- a/bindings/js/demo/chart-store.mjs +++ b/bindings/js/demo/chart-store.mjs @@ -17,6 +17,7 @@ export class ChartStore { 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; @@ -81,6 +82,21 @@ export class ChartStore { 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); diff --git a/bindings/js/demo/import.mjs b/bindings/js/demo/import.mjs index 91880f53..c27f4bd1 100644 --- a/bindings/js/demo/import.mjs +++ b/bindings/js/demo/import.mjs @@ -94,11 +94,19 @@ export class ChartImporter { 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` }); - const cellFiles = await Promise.all(outPaths.map(async (p) => ({ - name: p.replace(/^.*\//, ""), - bytes: await this.rpc("readFile", { path: p }), - }))); return pool.bake(stem, cellFiles); } finally { this.rpc("remove", { path: `/enc/drops/${id}/${stem}` }).catch(() => {}); diff --git a/bindings/js/demo/pick-report.mjs b/bindings/js/demo/pick-report.mjs index 36029657..7e921f42 100644 --- a/bindings/js/demo/pick-report.mjs +++ b/bindings/js/demo/pick-report.mjs @@ -13,11 +13,17 @@ 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) { + constructor(root, { getAux } = {}) { this.el = root.querySelector("#pick"); + this.getAux = getAux; this.features = []; this.sel = 0; this.fold = false; @@ -29,7 +35,9 @@ export class PickReport { this.render(); return; } - if (e.target.closest("#pick-close")) this.hide(); + 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; @@ -38,6 +46,34 @@ export class PickReport { }); } + // 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); @@ -70,7 +106,7 @@ export class PickReport { const sel = i === this.sel ? " sel" : ""; if (isNote(f)) { return ``; + ${BOOK_ICON}${esc(r.chip || f.cls)}`; } return `` + : `${esc(row.value)}`}
`).join(""); const noteBlocks = (r.notes || []).map((n) => `

${esc(n)}

`).join(""); const empty = r.empty @@ -116,7 +154,7 @@ export class PickReport { ${r.subtitle ? `
${esc(r.subtitle)}
` : ""} ${esc(r.chip || f.cls)} - +
@@ -165,7 +203,7 @@ export const PICK_STYLE = ` /* 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; font-size:11px; color:var(--ui-text-dim); } + .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); } @@ -190,6 +228,17 @@ export const PICK_STYLE = ` .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; }