Skip to content

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.

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

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.

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

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

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.

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.

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.

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" }
Two label layers, one decision stage. Each layer’s candidates go into `label-placement`; each layer draws only the winners.
Two label layers, one decision stage. Each layer’s candidates go into 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.

Place labels with collision enabled — evenly spaced and legibleThe same labels with collision disabled — overlapping and unreadable
The same label layer with collision on (left) and off (right). Collision is what makes a dense area readable.

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.

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.

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.

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.

  • Turn on the tile-grid overlay in ezu serve and check borders for duplicates — the symptom of a missing source/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.

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.