Skip to main content

Labels, Shields and Font Icons

CartoCSS label properties the fork added: a name that takes the free side of its icon, SDF font icons, rounded plates behind text and icon, callout labels for peaks, and per-label distance limits.

Fork feature

Added in PRs #57 and #56, with the renderer half in massif-maps/massif-maps-libs. Everything here is style syntax — no API call is needed. Technical notes: docs/internals/rendering/06-labels.mdx.

POI names placed on the free side of font icons

~2300 live POI labels: each name takes the first free side of its icon, and falls back to the icon alone where nothing fits (shield-text-optional). Icons are single SDF glyphs of an icon font.

Anchored shields: the name takes a free side

A shield is one label whose glyph run is [icon] [text]; only the text moves.

#poi {
shield-name: [name];
shield-file: url(shields/place.svg); /* a bitmap icon, as before */
/* AND/OR a font icon: the string holds the icon face's own character (a PUA codepoint, e.g. U+E990) */
shield-icon-name: '<icon character>';
shield-icon-face-name: 'osm';
shield-icon-size: 15;
shield-icon-fill: #b5651d;
shield-anchors: 'right,left,top,bottom'; /* sides, in preference order */
shield-text-optional: true; /* no side free -> draw the icon alone */
shield-text-dx: 2; /* gap from the icon, MIRRORED per side */
shield-text-horizontal-alignment: 'auto'; /* justify wrapped lines with the chosen side */
}
  • The culler tries the side the label already holds, then the style's order, and takes the first free one — keeping the current side is what stops names swapping sides under a moving camera.
  • The text is placed against the icon's edge, so dx/dy become a gap pushed away from the icon and the style does not have to mirror its own alignment.
  • shield-text-optional costs one more layout variant, not a second label.
  • Placement re-runs when the camera zooms by a quarter of a level (it used to run only on tile-set changes, so zooming in never gave a fallen-back name its text back).
  • Insertion is greedy: a name may take the space a lower-ranked neighbour's icon needed, and that icon is hidden. shield-placement-priority is the lever over who wins; a bigger shield-size sorts earlier among equal priorities.

A style without shield-anchors builds no variants and takes exactly the old path. With every POI anchored (~2300 labels, 4 sides + icon-only) a placement pass measured 20.8 ms against 16.3 ms unset, and frame time moved by under 1 ms.

Repeating a shield along its road

shield-placement / text-placement decide two things at once — how often the label is placed along a line, and whether its glyphs follow the line's direction:

PlacementRepeats at spacingGlyphs
pointno — one label per lineflat on the ground, swivelling to face the camera
billboardno — one label per lineupright, facing the camera
lineyesflat on the ground, following the line
billboard-lineyesupright, following the line
billboard-line-repeatyesupright, not following the line

billboard-line-repeat is the road-shield combination and is what shields default to: a route number repeated along its motorway has to stay readable, not turn with the bend. On a feature that is not a line it behaves as billboard.

#road_shield {
shield-name: [ref];
shield-file: url(shields/motorway.svg);
shield-spacing: 200; /* ground distance between repeats */
shield-min-distance: 100; /* ... and the screen-space guarantee */
/* shield-placement: billboard-line-repeat; the default; 'point' is the old one */
}

Text still defaults to point. text-spacing: 0 means one label for the whole line.

Font icons

shield-icon-name is a run of glyphs from an icon face, drawn before the text with its own colour and size. They are SDF like the text, so they stay sharp at any zoom and cost one atlas cell each — not a bitmap. A shield may carry both: shield-file is the first prefix glyph and the icon run follows it, side by side.

If a style packages no font at all, labels no longer fail: the SDK falls back to the system fonts (/system/fonts on Android, CoreText on iOS/macOS, DirectWrite on UWP), with Arial mapped to the platform default. Style-packaged fonts always keep precedence.

Font lists

A face name is a CSS-like list, most preferred first, and an entry may say which platform it is for:

#poi {
text-face-name: "Roboto, Helvetica Neue, sans-serif"; /* one string */
text-face-name: "android:Roboto", "ios:Helvetica Neue", "Arial"; /* or a CSS list */
}

android:, ios:, macos: and windows: are the tags; an entry tagged for another platform is dropped, an untagged one is kept everywhere. The first name the device has a font for becomes the main font and the rest are its glyph fallbacks. Resolution is cached, so a list costs nothing per tile.

The same list works for the font names of the vector elementsBalloonPopupStyleBuilder (title, description), BalloonPopupButtonStyleBuilder and TextStyleBuilder — which previously ignored any name the platform did not know verbatim:

BalloonPopupStyleBuilder builder = new BalloonPopupStyleBuilder();
builder.setTitleFontName("Roboto, Helvetica Neue, sans-serif");

Plates behind the text and the icon

#road_label {
text-background-fill: #ffffff;
text-background-opacity: 0.85;
text-background-radius: 3;
text-background-padding-x: 4;
text-background-padding-y: 2;
text-background-border-fill: #444444;
text-background-border-width: 1;
}

