Day 51 · 手写折线图 — 平滑曲线、面积渐变、最近点命中与十字准线
昨天的柱状图是"类目 vs 数值",今天的折线图是"趋势"——时间序列数据(温度曲线、产量走势、告警频率)是工业可视化的一半江山。折线图比柱状图多三个难点:平滑曲线怎么画不"过冲"、鼠标在空白处怎么找到最近的数据点(十字准线)、多个系列怎么切换显示。这三个难点分别对应 ECharts 的
smooth、axisPointer、legend——写完今天,这三个配置项对你就是透明的。
目录
- 一、折线图 vs 柱状图:数据形态决定图表形态
- 二、数据点 → 像素点
- 三、折线绘制:直线、圆角与平滑曲线
- 四、面积渐变填充
- 五、最近点命中与十字准线
- 六、多系列与图例切换
- 七、线条生长动画
- 八、常见坑点
- 九、自测挑战
- 十、总结
一、折线图 vs 柱状图:数据形态决定图表形态
| 维度 | 柱状图(昨天) | 折线图(今天) |
|---|---|---|
| x 轴 | 类目(离散:车间 A/B/C) | 连续量(时间、温度段) |
| 强调 | 单点数值大小比较 | 变化趋势与拐点 |
| 命中方式 | 点在矩形内(AABB) | 最近点(点到点距离) |
| 工业场景 | 各车间能耗对比 | 24 小时温度曲线 |
🎯 选型口诀:比大小用柱,看趋势用线,看分布用散点。选错图表是可视化设计的第一大忌。
二、数据点 → 像素点
/**
* 时间序列数据点
*/
interface SeriesPoint {
t: number; // 时间戳(毫秒)
v: number; // 数值
}
/**
* 把整条序列映射为像素点数组
* @param points 数据序列
* @param tScale 时间比例尺(复用 Day 50 的 linear)
* @param vScale 数值比例尺
*/
function mapPoints(
points: SeriesPoint[],
tScale: (t: number) => number,
vScale: (v: number) => number
): { x: number; y: number }[] {
return points.map((p) => ({ x: tScale(p.t), y: vScale(p.v) }));
}
时间轴刻度可以直接复用昨天的 niceTicks(把时间戳当数值算,标签再格式化为 HH:mm)——数学不区分你映射的是温度还是时间,这是比例尺抽象的威力。
三、折线绘制:直线、圆角与平滑曲线
3.1 直线版(地基)
/**
* 绘制折线(直线连接版)
*/
function drawPolyline(
ctx: CanvasRenderingContext2D,
pts: { x: number; y: number }[],
color: string
): void {
if (pts.length < 2) return;
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.lineJoin = "round"; // 转角圆头,避免尖刺
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.restore();
}
3.2 平滑曲线:为什么不能直接用 bezierCurveTo 连中点
最常见的错误实现:把相邻两点的中点当控制点硬连——在数据剧烈波动处会过冲(曲线冲出数据的最大最小值,工业图表里这等于"篡改数据")。
正确姿势是 monotone 插值(保单调性,绝不过冲)。下面是简化实现(Fritsch–Carlson 思路):
/**
* 单调三次插值:为每个点计算切线斜率,保证曲线不过冲
* 返回每段的贝塞尔控制点
*/
function monotoneControlPoints(
pts: { x: number; y: number }[]
): { c1: { x: number; y: number }[]; c2: { x: number; y: number }[] } {
const n = pts.length;
const slopes: number[] = []; // 每段的割线斜率
for (let i = 0; i < n - 1; i++) {
slopes.push((pts[i + 1].y - pts[i].y) / (pts[i + 1].x - pts[i].x || 1));
}
// 每个点的切线:取两侧割线斜率的调和平均(同号时),异号(局部极值)取 0
const tangents: number[] = [];
for (let i = 0; i < n; i++) {
const prev = i > 0 ? slopes[i - 1] : slopes[0];
const next = i < n - 1 ? slopes[i] : slopes[n - 2];
if (prev * next <= 0) {
tangents.push(0); // 局部极值点:水平切线,形成柔和的峰谷
} else {
tangents.push((prev + next) / 2);
}
}
// 由切线生成控制点:c1 沿切线走 dx/3,c2 反向走 dx/3
const c1: { x: number; y: number }[] = [];
const c2: { x: number; y: number }[] = [];
for (let i = 0; i < n - 1; i++) {
const dx = (pts[i + 1].x - pts[i].x) / 3;
c1.push({ x: pts[i].x + dx, y: pts[i].y + dx * tangents[i] });
c2.push({ x: pts[i + 1].x - dx, y: pts[i + 1].y - dx * tangents[i + 1] });
}
return { c1, c2 };
}
/**
* 绘制平滑折线(monotone 版,不会过冲)
*/
function drawSmoothLine(
ctx: CanvasRenderingContext2D,
pts: { x: number; y: number }[],
color: string
): void {
if (pts.length < 2) return;
const { c1, c2 } = monotoneControlPoints(pts);
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 0; i < pts.length - 1; i++) {
ctx.bezierCurveTo(c1[i].x, c1[i].y, c2[i].x, c2[i].y, pts[i + 1].x, pts[i + 1].y);
}
ctx.stroke();
ctx.restore();
}
💡 ECharts 的
smooth默认也是单调类插值(smoothMonotone: 'x'),你今天写的正是它的原理。试把tangents全部换成两侧斜率的简单平均——在锯齿数据上观察过冲,直观理解"为什么单调性重要"。
四、面积渐变填充
/**
* 折线下方的渐变面积(工业大屏的经典质感)
* @param baselineY 面积基线(通常是 yScale(0),即 x 轴所在像素)
*/
function drawArea(
ctx: CanvasRenderingContext2D,
pts: { x: number; y: number }[],
baselineY: number,
color: string
): void {
if (pts.length < 2) return;
ctx.save();
// 渐变:从线上 30% 透明度渐隐到基线的 0
const top = Math.min(...pts.map((p) => p.y));
const grad = ctx.createLinearGradient(0, top, 0, baselineY);
grad.addColorStop(0, color + "4d"); // 30% 透明(#RRGGBBAA 后两位 hex)
grad.addColorStop(1, color + "00");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.moveTo(pts[0].x, baselineY); // 从基线起笔
for (const p of pts) ctx.lineTo(p.x, p.y);
ctx.lineTo(pts[pts.length - 1].x, baselineY); // 回到基线闭合
ctx.closePath();
ctx.fill();
ctx.restore();
}
⚠️ 平滑版面积:把上面直线循环换成与
drawSmoothLine相同的 bezierCurveTo 段——线怎么画,面积边界就怎么画,否则线和面之间会出现缝隙。
五、最近点命中与十字准线
这是折线图交互的灵魂,也是与柱状图(AABB 命中)的本质区别。
5.1 需求
鼠标在画布任意位置移动(哪怕离线很远),都要找到水平方向最近的数据点,显示十字准线和数值标签——ECharts 的 tooltip.trigger: 'axis' + axisPointer 就是这套行为。
5.2 实现
/**
* 最近点查找:按 x 方向找最近的数据点
* @param mouse 鼠标像素坐标
* @param pts 数据点像素坐标数组
* @returns 最近点下标
*
* 性能说明:数据点通常已按 t 排序 → 可用二分把 O(n) 降到 O(log n)
* 这里给线性版(数据 <1 万点时足够快),二分版留作自测 T3
*/
function nearestPoint(
mouse: { x: number; y: number },
pts: { x: number; y: number }[]
): number {
let best = -1;
let bestDist = Infinity;
for (let i = 0; i < pts.length; i++) {
// 只比 x 距离:十字准线的语义是"这个时刻的值",与鼠标纵向位置无关
const d = Math.abs(pts[i].x - mouse.x);
if (d < bestDist) {
bestDist = d;
best = i;
}
}
return best;
}
/**
* 绘制十字准线:竖线(时间指示)+ 数据点放大 + 数值标签
*/
function drawCrosshair(
ctx: CanvasRenderingContext2D,
pt: { x: number; y: number },
dataPoint: SeriesPoint,
grid: Rect,
color: string
): void {
ctx.save();
// 1. 竖直虚线:贯穿整个 grid 高度
ctx.strokeStyle = "rgba(0,198,255,0.4)";
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.moveTo(pt.x, grid.y);
ctx.lineTo(pt.x, grid.y + grid.h);
ctx.stroke();
ctx.setLineDash([]);
// 2. 数据点:空心放大圆
ctx.fillStyle = "#0d1421";
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pt.x, pt.y, 5, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 3. 数值标签(点上方,防出界左右翻转)
const text = `${dataPoint.v.toFixed(1)}℃`;
ctx.font = "11px sans-serif";
const tw = ctx.measureText(text).width;
const tx = pt.x - tw / 2 < grid.x ? grid.x : pt.x + tw / 2 > grid.x + grid.w ? grid.x + grid.w - tw : pt.x - tw / 2;
ctx.fillStyle = "rgba(13,20,33,0.92)";
ctx.fillRect(tx - 4, pt.y - 24, tw + 8, 16);
ctx.fillStyle = "#e0e8f0";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(text, tx, pt.y - 16);
ctx.restore();
}
5.3 为什么只比 x 距离
对比两种语义:
- 比 x(本实现):回答"鼠标所指时刻,值是多少"——时间序列的标准交互
- 比欧氏距离:回答"离鼠标最近的数据点"——散点图的标准交互
折线图用后者会出现"鼠标在两条线之间时标签乱跳"的问题。交互设计先问语义,再写算法。
六、多系列与图例切换
6.1 数据结构
/** 一个系列 = 一条线 */
interface LineSeries {
name: string; // 图例文字("1号炉温度")
color: string;
visible: boolean; // 图例开关
points: SeriesPoint[];
}
6.2 图例渲染与点击切换
/**
* 绘制图例(左上角,色块 + 名称排成一行)
* @returns 每个图例项的命中区域(供点击检测)
*/
function drawLegend(
ctx: CanvasRenderingContext2D,
series: LineSeries[],
origin: { x: number; y: number }
): { rect: Rect; series: LineSeries }[] {
const items: { rect: Rect; series: LineSeries }[] = [];
ctx.save();
ctx.font = "12px sans-serif";
let cx = origin.x;
for (const s of series) {
const tw = ctx.measureText(s.name).width;
const itemW = 14 + 6 + tw + 24; // 色块14 + 间距6 + 文字 + 项间距24
// 色块(不可见时画空心)
ctx.fillStyle = s.visible ? s.color : "transparent";
ctx.strokeStyle = s.color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(cx, origin.y, 14, 10, 2);
ctx.fill();
ctx.stroke();
// 文字(不可见时灰色)
ctx.fillStyle = s.visible ? "#c0ccdd" : "#55637a";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(s.name, cx + 20, origin.y + 5);
items.push({ rect: { x: cx, y: origin.y - 4, w: itemW - 24, h: 18 }, series: s });
cx += itemW;
}
ctx.restore();
return items;
}
// 点击切换:mousedown 时检测点中了哪个图例项,翻转 visible 后重绘
canvas.addEventListener("mousedown", (e) => {
for (const item of legendItems) {
if (pointInRect(e.offsetX, e.offsetY, item.rect)) {
item.series.visible = !item.series.visible;
redraw();
return;
}
}
});
💡 隐藏系列后,y 轴范围要用"可见系列的并集"重新计算(复用
niceTicks),否则隐藏掉高峰系列后剩余曲线被压在底部——ECharts 的legend切换时自动做这件事,现在你知道为什么要做了。
七、线条生长动画
从左到右逐渐画出整条线:利用 clip 裁剪区随时间右移(Day 44 的知识在这里落地)。
/**
* 线条生长动画:clip 区域宽度从 0 扩展到全宽
* 技巧:不用逐段绘制,一次性画完整条线,用 clip 控制可见范围
*/
function playGrowAnimation(
ctx: CanvasRenderingContext2D,
grid: Rect,
series: LineSeries[],
tScale: (t: number) => number,
duration: number
): void {
const start = performance.now();
const frame = (now: number): void => {
const t = Math.min((now - start) / duration, 1);
const eased = 1 - Math.pow(1 - t, 3);
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawAxes(); // 轴和网格先画
// 关键:裁剪区右边界随进度右移
ctx.save();
ctx.beginPath();
ctx.rect(grid.x, grid.y - 10, grid.w * eased, grid.h + 20);
ctx.clip();
for (const s of series) {
if (!s.visible) continue;
const pts = mapPoints(s.points, tScale, vScale);
drawArea(ctx, pts, vScale(0), s.color);
drawSmoothLine(ctx, pts, s.color);
}
ctx.restore();
if (t < 1) requestAnimationFrame(frame);
};
requestAnimationFrame(frame);
}
🎯 为什么用 clip 而不是逐段画:逐段画要处理"最后一段画到一半"的插值,clip 方案把这个问题交给光栅化硬件,代码量少一个数量级——这是"用第 4 周的知识解第 8 周的题"。
八、常见坑点
坑 1:平滑曲线过冲篡改数据
用了简单平均切线,峰值被削平、谷值被抬高。修复:monotone 插值(见 3.2)。工业报表里曲线冲出量程是事故级 bug。
坑 2:面积与曲线之间露缝
面积用直线画、曲线用 bezier 画,两者边界不重合。修复:面积闭合路径必须和线用同一套控制点。
坑 3:数据点 x 不等距时折线变形
按数组下标映射 x(xScale(i)),但数据时间间隔不均(缺测、跳变)。修复:x 必须用真实时间戳映射(xScale(p.t)),缺测点要么断线(分段 moveTo)要么插值——ECharts 的 connectNulls: false 控制的就是这个。
坑 4:十字线闪烁
每次 mousemove 全量重绘包括动画从头播。修复:hover 重绘只画"轴 + 线 + 十字准线",不触发 playGrowAnimation(动画只在 setData 时播一次)。
坑 5:图例切换后 y 轴不重算
隐藏了最大值系列,y 轴还保留着大范围。修复:每次重绘前用可见系列重新 niceTicks。
九、自测挑战
T1 · 补全 LineChart 类(40 分钟)
参照昨天 BarChart 的骨架封装 LineChart:setData({ series: LineSeries[] })、hover 十字准线、图例点击切换、生长动画。今天核心作业。
T2 · 时间轴标签(15 分钟)
x 轴刻度显示 HH:mm 格式:把 niceTicks 算出的时间戳刻度用 new Date(t).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }) 格式化。
T3 · 二分最近点(20 分钟)
数据点按 t 升序 → 用二分查找把 nearestPoint 从 O(n) 优化到 O(log n)。先写测试对比两版结果一致性(10000 个点随机 x 查询)。
T4 · 缺测断线(进阶,25 分钟)
SeriesPoint.v 允许 null(传感器离线)。实现分段绘制:遇到 null 断开 moveTo,跳过 null 点继续画。测试:中间挖掉 3 个点,线在缺口两侧正确断开。
十、总结
今天完成手写图表的另一半:
| 你写的 | ECharts 对应配置 |
|---|---|
monotoneControlPoints() |
series.smooth + smoothMonotone: 'x' |
drawArea() 渐变 |
series.areaStyle |
nearestPoint() + drawCrosshair() |
tooltip.trigger: 'axis' + axisPointer |
drawLegend() + 点击切换 |
legend 组件 |
| clip 生长动画 | animationDuration + line 特有的 clip 策略 |
| 缺测断线(T4) | connectNulls: false |
手写阶段毕业。明天开始学 ECharts——你现在已经拥有"配置项背后的心智模型",学习效率会完全不同。接下来四天的口号:不是"学怎么用",是"验证它确实是你写过的那个东西"。