Zoom, Pan & Camera

Pointer gestures (interaction) and programmatic moves (map.camera) both move the same camera, a scale and translate pair applied as one transform. Panning and zooming never rewrite the map's geometry, so both are O(1) in feature count.

interaction: {
  zoom: { enabled: true, wheel: true, doubleClick: true },
  pan: { enabled: true, inertia: true },
}

Pointer gestures

GestureBehavior
WheelZooms about the pointer, so the geography under the cursor stays under it
DragPans. Releasing while moving fast keeps drifting and decelerates (inertia)
PinchTwo-pointer zoom about the midpoint between them
Double-clickZooms in one step, animated and anchored to the click point; Shift-double-click zooms out

Anchored wheel zoom is the detail that makes wheel zoom feel correct at all: without it, zooming recenters on the viewport middle and the reader loses whatever they were looking at. Delta is normalized across deltaMode (pixel, line, and page units all produce a comparable step) so a trackpad and a notched mouse wheel feel the same.

interaction: { zoom: { min: 0.8, max: 4096, wheel: true, step: 1.6 } }

zoom.step (default 1.6) is the factor a double-click zooms by; holding Shift while double-clicking divides by it instead, zooming out. zoom.min / zoom.max clamp every zoom, wheel, pinch, double-click, or camera call alike.

Inertial pan (pan.inertia, default true) carries a drag's velocity past release and decelerates it (friction 0.92 per frame, stopping once velocity drops below a small threshold), rather than having the map stop dead the instant the pointer lifts. It's skipped entirely under prefers-reduced-motion.

Pinch zoom reads two active pointers, zooming about their midpoint as the distance between them changes; it shares the same anchored-zoom math wheel zoom uses.

A drag that ends over a feature does not also count as a click on it. The browser fires a click event after any drag that starts and ends on the same element, so without suppressing it, panning the map and releasing over a country would select that country, and dragging a box across a state with a drilldown would drill into it. ApexMaps tracks this per gesture and swallows exactly that one trailing click.

On-screen zoom controls

+, - and a reset render by default wherever zoom is enabled, because every other way to change scale is a gesture: without them there is no keyboard path to a closer look, and on a trackpad over a scrolling page the wheel is a fight.

interaction: {
  zoom: {
    step: 1.6,               // used by the buttons, the keyboard and a double-click
    controls: {
      show: true,
      position: 'top-right', // or any other corner of the plot
      reset: true,
    },
  },
}
map.zoomIn()  // the same step the + control takes
map.zoomOut()
map.zoom      // the camera's current scale, 1 at the opening fit
  • The group is HTML over the plot, so it takes nothing off the map and never lands in the exported SVG.
  • Each control is a real, focusable button, disabled by the platform (not by imitation) at each end of the zoom range, which is also how a reader learns the range has an end.
  • Reset appears only once there is something to undo, including a spun globe, and steps ease about the center of the plot rather than the corner the button sits in.
  • + = - _ 0 do the same from the keyboard whether or not the buttons are rendered.

interaction.zoom.controls: false removes them for a dashboard that wants a clean tile.

The camera API

map.camera exposes four ways to move, from cinematic to instant, plus two map-level convenience methods that compute the target for you.

map.camera.flyTo({ center: [2.35, 48.85], zoom: 8 })
map.camera.easeTo({ center: [2.35, 48.85], zoom: 6, duration: 400 })
map.camera.jumpTo({ zoom: 4 })
map.camera.fitBounds(worldBounds, { padding: 40, maxZoom: 12 })
await map.frameFeature('FRA', { padding: 40 })
await map.resetView()
MethodMotionUse for
flyTo(target)Van Wijk zoom-and-pan arc, duration derived from distanceNarrative moves: a story beat, a "fly to this place" button
easeTo(target)Fixed duration and easing (default 400ms)Short, mechanical moves: a legend re-fit, a drilldown swap
jumpTo(target)NoneInstant repositioning: initializing a saved view, a slider being dragged
fitBounds(bounds, opts)'fly' (default), 'ease', or 'jump'Framing a world-space box, computing the zoom for you
frameFeature(key, opts)Same as fitBoundsFraming one feature by its join key
resetView(opts)Same as fitBoundsReturning to the map's initial fit

A CameraTarget accepts { center, zoom }, a world-space bounds box, or raw { k, x, y }; bounds takes priority when more than one is present.

flyTo: the Van Wijk path

flyTo follows the Van Wijk and Nuij (2003) zoom-and-pan interpolation, which arcs out to a wider zoom during a long move before arcing back in, the same "zoom out to see the route, then in on the destination" motion a real camera flyover uses. Interpolating center and scale linearly instead feels wrong, because perceived motion is logarithmic in scale, and that mismatch is exactly what separates a camera move that reads as cinematic from one that reads as a slideshow cut.

