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.

On a hi-DPI screen pass pixelRatio: window.devicePixelRatio (1 to 4, fractions included). The tile comes back that many times larger on each side, with every width, text size and blur the style gives in pixels scaled with it and the same labels in the same spots, so drawn at the plain tile’s size on the page it is the same map, sharp. tileSize is a different knob: a larger tile at the same pixel sizes, which draws finer.

const ratio = window.devicePixelRatio;
const px = r.renderTile(z, x, y, { format: 'rgba', pixelRatio: ratio });
const side = Math.round(r.renderTileSize * ratio);

r.warnings lists what building the style’s graph warned about — a node that works but relies on something the style does not say — for an editor that shows them next to the style, or a build that refuses a style with any.

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' for a colour) | 'boolean'
// p.default, p.minimum, p.maximum, p.enum (a string's values), 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, and clearGlyphBudget() lifts the cap; without one 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.

bindSource decodes the bytes each time they are bound, and a block of tiles reads most of its tiles more than once: the centre of one render is a neighbour of the next eight. loadSource decodes a tile-scoped payload once into a SourceData, a SourceSet says which of them one render reads and where, and renderTileWith renders from the set and from nothing that is bound:

import { SourceSet } from '@reearth/ezu';
const loaded = new Map(); // each fetched tile, decoded once
const load = async (z, x, y) => {
const key = `${z}/${x}/${y}`;
if (!loaded.has(key)) loaded.set(key, r.loadSource('basemap', await fetchMvt(z, x, y)));
return loaded.get(key);
};
for (const { z, x, y } of block) {
const set = new SourceSet();
set.bind(await load(z, x, y));
for (const [dx, dy] of r.requestedNeighborOffsets('basemap')) {
set.bind(await load(z, x + dx, y + dy), { coord: [dx, dy] });
}
const png = r.renderTileWith(z, x, y, set);
set.free();
}
for (const data of loaded.values()) data.free();

A set takes the coord and sourceZoom a bind does, and draws the same bytes as binding the same data in the same places; there is nothing to clear between tiles. Glyphs, fonts and sprites are still bound with bindSource, and neededCodepointsIn(set) names what a set needs. renderTileWith leaves a glyph budget for trimGlyphs() to apply after the batch. A set shares the data it holds, so freeing a SourceData once its sets have it is safe.

A render can also hand back what a features node held — the labels and the boxes placement gave them, the features a filter kept — next to the image, from the same evaluation:

const { image, features } = r.renderTileWithFeatures(z, x, y, set, ['boxes']);
// features[0]: { node: "boxes", extent: 4096, geojson: '{"type":"FeatureCollection",…}' }
const boxes = JSON.parse(features[0].geojson);

geojson is the text the renderer wrote, as renderFrameWithFeatures and the Go package hand it back too; parse it, or pass it on as it is. The collection is in tile units: x right and y down from the tile’s top-left corner, 0..extent across it. A node the render never reached has no extent and no features, and a name that is not a features node of the style throws InvalidStyle before anything renders.

Pass { nodes, coords } in place of the list for other coordinates: "lonlat" writes WGS84 to seven decimals, the numbers a page holding the tile would write, and "px" the rendered tile’s pixels from its top-left corner, at the tile size the render drew at (tileSize in opts, if you gave one).

const { features } = r.renderTileWithFeatures(z, x, y, set, { nodes: ['boxes'], coords: 'lonlat' });

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) => {
// The set loop above, for this tile, with `load` keeping what it decodes
// for the tiles around it.
const set = new SourceSet();
set.bind(await load(z, x, y));
return set;
});
// 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, awaiting your function before each for the SourceSet it renders from — the same set as for a single tile, so neighbours and overzoom work as above. A page’s tiles border each other, so keeping each SourceData for the tiles around it decodes every fetched tile once. The frame reads the set and leaves it yours. It draws at f.tileSize per render, so the renderer’s own tile size is untouched, and trims glyphs to a budget after each tile.

Until the promise settles the renderer is the frame’s. Glyphs a set needs are bound through the binder your function is handed, which works for that tile only and only until the function returns or settles; used later it throws StaleBinder, and a tile-scoped source bound through it throws UnknownSource, since the frame never reads bound tile data. Anything else that would change the renderer — bindSource, clearSources, renderTile, trimGlyphs, another renderFrame or renderFrameWithFeatures, setStyle, setTileSize, clearTileSize, setGlyphBudget, clearGlyphBudget, setCacheBudget, clearCacheBudget — throws FrameInProgress, so a request that runs while your function awaits its fetch cannot change the frame’s tiles under it. loadSource, renderTileWith and the queries that only read, such as sourceTile and neededCodepointsIn, still work, and the renderer is given back however the frame ends.

renderFrameWithFeatures renders the same page and also gathers features nodes over it, as the CLI’s --features does: each node’s page comes back as the file the CLI writes, byte for byte, with features wholly off the page left out, coordinates in longitude and latitude or page pixels, and, given a key, one copy of a feature that several tiles saw, sorted by the key:

const { image, features } = await r.renderFrameWithFeatures(f, sources, {
nodes: [{ node: "boxes", key: ["label-id", "part"] }],
coords: "lonlat", // or "px"
});
// features[0]: { node: "boxes", geojson: '{"type":"FeatureCollection","features":[\n…' }

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; tileSize keeps reporting the style’s own, and renderTileSize is the size renders use now. To gather features over such a page, r.frameFeatures(f, request) takes renderFrameWithFeatures’s request and returns a collector: render each tile through its renderTile(z, x, y, set, { tileSize: f.tileSize }), in any order, and finish() returns the pages. Every tile goes through the renderer that made the collector.

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. The render cache bounds itself at 8 MiB of pixels; setCacheBudget(bytes) moves that bound, down for a tight isolate or up for a batch of neighbouring tiles, and clearCacheBudget() goes back to the default.

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. onEvent hears each event as it happens and drainLines() takes what was buffered, as the Go package’s WithLogger and DrainLogs do.
  • opCount() and opNames() say which node ops the module registered; zero means its life-before-main constructors never ran and every style is about to fail with “unknown op”.

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.