【Canvas 2D】day30-paths-bezier

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

Canvas 路径与线条 — 折线、圆弧、贝塞尔,一切图形的骨架

昨天的矩形三件套是"预制板",今天的路径(Path)才是自由施工:从一点出发,moveTo 起笔、lineTo 走直线、arcTo 转弯、bezierCurveTo 画任意曲线,最后 stroke 描边或 fill 填充。路径是 Canvas 一切复杂图形的骨架——折线图、仪表盘、流程图连线、设备拓扑,全是路径。今天还要攻克工业图表的灵魂:贝塞尔曲线的控制点直觉(ECharts 的 smooth 曲线就是它)。产出:一张带平滑曲线的实时温度折线图。


目录


一、路径的核心概念:铅笔与显影

const ctx = canvas.getContext("2d")!;

// 路径两阶段:① 用"铅笔"描(不显示)→ ② 显影(stroke/fill)

ctx.beginPath();          // ① 拿出一张新草稿纸(开始新路径)
ctx.moveTo(20, 100);      // 铅笔抬到起点(不画线)
ctx.lineTo(120, 40);      // 划到下一个点(仍在草稿上)
ctx.lineTo(220, 80);      // 继续划
// ↑ 到这里屏幕上什么都没有!

ctx.strokeStyle = "#00ff88";
ctx.lineWidth = 2;
ctx.stroke();             // ② 显影:草稿变成可见线条

ctx.beginPath();          // ⚠ 不 beginPath 就继续画 = 新线接到旧路径上!
ctx.moveTo(20, 200);
ctx.lineTo(200, 200);
ctx.stroke();             // 这一笔会把上面那条也再描一遍(重复描边变粗变深)
最重要的心智模型:
路径 = 不可见的"施工图纸",stroke/fill = 按图纸施工
每次 beginPath = 换一张新图纸(旧的作废)
忘记 beginPath = 新图画在旧图纸上 —— 新手 90% 的"线怎么黏在一起了"都源于此

二、基本路径指令全家福

ctx.beginPath();           // 新路径(清空路径草稿)
ctx.moveTo(x, y);          // 抬笔移动(起点 / 断点续画)
ctx.lineTo(x, y);          // 直线连到 (x, y)
ctx.closePath();           // 闭合路径(终点直线连回起点)
ctx.stroke();              // 描边显影
ctx.fill();                // 填充显影(自动闭合)

// 多段独立线(一次 stroke,两条不相干的线):
ctx.beginPath();
ctx.moveTo(10, 10); ctx.lineTo(50, 10);    // 第一段
ctx.moveTo(10, 30); ctx.lineTo(50, 30);    // moveTo 重新起笔 = 新子路径
ctx.stroke();   // 两条短横线(互不相连)
// 描边与填充的组合拳(温度条的圆角背景将来这么画):
ctx.beginPath();
ctx.moveTo(30, 30);
ctx.lineTo(170, 30);
ctx.lineTo(170, 90);
ctx.closePath();           // 自动连回 (30, 30) —— 三角形
ctx.fillStyle = "#1a2740";
ctx.fill();                // 填充
ctx.strokeStyle = "#00ff88";
ctx.stroke();              // 再描边(fill+stroke 可以同一路径先后用)

三、线条美学:lineCap 与 lineJoin

// ===== lineCap:线段的"端帽"(两端长什么样) =====
ctx.lineWidth = 12;
ctx.lineCap = "butt";     // 默认:方形端帽,正好截到端点
ctx.lineCap = "round";    // 圆头:端点外扩一个半圆(画板笔触用这个)
ctx.lineCap = "square";   // 方头:端点外扩一个方块

// 肉眼实验:同一条线画三遍,只有端点不同
ctx.beginPath(); ctx.moveTo(40, 30); ctx.lineTo(200, 30); ctx.stroke();
// 分别改 lineCap 再画 y=60 / y=90 的线对比

