The navigation camera follows the snapped fix, not GPS

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

| Option | Default | What it controls |
|---|---|---|
| `pitch` | `60` | Tilt 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 |
| `zoom` | `17` | Follow-mode zoom |
| `anchorY` | `0.72` | Where the fix sits vertically on screen, as a fraction of container height from the top, clamped 0 to 1 |
| `easeMs` | `900` | Ease duration per `follow()` call, always capped by the observed fix interval |
| `autoRecentreMs` | `6000` | Idle 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.

| Field | Type | Notes |
|---|---|---|
| `lat` | number | Route-snapped latitude, decimal degrees, WGS84 |
| `lon` | number | Route-snapped longitude, decimal degrees, WGS84 |
| `courseDeg` | number, optional | Course 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](/studio) 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](/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](/docs/maps) is authoritative on the theme document and the `extra.nav.camera` block, the [SDKs reference](/docs/sdks) covers `NavigationCamera`, `PositionPuck` and the guidance helpers, and the [web maps SDK write-up](/news/web-maps-sdk) explains why the package wraps MapLibre rather than forking it.

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