Skip to content

Layer mapping

ezu translate lowers a MapLibre GL style into an ezu recipe. This page is the mapping table. For the workflow, see from a MapLibre style; for what is approximated or skipped, gaps and differences.

Every pair below is one XYZ tile of protomaps-light — 68 layers, 11 of them symbol — converted with ezu translate and rendered by ezu on the CPU, next to the same tile drawn by maplibre-gl-js on a GPU (headless Chromium, software WebGL). Nothing is hand-tuned between them: same style, same data, same tile.

Tile 14/14550/6452 rendered by ezuThe same tile rendered by maplibre-gl-js
ezu (left) and maplibre-gl-js (right). Shibuya and Aoyama at z14 — dense labels, icons, park fills. SSIM 0.800, closeness score 94.90/100.
Tile 12/3637/1613 rendered by ezuThe same tile rendered by maplibre-gl-js
ezu (left) and maplibre-gl-js (right). Greater Tokyo at z12 — coastline, motorways, sparse labels. SSIM 0.874, closeness score 95.18/100.
Tile 15/29101/12904 rendered by ezuThe same tile rendered by maplibre-gl-js
ezu (left) and maplibre-gl-js (right). A single Tokyo neighbourhood at z15 — street names and building fills. SSIM 0.846, closeness score 95.22/100.

SSIM is structural similarity over 8×8 luma windows, which is less dominated by antialiasing and label noise than a plain pixel difference. Reproduce any of these with ezu-compare:

Terminal window
cargo run --release -p ezu-compare -- \
--style https://papers.reearth.land/styles/protomaps-light/style.json \
--tiles 14/14550/6452,12/3637/1613,15/29101/12904 \
--out out/compare

Two structural facts first:

  • The ordered layer list becomes a stack — painter’s algorithm written as a graph, each layer composited over the ones below (a long list is split into a chain of narrower stacks, to the same pixels).
  • The recipe is zoom-independent. Zoom and data functions are emitted as raw expressions on *-expr fields and evaluated per tile, so one recipe renders correctly at every zoom.
MapLibre ezu
background solid. A zoom curve on background-color becomes an expr node feeding its color; a constant background-opacity is folded into the colour’s alpha
fill (solid colour, fill-outline-color) features + fill-solid (outline → edge, or edge-expr when data-driven). fill-opacity becomes fill-alpha / opacity-expr and, under an outline, edge-alpha / edge-alpha-expr too, since MapLibre fades the outline with the fill
line (+ line-dasharray, line-cap/join, line-miter-limit, line-gap-width) features → boundary (clip-edges: drop) → crisp stroke, the dash in line widths (dash-unit: width), so it scales with the width each feature is drawn at, an expression line-width included. A miter join carries line-miter-limit as miter-limit, MapLibre’s default of 2 when the layer sets none (a zoom curve through an expr node). On a polygon source-layer the rings are stroked without the tile-buffer clip edges, as MapLibre does. A gap renders MapLibre’s casing annulus — one stroke’s footprint with the corridor knocked out — so joins, caps and dash phase behave like the GL shader
circle (+ circle-stroke-*, circle-blur) circles: a crisp disk at each point with its stroke ring, each property a constant or a per-feature *-expr. circle-stroke-opacity becomes stroke-opacity, and circle-blur the blur fade to the edge. The ring gets stroke-align: outside, so it runs from circle-radius out to radius + stroke width, as in MapLibre
raster raster. The source’s minzoom / maxzoom carry over as min-zoom / max-zoom, as for vector and raster-dem sources. raster-opacity, raster-hue-rotate, raster-saturation, raster-contrast, raster-brightness-min and raster-brightness-max become the node’s opacity, hue-rotate-deg, saturation, contrast, brightness-min and brightness-max, which follow MapLibre’s shader to the formula; a zoom curve becomes an expr node feeding the field. A constant raster-resampling becomes the source’s resampling
hillshade (over raster-dem) dem + hillshade (tone calibration still approximate). A constant hillshade-shadow-color / hillshade-highlight-color becomes shadow-color / highlight-color; an expression warns and keeps the node’s default, as does an unset highlight (none, where MapLibre’s is white). The source encoding maps mapbox (the default) to mapbox-rgb, terrarium to terrarium, and custom to custom with redFactor/greenFactor/blueFactor/baseShift carried as red-factor/green-factor/blue-factor/base-shift (or to the fixed encoding whose factors they equal)
heatmap features → density (GL-JS kernel) → color-ramp, with the colour expression over heatmap-density baked to a 256-entry ramp per tile
fill-extrusion flat footprint fill-solid using fill-extrusion-color — no 3-D, height and base dropped
symbol (icons) the icon rides the layer’s label: a point symbol’s icon and text boxes join the shared collision index and place or drop as one unit
symbol (text) text node, point or line placement

Icons. icon-image (including data-driven), icon-size/-rotate/-opacity/ -anchor/-offset/-padding, icon-allow-overlap/icon-overlap/ -ignore-placement, icon-optional/text-optional, and icon-text-fit + -padding with nine-slice stretchX/stretchY/content metadata all convert. Icon-only symbol layers place too. Line-placed icons lower to a collision-free stamp, which keeps icon-anchor (data-driven too) and a constant icon-offset.

