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.

Start from what the style declares rather than from names you assume:

for (const s of r.sources()) {
// s.name, s.type, s.tileScoped, s.url, s.indexUrl
}

url is absent only for a geojson source with inline data — nothing to fetch and nothing to bind — and a glyphs url arrives with {fontstack} already substituted and percent-encoded, leaving {range} for you to fill in per block. boundSources() answers the opposite question, what is already bound, and cannot start the loop.

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.

A printed page is asked for as “1:1000 at 200 dpi, 245 × 174 mm, centred here”, not as a zoom. ezu frame meets the scale by choosing the tile size, renders the tiles the page covers at that size and crops them to the page. The JS package does the same, with the same code, so a page rendered here is the CLI’s pixel for pixel:

const mm = (length, dpi) => Math.round(length / 25.4 * dpi);
const f = r.computeFrame({
centerLon: 139.70, centerLat: 35.68,
scale: 1000, dpi: 200,
width: mm(245, 200), // 1929
height: mm(174, 200), // 1370
});
// f.zoom 19, f.tileSize 489, f.tilesX × f.tilesY: the 5 × 4 tiles to render
const rgba = await r.renderFrame(f, async ({ z, x, y }, binder) => {
// Bind this tile as under "Binding sources" above, but through the binder.
binder.bindSource('basemap', await fetchMvt(z, x, y));
});
// 1929 × 1370 straight RGBA: putImageData it, or encode it as a PNG

computeFrame changes nothing on the renderer; it aims the tile size at the style’s own unless styleTileSize names another, and zoom fixes the zoom as the CLI’s --zoom does. renderFrame renders the tiles one after another, clearing the tile-scoped sources and awaiting your function before each — the same binding as for a single tile, so neighbours, overzoom and glyphs work as above. It draws at f.tileSize per render, so the renderer’s own tile size is untouched.

Until the promise settles the renderer is the frame’s. Your function binds through the binder it is handed, which works for that tile only and only until the function returns or settles; used later it throws StaleBinder. Anything else that would change the renderer — bindSource, clearSources, renderTile, another renderFrame, setStyle, setTileSize — throws FrameInProgress, so a request that runs while your function awaits its fetch cannot slip its bindings into the frame. Queries that only read, such as sourceTile and neededCodepoints, still answer, and the renderer is given back however the frame ends.

To render a frame’s tiles yourself — spread over several renderers, say — draw each at f.tileSize (renderTile(z, x, y, { format: "rgba", tileSize: f.tileSize }), or setTileSize on the renderer) and copy it into the page with its top-left at (x · tileSize − left, y · tileSize − top), cropping the tiles along the border. clearTileSize() goes back to the style’s size, and so does setStyle.

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.