Foundations

Pan and zoom

A view onto a plane larger than the screen: moved by a drag, scaled about the point under the pointer.

Markdown

A canvas, a diagram or a map is larger than the screen, or finer than it shows. People move over it by panning and look closer by zooming, and they do both constantly, without thinking. It has to feel like moving a sheet of paper under the hand.

A plane under the hand

Scroll to move; pinch, or turn the wheel with Command or Control, to zoom about the pointer. Click the canvas, then Command-Plus and Command-Minus step through the named scales, Shift-1 fits everything and Command-0 returns to actual size.

0 of 10,000 drawn · 100% · 0 selected

Code
"use client";

import { Canvas, CanvasItem, applyItemChanges } from "@publictheta/ui/components/canvas/canvas.tsx";
import type { CanvasItemData, Viewport } from "@publictheta/ui/components/canvas/canvas.tsx";
import { useEffect, useRef, useState } from "react";
import { Readout } from "../../ui/controls.tsx";

const COUNT = 10_000;
const ACROSS = 100;
const PITCH = 72;

interface Tile extends CanvasItemData {
  readonly tone: number;
}

/** The same field on every visit: each tile's place and size follow from its number alone. */
function make(): Tile[] {
  return Array.from({ length: COUNT }, (_, index) => {
    const mixed = (index * 2654435761) % 4294967296;
    const width = 28 + (mixed % 28);
    const height = 24 + ((mixed >>> 7) % 28);
    return {
      id: `t${index}`,
      tone: mixed % 3,
      rect: {
        x: (index % ACROSS) * PITCH + ((mixed >>> 3) % 14),
        y: Math.floor(index / ACROSS) * PITCH + ((mixed >>> 11) % 14),
        width,
        height,
      },
    };
  });
}

const TONES = ["bg-surface-strong", "bg-selected-subtle", "bg-surface-raised"];

// One function for every render: a tile is drawn again only when it changes.
const renderTile = (tile: Tile) => (
  <CanvasItem
    aria-label={`Tile ${tile.id.slice(1)}`}
    className={`cursor-default rounded-sm border border-border ${TONES[tile.tone]}`}
  />
);

export default function CanvasField() {
  const [tiles, setTiles] = useState(make);
  const [selected, setSelected] = useState<readonly string[]>([]);
  const [view, setView] = useState<Viewport>({ x: -24, y: -24, zoom: 1 });
  const [drawn, setDrawn] = useState(0);
  const canvas = useRef<HTMLDivElement>(null);

  // Counted from what is on the page, after each move of the view.
  useEffect(() => {
    // oxlint-disable-next-line react/set-state-in-effect -- read back from the DOM the canvas has just drawn
    setDrawn(canvas.current?.querySelectorAll("[data-canvas-item]").length ?? 0);
    // oxlint-disable-next-line react/exhaustive-effect-dependencies -- what is drawn changes when the view or the tiles do
  }, [view, tiles]);

  return (
    <div className="flex w-full flex-col">
      <Canvas<Tile>
        ref={canvas}
        aria-label="Ten thousand tiles"
        items={tiles}
        onItemsChange={(changes) => setTiles((current) => [...applyItemChanges(current, changes)])}
        renderItem={renderTile}
        value={selected}
        onValueChange={setSelected}
        viewport={view}
        onViewportChange={setView}
        className="h-[420px] w-full rounded-lg border border-border-muted bg-surface max-sm:h-[340px]"
      />
      <Readout>
        {drawn.toLocaleString("en-US")} of {COUNT.toLocaleString("en-US")} drawn ·{" "}
        {Math.round(view.zoom * 100)}% · {selected.length.toLocaleString("en-US")} selected
      </Readout>
    </div>
  );
}

Best practices

Keep the point under the pointer still. Zooming grows or shrinks the plane around one point: the one under the pointer, or between two fingers. Zooming about the middle of the view makes people chase what they were looking at.

Let what is dragged stay under the pointer. A pan moves the plane exactly as far as the pointer moved, at any zoom.

Make equal gestures equal. Zoom multiplies: the same turn of a wheel or spread of the fingers doubles the scale whether it began at 10% or at 1000%. A turn one way and the same turn back return to the same view.

