Use in the browser
npm install @reearth/ezuThe 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.
The shape of a render
Section titled “The shape of a render”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.
Params, per render
Section titled “Params, per render”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.
Building the panel from the schema
Section titled “Building the panel from the schema”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.
Binding sources
Section titled “Binding sources”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 offOnly 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.
Past a source’s maxzoom
Section titled “Past a source’s maxzoom”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 ceilingr.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.
Glyphs and fonts
Section titled “Glyphs and fonts”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 PBFBoth 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.
Decoding a tile once for several renders
Section titled “Decoding a tile once for several renders”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 onceconst 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 page at a print scale
Section titled “A page at a print scale”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 PNGcomputeFrame 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.
Memory and lifetime
Section titled “Memory and lifetime”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.
Parallel rendering
Section titled “Parallel rendering”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-originandCross-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.
Gotchas
Section titled “Gotchas”- Every
Uint8Arrayreturn 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 anImageBitmap. std::time::Instantpanics onwasm32-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
tracingevents can be surfaced in the console by installing aLogSinkonce at startup — the same op/cache-hit/duration trace--verboseprints natively.onEventhears each event as it happens anddrainLines()takes what was buffered, as the Go package’sWithLoggerandDrainLogsdo. opCount()andopNames()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.