// ===== lineJoin:拐角的"关节"(两条线相接处) =====
ctx.lineJoin = "miter";   // 默认:尖角(延伸到交点)
ctx.lineJoin = "round";   // 圆角(折线图曲线感的廉价方案)
ctx.lineJoin = "bevel";   // 斜切角(切掉尖端)

// 肉眼实验:画直角折线
ctx.beginPath();
ctx.moveTo(40, 40); ctx.lineTo(150, 40); ctx.lineTo(150, 120);
ctx.stroke();   // 改三次 lineJoin 各画一遍
工业场景速记:
- 折线图拐点圆滑   → lineJoin: "round"
- 画笔笔触圆润     → lineCap: "round" + lineJoin: "round"(Day 35 画板)
- 流程图连线锐利   → 默认 miter(工程感)

四、圆弧:arc 的"时钟"参数

/**
 * 画圆弧:从 startAngle 转到 endAngle
 * ctx.arc(x, y, radius, startAngle, endAngle, anticlockwise?)
 *
 * 角度体系:以圆心为原点,3 点钟方向 = 0,顺时针为正(弧度制!)
 *
 *        -90°(270°)
 *      ┌────┼────┐
 * 180° ─┼────●────┼─ 0°     ← ● 是圆心 (x,y)
 *      └────┼────┘
 *         90°
 */
ctx.beginPath();
ctx.arc(200, 150, 60, 0, Math.PI * 2);        // 完整圆(转 360°)
ctx.stroke();

ctx.beginPath();
ctx.arc(200, 150, 60, -Math.PI / 2, Math.PI / 2);   // 右半圆(12点→6点方向)
ctx.stroke();

ctx.beginPath();
ctx.arc(200, 150, 60, Math.PI, 0);            // 上半圆(9点→3点)
ctx.stroke();

ctx.beginPath();
ctx.arc(200, 150, 60, Math.PI, 0, true);      // anticlockwise: 下半圆(逆时针转)
ctx.stroke();
// ⚠ arc 是"线到"起点再画弧 —— 连线事故现场:
ctx.beginPath();
ctx.moveTo(100, 150);          // 铅笔在 (100,150)
ctx.arc(200, 150, 60, 0, Math.PI);   // 弧的起点是 (260,150)
ctx.stroke();   // 💥 出现一条 (100,150)→(260,150) 的直线(arc 自动 lineTo 到弧起点)

// ✅ 画独立的弧:先 moveTo 到弧的起点(或弧是路径的第一个指令):
ctx.beginPath();
ctx.arc(200, 150, 60, 0, Math.PI);   // 路径第一个指令是 arc → 无连线
ctx.stroke();
// 弧度换算工具(全月随身):
const rad = (deg: number) => (deg * Math.PI) / 180;

// 仪表盘弧(Day 31 的前置):从 135° 到 405°(即绕过底部的 270° 大弧)
ctx.beginPath();
ctx.arc(150, 150, 80, rad(135), rad(405));
ctx.stroke();

五、贝塞尔曲线:控制点的直觉

5.1 二次贝塞尔:一个控制点

/**
 * 二次贝塞尔:起点 → 控制点 → 终点 的"磁力弯曲"
 * ctx.quadraticCurveTo(cpx, cpy, x, y)
 */
ctx.beginPath();
ctx.moveTo(50, 200);                              // 起点
ctx.quadraticCurveTo(150, 40, 250, 200);          // 控制点(150,40),终点(250,200)
ctx.stroke();

// 直觉:曲线被控制点"吸"过去,但永远不碰到它
// 曲线最凸处 ≈ 起终连线中点向控制点方向的一半(磁力随距离衰减)

5.2 三次贝塞尔:两个控制点

/**
 * 三次贝塞尔:两端各有一个控制点 —— 更强也更重要
 * ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)
 */
ctx.beginPath();
ctx.moveTo(50, 100);
ctx.bezierCurveTo(150, 20, 250, 180, 350, 100);   // cp1 拉起点,cp2 拉终点
ctx.stroke();

