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; 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

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" }

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.

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.

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. 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.

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.