# Canvas

Pan and zoom a canvas and connect its nodes.

`createViewport` pans and zooms a canvas, and `createConnect` connects its ports.

Import them from `@ui-sdk/core`. For moving nodes, choosing them, navigating with the keyboard, and finding them, use [Dragging and Resizing](/docs/core/behaviors/direct-manipulation), [Selection](/docs/core/behaviors/selection), [Focus and Keys](/docs/core/behaviors/focus), and [Find in Page](#find-in-page).

## Panning and Zooming

The viewport is `{ x, y, zoom }`. `x` and `y` place the canvas origin inside its frame, in screen pixels. `zoom: 1` means its normal size.

### Pan and Zoom

Start with a viewport in state and mark it as [live](/docs/core/kernel/handle-changes#live-state):

```ts
state: () => ({ viewport: { x: 0, y: 0, zoom: 1 } }),
live: ["viewport"],
```

In `api`, create the behavior and return it with the rest of your API:

```ts
const viewport = createViewport(context, {
  key: "viewport",
  surface: () => context.element("root"),
})
```

The `root` is the frame; `world` holds everything inside it. Connect the frame's events and the world's live style:

```ts
root: slot({
  attrs: () => ({
    tabindex: "0",
    style: css({ position: "relative", overflow: "clip", "touch-action": "none" }),
  }),
  on: {
    wheel: (event, { api }) => api.viewport.wheel(event),
    pointerdown: (event, { el, api }) => {
      if (event.target === el) api.viewport.pointerdown(event)
    },
    keydown: (event, { api }) => api.viewport.keydown(event),
  },
}),
world: slot({
  attrs: () => ({ style: css({ position: "absolute", left: "0", top: "0" }) }),
  live: live((state, { api }) => ({ style: api.viewport.style(state) })),
}),
```

Give the frame a visible size and an accessible name in your component. One transform moves the world; the live style keeps a pan from rendering every node again.

Drag the empty board or scroll to pan. Pinch, or hold Control or Command while scrolling, to zoom. Focus the board and try +, −, and 0; Reset View restores its starting position.

`0` resets the scale to 100%. To reset the position too, call `viewport.setViewport({ x: 0, y: 0, zoom: 1 })`.

### Fit, Reveal and Zoom from Code

Use `fit` to show a rectangle in full. Its coordinates are in canvas units:

```ts
viewport.fit(
  { left: 0, top: 0, right: 800, bottom: 600 },
  { padding: 16, maxZoom: 1 }
)
viewport.zoomBy(1.25)
viewport.panBy(80, 0)
```

`zoomBy` and `zoomTo` use the center of the frame unless you pass a point. `panBy` takes screen pixels. Set `minZoom` and `maxZoom` in the behavior's options to limit the scale; the defaults are 0.1 and 4.

When a node receives focus, `reveal` brings it into view. `rectOf` reads an element's rectangle in canvas units:

```ts
sight: slot({
  on: {
    focus: (_event, { el, api }) => {
      if (el) api.viewport.reveal(api.viewport.rectOf(el), { padding: 16 })
    },
  },
}),
```

Use `overflow: clip` on the frame so native focus scrolling doesn't compete with the viewport, or let the viewport take those scrolls with [`bindFindable`](#find-in-page). The `message` option customizes what a zoom key announces:

```ts
message: (zoom) => `Map at ${Math.round(zoom * 100)} percent`,
```

Press Fit to see every sight. Try the zoom and pan buttons, then Tab through the sights: the map brings each focused sight into view.

### Find in Page

Find in page (⌘F, or Ctrl+F) finds text anywhere on the canvas, but it reveals a match by scrolling, and the viewport pans instead. Put the world in a pane that fills the frame, and bind the pane with `bindFindable`:

```ts
pane: slot({
  attrs: () => ({
    style: css({
      position: "absolute",
      inset: "0",
      overflow: "scroll",
      "scrollbar-width": "none",
      "touch-action": "none",
      "pointer-events": "none",
    }),
  }),
  bind: (el, { api }) => api.viewport.bindFindable(el),
}),
world: slot({
  attrs: () => ({ style: css({ position: "absolute", left: "0", top: "0", "pointer-events": "auto" }) }),
  live: live((state, { api }) => ({ style: api.viewport.style(state) })),
}),
```

The pane must be one a user could scroll, with `overflow: scroll`, because find in page doesn't scroll `overflow: hidden`; `scrollbar-width: none` hides its scrollbars. Keep the frame's `overflow: clip`, and keep controls like a reset button in the frame, outside the pane. The pane passes presses and the wheel on empty space through to the frame, so its handlers work as before.

Every scroll the browser makes in the pane becomes a pan with the reason `find-in-page`, so the match comes into view the way the browser would show it, with its own highlight and count. The same goes for anything else that scrolls to an element, like `scrollIntoView` or a link to its id; Tab to a node out of view pans with `focus-in`.

The pane rests scrolled far from its origin, with an empty element to make room, so the browser can scroll toward a node whichever side of the view it's on. The world's `style` moves to match while it's bound; anything else in the pane would scroll away, which is why it holds only the world. A smooth `scrollIntoView` pans by its first step only; to animate a pan, call `reveal`.

Press ⌘F or Ctrl+F and search for lighthouse. Each one is off the map in a different direction; press Enter to go from one to the next, and the map pans to each.

### Screen and Canvas Points

A pointer event gives you screen coordinates. Convert them before placing something on the canvas:

```ts
const point = viewport.toCanvas(event.clientX, event.clientY)
```

`toScreen(point)` converts back to client coordinates, useful for a label outside the scaled world. `visible()` gives the rectangle currently in view, and `get()` returns the viewport.

By default, the behavior writes its state key directly. To let a component control the viewport, use the [change pattern](/docs/core/kernel/handle-changes#state-a-user-controls) with `set`:

```ts
set: (value, details) => change("viewport", value, details),
```

Changes report a reason: `wheel`, `drag`, `pinch`, `zoom-key`, `focus-in`, `find-in-page`, or `imperative`.

Double-click to add a dot. Pan or zoom, then add another: it lands where you click. The newest dot's label keeps its size while the canvas scales.

## Connections

`createConnect` follows a line from one port to another. You decide which pairs can connect and what to save when they do.

### Draw a Connection

Add a `connecting` field to state, initially `null as ConnectDraft | null`, and list it in `live`. Then create the behavior in `api`:

```ts
const connect = createConnect(context, {
  slot: "port",
  draft: "connecting",
  canConnect: (from, to) => from.endsWith(":out") && to.endsWith(":in"),
  point: (x, y) => viewport.toCanvas(x, y),
  connect: (from, to, details) =>
    change("edges", [...context.store.get().edges, { from, to }], details),
})
```

Here, port keys end in `:out` or `:in`. `canConnect` accepts only output-to-input pairs. Add your own checks there, such as refusing two ports on the same node. `point` puts the loose end of the line in canvas coordinates.

Give each port a `value`, such as `"prompt:out"` or `"answer:in"`, and connect its pointer handler. `role` gives you `source`, `target`, or `candidate` for styling:

```ts
port: slot<{ value: string }>({
  props: { value: "string" },
  live: live((state, { api, key }) => ({ "data-connect": api.connect.role(state, key) })),
  on: {
    pointerdown: (event, { el, api, key }) => {
      if (el) api.connect.pointerdown(event, key, el)
    },
  },
}),
```

While drawing, `connecting` holds `{ from, to, x, y, keyboard }`. Render that draft alongside the saved edges, using `x` and `y` for its loose end when `to` is `null`.

For keyboard access, call `connect.start(portKey)` from an action on the node, then pass its key events to `connect.keydown(event)`. Arrows choose a valid port in that direction; Enter or Space connects; Escape or Tab cancels.

Drag from Prompt's output to an input on Answer or Summary. Or focus Prompt, press C, choose an input with the arrow keys, and press Enter. Escape cancels.

The behavior announces each step. Give it a `label` function for readable port names. If the ports are hidden from assistive technology, keep the node's keyboard action accessible and describe its connections in text, such as `aria-description="Connected to Answer."`.

### Snapping, Words and Code

`radius` controls how close the pointer must get before a port becomes the target. It uses screen pixels, so it stays consistent at different zoom levels. The default is 28.

Override `message` when the default announcements don't fit. `connectMessage` supplies the defaults for the other steps:

```ts
radius: () => 48,
message: (message, label) =>
  message.type === "connect"
    ? `${label(message.from)} into ${label(message.to)}.`
    : connectMessage(message, label),
```

You can also connect from code:

```ts
if (connect.start("synth")) {
  connect.target("ch3")
  connect.commit({ reason: "connect-key" })
}
```

`active()` tells you whether a connection is underway; `cancel()` clears its draft. The `connect` callback decides what to save. In this patch bay, connecting an existing pair removes its cable.

Drag a source jack toward a channel, then connect the same pair again to unplug it. Try Synth into 3, or focus a source and press Enter to start; use arrows to choose, Enter to connect, and Escape to cancel.

## Accessibility

Give every pointer action a keyboard path and explain it beside the canvas. Bring focused nodes into view, and describe connections in words as well as lines.

For a full editor, combine these helpers with [moving in two dimensions](/docs/core/behaviors/direct-manipulation#moving-in-two-dimensions), [selection](/docs/core/behaviors/selection) and [spatial focus](/docs/core/behaviors/focus#spatial-focus). [Testing](/docs/core/testing) covers keyboard and accessibility checks for the result.

## API Reference

- `key` (`keyof State & string`): The state key that holds the `Viewport`. List it in `live`, so panning renders nothing. Example: [Pan and Zoom](#pan-and-zoom).
- `surface` (`() => HTMLElement | null`): The element that clips the canvas and takes the gestures. Example: [Pan and Zoom](#pan-and-zoom).
- `set?` (`((value: Viewport, details: { reason: ViewportReason; event?: Event; }) => boolean)`): Asks for a new viewport. Defaults to setting the state key; pass the primitive's change to make it controllable. Example: [Screen and Canvas Points](#screen-and-canvas-points).
- `minZoom?` (`(() => number)`) Example: [Fit, Reveal and Zoom from Code](#fit-reveal-and-zoom-from-code).
- `maxZoom?` (`(() => number)`) Example: [Fit, Reveal and Zoom from Code](#fit-reveal-and-zoom-from-code).
- `message?` (`((zoom: number) => string)`): What screen readers hear after a zoom key. Defaults to English, like "Zoom 150%". Example: [Fit, Reveal and Zoom from Code](#fit-reveal-and-zoom-from-code).

- `slot` (`string`): The slot whose parts are ports. Example: [Draw a Connection](#draw-a-connection).
- `draft` (`keyof State & string`): The state key that holds the `ConnectDraft` while one is drawn. Start it at `null`, and list it in `live`. Example: [Draw a Connection](#draw-a-connection).
- `connect` (`(from: string, to: string, details: { reason: ConnectReason; event?: Event; }) => boolean`): Makes the connection and returns whether it went through. Example: [Draw a Connection](#draw-a-connection).
- `canConnect?` (`((from: string, to: string) => boolean)`): Whether `from` can connect to `to`. Defaults to any other port. Example: [Draw a Connection](#draw-a-connection).
- `point?` (`((clientX: number, clientY: number) => { x: number; y: number; })`): Turns a point on screen into your units, like a canvas viewport's `toCanvas`. Defaults to client pixels. Example: [Draw a Connection](#draw-a-connection).
- `radius?` (`(() => number)`): How near, in screen pixels, a pointer must come to a port to snap to it. Example: [Snapping, Words and Code](#snapping-words-and-code).
- `label?` (`((key: string) => string)`): A port's name for screen readers. Defaults to its key. Example: [Draw a Connection](#draw-a-connection).
- `message?` (`((message: ConnectMessage, label: (key: string) => string) => string)`): What screen readers hear. Defaults to English. Example: [Snapping, Words and Code](#snapping-words-and-code).