// ⭐ CSS transition 的 cubic-bezier(0.4, 0, 0.2, 1) 就是这个曲线的"归一化坐标版"
// 你写了多年的缓动函数,今天见到它的真身

5.3 控制点实验(今天必做的肉眼训练)

// 把控制点画出来,用鼠标拖它看曲线怎么变 —— 直觉的唯一练法:
const cp = { x: 150, y: 40 };

function render(): void {
  ctx.clearRect(0, 0, width, height);

  // 辅助线:起点→控制点→终点
  ctx.strokeStyle = "#3a4a6a";
  ctx.setLineDash([4, 4]);                     // 虚线(Day 33 详讲,先用)
  ctx.beginPath();
  ctx.moveTo(50, 200); ctx.lineTo(cp.x, cp.y); ctx.lineTo(250, 200);
  ctx.stroke();
  ctx.setLineDash([]);

  // 真实曲线
  ctx.strokeStyle = "#00ff88";
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(50, 200);
  ctx.quadraticCurveTo(cp.x, cp.y, 250, 200);
  ctx.stroke();

  // 控制点本体(红点)
  ctx.fillStyle = "#ff4d4f";
  ctx.beginPath();
  ctx.arc(cp.x, cp.y, 5, 0, Math.PI * 2);
  ctx.fill();
}

// 鼠标拖动控制点(Day 34 交互的预告,今天可以直接抄):
canvas.addEventListener("mousemove", (e) => {
  const rect = canvas.getBoundingClientRect();
  cp.x = e.clientX - rect.left;
  cp.y = e.clientY - rect.top;
  render();   // 立即模式:改状态就全量重画(Canvas 的宿命)
});

5.4 平滑折线:图表的 smooth 秘密

/**
 * 把折线数据点用二次贝塞尔连成平滑曲线
 * 关键:控制点取相邻两点连线的中点 —— 曲线在各数据点处"平滑路过"
 */
interface Point { x: number; y: number }

function drawSmoothLine(ctx: CanvasRenderingContext2D, pts: Point[]): void {
  ctx.beginPath();
  ctx.moveTo(pts[0].x, pts[0].y);

  for (let i = 1; i < pts.length - 1; i++) {
    // 当前点与下一点的中点:
    const midX = (pts[i].x + pts[i + 1].x) / 2;
    const midY = (pts[i].y + pts[i + 1].y) / 2;
    // 以当前点为控制点,画到中点:
    ctx.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY);
  }
  // 最后一段直接连到终点:
  ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
  ctx.stroke();
}

六、非零环绕规则:路径的填充判定

// 路径可以自相交/嵌套 —— fill 怎么判定"内部"?
// 规则:从某点向无穷远射一条线,路径每穿过一次记 ±1(方向相反符号相反)
//       净值 ≠ 0 → 内部(填充);= 0 → 外部(不填充)

// 最实用的推论:两个【反向】的圆嵌套 → 环形(挖洞):
ctx.beginPath();
ctx.arc(150, 150, 80, 0, Math.PI * 2);          // 大圆(顺时针)
ctx.arc(150, 150, 50, 0, Math.PI * 2, true);    // 小圆(逆时针 → 反向 → 挖洞)
ctx.fill();   // 得到圆环!(仪表盘外环的画法)

// 同向嵌套则全部填充(两圆重叠区域也在内部)
// ⚠ 两个 arc 在同一路径里会自动连线 —— 幸好填充时连线在内部看不出来

七、实战:温度折线图

综合今日全部知识的产出——实时温度折线图(第 3 个月大屏的雏形):

// day30-line-chart.ts
import { bootCanvas } from "./canvas-boot.js";

const { ctx, width, height } = bootCanvas(document.querySelector("#board")!);

// ===== 1. 数据(模拟 60 秒的温度采样) =====
const temps: number[] = Array.from({ length: 60 }, (_, i) =>
  65 + Math.sin(i / 8) * 15 + Math.random() * 3
);

