Navigation Maneuver Arrows
The turn arrow a navigation app draws on the route: ManeuverArrowBuilder cuts the piece of the
route around a maneuver, and the head is a line property — no marker, no bitmap, no second
symbol layer.
Added in PR #61. The reference is
maplibre-navigation-ios, which slices the
route the same way but draws the head as a symbol. Technical notes:
docs/internals/rendering/15-maneuver-arrows.md.
Building the geometry
ManeuverArrowBuilder builder = new ManeuverArrowBuilder();
builder.setLengthBefore(30f); // metres walked back along the route
builder.setLengthAfter(30f); // metres walked forward
// from a RoutingInstruction's point index …
FeatureCollection arrow = builder.buildArrowAtIndex(null, routePoints, instruction.getPointIndex());
// … or from a position on the route
FeatureCollection arrow2 = builder.buildArrow(null, routePoints, maneuverPos);
It returns a FeatureCollection in WGS84 holding one line, running the way the driver goes,
clamped at the ends of the route. The head sits on the line's last vertex and points along its last
segment, so the builder keeps that segment long: when the cut lands a few metres past a route vertex,
the vertices of the last 60% of lengthAfter become one straight segment, as long as none strays
more than a tenth of that run from it (a real corner in the stretch stays, and keeps its short last
leg). The tail gets the same treatment on its first 60% of lengthBefore: a bend a few pixels after the
round cap steps the casing out on the inner side. Pass a Projection if the points are not WGS84 (null = WGS84).
The walk is done in an equirectangular plane anchored at the maneuver's latitude — exact at the tens
of metres an arrow spans.
Serving it is ordinary SDK API; there is no maneuver-specific data source:
GeoJSONVectorTileDataSource source = new GeoJSONVectorTileDataSource(0, 24);
int layer = source.createLayer("maneuver");
source.setLayerFeatureCollection(layer, null, arrow);
setLayerFeatureCollection replaces the whole layer, so an app showing several arrows (the current
maneuver and the next) keeps its own id → collection map and rebuilds from it.
With the surface API
The builder is spec kind geometry, type maneuver-arrow. Its two calls take the route as
positions in the call's projection and return the arrow as a GeoJSON FeatureCollection, ready for a
geojson source:
const builder = map.object('geometry', 'arrows', { type: 'maneuver-arrow', lengthBefore: 30, lengthAfter: 30 });
const arrow = builder.call('buildArrowAtIndex', routePoints, instruction.pointIndex);
// or builder.call('buildArrow', routePoints, maneuverPos)
const arrows = map.source('maneuver-src', { type: 'geojson', maxZoom: 24 });
arrows.setGeoJSON(arrows.createLayer('maneuver'), arrow);
map.addLayer('maneuver', { type: 'vector', source: 'maneuver-src', style: 'route-style' });
The feature has no properties; set them on the returned document before handing it over.
Styling contract
Two rules, four attachments, no assets:
#maneuver::case { line-color: @casing; line-width: linear([view::zoom], (12, 3.9), (17, 13));
line-join: round; line-cap: round; }
#maneuver::fill { line-color: @fill; line-width: linear([view::zoom], (12, 2.4), (17, 8));
line-join: round; line-cap: round; }
#maneuver::headcase { line-color: @casing; line-width: linear([view::zoom], (12, 3.9), (17, 13));
line-end-arrow: true; line-arrow-only: true;
line-arrow-width: 2.18; line-arrow-length: 1.72; }
#maneuver::head { line-color: @fill; line-width: linear([view::zoom], (12, 2.4), (17, 8));
line-end-arrow: true; line-arrow-only: true;
line-arrow-width: 2.4; line-arrow-length: 1.9; }
- Whole shaft first, head over it. An attachment is drawn at the position of its first rule,
hence four of them.
line-arrow-onlydraws the head alone, so the shaft runs its full length underneath, and the head's base carries a slot one line width wide so nothing of it crosses the shaft. - The casing's arrow numbers are smaller than the fill's, and that is not a typo. They are read against its own, wider line; repeating the fill's numbers draws a border round the head ~1.7× the one along the shaft.
- One knob drives the whole arrow. The widths interpolate over
[view::zoom]— the live camera zoom, re-evaluated per frame — and the head is a multiple of the width, so shaft, head and border shrink together and the arrow never swallows the junction.
A custom head
line-arrow-path takes the d attribute of an SVG path (M/L/H/V/C/S, Z, absolute or relative,
curves flattened) and fits it into the arrow box, so a contour lifted from an icon set works whatever
its viewBox. Both rules carry the same path, and the casing shrinks its box by fill / casing.
line-arrow-scale and line-arrow-rotation finish the placement.
The shape must be convex — a shape with lobes folds its border where they meet, and the SDK logs a warning rather than silently redrawing it.
Where it lands in the draw order
| Wiring | Result |
|---|---|
A VectorTileLayer of its own over the GeoJSON source | draws over every layer below it, under every Marker / Label / popup (billboards are one global pass after all layers). Works with any style. |
CompositeVectorTileLayer.addVectorDataSource("maneuver", source) | the arrow lands at the maneuver slot of the base style's layer order — over the roads, under the labels. The production wiring; the two rules must live in the style. |
With the composite wiring the route line itself should move into a tile source too, or the arrow
(inside the base layer) ends up under a route drawn as a VectorLayer. And within a vector tile
layer, labels draw after that layer's geometry —
setLabelRenderOrder(VECTOR_TILE_RENDER_ORDER_LAST) moves every base-map label above the arrow.
Behind 3D buildings
On 3D terrain with extruded buildings an arrow that goes behind one is hidden. Add
line-occlusion-opacity: 0.35; to the four rules to keep a faint ghost of it there, and normal
drawing everywhere it is visible: the four attachments ghost as one, so the head stays white with a
blue edge. The route line under it can carry the same property: layers ghost in stack order, so the
arrow's ghost lies over the route's. See
Lines behind buildings.
Why not a marker for the head
A rotated marker-file symbol was built first (it is what maplibre does) and every one of these had
to be fought: marker-fill does not tint a bitmap (marker-color does), a two-colour head can not
be tinted at all, each part needs its own attachment for draw order, the shaft's cap showed through
the head, and with marker-clip: true the head is baked into the per-tile terrain drape texture and
cut in half at every tile edge it overhangs. The line property has none of these: one geometry, one
style layer, one draw order, no bitmap.
See also
- GeoJSON Vector Tiling — the source that serves the arrow.
- Routing — where the maneuvers come from.