50,000 free calls a month, card-free. Get an API key →

news / navigation-camera-3d · raw .md
SDK6 min readMapMap engineering
A chase camera drawn as a wireframe frustum hovering behind a locator puck on a route line, with pitch and bearing arcs marked as dimension annotations.

The navigation camera follows the snapped fix, not GPS

NavigationCamera in @mapmap/maps is a course-up chase cam you drive with one call per location fix. It eases to the fix with linear timing, sets the map bearing to the course, holds a tilted pitch preset and anchors the puck at 0.72 of the container height so the camera looks up the road ahead instead of at the bonnet. The thing that decides whether it reads as a navigation app or as a bug report is which fix you feed it. Feed it the raw GPS fix and the camera swings around the carriageway on every noisy sample. Feed it the road-snapped position that GuidanceUpdate carries as snapped, and it stays glued to the road.

What does one constructor call actually give you?

It gives you a MapLibre camera driven per fix with five presets, plus a puck that moves in the same call. NavigationCamera holds only a structural handle on the map (easeTo, fitBounds, stop, events, container), so it is unit-testable without a WebGL context.

OptionDefaultWhat it controls
pitch60Tilt in degrees, clamped 0 to 85. 60 is MapLibre's non-experimental ceiling; above it you may see rendering artefacts and flattened DOM-marker pucks
zoom17Follow-mode zoom
anchorY0.72Where the fix sits vertically on screen, as a fraction of container height from the top, clamped 0 to 1
easeMs900Ease duration per follow() call, always capped by the observed fix interval
autoRecentreMs6000Idle time after the last user gesture before the camera recentres itself. 0 disables it
ts
import { NavigationCamera, PositionPuck } from "@mapmap/maps";

const camera = new NavigationCamera(map, { pitch: 60, zoom: 17 });
camera.attachPuck(new PositionPuck(map)); // one call now moves both
navigator.geolocation.watchPosition(({ coords }) =>
  camera.follow({ lat: coords.latitude, lon: coords.longitude }, coords.heading ?? undefined),
);

Two details that stop it looking cheap. follow(fix, courseDeg?) eases with linear timing (easing: (t) => t), not MapLibre's default ease-in-out, because ease-in-out per fix reads as rubber-banding rather than a glide. And each easeTo is essential: true, so it interrupts the previous one instead of queueing: a fast fix cadence never builds up a backlog of lagging animations. Omit courseDeg and the bearing is left unchanged rather than snapped to north.

The low anchor is plain arithmetic, not a magic number: the pixel offset is (anchorY - 0.5) * containerHeight, where 0.5 is the natural centre and positive y pushes the puck down the screen. A pitch preset above the map's maxPitch would be silently clamped by MapLibre, so the constructor raises the ceiling to honour the preset. It never lowers it.

Why does following the raw GPS fix look broken?

Because a raw GNSS fix is not on the road and its course is not the road's direction. The raw trace jitters and drifts off the carriageway, and the camera amplifies both: bearing comes straight from course over ground, so a noisy course spins the whole map, and the position is the centre of the frame, so lateral error slides the road sideways under a puck that is supposed to be on it. Everything wrong with the fix is multiplied by a camera that is tilted to 60 degrees and zoomed to 17.

The road-snapped fix is the raw fix projected onto the route line, with the course snapped to the route direction. It is what our guidance state machine already computes, so a camera that follows it gets a position that is on the carriageway and a bearing that points down it.

Where does the snapped fix come from?

GuidanceUpdate::Navigating carries it as snapped, a SnappedFix of lat, lon and courseDeg. This is Ferrostar's snapped_user_location surfaced through our FFI, with course snapping controlled by snapCourseToRoute in the guidance config, which is on by default.

FieldTypeNotes
latnumberRoute-snapped latitude, decimal degrees, WGS84
lonnumberRoute-snapped longitude, decimal degrees, WGS84
courseDegnumber, optionalCourse clockwise from true north, snapped to the route line when course snapping is on. Absent when the underlying update reports no course