Padding is measured from the content, so the same plate comes out a different size behind every label. text-background-width / text-background-height pin it instead — an outer size in pixels, border included, centred on what the plate sits behind, and the padding for that axis is ignored. Either axis can be left at 0 to keep being sized by the content, which is what a chip of one height around text of any length wants:

#topos {
text-name: [icon];
text-background-fill: #d95d39;
text-background-width: 22; /* one pin size, whatever glyph it carries */
text-background-height: 22;
text-background-radius: 11;
}

The same properties exist as shield-background-* (behind the text) and shield-icon-background-* (behind the icon run). A plate is nine-sliced from one cached atlas cell that carries both the fill and the border, drawn as a single quad: the border never shows through the fill, so a plate keeps its colour while the label fades in and at any text-background-opacity. Plates are part of what the label covers, so the culler tests the plated box.

Measured with a plate behind name and icon on ~2100 labels: about +1 ms per frame, no change in draw-call count. A screen of road shields — tens, not thousands — is far below the noise.

Halos are outlines

text-halo-*, shield-halo-* and shield-icon-halo-* draw around the glyph, not behind it — the glyph's own shape is cut out of the halo. So a label keeps its colour while it fades in, and a translucent text-fill (or text-opacity) shows the map through it rather than the halo. A style that relied on an opaque halo filling in behind a transparent fill now gets a hollow outline; give the fill its own colour instead.

Callout labels

A panorama has hundreds of summits inside a few degrees of the horizon, all wanting the same band of pixels. text-placement: callout lifts the name off its anchor in screen pixels and joins it back with a leader line; a name that loses its row steps to the next one instead of being hidden.

#mountain_peak {
text-name: [name];
text-secondary-name: [ele] + 'm'; /* one label, two type sizes */
text-secondary-scale: 0.62;
text-placement: callout;
text-callout-screen-anchor: 0.25; /* band, as a fraction of screen height from the top */
text-callout-step: -18; /* pixels per row; negative stacks DOWNWARDS */
text-callout-max-rows: 6;
text-callout-line-anchor: bottom-left; /* the point held over the summit */
text-callout-align: top-right; /* the point put on the band line */
text-callout-persist: 2; /* passes a visible name may fail before it hides */
text-min-distance: 20;
text-placement-priority: [ele]; /* the higher summit claims the row … */
text-rank: 0 - [view::distance]/100; /* … and the nearer of two equals wins it */
}
  • view::distance is metres from the camera to the label, evaluated per label per placement pass and added to the priority. It is defined in this expression only, and it never changes how a label looks — only which of two colliding labels keeps its slot.
  • Write 0 - x, not -x: a leading minus in front of a field parses as the literal string "-" and the declaration silently fails.
  • A callout must fit on the screen above its feature or it loses its name — a summit already high in the frame has nowhere to put one.
  • Unlike every other orientation, a callout is not dropped when the view meets the surface edge-on, which is what makes it work at a near-zero or negative tilt.

Ranking the app owns

The [rank] a vector tile ships is one editor's idea of what matters, and an outdoor app wants a different one. Put the ranking in a table style parameter instead of in the style, and the app writes it:

"styleparameters": {
"poi_rank": { "default": { "peak": 900, "alpine_hut": 800, "supermarket": 50, "zoo": 40 } }
}
#poi {
marker-placement-priority: get([param::poi_rank], [class], 100);
text-placement-priority: get([param::poi_rank], [class], 100);
}
decoder.setStyleParameter("poi_rank", outdoorRanking); // one call swaps the whole ranking

The lookup is resolved while the tile is decoded, so it costs nothing per frame. Changing the table re-decodes the visible tiles, which suits a profile the user picks — not a slider.

text-rank / shield-rank / marker-rank add to that priority per placement pass, which is the only place view::distance means anything, so the table decides the order and the view breaks the ties:

#poi {
marker-placement-priority: get([param::poi_rank], [class], 100);
marker-rank: 0 - [view::distance]/50; /* of two equals, the nearer one keeps its slot */
}

Ranking never changes how a label looks — only which of two colliding labels survives.

Distance limits

Cut labels that are too far to be useful (metres; 0, the default, means no limit):

#transportation_name { text-max-distance: 2000; }
#poi { marker-max-distance: 800; }
#shield { shield-max-distance: 1500; }

Sharpness

Glyph rasters follow a ladder (16 / 28 / 40 px) and the SDF is coverage-based (FreeType's bsdf), which is what removed the blurred stems and the holes at stem/shoulder joins that an outline-based SDF produced. Nothing to configure — but it is why label appearance changed relative to older builds of this fork.

2D and 3D from one style

text-orientation-mode is fixed when the tile is decoded, so it cannot be switched by a style parameter — branch on the SDK's own render::3d instead:

#road_label { text-orientation-mode: [render::3d] ? billboard-line : line; }

See also