# Canvas and rulers

> Pan and zoom a PixiJS scene with a camera, then add a grid, rulers, draggable guides, redlines, a marquee and equal-gap markers around it.

Source: https://pixi-silk.schmooky.dev/docs/canvas-and-rulers/

> **NOTE**
> Experimental in 1.0: the API may change in a minor release.

Demo sources run in the docs demo runner, `defineDemo({ setup({ stage, params, tick }) })`. In your own app, `stage` is `app.stage` (or a container on it), `params` holds the values of the demo controls, and `tick(fn)` is `app.ticker.add`, with `t` and `dt` in seconds.

Live demo `canvas-tools` (source):

```ts
import { Container, type FederatedPointerEvent } from 'pixi.js';
import { linear, SilkGraphics } from 'pixi-silk';
import { silkLabel } from 'pixi-silk/text';
import { Camera, ZoomControl } from 'pixi-silk/ui/camera';
import { TransformGizmo } from 'pixi-silk/ui/gizmo';
import { CanvasGrid } from 'pixi-silk/ui/grid';
import { SelectionGroup } from 'pixi-silk/ui/group';
import { Guides } from 'pixi-silk/ui/guides';
import { altDown } from 'pixi-silk/ui/keys';
import { Marquee } from 'pixi-silk/ui/marquee';
import { Measure } from 'pixi-silk/ui/measure';
import { boundsQuad, SelectionOutline, unionQuad } from 'pixi-silk/ui/outline';
import { Ruler } from 'pixi-silk/ui/ruler';
import { SnapEngine, type SnapRect } from 'pixi-silk/ui/snap';
import { findSpacing, SpacingMarkers } from 'pixi-silk/ui/spacing';
import { defineDemo } from './runtime';

// Drag on empty canvas to select, Shift adds and Alt takes away. Drag selected layers to move
// them: pink markers show equal gaps. Drag guides out of the rulers, drop them back to remove.
// Scroll to pan, pinch or ctrl + scroll to zoom.
export default defineDemo({
    title: 'Canvas tools',
    size: [760, 520],
    touchAction: 'none',
    ownsCamera: true,
    controls: {
        grid: { type: 'select', label: 'grid', options: ['lines', 'dots', 'off'], value: 'lines' },
        marquee: { type: 'select', label: 'marquee', options: ['intersect', 'contain'], value: 'intersect' },
    },
    setup({ silk, params }) {
        const app = silk.app;
        const world = new Container();
        const overlay = new Container();
        const camera = new Camera({ world, element: app.canvas });
        const grid = new CanvasGrid({ camera, ticker: app.ticker });
        // SilkText labels: distance fields stay sharp at any zoom
        const label = silkLabel();
        const ruler = new Ruler({ camera, ticker: app.ticker, label });
        const engine = new SnapEngine();
        const guides = new Guides({ camera, ruler, snap: engine, ticker: app.ticker, label });
        const group = new SelectionGroup();
        const hover = new SelectionOutline({ ticker: app.ticker });
        const outline = new SelectionOutline({ ticker: app.ticker, brackets: false });
        const spacing = new SpacingMarkers({ space: world, ticker: app.ticker });
        const measure = new Measure({ space: world, ticker: app.ticker, label });
        const layers: Container[] = [];
        let selected: readonly Container[] = [];
        let hovered: Container | null = null;

        const rectOf = (items: readonly Container[]): SnapRect => {
            const q = unionQuad(items.map((t) => boundsQuad(t, world)));

            return { x: q[0].x, y: q[0].y, width: q[2].x - q[0].x, height: q[2].y - q[0].y };
        };
        const others = () => layers.filter((l) => !selected.includes(l)).map((l) => rectOf([l]));
        const hooks = group.hooks();
        const gizmo = new TransformGizmo({
            mode: 'box',
            snapEngine: engine,
            ticker: app.ticker,
            onStart: () => {
                hooks.onStart();
                // snap to everything that is not moving, and to the guides
                engine.targets = others();
            },
            onChange: (_t, handle) => {
                hooks.onChange();
                if (handle === 'box-move') spacing.show(findSpacing(rectOf(selected), engine.targets));
            },
            onEnd: () => {
                hooks.onEnd();
                spacing.clear();
            },
        });
        const marquee = new Marquee({
            camera,
            surface: app.stage,
            candidates: () => layers,
            selection: () => selected,
            ticker: app.ticker,
            // live while dragging: only the outline follows, the gizmo comes when it ends
            onChange: (list) => (marquee.state.active.value ? preview(list) : select(list)),
            onEnd: (list) => select(list),
        });
        const zoom = new ZoomControl(camera, { label });

        world.addChild(group);
        guides.bindSnap(engine);
        camera.state.zoom.subscribe((k) => {
            engine.threshold = 6 / k;
        });
        engine.threshold = 6 / camera.zoom;
        overlay.addChild(hover, outline, spacing, measure, gizmo, guides, marquee, ruler, zoom);
        app.stage.addChild(grid, world, overlay);
        app.stage.eventMode = 'static';
        app.stage.hitArea = app.screen;

        function preview(list: readonly Container[]) {
            selected = list;
            outline.select(list);
            ruler.highlight(list.length ? list : null);
        }
        function select(list: readonly Container[]) {
            preview(list);
            // the gizmo's box frames the selection from here on
            outline.select([]);
            group.set(list);
            gizmo.attach(group.target);
            measure.size(list.length ? group.target : null);
            sync();
        }
        const sync = () => {
            const pair = altDown() && selected.length === 1 && hovered && hovered !== selected[0];

            measure.between(pair ? selected[0] : null, pair ? hovered : null);
            hover.hover(pair || (hovered && selected.includes(hovered)) ? null : hovered);
        };

        // an artboard: a frame you can marquee across, with layers on it
        const board = new SilkGraphics().rect(0, 0, 480, 360).fill(0xf4f4f6);

        world.addChildAt(board, 0);
        const layer = (x: number, y: number, draw: (g: SilkGraphics) => void) => {
            const item = new Container();
            const g = new SilkGraphics();

            draw(g);
            item.addChild(g);
            item.position.set(x, y);
            item.eventMode = 'static';
            item.cursor = 'pointer';
            item.on('pointerover', () => {
                hovered = item;
                sync();
            });
            item.on('pointerout', () => {
                if (hovered === item) hovered = null;
                sync();
            });
            item.on('pointerdown', (e: FederatedPointerEvent) => {
                if (e.button !== 0) return;
                e.stopPropagation();
                if (e.shiftKey)
                    select(selected.includes(item) ? selected.filter((s) => s !== item) : [...selected, item]);
                else if (!selected.includes(item)) select([item]);
                // press and move in one go
                if (selected.includes(item)) gizmo.grab(e);
            });
            world.addChildAt(item, world.children.length - 1);
            layers.push(item);

            return item;
        };

        layer(32, 32, (g) =>
            g.roundRect(0, 0, 416, 120, 24, 0.6).fill(linear([0x5e5ce6, 0xbf5af2], { from: [0, 0], to: [1, 1] })),
        );
        const a = layer(32, 184, (g) => g.roundRect(0, 0, 120, 96, 18, 0.6).fill(0xffffff));

        layer(180, 184, (g) => g.roundRect(0, 0, 120, 96, 18, 0.6).fill(0xffffff));
        layer(360, 184, (g) => g.roundRect(0, 0, 88, 96, 18, 0.6).fill(0xffffff));
        layer(32, 304, (g) => g.circle(12, 12, 12).fill(0x30d158));
        layer(72, 308, (g) => g.pill(0, 0, 160, 16).fill(0x1c1c1e));

        guides.add('x', 32);
        guides.add('y', 184);

        const place = () =>
            zoom.position.set(app.screen.width - zoom.size[0] - 12, app.screen.height - zoom.size[1] - 12);

        app.renderer.on('resize', place);
        place();
        select([a]);
        camera.fit({ x: -40, y: -40, width: 560, height: 440 }, 24, false);

        const onKey = (e: KeyboardEvent) => e.key === 'Alt' && sync();

        addEventListener('keydown', onKey);
        addEventListener('keyup', onKey);
        app.ticker.add(() => {
            grid.state.visible.set(params.grid !== 'off');
            if (params.grid !== 'off') grid.state.style.set(params.grid as 'lines' | 'dots');
            marquee.mode = params.marquee as 'intersect' | 'contain';
        });

        return () => {
            removeEventListener('keydown', onKey);
            removeEventListener('keyup', onKey);
            camera.destroy();
            ruler.destroy();
            marquee.destroy();
        };
    },
});
```

