# Dials and timeline

> A jog wheel with fine control and inertia, scrub fields you drag sideways, and a compact animation timeline with keys, a loop range and zoom for PixiJS.

Source: https://pixi-silk.schmooky.dev/docs/dials-and-timeline/

> **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 `dial` (source):

```ts
import { JogDial, Scrubber } from 'pixi-silk/ui/dial';
import { defineDemo } from './runtime';

// Drag around a dial: the value keeps adding up over full turns, and further out turns slower.
// Flick it and it keeps turning. The right dial clicks through steps and stretches past its limits.
// Scrub the fields sideways. Shift is fine, Ctrl or Cmd snaps, a tap on either end steps.
export default defineDemo({
    title: 'Dials and number fields',
    size: [600, 300],
    // phones: the fields go in a column under the dials
    phone: { size: [360, 440] },
    touchAction: 'none',
    setup({ silk, stage, phone }) {
        const ticker = silk.app.ticker;
        const endless = new JogDial({ ticker, radius: 62, turn: 360, format: (v) => `${Math.round(v)}°` });
        const stepped = new JogDial({
            ticker,
            radius: 62,
            step: 1,
            min: 0,
            max: 24,
            value: 6,
            theme: { fill: 0x30d158 },
        });
        const size = new Scrubber({ ticker, label: 'Size', value: 24, step: 1, min: 1, max: 200, perPixel: 0.5 });
        const opacity = new Scrubber({
            ticker,
            label: 'Opacity',
            value: 0.8,
            step: 0.05,
            min: 0,
            max: 1,
            perPixel: 0.005,
            format: (v) => `${Math.round(v * 100)}%`,
            theme: { fill: 0xbf5af2 },
        });
        const angle = new Scrubber({
            ticker,
            label: 'Angle',
            value: 0,
            step: 15,
            perPixel: 1,
            format: (v) => `${v.toFixed(1)}°`,
        });

        if (phone) {
            endless.position.set(95, 124);
            stepped.position.set(265, 124);
            size.position.set(105, 254);
            opacity.position.set(105, 309);
            angle.position.set(105, 364);
        } else {
            endless.position.set(110, 130);
            stepped.position.set(290, 130);
            size.position.set(420, 70);
            opacity.position.set(420, 125);
            angle.position.set(420, 180);
        }
        // the endless dial drives the angle field
        endless.state.value.subscribe((v) => angle.state.value.set(Math.round(v * 10) / 10));
        stage.addChild(endless, stepped, size, opacity, angle);

        return undefined;
    },
});
```

