Skip to main content

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.

Fork feature

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.

Navigation maneuver arrows, running live on the web SDK. The code →

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-only draws 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​

WiringResult
A VectorTileLayer of its own over the GeoJSON sourcedraws 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​