首页
/ Chart.js 数据抽稀(Decimation)实战:用 LTTB 与 Min-Max 算法让十万点级折线图保持流畅

Chart.js 数据抽稀(Decimation)实战:用 LTTB 与 Min-Max 算法让十万点级折线图保持流畅

2026-09-04 09:26:10作者:钟日瑜

本篇技术指南基于 Chart.js 官方示例 docs/samples/advanced/data-decimation.md,讲解如何利用内置的 decimation 插件对大数据量折线图做“抽稀”(data decimation)——在绘制前自动削减参与渲染的数据点数量,从而显著提升渲染性能。读完本文,你将能够:完整配置并动态切换 'min-max''lttb' 两种抽稀算法、理解插件的 6 条前置约束(requirements)、看懂插件在 beforeElementsUpdate 钩子中的核心实现,以及掌握 LTTB 与 Min-Max 两种算法在源码层面的工作机制。

一、为什么需要数据抽稀

当折线图的数据量达到数万甚至数十万点时(本文示例使用 100,000 个数据点),Canvas 上逐点连线、逐点计算坐标的成本会急剧上升,图表的绘制、缩放和交互都会变得卡顿。

抽稀(decimation)的思路是:在图表生命周期的早期阶段,自动把数据集削减到与画布宽度相称的点数——毕竟一个 800px 宽的画布上,同一像素列内显示 50 个点是完全没有意义的。Chart.js 将这个能力做成了内置插件 decimation,默认关闭,可按需开启并选择算法。

官方配置文档见 Data Decimation 配置说明,相关图表与坐标轴文档可参考 LineTime Scale

二、完整示例:10 万个时间序列点的抽稀演示

官方示例 data-decimation.md 是一个可交互的折线图示例,通过 4 个 action 按钮在“不抽稀 / Min-Max 抽稀 / LTTB 50 样本 / LTTB 500 样本”之间切换。下面按“actions → 数据 → 抽稀配置 → 图表配置”的顺序完整继承并讲解示例代码。

2.1 动态切换抽稀策略的 actions

const actions = [
  {
    name: 'No decimation (default)',
    handler(chart) {
      chart.options.plugins.decimation.enabled = false;
      chart.update();
    }
  },
  {
    name: 'min-max decimation',
    handler(chart) {
      chart.options.plugins.decimation.algorithm = 'min-max';
      chart.options.plugins.decimation.enabled = true;
      chart.update();
    },
  },
  {
    name: 'LTTB decimation (50 samples)',
    handler(chart) {
      chart.options.plugins.decimation.algorithm = 'lttb';
      chart.options.plugins.decimation.enabled = true;
      chart.options.plugins.decimation.samples = 50;
      chart.update();
    }
  },
  {
    name: 'LTTB decimation (500 samples)',
    handler(chart) {
      chart.options.plugins.decimation.algorithm = 'lttb';
      chart.options.plugins.decimation.enabled = true;
      chart.options.plugins.decimation.samples = 500;
      chart.update();
    }
  }
];

注意这些 handler 的共同模式:直接修改 chart.options.plugins.decimation 下的选项,然后调用 chart.update() 触发重新渲染。由于抽稀发生在每次更新的 beforeElementsUpdate 阶段(见第四节),chart.update() 后抽稀会立即以新参数重新执行——这也说明抽稀是动态的:调整 enabledalgorithmsamples 无需重建图表。

2.2 生成十万点级测试数据

// <block:data:1>
const NUM_POINTS = 100000;
Utils.srand(10);

// parseISODate returns a luxon date object to work with in the samples
// We will create points every 30s starting from this point in time
const start = Utils.parseISODate('2021-04-01T00:00:00Z').toMillis();
const pointData = [];

for (let i = 0; i < NUM_POINTS; ++i) {
  // Most data will be in the range [0, 20) but some rare data will be in the range [0, 100)
  const max = Math.random() < 0.001 ? 100 : 20;
  pointData.push({x: start + (i * 30000), y: Utils.rand(0, max)});
}

const data = {
  datasets: [{
    borderColor: Utils.CHART_COLORS.red,
    borderWidth: 1,
    data: pointData,
    label: 'Large Dataset',
    radius: 0,
  }]
};
// </block:data>

数据设计上有三个值得学习的细节:

  1. Utils.srand(10) 固定随机种子,保证每次生成的数据可复现。Utils 是官方文档示例工具集,srand(设置种子)、rand(min, max)(区间随机数)、parseISODate(解析 ISO 日期,内部返回 luxon 日期对象)均定义在 docs/scripts/utils.js 中。
  2. 模拟“尖峰”数据:99.9% 的点落在 [0, 20),0.1% 的点落在 [0, 100)。这种“大部分平缓、偶发异常尖峰”的分布正是 Min-Max 算法的用武之地——它能保证尖峰不被抽稀掉。
  3. 时间序列形式:每个点的 x 是毫秒时间戳(从 2021-04-01T00:00:00Z 起每隔 30 秒一个点),配合后文的 time 型 X 轴使用。10 万个点按 30 秒间隔覆盖约 34.7 天。
  4. radius: 0 关闭数据点圆点渲染:十万个点若每个都画圆点,性能开销会非常大;大数据量场景下通常只关心连线。

