Skip to content

Cameras & viewport

A viewport owns the camera that maps world coordinates to screen pixels. It holds a Frame (the pixel-space rect it draws into) plus a camera state (x, y, zoom, rotation) and exposes pan / zoom / coordinate-conversion methods. The renderer reads a viewport’s resolved camera each frame to build its view-projection matrix; interaction nodes use it to transform pointer events into world space.

Preview of the Pan & zoom demo
Pan & zoom Drag to pan and scroll to zoom a world-space grid. createViewport tracks the camera and bindViewport wires pointer gestures to it.

There are two layers to the API:

  • createViewport is the imperative state container. Call panBy / zoomAt / setCamera directly and the camera jumps instantly. This is what the renderer and hit-tests read.
  • bindViewport wraps a viewport with input handling — pointer drag, wheel zoom, two-finger pinch, momentum fling, and critically-damped smoothing. It drives the underlying viewport for you.
import { createViewport } from "insomni/viewport";
import { viewportFrame } from "insomni";
const viewport = createViewport({
frame: viewportFrame(canvas.width, canvas.height),
camera: { x: 0, y: 0, zoom: 1 },
});

| Field | Type | Notes | | ----------- | ------------------------ | --------------------------------------------------------------------------- | | frame | Frame | Pixel-space rect. Interpreted relative to parent if given. | | parent | ViewportLike | Optional parent; frame is then in the parent’s space. | | camera | CameraState | Initial camera. Missing fields default to identity (0 / 1). | | panBounds | CameraPanBoundsOptions | Constrain panning to keep content in view. See Pan bounds. |

CameraState is { x?, y?, zoom?, rotation? }x / y are the world point shown at the viewport center, zoom scales around the center, rotation is in radians. The resolved (all-fields-present) form is ResolvedCameraState.

createViewport returns a CameraViewport:

| Member | Description | | ------------------------------------ | ----------------------------------------------------------------- | | mode | Always "camera". | | frame | Frame relative to parent (or absolute if no parent). | | absoluteFrame | Frame in root/canvas space — walks the parent chain on each read. | | clipRect | Mutable FrameRect equal to absoluteFrame, refreshed in place. | | camera | Snapshot of the resolved camera state. | | panBy(dxPx, dyPx) | Pan by pixel deltas in the viewport’s frame space. | | zoomAt(anchorSx, anchorSy, factor) | Zoom around a screen-pixel anchor (absolute frame space). | | reset() | Restore the initial camera. | | setFrame(frame) | Replace the frame (same space as the original). | | setCamera(state) | Set the camera; omitted fields keep their current value. | | jumpCamera(state) | Alias for setCamera. | | screenToWorld(sx, sy) | Screen pixel → world Vec2. Rotation is not applied. | | worldToScreen(wx, wy) | World → screen pixel Vec2. | | setPanBounds(options \| null) | Replace or clear the pan-bound config. | | onChange(cb) | Subscribe to any view-state change; returns an unsubscribe fn. |

panBy drags the content: moving the pointer right shifts the world-view left. zoomAt is uniform — a ZoomFactor of { x, y } with mismatched axes collapses to the geometric mean.

// Pan 40px right, then zoom 2× around the canvas center.
viewport.panBy(40, 0);
viewport.zoomAt(canvas.width / 2, canvas.height / 2, 2);
// Convert a click to world space.
const world = viewport.screenToWorld(event.offsetX * dpr, event.offsetY * dpr);
type ZoomFactor = number | { x?: number; y?: number };

A bare number is a uniform multiplicative factor (>1 zooms in). The per-axis object form exists for symmetry with pinch gestures, but camera zoom is uniform: mismatched x / y collapse to sqrt(x * y).

panBounds keeps a world-space content rect in (or near) the viewport. When zoomed in, panning stops at the content edges plus a margin; when zoomed out far enough that content fits, the content can’t leave the viewport. Zoom itself is never restricted.

| Field | Type | Description | | ----------- | ----------------- | ------------------------------------------------------------------------- | | content | FrameRect | World-space rect to keep in view. Required — there’s no implicit content. | | overshoot | PanBoundsMargin | Pan-past-content allowance when content is larger than the viewport. | | drift | PanBoundsMargin | Content drift allowance when content fits inside the viewport. |

type PanBoundsMargin = number | { x?: number; y?: number };

A PanBoundsMargin is a fraction of the visible span, applied symmetrically to both edges. A bare number applies to both axes; the object form sets them per axis. PanBoundsMargins is the { overshoot?, drift? } pair that both CameraPanBoundsOptions and setPanBounds accept.

const viewport = createViewport({
frame: viewportFrame(w, h),
panBounds: {
content: { x: 0, y: 0, width: 1000, height: 600 },
overshoot: 0.1, // allow 10% rubber-band past the edges when zoomed in
drift: 0, // pin content centered when it fits
},
});
// Swap the lock later (e.g. after loading new data):
viewport.setPanBounds({ content: newBounds });
viewport.setPanBounds(null); // remove the lock entirely
function worldCullBounds(viewport: CameraViewport, rect?: FrameRect): FrameRect;

Returns the world-space AABB visible inside the viewport’s frame (or a sub-rect of it). Useful for hit-testing and manual culling — Renderer2D already applies render-time culling automatically. Rotation is ignored, so the returned rect is a conservative over-approximation when the camera is rotated.