The camera pans and zooms a world container. The other pieces on this page read the camera and
draw in screen space around it. Each one is its own `pixi-silk/ui/<name>` entry point, and none of
them holds a selection. They take plain arrays of containers and report back through callbacks,
so any selection store can drive them. [Gizmos and selection](/docs/gizmos-and-selection/) adds
one.

## Pan and zoom with a camera

```ts

const world = new Container();
const overlay = new Container();
const camera = new Camera({ world, element: app.canvas });

app.stage.addChild(world, overlay);
overlay.addChild(new ZoomControl(camera));
camera.fit();
```

The camera moves and scales `world`. Put overlays beside the world, not inside it: outlines,
gizmos and redlines on the overlay follow the objects and keep their size on screen.

A trackpad pinch, or ctrl or cmd with the wheel, zooms around the cursor. Scrolling pans, and so
do middle drag, right drag and Space + drag. On touch screens two fingers pinch and pan, so one
finger stays with the scene. Pans never reach the scene, so your own clicks and drags keep working.

| Option | Default | What it does |
|---|---|---|
| `zoomRange` | `[0.02, 256]` | Smallest and largest zoom. |
| `wheel` | `'pan'` | `'zoom'` makes the wheel zoom and Shift + wheel pan, like a map. |
| `panButtons` | `[1, 2]` | Mouse buttons that pan when dragged: 1 is middle, 2 is right. |
| `spacePan` | `true` | Holding Space turns a left drag into a pan. |
| `touchPan` | `'two'` | `'one'` pans with one finger. |
| `inertia` | `true` | A fling keeps gliding and slows down. |
| `keys` | `true` | `+` and `-` zoom, Shift + 0 is 100% and Shift + 1 fits the content. |
| `pageScroll` | `'auto'` | In a page that scrolls, a plain scroll keeps scrolling the page until the canvas is clicked. `false` gives the canvas every scroll. |
| `ignore` | none | Points the camera leaves alone, such as panels drawn over the view. |