Tell a pinch from a scroll. On a trackpad two fingers scroll and a pinch zooms; a mouse wheel scrolls, and zooms with the command or Control key. With Shift, a wheel that has one axis scrolls sideways. A device that reports in lines moves as far as one that reports in pixels.

Hold zoom between limits, and stop there. At a limit, a further zoom does nothing — the view does not creep sideways.

Step through the scales people know. Zoom in and zoom out go to the next of a few round scales — 50%, 100%, 200% — and from between two, to the nearer one that way. Offer "fit" and "100%" by name.

Fit what matters, with room around it. Fitting shows the whole of a part of the plane, as large as it will go, centred, with a margin. Fitting nothing, or a single point, does not zoom.

Reveal by the least movement. Bringing something into view — the next result of a search, a node reached by the keyboard — moves the view only as far as needed, leaves a little room past it, and does nothing if it is already in view. It does not change the zoom.

Move the view along under a drag at its edge. A drag that reaches the edge of the view wants what lies beyond it. Within a margin of the edge the view moves that way — at a walk at first, faster the further the pointer goes, past the edge too — and stops the moment the pointer comes back. Only an edge the drag has travelled toward pulls: a drag that begins beside an edge has not asked for anything yet.

Keep measurements on the screen in screen units. A handle, a hit target and the distance at which things snap are sizes on the screen: divide them by the zoom, so that they are the same to the hand at any scale.

Conventions

IntentPointerKeyboardTouch
PanScroll; drag with Space or the middle buttonArrow keys, when nothing is selectedDrag with two fingers
ZoomPinch; wheel with Command or ControlCommand-Plus, Command-MinusPinch
Fit everything—Shift-1—
Actual size—Command-0—

Command is ⌘ on Apple platforms and Ctrl elsewhere.

See also Snapping.

Behaviour

Places

A point of the plane, taken to the screen and back, is the same point
fc.assert(
  fc.property(view, point, (v, p) => {
    const back = toPlane(v, toScreen(v, p));
    return close(back.x, p.x) && close(back.y, p.y);
  }),
);
The view's corner is at the top-left of the screen, and a unit of the plane is zoom pixels
const v = { x: 10, y: 20, zoom: 2 };
expect(toScreen(v, { x: 10, y: 20 })).toEqual({ x: 0, y: 0 });
expect(toScreen(v, { x: 11, y: 21 })).toEqual({ x: 2, y: 2 });
expect(visibleRect(v, size)).toEqual({ x: 10, y: 20, width: 400, height: 300 });
A part of the plane is on the screen where its corner is, zoom times as large
expect(toScreenRect({ x: 10, y: 20, zoom: 2 }, { x: 15, y: 20, width: 30, height: 4 })).toEqual({
  x: 10,
  y: 0,
  width: 60,
  height: 8,
});

Far from zero

There is a point to measure from that the view is never 4096 screen pixels past
fc.assert(
  fc.property(view, fc.constantFrom(1, 1e6, -1e9, 1e12), (v, far) => {
    const moved = { x: v.x + far, y: v.y - far, zoom: v.zoom };
    const origin = viewOrigin(moved);
    const across = (moved.x - origin.x) * moved.zoom;
    const down = (moved.y - origin.y) * moved.zoom;
    return across >= 0 && across < 4096 && down >= 0 && down < 4096;
  }),
);
The point stays the same while the view moves a little, so what is placed from it stays placed
const origin = viewOrigin({ x: 1_000_000, y: 0, zoom: 1 });
expect(viewOrigin({ x: 1_000_500, y: 300, zoom: 1 })).toEqual(origin);
expect(viewOrigin({ x: 1_010_000, y: 0, zoom: 1 })).not.toEqual(origin);
The point is a whole number of the plane's units at any zoom the view allows
for (const zoom of ZOOM_STOPS) {
  const origin = viewOrigin({ x: 123_456.789, y: -98_765.4321, zoom });
  expect(Number.isInteger(origin.x) && Number.isInteger(origin.y)).toBe(true);
}

Panning

