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:
ezu legend style.json --prettyezu legend style.json --zoom 12 # only the entries that apply at z12The block
Section titled “The block”{ "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 |
Why an entry names a node
Section titled “Why an entry names a node”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:
ezu legend style.json --zoom 4Drawing the swatches
Section titled “Drawing the swatches”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:
ezu legend style.json --swatch-dir out/ --swatch-size 64x24 --zoom 11 --prettyEach 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.
A worked example
Section titled “A worked example”The watercolor example ships with the legend above. Drawn at 240×44:
ezu legend crates/ezu/examples/styles/watercolor.json \ --swatch-dir out/ --swatch-size 240x44 --zoom 13 --pretty
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.
The whole command
Section titled “The whole command”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.