`fit()`, `zoomTo()`, `zoomBy()` and `centerOn()` animate unless you pass `false`. `fit()` takes
padding per side, like `{ left: 280, right: 300 }`, to centre content between docked panels.
`state.zoom`, `state.x` and `state.y` are signals for readouts and for anything that redraws on
zoom. `screenToWorld()` and `worldToScreen()` convert points.

## Measure distances

`Measure` draws redlines from one object to another, like holding Alt in Figma. Side by side it
shows the gap along the band the two share. When one sits inside the other it shows the margin on
every side. `size()` adds a `W x H` badge.

```ts

declare const world: Container, overlay: Container, selected: Container, hovered: Container;

const measure = new Measure({ space: world });

overlay.addChild(measure);
measure.size(selected);
measure.between(selected, hovered);
```

Distances are in `space`, usually the camera's world, so a gap of 24 reads 24 at any zoom.
Redlines follow their objects every frame. `format` turns a value into a label, for units or
rounding.

## Draw a grid

```ts

const world = new Container();
const camera = new Camera({ world, element: app.canvas });
const grid = new CanvasGrid({ camera, style: 'dots', axes: true });

app.stage.addChild(grid, world);
```

`CanvasGrid` draws an infinite grid behind the world. Levels nest at 1, 10 and 100 world units,
and each one fades in as its lines spread apart on screen, so zooming never pops a level in.
A level of lines costs two shapes, whatever the size of the view. Dots take one shape per row.