const visible = worldCullBounds(viewport);
const items = quadtree.query(visible); // only build layers for what's on screen

bindViewport(viewport, element, options) attaches pointer / wheel / pinch input to a DOM element and returns a CameraViewportBinding. It manages an internal smoothDamp per camera axis plus a velocity tracker for release flings — you call update(dt) once per frame to advance the animation.

import { bindViewport } from "insomni";
const binding = bindViewport(viewport, canvas, {
minZoom: 0.25,
maxZoom: 20,
smoothTime: 0.1,
});
function frame(now: number) {
const dt = (now - last) / 1000;
last = now;
binding.update(dt); // step damping + flings, push to the viewport
renderer.render(layers);
requestAnimationFrame(frame);
}
// later
binding.destroy();

| Field | Type | Default | Description | | ------------ | -------------------------- | ------- | ------------------------------------------------------------ | | drag | boolean | true | Pointer drag pan. | | wheel | boolean | true | Mouse-wheel zoom. | | pinch | boolean | true | Two-finger pinch (touch). | | smoothTime | number | 0.1 | SmoothDamp time constant (seconds). 0 = instant. | | fling | boolean \| FlingOptions | on | Drag-release momentum. false disables. | | minZoom | number | 0.1 | Minimum camera zoom. | | maxZoom | number | 10 | Maximum camera zoom. | | initial | CameraState | — | Initial camera written to the viewport on bind. | | onZoom | (g: ZoomGesture) => void | — | Custom wheel/pinch handler; see ZoomGesture. |

| Field | Type | Default | Description | | --------------- | -------- | ------- | ------------------------------------------------------------------ | | friction | number | 4 | Exponential decay rate (1/s). Travel ≈ velocity / friction. | | minVelocity | number | 50 | Release speed (px/s) needed to start a fling. | | windowSeconds | number | 0.08 | Sample window the velocity tracker uses to estimate release speed. |

| Member | Description | | --------------------------- | --------------------------------------------------------------------- | | mode | Always "camera". | | interacting | true while a drag / pinch is in progress. | | flinging | true while a release fling is coasting. | | animating | true while any damper is still converging (e.g. after setTarget). | | enabled | Read/write — toggles the underlying interaction node. | | camera | Interpolated camera currently applied (ResolvedCameraState). | | target | The state the damping is moving toward. | | setTarget(state) | Set the damp target; omitted fields keep their current target. | | jumpTo(state) | Jump immediately, skipping interpolation. Cancels any fling. | | update(dt) | Step damping/fling and push to the viewport. Call per frame. | | stopAnimation() | Cancel pending smoothing and fling. | | resetToInitial() | Jump back to the first target captured after binding. | | screenToWorld(cssX, cssY) | Element-local CSS px → world Vec2, using the interpolated camera. | | worldToScreen(wx, wy) | World → element-local CSS px Vec2. | | destroy() | Detach all listeners and tear down the interaction node. |

ViewportBinding is an alias for CameraViewportBinding.

onZoom fires before the binding’s default uniform zoom, for both wheel and pinch. Call preventDefault() to suppress the default — useful when the scene wants to reinterpret the gesture (e.g. adjusting row spacing in a phylogram instead of scaling). For pinch, the pan (translation) component is still applied even when zoom is prevented.

| Field | Type | Description | | ------------------ | -------------------- | ------------------------------------------------------ | | source | "wheel" \| "pinch" | Which input produced the gesture. | | scale | number | Multiplicative factor for this tick (>1 zoom in). | | anchorX | number | Anchor in element-local CSS px (point that stays put). | | anchorY | number | — | | preventDefault() | () => void | Suppress the default uniform-camera zoom. |

bindViewport(viewport, canvas, {
onZoom(g) {
if (g.source === "wheel" && altKeyHeld) {
g.preventDefault(); // take over the gesture
adjustRowSpacing(g.scale);
}
},
});

A Frame is an immutable { x, y, width, height } rect with corner / center accessors and non-mutating transform helpers. Viewports take a Frame; many layout helpers return one.

import { createFrame, viewportFrame } from "insomni";
const root = viewportFrame(800, 600); // { x:0, y:0, width:800, height:600 }
const plot = root.padded({ left: 48, bottom: 32 }); // inset on two sides
const inner = createFrame({ x: 10, y: 10, width: 100, height: 100 });

| Member | Description | | ----------------------------------------------------- | ----------------------------------------------------- | | x / y / width / height | The rect. width / height are floored at 0. | | topLeft / topRight / bottomLeft / bottomRight | Corner Vec2s. | | center | Geometric center Vec2. | | rect | Plain FrameRect object (e.g. for layer.pushRect). | | padded(padding) | Nested frame inset on each side. | | translated(dx, dy) | Frame moved without resizing. | | resized(width, height) | Same origin, new size. |

Padding accepts a number (all sides), { all?, top?, right?, bottom?, left? }, or { ratio } (a fraction of the shorter side). createFrame(rect) wraps a plain rect; viewportFrame(width, height) is the (0, 0)-origin convenience for the outermost container. ZERO_RECT is the shared empty FrameRect.

  • Spaces & cameras — world / ui / device coordinate spaces.
  • Interactions — the interaction manager bindViewport is built on.
  • Renderer — how a viewport’s camera feeds the render pipeline.