> ## Documentation Index
> Fetch the complete documentation index at: https://docs.graphy.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Commands & history

A **command** is a single, undoable edit to a chart's spec — "hide the y-axis", "widen the line", "move the legend to the bottom". Instead of rebuilding the spec and passing a new `spec`, you dispatch a command and the provider applies it, recompiles, and records it on an undo stack.

```tsx theme={null}
import { SetAxisVisibilityCommand } from '@graphysdk/viz-engine';
import { useGraphCommands } from '@graphysdk/react-renderer';

function AxisToggle({ isVisible }: { isVisible: boolean }) {
  const { dispatch } = useGraphCommands();

  return (
    <button
      onClick={() =>
        dispatch(
          new SetAxisVisibilityCommand({ axis: 'y', isVisible: !isVisible })
        )
      }
    >
      {isVisible ? 'Hide' : 'Show'} y-axis
    </button>
  );
}
```

Every edit that reaches the chart goes through this one path — a toolbar button, an inline edit in `editable` mode, or a command streamed in from an agent — so all of them land in the same history and all of them are undoable.

## Dispatching

`useGraphCommands` returns `dispatch(command, options?)` and `commit()`. It must be called from inside a `<GraphProvider>`.

A command is a plain object you construct with its params:

```tsx theme={null}
dispatch(
  new SetStyleRuleCommand({
    list: 'defaults',
    rule: style.geom.line({ strokeWidth: 3 }),
  })
);
dispatch(new SetScaleDomainCommand({ scaledAesthetic: 'y', domainMin: 0 }));
dispatch(new SetLegendPositionCommand({ position: 'bottom' }));
```

Most commands that target a layer take an optional `layerId`. Omit it and the command finds the first layer of a compatible geom — enough for a single-series chart, while a combo chart should name the layer explicitly:

```tsx theme={null}
dispatch(
  new SetLineCurveCommand({
    layerId: 'trend',
    curve: 'smooth',
  })
);
```

A layer's id comes from the spec — set it when you build the layer, and every reference to it (commands, highlights, annotation anchors) can use that name:

```tsx theme={null}
pipe(
  createSpec({ x: 'month' }),
  geom.bar({ id: 'bars' }),
  geom.line({ id: 'trend' })
);
```

Leave `id` off and one is assigned at compile time, so a layer always has a stable identity — you just don't get to choose the name.

Five commands **require** `layerId` rather than accepting it: `SetLineWidthCommand`, `SetPointSizeCommand`, `SetBarCornerRadiusCommand` and `ToggleLineFillCommand`, which write per-layer stylesheet entries and so need an address, plus `RemoveLayerCommand`, which needs to know what it is removing.

Nothing is mutated. A command reads the current spec and returns a new one, so the provider can swap the compiled result in without touching the object you passed as `spec`.

### Persisting edits

The provider's `onSpecChange` fires with the spec a command produced. Store that if edits need to survive a reload:

```tsx theme={null}
<GraphProvider spec={spec} data={data} onSpecChange={setSpec}>
  <GraphRenderer mode="editable" />
</GraphProvider>
```

Handing that same value straight back as `spec` is a no-op — the provider recognises the echo and does not recompile.

### Live gestures

A slider or colour picker that edits while it is held would otherwise leave one undo entry — and one `onSpecChange` — per frame. Pass `{ transient: true }` on those frames and call `commit()` when the gesture ends:

```tsx theme={null}
import { SetStyleRuleCommand, style } from '@graphysdk/viz-engine';
import { useGraphCommands } from '@graphysdk/react-renderer';

function CornerRadiusSlider({ cornerRadius }: { cornerRadius: number }) {
  const { dispatch, commit } = useGraphCommands();

  return (
    <input
      type="range"
      min={0}
      max={16}
      value={cornerRadius}
      onChange={(event) =>
        dispatch(
          new SetStyleRuleCommand({
            list: 'overrides',
            rule: style.graph({ cornerRadius: event.target.valueAsNumber }),
          }),
          { transient: true }
        )
      }
      onPointerUp={commit}
      onBlur={commit}
    />
  );
}
```