| Option | Default | What it does |
|---|---|---|
| `style` | `'lines'` | `'lines'` or `'dots'`. Change it later through `state.style` and the grid crossfades. |
| `unit` | `1` | Finest step, in world units. |
| `ratio` | `10` | How many times each level is the one below. |
| `fade` | `[6, 120]` | Screen spacing where a level starts to show and where it reaches full strength. |
| `axes` | `false` | Draws the x = 0 and y = 0 axes. |

`gridLevels()` returns the levels for a zoom, for your own drawing.

## Add rulers and guides

```ts

declare const camera: Camera, overlay: Container, selected: Container[];

const ruler = new Ruler({ camera });
const engine = new SnapEngine();
const guides = new Guides({ camera, ruler, snap: engine });

overlay.addChild(guides, ruler);
ruler.highlight(selected);
guides.bindSnap(engine);
```

`Ruler` draws a top and a left ruler. Labels step in 1, 2 and 5 times a power of ten. When the
step changes, the labels the two steps share stay put while the rest fade. `highlight()` bands the
extent of objects or a world rectangle in the accent colour. `format` turns world values into
labels, for units such as `pt` or `mm`.

`Guides` lets the reader drag a guide out of a ruler: the top ruler gives a horizontal guide, the
left one a vertical guide. Dropping a guide back on the ruler removes it. While dragging, a guide
snaps to the edges and centres of the engine's targets, and Ctrl or Cmd turns snapping off.
`bindSnap()` feeds the guides back into the engine, so objects snap to them too. `add()`, `move()`,
`remove()` and `clear()` edit guides from code, and `state.guides` is a signal of all of them.

## Select with a marquee

```ts

declare const camera: Camera, overlay: Container, layers: Container[];
let selected: readonly Container[] = [];

app.stage.eventMode = 'static';
app.stage.hitArea = app.screen;
const marquee = new Marquee({
    camera,
    surface: app.stage,
    candidates: () => layers,
    selection: () => selected,
    onChange: (next) => {
        selected = next;
    },
});

overlay.addChild(marquee);
```

A left drag that starts on `surface` itself draws the rectangle. Shift adds to the selection and
Alt takes away. The camera keeps middle, right, Space and two-finger drags, and a pan that starts
mid-drag cancels the marquee. Near the edge of the view the camera pans.

`mode: 'intersect'` (the default) picks what the rectangle touches, rotated objects included.
`mode: 'contain'` picks what is fully inside. For a spatial index or custom shapes, pass
`hit: (rect, mode) => targets` instead of `candidates`.

## Show equal spacing

Live demo `smart-spacing` (source):

