Chart.js 数据抽稀(Decimation)实战:用 LTTB 与 Min-Max 算法让十万点级折线图保持流畅
本篇技术指南基于 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 配置说明,相关图表与坐标轴文档可参考 Line 与 Time 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() 后抽稀会立即以新参数重新执行——这也说明抽稀是动态的:调整 enabled、algorithm、samples 无需重建图表。
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>
数据设计上有三个值得学习的细节:
Utils.srand(10)固定随机种子,保证每次生成的数据可复现。Utils是官方文档示例工具集,srand(设置种子)、rand(min, max)(区间随机数)、parseISODate(解析 ISO 日期,内部返回 luxon 日期对象)均定义在 docs/scripts/utils.js 中。- 模拟“尖峰”数据:99.9% 的点落在
[0, 20),0.1% 的点落在[0, 100)。这种“大部分平缓、偶发异常尖峰”的分布正是 Min-Max 算法的用武之地——它能保证尖峰不被抽稀掉。 - 时间序列形式:每个点的
x是毫秒时间戳(从2021-04-01T00:00:00Z起每隔 30 秒一个点),配合后文的time型 X 轴使用。10 万个点按 30 秒间隔覆盖约 34.7 天。 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 轴必须是 linear 或 time 类型 |
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 项要求,插件在每次更新时会做同样校验并静默跳过不满足的数据集:
- 数据集的
indexAxis必须为'x'; - 数据集必须是 line 类型;
- 数据集的 X 轴必须是
'linear'或'time'类型; - 数据必须不需要解析,即
parsing为false; - 数据集对象必须是可变的:插件会把原始数据存为
dataset._data,并在数据集上重新定义data属性; - 当前轴范围内的点数必须超过
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.js 的 initialize() 中执行 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)以及
// 上一列的末点、本列的起点 推入结果,去重并保持有序
}
关键设计:
- 每个像素列输出至多 4 个点:列内
min、max(x用列内均值avgX代替,避免尖峰被“钉”在列边缘)加上区间首尾点,因此官方文档说它“could require up to 4 points for each pixel”——这正好解释了threshold默认为4 * availableWidth的原因; - 输出结果仍按
x有序且去重,可直接作为折线绘制; - 峰值点(min/max 索引对应的真实数据点)被原样保留,所以异常尖峰不会丢失。
4.5 LTTB 算法实现:面积最大的点胜出
lttbDecimation 按经典 LTTB 流程工作:
samples缺省取availableWidth(画布宽度,即每像素 1 样本);若samples >= count直接返回切片,不做抽稀;- 把中间数据划分为
samples - 2个桶(bucketWidth = (count - 2) / (samples - 2)),首尾两个点无条件保留; - 对每个桶,先计算下一桶所有点的平均位置
(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”的实现一一对应,可以作为你集成时的行为依据。
五、工程化使用建议
结合示例与源码,落地大数据量折线图时的推荐做法:
- 按数据形态选算法:要看趋势用
lttb(samples从画布宽度起调);要看峰值/噪声细节用min-max。本文示例的 4 个 action 按钮就是最直观的对比方式。 - 配套关闭非必要开销:
animation: false、parsing: false、radius: 0、刻度autoSkip: true——示例中每项注释(如 “Turn off animations and data parsing for performance”“Disabled rotation for performance”)都指向同一目标。 - 数据必须是预解析的有序
{x, y}对象:x升序排列,x为时间戳或数值。若数据来自字符串/日期对象,先在数据准备阶段解析好,再把parsing设为false。 - 利用视窗聚焦:插件只抽稀当前轴范围内的点,配合 time 轴的缩放(
min/max变化)可以“越缩越精细”地浏览大数据。 - 动态开关无需重建图表:直接改
chart.options.plugins.decimation.*后chart.update()即可,禁用时插件会自动还原dataset.data属性(cleanDecimatedData)。 - 注意副作用边界:抽稀期间
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 万点的时间序列演示,是把这些能力串起来的最短路径:按第二节代码搭好图表,再按第三节参数切换算法,即可在自己的项目中得到同等规模的流畅渲染。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00