2.3 抽稀插件初始配置

const decimation = {
  enabled: false,
  algorithm: 'min-max',
};

注意初始状态是 enabled: false(默认值),演示先从“不抽稀”开始,由按钮切换开启。

2.4 图表整体配置

const config = {
  type: 'line',
  data: data,
  options: {
    // Turn off animations and data parsing for performance
    animation: false,
    parsing: false,

    interaction: {
      mode: 'nearest',
      axis: 'x',
      intersect: false
    },
    plugins: {
      decimation: decimation,
    },
    scales: {
      x: {
        type: 'time',
        ticks: {
          source: 'auto',
          // Disabled rotation for performance
          maxRotation: 0,
          autoSkip: true,
        }
      }
    }
  }
};

module.exports = {
  actions: actions,
  config: config,
};

其中几项配置是抽稀能正常工作乃至高性能的硬性前提或强烈建议

配置 作用 与抽稀的关系
parsing: false 关闭数据解析 硬性要求。抽稀插件只支持无需解析的数据(数据必须是 {x, y} 对象且按 x 有序)
animation: false 关闭动画 十万点级数据开启动画会使每次 update() 产生海量属性动画任务
x: {type: 'time'} 时间轴 硬性要求。X 轴必须是 lineartime 类型
interaction: {mode: 'nearest', axis: 'x', intersect: false} 最近点交互 大数据量下用“最近点”而非“精确命中”做 hover 更合理
ticks.maxRotation: 0 + autoSkip: true 时间轴刻度不旋转、自动抽稀 刻度标签同样会“抽稀”:十万个数据点不可能都出刻度,autoSkip 保证只显示得下的标签

完整配置对照 Line 图表文档Time 坐标轴文档 可进一步理解 source: 'auto' 等刻度选项。

三、抽稀插件配置项与算法选择

抽稀插件的命名空间是 options.plugins.decimation,全局默认值定义在 Chart.defaults.plugins.decimation(见 配置文档):

选项 类型 默认值 说明
enabled boolean false 是否启用抽稀
algorithm string 'min-max' 抽稀算法,可选 'min-max''lttb'
samples number 无(默认取画布宽度) 'lttb' 算法使用,输出数据集的样本数。默认按每像素 1 个样本取值
threshold number 无(默认 4 倍画布宽度) 当前轴范围内样本数超过该值才触发抽稀;注意抽稀后的点数可能高于 threshold

3.1 两种算法的适用场景

  • LTTB(Largest-Triangle-Three-Buckets,最大三角形三桶):显著削减数据点数量,最适合“只用少量点展示数据趋势”的场景。算法实现参考了 Sveinn Steinarsson 的 flot-downsample 项目(MIT 许可)。
  • Min/Max:保留数据中的峰值,但每个像素最多可能产生 4 个点。适合噪声很大的信号、需要看清数据尖峰的场景。

回到本文示例:由于数据中刻意构造了 0.1% 的 [0, 100) 尖峰,min-max 模式能在抽稀后仍完整呈现这些异常峰值;而 LTTB 模式则给出更平滑的趋势线,samples 从 50 增到 500 时曲线细节逐渐增多。

3.2 使用前提(Requirements)

启用插件前必须逐条核对以下 6 项要求,插件在每次更新时会做同样校验并静默跳过不满足的数据集:

  1. 数据集的 indexAxis 必须为 'x'
  2. 数据集必须是 line 类型;
  3. 数据集的 X 轴必须是 'linear''time' 类型;
  4. 数据必须不需要解析,即 parsingfalse
  5. 数据集对象必须是可变的:插件会把原始数据存为 dataset._data,并在数据集上重新定义 data 属性;
  6. 当前轴范围内的点数必须超过 threshold 才会真正触发抽稀。

四、源码深潜:src/plugins/plugin.decimation.js 的核心机制

4.1 插件挂载点与逐数据集校验

插件实现位于 plugin.decimation.js,并通过 plugins/index.js 导出为 Decimation。插件 id 为 'decimation',内置默认值与文档一致:

export default {
  id: 'decimation',
  defaults: {
    algorithm: 'min-max',
    enabled: false,
  },
  beforeElementsUpdate: (chart, args, options) => { /* ... */ },
  destroy(chart) { cleanDecimatedData(chart); },
};

核心逻辑全部在 beforeElementsUpdate 钩子中,它按数据集逐个执行如下校验链(对应第三节 6 条要求):

