【Canvas 2D】day37-basic-transforms

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

Canvas 基础变换三件套 — translate / rotate / scale,坐标系随心而动

昨天学会了用 save/restore 保护状态,今天正式开上"变形金刚":translate(搬桌子)、rotate(转桌布)、scale(拉伸桌布)。核心思想一句话:不是把图形转个角度画出来,而是把整个坐标系转个角度,然后按老样子画图形。今天最大的坑不是 API,是两个认知:rotate 用的是弧度不是角度;变换顺序不可交换(先转再移 ≠ 先移再转)。搞透这两点,仪表盘指针、旋转图标、脉冲缩放这些工业组态常见效果信手拈来。


目录


一、核心思想:动的是坐标系,不是图形

1.1 一个对比实验

// 需求:在画布中心 (200, 150) 画一个 40×40 的方块

// 方式 A:算坐标(第 5 周的思路)
ctx.fillRect(200 - 20, 150 - 20, 40, 40);   // fillRect 的 x/y 是"左上角",要换算

// 方式 B:变换(本周的思路)
ctx.save();
ctx.translate(200, 150);    // 坐标原点搬到 (200,150)
ctx.fillRect(-20, -20, 40, 40);   // 以"新原点"为中心画 —— 直觉坐标!
ctx.restore();

方块简单,两种方式差别不大。但换成"以中心旋转 30° 的方块"呢?

// 方式 A:手动算旋转后的四个角坐标 —— 三角函数算到吐 ❌
const cx = 200, cy = 150, a = Math.PI / 6;
const corners = [[-20,-20],[20,-20],[20,20],[-20,20]].map(([x, y]) => [
  cx + x * Math.cos(a) - y * Math.sin(a),
  cy + x * Math.sin(a) + y * Math.cos(a),
]);
// ...还要 beginPath 逐点 moveTo/lineTo

// 方式 B:三行变换 ✅
ctx.save();
ctx.translate(200, 150);        // ① 原点搬到中心
ctx.rotate(Math.PI / 6);        // ② 坐标系转 30°
ctx.fillRect(-20, -20, 40, 40); // ③ 在"已旋转的坐标系"里画正方块
ctx.restore();

1.2 桌布模型(记住这个类比,终身受用)

想象画布上铺着一张透明桌布,你在桌布上画画:

translate(100, 50)   → 把桌布往右挪 100、往下挪 50
rotate(30°)          → 以【桌布当前原点】为轴转桌布
scale(2, 2)          → 把桌布均匀拉伸 2 倍(画的东西跟着变大)

关键:你的笔始终按"桌布上的坐标"画,
      桌布动了,笔迹落在画布上的位置/角度/大小就跟着变。

二、translate:搬桌子

2.1 基本用法

ctx.translate(dx, dy);   // 坐标系平移:后续绘制的坐标全部 + (dx, dy)

ctx.translate(100, 50);
ctx.fillRect(0, 0, 40, 40);   // 实际画在画布的 (100, 50) 处

2.2 累积效应

ctx.translate(100, 50);   // 原点:(0,0) → (100,50)
ctx.translate(30, 20);    // 原点:(100,50) → (130,70)   ⚠ 是累加,不是覆盖!

ctx.fillRect(0, 0, 10, 10);   // 画在 (130, 70)

2.3 典型用途:局部坐标系

/**
 * 绘制设备图标的通用模式:
 * 把原点搬到设备中心 → 后续所有坐标都"以设备中心为原点"写
 * 图标内部坐标再复杂,也只需考虑相对位置
 */
function drawDevice(ctx: CanvasRenderingContext2D, x: number, y: number): void {
  ctx.save();
  ctx.translate(x, y);            // 原点 = 设备中心

  ctx.fillStyle = "#4a6fa5";
  ctx.fillRect(-15, -15, 30, 30); // 本体:中心对齐,坐标直觉化

  ctx.strokeStyle = "#00ff88";
  ctx.strokeRect(-20, -20, 40, 40); // 外框

  ctx.fillStyle = "#ffc53d";
  ctx.beginPath();
  ctx.arc(0, 0, 4, 0, Math.PI * 2); // 中心指示灯
  ctx.fill();

  ctx.restore();
}

