【图表+ECharts】day51-handwritten-linechart

作者:mario 发布时间: 2026-09-03 阅读量:4 评论数:0

Day 51 · 手写折线图 — 平滑曲线、面积渐变、最近点命中与十字准线

昨天的柱状图是"类目 vs 数值",今天的折线图是"趋势"——时间序列数据(温度曲线、产量走势、告警频率)是工业可视化的一半江山。折线图比柱状图多三个难点:平滑曲线怎么画不"过冲"、鼠标在空白处怎么找到最近的数据点(十字准线)、多个系列怎么切换显示。这三个难点分别对应 ECharts 的 smoothaxisPointerlegend——写完今天,这三个配置项对你就是透明的。


目录


一、折线图 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 的骨架封装 LineChartsetData({ 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——你现在已经拥有"配置项背后的心智模型",学习效率会完全不同。接下来四天的口号:不是"学怎么用",是"验证它确实是你写过的那个东西"

评论