A transient dispatch recompiles and repaints like any other, but it replaces the run's single undo entry instead of pushing a new one, and keeps the run's *oldest* revert — so one undo returns to before the gesture began, not to its penultimate frame. `onSpecChange` fires once, when `commit()` closes the run.

A run covers one thing being edited: the dispatches fold together only while they are the same command type against the same edit target. A different target — or a different command type on the same target — starts its own entry, and so does a committed dispatch, an undo, a redo, or a new `spec`, `data` or theme from the host, each of which closes the open run first. Forgetting to `commit()` delays that gesture's `onSpecChange` until one of them arrives; it does not merge two gestures into one step.

Dragging something across the canvas is a different shape: move it locally in pixels and dispatch one command on drop. Transient dispatch is for gestures whose feedback genuinely needs the spec to recompile.

## Undo & redo

`useGraphHistory` gives you the controls plus the state a history UI reads. It subscribes to the stack, so a component using it re-renders whenever a step becomes available.

```tsx theme={null}
import { useGraphHistory } from '@graphysdk/react-renderer';

function UndoRedoToolbar() {
  const { undo, redo, canUndo, canRedo, undoDescription, redoDescription } =
    useGraphHistory();

  return (
    <>
      <button
        disabled={!canUndo}
        title={undoDescription ?? undefined}
        onClick={undo}
      >
        Undo
      </button>
      <button
        disabled={!canRedo}
        title={redoDescription ?? undefined}
        onClick={redo}
      >
        Redo
      </button>
    </>
  );
}
```

<ParamField path="canUndo / canRedo" type="boolean">
  Whether a step is available in each direction.
</ParamField>

<ParamField path="undoDescription / redoDescription" type="string | null">
  Human-readable label for the next step — `"Set line width to 3"` — for a
  button tooltip or a menu item. `null` when there is nothing to step.
</ParamField>

<ParamField path="undoStack / redoStack" type="CommandMetadata[]">
  The full history, for a history panel. Each entry carries `id`, `timestamp`,
  `description` and `author`. `undoStack` runs oldest first; `redoStack` runs in
  the order the steps were undone. In both it is the *last* entry that is next:
  the end of `undoStack` is what an undo reverses, the end of `redoStack` is
  what a redo re-applies.
</ParamField>

### Keyboard shortcuts

Shortcuts are opt-in. `useGraphHistoryShortcuts` binds them, driving the graph through an `handleRef` so it can be called from wherever your key handling lives, including above the provider:

```tsx theme={null}
import { useRef } from 'react';
import {
  type GraphHandle,
  useGraphHistoryShortcuts,
} from '@graphysdk/react-renderer';

function EditableChart() {
  const handleRef = useRef<GraphHandle>(null);
  useGraphHistoryShortcuts(handleRef);

  return (
    <GraphProvider spec={spec} data={data} handleRef={handleRef}>
      <GraphRenderer mode="editable" />
    </GraphProvider>
  );
}
```

It binds ⌘/Ctrl+Z, ⌘/Ctrl+Shift+Z and Ctrl+Y, and leaves alone any chord already handled by the app or typed into a text field. Pass `{ target }` — an element, or a ref holding one — to scope it to a subtree instead of `window`, or `{ enabled: false }` to unbind.

A chord is only claimed when the chart has a step to take: with an empty history the keystroke is left uncancelled, so an undo your app owns elsewhere on the page still gets it.

The same `handleRef` exposes `dispatch`, `commit`, `undo` and `redo` directly, for a toolbar or menu bar mounted outside the provider where the hooks can't reach. `undo` and `redo` return whether the chart took the step — that's the signal to fall through to your own handling. A step the chart declines, either because there is nothing to step or because the older spec no longer compiles, returns `false`.

## When a command doesn't apply