chart.data.datasets.forEach((dataset, datasetIndex) => {
  const {_data, indexAxis} = dataset;
  const meta = chart.getDatasetMeta(datasetIndex);
  const data = _data || dataset.data;

  if (resolve([indexAxis, chart.options.indexAxis]) === 'y') {
    // Decimation is only supported for lines that have an X indexAxis
    return;
  }
  if (!meta.controller.supportsDecimation) {
    // Only line datasets are supported
    return;
  }
  const xAxis = chart.scales[meta.xAxisID];
  if (xAxis.type !== 'linear' && xAxis.type !== 'time') {
    // Only linear interpolation is supported
    return;
  }
  if (chart.options.parsing) {
    // Plugin only supports data that does not need parsing
    return;
  }
  /* ...threshold 判断、算法分发... */
});

其中 supportsDecimation 标志只有折线图控制器置为 true——controller.line.jsinitialize() 中执行 this.supportsDecimation = true,而基类 core.datasetController.js 默认为 false,这从源码层面解释了“为什么只有 line 数据集支持抽稀”。

4.2 只对可视范围抽稀:getStartAndCountOfVisiblePointsSimplified

插件并不是无脑对整份数据抽稀,而是先借助索引轴(iScale)的用户边界找出当前可视区间内的起点和数量:

function getStartAndCountOfVisiblePointsSimplified(meta, points) {
  const pointCount = points.length;
  let start = 0;
  let count;
  const {iScale} = meta;
  const {min, max, minDefined, maxDefined} = iScale.getUserBounds();
  if (minDefined) {
    start = _limitValue(_lookupByKey(points, iScale.axis, min).lo, 0, pointCount - 1);
  }
  if (maxDefined) {
    count = _limitValue(_lookupByKey(points, iScale.axis, max).hi + 1, start, pointCount) - start;
  } else {
    count = pointCount - start;
  }
  return {start, count};
}

getUserBounds() 定义在 core.scale.js 中。从源码结构看,缩放/平移改变轴范围后,只有落在可视窗口内的点会被抽稀,窗口外(甚至未进入当前视口)的数据不参与计算——这对“大数据 + 视窗聚焦”的场景非常关键。

随后是阈值判断:

let {start, count} = getStartAndCountOfVisiblePointsSimplified(meta, data);
const threshold = options.threshold || 4 * availableWidth;
if (count <= threshold) {
  // No decimation is required until we are above this threshold
  cleanDecimatedDataset(dataset);
  return;
}

可见点数量不超过 threshold(默认 4 * chart.width)时不触发抽稀,并调用 cleanDecimatedDataset 还原数据集——这保证小数据量时图表行为与普通折线图完全一致。

4.3 用 Proxy 式属性替换隐藏原始数据

抽稀首次命中某数据集时,插件做了本文要求第 5 条提到的“可变数据集”改造:

if (isNullOrUndef(_data)) {
  // First time we are seeing this dataset
  dataset._data = data;
  delete dataset.data;
  Object.defineProperty(dataset, 'data', {
    configurable: true,
    enumerable: true,
    get: function() {
      return this._decimated;
    },
    set: function(d) {
      this._data = d;
    }
  });
}

效果是:

  • 读取 dataset.data 时拿到的是抽稀后的 _decimated 数组(图表渲染拿到的就是它);
  • dataset.data 赋值时,实际写入 _data(原始数据),下次更新会基于新原始数据重新抽稀;
  • 一旦禁用插件或图表销毁,cleanDecimatedDataset 会删除 _decimated/_data 并把 data 属性还原为普通属性,返回原始数据引用。
function cleanDecimatedDataset(dataset) {
  if (dataset._decimated) {
    const data = dataset._data;
    delete dataset._decimated;
    delete dataset._data;
    Object.defineProperty(dataset, 'data', {
      configurable: true, enumerable: true, writable: true,
      value: data,
    });
  }
}

这意味着业务代码仍然可以照常执行 chart.data.datasets[0].data = newData 更新数据,抽稀对上层 API 是透明的。

4.4 Min-Max 算法实现:每像素最多 4 个点

minMaxDecimation 的思路是把数据点的 x 线性映射到像素坐标后按整数像素列分组:

x = (point.x - xMin) / dx * availableWidth;
const truncX = x | 0;
if (truncX === prevX) {
  // 同一像素列内,维护 min/max 索引与该列的平均 x
  if (y < minY) { minY = y; minIndex = i; }
  else if (y > maxY) { maxY = y; maxIndex = i; }
  avgX = (countX * avgX + point.x) / ++countX;
} else {
  // 进入新像素列:把上一列的 min、max(x 取列内平均值 avgX)以及
  // 上一列的末点、本列的起点 推入结果,去重并保持有序
}

