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/).
Advanced
This guide is for authors of new chart families. To restyle or combine existing charts, see Styling and themes and Small multiples.
Package layout
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 motionUse 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
A builder extends ChartState and returns its module. Use this.channel() and
this.with() rather than mutating state.
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
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
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
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().
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,enterandexitare 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
A builder carries its module, so it works with visdelta/transition without
registration:
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
- Identity is declared. Source-record
datumKeyand markkeyare 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 cover the full runtime contract, the toolkit's geometry, topology, matching and policy helpers, and the checklist for an official chart.