DataZoom, toolbox and large-data performance

Add inside and slider zoom, expose toolbox actions, and keep the frame rate acceptable when a series has hundreds of thousands of points.

DataZoom and the toolbox

const option = {
  tooltip: { trigger: 'axis' },
  grid: { left: 56, right: 24, top: 32, bottom: 88 },
  xAxis: { type: 'time' },
  yAxis: { type: 'value' },

  dataZoom: [
    {
      type: 'inside',                 // wheel and pinch
      xAxisIndex: 0,
      zoomOnMouseWheel: true,
      moveOnMouseMove: true,
      moveOnMouseWheel: false,        // keep the wheel for zoom, not pan
      throttle: 50                    // ms between zoom events: cheap re-renders
    },
    {
      type: 'slider',                 // the draggable bar under the chart
      xAxisIndex: 0,
      height: 24,
      bottom: 32,
      brushSelect: false,
      showDataShadow: true,
      start: 0,
      end: 20,
      labelFormatter: (value) => new Date(value).toLocaleDateString()
    }
  ],

  toolbox: {
    right: 16,
    top: 0,
    feature: {
      dataZoom: { yAxisIndex: 'none' },     // drag-to-zoom on the plot area
      restore: {},                           // reset to the initial option
      saveAsImage: { name: 'revenue', pixelRatio: 2, backgroundColor: '#fff' },
      dataView: { readOnly: true, lang: ['Data', 'Close', 'Refresh'] },
      magicType: { type: ['line', 'bar'] }
    }
  },

  series: [{
    type: 'line',
    showSymbol: false,                // 20,000 points cannot each have a symbol
    sampling: 'lttb',                 // Largest-Triangle-Three-Buckets downsampling
    data: largeSeries
  }]
};
OptionEffectWhen it matters
dataZoom.inside.throttleLimits zoom event rateDense series where each zoom re-renders
showSymbol: falseNo per-point markerAny line with more than a few hundred points
sampling: 'lttb'Keeps the visual shape while dropping pointsTime series that are visually smooth
large: trueOptimised rendering pathScatter and bar with tens of thousands of items
largeThresholdPoint count at which large mode engagesTuning the trade-off between fidelity and speed
progressive / progressiveThresholdRenders in chunks so the page stays responsiveVery large scatter or graph series
useDirtyRect at initRepaints only changed regionsFrequent updates on a static chart
💡
Zoom state lives in the option. Calling setOption with a fresh option while the user is zoomed in resets the window unless the new option carries the current dataZoom.start and end. Merge rather than replace when updating a zoomable chart.

Making a huge series usable

// 1. Downsample before it reaches the chart when the data is not
//    already aggregated. 200,000 points at one per second is more than a
//    screen pixel can show anyway.
function bucket(points, bucketSize) {
  const out = [];
  for (let i = 0; i < points.length; i += bucketSize) {
    const slice = points.slice(i, i + bucketSize);
    if (!slice.length) break;
    const sum = slice.reduce((acc, p) => acc + p[1], 0);
    out.push([slice[0][0], sum / slice.length]);      // mean per bucket
  }
  return out;
}

// 2. Or let ECharts do it, which keeps the original detail available on zoom.
const series = [{
  type: 'line',
  showSymbol: false,
  sampling: 'lttb',
  progressive: 2000,          // draw 2,000 points per frame
  large: true,
  largeThreshold: 5000,
  lineStyle: { width: 1 },
  data: hugeSeries
}];

// 3. Typed-array datasets avoid per-point object allocation entirely.
const typed = {
  dataset: {
    dimensions: ['time', 'value'],
    source: {
      // one flat numeric array, indexed by the dimensions
    }
  }
};

// 4. Disable animation when the chart updates on a timer.
chart.setOption({ animation: false }, { lazyUpdate: true });

// 5. lazyUpdate batches the render to the next frame instead of rendering
//    synchronously inside a tight update loop.
for (const point of stream) {
  pushToSeries(point);
}
chart.setOption({ series: [{ data: current }] }, { lazyUpdate: true });

// 6. notMerge vs merge: only use notMerge when the structure changed.
//    Merging keeps the zoom, the legend selection and the animation state.
chart.setOption({ series: [{ data: next }] });
  • The cheapest optimisation is almost always to send fewer points. Aggregate on the server per zoom level, or bucket in the client before the first render.
  • lttb sampling preserves peaks and shapes well; average-based sampling smooths them away. For monitoring data where spikes matter, LTTB is the safer choice.
  • large: true changes the rendering path but does not reduce the data. Combine it with sampling, not instead of it.
  • A chart that updates from a WebSocket should throttle its setOption calls to the animation frame rate. Rendering faster than the screen refreshes only wastes work.

Incremental updates and zoom-aware tooltips

// A streaming chart: shift the window instead of growing the array forever.
const WINDOW = 600;
const buffer = [];

function push(point) {
  buffer.push(point);
  if (buffer.length > WINDOW) buffer.shift();
  chart.setOption({
    series: [{ data: buffer }],
    xAxis: { min: buffer[0]?.[0], max: buffer[buffer.length - 1]?.[0] }
  }, { lazyUpdate: true });
}

// When the user has zoomed, respect their window rather than snapping back.
let userZoom = null;
chart.on('dataZoom', () => {
  const model = chart.getModel().getComponent('dataZoom', 0);
  userZoom = { start: model.option.start, end: model.option.end };
});
chart.on('restore', () => { userZoom = null; });

function pushRespectingZoom(point) {
  buffer.push(point);
  if (buffer.length > WINDOW) buffer.shift();
  const option = { series: [{ data: buffer }] };
  if (userZoom) option.dataZoom = [{ start: userZoom.start, end: userZoom.end }];
  chart.setOption(option, { lazyUpdate: true });
}

// Reading the visible range for a custom aggregation or an API call
function visibleRange() {
  const axis = chart.getModel().getComponent('xAxis', 0).axis;
  return { min: axis.scale.getExtent()[0], max: axis.scale.getExtent()[1] };
}
ProblemCauseFix
Zoom resets on updatesetOption replaced the optionCarry start and end in the update
Update stuttersRendering on every messageThrottle to a frame, use lazyUpdate
Memory grows without boundAn append-only data arrayKeep a fixed window and shift
Filtering forces a new chartData was filtered in JavaScriptUse a filter transform on a dataset
Tooltip is slow on a big seriestrigger: 'item' with 100k pointsUse trigger: 'axis'
Panning is sluggishWheel bound to both zoom and panSet moveOnMouseWheel: false

FAQ

How many points can ECharts render?
A line chart with showSymbol: false and sampling handles hundreds of thousands of points smoothly on canvas. A scatter with symbols is far more expensive, so enable large mode and expect a few tens of thousands before it feels heavy.
Why does the chart reset when I update the data?
The update replaced the option object, including the current zoom. Either merge the update (pass only the series you changed) or explicitly carry dataZoom.start and end from the current state into the new option.

Interaction, resize and data updates Custom series and the matrix coordinate system

Last refreshed 2026-09-18.