// 主循环里调用时不用关心图标内部结构:
drawDevice(ctx, 100, 100);
drawDevice(ctx, 300, 200);

三、rotate:转桌布(弧度制速成)

3.1 API 与弧度

ctx.rotate(angle);   // 旋转坐标系,单位是【弧度】,顺时针为正!

弧度换算弧度 = 角度 × π / 180

// 常用角度速查(背下来,天天用):
Math.PI / 6    // 30°
Math.PI / 4    // 45°
Math.PI / 3    // 60°
Math.PI / 2    // 90°
Math.PI        // 180°
Math.PI * 2    // 360°

// 角度转弧度的工具函数(写一次,项目里到处用):
const deg = (d: number): number => (d * Math.PI) / 180;

ctx.rotate(deg(30));    // 转 30°,可读性好

3.2 旋转方向:y 轴向下 → “顺时针为正”

// ⚠ Canvas 坐标系 y 轴向下(Day 29 讲过),所以:
ctx.rotate(Math.PI / 6);   // 视觉上是【顺时针】转 30°(数学课本里是逆时针!)

// 心算方法:正角度 = 表针走的方向

3.3 旋转围绕谁?—— 当前原点!

ctx.rotate(Math.PI / 4);   // 围绕 (0,0) 转(默认原点在画布左上角)

// 想围绕点 (200,150) 转?先 translate 过去:
ctx.translate(200, 150);
ctx.rotate(Math.PI / 4);   // 现在围绕 (200,150) 转 ✅

这就是 translate 和 rotate 总是成对出现的原因——rotate 自己没有"围绕哪个点"的参数。


四、scale:拉伸桌布

4.1 基本用法与"双向拉伸"陷阱

ctx.scale(sx, sy);   // x 方向缩放 sx 倍,y 方向缩放 sy 倍

ctx.scale(2, 2);
ctx.fillRect(0, 0, 40, 40);   // 视觉上是 80×80 —— 图形变大

// ⚠ 但注意:不只尺寸,【坐标也被缩放】!
ctx.scale(2, 2);
ctx.fillRect(50, 50, 40, 40);   // 视觉位置在 (100,100),尺寸 80×80
                                // 坐标 50 也被 ×2 了 —— "双向拉伸"

4.2 负数缩放 = 镜像

ctx.scale(-1, 1);    // x 轴翻转:水平镜像
ctx.scale(1, -1);    // y 轴翻转:垂直镜像(上下颠倒)

// 实战:设备图标的"朝向"翻转(管道箭头朝左/朝右复用一份代码)
function drawArrow(ctx: CanvasRenderingContext2D, x: number, y: number, flip: boolean): void {
  ctx.save();
  ctx.translate(x, y);
  if (flip) ctx.scale(-1, 1);      // 朝左的箭头 = 朝右箭头的镜像
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(20, -8);
  ctx.lineTo(20, 8);
  ctx.closePath();
  ctx.fillStyle = "#00ff88";
  ctx.fill();
  ctx.restore();
}

4.3 scale 影响的不只是图形:线宽也被缩放

ctx.lineWidth = 2;
ctx.scale(3, 3);
ctx.strokeRect(0, 0, 40, 40);   // 线宽视觉上是 6px!⚠ 尺寸相关的属性全部被缩放

// 需要保持线宽不变时:先算回真实线宽
ctx.lineWidth = 2 / 3;          // 被 ×3 后恰好是 2px
// 或者用向量长度补偿(第 7 周流程图连线的通用做法,届时展开)

五、组合拳:以任意点为中心旋转

5.1 三步曲模板(本周最重要的代码模板)

/**
 * 以 (px, py) 为中心、旋转 angle 弧度地绘制
 * 三步曲:搬到中心 → 旋转 → 搬回去(或者不搬回去,直接画)
 */