// ===== 2. 坐标换算(值 → 画布坐标,图表的核心逻辑) =====
const padding = { left: 50, right: 20, top: 30, bottom: 40 };
const plotW = width - padding.left - padding.right;
const plotH = height - padding.top - padding.bottom;
const yMin = 40, yMax = 90;   // y 值域(温度范围)

/** 温度值 → y 坐标(⚠ y 向下:值越大 y 越小) */
const toY = (t: number) => padding.top + plotH * (1 - (t - yMin) / (yMax - yMin));
const toX = (i: number) => padding.left + (plotW / (temps.length - 1)) * i;

// ===== 3. 网格与坐标轴 =====
ctx.strokeStyle = "#1a2740";
ctx.lineWidth = 1;
for (let t = yMin; t <= yMax; t += 10) {           // 横向网格线 + 温度刻度
  const y = toY(t);
  ctx.beginPath();
  ctx.moveTo(padding.left, y);
  ctx.lineTo(width - padding.right, y);
  ctx.stroke();
  ctx.fillStyle = "#5a6a8a";
  ctx.font = "12px sans-serif";
  ctx.fillText(`${t}℃`, 10, y + 4);
}

// ===== 4. 平滑温度曲线 =====
const pts = temps.map((t, i) => ({ x: toX(i), y: toY(t) }));

ctx.strokeStyle = "#00ff88";
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 - 1; i++) {
  const midX = (pts[i].x + pts[i + 1].x) / 2;
  const midY = (pts[i].y + pts[i + 1].y) / 2;
  ctx.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY);
}
ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y);
ctx.stroke();

