Skip to content
Mapping

Camera & movement

Four camera verbs — fly, ease, fit, jump — and the rule for picking one. Every number here is measured from the shipping map.

The four verbs

FlyA NEW SUBJECT in the same world — a search pick, a lot click.flyTo, zoom 18, flat, north-up, 1200 ms.
EaseThe SAME subject reframed — chrome moved, a panel opened, a mode reset.easeTo: 260 ms for padding changes, 600–700 ms for pitch/bearing resets and zoom floors.
FitAn AREA — a suburb, a city, a parcel's true bounds.Suburb 1400 ms · city 1600 ms capped at zoom 13 · parcel via cameraForBounds between 13.6 and 18, 900 ms.
JumpThe WORLD changed — switching council, the initial centre resolving.jumpTo, zoom 14.2, no animation.
Code
// FLY — a new subject in the same world
map.flyTo({ center: [lng, lat], zoom: 18, pitch: 0, bearing: 0, duration: 1200 })

// EASE — the same subject, reframed
map.easeTo({ padding: { top: 0, bottom: px, left: 0, right: 0 }, duration: 260 })
map.easeTo({ pitch: 0, bearing: 0, duration: 600 })            // leaving 3D

// FIT — an area
map.fitBounds(bbox, { padding: 70, pitch: 0, bearing: 0, duration: 1400 })   // suburb
map.cameraForBounds(bbox, { padding: lotFitPadding(), maxZoom: 18, bearing: 0 })  // parcel

// JUMP — the world changed
map.jumpTo({ center, zoom: 14.2 })

// NEVER pass `essential` — under prefers-reduced-motion MapLibre collapses a
// flight to a jump, and that is the wanted behaviour.

Durations

  • 260 ms — chrome reflow: the bottom sheet settling, a panel changing the padding.
  • 600–700 ms — mode resets: back to flat/north-up, lifting to a zoom floor.
  • 900–1200 ms — subject changes: framing a parcel (900), flying to a lot (1200).
  • 1400–1600 ms — area fits: a suburb (1400), a city or an LGA (1600).

Rules

  • Land every programmatic move flat and north-up — pitch: 0, bearing: 0 ride along on every fly, fit and reset.
  • Leave pitch alone while a 3D envelope is up. The reframe owns tilt exactly then, and only then.
  • Emit one ease per gesture chain. Coalesce a drawer settling and a report landing into a single padded ease, via a camera-target signal cleared on moveend.
  • Skip the correcting fit when the interim frame already landed — under 0.25 zoom and 24 px apart, do nothing.
  • Never pass MapLibre's essential flag. Not on a fly, not on a fit, not on a reset.
  • Treat a zoom promise as a floor. Use zoomToAtLeast; if the user can already see lots, leave their camera where it is.
  • Pad a desktop parcel fit for the chrome — 400 px left for the search stack, up to 436 px right for the report panel, scaled down together when they approach the viewport.
  • Pad a phone fit symmetrically (24–28 px), and never add the sheet's height. It already rides in transform padding, and MapLibre ADDS transform padding to fit padding.
  • Never fit tile geometry. Lots stop at z15; a parcel bigger than a tile comes back truncated.

Behavior & Anatomy

Which one fires is not a matter of taste. A fly says “we went somewhere” and its arc is the sentence; an ease says “this is the same thing, re-framed”; a fit says “here is the whole of it”; a jump says the world you were looking at is not the world any more. Pick the verb that matches the change and the user's mental model survives the movement without anyone explaining it.

A jump across half a state is noise, not orientation — which is why switching council is a jumpTo at zoom 14.2 with no animation at all, rather than a 3-second flight over farmland.

The durations are a ladder, not a constant

The further the camera goes, the longer it takes, and nothing is instant except a world switch. A constant duration makes short moves feel sluggish and long ones feel teleported; the ladder is what lets distance be perceived as distance.

Flat and north-up, and the one exception

2D is the working view; 3D is entered deliberately, never drifted into. The exception is narrow and earned: while a 3D envelope is up, the reframe owns tilt, because forcing pitch 0 first paints a one-frame bounce — the camera drops flat and immediately re-tilts, and that single frame is the most noticeable thing on the screen.

One ease per gesture chain

MapLibre's easeTo begins by stopping the current animation, so uncoordinated calls truncate flights: the fly to a lot gets cut off mid-arc by a drawer measuring itself. A camera-target signal — set by the fly or the fit, cleared on moveend — coalesces the drawer settling and the report landing into ONE padded ease. It is also the honest answer to “are we already there?”, because map.getZoom() mid-ease returns the interpolated value, so a fit comparing against it would cancel itself while the interim fly was still animating.

Why a second ease is a shimmer

A parcel click carries only a POINT, so the interim frame is a fixed-zoom fly and the true bounds arrive later, from streamed geometry. When the correcting fit would land under 0.25 zoom and 24 px from where the camera already is, it is not a correction — it is a twitch, and the user reads it as the map second-guessing itself.

No essential flag, ever

Under prefers-reduced-motion MapLibre collapses a flight to a jump. The map obeys the OS, and that is the wanted behaviour, not a loss — essential exists to override the user's stated preference, and nothing this camera does is important enough to.

A zoom promise is a floor

The parcel fabric has no tiles under the council gate, so picking a development type at city zoom has to lift the camera far enough for lots to exist. Far enough, and no further: if the user can already see lots their camera is left exactly where it was, because moving it would undo a framing they chose.

Fit padding is the chrome's geometry

Desktop parcel fits keep the lot in the GAP between the search stack (~400 px left) and the report panel (up to ~436 px right) rather than centred underneath them. Both are scaled down together as they approach the viewport, because cameraForBounds returns undefined once padding gets near the container width — the map's own budget is 62% of it.

Phones pad symmetrically at 24–28 px, and deliberately do NOT include the bottom sheet's height: it already rides in transform padding, and MapLibre adds transform padding to fit padding. Pass it twice and the lot is shoved off-screen — one of those bugs that only appears on the device nobody develops on.

Why cameraForBounds rather than fitBounds

Because the minimum-zoom floor and the padding-exceeds-viewport case both have to stay ours. The floor is 13.6, the map's viewport-streaming threshold — below it a clicked lot can never resolve, so no parcel framing may land under it. The ceiling is 18: past that a suburban lot is wall-to-wall polygon and the context that made it meaningful is gone.

Navigate

Esc