Controls for numbers and time. `JogDial` and `Scrubber` are in `pixi-silk/ui/dial`, and
`MiniTimeline` is in `pixi-silk/ui/timeline`. For a rolling number readout, see the odometer on
[Interaction and motion](/docs/interaction-and-motion/#roll-a-number-with-an-odometer).

## Turn a value with a jog dial

```ts

const camera = { zoom: 1 };
const undo: number[] = [];
const zoom = new JogDial({
    radius: 64,
    turn: 2,
    step: 0.1,
    min: 0.1,
    max: 8,
    onChange: (v) => (camera.zoom = v),
    onCommit: (v) => undo.push(v),
});
```

Press anywhere on the dial and drag around its centre. The value follows the angle and keeps
adding up over full turns, so there is no seam at the top. Past the ring the dial turns slower the
further out the pointer is, which gives fine control without letting go.

A fast release keeps the dial spinning until `friction` stops it. With `min` and `max` the ring
becomes a gauge of the range, and past a limit the value stretches and springs back. A tap on the
right half steps up, on the left half down. `onChange` fires on every snapped change and
`onCommit` once the dial rests.

| Option | Default | What it does |
|---|---|---|
| `turn` | the range, else 1 | Value change for one full turn near the ring. |
| `step` | `0` | Snap step. 0 turns snapping off. |
| `fine` | `1` | How fast precision grows with distance: the speed is `(radius / distance) ** fine`. 0 turns it off. |
| `rubber` | `0.12` | How far the value stretches past a limit, as a fraction of a turn. |
| `inertia` | `true` | Keep spinning after a flick. |
| `friction` | `3.5` | How quickly a flick slows down, about 1 / seconds. |

## Drag a number field

```ts

const size = new Scrubber({ label: 'Size', value: 24, step: 1, min: 1, max: 200, perPixel: 0.5 });
```

Drag the field sideways to change its value. Shift scrubs at a tenth of the speed
(`fineFactor`), and Ctrl or Cmd snaps to the step. A tap on either end steps down or up. With
`min` and `max` the field fills like a slider. `perPixel` defaults to a quarter of the step.

Both controls keep their value in `state.value`, a signal you can also write to move them.

## Scrub an animation timeline

Live demo `timeline` (source):

```ts
import { SilkGraphics } from 'pixi-silk';
import { JogDial } from 'pixi-silk/ui/dial';
import { MiniTimeline } from 'pixi-silk/ui/timeline';
import { defineDemo, PALETTE } from './runtime';

// Scrub the ruler (past the ends it stretches), drag keys, Shift-click or box-select several,
// and move the green loop handles. The wheel zooms, sideways or with Shift it pans, and a
// double tap on the ruler zooms in or back out. Tap the dial to play or pause.
// The scene is drawn small and scaled up, so the ruler's 9 px labels read at 11 px.
export default defineDemo({
    title: 'Timeline and jog dial',
    size: [540, 184],
    // phones: the dial under the timeline, and 11 px ruler labels as the scene is not scaled up
    phone: { size: [360, 290] },
    touchAction: 'none',
    setup({ silk, stage, tick, phone }) {
        const span = phone ? 312 : 380;
        const ticker = silk.app.ticker;
        const timeline = new MiniTimeline({
            ticker,
            width: span,
            height: 70,
            textStyle: phone ? { fontSize: 11 } : undefined,
            duration: 120,
            loop: [24, 96],
            keys: [0, 12, 30, 44, 60, 72, 90, 108].map((time, i) => ({ id: `k${i}`, time })),
        });
        const ball = new SilkGraphics();
        const jog = new JogDial({ ticker, radius: 34, thickness: 10, turn: 24, readout: false });
        let last = 0;

        timeline.position.set(24, 104);
        if (phone) jog.position.set(180, 236);
        else jog.position.set(472, 139);
        // the dial scrubs the playhead, a tap on it plays or pauses
        jog.state.value.subscribe((v) => {
            timeline.state.time.set(Math.max(0, Math.min(120, timeline.state.time.value + v - last)));
            last = v;
        });
        jog.on('pointertap', () => timeline.toggle());
        timeline.state.playing.set(true);
        stage.addChild(ball, timeline, jog);
        tick(() => {
            // something to animate: a ball eased between the keys
            const time = timeline.state.time.value;
            const keys = timeline.keys.map((k) => k.time).sort((a, b) => a - b);
            const i = keys.findIndex((k) => k > time);
            const a = keys[Math.max(0, i - 1)] ?? 0;
            const b = keys[i] ?? 120;
            const u = b > a ? (time - a) / (b - a) : 0;
            const y = 80 - Math.sin(Math.PI * u) * 44;
            const x = 24 + (time / 120) * span;

            ball.clear();
            ball.circle(x, 92, 12).fill({ color: 0x000000, alpha: 0.4, blur: 6 });
            ball.circle(x, y, 12).fill(PALETTE.blue);
        });

        return undefined;
    },
});
```

```ts

const timeline = new MiniTimeline({
    duration: 120,
    fps: 30,
    loop: [24, 96],
    keys: [
        { id: 'jump', time: 30 },
        { id: 'land', time: 44 },
    ],
    onKeysChange: (keys) => localStorage.setItem('keys', JSON.stringify(keys)),
});

timeline.state.time.subscribe((frame) => console.log('frame', frame));
timeline.state.playing.set(true);
```

Times are in frames. Scrub the ruler to move the playhead, which snaps to frames and stretches
past either end. Drag a key to move it, Shift + click to add keys to the selection, or drag on
empty track for a selection box. Selected keys move together and stop at the ends of the track.
Drag the green handles on the ruler to set the loop range, and playback wraps inside it.

The wheel zooms around the pointer and Shift + wheel pans. Pixi listens to the wheel passively,
so the page scrolls too unless the canvas fills it. Without a wheel, a double tap on the ruler
zooms in and another zooms back out. `zoomBy()`, `panBy()` and `showAll()` do the same from code.

| State | What it holds |
|---|---|
| `time` | Playhead time in frames. Writing it moves the playhead. |
| `playing` | Writing it starts or stops playback. |
| `selection` | Ids of the selected keys. |
| `range` | Loop range in frames, or null for the whole track. |
| `view` | The visible window in frames. |

| Option | Default | What it does |
|---|---|---|
| `duration` | `120` | Length in frames. |
| `fps` | `30` | Playback speed. |
| `snap` | `1` | Keys and the playhead snap to multiples of this many frames. 0 turns it off. |
| `minSpan` | `8` | Fewest frames the view zooms in to. |
| `fontSize` | `9` | Size of the frame numbers on the ruler. `fontFamily` and `textStyle` set the rest. |
| `label` | `BitmapText` | Builds the frame numbers. Pass `silkLabel()` from `pixi-silk/text` for SDF text. |

The timeline holds keys and a playhead, not animation curves. Use `state.time` to drive your own
tweens or a Spine track.