// ===== 5. 当前值锚点(最后一个点:圆 + 数值) =====
const last = pts[pts.length - 1];
ctx.fillStyle = "#00ff88";
ctx.beginPath();
ctx.arc(last.x, last.y, 4, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#e6f1ff";
ctx.font = "bold 14px sans-serif";
ctx.fillText(`${temps[temps.length - 1].toFixed(1)}℃`, last.x - 30, last.y - 12);
今日实操:
1. 落地 day30-line-chart/,跑出上图
2. 平滑 vs 折线对照:注释掉贝塞尔逻辑,改成纯 lineTo → 肉眼对比
3. 控制点拖拽实验(5.3 节)玩 10 分钟 —— 建立控制点直觉
4. 挑战:把 60 个点改成 6 个点,观察平滑算法在稀疏数据下的表现
5. 博客:《ECharts 的 smooth 曲线,原来就这么几行》

八、类比记忆:GPS 导航

路径系统 = GPS 导航记录仪

- beginPath   = 开始新行程(清空上一段记录)
- moveTo      = 秒速移动到起点(不留下轨迹)
- lineTo      = 直线行驶到下一点
- arc         = 沿圆弧弯道行驶(顺/逆时针是绕行方向)
- bezierCurveTo = 弯道被"磁场"(控制点)吸附出的自然弧线
- closePath   = 自动返程回起点
- stroke/fill = 把行车轨迹【显示】出来(导航记录不显影就没人看得见)

两个高频事故的导航版解释:
- 忘 beginPath = 新行程续接在旧轨迹上(两段行程被连成一笔)
- arc 自动连线 = 你在老位置直接上了弯道,GPS 画出一条"瞬移直线"

九、常见坑点与最佳实践

坑点 1:忘记 beginPath 导致路径累积

// 循环里画多条线 —— 每条都叠在旧路径上:
for (const p of points) {
  ctx.moveTo(0, 0);
  ctx.lineTo(p.x, p.y);
  ctx.stroke();          // ⚠ 第一次画 1 条,第二次画 2 条(第 1 条被重描)...
}                        //    越画越深、性能越来越差

// ✅ 每次 stroke 前都是新路径:
for (const p of points) {
  ctx.beginPath();       // ← 关键
  ctx.moveTo(0, 0);
  ctx.lineTo(p.x, p.y);
  ctx.stroke();
}

坑点 2:stroke 内的 lineWidth 被后续覆盖

// lineWidth/strokeStyle 是【画笔当前状态】不是"这笔线"的属性:
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(100,0); ctx.stroke();  // 1px
ctx.beginPath(); ctx.moveTo(0,20); ctx.lineTo(100,20); ctx.stroke(); // 还是 1px
ctx.lineWidth = 4;
ctx.beginPath(); ctx.moveTo(0,40); ctx.lineTo(100,40); ctx.stroke(); // 4px(之后的都 4px)
// 状态机思维:改的是"画笔",影响的是之后(第 6 周 save/restore 管理"画笔快照")

坑点 3:arc 的角度用了角度制

ctx.arc(100, 100, 50, 0, 360);              // ❌ 转了 360 弧度 ≈ 57 圈多
ctx.arc(100, 100, 50, 0, Math.PI * 2);      // ✅ 一整圈
// 随身工具:const rad = (d) => d * Math.PI / 180

坑点 4:y 轴方向坑了图表

// 温度 80 比 60 高 → 但 toY(80) < toY(60)(y 向下)
// 所有的图表换算都要"反转":
const toY = (t) => top + plotH * (1 - (t - min) / (max - min));   // ✅ 1 - ratio
// 症状自查:曲线"上下颠倒"= 忘了 1 -

十、自测挑战

挑战 1(基础):指令对号入座

a. 画两条互不相连的平行线(一次 stroke)
b. 画一个梯形并填充
c. 画一个从 12 点方向开始的右半圆
d. 画一个圆环(非零环绕规则)

挑战 2(进阶):手写圆角矩形

// 实现 roundRect(ctx, x, y, w, h, r):四角是 r 半径的圆弧
// 用 lineTo + arc 组合(提示:每个角用 arc(x±r, y±r, r, 起始角, 结束角))
// 画板(Day 35)和流程图节点(Day 43)都要用它

挑战 3(进阶):仪表盘刻度环

// 画 240° 的弧(从 150° 到 390°),上面均匀分布 12 根刻度短线
// 训练点:弧度换算 + 刻度两端点的三角函数计算:
//   tickInner = center + (r-10) * (cos, sin)
//   tickOuter = center + r * (cos, sin)

挑战 4(论文级):讲清平滑算法

向橡皮鸭解释 drawSmoothLine:
1. 为什么控制点取"当前点"而不是"中点"?
2. 为什么最后一段要特殊处理?
3. 曲线在数据点处是"路过"还是"停驻"?这如何保证平滑?

十一、总结与知识图谱

路径与线条(Day 30)
│
├── 心智模型:路径 = 不可见草稿,stroke/fill = 显影
│
├── 基本指令
│   ├── beginPath —— 换新草稿(防累积 ⭐)
│   ├── moveTo / lineTo / closePath
│   └── 子路径(moveTo 重新起笔)
│
├── 线条美学
│   ├── lineCap —— 端帽(butt/round/square)
│   └── lineJoin —— 拐角(miter/round/bevel)
│
├── 圆弧
│   ├── arc(x,y,r,start,end,anticw) —— 3点钟 0 弧度、顺时针正
│   ├── arc 自动连线陷阱
│   └── rad() 换算工具
│
├── 贝塞尔
│   ├── quadraticCurveTo —— 一个控制点(磁力弯曲)
│   ├── bezierCurveTo —— 两个控制点(cubic-bezier 真身)
│   └── 平滑折线算法(中点 + 当前点做控制点)
│
└── 填充判定
    └── 非零环绕规则(反向嵌套 = 挖洞 → 圆环)

一句话总结:路径是"先描不显、后显影"的两阶段系统——beginPath 换草稿、moveTo 管断点、贝塞尔的"控制点磁力"是曲线直觉的全部;图表世界的折线和平滑,本质上就是今天的 lineToquadraticCurveTo 在数据点间的排列组合。


明日预告:Day 31 渐变与填充——fillStyle 不止是纯色:线性渐变的"两点一线"、径向渐变的"同心圆"、图案平铺;产出金属质感仪表盘 + 数据热力色带。

评论