Custom series and the matrix coordinate system
Draw arbitrary marks with renderItem, package them as a reusable custom series, and place charts on the matrix and calendar coordinate systems.
renderItem and custom primitives
import * as echarts from 'echarts/core';
import { CustomChart, BarChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([CustomChart, BarChart, GridComponent, TooltipComponent, CanvasRenderer]);
const option = {
tooltip: { trigger: 'item' },
grid: { left: 56, right: 24, top: 24, bottom: 32, containLabel: true },
xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri'] },
yAxis: { type: 'value' },
series: [{
type: 'custom',
// encode tells ECharts which dimensions are the position and the value
encode: { x: 0, y: 1, tooltip: [1] },
data: [
['Mon', 120, 4],
['Tue', 200, 9],
['Wed', 150, 6],
['Thu', 80, 2],
['Fri', 170, 8]
],
renderItem(params, api) {
const x = api.value(0) === undefined ? 0 : api.coord([api.value(0), api.value(1)])[0];
const [, y] = api.coord([api.value(0), api.value(1)]);
const size = api.size([0, api.value(2)]); // pixel size for a value span
const radius = Math.max(3, size[1] / 2);
return {
type: 'group',
children: [
{
type: 'circle',
shape: { cx: 0, cy: 0, r: radius },
style: api.style({ fill: '#6d28d9', opacity: 0.85 }),
position: [x, y]
},
{
type: 'text',
style: {
text: String(api.value(2)),
fill: '#334155',
fontSize: 11,
align: 'center'
},
position: [x, y - radius - 8]
}
]
};
}
}]
};| API call | Returns | Use it for |
|---|---|---|
api.value(dim) | The raw value of a dimension | Reading data in renderItem |
api.coord([x, y]) | Pixel coordinates | Positioning a mark |
api.size([dx, dy]) | Pixel size for a value span | Sizing a mark by a data value |
api.style() | The series style object | Inheriting theme colours |
api.style({ fill }) | A style with overrides | Deviating for one mark |
api.visual('color') | The colour from visualMap | Data-driven colour in a custom mark |
💡
renderItem runs for every data item on every render, so anything expensive inside it is a direct frame-rate cost. Precompute lookup tables outside and read them by index; do not parse strings or build objects per item.Packaging a custom series
// A reusable custom series is a plain helper that returns the series object.
export function lollipopSeries({ name, data, color = '#6d28d9', maxRadius = 18 }) {
return {
name,
type: 'custom',
encode: { x: 0, y: 1, tooltip: [1, 2] },
data,
renderItem(params, api) {
const [cx, cy] = api.coord([api.value(0), api.value(1)]);
const baseY = api.coord([api.value(0), 0])[1];
const weight = api.value(2) ?? 0;
const radius = 4 + (weight / 10) * (maxRadius - 4);
return {
type: 'group',
children: [
// the stem
{ type: 'line', shape: { x1: cx, y1: baseY, x2: cx, y2: cy },
style: { stroke: color, lineWidth: 2, opacity: 0.4 } },
// the head
{ type: 'circle', shape: { cx, cy, r: radius },
style: { fill: color, opacity: 0.9 } },
// the value label, only for the largest few points
...(weight >= 8 ? [{
type: 'text',
style: { text: String(api.value(1)), fill: '#0f172a', fontSize: 11, align: 'center' },
position: [cx, cy - radius - 6]
}] : [])
]
};
}
};
}
// usage
chart.setOption({
tooltip: { trigger: 'item' },
xAxis: { type: 'category', data: days },
yAxis: { type: 'value' },
series: [lollipopSeries({ name: 'Signups', data: rows, color: '#0ea5e9' })]
});
// Interactive custom marks: attach click handling by data index.
chart.on('click', { seriesName: 'Signups' }, (params) => {
openDetail(params.data[0]);
});- Each child of the returned group can carry its own
style,positionandtransition, so a custom mark animates like a built-in one. - Returning
nullfromrenderItemskips an item, which is the simplest way to conditionally hide marks without filtering the data. - The
paramsargument also carriesseriesIndex,dataIndexanddataInsideLength, which is what you need to look up a precomputed table. - Wrap a custom series in a helper rather than exporting a whole chart: it composes with ordinary series on the same axes, which is what makes it reusable.
Matrix and calendar coordinate systems
// The matrix coordinate system lays out a grid of tiny charts — one per row
// and column. It is designed for small multiples.
const matrixOption = {
tooltip: {},
matrix: {
x: { data: ['North', 'South', 'East', 'West'] },
y: { data: ['Q1', 'Q2', 'Q3', 'Q4'] },
left: 72,
right: 24,
top: 24,
bottom: 40,
cellWidth: 'auto',
cellHeight: 'auto'
},
series: [{
type: 'scatter',
coordinateSystem: 'matrix',
// data: [columnIndex, rowIndex, x, y, size]
data: [
[0, 0, 1, 12, 4],
[1, 0, 2, 18, 6],
[2, 0, 3, 9, 3],
[3, 0, 4, 22, 7]
],
symbolSize: (value) => Math.max(4, value[4] * 2),
itemStyle: { color: '#6d28d9' }
}]
};
// The calendar coordinate system plots time-shaped data on a year or month grid.
const calendarOption = {
tooltip: { formatter: (p) => `${p.data[0]}: ${p.data[1]}` },
visualMap: {
min: 0, max: 500, calculable: true,
orient: 'horizontal', left: 'center', bottom: 0,
inRange: { color: ['#ede9fe', '#6d28d9'] }
},
calendar: {
top: 40, left: 40, right: 24, bottom: 60,
cellSize: ['auto', 14],
range: '2026',
splitLine: { show: true, lineStyle: { color: '#e2e8f0' } },
itemStyle: { color: '#f8fafc', borderWidth: 1, borderColor: '#e2e8f0' },
dayLabel: { nameMap: 'en' },
monthLabel: { nameMap: 'en' }
},
series: [{
type: 'heatmap',
coordinateSystem: 'calendar',
data: heatmapRows // [['2026-01-15', 320], ...]
}]
};| Coordinate system | Series that use it | Fits |
|---|---|---|
cartesian2d | line, bar, scatter, custom | The default for most charts |
polar | line, bar, scatter, custom | Radial and rose charts |
geo / map | scatter, lines, effectScatter | Anything with geography |
calendar | heatmap, scatter, custom | Daily data across a year |
matrix | scatter, custom | Small multiples in a grid |
none | pie, funnel, treemap, sankey | Charts that define their own layout |
// A useful pattern: a matrix of small line charts using one custom series per
// cell is wasteful. Instead, one custom series draws every cell.
const smallMultiples = {
matrix: {
x: { data: regions, levelSize: 24 },
y: { data: metrics, levelSize: 24 },
body: { itemStyle: { borderColor: '#e2e8f0', borderWidth: 1 } }
},
series: [{
type: 'custom',
coordinateSystem: 'matrix',
data: cells.map((cell, index) => [...cell.coord, index]),
renderItem(params, api) {
const point = api.coord([api.value(2)]);
const cellWidth = params.coordSys.width / params.coordSys.cols;
const cellHeight = params.coordSys.height / params.coordSys.rows;
const series = cells[api.value(3)].values;
const points = series.map((value, i) => [
point[0] + (i / (series.length - 1)) * cellWidth,
point[1] + cellHeight - (value / maxValue) * cellHeight
]);
return {
type: 'polyline',
shape: { points },
style: { stroke: '#6d28d9', lineWidth: 1.5, fill: 'none' }
};
}
}]
};FAQ
Do I need a custom series for a simple annotation?
No.
markLine, markArea, markPoint and a graphic element cover most annotations declaratively. Custom series earn their complexity when the mark's shape or size depends on a data dimension that no built-in series supports.Why is my custom series not rendering?
Three usual causes:
CustomChart was not registered with echarts.use(), encode does not name the dimensions the coordinate system needs, or renderItem returned a shape for a coordinate system the series is not bound to. Check coordinateSystem matches the axis you configured.Related
Datasets, dimensions and transforms Styling, themes and dark mode
Last refreshed 2026-09-18.