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 callReturnsUse it for
api.value(dim)The raw value of a dimensionReading data in renderItem
api.coord([x, y])Pixel coordinatesPositioning a mark
api.size([dx, dy])Pixel size for a value spanSizing a mark by a data value
api.style()The series style objectInheriting theme colours
api.style({ fill })A style with overridesDeviating for one mark
api.visual('color')The colour from visualMapData-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, position and transition, so a custom mark animates like a built-in one.
  • Returning null from renderItem skips an item, which is the simplest way to conditionally hide marks without filtering the data.
  • The params argument also carries seriesIndex, dataIndex and dataInsideLength, 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 systemSeries that use itFits
cartesian2dline, bar, scatter, customThe default for most charts
polarline, bar, scatter, customRadial and rose charts
geo / mapscatter, lines, effectScatterAnything with geography
calendarheatmap, scatter, customDaily data across a year
matrixscatter, customSmall multiples in a grid
nonepie, funnel, treemap, sankeyCharts 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.

Datasets, dimensions and transforms Styling, themes and dark mode

Last refreshed 2026-09-18.