Labels and icons
Text is the part of a map that has to be correct, not just pretty. ezu ports
MapLibre’s symbol layer: rustybuzz shaping, outline fonts or MapLibre glyph-PBF
endpoints, point and line placement, and collision that is deterministic across
tile boundaries.
A point label
Section titled “A point label”"sources": { "sans": { "type": "font", "url": "https://example.com/NotoSans-Regular.ttf" }},"nodes": { "places_f": { "op": "features", "source": "basemap", "layer": "places", "filter-expr": ["==", ["get", "kind"], "city"] }, "places": { "op": "text", "features": "@places_f", "font": ["sans"], "text": ["get", "name"], "size": 13, "color": "#333333", "halo-color": "#ffffffcc", "halo-width": 1.5, "source": "basemap", "layer": "places" }}text is a literal string or a MapLibre string expression evaluated per feature.
font is an ordered fallback stack of font and/or glyphs source names — outline
fonts and SDF stacks mix freely, and the first entry covering a character shapes it.
Set source and layer. They are how the node names its neighbour-tile
bindings, and without them collision is centre-tile-only, which shows up as labels
duplicated or fighting across borders. Mirror filter-expr, min-zoom,
max-zoom, and min-zoom-field from the upstream features node too, so
neighbour candidates are filtered identically to this tile’s own features.
Halos exist for a reason
Section titled “Halos exist for a reason”A halo is not decoration; it is what keeps a label readable over a busy map. 1–2 px
of the background colour at 60–80 % alpha is usually enough. outline-sdf is on by
default, which draws outline-font glyphs through the SDF path so the halo shape and
cost match MapLibre’s.
Striking a label through
Section titled “Striking a label through”An address map that keeps abolished house numbers on the page wants them struck
through and greyed, the current ones plain. strikethrough-expr decides per
feature; the bar takes the label’s color-expr and opacity, and gets the halo
too:
"lots_label": { "op": "text", "features": "@lots", "font": ["sans"], "text": ["get", "number"], "size": 13, "halo-color": "#ffffffcc", "halo-width": 1.5, "color-expr": ["case", ["get", "abolished"], "#8a8a8a", "#222222"], "strikethrough-expr": ["get", "abolished"]}Each wrapped line gets its own bar across its advance width, at the font’s
strikeout position (0.3 em above the baseline for a glyphs stack, which has no
metrics). strikethrough-width overrides the default thickness of 0.07 em. The bar
belongs to the drawn label, so it follows the placed anchor and draws identically
across tile seams; it leaves the collision box alone. Line-placed labels ignore it.
A box behind the label
Section titled “A box behind the label”A page number on an index map, or a route number, reads best on a solid
box. background-color draws one behind each label, as big as the laid-out text
(every line of it) plus background-padding, with background-radius-px corners:
"page_badge": { "op": "text", "features": "@pages", "font": ["sans"], "text": ["get", "page"], "size": 12, "color": "#ffffff", "anchor": "top-left", "offset-em": [0.4, 0.3], "background-color": "#2f6fe0", "background-padding": [3, 6, 3, 6], "background-radius-px": 4}The box is drawn at its final size, so it fits “7” and “128” alike without a
stretchable sprite. anchor and offset-em place the box, not the text: here the
box’s top-left corner sits 0.4 em right of and 0.3 em below the point, and the number starts
background-padding inside the box, so changing the padding grows the box without
moving its corner. The box is also what collides. It draws under the label’s halo
and glyphs; when the symbol has an icon too, the icon goes underneath the box.
Line-placed labels draw without a box. For a box with a shape of its own (a shield,
a pointer), use a sprite with icon-text-fit instead. For a border, a shadow or
anything else drawn around the box, take the box itself from label-boxes (see
drawing the label boxes yourself).
Line placement
Section titled “Line placement”"roads_label": { "op": "text", "features": "@roads_f", "font": ["sans"], "text": ["get", "name"], "placement": "line", "spacing-px": 250, "max-angle-deg": 45, "keep-upright": true, "size": 11, "color": "#5a4a3a", "halo-color": "#fbf6e6", "halo-width": 1.2}placement: "line" walks each polyline with tangent-rotated glyphs and per-glyph
collision; "line-center" places one label at each line’s arc-length midpoint.
spacing-px is MapLibre’s symbol-spacing, and max-angle-deg rejects anchors
where the line bends too much over a label-length window — raise it and labels
follow hairpins into illegibility.
Line placement ignores max-width-em: a line label is always one line.
Wrapping and anchoring
Section titled “Wrapping and anchoring”For point labels, max-width-em (default 10) sets the wrap target in em, 0
disables wrapping, and line-height is baseline-to-baseline in em. anchor puts a
part of the block on the point, and offset-em shifts it from there.
anchor-offsets gives a label several positions to try instead, in order, the
first free one winning:
"anchor-offsets": [ { "anchor": "bottom", "offset-em": [0, -0.8] }, { "anchor": "bottom", "offset-em": [0, -2.4] }, { "anchor": "left", "radial-em": 1 }]Each entry is an anchor plus its own shift: offset-em as written, or
radial-em, a distance pushed away from the point in the anchor’s direction.
The same anchor can appear twice, so a label crowded out of its usual spot can
move a little further off before trying another side. That is much better
behaved in dense areas than a single fixed anchor.
Labelling areas
Section titled “Labelling areas”Point placement gives each polygon one label. By default it sits at the polygon’s
pole of inaccessibility, the spot inside farthest from the outline, as in MapLibre.
That is the roomiest spot, so on a long block that widens towards one end the label
goes to that end. A label that names the area, such as a block number on an address
map, usually belongs in the middle: polygon-anchor: "centroid" puts it at the
area-weighted centroid, and uses the pole instead when the centroid falls outside
the shape (an L or a U).
"block_number": { "op": "text", "features": "@blocks", "font": ["sans"], "text": ["get", "block_number"], "polygon-anchor": "centroid", "source": "city", "layer": "blocks"}Past the source’s max-zoom, each tile is cut from an ancestor that holds the
whole polygon, and the anchor is found on that polygon as the ancestor tile holds
it. Every deeper zoom, and every tile a label straddles, then agrees on one spot.
At the zooms the source serves, a polygon that crosses a tile edge arrives cut: each tile
holds its own part plus the tile’s buffer, and anchors that part. A large polygon
can then get its label off-centre, or one label in each tile it crosses. MapLibre
behaves the same way. stitch on the features node does not fix this: the merged
polygon gets one anchor, but the neighbour candidates the label node gathers are
still each tile’s own part, so the two halves of a label on a seam stop matching.
What works is data that keeps each labelled polygon whole in every tile it touches.
Generate the tiles with a buffer wider than the largest polygon you label, or serve
the layer from a lower max-zoom, where fewer polygons cross a tile edge, and let
the deeper zooms overzoom from there.
Pairing an icon with its label
Section titled “Pairing an icon with its label”An icon and its text place as one unit against the same collision index, so a symbol shows both or neither:
"poi": { "op": "text", "features": "@poi_f", "font": ["sans"], "text": ["get", "name"], "icon-sprite": "@sheet", "icon-name-expr": ["get", "icon"], "icon-size": 1, "icon-anchor": "center", "icon-offset": [0, -8], "text-optional": true}text-optional lets the icon place when the text is blocked; icon-optional does
the reverse. Without either, one blocked half drops the whole symbol — which is the
MapLibre default and the reason POIs sometimes vanish wholesale in dense areas. If
you are losing icons you expected to keep, text-optional: true is usually the fix.
icon-text-fit stretches the icon to the label’s box on the named axes, using the
sprite’s stretchX/stretchY/content metadata so a shield keeps its corners.
Collision, shared and deterministic
Section titled “Collision, shared and deterministic”Every label layer in a document collides in one index. Each layer lowers to a
text-labels (candidates) plus text-draw (winners) pair around a single
label-placement node:
"places_l": { "op": "text-labels", "features": "@places_f", "…": "…" },"roads_l": { "op": "text-labels", "features": "@roads_f", "…": "…" },"place": { "op": "label-placement", "labels": ["@roads_l", "@places_l"] },"places_d": { "op": "text-draw", "labels": "@places_l", "placement": "@place" },"roads_d": { "op": "text-draw", "labels": "@roads_l", "placement": "@place" }label-placement; each layer draws only the winners.The single text op is the convenient form for one layer; the split form is what
ezu translate emits, because it is what lets a POI knock out a road name the way
MapLibre does. Candidates are gathered from the eight neighbouring tiles, deduped,
ordered by sort-key-expr with ties broken by tile feature order, and placed
greedily — so both sides of a border reach the same decision.
labels lists the layers bottom first, in paint order, exactly as stack
does. Priority runs the other way: the last entry places first and wins. So the
POI layer belongs at the end of the list, and that is what lets it knock out a road
name underneath it.


