Events & custom drawing
Every chart in the package inherits one event surface and one drawing API from a common base class. The events are cancellable — a listener can veto the chart's built-in reaction — and the drawing API lets you paint your own annotations on top, in the chart's own coordinates, without forking anything.
Listening
chart.on(event, fn) registers a listener and returns a function that removes it. The
handler receives one object carrying the payload's own fields plus the cancellation
members — there is no detail wrapper, so a handler written against a payload
(e.bar, e.index) reads exactly the same as it always did.
const off = chart.on('click', (e) => {
console.log(e.meta, e.px, e.button);
console.log(e.type, e.native, e.cancelable, e.defaultPrevented);
});
off(); // unsubscribe
| Member | Type | Description |
|---|---|---|
type | string | Event name, so one function can serve several registrations. |
native | Event | null | The originating DOM event, or null for programmatic and internal causes. |
cancelable | boolean | Whether preventDefault() does anything for this event. |
defaultPrevented | boolean | True once any listener has cancelled the built-in reaction. |
preventDefault() | () => void | Suppress the chart's built-in reaction. A no-op when cancelable is false. |
Cancelling the default
Cancelling stops the chart from acting, not the other listeners from hearing about it — every registered handler still runs. That matters when one part of an app observes an event while another part governs it.
// Read-only chart: keep the view exactly where it was put.
chart.on('pan', (e) => e.preventDefault());
chart.on('zoom', (e) => e.preventDefault());
// Reject data that fails validation — the chart never rebuilds.
chart.on('dataAdd', (e) => {
if (e.items.some(isOutOfRange)) e.preventDefault();
});
Event reference
These nine are on every chart. Each visual adds its own on top — hover everywhere,
plus seriesFocus, seriesChange, select or
highlight depending on the chart; see that chart's Events section.
| Event | Payload fields | Cancel? | Fires |
|---|---|---|---|
click | meta, px, button | yes | On pointer-up over the chart. meta is the mark under the pointer, or null for the background. |
dblclick | meta, px, button | yes | On a double click, same payload as click. |
drag | phase, target, dx, dy, px | on 'start' |
Each phase of a press-move-release. target is 'view' (pan) or 'slider' (the pie/candlestick series slider). |
pan | offset, delta | yes | Before the view translates — from a drag, a programmatic panBy()/panTo(), or the zoom controls. |
zoom | scale, factor, center | yes | Before the view scales — wheel, buttons, or zoomBy()/zoomTo(). |
drawFinished | now, frame | no | After each rendered frame. See the warning below. |
dataAdd | action, items | yes | Before add() applies. |
dataUpdate | action, items | yes | Before update() / setData() applies. action is 'update' for a patch list, 'replace' for a whole dataset. |
dataRemove | action, items | yes | Before remove() applies; items holds the refs or indices passed in. |
drawFinished fires every animation frame — roughly sixty times a
second while anything is animating. Keep the handler cheap, and never let it trigger a re-render or
a layout read. It is a notification about work already done, so it is not cancellable.
pan and zoom only fire on charts
with panZoom.enabled — the pie and wind rose have no view to move. And the wind rose
emits click on pointer-down rather than pointer-up, because that is when its
petal selection has always happened; cancelling still suppresses that selection.
Order of precedence
Charts that expose interaction.mouse hooks (the wind rose and candlestick) now have two
override layers. They run in a fixed order, and each can stop the ones below it:
| # | Layer | Can |
|---|---|---|
| 1 | chart.on('click', …) listeners | Observe, and veto everything below. |
| 2 | interaction.mouse.onPetalClick & friends | Replace or wrap the built-in behavior; call defaultAction() to run it. |
| 3 | The chart's built-in behavior | Selection, focus, slider seek, pan. |
Data events fire first
The three data* events fire before the dataset changes, which is the
only point at which a veto can mean anything — a listener that reverted the chart afterwards would
still have paid for a rebuild and a visible frame. When a listener cancels, the call returns having
done nothing at all.
chart.on('dataAdd', (e) => e.preventDefault());
chart.add({ x: 'F', y: 42 });
chart.getData().points.length; // unchanged
Drawing layers
A layer is a callback invoked once per frame with a 2D context and the projections needed to place things in the chart's own coordinates. Layers paint onto a dedicated canvas above the chart's axes and legend, so they never contend with the chart's own repaint schedule.
| Method | Returns | Description |
|---|---|---|
addLayer(draw, options?) | ChartLayer | Register a per-frame paint callback. Options: id, z (paint order, low first), visible. |
addElement(element) | ChartLayer | Register one declarative annotation — see below. |
clearLayers() | void | Remove every layer and element. |
getLayerCount() | number | How many layers are registered. |
The returned handle has id, mutable z and visible,
setDraw(fn) to swap the callback in place, and remove(). Adding a layer
with an id that is already taken replaces it rather than stacking a second one.
const layer = chart.addLayer((c) => {
// Mark the mean, in the chart's own data coordinates.
const a = c.toDevice(0, mean);
const b = c.toDevice(1, mean);
c.ctx.strokeStyle = '#ff3b6b';
c.ctx.lineWidth = 2 * c.dpr;
c.ctx.beginPath();
c.ctx.moveTo(a.x, a.y);
c.ctx.lineTo(b.x, b.y);
c.ctx.stroke();
}, { id: 'mean', z: 10 });
layer.visible = false;
layer.remove();
The context passed to draw:
| Field | Type | Description |
|---|---|---|
ctx | CanvasRenderingContext2D | Already cleared, sized in device px. |
deviceW / deviceH | number | Canvas size in device px. |
dpr | number | Device pixel ratio — multiply CSS px by this. |
plotRect | [x0,y0,x1,y1] | The box the marks are laid out in, as canvas fractions (y-up). |
view | ViewTransform | Live pan/zoom, or identity on charts without it. |
now | number | Seconds since the current animation started. |
toDevice(x, y, space?) | { x, y } | Project a point to device px, ready to draw. |
toSpace(x, y, space?) | { x, y } | The inverse, for hit-testing your own marks. |
Declarative elements
addElement covers the common annotations without writing canvas code. Every element
accepts space, stroke, fill, lineWidth,
lineDash, opacity, plus the layer options id,
z and visible. Widths and dashes are in CSS px and are
scaled for you.
kind | Geometry | Notes |
|---|---|---|
'line' | from, to | Two points. |
'rect' | at, width, height, radius? | at is the lower-left corner in data/plot space. Both corners are projected, so it tracks a zoom correctly. |
'circle' | center, radius | The radius is in CSS px — a data-space radius would go elliptical under a non-uniform zoom. |
'text' | at, text, font?, align?, baseline? | font is a CSS shorthand sized in CSS px. |
'path' | points, closed? | A polyline, optionally closed. |
// A target band, plus the rule and label that go with it.
chart.addElement({
kind: 'rect', at: [0, 0.6], width: 1, height: 0.15,
fill: 'rgba(56,189,248,0.12)',
});
chart.addElement({
kind: 'line', from: [0, 0.75], to: [1, 0.75],
stroke: '#38bdf8', lineDash: [4, 4],
});
chart.addElement({
kind: 'text', space: 'plot', at: [0.02, 0.78],
text: 'target', fill: '#38bdf8', font: '12px system-ui',
});
Coordinate spaces
Both addLayer's projections and every element take a space, defaulting to
'data':
space | Units | Use for |
|---|---|---|
'data' | [0,1] per axis, y-up, follows pan/zoom | Anything pinned to the data. This is the space mark metadata is already in
(BarMeta.x0, slice angles, …), so an annotation on a mark belongs here. |
'plot' | [0,1] per axis, y-up, ignores pan/zoom | Things anchored to the frame — a corner badge, a legend of your own. |
'canvas' | CSS px from the top-left, y-down | DOM-style placement, when you already have a pixel offset. |
'data' and 'plot'
are the disc box rather than a plot rect — the same space their slice and segment metas use,
so the two line up.
In React
Every event above is a prop on the React components, and layers have a layers prop.
See React → Standard events.
<BarChart
data={data}
layers={layers}
onClick={(e) => e.preventDefault()}
onDataAdd={(e) => console.log(e.action, e.items)}
/>
© 2026 · License · React bindings →