【Canvas 2D】day31-gradients

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

Canvas 渐变与填充 — fillStyle 的三种形态,画出"质感"

纯色填充的界面像简笔画,渐变填充的界面像产品。今天把 fillStyle 的三种形态一次学全:颜色(四种格式的选型)、渐变(线性的"两点一线"、径向的"同心圆")、图案(Pattern 平铺)。工业可视化的质感密码全在这天:仪表盘的金属光泽(径向渐变)、温度条的冷热过渡(线性渐变)、热力图的色带插值(渐变坐标的数学)。产出:金属质感仪表盘 + 数据热力色带。


目录


一、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 的"图层混合"体系(橡皮擦的原理今天预习)。

评论