Canvas 基础变换三件套 — translate / rotate / scale,坐标系随心而动
昨天学会了用 save/restore 保护状态,今天正式开上"变形金刚":translate(搬桌子)、rotate(转桌布)、scale(拉伸桌布)。核心思想一句话:不是把图形转个角度画出来,而是把整个坐标系转个角度,然后按老样子画图形。今天最大的坑不是 API,是两个认知:rotate 用的是弧度不是角度;变换顺序不可交换(先转再移 ≠ 先移再转)。搞透这两点,仪表盘指针、旋转图标、脉冲缩放这些工业组态常见效果信手拈来。
目录
- 一、核心思想:动的是坐标系,不是图形
- 二、translate:搬桌子
- 三、rotate:转桌布(弧度制速成)
- 四、scale:拉伸桌布
- 五、组合拳:以任意点为中心旋转
- 六、变换顺序的不可交换性
- 七、实战:仪表盘指针动画
- 八、常见坑点与最佳实践
- 九、自测挑战
- 十、总结与知识图谱
一、核心思想:动的是坐标系,不是图形
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 就一直累加 |
九、自测挑战
- 换算题:口算以下角度的弧度:90°、45°、120°、270°、-90°。
- 预测题:不运行代码,推算方块最终画在画布的哪个位置?
ctx.save();
ctx.rotate(deg(90));
ctx.translate(100, 0);
ctx.fillRect(-10, -10, 20, 20);
ctx.restore();
- 实现题:写一个
drawRotatedText(ctx, x, y, angle, text)—— 文字围绕自身中心旋转(提示:需要 measureText 量宽度来定位中心)。 - 改错题:下面代码想让方块围绕自己中心 (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();
- 设计题:用今天的技能画"传送带上的旋转齿轮"——齿轮自转的同时随传送带平移。写出动画循环里的关键变换代码(齿轮画法可以简化为方块 + 对角线)。
十、总结与知识图谱
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 其实都是矩阵的快捷方式。学完矩阵就能解锁本周重头戏:以鼠标为中心的滚轮缩放 + 拖拽平移(组态画面/大屏编辑器的核心交互),它的数学本质是一个可控的场景矩阵。