Two outcomes leave the chart exactly as it was:

* **No-op** — the command found nothing to change: the value already matches, or the layer or scale it targets isn't in the spec. Nothing recompiles and nothing joins the history, so there's no empty step to undo past.
* **Rejected** — the edit produced a spec that fails to compile. The last good chart stays on screen and the diagnostics go to the provider's `onError`. An edit the chart never took is not one you can undo, so the history is left untouched.

Undo and redo are gated the same way. If restoring an older spec fails to compile — say the data loaded since no longer has a column that spec referenced — the step is refused, neither stack moves, and `undo()` returns `false` so the keystroke falls through to your app. The step stays on the stack and applies once the data supports it again, which is also to say the steps behind it are reachable only through it.

## History lifecycle

The history belongs to the chart the provider is currently showing:

* A **new `spec`** from the host is a new baseline and clears the history, once that input compiles. The stored steps restore values from a spec that no longer exists, and may target layers or scales the new one doesn't have. This is by reference, so a `spec` rebuilt on every render leaves nothing undoable — [keep it stable](/sdk-next/rendering/provider-and-renderer#keep-inputs-stable).
* An **`spec` that fails to compile** keeps it. The chart behind the error panel is still the last good one, so an undo is the way back to something renderable.
* **New `data`** or a **theme change** keeps it. The recorded steps still address the spec they were built from.
* History depth is capped at 100 steps; the oldest is dropped past that.

## Available commands