What is shown moves with the drag: the point under the pointer stays under it
fc.assert(
  fc.property(view, point, coordinate, coordinate, (v, at, dx, dy) => {
    const under = toPlane(v, at);
    const after = toScreen(pan(v, dx, dy), under);
    return close(after.x, at.x + dx, 1e-4) && close(after.y, at.y + dy, 1e-4);
  }),
);
A pan of no distance is the same view
const v = { x: 1, y: 2, zoom: 3 };
expect(pan(v, 0, 0)).toBe(v);

Zooming

The point under the anchor stays under it, whatever the zoom
fc.assert(
  fc.property(view, point, fc.double({ min: 0.05, max: 64, noNaN: true }), (v, anchor, zoom) => {
    const under = toPlane(v, anchor);
    const after = toScreen(zoomTo(v, zoom, anchor), under);
    return close(after.x, anchor.x, 1e-4) && close(after.y, anchor.y, 1e-4);
  }),
);
Zoom is held between its limits, and a view at a limit asked to go past it does not move
const v = { x: 5, y: 5, zoom: DEFAULT_ZOOM_LIMITS.maximum };
expect(zoomBy(v, 2, { x: 100, y: 100 })).toBe(v);
expect(zoomTo({ x: 0, y: 0, zoom: 1 }, 1e9, { x: 0, y: 0 }).zoom).toBe(DEFAULT_ZOOM_LIMITS.maximum);
expect(zoomTo({ x: 0, y: 0, zoom: 1 }, 0, { x: 0, y: 0 }).zoom).toBe(DEFAULT_ZOOM_LIMITS.minimum);
expect(zoomTo({ x: 0, y: 0, zoom: 1 }, 2, { x: 0, y: 0 }, { minimum: 0.5, maximum: 1.5 }).zoom).toBe(
  1.5,
);
Zooming by a factor and then by its inverse returns to the same view
fc.assert(
  fc.property(view, point, fc.double({ min: 0.5, max: 2, noNaN: true }), (v, anchor, factor) => {
    const limits = { minimum: 1e-6, maximum: 1e6 };
    const back = zoomBy(zoomBy(v, factor, anchor, limits), 1 / factor, anchor, limits);
    return close(back.x, v.x, 1e-4) && close(back.y, v.y, 1e-4) && close(back.zoom, v.zoom);
  }),
);
A step in or out goes to the next named scale, from on one or between two
expect(zoomStop(1, 1)).toBe(1.5);
expect(zoomStop(1, -1)).toBe(0.75);
expect(zoomStop(0.87, 1)).toBe(1);
expect(zoomStop(0.87, -1)).toBe(0.75);
// Arithmetic that lands a hair off a stop is on it.
expect(zoomStop(1 - 1e-12, 1)).toBe(1.5);
expect(zoomStop(1 + 1e-12, -1)).toBe(0.75);
A step past the last stop stays at it
expect(zoomStop(ZOOM_STOPS.at(-1)!, 1)).toBe(ZOOM_STOPS.at(-1));
expect(zoomStop(ZOOM_STOPS[0]!, -1)).toBe(ZOOM_STOPS[0]);

Fitting

A part of the plane is shown whole, in the middle, as large as the padding allows
fc.assert(
  fc.property(rect, fc.integer({ min: 0, max: 100 }), (r, padding) => {
    const v = fit(r, size, { padding, limits: { minimum: 1e-9, maximum: 1e9 } });
    const a = toScreen(v, r);
    const b = toScreen(v, { x: r.x + r.width, y: r.y + r.height });
    const inside =
      a.x >= padding - 1e-4 &&
      a.y >= padding - 1e-4 &&
      b.x <= size.width - padding + 1e-4 &&
      b.y <= size.height - padding + 1e-4;
    const centred = close(a.x, size.width - b.x, 1e-4) && close(a.y, size.height - b.y, 1e-4);
    // One of the two directions is filled exactly.
    const filled =
      close(b.x - a.x, size.width - padding * 2, 1e-4) ||
      close(b.y - a.y, size.height - padding * 2, 1e-4);
    return inside && centred && filled;
  }),
);
A part with no size is centred at the zoom the view has
const v = fit({ x: 50, y: 50, width: 0, height: 0 }, size, { zoom: 2 });
expect(v.zoom).toBe(2);
expect(toScreen(v, { x: 50, y: 50 })).toEqual({ x: 400, y: 300 });
Fitting does not zoom beyond the limits
expect(fit({ x: 0, y: 0, width: 1e-6, height: 1e-6 }, size).zoom).toBe(DEFAULT_ZOOM_LIMITS.maximum);

