# VisDelta: start here for agents
Current package: visdelta@0.4.2. Copy this complete HTML into index.html,
serve it over HTTP (for example `npx serve .`), and open the printed URL.
```html
```
## Invariants and current boundaries
- Supply tidy rows. Declare each encoding; there is no implicit color field.
- Keep source-record identity (datumKey) separate from mark identity (key).
- where() changes membership; highlight() changes emphasis; focus() moves the
camera without filtering. Filters do not reset category colors or legends.
- Line fits inferred x/y domains to visible rows. Other charts have their own
domain rules: do not assume where() always refits every chart's axes.
- Explicit valid numeric/temporal position domains are not niced. Inferred
domains may be rounded. Write fixed domains only when the story needs them.
- Decide whether dates mean local calendar time or absolute instants before
parsing. The temporal axis uses local-time scaleTime; there is no utc option.
- Every frame couples marks and the scales/axes that explain them. Source data,
semantic changes, planned stages and rendered frames are distinct layers.
- transition progress is 0..1. sequence progress is 0..states.length-1:
1.5 means halfway through the second leg. A stage is not a state-change count.
- duration is per serial route stage, not per whole story. play() is not awaitable.
- Await transition()/sequence() before seeking; there is no additional ready.
- inspect() reports compiled SVG property tracks. It is not a mark snapshot.
state.resolve(), inspect(t), global strict/debug and bindScroll are unavailable.
- The optional Unit directionalEase profile is an exception to strict identical
forward/reverse frames. Follow the chart-specific contract instead of guessing.
## Verification workflow
1. Check tidy rows, field types and unique identities; preserve the source state.
2. Inspect state.rows(), state.toSpec(), delta(a, b).stateChanges and
await visualizationWarnings(state). These work without rendering for inline
or supplied named data; warnings do not fetch a remote dataset for you.
3. Await creation, seek exact endpoints, and inspect stageCount(), stageChanges(),
stageOperations() and await inspect() on pair controllers. A sequence exposes
inspect() for its active leg, not the pair's stage-count methods.
4. Check actual mark geometry, visibility and axes in a browser. Cover forward,
reverse, scroll/scrub, resizing and reduced motion. Compare endpoints with
independent static charts under the same dimensions, margins and theme.
5. Use screenshots for final appearance checks; a successful import or an SVG
element alone does not prove the chart is drawn correctly.
## Copy a tested story instead of inventing a scroll controller
- [Single-file scroll story](https://songhaifan.github.io/visdelta/templates/scroll-story.html)
- [Agent workflow](https://songhaifan.github.io/visdelta/docs/guides/agent-workflow/)
- [Automatic behaviors](https://songhaifan.github.io/visdelta/docs/guides/automatic-behaviors/)
- [Scroll template and iframe contract](https://songhaifan.github.io/visdelta/docs/guides/scroll-template/)
- [Progress model](https://songhaifan.github.io/visdelta/docs/concepts/progress/)
- [Troubleshooting](https://songhaifan.github.io/visdelta/docs/guides/troubleshooting/)
Repository template: examples/templates/scroll-story.html. Its inline data is
fictional, and its only network dependencies are pinned VisDelta JS and CSS.
A content-height iframe needs manual controls or a cooperating parent page;
child scroll code cannot observe cross-origin parent scrolling on its own.
## API and semantic references
- [Reference](https://songhaifan.github.io/visdelta/docs/api/)
- [Machine-readable page index](https://songhaifan.github.io/visdelta/llms.txt)
- [Complete current documentation](https://songhaifan.github.io/visdelta/llms-full.txt)
The repository's semantic constitution is docs/language-framework.md;
docs/reference.md records implemented behavior. Public entries include visdelta,
visdelta/core, visdelta/area, visdelta/bar, visdelta/line, visdelta/point,
visdelta/unit, visdelta/pie, visdelta/facet, visdelta/transition, visdelta/plugins,
visdelta/toolkit, visdelta/chart-style, visdelta/browser and visdelta/style.css.
Diagnostic API proposals are design documents, not available exports.
# Builders (/docs/api/builders)
> Signatures of every builder method. Behaviour is documented on the concept and chart pages linked from each section.
Every method returns a **new** state; none mutates the receiver. Option bags not
listed here are documented on the linked pages.
## Factories [#factories]
```ts
bar(data?): BarState // visdelta/bar
line(data?): LineState // visdelta/line
area(data?): AreaState // visdelta/area
point(data?): PointState // visdelta/point
unit(data?): UnitState // visdelta/unit
pie(data?): PieState // visdelta/pie
```
`data` is rows, `{ values }`, `{ url, type? }` or `{ name }`; see
[Data sources](/docs/concepts/data-and-transforms#data-sources).
## Shared methods [#shared-methods]
Available on every chart state. See [Chart states](/docs/concepts/chart-states)
and [where, highlight and focus](/docs/concepts/selection).
```ts
.data(source)
.x(field, options?)
.y(field, options?)
.channel(name, field, options?)
.color(valueOrField, options?)
.size(field, options?)
.key(fieldOrFields)
.datumKey(fieldOrFields)
.tooltip(fieldOrFields)
.sort(field, 'ascending' | 'descending')
.where(selector)
.highlight(selector, { opacity? })
.focus(selector)
.reset()
.axis(config)
.transition({ duration?, ease?, stagger? })
.repeat(field, { direction?, scales?, columns?, gap?, minWidth?, autoHeight?, minHeight? })
.rows(source?) // → tidy rows after transforms
.toSpec() // → detached view spec
```
Unit compact layout bindings and chained setters are equally supported:
`.layout('beeswarm', { x: 'length' })` and `.layout('beeswarm').x('length')`
write the same state. The last explicit binding replaces the complete channel;
omitted layout options preserve it. See [Unit](/docs/charts/unit).
### Chart captions [#chart-captions]
Add titles and subtitles in the surrounding page markup. VisDelta renders the
visualization and its legends; chart captions are not part of chart state.
## Bar [#bar]
See [Bar](/docs/charts/bar).
```ts
.flip(options?)
.breakdown(field?, { layout?, op?, label?, ... })
.rollup(groupby?, options?)
.segment(fieldOrConfig?, config?)
.layout('simple' | 'grouped' | 'stacked', options?)
```
## Line [#line]
See [Line](/docs/charts/line).
```ts
.curve(d3CurveName)
.connect('adjacent' | 'across')
.strokeWidth(number)
.pointSize(number)
.flip(options?)
.breakdown(field, options?)
.rollup({ op? })
```
## Area [#area]
See [Area](/docs/charts/area).
```ts
.baseline(number)
.labels('inside' | 'none')
.curve(d3AreaCurveName)
.connect('adjacent' | 'across')
.breakdown(field, options?)
.layout('stacked' | 'stream', { offset?, order? })
.rollup({ op? })
```
## Point [#point]
See [Point](/docs/charts/point).
```ts
.pointSize(number)
.radius(number)
.connector({ from: number, channel?: 'x' | 'y' })
.connector({ by: string | string[], orderBy?: string })
.flip(options?)
.rollup(groupby, options?)
.breakdown(detail?, options?)
```
## Unit [#unit]
See [Unit](/docs/charts/unit).
```ts
.value(field, { unitValue?, maxUnits? })
.group(field)
.size(number)
.layout('grid' | 'force' | 'bar' | 'beeswarm', { x?, y?, columns? })
.columns(number)
.radius(number) // compatibility alias for size(number)
```
## Pie [#pie]
See [Pie](/docs/charts/pie).
```ts
.value(field, options?)
.label(field)
.labels('inside' | 'outside' | 'none')
.innerRadius(fraction) // 0 ≤ fraction < 1
```
## Constants [#constants]
| Export | Entry | Contents |
| --------------------- | --------------- | -------------------------------------------- |
| `D3_CURVE_NAMES` | `visdelta/line` | Curve names accepted by `line().curve()` |
| `D3_AREA_CURVE_NAMES` | `visdelta/area` | Curve names accepted by `area().curve()` |
| `UNIT_LAYOUTS` | `visdelta/unit` | Layout names accepted by `unit().layout()` |
### Channel labels [#channel-labels]
Use `label` in channel options for a field display name, for example
`.x("hp", { label: "Horsepower" })`. An omitted label defaults to the field's
readable name. Compact Unit layout bindings also accept `label` in their channel
objects and remain equivalent to chained `.x()` / `.y()` declarations.
### Readable axis ticks [#readable-axis-ticks]
Default non-log numeric axes use plain decimals for small values and `k`, `m`,
and `b` for thousands, millions, and billions. Temporal scales use UTC calendar
ticks with labels matched to their interval (years, months, dates, or times).
Month and date labels include the year when the domain crosses a year boundary.
An explicit channel `format` overrides the default: numeric channels accept D3
number formats, and temporal channels accept D3 time formats such as `%b %Y`.
# Core (/docs/api/core)
> DOM-free inspection. delta(), declaration edits and planning, identity, lineage and camera helpers.
Everything on this page is exported from `visdelta/core` (and from the
`visdelta` root) and runs without a DOM.
## delta() [#delta]
```ts
function delta(from: Visualization, to: Visualization): DeltaResult;
```
Compares two states of the same chart type. Throws
`delta() requires two states of the same chart type.` otherwise.
```ts
import { delta } from 'visdelta/core';
const change = delta(base, next);
change.stateChanges; // normalized facts in seven categories
change.hasDelta('encoding.y'); // raw semantic check
change.edits; // lossless declaration edits
change.lineage; // present when both states have inline data
```
| Field | Meaning |
| ---------------------- | ------------------------------------------------------------------------ |
| `stateChanges` | `StateChange[]`; see [State changes](/docs/concepts/state-changes) |
| `deltas` | Raw semantic differences |
| `hasDelta(type, action?)` | Whether a raw difference of `type` exists |
| `edits` | `DeclarationEdit[]` describing exact serialized differences |
| `lineage` | `LineageCorrespondence`; see [Identity](/docs/concepts/identity#lineage) |
Grain entries in `stateChanges` include `previous` and `next` descriptions of
their `groupby` and `measures`. See [Types](/docs/api/types#statechange).
## visualizationSpec() [#visualizationspec]
```ts
function visualizationSpec(input: Visualization): ViewSpec;
```
Resolves a builder or plain spec into a detached view spec, with field types
inferred from inline data.
## Declaration edits [#declaration-edits]
`delta().edits` describes exact changes to the serialized declaration. Each edit
has a `path`, `previous` and `next`. Values distinguish absence from `null`,
`false` and zero. Arrays, including ordered transform pipelines, are atomic.
```ts
import { applyDeclarationEdits, declarationEdits } from 'visdelta/core';
const edits = declarationEdits(base.toSpec(), next.toSpec());
const target = applyDeclarationEdits(base.toSpec(), edits); // detached copy
```
`applyDeclarationEdits()` rejects edits whose `previous` value no longer
matches. Edits are declaration differences, not animation commands.
## Declaration planning [#declaration-planning]
```ts
function planDeclarationTransition(
from: ViewSpec,
to: ViewSpec,
codec: DeclarationOperationCodec,
options?: DeclarationPlanOptions,
): DeclarationPlan;
```
Searches for a route of complete, valid declarations in which each step inserts,
removes or updates exactly one canonical operation. A chart-owned
**codec** decides what the operations are: it decomposes a spec into operations,
evaluates operations back into a spec, normalizes and validates it.
```ts
import { bar, createBarGrainDeclarationOperationCodec } from 'visdelta/bar';
import { planDeclarationTransition } from 'visdelta/core';
const from = bar(data).x('state').y('population').rollup().toSpec();
const to = bar(data).x('state').y('population').breakdown('age').toSpec();
const plan = planDeclarationTransition(from, to, createBarGrainDeclarationOperationCodec());
plan.status; // 'planned'
plan.stages.map((stage) => stage.operation.id); // ['__visdeltaBarGrain/grouping']
```
| `status` | Meaning |
| -------------- | -------------------------------------------------------------- |
| `planned` | `stages` holds a validated route, one operation per stage |
| `direct` | The canonical declarations are equivalent; no stage is needed |
| `unsupported` | No valid route, or a state outside the codec; `reason` explains |
| `search-limit` | The search exceeded `maxSearchStates` (default 2048) |
The search is bounded and deterministic. Every intermediate must pass the
codec's validator and round-trip through its normalizer, and source data and
datum identity are fixed boundaries. Unsupported results have an empty `stages`
array; at runtime the chart then uses its own route or the ordinary direct
transition. Built-in charts use this planner internally. Pie has no codec.
A codec may also constrain individual route edges with
`validateStep(from, to, endpoints)`. See the
[language framework](https://github.com/SonghaiFan/visdelta/blob/main/docs/language-framework.md#transition-contracts)
for the complete contract.
## Identity [#identity]
```ts
resolveMarkIdentity(...) // explicit .key() first, then the chart's default mark key
correspondMarks(...) // one-to-one updates, enter and exit
markKeyValue(...) // the string value of a mark key
```
These serve chart plugins. Lineage (below) describes split, merge and
reaggregation separately.
## Lineage [#lineage]
```ts
compileLineage(rows, transforms, { key?, grain? })
buildGroupingTree(table)
correspondLineage(from, to, { fromField?, toField? })
viewLineageCorrespondence(...)
lineageMarkKey(...)
```
At the low level, `key` identifies immutable source records and `grain`
identifies marks in one compiled view. Filtering, sorting, limiting, binning,
time units and folding preserve lineage. `sum` and `count` produce additive
contribution records. `mean`, `median` and other non-additive aggregates keep
provenance but mark arithmetic decomposition as unsafe.
## Data types [#data-types]
```ts
detectDataTypes(rows) // field → 'quantitative' | 'temporal' | 'nominal'
resolveEncodingTypes(...) // fill missing channel types from data
```
## Camera [#camera]
`fitCamera`, `focusCamera`, `cameraPosition`, `cameraScale`, `cameraSize`,
`pointBounds` and `rectBounds` implement the aspect-preserving camera used by
`.focus()`. Chart plugins supply mark bounds; Core fits one pan and zoom.
# API overview (/docs/api)
> Entry points, what each exports, and where each part of the API is documented.
VisDelta's API has three parts: **builders** that create immutable chart
states, **`delta()`** to inspect what changed, and **`transition()`** and
**`sequence()`** to render a seekable path.
The workbench above is a real controller. Seek in either direction, then switch
the inspector to **Delta** for `change.delta` or **Spec** for the resolved
endpoints.
```ts
import { bar } from 'visdelta/bar'; // builders
import { delta } from 'visdelta/core'; // inspection
import { transition, sequence } from 'visdelta/transition'; // rendering
import 'visdelta/style.css';
```
JSON transport uses `parseSpec` and `stringifySpec` from `visdelta/core` or
`visdelta`. See [JSON specs](/docs/guides/json) for validation boundaries and
using the same `ViewSpec` with builders, playback and React.
## Reference pages [#reference-pages]
## Entry points [#entry-points]
| Entry | Main exports |
| ---------------------- | -------------------------------------------------------------------------------------------------- |
| `visdelta` | Every builder, `delta`, `transition`, `sequence`, `visualizationWarnings`, `smallMultiples`, chart styles, `defineChartType`, `registerChartModule` and the core helpers |
| `visdelta/bar` | `bar`, `BarState`, `barModule`, `createBarGrainDeclarationOperationCodec` |
| `visdelta/line` | `line`, `LineState`, `lineModule`, `D3_CURVE_NAMES` |
| `visdelta/area` | `area`, `AreaState`, `areaModule`, `D3_AREA_CURVE_NAMES` |
| `visdelta/point` | `point`, `PointState`, `pointModule` |
| `visdelta/unit` | `unit`, `UnitState`, `unitModule`, `UNIT_LAYOUTS` |
| `visdelta/pie` | `pie`, `PieState`, `pieModule` |
| `visdelta/transition` | `transition`, `sequence` |
| `visdelta/react` | Optional React `Chart`, `ChartProps` and `ChartController` |
| `visdelta/core` | `delta`, `visualizationSpec`, declaration edits and planning, identity, lineage and camera helpers |
| `visdelta/facet` | `smallMultiples` |
| `visdelta/chart-style` | `defineChartStyle`, `d3ChartStyle`, `paperChartStyle`, `darkChartStyle`, `editorialChartStyle`, `chartStylePresets` |
| `visdelta/plugins` | `defineChartType`, `defineChartModule`, `ChartState`, `registerChartModule`, `motion`, `directionalEase` and SDK types |
| `visdelta/toolkit` | Optional scale, timing, geometry, topology, matching and policy helpers for plugins |
| `visdelta/browser` | The browser-global adapter used by the `VisDelta` global bundle |
| `visdelta/style.css` | The required base stylesheet |
| `visdelta/themes/*.css`| `default.css`, `dark.css` and `paper.css` token themes |
Importing an entry never registers chart types globally. A builder carries its
own lazily loaded module; registration is needed only for plain JSON specs.
## TypeScript [#typescript]
Declarations ship with the package. Commonly used types:
```ts
import type { Visualization, VisualizationTransition, VisualizationSequence, TransitionOptions, PlayOptions } from 'visdelta/transition';
import type { DeltaResult, StateChange, StateChangeCategory } from 'visdelta/core';
import type { VisualizationWarning } from 'visdelta';
import type { ChartStyleModule } from 'visdelta/chart-style';
```
## Current boundaries [#current-boundaries]
- Endpoint chart types must match; Bar-to-Line is rejected.
- VisDelta depends on the `d3-*` modules it renders with; they are not injected
or configurable.
- Declared transforms execute within VisDelta; data cleaning stays outside it.
- Controls call `progress()` from outside the library.
## Persistent views [#persistent-views]
Use `render(target, spec, options)` from `visdelta` to mount JSON strings, plain
specs or builders. The returned view provides `update`, `render`, `transition`,
`snapshot`, `resize` and `destroy`. Updates use a latest-pending policy by
default. See [JSON specs](/docs/guides/json) for scheduling, lifecycle and scoped
module examples.
`view.edit(message, options?)` accepts a fluent JSON batch with `requestId`,
`baseRevision` and `edits: [{ method, args }]`. It returns the same completion
result as `update()`. The Bar adapter supports a conservative method subset;
see [small agent edits](/docs/guides/json#small-edits-from-an-agent) for supported
methods, conflict handling and retry limits.
# sequence() (/docs/api/sequence)
> Join several authored states into one seekable timeline of adjacent transitions.
```ts
import { sequence } from 'visdelta/transition';
function sequence(
states: readonly Visualization[],
options: SequenceOptions,
): Promise;
```
`sequence()` prepares a transition for every adjacent pair (`A → B`, `B → C`, …)
and hands off between them at shared endpoints, so the chart host stays mounted
and never shows an empty frame between legs.
Each leg's runtime reads fonts and theme tokens from its actual chart root,
including after that root moves into the visible host. Resizing and reverse
handoffs keep that scope. Preparation uses hidden mounts under `document.body`;
make shared theme tokens available on the document root before initialization.
```ts
const story = await sequence([revenue, profit, ranked, revenue], {
target: '#chart',
});
story.progress(1.5); // halfway through profit → ranked
story.play({ duration: 850 }); // from the current position to the end
```
It requires at least two states, all of the same chart type. `SequenceOptions`
accepts the same fields as [`TransitionOptions`](/docs/api/transition#transitionoptions).
## VisualizationSequence [#visualizationsequence]
### VisualizationSequence
| Prop | Type | Description |
| ---------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `states` | `array` | Immutable states in timeline order. |
| `value` | `number` | Current timeline position. Integers identify authored states. |
| `progress` | `function` | Display a frame synchronously. Values between integers seek within a leg. |
| `play` | `function` | Play across adjacent legs. Duration applies to each serial route stage. |
| `pause` | `function` | Stop owned playback at the current frame. |
| `inspect` | `function` | Inspect the currently mounted leg. Stage positions are local to that leg. |
| `resize` | `function` | Recompile the active leg for the container's current size/theme. `width` and `height` change the requested canvas size for every leg, as in `TransitionOptions`; a dimension left out keeps its current request. |
| `destroy` | `function` | Stop playback and remove every prepared leg. The controller cannot be used afterwards. |
`value` runs from `0` to `states.length - 1`. Integer values identify authored
states exactly, even when a leg contains generated intermediate stages.
## SequencePlayOptions [#sequenceplayoptions]
### SequencePlayOptions
| Prop | Type | Description |
| ----------- | -------- | -------------------------------------------------------------------------------------------------- |
| `duration?` | `number` | Milliseconds for each serial route stage. Defaults to 800. |
| `from?` | `number` | Timeline position in leg units: 0 is the first state, 1 the second. Defaults to the current value. |
| `to?` | `number` | Timeline position in leg units: 0 is the first state, 1 the second. Defaults to the last state. |
Unlike a pair controller, a sequence's `play()` starts from the current value
by default.
## Resizing [#resizing]
A sequence takes the same `width` and `height` as `transition()`: a number of
pixels, or `"auto"` to follow the container. A dimension set to `"auto"` re-fits
the leg on screen when the container changes. Call `resize()` after a theme
change, or to change the requested size for every leg:
```ts
story.resize({ width: 'auto', height: 'auto' });
```
`resize()` recompiles the active leg and keeps the current position; the other
legs pick the new size up when they are shown. A dimension left out keeps its
request.
See [Multi-step stories](/docs/guides/stories) for scroll-driven usage.
# transition() (/docs/api/transition)
> Create one seekable transition between two states of the same chart type.
```ts
import { transition } from 'visdelta/transition';
function transition(
from: Visualization,
to: Visualization,
options: TransitionOptions,
): Promise;
```
`transition()` resolves and loads both states' data, computes their
[`delta()`](/docs/api/core#delta), loads the chart's renderer and mounts a
seekable route into `options.target`. If setup fails, the target's previous
contents are restored.
```ts
const change = await transition(from, to, {
target: '#chart',
data: { sales: rows },
height: 420,
chartStyle: paperChartStyle,
});
```
`from` and `to` can be builder states or plain specs; plain specs need a
[registered chart module](/docs/concepts/chart-states#plain-specs).
## TransitionOptions [#transitionoptions]
### TransitionOptions
| Prop | Type | Description |
| ------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data?` | `Record` | Named sources for visualizations that use .data("name"). |
| `width?` | `union` | Outer width of the canvas (the SVG), margins included, as in Observable Plot. A number is a fixed width in pixels: a container narrower than that overflows instead of the chart shrinking. `"auto"` makes the canvas exactly the container's content width and re-fits when it changes; the figure's own border gives way on that axis. Omitted: the container's width, inside the figure's own border. |
| `height?` | `union` | Outer height of the canvas (the SVG), margins included. A number is a fixed height in pixels and overrides the chart's default. `"auto"` makes the canvas exactly the container's content height and re-fits when it changes; the figure's border gives way. The container needs a height of its own (a fixed height, a flex or grid track), otherwise the chart keeps its default height. Omitted: the chart's default height. |
| `target` | `Target` | Where the chart renders. Required: the library never guesses a host element. |
| `chartStyle?` | `object` | Structural chart presentation; CSS can target its generated style class. |
### Canvas size [#canvas-size]
`width` and `height` are the outer size of the canvas (the SVG), margins included,
as in Observable Plot. Each takes a number of CSS pixels or `"auto"`.
| Option | Omitted | A number | `"auto"` |
| -------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `width` | The container's width, inside the figure's own border, re-fit on change. | Exactly this wide. A narrower container overflows; nothing is clipped. | Exactly the container's content width, re-fit when it changes. |
| `height` | The chart's own height (an authored `height`, else 500). | Exactly this tall; overrides the chart's own height. | Exactly the container's content height, re-fit when it changes. The container needs a height of its own. |
```ts
// Fixed: always 640 × 360, whatever the container is.
await transition(from, to, { target: '#chart', width: 640, height: 360 });
// Fit: follow the container, e.g. a resizable or floating panel with a height.
await transition(from, to, { target: '#panel', width: 'auto', height: 'auto' });
```
A chart is wrapped in a figure that a theme may give a border. A number is always
the canvas itself, so that chrome sits outside it. A top
legend also sits outside the canvas, above it, and never counts toward `width` or
`height`. For `"auto"` the figure's border on that axis gives way and the legend
takes its room from the container, so the canvas fills exactly what
is left and nothing overflows the container. The legend is a second
`` that exists only while there is a legend, after the
canvas in the document, so `querySelector('svg')` is still the canvas. A
transition opens or closes the legend space with playback progress. In Fit
mode the canvas uses the remaining height.
A style with `legendPosition: 'right'` keeps its legend inside the canvas.
`height: 'auto'` reads the room the container gives the chart. A container with
no height of its own (its height comes from its content) gives none, so the
chart keeps its own height instead of growing in a loop. Give the container a
fixed height, or make it a flex or grid track. An explicit `height` also
releases the view's default `min-height`, so it is the canvas height rather
than a floor under it.
`target` is a CSS selector or an element. The transition owns its contents until
`destroy()`.
## VisualizationTransition [#visualizationtransition]
The returned controller.
### VisualizationTransition
| Prop | Type | Description |
| ----------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `from` | `object` | Defensive copy of the resolved start state. |
| `to` | `object` | Defensive copy of the resolved end state. |
| `delta` | `object` | Semantic difference between the resolved endpoints, as returned by delta(). |
| `view` | `object` | The mounted chart-view element. |
| `value` | `number` | Current progress from 0 to 1. |
| `progress` | `function` | Synchronously display a frame. Direction is inferred from the previous value. |
| `play` | `function` | Plays from 0 to 1 by default. Returns this controller for chaining. |
| `pause` | `function` | Stop owned playback at the current frame. |
| `resize` | `function` | Recompile at the container's current size/theme, retaining progress and data. `width` and `height` change the requested canvas size as in `TransitionOptions`; a dimension left out keeps its current request. |
| `stageCount` | `function` | Serial route stages planned for this pair (at least 1). Stages split progress equally and `play({ duration })` gives each one `duration` ms, so a full play lasts `stageCount() × duration`. |
| `stageChanges` | `function` | Semantic changes for each actual route stage, in authored playback order. |
| `stageOperations` | `function` | Exact planner operation per route stage; undefined for direct or chart-refined stages. |
| `inspect` | `function` | Read compiled SVG property tracks without pausing or seeking playback. |
| `destroy` | `function` | Stop playback and remove the mounted transition. The controller cannot be used afterwards. |
| Method | Returns | Notes |
| -------------------------- | ----------- | ------------------------------------------------------------------------- |
| `progress(value)` | controller | Pauses playback and draws one frame. Finite values clamp to `[0, 1]`. |
| `play(options?)` | controller | Animates from `from` to `to`; see `PlayOptions`. |
| `pause()` | controller | Stops playback at the current frame. |
| `resize(options?)` | controller | Recompiles at the container's size and current theme, keeping progress. `{ width, height }` change the requested canvas size; a dimension left out keeps its request. |
| `stageCount()` | `number` | Serial route stages VisDelta planned for the pair (at least 1). They split progress equally, and `play({ duration })` gives each stage `duration` ms, so a full play lasts `stageCount() × duration`. |
| `destroy()` | `void` | Stops playback, disconnects observers and removes the chart. |
Pair controllers observe their container's width and resize automatically.
## PlayOptions [#playoptions]
### PlayOptions
| Prop | Type | Description |
| ----------- | -------- | --------------------------------------------------------- |
| `duration?` | `number` | Milliseconds for one serial route stage. Defaults to 800. |
| `from?` | `number` | Start progress, clamped to [0, 1]. Defaults to 0. |
| `to?` | `number` | End progress, clamped to [0, 1]. Defaults to 1. |
```ts
change.play(); // 0 → 1
change.play({ from: 1, to: 0 }); // reverse
change.play({ from: change.value }); // resume
```
## Errors [#errors]
| Message | Cause |
| ---------------------------------------------------- | ---------------------------------------- |
| `transition() requires two states of the same chart type.` | Endpoints from different chart families |
| `VisDelta needs a target: …` | `options.target` is missing |
| `VisDelta target not found: …` | The selector matched no element |
| `transition(): missing dataset "…"` | A named source is not in `options.data` |
| `progress must be a finite number.` | `progress()` received `NaN` or `Infinity` |
| `duration must be a finite non-negative number.` | Invalid `play({ duration })` |
| `This transition has been destroyed.` | The controller was used after `destroy()` |
See [Troubleshooting](/docs/guides/troubleshooting) for fixes.
## Inspect compiled motion [#inspect-compiled-motion]
`await change.inspect()` returns the compiled SVG phases and their recorded
property tracks, without pausing playback or changing its position. Each phase
has normalized `start`, `end`, and `properties`; each property identifies its
DOM `node`, `name`, `kind`, scheduled interval within the phase, and `from` / `to`
values at the **phase boundaries**. These are not necessarily the endpoints of
a delayed or chained property interval.
`await sequence.inspect()` reads the active leg. Refresh after resize or theme
changes. Borrowed nodes must not be mutated; reconstruction, resize and destroy
invalidate them. Custom tweens are explicitly opaque. Immediate DOM writes
and insertions/removals are outside this
recorded SVG report. The playground's **Inspect stages** control displays these
tracks alongside the timeline, including the coverage limits.
When height is `"auto"` (Fit), a constrained container hides the legend to
preserve the main canvas. The canvas budget is half the available width, capped
at 200 pixels. The legend returns when space allows; fixed pixel heights retain it.
`change.stageChanges()` reports the semantic changes between each actual pair of
planned intermediate states, in authored playback order. The playground uses
these to label the stage strip directly. It does not split the full pair's delta
into guessed phases.
`transition.stageOperations()` returns the exact planner operation for each route stage, in authored playback order. Each entry carries the slot path, action, and previous/next declaration values. Direct and chart-refined stages return `undefined`; semantic categories remain available through `stageChanges()`. These operations do not prove visible motion or alter stage timing.
Each inspected property includes `ancestry`, an array of `{ tag, classes }` from
its SVG root to the animated element. It is captured with the phase DOM restored,
before seeking can detach entering or exiting nodes. This snapshot remains readable
after inspection restores the visible frame. Repeated elements can share an ancestry;
`selector` retains the phase-specific positional path. An empty ancestry means no
complete SVG path was available.
# Types (/docs/api/types)
> The public types you are most likely to read or annotate, generated from the library's TypeScript source.
The tables on this page are generated at build time from the TypeScript source,
so they always match the published declarations.
## StateChange [#statechange]
`delta().stateChanges` is a `StateChange[]`. It is a discriminated union on
`category`:
```ts
type StateChange =
| { category: 'data'; action: 'add' | 'remove' | 'update' }
| {
category: 'grain';
action: 'split' | 'merge' | 'reaggregate' | 'change-reducer';
previous: GrainDescription;
next: GrainDescription;
}
| { category: 'encoding'; action: 'bind' | 'unbind' | 'remap'; channel: string }
| { category: 'coordinate'; action: 'rescale' | 'reorient'; channel?: 'x' | 'y' }
| { category: 'layout'; action: 'reorder' | 'rearrange' | 'reconnect' }
| { category: 'attention'; action: 'enter' | 'exit' | 'shift'; target: 'focus' | 'highlight' }
| { category: 'appearance'; action: 'restyle' | 'reshape'; property: string };
```
Narrow on `category` to read the extra fields:
```ts
for (const change of delta(from, to).stateChanges) {
if (change.category === 'encoding') console.log(change.channel);
if (change.category === 'grain') console.log(change.previous.groupby, '→', change.next.groupby);
}
```
### GrainDescription [#graindescription]
### GrainDescription
| Prop | Type | Description |
| ------------ | -------- | -------------------------------------- |
| `groupby` | `array` | |
| `measures` | `array` | |
| `unitValue?` | `number` | Quantity represented by one Unit mark. |
### GrainMeasure [#grainmeasure]
### GrainMeasure
| Prop | Type | Description |
| ------- | -------- | ----------- |
| `op` | `string` | |
| `field` | `union` | |
| `as` | `union` | |
## VisualizationWarning [#visualizationwarning]
Returned by `visualizationWarnings()` and dispatched in `visdelta:warnings`
events. See [Visualization warnings](/docs/guides/warnings).
### VisualizationWarning
Advisory only: never changes state, validity or transition planning.
| Prop | Type | Description |
| ------------ | ----------------------- | ----------------------------------------------- |
| `code` | `string` | |
| `severity` | `"warning"` | |
| `message` | `string` | |
| `suggestion` | `string` | |
| `source?` | `string` | Optional design-guide source for this advisory. |
| `field?` | `string` | |
| `evidence` | `Record` | |
## ChannelSpec [#channelspec]
The options accepted by `.x()`, `.y()`, `.color()`, `.size()` and `.channel()`.
### ChannelSpec
| Prop | Type | Description |
| ------------ | ------------------------- | ----------------------------------------------------------------------------- |
| `field?` | `string` | |
| `type?` | `union` | |
| `label?` | `string` | Display name for this channel. |
| `title?` | `` | Channel display names use label. |
| `format?` | `string` | D3 number format for quantitative axes, or UTC time format for temporal axes. |
| `aggregate?` | `union` | |
| `timeUnit?` | `string` | |
| `value?` | `union` | |
| `sort?` | `union` | |
| `domain?` | `array` | |
| `scheme?` | `string` | Named d3-scale-chromatic scheme, for example "Tableau10" or "Viridis". |
| `scale?` | `Record` | |
| `bin?` | `union` | |
## FacetLayout [#facetlayout]
Options for `.repeat()` and `smallMultiples()`. See [Small multiples](/docs/charts/repeat).
### FacetLayout
| Prop | Type | Description |
| ------------- | -------- | ----------- |
| `columns?` | `number` | |
| `gap?` | `number` | |
| `minWidth?` | `number` | |
| `direction?` | `union` | |
| `scales?` | `union` | |
| `autoHeight?` | `union` | |
| `minHeight?` | `number` | |
## ChartStyleDefinition [#chartstyledefinition]
The input to `defineChartStyle()`. See [Styling and themes](/docs/guides/styling).
### ChartStyleDefinition
| Prop | Type | Description |
| ----------------- | ----------------- | ----------- |
| `key` | `string` | |
| `tickSpacing?` | `Partial` | |
| `edgeTitleInset?` | `Partial` | |
| `legendInset?` | `Partial` | |
| `legendPosition?` | `union` | |
| `plot?` | `object` | |
| `axisTitle?` | `function` | |
## DeclarationPlanOptions [#declarationplanoptions]
### DeclarationPlanOptions
| Prop | Type | Description |
| ------------------ | -------- | ----------------------------------------------------------- |
| `maxSearchStates?` | `number` | Bound route search by distinct complete declaration states. |
# Area (/docs/charts/area)
> Magnitude and composition over an ordered dimension, with stacked layers, streamgraphs and direct labels.
```ts
import { area } from 'visdelta/area';
```
`area()` shows magnitude over an ordered dimension and supports stacked detail.
## Methods [#methods]
| Method | Meaning |
| -------------------------------------------------- | ------------------------------------------------------ |
| `.baseline(number)` | Set the ordinary area baseline |
| `.curve(d3AreaCurveName)` | Use an exact D3 curve export supported by `d3.area()` |
| `.connect('adjacent' \| 'across')` | Preserve gaps, or connect across filtered observations |
| `.breakdown(field, options?)` | Split a total into stacked layers |
| `.rollup(options?)` | Combine stacked layers into a total |
| `.layout('stacked' \| 'stream', { offset?, order? })` | Use a fixed stack, or configure D3 stream offsets and orders |
| `.labels('inside' \| 'none')` | Label stacked layers inside their area |
The supported curve names are exported as `D3_AREA_CURVE_NAMES`. Unlike Line,
Area does not accept `curveBundle`.
## Stacking and streams [#stacking-and-streams]
Ordinary stacked areas place series with larger whole-domain totals nearer the
baseline. Ties keep first-appearance order, and one fixed order applies across
all x positions. When colour maps the stacked series with no explicit domain or
palette, the legend and default colours follow the same ranking.
Stream layouts default to the `wiggle` offset and `insideOut` order. The
`diverging` offset supports signed values. To keep input layer order with a
fixed baseline, use:
```ts
detailed.layout('stream', { offset: 'none', order: 'none' });
```
## Direct labels [#direct-labels]
`.labels('inside')` identifies stacked and stream layers by their series value.
The renderer measures text, searches observation centres for a position where a
padded label fits inside the layer and picks black or white text for contrast.
Labels that cannot fit are omitted, and the colour legend still identifies those
layers. Custom label text, outside placement and callouts are not supported.
```ts
area(rows).x('year').y('revenue')
.breakdown('businessLine')
.color('businessLine')
.labels('inside');
```
## Transition behaviour [#transition-behaviour]
Area owns `y0`/`y1` geometry. A divider line appears only during a detail
transition; endpoints have no default border.
## Try it [#try-it]
Edit unemployment layers that settle into a stream in the
[playground](/playground#stream).
# Bar (/docs/charts/bar)
> Categorical comparison on a shared quantitative baseline, with stacked, grouped and flipped layouts.
```ts
import { bar } from 'visdelta/bar';
```
`bar()` compares categories through a shared quantitative baseline.
## Methods [#methods]
| Method | Meaning |
| --------------------------------------------------- | --------------------------------------------- |
| `.flip(options?)` | Switch between vertical and horizontal bars |
| `.breakdown(field?, options?)` | Split totals into detail segments |
| `.rollup(groupby?, options?)` | Combine detail into totals |
| `.segment(fieldOrConfig?, config?)` | Configure segmented bars explicitly |
| `.layout('simple' \| 'grouped' \| 'stacked', options?)` | Change the bar layout |
`.breakdown()` defaults to a stacked layout and the `sum` operator. It declares
detail, not colour; add `.color(field)` when the segments should be coloured.
```ts
const detailed = bar(sales)
.x('region')
.y('revenue')
.key(['region', 'channel'])
.breakdown('channel')
.color('channel');
const totals = detailed.rollup();
const grouped = detailed.layout('grouped');
const horizontal = detailed.flip();
```
## Transition behaviour [#transition-behaviour]
- **Baselines are preserved.** Entering and exiting bars grow from and shrink to
the baseline.
- **Additive splits stay readable.** For `sum` and `count`, a total splitting
into grouped detail passes through a stacked stage that preserves the total.
The merge reuses the same frames backward.
- **Reaggregation is composed.** Regrouping the same records, for example from
totals by year to totals by location, routes through their common grain.
- **Focus and sorts are staged.** A focused detail merges before the focus
changes; detail merges before a sort by total.
- **Orientation changes are coordinate transitions.** `.flip()` uses Bar's
direct coordinate route. A flip combined with a Grain change is not split into
planner stages.
Bar marks always use square corners, in every layout and throughout
transitions.
## Examples [#examples]
Edit a Bar chart yourself in the playground: [sort](/playground#sort),
[split](/playground#split) or [regroup](/playground#regroup).
## Limits [#limits]
Bar's declaration planner supports one aggregate measure over inline rows, with
a category and optional detail field. Wide folds, multiple aggregates,
unresolved data, mismatched aliases or mark keys, and reorientation combined
with a Grain change are outside it. Those pairs keep Bar's chart-authored route
or its ordinary direct transition.
# Choosing a chart (/docs/charts)
> Six peer chart modules, each answering one kind of question and owning its own transition rules.
Each chart module is an independent peer. Its builder adds chart-specific
methods to the [shared grammar](/docs/concepts/chart-states#shared-grammar), and
the module decides how its marks move. Both endpoints of a transition must use
the same chart type.
| Chart | Question it answers | Chart-owned rule |
| ----------------------------- | -------------------------------------------- | -------------------------------------------------------- |
| [Bar](/docs/charts/bar) | How do categories compare? | Bars share a quantitative baseline |
| [Line](/docs/charts/line) | How does a value change in order? | Keys identify points inside paths; gaps stay honest |
| [Area](/docs/charts/area) | How does magnitude or composition change? | Explicit baseline and stacked boundaries |
| [Point](/docs/charts/point) | How do two quantities relate? | Compact Cartesian axes; entering points grow in place |
| [Unit](/docs/charts/unit) | How many countable items are there? | Grid, force, unit-bar and beeswarm layouts; shortest travel |
| [Pie](/docs/charts/pie) | What share of one total does each part take? | Keyed angular motion; direct transitions only |
Every chart can also be partitioned into [small multiples](/docs/charts/repeat)
with `.repeat()`.
## Import only what you use [#import-only-what-you-use]
```ts
import { line } from 'visdelta/line';
const chart = line(rows)
.x('date', { type: 'temporal' })
.y('close');
```
A builder carries its chart module, so `transition()` loads only the renderer
it needs.
## Design warnings [#design-warnings]
Each chart module can also report advisory design warnings, such as too many
stacked columns or a truncated bar baseline. They never change what renders.
See [Visualization warnings](/docs/guides/warnings).
# Line (/docs/charts/line)
> Ordered trends with keyed observations inside the path and honest gaps by default.
```ts
import { line } from 'visdelta/line';
```
`line()` shows an ordered trend. `.key()` identifies observations **inside** the
SVG path, not only as separate point elements, so a single observation can enter
or leave a line.
## Methods [#methods]
| Method | Meaning |
| ----------------------------------- | ----------------------------------------------- |
| `.curve(d3CurveName)` | Use any exact D3 line-curve export |
| `.connect('adjacent' \| 'across')` | Keep honest gaps, or connect across them |
| `.strokeWidth(number)` | Set the line width |
| `.pointSize(number)` | Set the point radius |
| `.flip(options?)` | Swap the position mappings in an explicit order |
| `.breakdown(field, options?)` | Split one line into series |
| `.rollup(options?)` | Combine series into one line |
Curve names are exact D3 exports such as `curveLinear`, `curveStep` and
`curveMonotoneX`. Line also accepts `curveBundle`. The full list is exported as
`D3_CURVE_NAMES`.
```ts
const prices = line(rows)
.x('date', { type: 'temporal' })
.y('close')
.key(['date', 'ticker'])
.breakdown('ticker')
.color('ticker');
const average = prices.rollup({ op: 'mean' });
```
## Transition behaviour [#transition-behaviour]
- **Adding an observation** extends the line to it before its point appears.
- **Removing or filtering** runs the same frames backward: the point leaves
before the segment retracts.
- **Gaps stay honest.** With the default `connect('adjacent')`, filtered
observations leave a visible gap. `connect('across')` joins across them.
- **Split and merge** zip series out of, and back into, the summary line.
## Limits [#limits]
The declaration planner treats raw y remapping as an Encoding operation and a
supported summary (source field, reducer and output alias) as one Grain
operation. Raw-to-summary pairs keep Line's native route when eligible. Complex
pipelines remain outside the planner and use the ordinary transition.
## Try it [#try-it]
Edit an average line that unzips into AAPL and GOOG in the
[playground](/playground#zip).
# Pie (/docs/charts/pie)
> Nonnegative parts of one positive total, with keyed angular motion and direct transitions.
```ts
import { pie } from 'visdelta/pie';
```
`pie()` shows nonnegative parts of one positive total. Every row is one keyed
slice.
## Methods [#methods]
| Method | Meaning |
| ----------------------------------------- | ---------------------------------------------------- |
| `.value(field, options?)` | Bind the slice magnitude (`encoding.angle`) |
| `.label(field)` | Bind the slice label (`encoding.label`) |
| `.labels('inside' \| 'outside' \| 'none')` | Place labels; `outside` is the default |
| `.innerRadius(fraction)` | Create a donut; the fraction must be in `[0, 1)` |
Input order determines clockwise slice order, starting at the top. `.sort()`
changes that order explicitly. `.where()`, `.highlight()`, colour and tooltips
are supported.
Outside labels have leader lines and vertical spacing. Names that exceed the
available gutter are shortened, and the full values stay in the tooltip.
## Validation [#validation]
Pie validates its input instead of guessing:
- Repeated categories are rejected; aggregate them before building the chart.
- Negative, missing and non-finite values, and a non-positive total, are
rejected.
- Zero-valued components have no visible sector.
- Cartesian position channels, axes and `focus()` are rejected.
## Transition behaviour [#transition-behaviour]
Pie uses the direct runtime transition with recorded angular and radial motion
and keyed enter and exit. Slices move as wedges rather than as interpolated SVG
path strings.
It has no declaration-operation planner, automatic intermediate states, focus
camera or cross-chart geometry bridge. A Pie state cannot transition into
another chart type.
# Point (/docs/charts/point)
> Scatterplots, dot plots, dumbbells and lollipops on compact Cartesian axes.
```ts
import { point } from 'visdelta/point';
```
`point()` places observations on compact Cartesian axes. It covers scatterplots,
dot plots, dumbbells and lollipops without changing datum identity.
## Methods [#methods]
| Method | Meaning |
| ---------------------------------------- | ----------------------------------------------- |
| `.pointSize(number)`, `.radius(number)` | Set a constant point radius |
| `.connector({ from, channel? })` | Draw a lollipop stem from a quantitative baseline |
| `.connector({ by, orderBy? })` | Connect adjacent dots within each ordered group |
| `.flip(options?)` | Swap x and y in an explicit order |
| `.rollup(groupby, options?)` | Combine details into summary points |
| `.breakdown(detail?, options?)` | Reveal detailed points |
## Connectors [#connectors]
Connectors are derived geometry drawn behind the dots. They never change the
data grain or datum identity.
```ts
// Lollipops: stems from a constant baseline.
point(rows).x('region').y('sales').connector({ from: 0 });
// Dumbbells: connect each country's dots in year order.
point(rows)
.x('value')
.y('country')
.color('year')
.connector({ by: 'country', orderBy: 'year' });
```
A baseline connector infers its channel when exactly one positional channel is
quantitative; set `channel` when both are.
## Summaries [#summaries]
`.rollup()` combines detailed points into summaries with explicit reducers for
each position, and optionally a count mapped to size:
```ts
const summary = detailed.rollup('cyl', {
key: 'cyl',
x: { op: 'mean' },
y: { op: 'mean' },
size: { op: 'count', range: [10, 24] },
});
```
## Transition behaviour [#transition-behaviour]
An ordinary entering point grows at its target position. Summary and detail
motion may use a declared parent position, because that movement carries
meaning.
Summary/detail transitions use gooey blending by default. Points in the same
group share a silhouette while they merge or separate; completed endpoints
return to crisp circles. Forward, reverse and scrubbed playback share the same
geometry.
## Try it [#try-it]
Edit the motor-cars scatter in the playground: [summarize](/playground#summarize)
or [focus](/playground#focus).
# Small multiples (/docs/charts/repeat)
> Partition any chart into panels with .repeat(), in one SVG with one transition controller.
`.repeat(field, options)` partitions a chart into small multiples. The x and y
bindings keep their meaning, each panel gets its own coordinate frame, and one
transition controller drives the whole chart.
```ts
const repeated = chart.repeat('component', {
direction: 'y',
scales: 'shared',
});
```
`smallMultiples(chart, field, options)` from `visdelta/facet` is the functional
form of the same operation.
## Options [#options]
| Option | Default | Meaning |
| ------------ | ---------- | ----------------------------------------------------------------- |
| `direction` | `'grid'` | `'y'` places panels in rows, `'x'` in columns, `'grid'` responsively |
| `scales` | `'shared'` | `'independent'` derives numeric domains per panel |
| `columns` | `2` | Maximum columns for a grid |
| `gap` | `24` | Pixels between panels |
| `minWidth` | `240` | Minimum panel width; narrow containers reduce the column count |
| `autoHeight` | `false` | Grow the canvas to fit every panel row |
| `minHeight` | `240` | Minimum complete panel height, used with `autoHeight` |
Shared scales compute numeric domains from all transformed rows. Independent
scales help inspect patterns within a panel, but lengths can no longer be
compared across panels, and Bars emit a non-blocking warning. Explicit domains
always take priority. Categorical domains and colours stay consistent across
panels.
Calling `.repeat()` again replaces the panel binding instead of nesting panels.
Missing, null or non-scalar facet values are rejected.
## Size and resize [#size-and-resize]
Repeat keeps the outer canvas height by default; the container width controls
the layout, and panels are laid out again rather than scaled. With
`autoHeight: true`, the canvas grows to fit the panel rows. An explicit chart or
transition `height` takes priority. During a transition the canvas reserves the
largest height its route needs, so playback never changes the outer size.
## State changes [#state-changes]
| Change | Category |
| --------------------------------------- | ------------------------- |
| Binding, removing or changing the field | Encoding (`bind`, `unbind`, `remap`) |
| Direction, columns, gap or minimum width | Layout (`rearrange`) |
| Shared vs independent numeric scales | Coordinate (`rescale`) |
## Supported transitions [#supported-transitions]
| Chart | Flat ↔ repeated | Repeated ↔ repeated |
| ------------------ | ----------------------------------- | ------------------- |
| Bar | Supported, with unchanged membership and grain and globally unique keys | Supported |
| Line | Supported for an existing breakdown field, with unchanged observations and bindings | Supported |
| Area, Point, Unit | Not supported | Supported |
When existing panels change arrangement, each keyed panel first moves as a
container in separate x/width and y/height stages; then the chart inside
reflows using its own transition. Reverse replays both levels backward.
Combining expansion or rearrangement with a flip in one leg is unsupported; use
a [sequence](/docs/api/sequence) to separate them. Nested small multiples are
unsupported.
# Unit (/docs/charts/unit)
> Countable items as individually matched circles, in grid, force, unit-bar and beeswarm layouts.
```ts
import { unit } from 'visdelta/unit';
```
`unit()` turns rows, or counts, into individually matched circles.
## Methods [#methods]
| Method | Meaning |
| ------------------------------------------------- | ---------------------------------------------------- |
| `.value(field, { unitValue?, maxUnits? })` | Expand a count field into units |
| `.group(field)` | Declare categorical membership, without choosing colour |
| `.layout('grid' \| 'force' \| 'bar' \| 'beeswarm', options?)` | Arrange the same units |
| `.columns(number)` | Set grid or unit-bar columns |
| `.size(number)` | Set constant mark size (requested circle radius in pixels) |
| `.radius(number)` | Compatibility alias for `.size(number)` |
| `.x(field, options?)`, `.y(field, options?)` | Declare force targets; x also positions a beeswarm |
The available layout names are exported as `UNIT_LAYOUTS`.
```js
unit(rows)
.key("flowerId")
.size(6)
.layout("beeswarm", { x: "petalLength" })
.color("species");
```
Layout options accept `x` and `y` as field names or full channel configurations,
and `columns`. Layout type, columns and x/y bindings form one complete planner
operation. The existing horizontal-move-then-fall animation remains inside it,
with the usual per-mark stagger. Size is an independent Appearance channel.
Compact layout options and chained `.x()` / `.y()` calls are equally supported
forms of the same declaration, not compatibility syntax:
```js
unit(rows).layout("beeswarm", { x: "petalLength" });
unit(rows).layout("beeswarm").x("petalLength");
```
Both write one position binding. The last explicit assignment wins, including
across the two forms, and replaces the complete channel and its options:
```js
unit(rows)
.key("flowerId")
.size(6)
.layout("beeswarm", { x: "petalLength" })
.x("petalWidth") // replaces the earlier x binding and its options
.color("species");
```
Reversing those x assignments makes `petalLength` win. Omitted layout options
retain earlier settings: `.x("petalWidth").layout("beeswarm")` keeps
`petalWidth`. Equivalent final states produce the same difference and transition
route; normal overwrites do not issue warnings. `.radius(6)` and layout `{ radius: 6 }` remain aliases
for `.size(6)` and obey the same last-assignment rule.
Currently Unit renders circles: size is their requested radius in pixels,
which layout fitting can reduce when space is limited. Field-driven size,
rectangle marks and glyph marks are not yet supported.
Data, quantity, grouping, size, color and attention remain independent operations.
One operation can still produce several semantic categories in `delta()`.
## Layouts [#layouts]
- **`grid`** arranges units in rows of `columns`.
- **`bar`** centres one stack of units on each category tick. It requires a
`.group()` field.
- **`beeswarm`** places units without overlap along x. It requires an x field.
- **`force`** records a D3 simulation's ticks as a reversible physical path and
maps progress to its cumulative movement. With no positional channel, units
are attracted to the plot centre. An explicit x or y channel supplies a
per-unit `forceX` or `forceY` target, so a categorical field forms labelled
attractor bands.
Layout changes preserve matching keys. Remaining unmatched units use a global
minimum-travel assignment, and layout changes use a short, bounded per-unit
stagger unless you override it.
## Quantity per circle [#quantity-per-circle]
`unitValue` sets how much each circle represents and defaults to `1`. A positive
remainder gets one more circle, so 51 sites at `unitValue: 10` render 6 circles.
Label the chart or legend with the unit value you choose. `maxUnits` is a
whole-chart safety cap.
Changing `unitValue` is a **Grain** change: a coarse circle is the parent of the
finer circles from the same row, and the transition splits or merges them around
the parent's centroid.
## Try it [#try-it]
Edit 150 Iris flowers as they leave a grid for a beeswarm in the
[playground](/playground#swarm).
# Chart states (/docs/concepts/chart-states)
> Immutable, chainable builders that describe one chart. Every visual mapping is explicit.
A **chart state** is an immutable description of one chart: its data, field
mappings, transforms, layout, attention and appearance. Builders such as
`bar()` create one, and every chained call returns a new state.
```ts
import { bar } from 'visdelta/bar';
const base = bar(rows).x('category').y('sales');
const next = base.y('profit');
base === next; // false: chaining never mutates
base.toSpec(); // a detached, serializable view spec
```
Chaining does not render anything. A state becomes visible only when it is an
endpoint of [`transition()`](/docs/api/transition) or [`sequence()`](/docs/api/sequence).
## Builders [#builders]
| Builder | Import | Reader question |
| --------- | ---------------- | ------------------------------------------------ |
| `bar()` | `visdelta/bar` | How do categories compare? |
| `line()` | `visdelta/line` | How does a value change in order? |
| `area()` | `visdelta/area` | How does magnitude or composition change? |
| `point()` | `visdelta/point` | How do two quantities relate? |
| `unit()` | `visdelta/unit` | How many countable items are there? |
| `pie()` | `visdelta/pie` | What share of one total does each part take? |
Each builder accepts a [data source](/docs/concepts/data-and-transforms) and
adds chart-specific methods on top of the shared grammar below.
## Shared grammar [#shared-grammar]
Every chart state supports these methods:
| Method | Meaning |
| ------------------------------------- | ---------------------------------------------------------- |
| `.data(source)` | Replace the data source |
| `.x(field, options?)`, `.y(field, options?)` | Map fields to position |
| `.channel(name, field, options?)` | Declare another channel |
| `.color(valueOrField, options?)` | Declare a constant colour or a data-driven colour |
| `.size(field, options?)` | Declare a quantitative size mapping |
| `.key(fieldOrFields)` | Identify the same mark across states |
| `.datumKey(fieldOrFields)` | Override inferred source-record identity |
| `.tooltip(fieldOrFields)` | Declare tooltip fields |
| `.sort(field, order?)` | Sort the current rows |
| `.where(selector)` | Keep matching rows; other marks exit |
| `.highlight(selector, options?)` | Keep all rows; dim non-matching marks |
| `.focus(selector)` | Keep all rows; fit the camera around matching marks |
| `.reset()` | Return to the declaration before the first semantic operation |
| `.axis(config)` | Change the coordinate view or axis order |
| `.transition({ duration?, ease?, stagger? })` | Set timing for this state's transitions |
| `.repeat(field, options?)` | Partition into [small multiples](/docs/charts/repeat) |
| `.rows(source?)` | Read the tidy rows after all declared transforms |
| `.toSpec()` | Return a detached, serializable view spec |
## Mappings are explicit [#mappings-are-explicit]
VisDelta never decides that a field should mean x, y, colour, size or detail.
For example, `.breakdown('region')` declares detail but does not silently add
`.color('region')`. Presentation defaults such as radius, line width, margins
and duration are supplied for you. Field meanings are not.
Without a declared colour channel, marks use the chart style's neutral ink and
there is no colour legend.
```ts
// A constant colour is Appearance.
base.color('#4269d0');
// A field-driven colour is Encoding.
base.color('region');
```
## Channel options [#channel-options]
Channel methods accept options such as `type`, `label`, `format`, `domain`,
`scale`, `sort`, `aggregate`, `timeUnit` and `bin`.
Explicit numeric and temporal position `domain` bounds are kept exactly;
only inferred domains are rounded. Log axes use ordinary D3 log ticks without
adding data-endpoint ticks.
```ts
line(rows)
.x('date', { type: 'temporal', label: 'Date' })
.y('close', { label: 'Close (USD)', format: '$.2f' });
```
- **Type.** Core detects `quantitative`, `temporal` and `nominal` from the data
and fills only missing types. An authored type always wins.
- **Format.** A [D3 format specifier](https://d3js.org/d3-format) for a
quantitative axis, such as `"~s"`.
- **Scale.** Quantitative position channels support `{ type: 'linear' }`,
`{ type: 'sqrt' }` and `{ type: 'log', base? }`. Log scales default to base 10.
### Colour [#colour]
For a colour field, omit `range` to use VisDelta's default categorical palette,
provide `range` for exact colours, or name a
[D3 Scale Chromatic](https://d3js.org/d3-scale-chromatic) scheme:
```ts
bar(rows).color('region', { scheme: 'Tableau10' });
point(rows).color('score', { type: 'quantitative', scheme: 'Viridis' });
```
An explicit `range` takes precedence over `scheme`. Colours follow an explicit
`domain` order when you supply one, otherwise the data-domain order. Filtering
keeps every category's colour assignment and legend entry.
## Plain specs [#plain-specs]
`toSpec()` returns plain, serializable data. You can store it, send it and
compare it. A plain spec can also be passed to `delta()` and `transition()`.
Because a plain object cannot carry a reference to its chart module, rendering
it requires registering that module first. Builders carry their module
automatically.
```ts
import { barModule } from 'visdelta/bar';
import { registerChartModule } from 'visdelta/plugins';
registerChartModule(barModule);
const change = await transition(savedSpecA, savedSpecB, { target: '#chart' });
```
Each focused entry exports its module: `barModule`, `lineModule`, `areaModule`,
`pointModule`, `unitModule` and `pieModule`.
# Data and transforms (/docs/concepts/data-and-transforms)
> VisDelta expects tidy data. Declared transforms change which rows a state uses; they are not a data-cleaning API.
## Tidy input [#tidy-input]
VisDelta expects **tidy** data: one observation per row and one variable per
field. Clean, join, pivot and otherwise reshape data before passing it to the
library. VisDelta does not repair data.
## Data sources [#data-sources]
Every builder accepts one of four source forms:
```ts
bar(rows); // an inline array of objects
bar({ values: rows }); // the same, wrapped
bar({ url: './data.csv' }); // type inferred from the extension
bar({ url: './data.json', type: 'json' }); // explicit type
bar({ name: 'sales' }); // resolved from transition options
```
A named source is supplied when the transition is created:
```ts
await transition(from, to, { target: '#chart', data: { sales: rows } });
```
A bare string is rejected, because the library will not guess whether
`"sales.csv"` names a file or a dataset. URLs are loaded by `transition()`, and
a source shared by both endpoints is loaded once per transition.
`state.rows()` returns the current state's tidy rows after all declared
transforms. Inline data is read directly; pass source rows only when the state
uses a named dataset:
```ts
const all = bar(rows).x('country').y('sites');
const nordic = all.where({ country: ['Norway', 'Sweden'] });
all.rows(); // every row
nordic.rows(); // Norway and Sweden only
```
## Field types [#field-types]
Core detects three field types from the rows. Detection fills only missing
channel types; a type you declare always wins.
| Detected as | When |
| -------------- | ---------------------------------------------------------- |
| `quantitative` | Numbers and numeric strings |
| `temporal` | ISO date strings and valid `Date` objects |
| `nominal` | Mixed or unknown values |
Declare `ordinal` yourself when order is meaningful for a categorical field.
## Transforms [#transforms]
Builder methods such as `.where()`, `.sort()`, `.breakdown()` and `.rollup()`
produce strict, declared transforms. They run in declaration order inside
VisDelta, with no other runtime dependency.
| Operation | Supported form |
| ----------- | ---------------------------------------------------------------------- |
| `filter` | One field comparison: `{ field, equal \| notEqual \| oneOf \| gt \| gte \| lt \| lte }` |
| `timeUnit` | `month` |
| `fold` | Explicit fields and output names |
| `bin` | Positive `step` or positive integer `maxbins` |
| `aggregate` | `count`, `sum`, `mean`, `min`, `max`, `median` |
| `sort` | One field or an ordered field list |
| `limit` | A non-negative integer |
Each transform entry contains exactly one operation. Unknown properties and
unknown operation names are errors, never silently ignored.
Transforms are part of a state's meaning, so their order matters. Filtering
source rows before an aggregate is different from filtering aggregate rows after
it:
```ts
base.where({ region: 'North' }).rollup(); // filter sources, then total
base.rollup().where({ sales: { gt: 100 } }); // total, then filter totals
```
A selector's fields must exist at that point in the pipeline. An aggregate can
remove source fields that are not part of its output grain.
### Writing a spec by hand [#writing-a-spec-by-hand]
The same transforms can be written directly in a plain view spec:
```ts
const spec = {
mark: 'bar',
data: { values: rows },
transform: [
{ filter: { field: 'sales', gte: 0 } },
{
aggregate: {
groupby: ['region'],
fields: [{ op: 'sum', field: 'sales', as: 'sales' }],
},
},
{ sort: { field: 'sales', order: 'descending' } },
],
encoding: {
x: { field: 'region', type: 'nominal' },
y: { field: 'sales', type: 'quantitative' },
},
};
```
Plain specs need their chart module registered before rendering; see
[Plain specs](/docs/concepts/chart-states#plain-specs).
# Identity (/docs/concepts/identity)
> How VisDelta knows which mark is the same mark in both states.
To move a mark, VisDelta has to know it is the same mark in A and B. Marks that
match move; the others enter or leave.
## Keys [#keys]
Give marks a key when they should keep their identity through a change:
```ts
point(cars).x('weight').y('mpg').key('name');
bar(sales).x('region').y('revenue').key(['region', 'channel']);
```
Without `.key()`, each chart type uses a sensible default. If marks fade in and
out when you expected them to move, add a key.
## Records and marks are different [#records-and-marks-are-different]
A **record** is a row of your data. A **mark** is something drawn. When you
total rows, many records become one mark, and the records still keep their own
identity.
VisDelta finds record identity on its own: a unique `id` field if there is one,
otherwise the smallest set of text fields that tells rows apart. Override it
only when that guess would be wrong:
```ts
bar(rows).datumKey('id');
```
## Lineage [#lineage]
With inline data, VisDelta also traces which records feed which marks in both
states. That is how it knows a total **split** into parts, parts **merged**
into a total, or groups were **reaggregated**:
```ts
delta(byYear, byLocation).lineage.mode; // 'reaggregate'
```
Lineage is evidence, not an order. Bar uses it to keep sums readable while they
split and merge; averages and medians keep their history but move directly,
because their parts don't add up to the whole.
The functions behind this are in [Core](/docs/api/core#lineage).
# Design principles (/docs/concepts/principles)
> The rules VisDelta is built on, and which a chart module must keep.
These principles explain behaviour you will observe in every chart type. The
complete normative text, including vocabulary governance and transition
contracts, is the
[language framework](https://github.com/SonghaiFan/visdelta/blob/main/docs/language-framework.md)
in the repository.
## Every frame is true [#every-frame-is-true]
Marks always agree with the scales, ticks, grid lines and titles that explain
them, at every progress value. Exit marks before changing the scale; change the
scale before entering marks.
## State is fact; history is not a script [#state-is-fact-history-is-not-a-script]
Planning consumes endpoint states, not the sequence of builder calls that
produced them. Two chains that reach the same normalized state produce the same
transition. Transform order is still part of a state's meaning: sorting before a
limit can change which rows remain.
## Data meaning is explicit [#data-meaning-is-explicit]
VisDelta expects tidy data and does not clean, pivot, join or repair it. Every
visual encoding is declared. Presentation defaults such as radius, line width,
margins and duration are supplied for you, but a field never becomes x, y,
colour, size or detail unless you map it. Generated intermediate states may not
invent or remap a colour channel.
## Selection words have separate meanings [#selection-words-have-separate-meanings]
`where()` changes membership, `highlight()` changes emphasis and `focus()` moves
the camera. Focus never filters data; highlight never changes scales. See
[where, highlight and focus](/docs/concepts/selection).
## Correspondence is evidence, not a route [#correspondence-is-evidence-not-a-route]
Shared source records establish relationships between marks. They do not by
themselves justify a particular motion. Missing or unsafe evidence keeps the
chart's ordinary transition.
## Reverse means the same frames backward [#reverse-means-the-same-frames-backward]
For a reversible route, progress `p` played forward and `1 − p` played backward
show the same frame. Explicit directional easing is the documented exception.
## Core knows no chart types [#core-knows-no-chart-types]
The generic runtime contains no branches for any built-in chart. Each chart
module owns its builder, compiler, marks, axes, matching and motion, and the
modules are peers. Adding a chart type should not require editing the runtime.
## Keep the language small [#keep-the-language-small]
One word for one concept. Builder syntax, emitted state-change actions,
correspondence operations and transition phases stay visibly distinct.
# Progress (/docs/concepts/progress)
> A transition is a function of one number. Play, pause and seek all move that number.
A transition has one input: progress, from `0` (state A) to `1` (state B).
```ts
change.progress(0); // A
change.progress(0.5); // halfway
change.progress(1); // B
change.value; // where it is now
```
`progress()` draws that frame immediately and stops any playback. You can seek
in any order. Ordinary reversible routes reuse the same frames backward; Unit's opt-in `directionalEase()` is a documented exception.
## Play [#play]
```ts
change.play(); // 0 → 1
change.play({ duration: 1200 }); // slower
change.play({ from: 1, to: 0 }); // backward
change.play({ from: change.value }); // carry on from here
```
`play()` starts at `0` unless you say otherwise, and `pause()` stops on the
current frame.
`duration` is per **stage**. A route with one stop has two stages, so
`{ duration: 800 }` takes 1600 ms.
## Sequences count in states [#sequences-count-in-states]
For a [`sequence()`](/docs/api/sequence), progress runs from `0` to the number
of states minus one. `1.5` is halfway between the second and third state.
Whole numbers always land exactly on a state you wrote.
## States, legs and stages [#states-legs-and-stages]
For authored states `[A, B, C]`, the two legs are `A → B` and `B → C`.
A chart may insert intermediate states and rendered phases *inside* either leg;
these do not add new integer positions to a sequence.
| Sequence position | Location |
| --- | --- |
| `0` | Authored A |
| `0.5` | Halfway through the complete A → B leg |
| `1` | Authored B |
| `1.5` | Halfway through the complete B → C leg |
| `2` | Authored C |
A pair controller's route stages split its 0..1 progress range equally.
`inspect()` gives the actual compiled phase intervals inside that range. A
semantic action can span several phases; neither the number of actions nor
`states.length` is a phase count. If the two legs contain 2 and 3 serial route
stages, `play({ duration: 800 })` takes `(2 + 3) × 800 = 4000 ms` for the
whole sequence. The midpoint of a leg is not necessarily half of its semantic
operations.
`play()` returns its controller, so `await change.play()` does **not** wait for
completion. Await `transition()` or `sequence()` to prepare a controller, then
read `value` while playback runs. The [scroll template](/docs/guides/scroll-template)
drives progress externally and never assumes `play()` is a Promise.
## Resize and clean up [#resize-and-clean-up]
A transition follows its container's width on its own. Call `resize()` after a
theme change, or `resize({ height: 480 })` to change the height. Call
`destroy()` when the chart leaves the page.
# where, highlight and focus (/docs/concepts/selection)
> Three selection words with three separate meanings. Membership, emphasis and camera.
VisDelta keeps three selection operations strictly apart. They accept the same
selector but answer different questions:
```ts
base.where({ region: 'North' }); // remove other rows
base.highlight({ region: 'North' }); // keep rows; change emphasis
base.focus({ region: 'North' }); // keep rows; move the camera
```
| Method | Changes | State-change category | Affects `rows()` |
| -------------- | --------------------------- | --------------------- | ---------------- |
| `.where()` | Data membership | Data | Yes |
| `.highlight()` | Emphasis of matching marks | Attention | No |
| `.focus()` | The view camera | Attention | No |
## where(selector) [#whereselector]
Matching rows stay and non-matching rows exit. `where()` adds a filter
transform, so `state.rows()` also returns only the matching rows.
Line fits its inferred x/y domains to the visible rows. Colors and legend
mappings keep their full data lineage, and missing interior observations still
leave gaps. Supply explicit position domains when a story needs fixed axes;
repeated panels with shared scales retain a common domain.
## highlight(selector, options?) [#highlightselector-options]
`highlight()` changes attention without changing rows, identity, layout, axes or
scale domains. Matching marks keep full opacity. Non-matching marks use the chart
style's `--vd-dim-opacity`, or the `opacity` you pass:
```ts
base.highlight({ country: 'Norway' });
base.highlight({ sites: { gte: 10 } }, { opacity: 0.08 });
```
## focus(selector) [#focusselector]
`focus()` changes only the camera. Core finds the rendered bounds of the
matching marks, then applies one aspect-preserving two-dimensional pan and zoom.
Every row and mark identity remains. The visible scales and axes follow the
camera; the underlying data domain is not recomputed.
When nothing matches, the camera stays at the complete-chart view. Focus never
zooms out farther than that view.
## Selectors [#selectors]
A selector is always an object, never a string. Specs stay plain data that can
be saved, sent and compared without a parser.
### Field shorthand [#field-shorthand]
Use a scalar for equality, an array for membership or a constraint object for
comparisons. Several fields in one object are joined with AND.
```ts
{ region: 'North' } // region equal "North"
{ region: ['North', 'South'] } // region oneOf [...]
{ age: { gte: 18, lt: 65 } } // 18 <= age < 65
{ region: 'North', age: { gt: 80 } } // region AND age
{ cancelledAt: null } // equality with null
```
### Explicit comparison [#explicit-comparison]
```ts
{ field: 'sales', equal: 100 }
{ field: 'region', oneOf: ['North', 'South'] }
{ field: 'sales', gte: 10, lt: 100 }
{ field: 'date', gte: '2026-01-01', lt: '2027-01-01' }
```
The supported operators are exactly `equal`, `notEqual`, `oneOf`, `gt`, `gte`,
`lt` and `lte`. The operator is `equal`, not `equals`.
Range operands must be finite numbers, `Date` values or valid ISO date strings.
Missing values, empty strings, booleans and `NaN` never match a range.
## Composition [#composition]
All conditions in one selector, and all repeated calls to the same method, are
joined with AND. Each call narrows the previous state; it does not replace it.
```ts
const olderNorth = base
.where({ region: ['North', 'South'] })
.where({ region: 'North' })
.where({ age: { gt: 80 } });
```
The three scopes are independent and can coexist:
```ts
const next = base
.where({ year: 2022 }) // membership
.highlight({ country: 'Sweden' }, { opacity: 0.1 }) // emphasis
.focus({ country: ['Denmark', 'Sweden'] }); // camera
```
Because a second `.focus()` narrows the first, `state.focus(north).focus(south)`
means their intersection. To replace a condition, start from an earlier state
or call `.reset()`, which returns the declaration before the chain's first
semantic operation:
```ts
const south = state.reset().focus({ region: 'South' });
```
# State changes (/docs/concepts/state-changes)
> delta() names what changed between two states, using seven kinds of change.
`delta(a, b)` tells you what changed between two states. It is the **Δ** line
under every chart on this site.
```ts
import { delta } from 'visdelta/core';
delta(a, b).stateChanges;
// [{ category: 'encoding', action: 'remap', channel: 'y' }]
```
It needs no browser, so it also runs in Node and in tests. A transition
exposes the same result as `change.delta`.
## Seven kinds of change [#seven-kinds-of-change]
Every entry is a fact about the two states: a **category**, an **action**, and
sometimes a detail such as the channel. A few lines that are easy to mix up:
- A colour **field** (`.color("region")`) is Encoding. A colour **value**
(`.color("#3366ff")`) is Appearance.
- Mapping y to another field is Encoding. Changing the scale or flipping the
chart is Coordinate.
- `where()` changes Data. `highlight()` and `focus()` change Attention: every
row stays.
- Changing `sum` to `mean` is Grain, even if the groups stay the same.
## Several at once [#several-at-once]
One edit can produce several facts. Splitting totals into coloured detail is a
Grain change, a new colour binding and a colour style change, all at once:
## Not a schedule [#not-a-schedule]
The list is for reading. Its order is fixed by category and says nothing about
what moves first. How a chart moves is decided by its chart type; see
[Transitions](/docs/concepts/transitions).
## More detail [#more-detail]
The same result also carries the raw differences (`deltas`, `hasDelta()`),
exact declaration `edits`, and `lineage` when both states have inline data. See
[Core](/docs/api/core) and [Identity](/docs/concepts/identity).
# Transitions (/docs/concepts/transitions)
> How VisDelta gets from one state to the other, and why every frame is a real chart.
Two states don't say *how* to get from one to the other. Each chart type
chooses a route, following a few fixed rules.
## Every frame is a real chart [#every-frame-is-a-real-chart]
At any point between A and B, the marks agree with the axes that explain them.
When the scale changes, the ticks, grid, title and marks move together.
When marks come and go, the order keeps each frame honest:
```text
Leaving: marks exit first, then the scale settles
Arriving: the scale makes room first, then marks enter
```
A line reaches a new point before the point appears:
## Routes can have stops [#routes-can-have-stops]
Sometimes the clearest route passes through a third state. Splitting totals
into side-by-side groups goes through a stack first, so each total stays
readable on the way:
These stops are complete, valid charts. They never invent a field or a colour,
and they never replace a state you wrote. When no good route exists, the
chart moves directly.
## Your own stops [#your-own-stops]
Pass several states to [`sequence()`](/docs/api/sequence) to choose the stops
yourself. Each one is kept exactly.
```ts
const story = await sequence([revenue, profit, ranked], { target: '#chart' });
```
## Backward is the same frames [#backward-is-the-same-frames]
Playing from B to A shows the frames of A to B in reverse, not a second
animation. The one exception is Unit's opt-in directional easing, which can
take a different path back while keeping both ends the same.
## Who decides [#who-decides]
The core runtime has no special cases for any chart type. Each chart type owns
its own route, matching and motion. See [Charts](/docs/charts) for each one, or
[Custom chart types](/docs/guides/custom-chart-types) to add your own.
# Start here for agents (/docs/guides/agent-workflow)
> A verified entry point, observable API facts and a short workflow for building a correct VisDelta story.
Start with the [machine-readable quick start](/llms.txt). Its first example is
a complete, version-pinned HTML page; the same quick start is included before
the reference in [llms-full.txt](/llms-full.txt).
For a scrolling story, copy the [single-file template](/templates/scroll-story.html)
and follow its [scroll and iframe contract](/docs/guides/scroll-template).
It contains inline fictional data, real chart states and controls. Serve the
file over HTTP; no bundler or data service is required.
## Before drawing [#before-drawing]
1. Supply tidy rows and check the fields and types you will bind.
2. Choose stable source-record identity separately from mark identity. A line
with several series commonly uses `.key(['date', 'series'])`.
3. Decide whether dates are local calendar dates or absolute instants. ISO
date-only strings are parsed by JavaScript as UTC instants; the current
temporal axis uses local time. Neither “always local midnight” nor “always
UTC” is a safe universal conversion rule.
4. Declare position domains when a story requires fixed comparison scales.
Leave them inferred when the axis should fit visible data. Read the
[chart-specific domain rules](/docs/guides/automatic-behaviors).
## Query what exists today [#query-what-exists-today]
```ts
import { delta, visualizationWarnings } from 'visdelta';
const declared = state.toSpec(); // declaration, not final pixel geometry
const visibleRows = state.rows(); // transformed inline rows
const changes = delta(from, to).stateChanges;
const warnings = await visualizationWarnings(state);
```
These are useful for inline data. Warning inspection can also receive named
rows, but does not fetch remote sources. Rendering can load a declared URL;
await controller creation before using the resolved endpoint declarations.
```ts
const change = await transition(from, to, { target: '#chart' });
console.log(change.from, change.to, change.delta.stateChanges);
console.log(change.stageCount(), change.stageOperations());
console.log(await change.inspect());
change.progress(1);
```
`inspect()` reports compiled SVG tracks, phase boundaries and property endpoint
values. Custom tween internals, immediate writes, node lifecycle changes and
External page markup is outside its coverage. It does not return a list of marks or
prove that a line is visible. `state.resolve()`, `inspect(t)`, global strict/debug
configuration and `bindScroll()` are not implemented APIs.
## Verify behavior, then appearance [#verify-behavior-then-appearance]
- Check the exact start/end states against independent static charts at the
same size, margins and theme. An SVG node alone is insufficient: check actual
bar dimensions and line drawing state.
- Check forward/reverse playback and seeking. To sample a phase, use its
compiled interval from `inspect()`, rather than equating semantic changes
with rendered phases. Unit's opt-in `directionalEase()` has its documented
directional exception.
- Check narrow and desktop layouts, resize, theme refresh and reduced motion.
- Check that filtering keeps colors and legend categories stable where the
chart contract requires it, and that empty selections can be restored.
- Use screenshots for the final visual review, after the numerical assertions.
The repository runs the [distribution and template browser checks](https://github.com/SonghaiFan/visdelta/tree/main/scripts)
with `npm run browser:check`. The single-file templates are copied unchanged into
the built documentation site and tested through their real controls.
## Avoid plausible but unsupported fixes [#avoid-plausible-but-unsupported-fixes]
Do not invent `scale.nice`, `axis.endpointTicks`, `utc`, `domain: 'filtered'`,
`story.ready` or a `play()` completion Promise. Check the
[automatic behavior table](/docs/guides/automatic-behaviors) before adding an option.
Do not assume every combination of data, grain and scale edits has a planned
route; the [chart contracts](/docs/concepts/transitions) define the supported
subsets and fallback boundaries.
# Automatic behaviors (/docs/guides/automatic-behaviors)
> What VisDelta derives automatically, what affects it and which controls exist in 0.4.2.
This table describes **implemented behavior in 0.4.2**. “No switch” means that
an option is unavailable, not that an agent should try an undocumented spelling.
A declaration (`toSpec()`) is not a resolved layout; actual ticks and geometry
also depend on chart type, dimensions, margins, theme and sometimes fonts.
| Behavior | Trigger and result | Existing control or boundary |
| --- | --- | --- |
| Field types | Missing channel types are inferred from bound data. Authored types take priority. | Set `type` on the channel. Validate the actual values; inference does not clean data. |
| Line position domains | Inferred x/y domains fit visible transformed observations. Lineage remains available for gaps, category colors and legends. Repeated panels with shared scales retain their shared domain. | Declare a channel `domain: [min, max]` for fixed bounds; there is no `'filtered'` / `'source'` domain mode. |
| Other position domains | Bar's category axis follows displayed categories; its measure domain uses lineage values and includes the baseline unless overridden. Area retains lineage-derived stack/position domains. Point uses its plotted/view rows, or a shared panel domain. Unit domains depend on its layout. | Read each [chart's contract](/docs/charts). Do not generalize Line's filter behavior to every chart. |
| Rounding inferred domains | The numeric linear/sqrt/log and temporal scale helpers apply D3 `nice()` to inferred domains. The log helper uses 1 as its default lower bound before rounding; declare valid positive bounds when that is not the intended range. | Explicit supported position bounds are preserved, including Bar measure domains. The current log guard clamps positive bounds below 0.1; exact sub-0.1 log domains are unsupported in 0.4.2. `scale: { nice: false }` is not a supported switch. |
| Data-endpoint ticks | Continuous non-log axes prioritize available data endpoints, then admit ordinary ticks with sufficient space. | Tick spacing is part of the chart style. No `axis.endpointTicks` switch exists. Log axes use ordinary log ticks without injected data endpoints. |
| Color and legends | A declared field color creates a legend. Categorical mappings/legend membership retain lineage rather than changing slots after a display filter. With no color mapping, marks are black and there is no color legend. | Declare a constant `.color('#3269b5')` when a field legend is not needed. This changes color meaning; it is not a “hide legend” toggle. A global `legend: false` switch is unavailable. |
| Legend layout | Top legends occupy a separate block above the canvas. Width and label measurement affect wrapping. | Chart styles support `legendPosition: 'right'`; panels own their local legends. Constrained `height: 'auto'` may hide a legend when there is insufficient room. |
| Default presentation | Themes provide spacing, type, axis styling and colors for an already-declared mapping. | Use chart styles and `--vd-*` tokens; call controller `resize()` after a theme change. For sequences, place shared tokens on the document root because preparation mounts live under `body`. |
| CSS variable colors | Color ranges such as `['var(--story-north)', 'var(--story-south)']` resolve variables when rendered. | Define them on the document root for sequences and call `resize()` after changing them; they are not continuously re-read on every cached frame. |
| Date parsing and axis timezone | Temporal values accept valid `Date` objects and supported ISO strings. JavaScript parses date-only ISO strings as UTC instants; the axis uses local-time `scaleTime`. | Choose a date convention explicitly. There is no UTC axis switch; tests that depend on the displayed date should control the browser timezone. |
| Focus camera | `focus()` fits selected mark bounds with one aspect-preserving zoom and pan. The other axis may gain empty space; membership and the underlying data domain are unchanged. | Use an earlier unfocused state to clear the camera, or use `where()` when rows should actually leave. Focus is not an independent x/y domain fit. |
| Generated intermediate states | A chart's supported planner/transition policy can insert complete states and motion phases within an authored leg. Unsupported combinations retain the documented fallback. | `sequence()` fixes authored waypoints. Pair controllers expose `stageCount()`, `stageChanges()`, `stageOperations()` and `inspect()`; semantic change count is not phase count. |
| Lazy modules and preparation | Focused builders carry lazy chart modules. `transition()` resolves inputs/prepares its surface; `sequence()` awaits each adjacent pair before returning. Some frame work can remain lazy. | Await creation. The returned controller can seek immediately; there is no extra `.ready` property. Plain specs require registered modules. |
| Progress and duration | A pair uses 0..1. A sequence uses 0..number of states minus one. `duration` applies per serial route stage. | `progress()` synchronously seeks and stops owned playback. `play()` returns the controller, not a completion Promise. See the [progress model](/docs/concepts/progress). |
| Resize | Automatic canvas dimensions observe their container. Explicit pixel dimensions stay fixed until changed. | Use `width: 'auto'` / `height: 'auto'` with a sized host, or `resize({ width, height })`. Refresh after changing fonts or theme. |
| Warnings | Chart-owned advisory rules inspect prepared state and transformed rows. Rendering reports them; cached frames need not run those rules again. | `visualizationWarnings()` inspects without drawing for supplied data. It is not a per-frame invariant checker or an unknown-option strict mode. |
| Scroll, replay and reduced motion | The library evaluates requested progress; it does not install scroll listeners, sticky layout or a reduced-motion policy for an application. | The [single-file template](/docs/guides/scroll-template) supplies these controls and a manual iframe fallback outside the library. |
## Choose the intended domain policy [#choose-the-intended-domain-policy]
```ts
// Let Line fit the surviving observations.
const selected = all.where({ series: 'North' });
// Or deliberately fix the comparison frame for the whole story.
const fixed = all.y('value', { domain: [0, 100] });
const selectedFixed = fixed.where({ series: 'North' });
```
An explicit domain can intentionally clip marks. That is different from missing,
non-finite or incorrectly transformed geometry; “all marks must be inside the
domain” is not a universal validity test.
# Use without a bundler (/docs/guides/cdn)
> Run VisDelta from one versioned ESM URL, or from a self-contained global script.
## A complete page [#a-complete-page]
Save this as `index.html` and open it through a local web server. Browsers
restrict module loading from `file://`, so run any static server you already
use, for example `npx serve`, and open the URL it prints.
```html title="index.html"
My first VisDelta transition
```
## Why `/+esm` [#why-esm]
VisDelta's published ESM entry imports the `d3-*` modules by bare name, which a
browser cannot resolve on its own. jsDelivr's `/+esm` endpoint rewrites those
imports to version-pinned CDN URLs, the same convention D3 itself uses.
- **Pin the version** for durable pages.
- **Don't add SRI** to a `+esm` URL. The file is generated by jsDelivr, not
stored statically, so its hash is not guaranteed to be stable.
- Chart renderers still load lazily from the root URL.
## Focused CDN entries [#focused-cdn-entries]
The root URL is the recommended path. A page that deliberately wants a narrower
initial entry can request a packaged file directly:
```js
import { bar } from 'https://cdn.jsdelivr.net/npm/visdelta@0.4.2/dist/bar.js/+esm';
import { transition } from 'https://cdn.jsdelivr.net/npm/visdelta@0.4.2/dist/transition-entry.js/+esm';
```
## Global script [#global-script]
For a page without modules, load the self-contained global bundle. The `d3-*`
modules VisDelta uses are compiled in.
```html
```
The global build exposes the same chart and transition API as `window.VisDelta`.
# Controlling playback (/docs/guides/controlling-playback)
> Drive one transition from buttons, a slider, scroll position or your own timeline.
VisDelta has no built-in controls. Every control calls the same controller
methods: `progress()`, `play()` and `pause()`. The player on this site is built
exactly this way.
## Buttons [#buttons]
```ts
const change = await transition(from, to, { target: '#chart' });
playButton.addEventListener('click', () => change.play({ duration: 800 }));
reverseButton.addEventListener('click', () => change.play({ from: 1, to: 0 }));
pauseButton.addEventListener('click', () => change.pause());
```
On a pair controller, `play()` starts from `0` unless you pass `from`. To resume
from the current frame, pass it explicitly:
```ts
resumeButton.addEventListener('click', () => {
change.play({ from: change.value, to: 1 });
});
```
## A slider [#a-slider]
```ts
slider.addEventListener('input', () => {
change.progress(slider.valueAsNumber); // 0 … 1
});
```
`progress()` pauses any running playback, so a reader can grab the slider
mid-animation.
## Scroll position [#scroll-position]
Use the [tested, single-file scroll story](/docs/guides/scroll-template) for
step cards, holds and replay. It measures actual card positions, coalesces events
and includes explicit handling for content-height iframes. Mapping progress from
an iframe's own viewport cannot reveal a cross-origin parent's scroll position.
All control modes call the same `progress()` API, so reverse scrolling needs no
separate chart animation.
## Your own clock [#your-own-clock]
Because progress is just a number, VisDelta can follow any timeline, such as a
media element, an animation library or a test harness:
```ts
video.addEventListener('timeupdate', () => {
change.progress(video.currentTime / video.duration);
});
```
## Reflecting progress in the UI [#reflecting-progress-in-the-ui]
`play()` animates internally. Read `change.value` on each animation frame to keep
a slider or label in sync while it plays:
```ts
function playAndTrack(to: number) {
change.play({ from: change.value, to });
const tick = () => {
slider.value = String(change.value);
if (change.value !== to) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
```
## Respecting reduced motion [#respecting-reduced-motion]
Don't start animations automatically for readers who prefer reduced motion. A
reader-initiated control is still fine, as is jumping straight to the end state:
```ts
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) change.progress(1);
else change.play();
```
# Custom chart types (/docs/guides/custom-chart-types)
> Add an independent chart module through the plugin SDK, without changing the runtime.
A chart type is an independent module. The runtime works before the module is
imported and does not change when it is added. The built-in charts use the same
contract, and Pie is the smallest complete reference implementation in the
repository (`src/charts/pie/`).
This guide is for authors of new chart families. To restyle or combine
existing charts, see [Styling and themes](/docs/guides/styling) and
[Small multiples](/docs/charts/repeat).
## Package layout [#package-layout]
```text
my-chart/
package.json
index.js # immutable builder that carries its module
module.js # lazy loader
plugin.js # validation, identity defaults, renderer factory
geometry.js # transformed rows → keyed geometry
render.js # keyed DOM joins and recorded motion
```
Use `visdelta` as a peer dependency at a version range you have tested, and
declare the `d3-*` packages you import as your own dependencies. Import SDK
contracts from `visdelta/plugins` and optional helpers from `visdelta/toolkit`.
Never import `visdelta/dist/...` or another chart's internals.
## 1. A builder [#1-a-builder]
A builder extends `ChartState` and returns its module. Use `this.channel()` and
`this.with()` rather than mutating state.
```js title="index.js"
import { ChartState, normalizeDataSource } from 'visdelta/plugins';
import { chartModule } from './module.js';
class DonutState extends ChartState {
chartModule() {
return chartModule;
}
value(field) {
return this.channel('angle', field, { type: 'quantitative' });
}
}
export function donut(data) {
return new DonutState({
mark: 'example-donut',
data: normalizeDataSource(data),
encoding: {},
});
}
```
Inherited methods such as `.key()`, `.color()` and `.where()` keep working and
keep returning new states.
## 2. A lazy module [#2-a-lazy-module]
```js title="module.js"
import { defineChartModule } from 'visdelta/plugins';
export const chartModule = defineChartModule({
key: 'example-donut',
load: () => import('./plugin.js'),
});
```
The loaded file exports `plugin = defineChartType({ ... })` with the same key.
## 3. The plugin [#3-the-plugin]
```js title="plugin.js"
import { defineChartType } from 'visdelta/plugins';
export const plugin = defineChartType({
key: 'example-donut',
scenes: [],
presentation: { plot: { margin: { top: 24, right: 20, bottom: 24, left: 20 } } },
prepareSpec(spec) {
// Reject syntax this renderer cannot honour instead of ignoring it.
if (spec.encoding?.x || spec.encoding?.y) throw new Error('Donut has no position channels.');
return spec;
},
createRenderer: (runtime, presentation) => (chart, rows, spec) => {
// Join rows by key and animate through runtime.motion(), see below.
},
});
```
Supply only the hooks your chart needs. Useful ones include `createRenderer`,
`createSpecCompiler`, `prepareSpec`, `layoutChannels`, `warnings`,
`defaultHeight`, `legendHeight` and `transition` (with `intermediateSpecs`,
`canonicalPair` and `plan`). `defineChartType` already supplies `legendHeight`
from the runtime that draws the legend; return 0 from it only when your renderer
draws no legend above the canvas.
## 4. Recorded motion, not D3 transitions [#4-recorded-motion-not-d3-transitions]
VisDelta seeks recorded property tracks; D3's scheduler is timer-driven and
cannot be sought. A renderer joins data with D3 as usual but animates through
`runtime.motion()`, never `selection.transition()`.
```js
import { scaleLinear } from 'd3-scale';
const render = (runtime) => (chart, rows) => {
const x = scaleLinear().domain([0, 100]).range([0, chart.innerWidth]);
const dots = chart.g
.selectAll('circle.dot')
.data(rows, (row) => row.id)
.join('circle')
.attr('class', 'dot');
runtime.motion(dots, chart.transition.base)
.attr('cx', (row) => x(row.value))
.style('opacity', 1);
};
```
`motion(selection, base)` mirrors the d3-transition authoring surface (`attr`,
`style`, `attrTween`, `styleTween`, `tween`, `text`, `delay`, `duration`,
`ease`, `remove` and chained `transition()`) but records tracks. Because frames
are sought, not played:
- `on('start' | 'end')` callbacks are rejected. Compute end states up front.
- `remove()` detaches a node only when the transition finishes, so exited marks
can return when the reader scrubs backward.
- `chart.transition.base`, `enter` and `exit` are plain `{ delay, duration, ease }`
descriptors, not D3 transitions.
Prefer `runtime.motion` over the standalone `motion` export in separately
bundled plugins, so your renderer records into the host's recorder.
## 5. Use it [#5-use-it]
A builder carries its module, so it works with `visdelta/transition` without
registration:
```js
import { transition } from 'visdelta/transition';
import { donut } from 'my-chart';
const change = await transition(donut(rows).value('a'), donut(rows).value('b'), {
target: '#chart',
});
```
Only plain JSON specs need `registerChartModule(chartModule)`, because a plain
object cannot carry a module reference.
## Rules to keep [#rules-to-keep]
- **Identity is declared.** Source-record `datumKey` and mark `key` are different
contracts. Validate uniqueness and reject unsupported key forms rather than
guessing.
- **Validate transformed rows.** Geometry receives rows after declared
transforms. Reject invalid values instead of silently repairing them.
- **No chart-name branches in core.** Everything chart-specific stays in your
module.
- **Planning is optional.** A direct transition is a complete, working fallback.
Add a declaration-operation codec only when you can make it normalize to a
fixed point, round-trip exactly and validate every complete candidate state.
- **Test like the built-ins.** Check bindings, immutability, invalid values,
emitted state changes, forward and reverse play, scrubbing, cleanup and resize.
The repository's [plugin authoring notes](https://github.com/SonghaiFan/visdelta/blob/main/docs/extending-with-plugins.md)
cover the full runtime contract, the toolkit's geometry, topology, matching and
policy helpers, and the checklist for an official chart.
# Design comparisons (/docs/guides/design-comparisons)
> Paired "Not ideal → Better" charts for common design choices, drawn live by VisDelta with its own advisory warnings and runnable code.
Each pair tells one illustrative story in two forms. The left chart is a
reasonable-looking choice that makes the reader's task harder; the right chart
answers the same question more directly. Both are real VisDelta states, and the
hints under each chart are the [visualization warnings](/docs/guides/warnings)
VisDelta reports for it.
The guidance is paraphrased from the linked Datawrapper Academy articles. These
are contextual choices, not automatic validity rules: VisDelta still renders
every chart on this page.
# JSON specs (/docs/guides/json)
> Save and load ViewSpec declarations with the same runtime as builders and React.
A JSON spec is a serialized `ViewSpec`. It describes one chart state, including
its data source, encodings, ordered transforms and chart-specific options.
Full specs use the existing rendering engine. Fluent edit messages below are a separate authoring input.
## One chart, three ways [#one-chart-three-ways]
Switch between the chain API, JSON and React below. Each renders the same bar
chart: categories A, B and C with values 12, 28 and 19.
## Render and update directly [#render-and-update-directly]
```ts
import { render } from 'visdelta';
const view = await render('#chart', json, {
data: { sales },
height: 280,
updates: { policy: 'latest', duration: 600 },
});
// Pass a complete JSON string or spec object from your agent.
const result = await view.update(nextSpec);
// result.status: 'applied', 'superseded', or 'destroyed'
view.destroy();
```
No parsing or registration step is needed for built-in charts with `render()`.
Each update is a complete state of the same chart type. The default `latest`
policy finishes the active update, then plays only the newest valid pending
state. Invalid JSON, missing named data and runtime validation failures reject
without replacing the visible chart. Catch update errors in your application.
Send complete JSON output; partial token streams are not chart states. Reject
responses from obsolete agent turns before calling `update()`.
The promise settles when the update is applied, superseded or destroyed.
Duration is milliseconds per planned route stage. Use
`view.update(nextSpec, { animate: false })` for a scheduled immediate replacement.
Supply `{ data: { sales: newRows } }` to replace named bindings; already resolved
endpoints retain their own data. `view.snapshot()` returns a detached
`{ revision, spec }` for the latest accepted state, which can be ahead of the
visible animation. Acceptance does not guarantee later route preparation will
succeed. `view.resize()` retains new dimensions, and `view.destroy()` also settles
outstanding updates and prevents late mounts.
For manual progress control, use `await view.transition(nextSpec)` once updates
have settled. It does not autoplay. Finish or pause at an endpoint before the
next submission; updates during manual playback or at an intermediate frame
reject. Its `play()` method returns the controller, not a promise.
To include only selected chart modules:
```ts
import { createRenderer } from 'visdelta/render';
import { barModule } from 'visdelta/bar';
const renderer = createRenderer({ modules: [barModule] });
const view = await renderer.render('#chart', json, { data: { sales } });
```
This module list belongs to the renderer and ignores global registrations.
## Reading and writing specs [#reading-and-writing-specs]
`parseSpec(json: string): ViewSpec` reads JSON and normalizes the declaration
using the same logic as `delta()` and the runtime. `stringifySpec(state, space?)`
accepts a builder or plain `ViewSpec`; `space` is JSON indentation. Both are
exported from `visdelta` and `visdelta/core`. TypeScript callers can import
`ViewSpec` from either entry.
Both helpers check common structure, ordered transforms and JSON values.
They preserve custom chart types and extension fields. They do not load modules,
fetch data, check field availability or prove that a particular chart supports
an encoding combination. Those checks remain in the data pipeline and chart
plugins when you render or plan a transition.
Object properties with `undefined` are omitted; valid dates become ISO strings.
Non-finite numbers, functions, symbols, bigints, circular references, unsupported
object classes and undefined array entries throw instead of silently losing
values. For builders, validation sees the declaration returned by `toSpec()`.
Existing aliases such as `key` normalize into `meta`; this is a declaration
round trip, not preservation of JSON whitespace or property placement.
Use explicit `{ "name": "sales" }` or `{ "url": "sales.csv" }` data sources.
Named data is supplied through the existing `data` option on the runtime or
React component. Inline rows may use an array or `{ "values": [...] }`.
## React and sequences [#react-and-sequences]
Parse stored specs once when loading them, then pass the resulting objects to
`Chart`. Keep their references stable across renders.
```tsx
import { Chart } from 'visdelta/react';
// Define these once when loading the specs, or memoize them.
const pair = [from, to];
const states = [from, to, next];
```
Register the required chart modules before mounting, just as for the imperative
runtime. Playback options, callbacks, host elements and React props are not part
of the spec. A sequence remains an array of states supplied to `sequence()` or
`Chart`; this API does not introduce a JSON timeline format.
## Small edits from an agent [#small-edits-from-an-agent]
The Bar module accepts fluent JSON commands on the existing chart:
```ts
const { revision } = view.snapshot();
await view.edit({
requestId: 'turn-1-flip',
baseRevision: revision,
edits: [{ method: 'flip', args: [] }],
});
```
A batch builds one destination. Earlier accepted edits survive skipped pending
animations. `snapshot()` reports acceptance; the returned promise reports playback
completion, supersession or destruction. A stale revision rejects with
`code: 'REVISION_CONFLICT'`. Read the new snapshot before generating a replacement
request. Identical request IDs and payloads reuse their original outcome; changing
a payload requires a new ID. Each view retains up to 1024 requests, including
failures; start a new view session after that limit.
Bar supports `flip`, `color`, `x`, `y`, `sort`, `where`, `focus`
and `highlight`. Axis options support `type`, `label` and `format` strings. Axis
bindings refer to the current channels after a flip. Filters and highlights
accumulate as they do in native chains. Aggregation edits, aggregate axis remaps,
other built-in charts and arbitrary builder methods are unsupported; use full
`update()` states for these. Edits cannot replace dataset bindings.
Try [Liveshow](/liveshow). Its Grain category deliberately uses `update()`.
This is a local, tool-friendly API; an application can expose it through MCP,
but VisDelta does not start an MCP server.
## Observe the rendered chart [#observe-the-rendered-chart]
```ts
const marks = view.inspectMarks();
const image = await view.capture();
// image.dataUrl is a PNG; both results carry the accepted revision.
```
Call these after edits have settled. Mark observations contain bound scalar data,
CSS-pixel bounds and explicit spatial orders, so a downstream agent can resolve
“the third bar” against the actual display. Only visible keyed marks are listed.
Capture includes the primary SVG canvas on white, not the surrounding application
or separate legend SVGs. External images/foreignObject are unsupported. Observation
rejects pending or manual transitions, and capture rejects revision changes while
encoding; retry after the current update finishes.
# Author a downstream MDX story (/docs/guides/mdx)
> Use native VisDelta states in a narrative application, with explicit ownership of progress and asynchronous chart creation.
## VisDelta Story [#visdelta-story]
The MDX editor and interactive examples are maintained in the independent
**VisDelta Story** downstream application. [Open its entry page](/story/).
Its source lives beside this repository in `../visdelta-story/`; it consumes
VisDelta through public package exports. The app owns MDX compilation, scrolling,
shared variables, controls, live Tailwind styling and project persistence.
The application maintains its own [Story documentation](https://songhaifan.github.io/visdelta-story/docs/), with MDX authoring, controls, examples and deployment guides.
The examples below describe how another MDX application can use VisDelta's
native chart declarations and playback APIs.
## Integrate with your own MDX application [#integrate-with-your-own-mdx-application]
An MDX application can import native VisDelta states and supply them to its own
narrative components. VisDelta supplies chart state and rendering; the application
supplies MDX compilation, layouts, scrolling, editing and error presentation.
The VisDelta library itself does not export an MDX compiler or `Scrolly`/`Step` components; Studio implements these in its application layer.
## A paragraph and its chart belong together [#a-paragraph-and-its-chart-belong-together]
Start with the complete
[`sales.mjs`](https://github.com/SonghaiFan/visdelta/blob/main/examples/authoring/sales.mjs)
module. These three rows have unique regions, so Bar infers its mark key.
This simple example needs no explicit `key()` or `datumKey()`:
```js title="sales.mjs"
import { bar } from 'visdelta';
export const sales = [
{ region: 'North', revenue: 18 },
{ region: 'South', revenue: 12 },
{ region: 'West', revenue: 9 },
];
export const chart = bar(sales).x('region').y('revenue');
```
The accompanying
[`story.mdx`](https://github.com/SonghaiFan/visdelta/blob/main/examples/authoring/story.mdx)
uses the following **application-owned component contract**. The MDX application
supplies `Scrolly` and `Step` as default components:
```mdx
import { chart } from './sales.mjs'
# Which region sells the most?
## Compare all three regions
Each bar shows one region's revenue.
## Look at North
North has the highest revenue. The other regions remain visible.
## Return to the whole picture
Compare North with the other regions again.
```
Read ordinary Markdown, use `Scrolly` to group the story, and give each `Step`
the chart to show while reading it. Each `chart` prop is a complete immutable
VisDelta declaration. Highlighting creates a new declaration; it does not modify
the base chart or inherit changes from the preceding step. Repeating `chart`
returns to the overview. The application collects those declarations in step
order and creates the sequence; authors maintain no parallel `states` array.
`chart` is a prop name, not a new builder or a rendered controller. VisDelta does
not export `chart()`, `Scrolly` or `Step`. Applications own their component
registration and may allow explicit standard MDX imports to override defaults.
They also own optional IDs, accessible titles, layouts and hold policies. Avoid
reconstructing VisDelta as another YAML grammar or a second highlighting prop.
The onboarding target is to copy the template, change fields and add a highlight
step in two minutes. This is a design target, not a measured user-study result.
More complex stories can use the
[`network-states.mjs`](https://github.com/SonghaiFan/visdelta/blob/main/examples/authoring/network-states.mjs)
Line example with explicit identity and temporal bindings. Dates serialize to
ISO strings; compare instants rather than requiring `rows()` to return `Date`s.
## Optional chart presentation [#optional-chart-presentation]
An application can expose a `chartStyle` prop on `Scrolly` and map preset names
through the existing `chartStylePresets` export:
```mdx
Compare all regions.
Look at North.
```
`chartStyle` here is an application prop, not a new VisDelta builder or string
option. The application resolves `d3`, `paper`, `dark` or `editorial` to a module
from `visdelta/chart-style`, or accepts a native `defineChartStyle(...)` module,
and passes it as the library's `options.chartStyle`. Both `sequence()` and the
single-state `transition(chart, chart)` path need that option.
One module applies to the whole sequence. Steps keep using native chart
declarations for authored color changes; they do not interpolate style modules.
The application validates names, retains stable module identity through parent
renders, and preserves desired progress when replacing a controller for a new
module. Keep page-theme refreshes separate: they use `resize()`.
See [Styling and themes](/docs/guides/styling/) for CSS inheritance, explicit-color
precedence and the difference between structural modules and CSS token themes.
## Installed-package contract [#installed-package-contract]
Use `import 'visdelta/style.css'` once in the browser application. Pin the
package version used by the story. The current integration baseline is 0.4.2.
An unreleased fix in this checkout preserves chart-root theme/font scope when
sequence legs move out of their hidden preparation mounts. Version 0.4.2 can
measure legend labels using the empty preparation host's font after a handoff.
Integration snapshots containing this fix must use a distinct prerelease version,
not be presented as the published 0.4.2 package.
The Paper neutral-ink correction is also unreleased: older builds can inherit
light neutral marks from a dark page. Pin the tested package or integration
snapshot rather than assuming this checkout's fixes exist in 0.4.2.
| Concern | Implemented behavior | Application responsibility |
| --- | --- | --- |
| State construction | Builders, `rows()`, `toSpec()` and `delta()` do not need a DOM | Keep source data and base declarations stable; choose date and identity conventions |
| Mount | `await sequence(states, options)` requires at least two states and a browser DOM | Attach an empty, exclusively owned target; set its dimensions |
| One static state | `await transition(state, state, options)`, then `.progress(1)` | Keep a single-state story at authored position 0 |
| Scroll position | Sequence uses `0..states.length - 1`; finite inputs clamp | Measure anchors, apply holds and coalesce events; do not normalize the whole story to 0..1 |
| Playback | `.progress()` stops controller-owned playback; `.play()` returns the controller | Choose one input owner at a time; implement reduced-motion policy |
| Resize/theme | `.resize()` retains current progress and re-reads dimensions/theme | Give `height: 'auto'` a measurable CSS height; refresh after theme changes |
| Initialization | Creation returns a Promise; no cancellation signal or separate controller `ready` | Buffer desired progress and destroy late results from obsolete generations |
| Errors | Creation can reject; invalid progress/dimensions throw | Show the current generation's error and prevent stale errors replacing a newer preview |
| Cleanup | `.destroy()` is idempotent | Disconnect application observers, listeners and RAFs; remove the generation's mount |
Position domains are independent of narrative progress. `highlight()` emphasizes
marks, `where()` changes membership, and `focus()` changes the view. A legacy
field named “focus” must be interpreted by its behavior rather than translated
by spelling.
## Inline declarations and React rendering [#inline-declarations-and-react-rendering]
`chart.highlight({ region: 'North' })` produces a fresh immutable object on each
call, even when its declaration is unchanged. Collecting child props also creates
a new array. An application must not use either reference alone to rebuild its
session on every parent render or status update. Asking MDX authors to use React
memoization defeats the simple authoring contract.
Within a compiled document generation, the application can compare the complete
detached `toSpec()` declarations and retain equivalent step inputs. Preserve array
order and distinguish real data, encoding and metadata changes. Keep plugin
module identity and external dataset/reload inputs in the lifecycle dependencies;
equal serialized specs alone do not prove equivalent custom plugins or remote
data. Do not introduce a second chart normalizer.
Standard chart declarations are JSON-safe: serialization turns dates into ISO
strings and does not preserve arbitrary callbacks or class instances. A custom
builder's non-JSON output needs a conservative identity policy or a clear error,
not a fingerprint that silently drops values. Test repeated inline expressions
under parent/status/theme renders: initialization must settle, while changed
chart data must rebuild. Those component tests belong to the application.
## Isolate asynchronous generations [#isolate-asynchronous-generations]
[`mount-story.mjs`](https://github.com/SonghaiFan/visdelta/blob/main/examples/authoring/mount-story.mjs)
is a copyable application integration example. It creates a private child mount
per call, buffers the latest seek while creation is pending, supports single
states, and disposes a controller that arrives after unmount. Its `ready` property
belongs to the example wrapper, not to VisDelta's controller.
```js
const session = mountStory(host, states);
session.seek(currentPosition);
session.ready.catch(error => {
if (thisGenerationIsCurrent()) showError(error);
});
// Theme change: session.resize();
// Effect cleanup: remove application listeners, then session.destroy();
```
Removing a mount does not cancel creation or network requests. Pending work can
finish into the detached mount before its controller is destroyed. Do not reuse
that target for a newer generation. A published preview can keep the previous
chart until a replacement is ready; implementing that swap is application policy.
Sequence preparation uses hidden mounts under `document.body`. Put shared theme
tokens on the document root so preparation and visible charts see the same
theme. Scope styles and DOM queries per instance; avoid global chart IDs.
## Verification and feedback [#verification-and-feedback]
`npm run authoring:check` installs a frozen tarball in a separate consumer,
bundles the actual example imports and checks authored endpoints against
independent static renders. It also covers pending seeks, reverse scrubbing,
jumps, resizing, static states, multiple instances, obsolete generations and
failed preparation cleanup. It never aliases package imports to source files.
A downstream application still needs its own MDX compile/build, editing/save,
scroll mapping, desktop/narrow layout and accessibility tests. Report library
issues with the package version, native states, dimensions, requested progress,
observed frame and expected behavior. An unsupported authoring option is not
automatically a renderer bug. Diagnostic API proposals remain unimplemented.
# Use with React (/docs/guides/react)
> One Chart component for static states, transitions and sequences.
Import `Chart` from `visdelta/react`. React is an
optional peer dependency (React 18 or 19); other VisDelta entries do not import it.
The component owns mounting, asynchronous replacement and cleanup. Import
`visdelta/style.css` once in your application.
```tsx title="SalesChart.tsx"
'use client';
import { useMemo, useState } from 'react';
import { bar } from 'visdelta/bar';
import { Chart } from 'visdelta/react';
import 'visdelta/style.css';
export function SalesChart({ rows }: {
rows: { category: string; sales: number; profit: number }[];
}) {
const [progress, setProgress] = useState(0);
const states = useMemo(() => {
const a = bar(rows).x('category').y('sales');
const b = a.y('profit');
return [a, b, b.sort('profit', 'descending')];
}, [rows]);
const pair = useMemo(() => states.slice(0, 2), [states]);
return (
);
}
```
See [one chart, three ways](/docs/guides/json#one-chart-three-ways) for a
side-by-side example of the chain API, JSON spec and React.
## Component contracts [#component-contracts]
`state` accepts a builder, a plain `ViewSpec`, or a non-empty readonly array of
those values:
- One state or a one-element array displays a static chart at progress 0.
- Two states use the existing `transition()` runtime. Progress ranges from 0 to 1.
- Longer arrays use `sequence()`. Progress uses authored state positions: 0 is
the first state, 1 the second, and 1.5 is halfway from the second to the third.
- Empty arrays report an error through `onError` or an error boundary.
Changing `state` does not automatically start playback. Automatic planner stages
do not change the authored progress units.
`Chart` accepts `data` (named datasets), `width`, `height`, `chartStyle`,
`onReady`, `onError`, and ordinary host attributes such as `className`, `style`
and `aria-label`. The host's children belong to VisDelta; do not supply children
or `dangerouslySetInnerHTML`. Builders carry their chart modules; plain specs
still require the appropriate registered module.
Keep states, state arrays, named-data maps and chart-style modules stable
between unrelated renders. Avoid creating a new array directly in JSX when
its contents have not changed. Use module constants or `useMemo`; changing their
identity prepares a replacement. Dimension prop changes also prepare a new
controller; container resizing uses the runtime's existing resize handling.
For `height="auto"`, give the component host a CSS height, for example
`style={{ height: 400 }}`.
A replacement retains the previous progress, clamped to its range, unless
`progress` is supplied. The old chart stays visible until the new one is ready.
Failed replacements keep the old chart and call `onError`; without `onError`,
the error reaches React's nearest error boundary.
## Playback and custom controls [#playback-and-custom-controls]
Omit `progress` when the controller owns playback. `onReady` exposes the existing
controller API (`ChartController` is the transition/sequence controller union), including `play()`, `pause()`, `progress()` and `resize()`:
```tsx
const controller = useRef(null);
{
controller.current = change;
return () => { controller.current = null; };
}}
onError={error => setError(error.message)}
/>
controller.current?.play({ from: 0, to: 1, duration: 800 })}>
Play
```
Import `useRef` from React and `ChartController` from
`visdelta/react`. The optional cleanup returned by `onReady` runs before
replacement or unmount. The component destroys its controller; callers should
not destroy it themselves. Changing callbacks does not recreate the chart.
Choose one progress owner: a controlled `progress` prop for a slider or scroll
position, or the controller for playback. Buttons, looping, viewport detection
and reduced-motion policies remain application concerns. After a CSS theme
change, call the controller's `resize()` to refresh its colours.
## Server rendering [#server-rendering]
The entry is marked `'use client'`. Server rendering emits an empty host;
chart rendering begins after mounting in the browser. This is not server-side
SVG rendering. In Next.js, construct builder instances inside a client
component rather than passing class instances across the server/client boundary.
`delta()` remains DOM-free and can run on the server.
## Other frameworks [#other-frameworks]
Use the existing imperative API: create a transition when its host is attached,
drive `progress()` or `play()`, and call `destroy()` when the host is removed.
# Copy a scroll story (/docs/guides/scroll-template)
> A runnable single-file template with measured step positions, holds, replay, themes and an explicit iframe contract.
[Open the complete HTML template](/templates/scroll-story.html). Save that file
as `index.html`, run a static server such as `npx serve .`, and open its HTTP
URL. It pins both JavaScript and CSS to VisDelta **0.4.2**; it needs network
access to jsDelivr, but no bundler, application framework or external dataset.
The source of truth is
[`examples/templates/scroll-story.html`](https://github.com/SonghaiFan/visdelta/blob/main/examples/templates/scroll-story.html).
The documentation build copies it unchanged. Its inline data is fictional.
## Replace data, then authored states [#replace-data-then-authored-states]
The `AUTHORED STORY` section contains rows and `makeStates(line)`. Each state
must have one matching `.step` card, and all states use the same chart type.
The template currently shows Data membership, then Attention, then clearing
Attention; categories still describe the changes, not the scroll schedule.
Dates in this example mean local calendar dates, constructed with
`new Date(year, monthIndex, day)`. Use an explicit instant/timezone convention
instead when your data represents timestamps. The current axis is local-time.
The source data and actual state-construction code appear before the chart in
the page's expandable source section. Edit those source definitions rather than
maintaining a second, separately synchronized animation.
## Scroll contract [#scroll-contract]
Step anchors are measured from card positions in document coordinates. The
reading trigger is 65% down the viewport. Between two anchors:
| Fraction of the interval scrolled | Story position |
| --- | --- |
| 0 to 0.45 | Hold the current integer keyframe |
| 0.45 to 1 | Interpolate to the next integer keyframe |
| At the next anchor | Exactly the next authored state |
`HOLD` controls the hold fraction in the template. This is page-control policy,
not a VisDelta timing option. The same measured mapping works in reverse and
with unequal card heights. Layout changes remeasure anchors. Events are
coalesced with `requestAnimationFrame`, and unchanged progress does not redraw
the chart. The template waits for fonts and `await sequence(...)` before
installing its controls; no `story.ready` Promise is needed.
Replay advances through the same scroll mapping, including holds. Wheel,
touch, scroll-navigation keys, slider input or a parent message can take over.
The slider uses **leg units**, from 0 to 3 for four authored states. It works
without any scrolling; the next real scroll event takes control back from a manual selection. With reduced motion, positions snap to authored states
and Replay jumps to the last state rather than animating.
Theme tokens live on the document root so sequence preparation mounts see the
same theme. A theme change calls `story.resize()` at the current position.
Automatic dimensions follow the chart container; viewport resize also refreshes
the controller. The page disconnects listeners/observers and destroys the
sequence when it leaves the document.
## Iframes: pick one owner for progress [#iframes-pick-one-owner-for-progress]
- **An iframe with an internal scroll area:** the child uses its own scroll
position and the normal template behavior.
- **A frame expanded to its content height:** the child has no internal scroll
travel. The template shows a note and retains the position slider and replay.
Its capped section heights avoid viewport-height sizing expanding forever.
- **A cooperating parent:** configure the exact `PARENT_ORIGIN` in the template
and send positions in sequence leg units. The child accepts only the configured
origin, its actual parent window, the matching story ID and a finite position.
Values are clamped to the story range.
A cross-origin child cannot read its parent's scroll position. CSS sticky is
relative to the child's viewport; this template does not promise to keep a chart
visible while a very tall iframe scrolls past in the parent. Prefer a normally
scrolling frame or host the story in the parent for that presentation.
For cooperative control, set `PARENT_ORIGIN` in the child and adapt this parent
code to the publication's own step measurements:
```js
const childOrigin = 'https://story.example'; // exact child origin
const frame = document.querySelector('iframe');
let ready = false;
window.addEventListener('message', (event) => {
if (event.origin !== childOrigin || event.source !== frame.contentWindow) return;
if (event.data?.type === 'visdelta:story-ready' && event.data.id === 'network-story') ready = true;
});
function sendPosition(progress) {
if (!ready || !Number.isFinite(progress)) return;
frame.contentWindow.postMessage({
type: 'visdelta:story-progress', id: 'network-story', progress,
}, childOrigin);
}
// Map the parent publication's step measurements to 0..3, then call sendPosition.
```
The initial handshake prevents early progress messages being lost while the
sequence is preparing. The template never accepts wildcard origins.
## What is verified [#what-is-verified]
Browser checks exercise the actual copied HTML at desktop and narrow widths:
forward/reverse scroll, holds, complete keyframes, replay/interruption,
resizing, dark theme, reduced motion, an internally scrolling iframe, a
content-height iframe and the opt-in parent message contract. Offline CI routes
the pinned package URLs to the frozen local build; a separate CDN smoke mode
runs the unchanged page against the published package. See the
[agent workflow](/docs/guides/agent-workflow) for the current diagnostic APIs.
# Multi-step stories (/docs/guides/stories)
> Use sequence() to join several authored states into one seekable timeline.
When a chart moves through more than two states, build them all up front and
pass them to `sequence()`. You get one controller for the whole timeline, with
no empty frame between legs.
## Build the states [#build-the-states]
```ts
import { bar } from 'visdelta/bar';
import { sequence } from 'visdelta/transition';
const revenue = bar(products).x('product').y('revenue');
const profit = revenue.y('profit');
const ranked = profit.sort('profit', 'descending');
const emphasised = ranked.highlight({ product: 'Software' });
const story = await sequence([revenue, profit, ranked, emphasised], {
target: '#chart',
});
```
All states must use the same chart type. To return to the start, repeat the
first state at the end: `[revenue, profit, ranked, revenue]`.
## Seek in leg units [#seek-in-leg-units]
A sequence's `value` runs from `0` to `states.length - 1`. Integers are the
authored states; fractions are positions within a leg.
```ts
story.progress(0); // revenue
story.progress(1.5); // halfway from profit to ranked
story.progress(3); // emphasised
story.play({ to: 2 }); // from the current position to ranked
```
## Scroll-driven steps [#scroll-driven-steps]
Start from the [single-file scroll template](/docs/guides/scroll-template).
It maps measured step positions to sequence leg units, holds each authored
state before advancing, and handles reverse scrolling, replay, resize,
reduced motion and iframe fallback. Keep scroll control outside the library.
The [progress model](/docs/concepts/progress) explains why authored states stay
at integers even when the planner inserts extra route stages.
## Authored states are fixed [#authored-states-are-fixed]
Each state you pass is a boundary the planner keeps. Within each pair, the
chart may still insert generated stages; these occupy the existing leg and do
not shift integer positions. They do add playback time, because `duration`
applies to every serial stage. See [Progress](/docs/concepts/progress#play).
# Styling and themes (/docs/guides/styling)
> Structural chart styles, CSS token themes and dark mode. Style changes presentation, never meaning.
Style changes presentation. It never changes data meaning or transition meaning:
a style can recolour an already-declared colour mapping, but it cannot decide to
map a field to colour.
## Structural presets [#structural-presets]
Pass a chart style to the transition. Both endpoints use the same style.
```ts
import { paperChartStyle } from 'visdelta/chart-style';
const change = await transition(from, to, {
target: '#chart',
chartStyle: paperChartStyle,
});
```
| Preset | Character |
| --------------------- | ----------------------------------------------------------- |
| `d3ChartStyle` | The default |
| `paperChartStyle` | Print-like: square figure, warm paper surface |
| `darkChartStyle` | Dark surface with a monochrome blue series palette |
| `editorialChartStyle` | Ink on paper: hairline grids, tabular numerals, flat figure |
`chartStylePresets` collects them by key (`d3`, `paper`, `dark`, `editorial`).
The runtime scopes each preset to its own `.vd-style-*` root, so transitions on
one page can use different styles. Bars keep square corners in every style.
Page theme tokens are inherited. A preset's local `.vd-style-*` declarations
override inherited values; tokens the preset does not define continue to inherit.
For example, Paper keeps its light surface and dark neutral ink on a dark page,
while the default D3 style follows page tokens. More specific application CSS can
still override a preset. Explicit chart colors, palettes and schemes take
precedence over the style's default mark ink or categorical palette.
Choose a preset above to restyle five chart types at once; the shared slider
seeks all five transitions.
## Define a structural style [#define-a-structural-style]
A chart style sets spacing, margins, grid policy, domain lines, title placement
and legend placement:
```ts
import { defineChartStyle } from 'visdelta/chart-style';
const compact = defineChartStyle({
key: 'compact',
tickSpacing: { x: 56, y: 44 },
legendPosition: 'right',
plot: { grid: 'horizontal', margin: { top: 48, right: 88 } },
});
```
The default `legendPosition: 'top'` draws the legend in a block above the canvas,
outside the chart's SVG, so it does not count toward the chart's `width` or
`height`. `'right'` keeps the legend inside the canvas, in its right margin.
Values merge in order: neutral defaults, the chart module's own defaults, then
the style's overrides. An authored `spec.margin` is the final override. Margins
are preferred spacing, not fixed reservations: whitespace adapts to the
container while axis text keeps its size. A transition shares plot margins
across its route; space for a top or right legend opens and closes with playback.
In Fit mode, top-legend space also change the remaining canvas height.
Use a stable module reference for a mounted chart. A style contains functions
such as `axisTitle`, so it is not a JSON configuration. Selecting a different
module requires creating a replacement transition or sequence; `resize()`
refreshes dimensions and CSS tokens, not structural style rules. Apply the last
requested progress to the replacement before showing it.
A custom `key` scopes the root class but does not generate CSS. Its module sets
structure; add scoped CSS tokens separately if it needs a new visual palette or
font. Reusing a built-in key also picks up that key's existing CSS.
## CSS tokens [#css-tokens]
VisDelta reads its colours, type and line weights from CSS custom properties
when it renders. Import `visdelta/style.css` once; it defines the default
`--vd-*` tokens. Optional token themes are available as
`visdelta/themes/default.css`, `dark.css` and `paper.css`.
Override tokens on a wrapper to restyle charts without changing any state:
```css
.my-chart .vd-transition-root {
--vd-color-surface: #ffffff;
--vd-color-mark-default: #17171a;
--vd-grid: #e8e8e4;
--vd-axis-text: #5f5f66;
}
```
Without a colour declaration, marks use the style's neutral ink
(`--vd-color-mark-default`) and draw no colour legend. Declared categorical
palettes use `--vd-series-1` to `--vd-series-10` unless the state names a
`scheme` or `range`. The defaults are VisDelta's logo blue, amber and coral,
followed by seven more hues.
## Dark mode [#dark-mode]
The charts on this site follow the site theme by overriding tokens under a
`.dark` ancestor. Because tokens are read at render time, refresh the chart after
the theme changes:
```ts
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
change.resize(); // re-reads tokens, keeps the current progress
});
```
# Troubleshooting (/docs/guides/troubleshooting)
> The errors VisDelta throws, what causes them and how to fix them.
VisDelta validates declarations instead of guessing, so most problems surface
as an explicit error. The messages below are quoted from the library.
Before inventing an option or a scroll controller, check the
[automatic behavior table](/docs/guides/automatic-behaviors),
[agent workflow](/docs/guides/agent-workflow) and
[runnable scroll template](/docs/guides/scroll-template).
## Setup [#setup]
`transition()` and `sequence()` require a `target`. The library never guesses a
host element.
```ts
await transition(from, to, { target: '#chart' });
await transition(from, to, { target: document.querySelector('#chart')! });
```
The selector matched nothing when the transition was created. Create the
transition after the element is in the document, for example in a component's
mount effect, or pass the element itself.
Both endpoints must come from the same chart family. A Bar state cannot
transition into a Line state. `delta()` has the same rule. Use one chart type
and change its encodings, layout or grain instead.
A **plain spec** was rendered without its chart module. Builders carry their
module; plain objects cannot. Register the module first:
```ts
import { barModule } from 'visdelta/bar';
import { registerChartModule } from 'visdelta/plugins';
registerChartModule(barModule);
```
Import the stylesheet once: `import 'visdelta/style.css'`, or link
`dist/visdelta.css` from the CDN.
Browsers block ES modules on `file://`. Serve the folder with any static server,
for example `npx serve`, and open the `http://` URL it prints.
## Data [#data]
A bare string could be a file or a named dataset. Be explicit:
```ts
bar({ url: 'sales.csv' }); // a file
bar({ name: 'sales' }); // a named dataset, supplied in transition options
```
A state uses `{ name: 'sales' }`, but the transition options do not provide it:
```ts
await transition(from, to, { target: '#chart', data: { sales: rows } });
```
The data source has an unsupported shape. Pass an array of objects,
`{ values: rows }` or `{ url }`.
A hand-written transform has an unknown key, or more than one operation in one
entry. Each entry holds exactly one of `filter`, `timeUnit`, `fold`, `bin`,
`aggregate`, `sort` or `limit`. See [Transforms](/docs/concepts/data-and-transforms#transforms).
Selectors use the operator `equal`, not `equals`. Range operators (`gt`, `gte`,
`lt`, `lte`) need finite numbers, `Date` values or ISO date strings, and
missing values never match a range. Repeated calls narrow the selection with
AND; use `.reset()` to start over. See [Selectors](/docs/concepts/selection#selectors).
## Playback [#playback]
A controller was used after `destroy()`. In component code, clear your reference
in the cleanup function, and guard asynchronous callbacks. See
[Use with React](/docs/guides/react).
`progress()` received `NaN` or `Infinity`, often from an empty input value.
Finite values are clamped to `[0, 1]` automatically.
On a pair controller, `play()` starts from `0` unless you pass `from`. Resume
with `change.play({ from: change.value, to: 1 })`. A sequence's `play()`
already starts from its current value.
`duration` applies to each **serial route stage**. A route with generated
intermediate states has several stages, so it takes a multiple of `duration`.
See [Progress](/docs/concepts/progress#play).
VisDelta reads CSS tokens when it renders. Call `change.resize()` after changing
the theme. See [Dark mode](/docs/guides/styling#dark-mode).
Marks that should continue need matching identity at both endpoints. Add
`.key()` with a field that identifies the same item in both states. See
[Identity](/docs/concepts/identity).
## Still stuck? [#still-stuck]
Open an issue on [GitHub](https://github.com/SonghaiFan/visdelta/issues) with a
minimal pair of states. `JSON.stringify(state.toSpec())` for both endpoints is
usually enough to reproduce a problem.
# Updating a chart in an app (/docs/guides/updating-a-chart)
> Keep a chart on screen and animate it to each new state your application produces.
There is no "mounted chart" object to mutate. A chart that stays on screen is a
transition held at an endpoint, and your application owns the current state.
```ts
import { transition } from 'visdelta/transition';
let state = byRegion;
let held = await transition(state, state, { target: '#chart' });
held.progress(1);
async function go(next: typeof state) {
const change = await transition(state, next, { target: '#chart' });
held.destroy();
held = change;
state = next;
change.play({ duration: 700 });
}
await go(state.focus({ region: 'North' }));
```
`transition()` resolves both states before touching the target, so the
previous chart stays visible until the next one is ready.
## Replacing, not narrowing [#replacing-not-narrowing]
Every grammar call returns a new state, so `go(state.focus(...))` is the whole
update. Remember that selections narrow. `state.focus(north).focus(south)` means
their intersection; to move the camera from North to South, reset first:
```ts
await go(state.reset().focus({ region: 'South' }));
```
## Rapid updates [#rapid-updates]
If updates can arrive faster than a transition is prepared, serialize them so
that each new transition starts from the state that is actually on screen:
```ts
let queue = Promise.resolve();
function update(next: typeof state) {
queue = queue.then(() => go(next));
return queue;
}
```
## Known paths [#known-paths]
When the states are known up front, prefer [`sequence()`](/docs/guides/stories).
It prepares every leg in advance and hands off at shared endpoints, so `A → B → C`
never shows an empty frame between legs.
# Visualization warnings (/docs/guides/warnings)
> Advisory design checks that never change what renders.
VisDelta can point out common design problems, such as a truncated bar baseline
or too many categories sharing one colour. Warnings are advice only: charts
still render, and declarations and transition plans are unchanged.
## Inspect without rendering [#inspect-without-rendering]
```ts
import { bar, visualizationWarnings } from 'visdelta';
const chart = bar([
{ category: 'A', value: 12 },
{ category: 'B', value: 14 },
])
.x('category')
.y('value', { domain: [10, 20] });
const warnings = await visualizationWarnings(chart);
warnings[0].code; // 'bar.truncated-baseline'
```
`visualizationWarnings(visualization, datasets?)` returns a promise of
`VisualizationWarning[]`. Supply named data in `datasets`; the inspection does
not fetch remote data. Plain specs need a registered chart module, just like
transitions.
Each warning has a `code`, `severity: 'warning'`, a `message`, a `suggestion`,
`evidence`, and optionally a `field` and a `source` link to the design guidance
behind it.
## While rendering [#while-rendering]
Rendering reports the same warnings in three ways:
- as `console.warn` messages, deduplicated per host;
- as a bubbling `visdelta:warnings` event whose `event.detail` is the warning
array;
- as JSON in the chart scene element's `data-visdelta-warnings` attribute.
```ts
document.querySelector('#chart')!.addEventListener('visdelta:warnings', (event) => {
const warnings = (event as CustomEvent).detail;
console.table(warnings.map((warning: { code: string }) => warning.code));
});
```
An empty array clears previously reported diagnostics. Warnings describe
evaluated chart states, not every animation frame.
## Rules [#rules]
| Code | Trigger |
| ---------------------------------- | ----------------------------------------------------------------------- |
| `bar.too-many-stacked-columns` | More than 10 categories in vertical stacked bars |
| `bar.truncated-baseline` | An explicit measure domain that excludes zero |
| `point.too-many-dots-per-category` | More than 3 observations per category on a categorical × quantitative plot |
| `point.small-sample` | 1–4 observations on two quantitative axes |
| `point.coincident-positions` | Observations at identical x/y positions |
| `point.negative-size` | Negative values in a mapped size field |
| `connection.unordered-axis` | A Line or Area x channel that is nominal |
| `connection.smoothing` | Natural or cardinal interpolation with at least three x values |
| `color.too-many-categories` | More than 5 nominal values sharing a colour channel |
| `line.too-many-series` | More than 10 series |
| `area.too-many-series` | More than 6 layers |
| `area.nonzero-baseline` | A single area with a nonzero baseline |
| `area.negative-components` | Multiple layers with negative values |
| `pie.too-many-slices` | More than four slices |
| `pie.simple-share` | One or two slices |
Counts use the transformed rows, so filtering and aggregation affect the
advice. The thresholds are VisDelta heuristics, not universal perceptual laws.
Warnings never collapse categories, recolour marks or rewrite domains.
Some judgements are out of scope, because they depend on context the library
cannot see: author intent, causal claims, annotation quality, label collisions,
palette accessibility and whether pie slices form a complete whole. Review these
yourself.
## In a chart plugin [#in-a-chart-plugin]
A plugin declares an optional pure `warnings(spec, rows)` inspector through
`defineChartType`. It receives the prepared spec and transformed rows. Plugins
without this hook report no warnings.
# Overview (/docs)
> Write a chart one chained call at a time. Every call is a new state, and VisDelta draws the change.
You write a chart one chained call at a time. Every call gives you a new
state, and every state can move to the next one: VisDelta works out what
changed and draws every frame in between.
Drag the scrubber. Each position is a real chart: the bars, the axis and its
title always agree. Press play again at the end and the same frames run
backward.
## Three ideas [#three-ideas]
**Every call is a state.** `bar(rows).x("product").y("revenue")` describes a
chart. It draws nothing and never changes. Add `.y("profit")` and you have the
next state; the one before it is still there.
**Any change can move.** `transition(a, b)` matches the marks that stay, the
ones that leave and the ones that arrive, and plans a route from one state to
the next. `sequence([a, b, c])` strings many states into one timeline.
**Progress is just a number.** `progress(0.42)` shows one frame. A button, a
slider, scroll position or a timeline can supply that number. VisDelta never
decides when.
## What it is not [#what-it-is-not]
It does not clean data, guess which field should be colour, or morph one chart
type into another. A Bar state transitions to another Bar state.
## Start here [#start-here]
# Installation (/docs/installation)
> Install VisDelta from npm or load it directly in a browser. Then import a chart module, the transition runtime and the stylesheet.
VisDelta is an ESM-only package. It depends only on the `d3-*` modules it
renders with, and it runs declared transforms itself, so you don't need to
install anything else.
## From npm [#from-npm]
Use the package when your application already has a bundler.
```npm
npm install visdelta
```
Import the chart module you use, the transition runtime and the required CSS:
```ts title="main.ts"
import { bar } from 'visdelta/bar';
import { transition } from 'visdelta/transition';
import 'visdelta/style.css';
```
Focused entry points keep each chart module independent. Importing from the
package root is also supported when one convenient entry is preferable:
```ts
import { bar, transition } from 'visdelta';
```
Chart renderers load lazily in both cases: a builder carries a reference to its
module, and `transition()` imports the renderer when it first needs it.
Import `visdelta/style.css` once per page. Without it, chart roots, axes and
tooltips are unstyled.
## From a CDN, without a bundler [#from-a-cdn-without-a-bundler]
The package root can be imported directly from jsDelivr. The `/+esm` suffix asks
jsDelivr to rewrite VisDelta's bare `d3-*` imports to version-pinned CDN
modules, which is the same convention D3 uses.
```html title="index.html"
```
Pin the version for durable pages. `vd` is a local module namespace, not a
global variable. See [Use without a bundler](/docs/guides/cdn) for a complete
page, the global script and focused CDN entries.
## Entry points [#entry-points]
| Import | Contents |
| ------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `visdelta` | Every built-in builder plus `delta`, `transition`, `sequence` and helpers |
| `visdelta/bar`, `/line`, `/area`, `/point`, `/unit`, `/pie` | One focused chart builder and its module |
| `visdelta/transition` | `transition()` and `sequence()` |
| `visdelta/core` | DOM-free state, difference, identity, lineage and camera helpers |
| `visdelta/chart-style` | Structural chart styles and presets |
| `visdelta/plugins` | Define and register independent chart modules |
| `visdelta/toolkit` | Optional compiler tools, renderer base and plot-style composition |
| `visdelta/facet` | `smallMultiples()` composition |
| `visdelta/browser` | Browser-global dependency adapter |
| `visdelta/style.css` | Required base stylesheet |
| `visdelta/themes/default.css`, `dark.css`, `paper.css` | Optional CSS token themes |
See the [API reference](/docs/api) for what each entry exports.
## Requirements [#requirements]
- **Browsers**: rendering requires a DOM. `delta()` and the other `visdelta/core`
helpers are DOM-free and also run in Node.
- **Node**: the published ESM package supports Node 18 and later.
- **TypeScript**: declarations ship with the package. No `@types` package is needed.
# Quick start (/docs/quick-start)
> Your first transition in four steps.
### Describe a chart [#describe-a-chart]
```ts
import { bar } from 'visdelta/bar';
const products = [
{ product: 'Hardware', revenue: 12, profit: 5 },
{ product: 'Software', revenue: 18, profit: 11 },
{ product: 'Services', revenue: 9, profit: 7 },
{ product: 'Support', revenue: 6, profit: 4 },
];
const a = bar(products)
.x('product', { label: 'Product line' })
.y('revenue', { label: 'Revenue ($M)' });
```
This is a description, not a drawing. Nothing renders yet.
### Describe the change [#describe-the-change]
```ts
const b = a.y('profit', { label: 'Profit ($M)' });
```
`a` is untouched. `b` is a new state that differs in one line.
### Connect them [#connect-them]
```ts
import { transition } from 'visdelta/transition';
import 'visdelta/style.css';
const change = await transition(a, b, { target: '#chart' });
```
VisDelta draws `a` into `#chart` and prepares every frame up to `b`.
### Move through it [#move-through-it]
```ts
change.play(); // a → b
change.play({ from: 1, to: 0 }); // b → a, the same frames backward
change.progress(0.42); // one exact frame
```
## Drive it with anything [#drive-it-with-anything]
```ts
slider.addEventListener('input', () => change.progress(slider.valueAsNumber));
```
A slider, a button, scroll position or a video's clock. They all call
`progress()`.
## See what changed [#see-what-changed]
```ts
import { delta } from 'visdelta/core';
delta(a, b).stateChanges;
// [{ category: 'encoding', action: 'remap', channel: 'y' }]
```
This is the **Δ** line under every chart on this site.