collide: false draws every label, which is occasionally what you want for a
diagram and never what you want for a map. The figure above is the whole
argument for leaving it on.
For a single layer whose labels must all show, overlap loosens the rule
without turning it off. "always" (MapLibre’s text-allow-overlap) shows each
label at its first position whatever it covers. "fallback" first tries every
position in anchor-offsets, as a normal label does, and shows the label
overlapping at its first position only when all of them are blocked, so labels
still spread out where there is room.
Where neighbouring features carry the same name, dedupe-key-expr shows the
name once. It gives each label a
key, and once a label has placed, later labels in the same layer with an equal
key whose anchor is within dedupe-distance-px of it are dropped:
"names": { "op": "text-labels", "features": "@buildings", "font": ["sans"], "text": ["get", "name"], "dedupe-key-expr": ["get", "name"], "dedupe-distance-px": 120 }Only a label that actually placed hides its duplicates, so if the first one loses
its collision the next one still gets its turn and the name is not lost. A label
with a null or empty key never dedupes. label-boxes with include: "all"
reports a dropped label with placed: false and drop-reason: "dedupe", which is
enough to list the omitted names elsewhere on the sheet. The rule applies to
point labels; line labels already keep apart by spacing-px.
Drawing the label boxes yourself
Section titled “Drawing the label boxes yourself”label-boxes turns the boxes a label-placement node measured into ordinary
polygons, so any geometry and paint op can draw them. It reads the decisions
already made, so nothing is placed twice and every box sits exactly where
text-draw puts its label. Each polygon carries the labelled feature’s
properties, plus:
| Property | Value |
|---|---|
part |
text: the laid-out text block, no padding. icon: the icon, turned by icon-rotate. background: the label’s background box, set by background-padding or background-color, painted or not. glyph: one glyph of a line label. collision: a box placement tested, padding-px included. |
placed |
true, or false for a candidate that did not place (only with include: "all"); its drop-reason says why: collision or dedupe. |
label-id |
Shared by every box of one label, and the same on every tile. |
parts picks which kinds to emit (default: all of them), labels limits the
output to one of the placement’s layers, and filter-expr keeps only the boxes
for which a MapLibre expression over these properties is true. A line label has no single block: it gives
one glyph quad per glyph, turned along the path, plus the axis-aligned collision
box placement tested for each glyph. Labels placed from a neighbouring tile’s
candidates come through like their text, so a box that crosses a tile edge carries
on across it.
A bordered badge is the background box grown with round joins, filled and
outlined. background-padding without a background-color gives the label its
box, measured, anchored and collided as usual, while text-draw leaves it
unpainted:
"pages_l": { "op": "text-labels", "features": "@pages", "font": ["sans"], "text": ["get", "page"], "size": 12, "color": "#2f6fe0", "anchor": "top-left", "offset-em": [0.4, 0.3], "background-padding": [3, 6, 3, 6], "padding-px": 4 },"place": { "op": "label-placement", "labels": ["@pages_l"] },"badge": { "op": "label-boxes", "placement": "@place", "parts": ["background"] },"cushion": { "op": "buffer", "features": "@badge", "distance": 2, "join": "round" },"cushion_fill": { "op": "fill-solid", "features": "@cushion", "fill": "#ffffff" },"cushion_rings": { "op": "boundary", "features": "@cushion" },"cushion_edge": { "op": "stroke", "features": "@cushion_rings", "color": "#2f6fe0", "width-px": 1.5 },"pages_d": { "op": "text-draw", "labels": "@pages_l", "placement": "@place" },"out": { "op": "stack", "layers": ["@base", "@cushion_fill", "@cushion_edge", "@pages_d"] }The buffer radius rounds the corners. The badge then reaches past the box that
collided, so padding-px should cover the buffer distance plus half the stroke
width, or neighbouring badges can touch. The same boxes make an offset shadow
(transform the cushion and fill it first) or corner brackets (boundary, then
dash).
To see why a label is missing, draw every collision box, with the losers in red:
"debug": { "op": "label-boxes", "placement": "@place", "parts": ["collision"], "include": "all" },"debug_rings": { "op": "boundary", "features": "@debug" },"debug_edge": { "op": "stroke", "features": "@debug_rings", "width-px": 1, "color": "#16a34a", "color-expr": ["case", ["get", "placed"], "#16a34a", "#dc2626"] }A losing candidate is shown at its first anchor, overlapping the box that blocked it. Copies of one label from neighbouring tiles are not candidates of their own, so they never show as losers.
Leader lines for moved labels
Section titled “Leader lines for moved labels”A label that anchor-offsets pushed off its first position can sit some way from
what it names. segment-to draws a leader from the labelled feature to its box,
stopping at the box’s edge rather than running under the text. It needs the
feature’s own position as longitude and latitude properties (lng and lat
here), which the boxes carry along with the rest of the feature’s
properties. A placed label’s boxes also carry variant-index, 0 at the first
position, so filter-expr on label-boxes keeps only the labels that moved:
"names": { "op": "text-labels", "features": "@shops", "font": ["sans"], "text": ["get", "name"], "anchor-offsets": [ { "anchor": "bottom", "offset-em": [0, -0.8] }, { "anchor": "bottom", "offset-em": [0, -2.4] }, { "anchor": "left", "radial-em": 1.5 } ] },"place": { "op": "label-placement", "labels": ["@names"] },"moved": { "op": "label-boxes", "placement": "@place", "parts": ["text"], "filter-expr": [">", ["coalesce", ["get", "variant-index"], 0], 0] },"leaders": { "op": "segment-to", "features": "@moved", "lng-field": "lng", "lat-field": "lat", "gap-px": 2 },"leader_d": { "op": "stroke", "features": "@leaders", "color": "#555555", "width-px": 0.75 },"names_d": { "op": "text-draw", "labels": "@names", "placement": "@place" },"out": { "op": "stack", "layers": ["@base", "@leader_d", "@names_d"] }Each line heads for the centre of the box and ends where it first meets the
edge; gap-px leaves that many pixels of air before it. A label whose box covers
its feature’s position gets no leader. With background-padding set, take
parts: ["background"] instead, so the leader stops at the padded box.
filter-expr sees every property a box carries, part included, so it can pick
on placed, overlapped or the labelled feature’s own fields just as well.
Glyphs without font files
Section titled “Glyphs without font files”Instead of a font source, point at a MapLibre glyph endpoint:
"sources": { "gl": { "type": "glyphs", "url": "https://example.com/fonts/{fontstack}/{range}.pbf" }},"nodes": { "labels": { "op": "text", "features": "@f", "font": ["gl"], "text": ["get", "name"] }}Ranges are fetched lazily, and these are the same pre-rendered SDF glyphs MapLibre
draws — which is why ezu translate falls back to a style’s own glyphs endpoint
when no --font mapping is given. In a wasm host you cannot fetch lazily, so ask
neededGlyphRanges() after binding features and before rendering; see
use in the browser.
Debugging placement
Section titled “Debugging placement”- Turn on the tile-grid overlay in
ezu serveand check borders for duplicates — the symptom of a missingsource/layer. - Draw the collision boxes, rejected candidates included, with
label-boxes(see drawing the label boxes yourself). max-extent-px(default 128) is the padding the node requests; labels whose bounding box exceeds it are culled with a warning. Very large text needs a larger value.- ezu has no viewport, so there is no fade and no viewport-centre priority. See gaps and differences.
A worked example
Section titled “A worked example”atlas.json
is this page assembled: three label layers (city, neighbourhood, line-placed road
names) through one label-placement, over a cased-road basemap, with the font
declared as a font source URL. See it rendered in the gallery.
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.