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": { "fonts": { "op": "fonts", "font-stacks": { "sans": ["sans"] } }, "places_f": { "op": "features", "source": "basemap", "layer": "places", "filter-expr": ["==", ["get", "kind"], "city"] }, "places": { "op": "text", "features": "@places_f", "fonts": "@fonts", "font": ["sans"], "text": ["get", "name"], "size": 13, "color": "#333333", "halo-color": "#ffffffcc", "halo-width": 1.5 }}text is a literal string or a MapLibre string expression evaluated per feature.
Fonts. The fonts node loads the style’s font and glyphs sources into
named stacks. Each stack is an ordered fallback chain — outline fonts and SDF
glyph stacks mix freely, and the first entry covering a character shapes it —
and glyph ranges are fetched as the labels need them. A label node takes the
stacks on fonts and names the one its labels use in font, as MapLibre’s
text-font does: an array of font names, joined with , to form the stack’s
name. font-expr picks a stack per feature, and a format section’s
text-font picks one per section, from the same stacks:
"fonts": { "op": "fonts", "font-stacks": { "Noto Sans Regular": ["noto"], "Noto Sans Bold": ["noto-bold", "noto"]} },"places": { "op": "text", "features": "@places_f", "fonts": "@fonts", "font": ["Noto Sans Regular"], "font-expr": ["case", ["get", "capital"], ["literal", ["Noto Sans Bold"]], ["literal", ["Noto Sans Regular"]]], "text": ["get", "name"] }One fonts node serves every label layer of a style. Label nodes used to name
their font / glyphs sources in font, with font-stacks for the stacks
font-expr could pick, and load the fonts themselves. Those uses are
deprecated: such a style still draws as it did, and the build warning spells out
the fonts node that replaces them.
Neighbour tiles. Collision also places the neighbour tiles’ labels, so a
label on a tile edge is decided the same way on both sides. The label node asks
its features input to carry them: the features node reads the neighbour
tiles’ copies of its layer, with its own filter and zoom band, and an op on the
way that works feature by feature — centroid, buffer, simplify,
transform, dash and the like — applies itself to them as the neighbour’s own
render would. Nothing needs repeating on the label node.
An op that merges features or combines several inputs (convex-hull,
voronoi, feature-boolean, …) cannot say what a neighbour tile would make of
them, so it passes none on. The build then warns, and collision is
centre-tile-only, which shows up as labels duplicated, fighting or cut off
across borders.
Label nodes used to name source and layer (with filter-expr and
min-zoom-field) to read the neighbours themselves. Those fields are
deprecated: a style naming both still draws as it did, with a warning, and
drops them to take the neighbours from its input instead.
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", "fonts": "@fonts", "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", "fonts": "@fonts", "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", "fonts": "@fonts", "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.
The layout fields also come per feature: max-width-em-expr, line-height-expr,
letter-spacing-em-expr, justify-expr, transform-expr and offset-em-expr
each take a MapLibre expression in place of the constant, evaluated with the
feature’s properties and the tile’s zoom. A zoom curve widens the wrap as the map
zooms in:
"max-width-em-expr": ["interpolate", ["linear"], ["zoom"], 10, 6, 16, 10]rotate-deg (or rotate-deg-expr, per feature) turns a point label clockwise
about its anchor, with its offset and background box, the way MapLibre’s
text-rotate does, so a label can follow an angle stored on its feature
(["get", "angle"]). The label then collides by the axis-aligned bounds of its turned
box, so a label at 45° reserves more room than it covers.
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", "fonts": "@fonts", "font": ["sans"], "text": ["get", "block_number"], "polygon-anchor": "centroid"}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:
"icons": { "op": "sprite", "sprite": "@sheet" },"poi": { "op": "text", "features": "@poi_f", "fonts": "@fonts", "font": ["sans"], "text": ["get", "name"], "icon-sheet": "@icons", "icon-name-expr": ["get", "icon"], "icon-size": 1, "icon-anchor": "center", "icon-offset": [0, -8], "text-optional": true}The sprite node loads the sheet the sheet source declares, and icon-sheet
takes it in; icon-name-expr names each feature’s icon in it (icon-name names
one for every feature). A name the sheet lacks draws no icon, and the label keeps
its text. stamp and line-stamp take a sheet the same way, on sheet, when
their name-expr names an icon per feature. Label nodes used to name the sprite
source in icon-sprite and load the sheet themselves; that field is deprecated,
and a style using it draws as before, with a warning.
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", "fonts": "@fonts", "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, dedupe, or culled for a point label whose box reaches past max-extent-px. |
label-id |
Shared by every box of one label, and the same on every tile. |
label-layer |
The id of the text-labels node the label came from, which tells apart two layers labelling the same features, such as a number and a name on the same points. |
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", "fonts": "@fonts", "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", "fonts": "@fonts", "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": { "fonts": { "op": "fonts", "font-stacks": { "Noto Sans Regular": ["gl"] } }, "labels": { "op": "text", "features": "@f", "fonts": "@fonts", "font": ["Noto Sans Regular"], "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 label node that gets no neighbours.ezu checknames the op that stops them. - 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.