function rotateAt(ctx: CanvasRenderingContext2D, px: number, py: number, angle: number, draw: () => void): void {
  ctx.save();
  ctx.translate(px, py);      // ① 原点搬到旋转中心
  ctx.rotate(angle);          // ② 绕新原点旋转
  draw();                     // ③ 在"以中心为原点、已旋转"的坐标系里画
  ctx.restore();
}

// 用法:画一个绕自身中心 (200,150) 转 30° 的方块
rotateAt(ctx, 200, 150, deg(30), () => {
  ctx.fillStyle = "#00ff88";
  ctx.fillRect(-20, -20, 40, 40);   // 坐标以中心为原点,好写!
});

5.2 变体:以任意点为中心缩放

// 同样的三步曲,把 rotate 换成 scale:
ctx.save();
ctx.translate(px, py);      // ① 搬到缩放中心
ctx.scale(s, s);            // ② 缩放
drawStuff();                // ③ 以中心为原点画
ctx.restore();

// 典型场景:图标的"脉冲报警"效果 —— 围绕自身中心 1.0→1.2→1.0 循环缩放
const s = 1 + 0.2 * Math.sin(t * 6);   // t 为时间秒数,正弦呼吸

5.3 更复杂的组合:中心缩放 + 中心旋转

ctx.save();
ctx.translate(px, py);       // 搬到中心
ctx.rotate(angle);           // 转一转
ctx.scale(s, s);             // 再缩一缩
drawAtLocalOrigin();         // 局部坐标系里随便画
ctx.restore();
// 顺序语义:先应用 scale 再 rotate?—— 反直觉预警 → 看第六节

六、变换顺序的不可交换性

6.1 一个翻车实验

// 实验 A:先移再转
ctx.save();
ctx.translate(100, 0);
ctx.rotate(deg(90));
ctx.fillRect(-10, -10, 20, 20);   // 方块在哪?
ctx.restore();

// 实验 B:先转再移
ctx.save();
ctx.rotate(deg(90));
ctx.translate(100, 0);
ctx.fillRect(-10, -10, 20, 20);   // 方块在哪?
ctx.restore();

用桌布模型推演

实验 A(先移再转):
  桌布先右移 100 → 原点到 (100,0)
  再原地转 90°(围绕新原点 (100,0))
  → 画在 (100, 0) 附近 ✅

实验 B(先转再移):
  桌布先转 90° → 桌布的 x 轴指向画布的"下方"了(顺时针90°)
  再沿【桌布自己的 x 轴】移 100
  → 实际移到了画布的 (0, 100)!
  → 画在 (0, 100) 附近 ⚠ 和 A 完全不同!

6.2 规则总结

变换是"从后往前"作用于图形的:

代码顺序:translate → rotate → scale → 绘制
作用于图形:绘制的东西先被 scale,再被 rotate,最后被 translate

记忆法:代码像穿衣服,图形像人——
        最先写的(translate)是最外层的大衣。

6.3 为什么"以中心旋转"模板是对的?

ctx.translate(px, py);   // 最外层:整体搬到中心
ctx.rotate(angle);       // 中间层:围绕当前原点(=中心)转
// 绘制的图形:先转 → 再被搬到中心
// 旋转围绕的是 translate 后的原点 → 正是中心 ✅

七、实战:仪表盘指针动画

今天的实战目标:一个带刻度的半圆仪表盘 + 平滑摆动的指针——工业大屏最经典的元素。用到的技能:rotate 三步曲(指针)、arc(表盘)、rAF + 时间驱动(Day 34)、save/restore(昨天)。

7.1 完整代码

// day37-gauge.ts —— 仪表盘指针动画
import { bootCanvas } from "../day29/canvas-boot.js";

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

const CX = cssWidth / 2;        // 表盘中心
const CY = cssHeight / 2 + 60;  // 稍微下移,半圆表盘更好看
const R = 140;                  // 表盘半径

