Skip to content

legend

A legend is not part of a tile. It sits beside the map, at a size and in a layout only the host knows — a sidebar on a web page, a corner of a print layout, a collapsible panel next to a zoom control. Baking one into the raster would freeze all of that at render time.

So the style declares the legend’s content, and the host draws it. ezu legend emits the declaration as JSON:

Terminal window
ezu legend style.json --pretty
ezu legend style.json --zoom 12 # only the entries that apply at z12
{
"legend": {
"title": "Median household income",
"note": "ACS 5-year estimates, classified by natural breaks. County-level, so neighbourhood differences are averaged away.",
"entries": [
{ "label": "under $40,000", "from": "@income", "properties": { "income": 30000 } },
{ "label": "$40,000–60,000", "from": "@income", "properties": { "income": 50000 } },
{ "label": "over $60,000", "from": "@income", "properties": { "income": 80000 } },
{ "label": "county line", "from": "@borders", "min-zoom": 6 }
]
}
}
Field Type Meaning
title string heading — usually what is being mapped
note string prose below the entries: sources, classification, what is left out
entries array rows, in reading order

And on an entry:

Field Type Meaning
label string required — what the reader sees
from @node required — the node that draws this symbol
properties object feature properties that select this case
note string prose for this entry alone
min-zoom / max-zoom integer zooms this entry applies to; absent means all
geometry all | polygon | line | point which geometry the swatch’s stand-in feature carries; see below

The obvious way to write a legend row is to restate its colour. That colour is then free to drift: change the map’s palette and the legend keeps explaining the old one, with nothing to catch it.

So an entry names the node that draws the symbol and the feature that selects the case instead. { "income": 30000 } is not the value being labelled — it is a value inside the class, fed to the node’s -expr fields the way a real feature would be, so that whatever the map draws for a county with that income is what the entry stands for.

from is checked when the graph is built: the node must exist and must produce a Raster, so an entry cannot point at something that draws nothing. ezu check reports a broken legend the same way it reports a broken node reference.

What this does not pin down is the labels. Nothing checks that "under $40,000" matches the break in your expression — the breaks are stated in two places and it is on you to keep them agreeing. That is the cost of declaring a legend rather than deriving one, and the reason to declare it is the same reason to state a classification method at all: the choice of where the classes fall is the author’s, and a reader can only weigh a map whose author says what those choices were.

A web map’s symbols come and go with scale, and a legend that explains symbols the reader cannot see is noise. Gate an entry with min-zoom / max-zoom and ask for one zoom’s worth at a time:

Terminal window
ezu legend style.json --zoom 4

A symbol in this renderer is rarely a colour. A watercolour fill is brush dabs; a sketched road is a jittered stroke; a dot density layer is a scatter. None of that reduces to a hex value a host could put in a <div>, so swatches are drawn by the renderer, through the same node the map uses:

Terminal window
ezu legend style.json --swatch-dir out/ --swatch-size 64x24 --zoom 11 --pretty

Each entry’s symbol lands in out/<n>.png, and the emitted JSON gains a swatch path per entry. --swatch-size takes WIDTHxHEIGHT, or one number for a square — a swatch is not a map tile, so it need not be square.

Two things make this work, and both are worth knowing because both are visible in the result.

The entry’s node is rendered on its own. Only that node and what it depends on are drawn, so there is no basemap under the symbol and everywhere the symbol does not cover is transparent — a host can put a swatch on whatever background its legend has. Sources belonging to other layers are never touched, so a swatch cannot fail on a DEM it has nothing to do with.

The features are stood in for. The graph is handed one synthetic feature carrying the entry’s properties, filling the swatch: a polygon, a line across the middle, and a point at the centre, all at once. A fill node reads the polygon, a stroke the line, a circle or stamp the point, and each ignores the rest.

When a geometry op sits between the source and the entry’s node that can double up — boundary turns the polygon into a rectangle outline, which a stroke then draws as well as the line. Name the geometry on the entry to stop it:

{ "label": "coastline", "from": "@coast", "geometry": "line" }

Because the whole node runs, a swatch shows what that node draws for one feature — not a curated illustration of it. A dot-density layer’s swatch is a patch of scattered dots at the true density for that zoom, which reads as a texture rather than as “one dot = 100 people”. If you want the second thing, give the legend an entry pointing at a node that draws a single dot.

Swatches are drawn at one zoom, since a symbol may change with scale; --zoom picks it and defaults to 12.

Give the stand-in whatever the layer filters on

Section titled “Give the stand-in whatever the layer filters on”

The entry’s node is rendered with everything it depends on, and that includes the features node’s filter-expr and min-zoom-field. The stand-in feature has only the properties the entry declares, so a filter it cannot satisfy drops it and the swatch comes out empty.

The watercolour example’s roads are filtered:

"roads_trunk_f": {
"op": "features", "layer": "roads", "min-zoom-field": "min_zoom",
"filter-expr": ["in", ["get", "kind_detail"], ["literal", ["trunk", "primary", "motorway"]]]
}

so its entry has to say enough to pass:

{ "label": "trunk road", "from": "@roads_trunk", "geometry": "line",
"properties": { "kind_detail": "trunk", "min_zoom": 0 } }

An empty swatch almost always means this. Read the features node feeding the entry and give the stand-in what it asks for.

The watercolor example ships with the legend above. Drawn at 240×44:

Terminal window
ezu legend crates/ezu/examples/styles/watercolor.json \
--swatch-dir out/ --swatch-size 240x44 --zoom 13 --pretty
Three swatches: a mottled blue wash, a flat green wash, and a faint train of brown brush dabs

The three entries, at 1:1. Water is a dab fill, so its swatch is mottled the way the map is. The trunk road is a single glazing-brush stroke at the map’s own 1.6 px radius and 0.6 opacity — at legend size that reads as a faint train of dabs, which is what the map draws rather than an artifact of the swatch.

And the JSON, with swatch paths added:

{
"title": "Watercolour",
"note": "Washes, not categories: the landuse wash fades in with zoom, so the green means \"more built-up than not\" rather than any one class.",
"entries": [
{
"label": "water",
"from": "water",
"geometry": "polygon",
"swatch": "out/0.png"
},
{
"label": "green space",
"from": "landuse",
"geometry": "polygon",
"swatch": "out/1.png"
},
{
"label": "trunk road",
"from": "roads_trunk",
"properties": { "kind_detail": "trunk", "min_zoom": 0 },
"note": "Drawn with the glazing brush, so the stroke thins and thickens along its length.",
"geometry": "line",
"swatch": "out/2.png"
}
]
}

from comes back without its @: it is an id to look up, not a reference to resolve. Absent fields stay absent rather than coming through as nulls, so a host can test for them.

Terminal window
ezu legend STYLE [--zoom Z] [--out FILE] [--pretty]
[--swatch-dir DIR] [--swatch-size WxH] [--assets-dir DIR]
Flag Meaning
--zoom keep only the entries that apply at this zoom, and draw swatches at it
--out write the JSON to a file instead of stdout
--pretty indent the JSON
--swatch-dir draw each entry’s symbol into this directory as <n>.png
--swatch-size WIDTHxHEIGHT, or one number for a square (default 48x32)
--assets-dir where to resolve relative asset paths, for symbols needing a brush, font or sprite

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.