关键设计:

  1. 每个像素列输出至多 4 个点:列内 minmaxx 用列内均值 avgX 代替,避免尖峰被“钉”在列边缘)加上区间首尾点,因此官方文档说它“could require up to 4 points for each pixel”——这正好解释了 threshold 默认为 4 * availableWidth 的原因;
  2. 输出结果仍按 x 有序且去重,可直接作为折线绘制;
  3. 峰值点(min/max 索引对应的真实数据点)被原样保留,所以异常尖峰不会丢失。

4.5 LTTB 算法实现:面积最大的点胜出

lttbDecimation 按经典 LTTB 流程工作:

  1. samples 缺省取 availableWidth(画布宽度,即每像素 1 样本);若 samples >= count 直接返回切片,不做抽稀;
  2. 把中间数据划分为 samples - 2 个桶(bucketWidth = (count - 2) / (samples - 2)),首尾两个点无条件保留;
  3. 对每个桶,先计算下一桶所有点的平均位置 (avgX, avgY)(三桶中的“下一桶”),再在当前桶内寻找使 (上一个选中点 a) → (候选点 j) → (avg) 三点构成三角形面积最大的那个点作为代表点:
for (j = rangeOffs; j < rangeTo; j++) {
  area = 0.5 * Math.abs(
    (pointAx - avgX) * (data[j].y - pointAy) -
    (pointAx - data[j].x) * (avgY - pointAy)
  );
  if (area > maxArea) {
    maxArea = area;
    maxAreaPoint = data[j];
    nextA = j;
  }
}

源码注释中还有一段值得注意的实现细节:原版 flot-downsample 将 maxArea 初始化为 1,但 Chart.js 改为 -1——因为当信号完全平坦时所有三角形面积为 0,按原初始化 nextA 永远不会被赋值,下一轮循环中 a 会变成 undefined 而崩溃。这个改动保证了平坦/单调数据下的健壮性,测试用例 should not crash with uneven points(15,552 个不等距点、devicePixelRatio: 1.25)正是为这类边界场景兜底。

4.6 行为验证:单元测试怎么说

plugin.decimation.tests.js 覆盖了插件的关键契约:

  • samples 大于数据点数时全量绘制(10 个点、samples: 100 → 仍输出 10 个点);
  • samples 小于数据点数时精确输出指定数量samples: 7 → 输出 7 个点);
  • threshold 生效:samples: 5, threshold: 7 时只抽到 5 个点;
  • 只抽稀可视范围:轴范围 min: 3, max: 6 时,输出以 originalData[2] 为起点、共 5 个点,范围外数据不参与。

这些断言与第四节源码中“samples >= count 返回切片”“基于 getUserBounds() 计算 start/count”的实现一一对应,可以作为你集成时的行为依据。

五、工程化使用建议

结合示例与源码,落地大数据量折线图时的推荐做法:

  1. 按数据形态选算法:要看趋势用 lttbsamples 从画布宽度起调);要看峰值/噪声细节用 min-max。本文示例的 4 个 action 按钮就是最直观的对比方式。
  2. 配套关闭非必要开销animation: falseparsing: falseradius: 0、刻度 autoSkip: true——示例中每项注释(如 “Turn off animations and data parsing for performance”“Disabled rotation for performance”)都指向同一目标。
  3. 数据必须是预解析的有序 {x, y} 对象x 升序排列,x 为时间戳或数值。若数据来自字符串/日期对象,先在数据准备阶段解析好,再把 parsing 设为 false
  4. 利用视窗聚焦:插件只抽稀当前轴范围内的点,配合 time 轴的缩放(min/max 变化)可以“越缩越精细”地浏览大数据。
  5. 动态开关无需重建图表:直接改 chart.options.plugins.decimation.*chart.update() 即可,禁用时插件会自动还原 dataset.data 属性(cleanDecimatedData)。
  6. 注意副作用边界:抽稀期间 dataset.data 的读值已被代理到 _decimated,若你在业务代码中直接缓存 chart.data.datasets[0].data 引用,需要意识到拿到的是抽稀结果而非原始数据;原始数据保存在 dataset._data 中。

六、小结

Chart.js 的 decimation 插件是面向时间序列/线性轴大数据量折线图的内置渲染优化方案:通过 options.plugins.decimation 的 4 个选项(enabled/algorithm/samples/threshold)控制行为,min-max 保峰值、lttb 保趋势;实现上它以 beforeElementsUpdate 为入口,对满足 6 条前提的数据集,只针对可视范围执行抽稀,并用 _data/_decimated + data 属性代理的方式把结果无缝接入渲染管线,同时保证禁用或销毁时能干净还原。data-decimation 示例 中 10 万点的时间序列演示,是把这些能力串起来的最短路径:按第二节代码搭好图表,再按第三节参数切换算法,即可在自己的项目中得到同等规模的流畅渲染。

登录后查看全文
热门项目推荐
相关项目推荐