const deg = (d: number): number => (d * Math.PI) / 180;

/** 绘制表盘:外弧 + 主刻度 + 次刻度 + 数值标签 */
function drawDial(): void {
  // 半圆弧(从 180° 到 360°,即左→上→右)
  ctx.strokeStyle = "#3a4a6a";
  ctx.lineWidth = 8;
  ctx.beginPath();
  ctx.arc(CX, CY, R, Math.PI, Math.PI * 2);
  ctx.stroke();

  // 刻度:每 20° 一根主刻度(对应 0-100 的值)
  ctx.save();
  ctx.translate(CX, CY);                       // 原点搬到表盘中心(刻度围绕它转)
  for (let v = 0; v <= 100; v += 5) {
    const isMajor = v % 20 === 0;
    const angle = Math.PI + deg((v / 100) * 180);   // 值 → 弧度:180°(左) 到 360°(右)

    ctx.save();                                // 每根刻度独立变换空间
      ctx.rotate(angle);                       // x 轴指向刻度方向
      ctx.strokeStyle = isMajor ? "#e6f1ff" : "#3a4a6a";
      ctx.lineWidth = isMajor ? 3 : 1;
      ctx.beginPath();
      ctx.moveTo(R - 14, 0);                   // 刻度从半径内侧开始(沿 x 轴)
      ctx.lineTo(R - (isMajor ? 4 : 8), 0);    // 主刻度更长
      ctx.stroke();
    ctx.restore();

    // 主刻度的数值标签(文字不能跟着转 → 用三角函数定位,rotate 只给刻度线用)
    if (isMajor) {
      const a = angle;
      const lx = CX + Math.cos(a) * (R - 30);  // 标签在刻度内侧 30px
      const ly = CY + Math.sin(a) * (R - 30);
      ctx.fillStyle = "#8fa3c0";
      ctx.font = "12px sans-serif";
      ctx.textAlign = "center";
      ctx.textBaseline = "middle";
      ctx.fillText(String(v), lx, ly);
    }
  }
  ctx.restore();
}

/**
 * 绘制指针:以表盘中心为轴,rotate 到当前值对应角度
 * @param value 当前值(0-100)
 */
function drawNeedle(value: number): void {
  const angle = Math.PI + deg((value / 100) * 180);   // 值 → 指针角度

  ctx.save();
  ctx.translate(CX, CY);       // ① 原点 = 表盘中心
  ctx.rotate(angle);           // ② 坐标系转到指针方向

  // 指针:在"已旋转的坐标系"里就是一根指向 x 正方向的细长三角
  ctx.shadowColor = "rgba(0,255,136,0.6)";   // Day 32:发光特效(save/restore 保护)
  ctx.shadowBlur = 8;
  ctx.fillStyle = "#00ff88";
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(R - 24, -3);
  ctx.lineTo(R - 24, 3);
  ctx.closePath();
  ctx.fill();
  ctx.restore();

  // 中心轴帽(不旋转,画在中心)
  ctx.fillStyle = "#e6f1ff";
  ctx.beginPath();
  ctx.arc(CX, CY, 7, 0, Math.PI * 2);
  ctx.fill();
}

// ===== 动画:数值平滑逼近目标值(大屏仪表的通用手法)=====
let current = 0;          // 当前显示值
let target = 76;          // 目标值(实际项目来自 WebSocket 推送——第 8 周接上)