Text. symbol-placement: point / line / line-center with text-field, -size/-color/-halo-*/-opacity, anchor, offset, justify, wrapping, line height, letter spacing, transform, spacing, each of those layout properties as an expression too (a zoom curve on text-max-width, a match on text-justify), lowered to the matching *-expr field, a data-driven text-anchor on point placement (lowered to anchor-expr), text-rotate on point placement (rotate-deg / rotate-deg-expr), text-variable-anchor (with text-radial-offset and per-anchor offset mirroring) and text-variable-anchor-offset, both lowered to anchor-offsets, and on lines symbol-spacing / text-max-angle / text-keep-upright. Collision: text-allow-overlap/-ignore-placement/ -padding, text-overlap, symbol-sort-key.

Point placement labels each point, and each polygon once at its pole of inaccessibility (MapLibre’s findPoleOfInaccessibility, per tile); line placement walks each polyline with tangent-rotated glyphs and per-glyph collision, anchored on tile-clipped geometry with MapLibre’s spacing phases.

All of a style’s symbol layers collide in one shared index. Each lowers to a text-labels (candidates) + text-draw (placed labels) pair around a single label-placement node, placed top layer first — so a POI can knock out a road name, as MapLibre does. Placement is deterministic across tiles: candidates are gathered from the eight neighbours, deduped, ordered by symbol-sort-key with ties broken by tile feature order, and placed greedily.

Fonts. Zero-config: an unmapped text-font stack is served from the style’s own glyphs endpoint as an SDF glyphs source — the same pre-rendered glyphs MapLibre draws. --font "NAME=SOURCE" (or ConvertOptions::fonts) overrides with a real font per entry for outline rendering. A {token} field is an expression once the style is migrated (--migrate, see below).

MapLibre ezu
fill-pattern (constant) icon → tiling, clipped to the fill shape via blend { composite: "source-atop" }
line-pattern (constant) icon → line-stamp (over boundary, so polygon rings too), repeated along the line and fitted to line-width. An expression line-width, line-opacity or line-offset becomes width-expr / opacity-expr / offset-expr, and a constant line-translate shifts the lines first; line-gap-width is not drawn and warns
line-pattern (data-driven) line-stamp with the style’s sprite sheet and the pattern expression as name-expr, cropping each feature’s icon
top-level sprite (single URL or [{id, url}]) one sprite source per sheet — atlas <url>.png plus index <url>.json, or an inline index. sheet:icon names supported
MapLibre ezu
circle-translate, fill-translate, line-translate, fill-extrusion-translate (constant [x, y]) a transform node with translate-px between the features and the paint node, a line-pattern layer’s line-stamp included. The *-translate-anchor beside it needs nothing: a tile is never rotated
line-offset (constant or expression) the stroke’s, or on a line-pattern layer the line-stamp’s, offset-px / offset-expr: the line drawn beside itself, to the right of travel, corners mitred as MapLibre extrudes them
MapLibre ezu
layer filter, expression form passes through verbatim as the features node’s filter-expr
layer filter, legacy form (bare fields, !in, !has, none) converted by maplibre_expr::convert_legacy_filter — MapLibre’s own pre-compile conversion, strict-type semantics included
zoom / data functions (interpolate, step, any expression) emitted raw onto the target node’s *-expr field, evaluated per tile
legacy forms — {stops} function objects, {token} label strings, non-standard hsl() colours converted only with --migrate (ConvertOptions::migrate), by maplibre_expr::migrate — MapLibre’s own migrate, each value converted with its property spec. Off by default: a legacy form is passed through untouched and named in a warning; a function object is then rejected by ezu-paint, a token string renders literally
layer minzoom / maxzoom a render-time gate: min-zoom / max-zoom on the features, raster or dem node the layer reads, and for a background a switch picked by an expr over the zoom
multiple vector sources all emitted, each with its minzoom / maxzoom as min-zoom / max-zoom; each features node targets its (source, layer)
inline / remote geojson source a geojson source; the host projects it per tile and binds one layer (features targets (source, source))
CSS named colours (steelblue, transparent, …) resolved to hex
translucent colours (rgba(), hsla(), #rrggbbaa) #rrggbbaa, the alpha multiplied by the node’s opacity as MapLibre multiplies it by the layer’s
layout.visibility: "none" dropped by default, or kept behind a switch with --keep-hidden (set its case’s when to true to enable)

The figures at the top of this page were produced by ezu-compare, which converts a style, renders it with ezu, renders a maplibre-gl-js reference, and reports RMSE, “visibly different” pixel fraction, and SSIM per tile. Point it at your style rather than trusting a general claim:

Terminal window
cargo run --release -p ezu-compare -- \
--style YOUR_STYLE_URL_OR_PATH \
--tiles 12/3637/1613,13/7275/3225,14/14550/6452 \
--out out/compare

It writes the ezu render, the reference, a diff image, and the emitted recipe, so a disagreement is something you can look at rather than infer.

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.