Duration is derived from the path's own length unless you override it, so crossing a continent naturally takes longer than nudging to a neighboring county:

map.camera.flyTo({ center: [139.69, 35.69], zoom: 8 })              // Tokyo, derived duration
map.camera.flyTo({ center: [2.35, 48.85], zoom: 8, speed: 1.6 })    // faster
map.camera.flyTo({ center: [2.35, 48.85], zoom: 8, curve: 0 })      // no arc, pure ease

speed divides the natural duration (higher is faster); curve controls how far the arc bulges outward, and 0 disables it for a move that should feel mechanical rather than cinematic.

fitBounds, frameFeature, resetView

fitBounds takes a world-space box and computes the camera state that frames it, padding-aware:

const a = map.viewport.project([west, north])
const b = map.viewport.project([east, south])
await map.camera.fitBounds(
  [[Math.min(a[0], b[0]), Math.min(a[1], b[1])], [Math.max(a[0], b[0]), Math.max(a[1], b[1])]],
  { padding: 40, maxZoom: 24 },
)

frameFeature(key, options) is the same operation scoped to one feature's own geometry, so you don't have to compute its bounds yourself:

await map.frameFeature('FRA', { padding: 40 })

resetView(options) returns to the camera state the map started at, refitting the full geometry collection:

await map.resetView()

All three accept { padding, duration, transition }, where transition picks flyTo, easeTo, or jumpTo underneath.

Interruptible and retargeting

Every camera move can be interrupted by the next one, and the next one starts from wherever the camera has actually interpolated to rather than queueing behind the first or snapping to its end state first. Panning or zooming by hand also stops any in-flight move outright.

// Click two flyTo buttons in quick succession: the second does not queue and
// does not snap. It starts from the camera's current interpolated position.
map.camera.flyTo({ center: [2.35, 48.86], zoom: 8 })
map.camera.flyTo({ center: [139.69, 35.69], zoom: 8 })

This matters most in a scroll-driven story, where the reader can outrun any animation at any moment: a camera move that could only ever finish or queue would fall behind the reader and then visibly catch up. Every camera method also honors prefers-reduced-motion by jumping directly to the target instead of animating, rather than by skipping the move.

Rotating a globe

On an orthographic projection a drag spins the sphere instead of sliding a picture of the earth around inside its box, so the far hemisphere is reachable rather than permanently off-screen. It is on by default for globe projections.

new ApexMaps(el, {
  geo: { projection: 'orthographic' },
  interaction: { rotate: { enabled: 'auto', inertia: true } },
})

map.rotateTo([-77, 39]) // absolute [lambda, phi, gamma?], as a drag would
map.rotation            // [lambda, phi, gamma]
map.on('rotate', ({ rotate }) => {})
map.on('rotateEnd', ({ rotate }) => {})
  • Dragging is versor-based (Bostock and Davies), so the point under the cursor stays under it at any latitude, however far the globe has already turned. Longitude wraps, so a spin runs through 360 degrees and out the other side.
  • interaction.rotate.enabled defaults to 'auto': on for orthographic, off for a flat map where a drag means "move the map". true forces it on for any projection that can rotate and invert (a stereographic or azimuthal view); false gives the drag back to panning. inertia defaults to pan.inertia.
  • Camera moves rotate too. Where re-centering means turning (the azimuthal family), a center target given to flyTo, easeTo or jumpTo becomes a rotation, interpolated as a quaternion slerp: the short way round, a steady angular pace, and no swing-out crossing a pole. A flyTo scales its duration by the angle covered and takes the longer of that and the Van Wijk path, so a turn and a zoom land together.
  • resetView() restores the opening rotation as well as the camera, because on a globe the spin is where the reader has navigated to, and frameFeature() turns first so a feature behind the planet can be framed at all.
  • Flat projections are pixel-identical, conics included: turning a conic defined by standard parallels would re-skew the map, so supportsRecentre() decides and leaves them panning.

Rotation is a projection change, not a transform, so it reprojects every coordinate (paths, arcs, bubbles, label anchors); a drag coalesces that to one pass per frame. The rotate and rotateEnd events carry { rotate: [lambda, phi, gamma] } and are wired through all three framework wrappers.

See also

  • Drilldown for how the camera frames a feature before its child level swaps in.
  • Selection & Linked Maps for the box-selection gesture that shares the same pointer plumbing as pan and zoom.
  • Theming for how prefers-reduced-motion also affects fills and transitions elsewhere on the map.