Revealing

A part already in view leaves the view as it is
const v = { x: 0, y: 0, zoom: 1 };
expect(reveal(v, { x: 100, y: 100, width: 50, height: 50 }, size)).toBe(v);
A part out of view is brought in by the least movement, with the margin to spare
const v = { x: 0, y: 0, zoom: 2 };
// The view shows 400 by 300 of the plane; a margin of 20 pixels is 10 of it.
expect(reveal(v, { x: 500, y: 100, width: 50, height: 50 }, size, 20)).toEqual({
  x: 160,
  y: 0,
  zoom: 2,
});
expect(reveal(v, { x: -80, y: 400, width: 50, height: 50 }, size, 20)).toEqual({
  x: -90,
  y: 160,
  zoom: 2,
});
After revealing, the part is within the view wherever it fits
const rect = fc.record({
  x: coordinate,
  y: coordinate,
  width: fc.double({ min: 0, max: 300, noNaN: true }),
  height: fc.double({ min: 0, max: 300, noNaN: true }),
});
fc.assert(
  fc.property(view, rect, (v, r) => {
    const seen = visibleRect(reveal(v, r, size), size);
    const fits = r.width <= seen.width && r.height <= seen.height;
    const slack = 1e-6 * Math.max(1, Math.abs(r.x), Math.abs(r.y));
    return (
      !fits ||
      (r.x >= seen.x - slack &&
        r.y >= seen.y - slack &&
        r.x + r.width <= seen.x + seen.width + slack &&
        r.y + r.height <= seen.y + seen.height + slack)
    );
  }),
);
A part larger than the view is shown from its start
const v = reveal({ x: 0, y: 0, zoom: 1 }, { x: 1000, y: 1000, width: 5000, height: 50 }, size);
expect(v.x).toBe(1000);

The wheel

A turn of the wheel scrolls: what is shown moves against the turn
expect(wheelIntent({ ...wheel, deltaX: 3, deltaY: 40 }, size)).toEqual({
  kind: "pan",
  dx: -3,
  dy: -40,
});
With Shift, a wheel of one axis scrolls sideways
expect(wheelIntent({ ...wheel, deltaY: 40, shiftKey: true }, size)).toEqual({
  kind: "pan",
  dx: -40,
  dy: 0,
});
A pinch, or the wheel with the command or Control key, zooms: in for a turn away, out for a turn toward
const pinch = wheelIntent({ ...wheel, deltaY: -10, ctrlKey: true }, size);
const command = wheelIntent({ ...wheel, deltaY: 10, metaKey: true }, size);
expect(pinch.kind === "zoom" && pinch.factor > 1).toBe(true);
expect(command.kind === "zoom" && command.factor < 1).toBe(true);
Equal turns multiply the zoom equally, and a turn and its opposite undo each other
const a = wheelIntent({ ...wheel, deltaY: -30, ctrlKey: true }, size);
const b = wheelIntent({ ...wheel, deltaY: 30, ctrlKey: true }, size);
const twice = wheelIntent({ ...wheel, deltaY: -60, ctrlKey: true }, size);
if (a.kind !== "zoom" || b.kind !== "zoom" || twice.kind !== "zoom") throw new Error("not a zoom");
expect(a.factor * b.factor).toBeCloseTo(1, 12);
expect(a.factor * a.factor).toBeCloseTo(twice.factor, 12);
A wheel that reports in lines or pages moves as far as one that reports the same in pixels
expect(wheelIntent({ ...wheel, deltaY: 3, deltaMode: 1 }, size)).toEqual({
  kind: "pan",
  dx: -0,
  dy: -48,
});
expect(wheelIntent({ ...wheel, deltaY: 1, deltaMode: 2 }, size)).toEqual({
  kind: "pan",
  dx: -0,
  dy: -600,
});
One notch zooms by no more than a notch should, however large the device says it was
const huge = wheelIntent({ ...wheel, deltaY: -5000, ctrlKey: true }, size);
expect(huge.kind === "zoom" && huge.factor).toBeCloseTo(Math.E, 12);