Only Navigating carries a snapped fix. Arrived and OffRoute do not, and that is deliberate: once you are completely off route there is no route position worth pretending to. The same honesty shows up in rerouting. reroute_anchor() uses the snapped position while you are still on the route line, because the heading is steadier, but switches to the raw fix once you are completely off route, because by then the snapped location sits on the carriageway the driver has already abandoned.

On mobile, read it off the update and hand it to a camera you own, because neither mobile SDK ships a camera class. The read is the part we give you, and iOS has a convenience for it. Both snippets below return nil or null on any update that is not Navigating, which is the check you want anyway:

swift
// iOS: MapMapKit. GuidanceDisplay.snappedFix(_:) -> SnappedFix?
if let fix = GuidanceDisplay.snappedFix(update) {
    // fix.lat, fix.lon, fix.courseDeg. Drive your own MapLibre Native
    // camera from these: course-up bearing, tilted pitch.
}
kotlin
// Android: ai.mapmap:core. No snappedFix helper, so read the field.
val snapped = (update as? GuidanceUpdate.Navigating)?.snapped
// snapped?.lat, snapped?.lon, snapped?.courseDeg -> your own camera.

The same shape comes out of @mapmap/core, where updateLocation returns a tagged plain object: { state: "navigating", …, snapped: { lat, lon, courseDeg }, speedLimit }.

What do the three modes do?

camera.mode reports "follow", "overview" or "free", and the transitions between them are the whole user experience.

  • follow: the chase cam. follow(fix, courseDeg?) glides the camera and co-drives an attached puck.
  • overview: overview(route.geometry) fits the whole route top-down, pitch 0, bearing 0, with 48 px of padding. Later calls reuse the last geometry given.
  • free: entered by any user drag, rotate, pitch or zoom. The camera stops easing and hands control over, but it keeps recording fixes, then calls resume() itself after autoRecentreMs of idle. resume() returns to follow at the last recorded fix and its last known course.

Gestures are distinguished from our own moves by checking for originalEvent on the map event, because easeTo and fitBounds fire the same dragstart/zoomstart events without one. Without that check the camera would treat its own animation as a user takeover and put itself in free mode forever. destroy() removes the listeners and cancels any pending recentre.

Can you design the camera rather than hard-code it?

Yes. Studio's Navigation tab has a "Drive the route" button that plays this exact camera along a route over your own theme, and the camera settings save into the theme under extra.nav.camera: pitch (0 to 85, default 60), zoom (14 to 20, default 17.5) and speedMps (2 to 40, default 12, the demo drive speed). Build a MapMapMap from that theme and NavigationCamera reads the block as its defaults. Precedence is explicit and one way: option, then design, then built-in. The block travels with the theme document, hosted publishes included, and never enters the compiled style.json.

What it does not do

An honest list, because these will find you.

  • NavigationCamera is web only. There is no equivalent class in the Android or iOS SDKs. They give you snapped and you wire your own MapLibre Native camera to it.
  • Globe projection is unsupported. NavigationCamera.isSupported(map) returns false under the globe and vertical-perspective projections, whose camera geometry breaks the low-anchor offset maths and the top-down overview framing. Switch to mercator before navigating.
  • The browser path in our docs feeds the camera a raw fix. The published @mapmap/core npm artefact is the Node build, so the watchPosition snippet above is honestly the raw-fix version. The snapped path is real today on Android, iOS and Node or worker-side.
  • Do not put 3D buildings in a navigation view on mobile. MapLibre Native's fill-extrusion memory use at street-level zooms is prohibitive; use styleForNavigation(styleJson).
  • @mapmap/maps is 0.2.0. It is early, and things will change.

Try it

Open Studio, pick a base style, and press "Drive the route" to see the camera over your own theme before you write any code. The maps documentation is authoritative on the theme document and the extra.nav.camera block, the SDKs reference covers NavigationCamera, PositionPuck and the guidance helpers, and the web maps SDK write-up explains why the package wraps MapLibre rather than forking it.

Map data derives from OpenStreetMap. Credit "© OpenStreetMap contributors" on anything you render.