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; a value may be a place in the swatch, see below |
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 |
features |
array | stand-in features placed by hand, for a symbol made of parts; replaces geometry, 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" }Placing the stand-ins yourself
Section titled “Placing the stand-ins yourself”Some symbols are an arrangement of parts, and the fixed stand-in cannot
arrange anything. On an address map, an “assigned house number” is a building square,
a dot at its representative point, a dotted connector out to an entrance dot,
and the number over the representative point — a stack of a fill, circles,
a dashed stroke and a text node, each fed by its own features node. On the
map, the connector comes from the entrance points themselves: each carries
its building’s representative point as a longitude and latitude, and
segment-to joins the two.
"ent": { "op": "features", "source": "addresses", "layer": "entrances" },"conn": { "op": "segment-to", "features": "@ent", "lng-field": "anchor_lng", "lat-field": "anchor_lat" },"dash": { "op": "dash", "features": "@conn", "dash-px": 3, "gap-px": 2 },"line": { "op": "stroke", "features": "@dash", "width-px": 1.5, "color": "#606060" }features lists the stand-ins to draw instead, and the entrance carries the
same two properties the map’s entrances do:
{ "label": "assigned house number", "from": "@addr", "properties": { "no": "12" }, "features": [ { "geometry": { "type": "Polygon", "coordinates": [[[0.06, 0.45], [0.42, 0.45], [0.42, 0.92], [0.06, 0.92]]] } }, { "geometry": { "type": "Point", "coordinates": [0.24, 0.68] }, "properties": { "part": "rep" } }, { "geometry": { "type": "Point", "coordinates": [0.88, 0.68] }, "properties": { "part": "entrance", "anchor_lng": { "swatch-x": 0.24 }, "anchor_lat": { "swatch-y": 0.68 } } } ]}Each geometry is GeoJSON-shaped — Point, LineString or Polygon — but
its coordinates are [x, y] fractions of the swatch, from 0 to 1, with
the origin at the top-left. They are scaled to --swatch-size when drawn, so
one entry serves every size; what does not scale is anything the nodes measure
in pixels, so a circle’s radius and a label’s size stay as the map has them.
The fractions apply to each axis separately, which means a square drawn as
0.36 × 0.47 of a 96×48 swatch comes out about 35×23 pixels: write the
coordinates for the shape your legend uses.
All the stand-ins go to every features node the entry’s graph reads, as the
fixed one does, and each node’s filter-expr picks out its part — by
["geometry-type"], or by a property only that part carries, like part
above. A feature’s own properties are laid over the entry’s, so what every
part shares (here the number the label reads) is said once.
features and geometry cannot both be given, since the features already say
what geometry there is; ezu check refuses that, an empty list, a line of
fewer than two positions, a ring of fewer than three, and a position outside
0–1.
Labels in a swatch are placed with the same collision as on the map, against the swatch’s own labels only: a swatch has no neighbouring tiles, so a label cut by one edge stays cut rather than reappearing from the other side.
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.
Positions in properties
Section titled “Positions in properties”segment-to reads where to draw to from the feature’s properties, in
longitude and latitude, and a swatch has no geography for those to mean
anything in. So a swatch is given one: it is drawn as a fixed tile at its zoom
(the one whose top-left corner is the middle of the world), spanning that
tile from corner to corner. A property written as { "swatch-x": f } becomes
the longitude f of the way across that tile, and { "swatch-y": f } the
latitude f of the way down. In the house-number entry, anchor_lng and
anchor_lat come out as the longitude and latitude of the representative point’s
dot, and segment-to draws the connector from there to the entrance, as it
does on the map — the swatch needs no stroke of its own.
Each property holds one axis, because that is how the fields come: a longitude
in one and a latitude in another. Mercator maps the two independently, so each
converts without the other. They work in the entry’s properties as well as
in a feature’s, and with the fixed stand-in as well as with features.
The ent node has no filter and needs none: every stand-in reaches it, but
only the entrance carries the two fields, and segment-to draws nothing for a
feature without them.
ezu check refuses an object naming swatch-x or swatch-y that is not
exactly one of them holding a number from 0 to 1. Any other object as a
property value is dropped: a feature property is a scalar.
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", "source": "basemap", "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. An entry’s features come back as declared, so a host
that draws its own swatches can stand in the same features --swatch-dir
does. Swatch positions in properties come back as declared too, not as
degrees: which longitude { "swatch-x": 0.24 } stands for depends on the zoom
the swatch is drawn at, and a host drawing its own picks that. Hosts building
on the Rust crates get the conversion from ezu::paint::legend —
stand_in_layer resolves it for a zoom, and swatch_tile names the tile to
render as.
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.