A drag near the edge

A drag held clear of the edges moves the view nowhere
expect(edgePan({ x: 400, y: 300 }, size, right)).toEqual({ x: 0, y: 0 });
expect(edgePan({ x: 768, y: 300 }, size, right)).toEqual({ x: 0, y: 0 });
Within a margin of an edge the view moves that way, faster the further the pointer goes, past the edge too
const near = edgePan({ x: 780, y: 300 }, size, right).x;
const at = edgePan({ x: 800, y: 300 }, size, right).x;
const past = edgePan({ x: 840, y: 300 }, size, right).x;
expect(near).toBeGreaterThan(0);
expect(at).toBeGreaterThan(near);
expect(past).toBeGreaterThan(at);
expect(edgePan({ x: 400, y: 20 }, size, { x: 0, y: -50 })).toEqual({ x: 0, y: -near });
It moves as fast as a list scrolls under a drag held as far past its edge
expect(edgePan({ x: 778, y: 300 }, size, right).x).toBe(autoScrollSpeed(10));
expect(edgePan({ x: 99_999, y: 300 }, size, right).x).toBe(autoScrollSpeed(Infinity));
An edge does not pull a drag that has not travelled toward it: one begun beside an edge moves nothing until it heads for it
expect(edgePan({ x: 790, y: 300 }, size, { x: -5, y: 0 })).toEqual({ x: 0, y: 0 });
expect(edgePan({ x: 790, y: 300 }, size, { x: 0, y: 40 })).toEqual({ x: 0, y: 0 });
expect(edgePan({ x: 790, y: 595 }, size, { x: 3, y: 40 }).y).toBeGreaterThan(0);
A view too small to have a middle clear of its edges has narrower margins: its middle half is still
const small = { width: 80, height: 80 };
expect(edgePan({ x: 50, y: 40 }, small, right)).toEqual({ x: 0, y: 0 });
expect(edgePan({ x: 70, y: 40 }, small, right).x).toBeGreaterThan(0);

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/viewport.json
Source

src/primitives/viewport.ts

/**
 * A view onto a plane that is larger than the screen, or finer: a canvas, a
 * diagram, a map. The view is where its top-left corner is on the plane, and
 * how many screen pixels one unit of the plane takes. Everything else — where a
 * point on the plane is on the screen, what a pinch does — follows from those
 * three numbers, and all of it is arithmetic: nothing here allocates more than
 * the value it returns, and nothing knows about elements or events.
 *
 * - Panning moves the view by a distance measured on the screen.
 * - Zooming keeps one point still: the one under the pointer, or between the
 *   fingers. The plane grows or shrinks around it.
 * - Zoom multiplies. A wheel's clicks, a trackpad's pinch and a button's steps
 *   are all turned into a factor, so that each feels the same at any scale.
 * - Zoom is held between limits, and a view asked to go past one stays at it
 *   without moving.
 */
import type { Point, Rect, Size } from "../algorithms/geometry.ts";
import { autoScrollSpeed } from "./selection.ts";

export interface Viewport {
  /** The point of the plane at the view's top-left corner. */
  readonly x: number;
  readonly y: number;
  /** Screen pixels to one unit of the plane. */
  readonly zoom: number;
}

export interface ZoomLimits {
  readonly minimum: number;
  readonly maximum: number;
}

export const DEFAULT_ZOOM_LIMITS: ZoomLimits = { minimum: 0.02, maximum: 256 };

/** The stops that stepping in and out passes through: the scales people ask for by name. */
export const ZOOM_STOPS: readonly number[] = [
  0.02, 0.05, 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 8, 16, 32, 64, 128, 256,
];

export const IDENTITY: Viewport = Object.freeze({ x: 0, y: 0, zoom: 1 });

