Skip to content

Use in the browser

Terminal window
npm install @reearth/ezu

The package ships browser, Node, and workerd builds — scalar and SIMD — behind conditional exports, so your bundler picks one. The wasm module is stateful: a Renderer holds the parsed style, its built graph, an in-memory asset bank, and a per-tile binding buffer mirroring the style’s sources block.

The JS side owns all I/O. ezu never fetches. You supply decoded bytes.

import init, { Renderer, simdEnabled } from '@reearth/ezu';
await init();
const r = new Renderer(styleJson);
const z = 13, x = 7276, y = 3225;
const mvt = new Uint8Array(await (await fetch(`/mvt/${z}/${x}/${y}`)).arrayBuffer());
r.clearSources();
r.bindSource('basemap', mvt); // keyed by the `sources` entry name
// Fastest path to the screen: skip the PNG round trip.
const rgba = r.renderTile(z, x, y, { format: 'rgba' });
const w = r.tileSize;
canvas.getContext('2d').putImageData(
new ImageData(new Uint8ClampedArray(rgba.buffer), w, w), 0, 0);

renderTile also returns 'png' (the default) or lossless 'webp'. Use RGBA when the destination is a canvas; use PNG/WebP when the bytes are going into an <img>, a cache, or over the network.

A style’s declared params are overridable here the same way they are from the CLI or the tile server’s query string:

r.renderTile(z, x, y, {
format: 'rgba',
params: { paper: '#ffe0f0', softness: 2, labels: false },
});

Values are validated against the declarations — the wrong type, an out-of-range number, or a name the style does not declare throws rather than rendering something quietly wrong. Names you leave out keep their declared default.

One built graph serves every combination, and the cache keys on the values each node actually reads, so moving one slider re-evaluates only its subtree. That is what makes a params panel in a browser feel immediate.

r.paramsSchema returns the JSON Schema of the declarations — the same document the tile server serves at /style/params:

for (const [name, p] of Object.entries(r.paramsSchema.properties)) {
// p.type: 'number' | 'string' (format 'color') | 'boolean'
// p.default, p.minimum, p.maximum, p.description
panel.append(control(name, p));
}

Drive your controls off that rather than reading the style’s params block yourself. It follows setStyle, so the panel and the graph being rendered cannot drift apart — and a style that declares a param you have no control for shows up as a missing widget instead of a knob that silently does nothing.

bindSource(name, bytes, opts?) dispatches on the declared type of that source:

Declared type What the renderer does with the bytes Lifetime
brush parse the .myb, register in the bank persistent
image, sprite decode PNG/WebP persistent
mvt, pmtiles decode MVT, bind each layer as tile.<layer> cleared by clearSources()
dem decode and stitch the 3×3 neighbourhood per tile
raster decode RGBA imagery and stitch per tile

Neighbour tiles are bound with { coord: [dx, dy] }, dx, dy ∈ {-1, 0, 1}, the centre being the default [0, 0].

Do not fetch a blind 3×3 for everything. Ask what the style actually reads:

r.requestedNeighborOffsets('basemap'); // e.g. [[-1,0],[1,0],[0,-1],[0,1]] — or []
r.requestedNeighborOffsets('terrain'); // all eight, unless neighbor-fetch is off

Only cross-tile label collision and edge-continuous DEM/raster shading read neighbours, and only for the sources that need them. A vector source answers from the graph, so its list is often empty; a dem or raster stitches its window into one buffer, so it asks for all eight unless it declares neighbor-fetch: false, or no node reads it.

Bind every offset it reports. The centre tile alone is not a smaller render of the same thing — the stitch fills the missing pad by clamping the centre’s edge pixels, and anything sampling the pad drags that guess back inside the tile as a seam along the border. The renderer warns when a dem or raster window comes up short — a host with a LogSink installed will see it.

sourceTile(name, z, x, y) accepts off-grid coordinates for exactly this loop: x wraps around the antimeridian, and a y off the top or bottom of the world comes back unchanged, so its fetch misses and that edge clamps — which is what the pole should look like.

Sources end at different depths — a vector basemap at z15, a terrain pyramid at z12–14 — while the map keeps zooming. Ask which tile to fetch rather than hard-coding each ceiling, then say which zoom the bytes came from:

const t = r.sourceTile('terrain', z, x, y); // { z: 14, x: …, y: … } past the ceiling
r.bindSource('terrain', await fetchDem(t.z, t.x, t.y), { sourceZoom: t.z });

Vector geometry is reprojected into the tile’s frame; dem and raster have the ancestor’s covering sub-rectangle resampled. Each neighbour resolves against its own ancestor, so pass the neighbour’s coordinate to sourceTile too — a 3×3 window straddles two parents whenever the centre sits on a parent boundary.

Skip this and the source simply has nothing bound past its ceiling: the dem node emits a zero-elevation field and the hillshade goes flat with no error. A style that would rather fail than draw that says on-missing: error on the source, and renderTile throws instead.

This host cannot fetch lazily, so after binding vector sources and before rendering, ask what text needs:

r.neededGlyphRanges(); // { fonts: [0, 256, 8192] } → fetch `…/{fontstack}/0-255.pbf`, etc.
r.neededCodepoints(); // exact codepoints, if you can build your own PBF

Both over-approximate. setGlyphBudget(bytes) caps what each fontstack keeps resident; without it a fontstack keeps every range ever bound for the renderer’s life.

system: font sources do not work here — there are no installed fonts in wasm. Use a font file URL or a glyphs endpoint.

memoryUsage() reports the renderer’s payloads (glyphs, fonts, images, cache) plus module-wide heapBytes. heapBytes is a high-water mark: wasm cannot return pages to the host, so it never falls. In a memory-capped isolate, poll it and shed load — drop a renderer with free() — before an allocation fails.

setStyle(styleJson) swaps the style in place and returns the new node count, reusing the module and the asset bank.

Three builds sit side by side:

  • scalar and SIMD (+simd128) — stable Rust, run anywhere. simdEnabled() tells you which you loaded.
  • threads — renders across Web Workers via wasm-bindgen-rayon. Nightly to build; at runtime the page must be cross-origin isolated (Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp, which means HTTPS or localhost).
if (threadsEnabled()) {
await initThreadPool(navigator.hardwareConcurrency);
r.renderTile(z, x, y, { parallel: true });
}

ezu serve hosts a self-contained demo page with exactly the headers the threads build needs — the quickest way to see it working before wiring up your own server.

  • Every Uint8Array return value is copied across the wasm-bindgen boundary. A 512×512 RGBA buffer is 1 MB, comparable to a PNG decode on the JS side — so prefer RGBA only when you would otherwise re-decode the PNG into an ImageBitmap.
  • std::time::Instant panics on wasm32-unknown-unknown, so timing in the rendering crates is compiled out on wasm. If you extend those crates, gate new timing the same way.
  • Per-node tracing events can be surfaced in the console by installing a LogSink once at startup — the same op/cache-hit/duration trace --verbose prints natively.

The full JS API, build commands, and benchmarks live in the ezu-wasm README.

Map renders on this site are made fromOpenStreetMap data viaProtomaps (© OpenStreetMap contributors), elevation from Re:Earth Terrain,Mapterhorn andEGM2008 (NGA), and aerial imagery from GSI Japan(© 国土地理院). The painterly styles use CC0 brushes byDavid Revoy.