Custom
A custom series lets you draw your own shapes and annotations alongside other
series, using the same plot coordinates. Use it for specialized marks, regions,
or overlays. Provide a renderer that draws on the canvas
with access to the plot bounds, scales, and helpers for converting data
positions into pixel coordinates. The examples below are just starting points.
Custom series provide an escape hatch beyond the built-in series, giving you
the freedom to draw any visualization you can create on the canvas.
Confidence ellipses
Overlay 95% contours for two specified Gaussian models on their sampled points. Draw the ellipse in data coordinates so its shape follows the axis scales.
const groups = [ { x: 32, y: 40, sx: 10, sy: 5, angle: 0.6 }, { x: 68, y: 62, sx: 8, sy: 5, angle: -0.8 }, ]
const points = groups.flatMap((group, g) => Array.from({ length: 80 }, (_, i) => { const radius = Math.sqrt(-2 * Math.log((i + 0.5) / 80)) const theta = i * 2.399963 const u = group.sx * radius * Math.cos(theta) const v = group.sy * radius * Math.sin(theta) return { x: group.x + u * Math.cos(group.angle) - v * Math.sin(group.angle), y: group.y + u * Math.sin(group.angle) + v * Math.cos(group.angle), group: g, } }))
const confidence_ellipses = plot({ series: [ plot.scatter({ data: points, size: 3, color: pure(row => Number(row.group) === 0 ? { light: '#0d9488', dark: '#5eead4' } : { light: '#7c3aed', dark: '#c4b5fd' }), tooltip: pure(({ x, y }) => h("span", {}, Number(x).toFixed(1) + ', ' + Number(y).toFixed(1))), }), plot.custom({ data: groups, x: 'x', y: 'y', color: pure((_, i) => i === 0 ? { light: '#0f766e', dark: '#5eead4' } : { light: '#6d28d9', dark: '#c4b5fd' }), hoverable: false, renderer: pure((_, { ctx, x_scale, y_scale, color_at }) => { ctx.save() ctx.lineWidth = 2 groups.forEach((group, i) => { ctx.strokeStyle = color_at(i) ctx.beginPath() // The 95% contour of the specified bivariate Gaussian model. for (let step = 0; step <= 100; step++) { const theta = step / 100 * Math.PI * 2 const u = Math.sqrt(5.991) * group.sx * Math.cos(theta) const v = Math.sqrt(5.991) * group.sy * Math.sin(theta) const x = Number(x_scale(group.x + u * Math.cos(group.angle) - v * Math.sin(group.angle))) const y = Number(y_scale(group.y + u * Math.sin(group.angle) + v * Math.cos(group.angle))) if (step === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) } ctx.closePath() ctx.stroke() }) ctx.restore() }, { groups }), }), ], height: 260, margin: { top: 8, right: 8, bottom: 24, left: 30 }, axis: { x: { min: 0, max: 100, label: '' }, y: { min: 0, max: 100, label: '' } }, background: { light: '#ffffff', dark: '#151b28' }, chrome_color: { light: '#e2e8f0', dark: '#334155' }, grid: false, border: false, zoom_pan: false, })
// In Antithesis notebook, plot() returns a Printable. Uncomment below to display it: // print(confidence_ellipses) // presentation.blank("Confidence ellipses").print(confidence_ellipses)Chord diagram
Connect five groups with ribbons whose widths represent shared volume. The outer arcs use the same weights, showing each group’s total connections. Hover an outer arc to inspect its total.
const names = ['A', 'B', 'C', 'D', 'E']
const matrix = [ [0, 12, 8, 4, 16], [12, 0, 18, 6, 3], [8, 18, 0, 14, 7], [4, 6, 14, 0, 11], [16, 3, 7, 11, 0], ]
const gap = 0.08
const total = matrix.flat().reduce((sum, value) => sum + value, 0)
const unit = (Math.PI * 2 - gap * names.length) / total
let angle = -Math.PI / 2
const groups = matrix.map((row, i) => { const start = angle const segments = row.map(value => { const from = angle angle += value * unit return { start: from, end: angle } }) const end = angle angle += gap return { name: names[i], total: row.reduce((sum, value) => sum + value, 0), start, end, segments } })
const chord_diagram = plot({ series: [plot.custom({ data: groups, color: pure((_, i) => [ { light: '#0d9488', dark: '#5eead4' }, { light: '#7c3aed', dark: '#c4b5fd' }, { light: '#db2777', dark: '#f9a8d4' }, { light: '#d97706', dark: '#fcd34d' }, { light: '#2563eb', dark: '#93c5fd' }, ][i]), hit_test: pure((_, { cursor, inner }) => { const cx = (inner.left + inner.right) / 2 const cy = (inner.top + inner.bottom) / 2 const radius = Math.min(inner.right - inner.left, inner.bottom - inner.top) / 2 - 32 const distance = Math.hypot(cursor.x - cx, cursor.y - cy) if (Math.abs(distance - (radius + 7)) > 8) return null let angle = Math.atan2(cursor.y - cy, cursor.x - cx) if (angle < -Math.PI / 2) angle += Math.PI * 2 const index = groups.findIndex(group => angle >= group.start && angle <= group.end) return index < 0 ? null : index }, { groups }), tooltip: pure(({ row }) => h("span", {}, 'Group ' + row?.name + ': ' + row?.total + ' connections')), highlight_renderer: pure((series, i, { ctx, inner, color_at, highlight_color_at }) => { const rows = series.rows const group = rows[i] const cx = (inner.left + inner.right) / 2 const cy = (inner.top + inner.bottom) / 2 const radius = Math.min(inner.right - inner.left, inner.bottom - inner.top) / 2 - 32 const point = (a) => [cx + radius * Math.cos(a), cy + radius * Math.sin(a)] // Match the main renderer: ribbons take the lower-index group's color. ctx.fillStyle = color_at(i) ctx.globalAlpha = 0.45 for (let j = i + 1; j < rows.length; j++) { const source = group.segments[j] const target = rows[j].segments[i] const [sx, sy] = point(source.start) const [tx, ty] = point(target.start) ctx.beginPath() ctx.moveTo(sx, sy) ctx.arc(cx, cy, radius, source.start, source.end) ctx.quadraticCurveTo(cx, cy, tx, ty) ctx.arc(cx, cy, radius, target.start, target.end) ctx.quadraticCurveTo(cx, cy, sx, sy) ctx.closePath() ctx.fill() } ctx.globalAlpha = 1 ctx.strokeStyle = highlight_color_at(i) ctx.lineWidth = 13 ctx.beginPath() ctx.arc(cx, cy, radius + 7, group.start, group.end) ctx.stroke() }), renderer: pure((_, { ctx, inner, color_at }) => { const cx = (inner.left + inner.right) / 2 const cy = (inner.top + inner.bottom) / 2 const radius = Math.min(inner.right - inner.left, inner.bottom - inner.top) / 2 - 32 const point = (a) => [cx + radius * Math.cos(a), cy + radius * Math.sin(a)] ctx.save() // Allocate ribbon endpoints from the same weights as the outer arcs. groups.forEach((group, i) => { for (let j = i + 1; j < groups.length; j++) { const source = group.segments[j] const target = groups[j].segments[i] const [sx, sy] = point(source.start) const [tx, ty] = point(target.start) ctx.fillStyle = color_at(i) ctx.globalAlpha = 0.45 ctx.beginPath() ctx.moveTo(sx, sy) ctx.arc(cx, cy, radius, source.start, source.end) ctx.quadraticCurveTo(cx, cy, tx, ty) ctx.arc(cx, cy, radius, target.start, target.end) ctx.quadraticCurveTo(cx, cy, sx, sy) ctx.closePath() ctx.fill() } }) ctx.globalAlpha = 1 ctx.font = '12px sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'middle' groups.forEach((group, i) => { ctx.strokeStyle = color_at(i) ctx.fillStyle = color_at(i) ctx.lineWidth = 9 ctx.beginPath() ctx.arc(cx, cy, radius + 7, group.start, group.end) ctx.stroke() const mid = (group.start + group.end) / 2 ctx.fillText(group.name, cx + (radius + 22) * Math.cos(mid), cy + (radius + 22) * Math.sin(mid)) }) ctx.restore() }, { groups }), })], height: 260, margin: 0, axis: { x: { hidden: true, min: 0, max: 1 }, y: { hidden: true, min: 0, max: 1 } }, background: { light: '#ffffff', dark: '#151b28' }, grid: false, border: false, zoom_pan: false, })
// In Antithesis notebook, plot() returns a Printable. Uncomment below to display it: // print(chord_diagram) // presentation.blank("Chord diagram").print(chord_diagram)Ridgeline distributions
Compare six weekly distributions with overlapping density curves. Each ridge uses the same horizontal scale and density normalization, revealing shifts in location and spread. Hover a ridge to see its sample count and mean.
const containsPoint = pure((points, x, y) => { let inside = false for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const [xi, yi] = points[i] const [xj, yj] = points[j] if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside } return inside })
const data = Array.from({ length: 6 }, (_, i) => { const values = Array.from({ length: 80 }, (_, j) => { const z = Math.sqrt(-2 * Math.log((j + 0.5) / 80)) * Math.cos(j * 2.399963) return 25 + i * 8 + z * [8, 5, 11, 4, 9, 6][i] + (j % 3 === 0 ? 14 : 0) }) return { group: 'Week ' + (i + 1), mean: values.reduce((sum, value) => sum + value, 0) / values.length, density: Array.from({ length: 101 }, (_, value) => ({ value, density: values.reduce((sum, sample) => sum + Math.exp(-0.5 * ((value - sample) / 3) ** 2), 0) / (values.length * 3 * Math.sqrt(2 * Math.PI)), })), } })
const peak = Math.max(...data.flatMap(row => row.density.map(point => point.density)))
const ridgeline_distributions = plot({ series: [plot.custom({ data, y: 'group', color: pure((_, i) => ({ light: 'hsl(' + (175 + i * 15) + ', 55%, 55%)', dark: 'hsl(' + (175 + i * 15) + ', 50%, 45%)' })), hit_test: pure((_, { cursor, x_scale, resolve_y, inner }) => { const step = (inner.bottom - inner.top) / data.length // Reverse paint order selects the foreground ridge where shapes overlap. for (let i = data.length - 1; i >= 0; i--) { const center = resolve_y(i) if (center === undefined) continue const baseline = center + step * 0.45 const points = [ [Number(x_scale(0)), baseline], ...data[i].density.map(point => [ Number(x_scale(point.value)), baseline - point.density / peak * step * 1.9, ]), [Number(x_scale(100)), baseline], ] if (containsPoint(points, cursor.x, cursor.y)) return i } return null }, { containsPoint, data, peak }), tooltip: pure(({ row }) => h("span", {}, row?.group + ' · 80 samples · mean ' + Number(row?.mean).toFixed(1))), renderer: pure((series, { ctx, x_scale, resolve_y, color_at, inner }) => { const step = (inner.bottom - inner.top) / data.length ctx.save() // Paint from the top row down, so foreground ridges cover the preceding tails. const rows = series.rows rows.forEach((row, i) => { const center = resolve_y(i) if (center === undefined) return const baseline = center + step * 0.45 ctx.fillStyle = color_at(i) ctx.strokeStyle = '#334155' ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(Number(x_scale(0)), baseline) row.density.forEach(point => { ctx.lineTo(Number(x_scale(point.value)), baseline - point.density / peak * step * 1.9) }) ctx.lineTo(Number(x_scale(100)), baseline) ctx.closePath() ctx.globalAlpha = [1, 0.7, 0.85, 0.65, 0.8, 0.7][data.indexOf(row)] ctx.fill() ctx.globalAlpha = 1 ctx.stroke() }) ctx.restore() }, { data, peak }), })], height: 260, margin: { top: 24, right: 8, bottom: 24, left: 52 }, axis: { x: { min: 0, max: 100, label: '' }, y: { categories: data.map(row => row.group).reverse(), padding_top: 28, label: '' }, }, background: { light: '#ffffff', dark: '#151b28' }, chrome_color: { light: '#e2e8f0', dark: '#334155' }, grid: false, border: false, zoom_pan: false, })
// In Antithesis notebook, plot() returns a Printable. Uncomment below to display it: // print(ridgeline_distributions) // presentation.blank("Ridgeline distributions").print(ridgeline_distributions)Violin distributions
Mirror a kernel density estimate around each group’s center. Wider sections contain more observations; the central segment shows the interquartile range and the white dot marks the median. Hover a violin to inspect its median and quartiles.
function distributions() { return ['A', 'B', 'C'].map((group, g) => { const values = Array.from({ length: 60 }, (_, i) => { const z = Math.sqrt(-2 * Math.log((i + 0.5) / 60)) * Math.cos(i * 2.399963) return 36 + g * 10 + z * (6 + g * 2) + (g === 2 && i % 2 === 0 ? 18 : 0) }).sort((a, b) => a - b) const density = Array.from({ length: 101 }, (_, value) => ({ value, density: values.reduce((sum, sample) => sum + Math.exp(-0.5 * ((value - sample) / 4) ** 2), 0) / (values.length * 4 * Math.sqrt(2 * Math.PI)), })) return { group, values, density, q1: values[15], median: (values[29] + values[30]) / 2, q3: values[44] } }) }
const containsPoint = pure((points, x, y) => { let inside = false for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const [xi, yi] = points[i] const [xj, yj] = points[j] if ((yi > y) !== (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside } return inside })
const data = distributions()
const peak = Math.max(...data.flatMap(row => row.density.map(point => point.density)))
const violin_distributions = plot({ series: [plot.custom({ data, x: 'group', color: pure((_, i) => [ { light: 'color-mix(in oklch, #0d9488 60%, transparent)', dark: 'color-mix(in oklch, #5eead4 60%, transparent)' }, { light: 'color-mix(in oklch, #7c3aed 60%, transparent)', dark: 'color-mix(in oklch, #c4b5fd 60%, transparent)' }, { light: 'color-mix(in oklch, #c2410c 60%, transparent)', dark: 'color-mix(in oklch, #fdba74 60%, transparent)' }, ][i]), hit_test: pure((_, { cursor, y_scale, resolve_x, inner }) => { const width = Math.min(34, (inner.right - inner.left) / 9) for (let i = data.length - 1; i >= 0; i--) { const x = resolve_x(i) if (x === undefined) continue const points = [ ...data[i].density.map(point => [x + point.density / peak * width, Number(y_scale(point.value))]), ...data[i].density.slice().reverse().map(point => [x - point.density / peak * width, Number(y_scale(point.value))]), ] if (containsPoint(points, cursor.x, cursor.y)) return i } return null }, { containsPoint, data, peak }), tooltip: pure(({ row }) => h("span", {}, 'Group ' + row?.group + ' · median ' + Number(row?.median).toFixed(1) + ' · middle 50%: ' + Number(row?.q1).toFixed(1) + '–' + Number(row?.q3).toFixed(1))), renderer: pure((series, { ctx, y_scale, resolve_x, color_at, inner }) => { const width = Math.min(34, (inner.right - inner.left) / 9) ctx.save() const rows = series.rows rows.forEach((row, i) => { const x = resolve_x(i) if (x === undefined) return ctx.fillStyle = color_at(i) ctx.beginPath() row.density.forEach((point, j) => { const px = x + point.density / peak * width const py = Number(y_scale(point.value)) if (j === 0) ctx.moveTo(px, py) else ctx.lineTo(px, py) }) row.density.slice().reverse().forEach(point => { ctx.lineTo(x - point.density / peak * width, Number(y_scale(point.value))) }) ctx.closePath() ctx.fill() ctx.strokeStyle = '#151b28' ctx.lineWidth = 4 ctx.beginPath() ctx.moveTo(x, Number(y_scale(row.q1))) ctx.lineTo(x, Number(y_scale(row.q3))) ctx.stroke() ctx.fillStyle = '#ffffff' ctx.beginPath() ctx.arc(x, Number(y_scale(row.median)), 3, 0, Math.PI * 2) ctx.fill() }) ctx.restore() }, { peak }), })], height: 260, margin: { top: 8, right: 8, bottom: 24, left: 30 }, axis: { x: { categories: ['A', 'B', 'C'], label: '' }, y: { min: 0, max: 100, label: '' } }, background: { light: '#ffffff', dark: '#151b28' }, chrome_color: { light: '#e2e8f0', dark: '#334155' }, grid: false, border: false, zoom_pan: false, })
// In Antithesis notebook, plot() returns a Printable. Uncomment below to display it: // print(violin_distributions) // presentation.blank("Violin distributions").print(violin_distributions)Custom hover highlights
By default, hovering over a point calls renderer again with only that point.
Use the supplied series data when drawing, and set highlight_color to give
the point a different hover color.
For a different shape, such as a halo, provide highlight_renderer. It receives
the full series, the hovered point’s index, and drawing helpers. Set
highlight: false to turn off hover highlights. Highlighting requires data
and a hit_test callback to identify the point under the pointer.
The plot below shows circles above arrows. At x = 20, hovering redraws the mark with the default highlight. At x = 50, the circle gains a halo and the arrow grows larger. At x = 80, hovering shows tooltips without highlighting.
const renderer = pure((series, { ctx, resolve_x, resolve_y, color_at }) => { for (let i = 0; i < series.x.length; i++) { const x = resolve_x(i), y = resolve_y(i) if (x === undefined || y === undefined) continue ctx.fillStyle = color_at(i) ctx.beginPath() ctx.arc(x, y, 16, 0, Math.PI * 2) ctx.fill() } })
const hit_test = pure((series, { cursor, resolve_x, resolve_y }) => { for (let i = series.x.length - 1; i >= 0; i--) { const x = resolve_x(i), y = resolve_y(i) if (x !== undefined && y !== undefined && Math.hypot(cursor.x - x, cursor.y - y) <= 16) return i } return null })
const highlight_renderer = pure((series, i, { ctx, resolve_x, resolve_y, highlight_color_at }) => { const x = resolve_x(i), y = resolve_y(i) if (x === undefined || y === undefined) return ctx.strokeStyle = highlight_color_at(i) ctx.lineWidth = 3 ctx.beginPath() ctx.arc(x, y, 22, 0, Math.PI * 2) ctx.stroke() })
const draw_arrow = pure((ctx, x, y, size) => { ctx.beginPath() ctx.moveTo(x - size, y - size * 0.3) ctx.lineTo(x, y - size * 0.3) ctx.lineTo(x, y - size * 0.75) ctx.lineTo(x + size, y) ctx.lineTo(x, y + size * 0.75) ctx.lineTo(x, y + size * 0.3) ctx.lineTo(x - size, y + size * 0.3) ctx.closePath() ctx.fill() })
const arrow_renderer = pure((series, { ctx, resolve_x, resolve_y, color_at }) => { for (let i = 0; i < series.x.length; i++) { const x = resolve_x(i), y = resolve_y(i) if (x === undefined || y === undefined) continue ctx.fillStyle = color_at(i) draw_arrow(ctx, x, y, 16) } }, { draw_arrow })
const arrow_hit_test = pure((series, { cursor, resolve_x, resolve_y }) => { for (let i = series.x.length - 1; i >= 0; i--) { const x = resolve_x(i), y = resolve_y(i) if (x === undefined || y === undefined) continue const dx = cursor.x - x, dy = Math.abs(cursor.y - y) if (dx >= -16 && dx <= 16 && dy <= (dx < 0 ? 16 * 0.3 : (16 - dx) * 0.75)) return i } return null })
const arrow_highlight = pure((_series, i, { ctx, resolve_x, resolve_y, color_at }) => { const x = resolve_x(i), y = resolve_y(i) if (x === undefined || y === undefined) return ctx.fillStyle = color_at(i) draw_arrow(ctx, x, y, 24) }, { draw_arrow })
const custom_highlights = plot({ height: 260, axis: { x: { min: 0, max: 100 }, y: { min: 0, max: 100 } }, margin: { top: 12, right: 12, bottom: 28, left: 32 }, grid: false, zoom_pan: false, series: [ plot.custom({ data: [{ x: 20, y: 65 }], x: 'x', y: 'y', renderer, hit_test, color: 'color-mix(in oklch, teal 40%, transparent)', tooltip: true, }), plot.custom({ data: [{ x: 50, y: 65 }], x: 'x', y: 'y', renderer, hit_test, highlight_renderer, color: { light: '#7c3aed', dark: '#c4b5fd' }, tooltip: true, }), plot.custom({ data: [{ x: 80, y: 65 }], x: 'x', y: 'y', renderer, hit_test, color: '#94a3b8', highlight: false, tooltip: true, }), plot.custom({ data: [{ x: 20, y: 35 }], x: 'x', y: 'y', renderer: arrow_renderer, hit_test: arrow_hit_test, color: 'color-mix(in oklch, teal 40%, transparent)', tooltip: true, }), plot.custom({ data: [{ x: 50, y: 35 }], x: 'x', y: 'y', renderer: arrow_renderer, hit_test: arrow_hit_test, highlight_renderer: arrow_highlight, color: { light: '#7c3aed', dark: '#c4b5fd' }, tooltip: true, }), plot.custom({ data: [{ x: 80, y: 35 }], x: 'x', y: 'y', renderer: arrow_renderer, hit_test: arrow_hit_test, color: '#94a3b8', highlight: false, tooltip: true, }), ], })
// In Antithesis notebook, plot() returns a Printable. Uncomment below to display it: // print(custom_highlights) // presentation.blank("Custom highlights").print(custom_highlights)Custom arguments
| Field | Type | Default | Description |
|---|---|---|---|
data? |
{[key: string]: any}[] |
None | Optional data. Required when using field selectors, tooltips, selection callbacks, or hover highlights. |
x? |
FieldArg |
Unclaimed | Horizontal field or accessor. If omitted, this series does not affect the automatic range of this axis. |
y? |
FieldArg |
Unclaimed | Vertical field or accessor. If omitted, this series does not affect the automatic range of this axis. |
renderer |
CustomRendererFn |
Required | Draw the series using its data and the supplied canvas context and pixel resolvers. |
highlight_renderer? |
CustomHighlightRendererFn |
Single-point repaint | Draw on the hover overlay using the full series, hovered index, and highlight context. Requires data and hit testing. |
highlight? |
boolean |
true |
Highlight a data point when you hover over it, using the custom callback or default drawing. |
highlight_color? |
ColorArg |
Automatic | Exact hover color, including opacity. For custom series, affects only the fallback renderer. |
hit_test? |
CustomHitTestFn |
None | Return the index in data of the point under the pointer, or null. Required for custom tooltips and selection. |
color? |
ColorArg |
Black | Color made available to the renderer, including optional per-row colors. |
axis_range? |
{ x?: number[]; y?: number[] } |
Data extent | Explicit [low, high] bounds for either axis. |
tooltip? |
TooltipArg |
None | Use true for the default tooltip, or a callback for custom content. |
on_select? |
SelectFn |
None | Callback receiving the selected point and its source data. |
hoverable? |
boolean |
true |
Enable tooltips, selection callbacks, and hover highlights for this series. |
This example draws a cross at each point and finds the nearest cross within eight pixels for tooltips and selection:
const selected_data = new reactive.StateVar('Select a point to inspect its data.')
const series = [plot.custom({ data: [{ x: 1, y: 3 }, { x: 2, y: 5 }, { x: 3, y: 4 }], x: 'x', y: 'y', color: { light: 'steelblue', dark: 'lightskyblue' }, axis_range: { x: [0, 4], y: [0, 6] }, renderer: pure((series, { ctx, resolve_x, resolve_y, color_at }) => { ctx.save() ctx.lineWidth = 2 for (let i = 0; i < series.x.length; i++) { const x = resolve_x(i) const y = resolve_y(i) if (x === undefined || y === undefined) continue ctx.strokeStyle = color_at(i) ctx.beginPath() ctx.moveTo(x - 4, y) ctx.lineTo(x + 4, y) ctx.moveTo(x, y - 4) ctx.lineTo(x, y + 4) ctx.stroke() } ctx.restore() }), hit_test: pure((series, { cursor, resolve_x, resolve_y }) => { let nearest = null let distance = 8 for (let i = 0; i < series.x.length; i++) { const x = resolve_x(i) const y = resolve_y(i) if (x === undefined || y === undefined) continue const candidate = Math.hypot(cursor.x - x, cursor.y - y) if (candidate <= distance) { nearest = i distance = candidate } } return nearest }), tooltip: pure(({ x, y }) => h("span", {}, `(${x}, ${y})`)), on_select: pure(({ row }) => selected_data.set(JSON.stringify(row)), { selected_data }), hoverable: true, })]
const custom_crosses_plot = plot({ series, height: 260 })
const custom_crosses = h( 'div', {}, custom_crosses_plot, h( pure(({ selected_data }) => h('output', { 'aria-live': 'polite', style: { display: 'block' } }, selected_data.get()), ), { selected_data }, ), )
// In Antithesis notebook, plot() returns a Printable. Uncomment below to display it: // print(custom_crosses) // presentation.blank("Custom crosses").print(custom_crosses)Use axis_range to set the axis ranges when they cannot be inferred from the data.
Custom rendering
The renderer callback draws your custom series on the plot’s canvas whenever the plot renders.
It receives the series and a CustomRenderContext with the canvas, scales,
and helpers needed to position and style your drawing.
The composed series contains resolved x and y columns, source rows when
provided, and theme-resolved colors. Category coordinates are stored as
indices. Use the pixel resolvers to place them on the canvas.
| Context field | Type | Description |
|---|---|---|
ctx |
CanvasContext |
Main-thread or offscreen 2D canvas context. |
inner |
Rect |
Plot bounds in pixels: left, right, top, and bottom. |
x_scale |
Scale |
Composed horizontal D3 scale. |
y_scale |
Scale |
Composed vertical D3 scale. |
color |
string |
Resolved fallback series color. |
x_categories? |
string[] |
Horizontal category labels, when categorical. |
y_categories? |
string[] |
Vertical category labels, when categorical. |
resolve_x |
PixelResolver |
Resolve a data index to its x position in pixels. |
resolve_y |
PixelResolver |
Resolve a data index to its y position in pixels. |
color_at |
ColorResolver |
Resolve a data index to its color, including the series fallback. |
resolve_x(index) and resolve_y(index) return a point’s pixel position.
Skip drawing the point if either returns undefined. color_at(index) returns
its color. ctx provides the standard 2D drawing methods, and x_scale and
y_scale convert data values into pixel positions.
Hit testing
Hit testing identifies which part of your drawing is under the pointer, enabling
tooltips, selection callbacks, and hover highlights. Provide
hit_test(series, context) and return the corresponding index in data, or
null when the pointer is outside your shapes.
Use cursor to read the pointer position in pixels and resolve_x(index) and
resolve_y(index) to find each point’s position on the canvas. Compare these
positions using the shape you draw, such as a circle’s radius or an arrow’s outline.
| Context field | Type | Description |
|---|---|---|
cursor |
{ x: number; y: number } |
Pointer position in canvas pixels. |
inner |
Rect |
Plot bounds in pixels. |
x_scale |
Scale |
Horizontal scale for converting data values to pixel positions. |
y_scale |
Scale |
Vertical scale for converting data values to pixel positions. |
resolve_x |
PixelResolver |
Resolve a data index to its x position in pixels. |
resolve_y |
PixelResolver |
Resolve a data index to its y position in pixels. |
Highlight rendering
Use highlight_renderer(series, point_index, context) to draw a custom hover
effect, such as a halo or a larger arrow. The callback receives the full series
and the index returned by hit_test. Draw the effect on context.ctx, using
resolve_x(point_index) and resolve_y(point_index) to position it.
The context includes all fields from CustomRenderContext, plus the following helper.
| Context field | Type | Description |
|---|---|---|
highlight_color_at |
ColorResolver |
Resolve a data index to its automatic hover color, which may include transparency. |
Use color_at(point_index) to retain the original series color, as the enlarged
arrow does in the hover examples. The highlight_color
option controls the default repaint. With a custom highlight renderer, you choose
the drawing color in the callback.
Anta clears the hover overlay when the hovered point changes. Set
highlight: false to disable hover highlights.
Shared configuration
See Install and configure to set up Plot. The argument tables describe omitted fields; examples may override these defaults. See Data fields, Series colors, Strokes, and Mark shapes for shared options. For callbacks, see Tooltips and Selection.