function frame(now: number): void {
  // 缓动逼近:每帧靠近目标 8%(指数逼近,自然减速)
  current += (target - current) * 0.08;

  // 模拟数据周期变化(第 8 周换成真实推送)
  if (Math.random() < 0.01) target = 30 + Math.random() * 65;

  ctx.clearRect(0, 0, cssWidth, cssHeight);
  drawDial();
  drawNeedle(current);

  // 数值文字
  ctx.fillStyle = "#00ff88";
  ctx.font = "bold 32px sans-serif";
  ctx.textAlign = "center";
  ctx.fillText(current.toFixed(1), CX, CY - 40);

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

7.2 复盘两个关键设计

设计 为什么
刻度线用 rotate 画、标签用三角函数定位 刻度线是"沿半径方向的线",rotate 后画水平线极简单;标签是文字,转了会歪,必须保持正向
current += (target - current) * 0.08 指针直接跳到新值很生硬;指数逼近 = 自然的物理感(大屏仪表标配动效,记住这个 0.08)

八、常见坑点与最佳实践

# 症状 解法
1 rotate 传角度值 rotate(30) 转了约 1719°(30 弧度) 弧度!用 deg() 工具函数
2 以为 rotate 可以指定中心 图形围绕画布左上角转 rotate 围绕当前原点,先 translate 到中心
3 变换顺序随手写 同样两行代码换个顺序,结果天差地别 记住"从后往前作用";不确定就桌布推演
4 scale 后忘了坐标也被缩放 图形位置飘了 位置统一在 translate 之后用局部坐标;或先算好
5 scale 后线宽变粗 大图边框糊成一片 lineWidth = 期望值 / scale
6 变换没包 save/restore 后面的图形全部歪掉 涉及变换必配对(昨天准则 1)
7 负角度方向搞反 逆时针变成了顺时针 y 轴向下 → 正角度顺时针;负角度逆时针
8 嵌套变换忘了累积性 translate 叠加越飞越远 每次 restore 后坐标系才复原;不 restore 就一直累加

九、自测挑战

  1. 换算题:口算以下角度的弧度:90°、45°、120°、270°、-90°。
  2. 预测题:不运行代码,推算方块最终画在画布的哪个位置?
ctx.save();
ctx.rotate(deg(90));
ctx.translate(100, 0);
ctx.fillRect(-10, -10, 20, 20);
ctx.restore();
  1. 实现题:写一个 drawRotatedText(ctx, x, y, angle, text) —— 文字围绕自身中心旋转(提示:需要 measureText 量宽度来定位中心)。
  2. 改错题:下面代码想让方块围绕自己中心 (100,100) 旋转并放大 1.5 倍,但有 bug。找出来并修复。
ctx.save();
ctx.rotate(deg(30));
ctx.scale(1.5, 1.5);
ctx.translate(100, 100);
ctx.fillRect(-20, -20, 40, 40);
ctx.restore();
  1. 设计题:用今天的技能画"传送带上的旋转齿轮"——齿轮自转的同时随传送带平移。写出动画循环里的关键变换代码(齿轮画法可以简化为方块 + 对角线)。

十、总结与知识图谱

Day 37 变换三件套
├── 核心思想
│   └── 桌布模型:动坐标系,图形按局部坐标画
├── translate(dx, dy)
│   ├── 累积性(多次调用叠加)
│   └── 用途:局部坐标系(图元以自身中心为原点)
├── rotate(radian)
│   ├── 弧度制:deg() 工具函数
│   ├── 顺时针为正(y 轴向下)
│   └── 围绕当前原点 → 与 translate 搭配
├── scale(sx, sy)
│   ├── 坐标 + 尺寸双向缩放
│   ├── 负数 = 镜像(flip 复用)
│   └── 线宽补偿 lineWidth / s
├── 组合模板
│   └── 中心变换三步曲:translate(中心) → rotate/scale → 局部坐标画
├── 顺序不可交换
│   └── 从后往前作用;代码 = 穿衣服顺序
└── 实战
    └── 仪表盘:rotate 画刻度/指针 + 指数逼近动效(0.08 缓动)

明天预告(Day 38):变换矩阵 setTransform/transform——六个参数 a b c d e f 背后的统一模型。今天的三个 API 其实都是矩阵的快捷方式。学完矩阵就能解锁本周重头戏:以鼠标为中心的滚轮缩放 + 拖拽平移(组态画面/大屏编辑器的核心交互),它的数学本质是一个可控的场景矩阵。

评论