```ts
import { Container, type FederatedPointerEvent, type PointData } from 'pixi.js';
import { SilkGraphics } from 'pixi-silk';
import { SnapEngine, type SnapRect } from 'pixi-silk/ui/snap';
import { findSpacing, gapsBetween, SpacingMarkers } from 'pixi-silk/ui/spacing';
import { defineDemo } from './runtime';

// Drag a card: it snaps to equal gaps and shared edges, and pink markers show every equal gap.
// Hover the stack on the right to see its spacing, the way an auto-layout frame shows it.
export default defineDemo({
    title: 'Smart spacing',
    size: [760, 380],
    touchAction: 'none',
    controls: {
        snap: { type: 'toggle', label: 'snap', value: true },
    },
    setup({ stage, params }) {
        const scene = new Container();
        const markers = new SpacingMarkers({ space: scene });
        const stack = new Container();
        const engine = new SnapEngine({ threshold: 8 });
        const cards: Container[] = [];
        const rect = (c: Container): SnapRect => {
            const b = c.getLocalBounds();

            return { x: c.x + b.minX, y: c.y + b.minY, width: b.width, height: b.height };
        };
        let drag: { card: Container; grab: PointData; id: number } | null = null;

        stage.addChild(scene, markers);
        stage.eventMode = 'static';
        stage.hitArea = { contains: () => true };

        const card = (x: number, y: number, w: number, h: number, color: number) => {
            const c = new Container();

            c.addChild(new SilkGraphics().roundRect(0, 0, w, h, 14, 0.6).fill(color));
            c.position.set(x, y);
            c.eventMode = 'static';
            c.cursor = 'grab';
            c.on('pointerdown', (e: FederatedPointerEvent) => {
                const p = scene.toLocal(e.global);

                drag = { card: c, grab: { x: p.x - c.x, y: p.y - c.y }, id: e.pointerId };
                engine.targets = cards.filter((o) => o !== c).map(rect);
            });
            scene.addChild(c);
            cards.push(c);

            return c;
        };

        card(40, 60, 110, 80, 0x0a84ff);
        card(190, 60, 110, 80, 0x5e5ce6);
        card(380, 80, 90, 60, 0xbf5af2);
        card(110, 220, 140, 90, 0x30d158);

        stage.on('globalpointermove', (e: FederatedPointerEvent) => {
            if (!drag || e.pointerId !== drag.id) return;
            const p = scene.toLocal(e.global);
            const c = drag.card;
            const b = c.getLocalBounds();
            const moving = {
                x: p.x - drag.grab.x + b.minX,
                y: p.y - drag.grab.y + b.minY,
                width: b.width,
                height: b.height,
            };
            const snap = params.snap ? engine.snap(moving) : { dx: 0, dy: 0 };

            c.position.set(p.x - drag.grab.x + snap.dx, p.y - drag.grab.y + snap.dy);
            // what lines up after snapping
            markers.show(findSpacing(rect(c), engine.targets));
        });
        const end = () => {
            drag = null;
            markers.clear();
        };

        stage.on('pointerup', end);
        stage.on('pointerupoutside', end);

        // an auto-layout stack: its children's gaps on hover
        stack.position.set(560, 40);
        for (const [i, h] of [56, 40, 72].entries()) {
            const row = new SilkGraphics().roundRect(0, 0, 150, h, 10, 0.6).fill(0x2c2c2e);

            row.y = [0, 72, 128][i];
            stack.addChild(row);
        }
        stack.eventMode = 'static';
        stack.on('pointerover', () => {
            const rows = stack.children.map((r) => ({ x: stack.x, y: stack.y + r.y, width: 150, height: r.height }));

            markers.show({ gaps: gapsBetween(rows, 'y') });
        });
        stack.on('pointerout', () => markers.clear());
        scene.addChild(stack);

        return undefined;
    },
});
```

`findSpacing()` looks at a box among others. When the gap to a neighbour equals another gap in the
same row or column, it returns every one of those gaps. It also returns edges and centres that
line up. `SpacingMarkers` draws them as segments with value pills.

```ts

declare const world: Container, overlay: Container, moving: SnapRect, others: SnapRect[];

const engine = new SnapEngine({ targets: others });
const markers = new SpacingMarkers({ space: world });

overlay.addChild(markers);
const snap = engine.snap(moving);

markers.show(findSpacing({ ...moving, x: moving.x + snap.dx, y: moving.y + snap.dy }, others));
```

Snap first, then report on the snapped box, or the markers show gaps the box is about to leave.
`gapsBetween()` reads the gaps of a row or a column, so an auto-layout container can show its
spacing with the same markers.

## Labels

`Measure`, `Ruler`, `Guides` and `ZoomControl` draw their text with PixiJS `Text` by default.
Pass `label: silkLabel()` from `pixi-silk/text` to draw it with [SilkText](/docs/text/) instead.
The widgets never import SilkText on their own, so an app that skips the option does not ship it.

```ts

declare const camera: Camera, world: Container;

const label = silkLabel();
const measure = new Measure({ space: world, label });
const ruler = new Ruler({ camera, label });
const zoom = new ZoomControl(camera, { label });
```

## Where it stops

The pieces draw and report. They do not keep a document, a layer list or an undo stack.
[Gizmos and selection](/docs/gizmos-and-selection/) adds a selection store and a history, and
[Panels](/docs/panels/) adds a layer tree and an inspector.
