Canvas 渐变与填充 — fillStyle 的三种形态,画出"质感"
纯色填充的界面像简笔画,渐变填充的界面像产品。今天把 fillStyle 的三种形态一次学全:颜色(四种格式的选型)、渐变(线性的"两点一线"、径向的"同心圆")、图案(Pattern 平铺)。工业可视化的质感密码全在这天:仪表盘的金属光泽(径向渐变)、温度条的冷热过渡(线性渐变)、热力图的色带插值(渐变坐标的数学)。产出:金属质感仪表盘 + 数据热力色带。
目录
- 一、fillStyle 的三种形态
- 二、颜色格式全解
- 三、线性渐变:两点一线
- 四、径向渐变:两个同心圆
- 五、createPattern:图案平铺
- 六、色带插值:热力图的数学
- 七、实战:金属质感仪表盘
- 八、类比记忆:喷枪与贴膜
- 九、常见坑点与最佳实践
- 十、自测挑战
- 十一、总结与知识图谱
一、fillStyle 的三种形态
// fillStyle / strokeStyle 接受三种"颜料":
// ① 颜色字符串(昨天的用法)
ctx.fillStyle = "#00ff88";
ctx.fillStyle = "rgba(0, 255, 136, 0.5)";
// ② 渐变对象(今天的主角 —— 不是字符串,是"先创建再赋值")
const grad = ctx.createLinearGradient(0, 0, 200, 0);
ctx.fillStyle = grad;
// ③ 图案对象(图片/画布平铺)
const pattern = ctx.createPattern(image, "repeat");
ctx.fillStyle = pattern;
// ⚠ 心智转变:fillStyle 不止是"颜色值",是"颜料配方"
// 配方挂在画笔状态上 —— 后续所有 fill 都用它,直到换配方
二、颜色格式全解
// ===== 四种格式的写法与用途 =====
// 1. 十六进制(首选:紧凑、大屏配色表的标准格式)
ctx.fillStyle = "#0a1628"; // 深空蓝(本月的主题底色)
ctx.fillStyle = "#00ff88"; // 荧光绿
ctx.fillStyle = "#0a1628cc"; // 8 位 = 带 80% 不透明度(#rrggbbaa)
// 2. rgb / rgba(需要动态计算分量时用)
ctx.fillStyle = "rgb(10, 22, 40)";
ctx.fillStyle = `rgba(255, 77, 79, ${alpha})`; // ⭐ 动态透明度(报警闪烁效果)
// 3. hsl / hsla(需要"按色相渐变"时用 —— 热力图色带的核心)
// hsl(色相0-360, 饱和度%, 亮度%)
ctx.fillStyle = "hsl(120, 100%, 50%)"; // 纯绿
ctx.fillStyle = `hsl(${hue}, 90%, 50%)`;// hue 扫 0→360 = 红橙黄绿蓝紫彩虹
// 4. 预设颜色名(原型 Demo 快速用,正式项目不用)
ctx.fillStyle = "red";
// 选型速记:
// 静态配色 → #hex
// 动态透明度 → rgba 模板串
// 动态色相(色带)→ hsl 模板串
// 温度→颜色的映射(工业场景高频函数,今天先记 hsl 版):
/** 温度(0-100) → 冷暖色(蓝→红) */
function tempColor(temp: number): string {
const hue = 220 - (temp / 100) * 220; // 220°(蓝) → 0°(红)
return `hsl(${hue}, 85%, 55%)`;
}
tempColor(0); // 蓝(冷)
tempColor(50); // 绿黄(温)
tempColor(100); // 红(热)
三、线性渐变:两点一线
3.1 基本用法
/**
* 线性渐变 = 在画布上定义一条"方向线",颜色沿线分布
* createLinearGradient(x0, y0, x1, y1)
*/
const grad = ctx.createLinearGradient(0, 0, 200, 0); // 从 (0,0) 到 (200,0):从左到右
// 色标:offset 0-1 的位置上放什么颜色(必须先 addColorStop 才能用!)
grad.addColorStop(0, "#00c6ff"); // 起点:蓝
grad.addColorStop(1, "#0072ff"); // 终点:深蓝
// 中间自动插值(蓝→深蓝的平滑过渡)
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 200, 100); // 用渐变配方填矩形
3.2 渐变坐标在画布上,不在形状里(最重要的认知)
// ⚠ 渐变的坐标系是【整个画布】,不是"被填充的形状":
const grad = ctx.createLinearGradient(0, 0, 400, 0); // 横跨整个画布 0→400
grad.addColorStop(0, "#000");
grad.addColorStop(1, "#fff");
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 100, 50); // 这块矩形:只显示渐变的【左端 25%】(偏黑)
ctx.fillRect(300, 100, 100, 50); // 这块矩形:只显示【右端 25%】(偏白)
// 推论(两个高频需求):
// 需求 A:多个形状共享同一条渐变 → 坐标用画布绝对坐标(如上)
// 需求 B:每个形状各自完整渐变 → 每个形状用自己的局部坐标建渐变:
function gradientRect(x: number, y: number, w: number, h: number) {
const g = ctx.createLinearGradient(x, y, x + w, y); // 以矩形自身为坐标
g.addColorStop(0, "#00c6ff");
g.addColorStop(1, "#0072ff");
ctx.fillStyle = g;
ctx.fillRect(x, y, w, h);
}
3.3 三种常用方向
// 水平(左→右):
ctx.createLinearGradient(x, y, x + w, y);
// 垂直(上→下):
ctx.createLinearGradient(x, y, x, y + h);
// 对角(左上→右下):
ctx.createLinearGradient(x, y, x + w, y + h);
// ⚠ 垂直渐变的 y 方向:起点在上(y 小)—— 温度条"上红下蓝"就从这控制
四、径向渐变:两个同心圆
4.1 基本用法
/**
* 径向渐变 = 内圆到外圆的放射过渡
* createRadialGradient(x0, y0, r0, x1, y1, r1)
* 颜色从【内圆边缘】过渡到【外圆边缘】
*/
const grad = ctx.createRadialGradient(150, 150, 10, 150, 150, 100);
// 内圆:圆心(150,150) 半径10
// 外圆:圆心(150,150) 半径100
grad.addColorStop(0, "#ffffff"); // 内圆处:白(高光)
grad.addColorStop(1, "#1a2740"); // 外圆处:深色(边缘)
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(150, 150, 100, 0, Math.PI * 2);
ctx.fill(); // 一颗"球":中心亮、边缘暗 —— 立体感的全部秘密
4.2 内外圆偏移:光照方向
// 内圆不必与外圆同心 —— 偏移内圆 = 光源偏移:
const grad = ctx.createRadialGradient(120, 120, 5, 150, 150, 100);
// ↑ 内圆偏向左上 → 左上有高光 → 像左上方打光
grad.addColorStop(0, "rgba(255,255,255,0.9)");
grad.addColorStop(1, "#1a2740");
// 应用:仪表盘金属球的质感 = 内圆偏左上 + 白高光到深色
// 灯光闪烁动画(Day 32 阴影登场前的预告):radial 渐变的内圆半径随时间脉动
五、createPattern:图案平铺
/**
* 图案平铺:把一张图(或另一个 canvas)当壁纸贴
* createPattern(image, repetition)
*/
const img = new Image();
img.src = "./grid.png"; // 16×16 的网格单元图
img.onload = () => {
const pattern = ctx.createPattern(img, "repeat"); // 双向平铺
ctx.fillStyle = pattern;
ctx.fillRect(0, 0, width, height); // 整张画布铺上"坐标纸"
};
// repetition 四种:
// "repeat" 双向平铺(默认)
// "repeat-x" 只横向
// "repeat-y" 只纵向
// "no-repeat" 只贴一次
// ⭐ 高级技巧:用 canvas 做 pattern 的源(不需要图片文件!):
// 圆点纹理:
const tile = document.createElement("canvas");
tile.width = tile.height = 24;
const tctx = tile.getContext("2d")!;
tctx.fillStyle = "#1a2740";
tctx.fillRect(0, 0, 24, 24);
tctx.fillStyle = "#3a4a6a";
tctx.beginPath();
tctx.arc(12, 12, 2, 0, Math.PI * 2);
tctx.fill();
const dots = ctx.createPattern(tile, "repeat")!;
ctx.fillStyle = dots;
ctx.fillRect(0, 0, width, height); // 工业大屏经典的"圆点网格背景"
六、色带插值:热力图的数学
/**
* 数据值 → 色带颜色的插值(热力图/温度云图的核心算法)
* 方案一:hsl 直接插值(简单,但颜色过渡的"感知均匀度"一般)
*/
function heatColor(value: number): string { // value: 0-1
const hue = 240 * (1 - value); // 240°(蓝) → 0°(红)
return `hsl(${hue}, 95%, ${40 + value * 20}%)`;
}
/**
* 方案二:多色标插值(专业热力图:蓝→青→绿→黄→红)
* 手动在色标之间线性插值 —— 今天的高级练习
*/
type Stop = { at: number; rgb: [number, number, number] };
const HEAT_STOPS: Stop[] = [
{ at: 0.0, rgb: [8, 25, 66] }, // 深蓝(冷)
{ at: 0.25, rgb: [0, 180, 220] }, // 青
{ at: 0.5, rgb: [0, 220, 120] }, // 绿
{ at: 0.75, rgb: [255, 200, 0] }, // 黄
{ at: 1.0, rgb: [255, 50, 30] }, // 红(热)
];
/** 在色标间线性插值:value(0-1) → rgb 字符串 */
function lerpColor(value: number): string {
const v = Math.min(Math.max(value, 0), 1);
// 找到 value 所在的两个色标区间:
let i = 0;
while (i < HEAT_STOPS.length - 2 && v > HEAT_STOPS[i + 1].at) i++;
const [s0, s1] = [HEAT_STOPS[i], HEAT_STOPS[i + 1]];
// 区间内比例:
const t = (v - s0.at) / (s1.at - s0.at);
// 三个通道各自插值:
const rgb = s0.rgb.map((c, k) => Math.round(c + (s1.rgb[k] - c) * t));
return `rgb(${rgb.join(",")})`;
}
// 色带渲染(图例):一行代码画出渐变色条:
const bandGrad = ctx.createLinearGradient(0, 0, 200, 0);
for (const s of HEAT_STOPS) {
bandGrad.addColorStop(s.at, `rgb(${s.rgb.join(",")})`); // 色标直接喂给渐变!
}
ctx.fillStyle = bandGrad;
ctx.fillRect(0, 0, 200, 12);
// ⭐ 顿悟时刻:Canvas 渐变的 addColorStop 本身就是"多色标插值器"
// —— 你手写 lerpColor 是为了理解原理,实际渲染色带直接用渐变
七、实战:金属质感仪表盘
综合今天全部知识——带金属球心、渐变弧、色带的仪表盘:
// day31-gauge.ts
import { bootCanvas } from "./canvas-boot.js";
const { ctx, width, height } = bootCanvas(document.querySelector("#board")!);
const cx = width / 2, cy = height / 2 + 20;
const R = 130;
// ===== 1. 外框:径向渐变的金属环 =====
const ringGrad = ctx.createRadialGradient(cx - 30, cy - 30, 20, cx, cy, R + 20);
ringGrad.addColorStop(0, "#4a5a7a"); // 左上高光
ringGrad.addColorStop(0.6, "#1a2740"); // 中部过渡
ringGrad.addColorStop(1, "#0a1628"); // 右下暗部
ctx.fillStyle = ringGrad;
ctx.beginPath();
ctx.arc(cx, cy, R + 20, 0, Math.PI * 2);
ctx.fill();
// ===== 2. 表盘底:深色径向渐变(凹陷感) =====
const dialGrad = ctx.createRadialGradient(cx, cy, 10, cx, cy, R);
dialGrad.addColorStop(0, "#16223a");
dialGrad.addColorStop(1, "#050a14");
ctx.fillStyle = dialGrad;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fill();
// ===== 3. 数值弧:冷→热线性渐变(135° 到 405° 的 270° 弧) =====
// 弧的起终点坐标(弧的两端在圆上的位置):
const rad = (d: number) => (d * Math.PI) / 180;
const arcStart = { x: cx + R * Math.cos(rad(135)), y: cy + R * Math.sin(rad(135)) };
const arcEnd = { x: cx + R * Math.cos(rad(405)), y: cy + R * Math.sin(rad(405)) };
const arcGrad = ctx.createLinearGradient(arcStart.x, arcStart.y, arcEnd.x, arcEnd.y);
arcGrad.addColorStop(0, "#00c6ff"); // 低温端:蓝
arcGrad.addColorStop(0.5, "#00ff88"); // 中值:绿
arcGrad.addColorStop(1, "#ff4d4f"); // 高温端:红
ctx.strokeStyle = arcGrad;
ctx.lineWidth = 14;
ctx.lineCap = "round";
ctx.beginPath();
ctx.arc(cx, cy, R - 15, rad(135), rad(405));
ctx.stroke();
// ===== 4. 数值指针:白色小圆点(动画版留给 Day 34) =====
const value = 0.72; // 0-1
const needleAngle = rad(135 + 270 * value);
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(cx + (R - 15) * Math.cos(needleAngle), cy + (R - 15) * Math.sin(needleAngle), 6, 0, Math.PI * 2);
ctx.fill();
// ===== 5. 数值文本(正式课在 Day 33,先撑住场面) =====
ctx.fillStyle = "#e6f1ff";
ctx.font = "bold 32px sans-serif";
ctx.textAlign = "center";
ctx.fillText(`${Math.round(value * 100)}℃`, cx, cy + 10);
// ===== 6. 底部色带图例(第六节的色条) =====
const bandGrad = ctx.createLinearGradient(width/2 - 100, 0, width/2 + 100, 0);
bandGrad.addColorStop(0, "#00c6ff");
bandGrad.addColorStop(0.5, "#00ff88");
bandGrad.addColorStop(1, "#ff4d4f");
ctx.fillStyle = bandGrad;
ctx.fillRect(width/2 - 100, height - 40, 200, 10);
今日实操:
1. 落地 day31-gauge/,跑出仪表盘
2. 渐变坐标实验:把 arcGrad 换成画布全局坐标 → 看数值弧的配色怎么"错位"了
3. 内圆偏移实验:改外框 ringGrad 的内圆位置 → 观察光照方向变化
4. 手写 lerpColor(6.2 节)并 console.table 验证插值中间值
5. 博客:《渐变不在形状里,在画布上 —— 我踩过最深的理解坑》
八、类比记忆:喷枪与贴膜
fillStyle 三形态 = 三种上色工具
- 纯色 = 油漆滚筒:一种颜色刷到底
- 线性渐变 = 直线移动的喷枪:喷枪走过的"两点一线"决定颜色方向
(喷枪的路径登记在【车间】(画布)墙上,不是【工件】(形状)上
—— 所以两个工件各自只能截到"线"的一段)
- 径向渐变 = 球形喷雾罐:从内圆喷到外圆,罐口偏移 = 光源偏移
- Pattern = 壁纸:一张单元图反复贴(repeat-x/y 控制贴的方向)
色带插值 = 调漆公式:
在两个标准漆之间按比例勾兑 —— lerpColor 是手工勾兑,
Canvas 渐变是自动勾兑机(addColorStop 就是配方单)
九、常见坑点与最佳实践
坑点 1:渐变忘了 addColorStop
const grad = ctx.createLinearGradient(0, 0, 100, 0);
// 没有 addColorStop 直接用:
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 100, 50); // ⚠ 行为:整个渐变是"透明黑"(InvisibleBlack)
// 症状:怎么填都是"没填上"—— 检查是否漏了色标
坑点 2:渐变坐标用了形状局部坐标导致"看不到过渡"
// 想给矩形做渐变,坐标却写成画布左上角:
const g = ctx.createLinearGradient(0, 0, 100, 0);
ctx.fillStyle = g;
ctx.fillRect(500, 500, 200, 100);
// 渐变线在 0→100,矩形在 500+ —— 矩形全在渐变"终点之外"
// → 整块显示最后一个色标的纯色(看不出是渐变)
// ✅ 要么坐标对准矩形,要么全局渐变就接受"局部截取"
坑点 3:pattern 在图片加载完成前创建
// ❌ Image 是异步的:
const img = new Image();
img.src = "./grid.png";
const pattern = ctx.createPattern(img, "repeat"); // img 还没加载 → pattern 无效
ctx.fillRect(0, 0, 100, 100); // 画不出东西
// ✅ 在 onload 里创建:
img.onload = () => { /* 创建 pattern 并绘制 */ };
坑点 4:addColorStop 的 offset 越界或乱序
grad.addColorStop(0.5, "#fff");
grad.addColorStop(0.2, "#000"); // ⚠ 不报错,但顺序乱可能导致未定义行为
grad.addColorStop(1.5, "#f00"); // ❌ 报错:offset 必须在 0-1
// 规范:按 0→1 升序添加
十、自测挑战
挑战 1(基础):三种渐变方向
画三个 200×80 的矩形并排:
a. 左红右蓝水平渐变
b. 上亮下暗垂直渐变(模拟"灯光从上方打")
c. 中心白边缘黑的径向渐变(球体感)
挑战 2(进阶):温度条的冷暖渐变
// 复用 Day 29 的温度条,把纯色填充升级为:
// 温度 < 50:蓝→青 渐变;50-80:黄;>80:红→深红
// 提示:根据 value 分段创建不同的渐变对象
挑战 3(进阶):手写色带图例组件
// 实现 drawLegend(ctx, x, y, w, h, stops: Stop[], minLabel, maxLabel)
// 渐变条 + 两端数值标签 —— 第 3 个月大屏的通用组件,今天就预研
挑战 4(论文级):讲清"渐变在画布上"
向橡皮鸭解释:
1. 同一个渐变对象 fill 两个不同位置的矩形,各显示什么?为什么?
2. 径向渐变的内外圆偏移为什么能模拟光照?
3. lerpColor 与 addColorStop 的关系是什么?(手工 vs 机器)
十一、总结与知识图谱
渐变与填充(Day 31)
│
├── fillStyle 三形态
│ ├── 颜色字符串 —— #hex / rgba / hsl / 名称
│ ├── 渐变对象 —— 先 create 再 addColorStop 再赋值
│ └── Pattern —— 图案平铺(repeat/x/y/no)
│
├── 线性渐变
│ ├── createLinearGradient 两点一线
│ ├── ⭐ 坐标在画布上不在形状里
│ └── 三方向:水平/垂直/对角
│
├── 径向渐变
│ ├── createRadialGradient 内外两圆
│ ├── 内圆偏移 = 光源方向(立体感)
│ └── 应用:金属质感/球体/光晕
│
├── 色带插值
│ ├── hsl 色相扫描(简单热力)
│ ├── 多色标 lerp(专业热力)
│ └── addColorStop 就是插值器
│
└── 实用工具
├── tempColor —— 温度→冷暖色
└── lerpColor —— 多色标插值
一句话总结:fillStyle 是"颜料配方"而非颜色值——渐变坐标定义在画布上(形状只是截取窗口)这个认知分水岭,加上"径向渐变内圆偏移=光源"和"addColorStop 即插值器"两把钥匙,仪表盘质感和热力图色带就从玄学变成了排列组合。
明日预告:Day 32 阴影与合成——shadow 四件套做发光效果,globalCompositeOperation 的 12 种模式是 Canvas 的"图层混合"体系(橡皮擦的原理今天预习)。