/** Where a point of the plane is on the screen, from the view's top-left corner. */
export function toScreen(view: Viewport, point: Point): Point {
  return { x: (point.x - view.x) * view.zoom, y: (point.y - view.y) * view.zoom };
}

/** The point of the plane at a place on the screen. */
export function toPlane(view: Viewport, point: Point): Point {
  return { x: point.x / view.zoom + view.x, y: point.y / view.zoom + view.y };
}

/** The part of the plane a view of the given size shows. */
export function visibleRect(view: Viewport, size: Size): Rect {
  return { x: view.x, y: view.y, width: size.width / view.zoom, height: size.height / view.zoom };
}

/** Where a part of the plane is on the screen, and how large: a selection's outline, a handle's place. */
export function toScreenRect(view: Viewport, rect: Rect): Rect {
  return {
    x: (rect.x - view.x) * view.zoom,
    y: (rect.y - view.y) * view.zoom,
    width: rect.width * view.zoom,
    height: rect.height * view.zoom,
  };
}

/**
 * A point of the plane near the view to measure from, so that what is drawn is
 * placed by small numbers however far from zero the view has travelled: a
 * browser places an element exactly only within a few million pixels. The view
 * is never `reach` screen pixels past it, and it stays the same point while the
 * view moves within one step of a coarse grid — so what is placed from it is
 * placed again only rarely.
 */
export function viewOrigin(view: Viewport, reach = 4096): Point {
  const step = 2 ** Math.floor(Math.log2(reach / view.zoom));
  return { x: Math.floor(view.x / step) * step, y: Math.floor(view.y / step) * step };
}

/** The view moved by a distance on the screen: what is shown moves with the pointer that drags it. */
export function pan(view: Viewport, dx: number, dy: number): Viewport {
  if (dx === 0 && dy === 0) return view;
  return { x: view.x - dx / view.zoom, y: view.y - dy / view.zoom, zoom: view.zoom };
}

function hold(zoom: number, { minimum, maximum }: ZoomLimits): number {
  return Math.min(Math.max(zoom, minimum), maximum);
}

/**
 * The view at a zoom, with the point of the plane under `anchor` — a place on
 * the screen — still under it. A zoom beyond the limits is the limit.
 */
export function zoomTo(
  view: Viewport,
  zoom: number,
  anchor: Point,
  limits: ZoomLimits = DEFAULT_ZOOM_LIMITS,
): Viewport {
  const next = hold(zoom, limits);
  if (!(next > 0) || next === view.zoom) return view;
  // The plane point under the anchor before and after is the same point.
  return {
    x: view.x + anchor.x / view.zoom - anchor.x / next,
    y: view.y + anchor.y / view.zoom - anchor.y / next,
    zoom: next,
  };
}

/** The view zoomed by a factor about a place on the screen. */
export function zoomBy(
  view: Viewport,
  factor: number,
  anchor: Point,
  limits: ZoomLimits = DEFAULT_ZOOM_LIMITS,
): Viewport {
  return zoomTo(view, view.zoom * factor, anchor, limits);
}

/**
 * The next stop in or out from a zoom: the next of the named scales, so that
 * stepping in from 87% goes to 100%, and from 100% to 150%.
 */
export function zoomStop(zoom: number, direction: 1 | -1, stops: readonly number[] = ZOOM_STOPS): number {
  // A zoom a hair off a stop — arrived at by arithmetic — is at that stop.
  const near = 1e-9;
  if (direction === 1) return stops.find((stop) => stop > zoom * (1 + near)) ?? stops.at(-1) ?? zoom;
  return stops.findLast((stop) => stop < zoom * (1 - near)) ?? stops[0] ?? zoom;
}

/**
 * The view that shows the whole of a part of the plane, as large as it will go,
 * in the middle, with `padding` screen pixels around it. A part with no size is
 * shown at the zoom the view has, centred.
 */
