AntaPlotCustom
Switch to dark theme
Search documentation
On this page

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.