# 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)} /> ``` 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.