export function fit(
  rect: Rect,
  size: Size,
  { padding = 0, limits = DEFAULT_ZOOM_LIMITS, zoom: current = 1 } = {},
): Viewport {
  const width = Math.max(size.width - padding * 2, 1);
  const height = Math.max(size.height - padding * 2, 1);
  const wanted = Math.min(
    rect.width > 0 ? width / rect.width : Infinity,
    rect.height > 0 ? height / rect.height : Infinity,
  );
  const zoom = hold(Number.isFinite(wanted) ? wanted : current, limits);
  return {
    x: rect.x + rect.width / 2 - size.width / zoom / 2,
    y: rect.y + rect.height / 2 - size.height / zoom / 2,
    zoom,
  };
}

/**
 * The view moved by the least that brings a part of the plane into it, with
 * `margin` screen pixels to spare — and not moved at all if it is already
 * there. A part larger than the view is shown from its start.
 */
export function reveal(view: Viewport, rect: Rect, size: Size, margin = 0): Viewport {
  const along = (start: number, extent: number, at: number, span: number): number => {
    const room = span / view.zoom;
    const edge = Math.min(margin / view.zoom, Math.max((room - extent) / 2, 0));
    if (extent + edge * 2 > room || start - edge < at) return start - edge;
    if (start + extent + edge > at + room) return start + extent + edge - room;
    return at;
  };
  const x = along(rect.x, rect.width, view.x, size.width);
  const y = along(rect.y, rect.height, view.y, size.height);
  return x === view.x && y === view.y ? view : { x, y, zoom: view.zoom };
}

/** What a wheel event carries, of all that it does. */
export interface WheelInput {
  readonly deltaX: number;
  readonly deltaY: number;
  /** 0 for pixels, 1 for lines, 2 for pages. */
  readonly deltaMode: number;
  /** Set by a trackpad's pinch, which browsers report as a wheel with Control held. */
  readonly ctrlKey: boolean;
  readonly metaKey: boolean;
  readonly shiftKey: boolean;
}

export type WheelIntent =
  | { readonly kind: "pan"; readonly dx: number; readonly dy: number }
  | { readonly kind: "zoom"; readonly factor: number };

const LINE = 16;

/**
 * What a turn of the wheel means on a canvas. A pinch, or the wheel with the
 * command or Control key, zooms; anything else scrolls — sideways with Shift,
 * where the device has one axis. Lines and pages are turned into pixels first,
 * so that a mouse that reports in lines moves as far as one that reports in
 * pixels.
 */
export function wheelIntent(event: WheelInput, pageSize: Size): WheelIntent {
  const unit = event.deltaMode === 1 ? LINE : event.deltaMode === 2 ? pageSize.height : 1;
  const dx = event.deltaX * unit;
  const dy = event.deltaY * unit;
  if (event.ctrlKey || event.metaKey) {
    // A pinch's deltas are small and many; a wheel's click is large. One curve
    // serves both: equal turns multiply the zoom equally. A click is held to
    // what one notch should do, however large the device says it was.
    const turn = Math.max(-100, Math.min(100, dy));
    return { kind: "zoom", factor: Math.exp(-turn * 0.01) };
  }
  // With Shift, a one-axis wheel scrolls sideways.
  if (event.shiftKey && dx === 0) return { kind: "pan", dx: -dy, dy: 0 };
  return { kind: "pan", dx: -dx, dy: -dy };
}

/**
 * How fast a drag held near the edge of a view moves the view along, in screen
 * pixels a second each way: negative toward the left and the top. Nothing while
 * the pointer is clear of the edges; at a walk once it is within `margin` of
 * one, and faster the further it goes, past the edge too. An edge pulls only a
 * drag that has travelled toward it — `travelled` is how far the drag has come
 * from its press — so that one begun beside an edge moves nothing until it
 * heads for it.
 */
export function edgePan(point: Point, size: Size, travelled: Point, margin = 32): Point {
  const along = (at: number, extent: number, come: number): number => {
    // A view too small to have a middle clear of both bands has narrower bands.
    const band = Math.min(margin, extent / 4);
    if (come < 0 && at < band) return -autoScrollSpeed(band - at);
    if (come > 0 && at > extent - band) return autoScrollSpeed(at - (extent - band));
    return 0;
  };
  return { x: along(point.x, size.width, travelled.x), y: along(point.y, size.height, travelled.y) };
}