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.

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 },
});
FieldTypeNotes
frameFramePixel-space rect. Interpreted relative to parent if given.
parentViewportLikeOptional parent; frame is then in the parent’s space.
cameraCameraStateInitial camera. Missing fields default to identity (0 / 1).
panBoundsCameraPanBoundsOptionsConstrain 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:

MemberDescription
modeAlways "camera".
frameFrame relative to parent (or absolute if no parent).
absoluteFrameFrame in root/canvas space — walks the parent chain on each read.
clipRectMutable FrameRect equal to absoluteFrame, refreshed in place.
cameraSnapshot 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.

FieldTypeDescription
contentFrameRectWorld-space rect to keep in view. Required — there’s no implicit content.
overshootPanBoundsMarginPan-past-content allowance when content is larger than the viewport.
driftPanBoundsMarginContent 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();
FieldTypeDefaultDescription
dragbooleantruePointer drag pan.
wheelbooleantrueMouse-wheel zoom.
pinchbooleantrueTwo-finger pinch (touch).
smoothTimenumber0.1SmoothDamp time constant (seconds). 0 = instant.
flingboolean | FlingOptionsonDrag-release momentum. false disables.
minZoomnumber0.1Minimum camera zoom.
maxZoomnumber10Maximum camera zoom.
initialCameraStateInitial camera written to the viewport on bind.
onZoom(g: ZoomGesture) => voidCustom wheel/pinch handler; see ZoomGesture.
FieldTypeDefaultDescription
frictionnumber4Exponential decay rate (1/s). Travel ≈ velocity / friction.
minVelocitynumber50Release speed (px/s) needed to start a fling.
windowSecondsnumber0.08Sample window the velocity tracker uses to estimate release speed.
MemberDescription
modeAlways "camera".
interactingtrue while a drag / pinch is in progress.
flingingtrue while a release fling is coasting.
animatingtrue while any damper is still converging (e.g. after setTarget).
enabledRead/write — toggles the underlying interaction node.
cameraInterpolated camera currently applied (ResolvedCameraState).
targetThe 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 dense tree/grid layout instead of scaling). For pinch, the pan (translation) component is still applied even when zoom is prevented.

FieldTypeDescription
source"wheel" | "pinch"Which input produced the gesture.
scalenumberMultiplicative factor for this tick (>1 zoom in).
anchorXnumberAnchor in element-local CSS px (point that stays put).
anchorYnumber
preventDefault()() => voidSuppress 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 });
MemberDescription
x / y / width / heightThe rect. width / height are floored at 0.
topLeft / topRight / bottomLeft / bottomRightCorner Vec2s.
centerGeometric center Vec2.
rectPlain 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.