<Accordion title="All commands" icon="list">
  Each takes its params as the first constructor argument and optional metadata as the second. Scale commands take `scaledAesthetic` to pick the scale by the aesthetic it drives (`'x'`, `'y'`, `'color'`, …). Axis commands take `axis`: `'x' | 'y'` for all of them except `SetAxisLabelCommand`, which also accepts `'ySecondary'`, and the two grid line-style commands, which accept `'both'` so a change across the whole grid folds into one undo entry.

  `AddLayerCommand`, `AddHighlightCommand` and `AddAnnotationCommand` each draw a line between what you construct with and what serializes: the constructor's `id` is optional and minted when omitted, while the serialized params carry it settled, so a deserialized command replays to the same spec instead of adding a second thing.

  `SequenceCommand({ commands })` applies several commands as one undo step, in the order given. It is about whatever its first command is about, and it carries that command's description. It does nothing when together they leave the spec unchanged.

  **Chart type**

  | Command | Params |
  | - | - |
  | `SetChartTypeCommand` | `{ coordType: 'cartesian' \| 'flip', geom, position }`, or `{ coordType: 'polar', geom, position, theta, innerRadius }` |

  What a chart *is* — a column chart, a stacked bar, a donut — spans the coordinate system, every series layer and the scales those geoms need, so this writes them together as one undo entry. `readChartType(spec)` reads it back for a picker, returning `null` for a combo chart no single option describes. Reference lines and fitted trends keep their own shape across the switch.

  **Titles & content**

  | Command | Params |
  | - | - |
  | `SetContentTitleCommand` | `title` |
  | `SetContentSubtitleCommand` | `subtitle` |
  | `SetContentCaptionCommand` | `caption` |
  | `SetContentSourceCommand` | `source` |
  | `ToggleContentVisibilityCommand` | `part`, `isVisible` |

  **Axes & grid**

  | Command | Params |
  | - | - |
  | `SetAxisLabelCommand` | `axis`, `label` |
  | `SetAxisPositionCommand` | `axis`, `position` |
  | `SetAxisVisibilityCommand` | `axis`, `isVisible` |
  | `SetAxisTicksVisibilityCommand` | `axis`, `isVisible` |
  | `SetAxisTickModeCommand` | `axis`, `tickMode` |
  | `SetGridVisibilityCommand` | `axis`, `isVisible: boolean \| null` (`null` defers to the compiler) |
  | `SetGridLineStyleCommand` | `axis: 'x' \| 'y' \| 'both'`, `lineStyle` |
  | `SetGridLineWidthCommand` | `axis: 'x' \| 'y' \| 'both'`, `lineWidth` |

  **Legend & headline**

  | Command | Params |
  | - | - |
  | `SetLegendPositionCommand` | `position` |
  | `SetLegendAlignCommand` | `align` |
  | `SetLegendPlacementCommand` | `position`, `align` |
  | `SetLegendDisplayCommand` | `display` |
  | `SetHeadlineShowCommand` | `show` |
  | `SetHeadlinePositionCommand` | `position` |
  | `SetHeadlineSizeCommand` | `size` |
  | `SetHeadlineCompareWithCommand` | `compareWith` |

  **Appearance & formatting**

  | Command | Params |
  | - | - |
  | `SetHighlightDimStyleCommand` | `dimStyle: 'dim' \| 'desaturate'` |
  | `SetNumberFormatDecimalsCommand` | `decimals` |
  | `SetNumberFormatAbbreviationCommand` | `abbreviation` |

  Paint — the graph's background, border, corner radius and text scale included — is a stylesheet edit rather than a config one: reach for `SetStyleRuleCommand` with `style.graph({ … })`, and see [Styling](/sdk-next/config/styling) for the vocabulary.

  **Coordinates**

  | Command | Params |
  | - | - |
  | `SetCoordLimitsCommand` | `xLimits`, `yLimits` |
  | `SetPolarInnerRadiusCommand` | `innerRadius` |
  | `SetPolarStartAngleCommand` | `startAngle` |

  **Layers**

  | Command | Params |
  | - | - |
  | `AddLayerCommand` | `layer`, `index?` |
  | `RemoveLayerCommand` | `layerId` |
  | `SetLayerPositionCommand` | `layerId?`, `position` |
  | `SetLayerStatCommand` | `layerId?`, `stat` |
  | `SetLayerYScaleTypeCommand` | `layerId?`, `yScaleType` |
  | `SetLineCurveCommand` | `layerId?`, `curve` |
  | `SetLineMissingValuesCommand` | `layerId?`, `missingValues` |
  | `ToggleLinePointsCommand` | `layerId?`, `showPoints`, `removedLayer?` |
  | `ToggleLineFillCommand` | `layerId`, `showFill`, `fillAlpha?` |
  | `SetLineWidthCommand` | `layerId`, `lineWidth: number \| null` |
  | `SetPointSizeCommand` | `layerId`, `size: number \| null` |
  | `SetBarWidthCommand` | `layerId?`, `width` |
  | `SetBarCornerRadiusCommand` | `layerId`, `cornerRadius: BorderRadiusToken \| number \| null` |
  | `SetRuleValueCommand` | `layerId?`, `value` |
  | `SetRuleLabelCommand` | `layerId?`, `label` |
  | `ToggleGoalLineCommand` | `showGoalLine`, `layerId?`, `value?`, `label?`, `removedLayer?` |
  | `SetStatLineCommand` | `line: 'none' \| 'trend' \| 'average'`, `layerId?`, `method?`, `group?`, `label?`, `removedLayer?` |
  | `ToggleDataLabelsCommand` | `layerId?`, `showDataLabels` |
  | `SetDataLabelsFormatCommand` | `layerId?`, `format` |
  | `ToggleCategoryLabelsCommand` | `layerId?`, `showCategoryLabels` |
  | `ToggleStackTotalsCommand` | `layerId?`, `showStackTotals` |

  `AddLayerCommand` and `RemoveLayerCommand` are the two directions of every "show a trend line" or "add a series" toggle, so `AddLayerCommand` carries a whole layer rather than a per-feature flag. The toggles that remove a layer of their own — `ToggleLinePointsCommand`, `ToggleGoalLineCommand`, `SetStatLineCommand` — carry a `removedLayer` for the same reason: it is what lets undo bring back the layer's own settings and identity rather than a fresh one.

  `SetLineWidthCommand`, `SetPointSizeCommand`, `SetBarWidthCommand`, `SetBarCornerRadiusCommand` and `ToggleLineFillCommand` are typed shortcuts over the stylesheet — each writes a `{ layer }`-scoped entry through `SetStyleRuleCommand`, merging into whatever else that layer's entry paints.

  **Styles**

  | Command | Params |
  | - | - |
  | `SetStyleRuleCommand` | `list`, `rule`, `index?` |

  `SetStyleRuleCommand` is the one command for paint (widths, dashes, opacities, colors beyond the palette). An entry is addressed **structurally**: the rule's own `select` and `when` say which entry it is, so the command writes whatever paints those elements under those conditions, whoever authored it. An entry's `id` travels inside the rule, through the builder's options bag — `style.geom.line({ strokeWidth: 3 }, { id: 'emphasis' })`.

  ```tsx theme={null}
  // insert or replace
  dispatch(
    new SetStyleRuleCommand({
      list: 'overrides',
      rule: style.geom.line({ strokeWidth: 3 }),
    })
  );

  // remove every entry at that address
  dispatch(
    new SetStyleRuleCommand({
      list: 'overrides',
      rule: { select: { target: 'geom', kind: 'line' }, declarations: null },
    })
  );
  ```

  Declarations at an address the list already holds replace that entry's, keeping its position in the cascade. `declarations: null` removes every entry at the address; an address the list doesn't hold is a no-op. See [Styling](/sdk-next/config/styling) for the targets and the vocabulary each one takes.

  **Scales**

  | Command | Params |
  | - | - |
  | `SetScaleDomainCommand` | `scaledAesthetic`, `domainMin`, `domainMax` |
  | `SetScalePaletteCommand` | `scaledAesthetic`, `palette` |
  | `SetScaleTransformCommand` | `scaledAesthetic`, `transform` |
  | `SetScaleReverseCommand` | `scaledAesthetic`, `reverse` |

  **Highlights**

  | Command | Params |
  | - | - |
  | `AddHighlightCommand` | `predicate`, `id?`, `scope?`, `layerId?`, `index?` |
  | `RemoveHighlightCommand` | `id` |

  **Annotations**

  | Command | Params |
  | - | - |
  | `AddAnnotationCommand` | `kind`, `annotation`, `index?` |
  | `RemoveAnnotationCommand` | `id` |
  | `UpdateAnnotationCommand` | `kind`, `id`, `patch` |
  | `MoveAnnotationCommand` | `id`, `translation` |

  Annotations are addressed by an `id` unique across every kind, so removing, updating and moving take the id alone. `UpdateAnnotationCommand` patches whichever fields you name — moving one and restyling it are the same command, so a drag and a colour picker share a path. `MoveAnnotationCommand` is the specialised drag: a translation in panel fractions, clamped inside the panel, relative rather than absolute, and reverted by the inverse patch since clamping isn't invertible.
</Accordion>

## Commands over the wire

Commands are registered by type, so they round-trip through JSON. `commandRegistry` turns a command into a `SerializedCommand` — `{ type, params, metadata }` — and a `SerializedCommand` back into a live instance. That is what lets an edit arrive from an agent, a collaborator, or a replayed session log and land on the same history as a click:

```tsx theme={null}
import { commandRegistry, type SerializedCommand } from '@graphysdk/viz-engine';

const payload: SerializedCommand = commandRegistry.serialize(command);
dispatch(commandRegistry.deserialize(payload));
```

`deserialize` throws on a type it doesn't know, so treat an unrecognised payload as a version mismatch rather than a no-op. This round trip is also why the `Params` types are the settled ones: a replayed command reproduces the same spec rather than minting a second id.

## Related

* [Provider & renderer](/sdk-next/rendering/provider-and-renderer) — `onSpecChange`, `handleRef`, `mode="editable"`
* [Serializable spec](/sdk-next/concepts/serializable-spec) — the spec commands edit, and how to persist it


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.