Skip to content

第2章:基础图形绑制

2.1 章节概述

在上一章中,我们学习了如何创建和配置 Canvas。现在,终于到了最激动人心的部分——开始绘图!

绘制图形是 Canvas 最核心的能力。无论你是要开发游戏、数据可视化应用,还是图像编辑器,都需要从最基础的图形绘制开始。

本章将循序渐进地讲解:

  • 矩形绘制:Canvas 中唯一不需要路径的图形
  • 圆与圆弧:理解弧度、方向,绘制圆形和扇形
  • 直线与折线:从点到线的绘制方法
  • 多边形:三角形、正多边形、星形等复杂形状
  • 填充与描边:理解两种绘制模式的区别

学完本章后,你将能够使用 Canvas 绘制各种基础几何图形,为后续学习路径和曲线打下基础。


2.2 Canvas 绘图的基本模式

在深入具体图形之前,让我们先理解 Canvas 绘图的基本模式。Canvas 有两种绘图方式:

2.2.1 立即绘制模式

某些图形可以直接绘制,不需要先构建路径:

javascript
// 矩形是唯一可以立即绘制的图形
ctx.fillRect(x, y, width, height);    // 填充矩形
ctx.strokeRect(x, y, width, height);  // 描边矩形
ctx.clearRect(x, y, width, height);   // 清除矩形区域

这三个方法非常方便,调用即绘制,不需要其他步骤。

2.2.2 路径绘制模式

除了矩形之外,所有其他图形都需要通过"路径"来绘制。路径绘制的基本流程是:

javascript
// 步骤 1:开始一个新路径
ctx.beginPath();

// 步骤 2:构建路径(可以是多个命令的组合)
ctx.moveTo(50, 50);      // 移动画笔
ctx.lineTo(150, 50);     // 画直线
ctx.lineTo(100, 150);    // 画直线
ctx.closePath();         // 可选:闭合路径

// 步骤 3:绘制路径(填充或描边)
ctx.fill();   // 填充
// 或
ctx.stroke(); // 描边

理解这个基本流程非常重要。让我们用一个生动的比喻来解释:

想象你在用铅笔画画

  1. beginPath():拿起一支新铅笔,准备开始画
  2. moveTo(x, y):把铅笔移到某个位置,但不留下痕迹(铅笔抬起)
  3. lineTo(x, y):把铅笔按在纸上,画一条线到新位置
  4. closePath():自动画一条线回到起点,形成闭合图形
  5. fill()stroke():用颜料填充图形,或用墨水描绘轮廓

为什么需要 beginPath()?

每次开始新图形时都应该调用 beginPath()。如果不调用,新的路径会和之前的路径叠加在一起:

javascript
// 问题代码:两个圆会"连在一起"
ctx.arc(100, 100, 50, 0, Math.PI * 2);  // 第一个圆
ctx.arc(250, 100, 50, 0, Math.PI * 2);  // 第二个圆
ctx.stroke();  // 两个圆之间会有一条连线!

// 正确代码:每个圆都是独立的路径
ctx.beginPath();
ctx.arc(100, 100, 50, 0, Math.PI * 2);
ctx.stroke();

ctx.beginPath();
ctx.arc(250, 100, 50, 0, Math.PI * 2);
ctx.stroke();

2.3 矩形绘制

矩形是最简单的图形,Canvas 为它提供了三个专用方法。

2.3.1 fillRect - 填充矩形

fillRect(x, y, width, height) 绘制一个填充的实心矩形。

参数详解

参数含义说明
x左上角 X 坐标矩形起始点的水平位置
y左上角 Y 坐标矩形起始点的垂直位置
width宽度正值向右延伸,负值向左延伸
height高度正值向下延伸,负值向上延伸

基础用法

javascript
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

// 设置填充颜色
ctx.fillStyle = '#4D7CFF';

// 绘制填充矩形
// 从 (50, 50) 开始,宽 200,高 100
ctx.fillRect(50, 50, 200, 100);

理解矩形的位置

Canvas 坐标系:

(0,0) ────────────────────────────────→ X

  │    (50,50)
  │       ┌────────────────────┐
  │       │                    │
  │       │   200 x 100        │ height=100
  │       │                    │
  │       └────────────────────┘
  │              width=200


  Y

使用负值尺寸

有时候你可能需要从某个点"向左上方"绘制矩形。这时可以使用负值:

javascript
// 常规方向:从 (200, 200) 向右下方绘制
ctx.fillStyle = '#4D7CFF';
ctx.fillRect(200, 200, 100, 80);

// 向左绘制:width 为负值
ctx.fillStyle = '#FF6B6B';
ctx.fillRect(200, 200, -100, 80);  // 从 x=200 向左延伸到 x=100

// 向上绘制:height 为负值
ctx.fillStyle = '#4ECDC4';
ctx.fillRect(200, 200, 100, -80);  // 从 y=200 向上延伸到 y=120

// 向左上方绘制:两者都为负值
ctx.fillStyle = '#FFD93D';
ctx.fillRect(200, 200, -100, -80); // 向左上方绘制

图示

          负height (-80)


   ┌───────────┼───────────┐
   │  (-100,-80)  │  (100,-80) │
   │    黄色    │    青色    │
   │           │           │
负width ───────(200,200)───────→ 正width (+100)
   │           │           │
   │  (-100,80) │  (100,80)  │
   │    红色    │    蓝色    │
   └───────────┼───────────┘


          正height (+80)

2.3.2 strokeRect - 描边矩形

strokeRect(x, y, width, height) 绘制一个只有边框的空心矩形。

javascript
// 设置描边颜色和线宽
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;

// 绘制描边矩形
ctx.strokeRect(50, 50, 200, 100);

理解线宽对边界的影响

这是一个容易被忽视但很重要的概念:描边是以路径为中心,向两边扩展的

假设你绘制一个从 (50, 50) 开始的矩形,线宽为 10px:

javascript
ctx.lineWidth = 10;
ctx.strokeRect(50, 50, 100, 100);

实际绘制结果是:

线宽 10px,向两边各扩展 5px:

        45   50              150  155
         │   │                │   │
     45 ─┼───┼────────────────┼───┼─
         │███│                │███│  ← 5px
     50 ─┼───┼────────────────┼───┼─
         │   │                │   │
         │   │                │   │
         │   │    内部区域     │   │
         │   │                │   │
         │   │                │   │
    150 ─┼───┼────────────────┼───┼─
         │███│                │███│  ← 5px
    155 ─┼───┼────────────────┼───┼─
             ↑                ↑
            5px              5px

这意味着:

  1. 矩形实际占用的区域比你指定的要大(每边多出 lineWidth / 2
  2. 如果你在 (0, 0) 绘制,部分描边会超出 Canvas 边界,被裁掉
  3. 奇数线宽会导致子像素渲染,边缘模糊

避免描边被裁切

javascript
// 问题:描边被裁切
ctx.lineWidth = 10;
ctx.strokeRect(0, 0, 100, 100);  // 左边和上边各有 5px 被裁掉

// 解决:预留空间
const lineWidth = 10;
const margin = lineWidth / 2;
ctx.lineWidth = lineWidth;
ctx.strokeRect(margin, margin, 100, 100);  // 完整显示

2.3.3 clearRect - 清除矩形区域

clearRect(x, y, width, height) 清除指定矩形区域内的所有像素,使其变为完全透明。

javascript
// 先绘制一个蓝色矩形
ctx.fillStyle = '#4D7CFF';
ctx.fillRect(50, 50, 200, 150);

// 然后在中间清除一块区域
ctx.clearRect(100, 80, 100, 90);

// 结果:蓝色矩形中间出现一个透明的"洞"

clearRect 的典型用途

用途1:清空整个 Canvas

javascript
// 清空整个画布
ctx.clearRect(0, 0, canvas.width, canvas.height);

这是最常见的用法,在动画的每一帧开始时,先清空上一帧的内容。

用途2:动画循环

javascript
function animate() {
    // 1. 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    
    // 2. 更新状态
    ball.x += ball.vx;
    ball.y += ball.vy;
    
    // 3. 绘制新一帧
    ctx.beginPath();
    ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
    ctx.fill();
    
    // 4. 循环
    requestAnimationFrame(animate);
}

animate();

用途3:实现橡皮擦

javascript
canvas.addEventListener('mousemove', (e) => {
    if (isErasing) {
        const x = e.offsetX;
        const y = e.offsetY;
        const eraserSize = 20;
        
        // 以鼠标位置为中心清除一个小区域
        ctx.clearRect(
            x - eraserSize / 2,
            y - eraserSize / 2,
            eraserSize,
            eraserSize
        );
    }
});

注意:clearRect 清除的是像素,不是"图形"。Canvas 不记得你画了什么,只知道像素值。

2.3.4 绘制圆角矩形

Canvas 原生不支持圆角矩形,但这是一个非常常用的图形。我们可以通过路径来实现。

理解圆角矩形的结构

圆角矩形由 4 条直线 + 4 段圆弧组成:

        r  ╭──────────────────────╮  r
           │                      │
           │                      │
           │                      │
           │                      │
        r  ╰──────────────────────╯  r

每个角都是一个四分之一圆(90度圆弧)
r = 圆角半径

实现方法1:使用 arcTo

arcTo 方法可以绘制连接两条切线的圆弧,非常适合绘制圆角:

javascript
/**
 * 绘制圆角矩形路径
 * @param {CanvasRenderingContext2D} ctx - 绑制上下文
 * @param {number} x - 左上角 x 坐标
 * @param {number} y - 左上角 y 坐标
 * @param {number} width - 宽度
 * @param {number} height - 高度
 * @param {number} radius - 圆角半径
 */
function roundRect(ctx, x, y, width, height, radius) {
    // 限制圆角半径不超过矩形的一半
    radius = Math.min(radius, width / 2, height / 2);
    
    ctx.beginPath();
    
    // 从左上角圆弧的结束点开始(顺时针绘制)
    ctx.moveTo(x + radius, y);
    
    // 上边 → 右上角圆弧
    ctx.lineTo(x + width - radius, y);
    ctx.arcTo(x + width, y, x + width, y + radius, radius);
    
    // 右边 → 右下角圆弧
    ctx.lineTo(x + width, y + height - radius);
    ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
    
    // 下边 → 左下角圆弧
    ctx.lineTo(x + radius, y + height);
    ctx.arcTo(x, y + height, x, y + height - radius, radius);
    
    // 左边 → 左上角圆弧
    ctx.lineTo(x, y + radius);
    ctx.arcTo(x, y, x + radius, y, radius);
    
    ctx.closePath();
}

// 使用示例
roundRect(ctx, 50, 50, 200, 100, 20);
ctx.fillStyle = '#4D7CFF';
ctx.fill();

roundRect(ctx, 50, 200, 200, 100, 20);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
ctx.stroke();

理解 arcTo 的工作原理

arcTo(x1, y1, x2, y2, radius) 的工作方式:

当前点 ──────────→ 控制点(x1,y1)



                终点方向(x2,y2)

arcTo 会:
1. 计算从"当前点→控制点"的直线
2. 计算从"控制点→终点方向"的直线
3. 绘制连接这两条线的圆弧(半径为 radius)
4. 自动绘制从"当前点"到"圆弧起点"的直线

实现方法2:不同圆角半径

有时候我们需要四个角有不同的圆角半径:

javascript
/**
 * 绘制可变圆角矩形
 * @param {number[]} radius - [左上, 右上, 右下, 左下]
 */
function roundRectVariable(ctx, x, y, width, height, radius) {
    // 支持单个值或数组
    if (typeof radius === 'number') {
        radius = [radius, radius, radius, radius];
    }
    
    const [tl, tr, br, bl] = radius;
    
    ctx.beginPath();
    ctx.moveTo(x + tl, y);
    ctx.lineTo(x + width - tr, y);
    ctx.arcTo(x + width, y, x + width, y + tr, tr);
    ctx.lineTo(x + width, y + height - br);
    ctx.arcTo(x + width, y + height, x + width - br, y + height, br);
    ctx.lineTo(x + bl, y + height);
    ctx.arcTo(x, y + height, x, y + height - bl, bl);
    ctx.lineTo(x, y + tl);
    ctx.arcTo(x, y, x + tl, y, tl);
    ctx.closePath();
}

// 使用示例:不同的圆角
roundRectVariable(ctx, 50, 50, 200, 100, [0, 20, 0, 20]);
ctx.fillStyle = '#4ECDC4';
ctx.fill();

roundRectVariable(ctx, 50, 200, 200, 100, [30, 0, 30, 0]);
ctx.fillStyle = '#FFD93D';
ctx.fill();

现代浏览器的原生支持

从 2021 年开始,现代浏览器开始支持原生的 roundRect 方法:

javascript
// Chrome 99+, Firefox 112+, Safari 16+
ctx.beginPath();
ctx.roundRect(50, 50, 200, 100, 20);  // 统一圆角
ctx.fill();

ctx.beginPath();
ctx.roundRect(50, 200, 200, 100, [10, 20, 30, 40]);  // 不同圆角
ctx.stroke();

在使用前最好检测支持:

javascript
if (typeof ctx.roundRect === 'function') {
    // 使用原生方法
    ctx.roundRect(x, y, width, height, radius);
} else {
    // 使用自定义实现
    roundRect(ctx, x, y, width, height, radius);
}

2.4 圆与圆弧

圆和圆弧是通过 arc 方法绘制的。这是 Canvas 中最重要的方法之一,我们需要深入理解它。

2.4.1 arc 方法详解

方法签名

javascript
ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise)

参数详解

参数含义说明
x圆心 X 坐标圆的中心点水平位置
y圆心 Y 坐标圆的中心点垂直位置
radius半径圆的半径,必须为正数
startAngle起始角度弧度,不是角度!
endAngle结束角度弧度,不是角度!
counterclockwise是否逆时针可选,默认 false(顺时针)

2.4.2 理解弧度

这是学习 arc 方法时最容易困惑的地方。Canvas 使用弧度(Radian)而不是角度(Degree)

弧度与角度的关系

一个完整的圆 = 360° = 2π 弧度

所以:
  1° = π/180 弧度
  1 弧度 = 180/π 度 ≈ 57.3°

常用角度对应的弧度

角度弧度JavaScript 表示
00
30°π/6Math.PI / 6
45°π/4Math.PI / 4
60°π/3Math.PI / 3
90°π/2Math.PI / 2
180°πMath.PI
270°3π/2Math.PI * 1.5
360°Math.PI * 2

角度与弧度的转换函数

javascript
/**
 * 角度转弧度
 * @param {number} degrees - 角度值
 * @returns {number} 弧度值
 */
function degToRad(degrees) {
    return degrees * (Math.PI / 180);
}

/**
 * 弧度转角度
 * @param {number} radians - 弧度值
 * @returns {number} 角度值
 */
function radToDeg(radians) {
    return radians * (180 / Math.PI);
}

// 使用示例
console.log(degToRad(90));   // 1.5707963... (即 π/2)
console.log(radToDeg(Math.PI)); // 180

2.4.3 理解 Canvas 的角度方向

Canvas 的角度系统与数学课本上的不太一样:

Canvas 角度方向:

            270° (-π/2 或 3π/2)


    180° (π) ───────●─────── 0° (0 或 2π)  ← 起始点(3点钟方向)


             90° (π/2)

特点:
1. 0° 在右侧(3点钟方向),不是上方
2. 角度顺时针增加(与数学坐标系相反)
3. 默认绘制方向是顺时针

为什么是这样?

这是因为 Canvas 的 Y 轴向下。在标准数学坐标系中,Y 轴向上,所以逆时针是正方向。但 Canvas 的 Y 轴向下,导致角度的方向"翻转"了。

2.4.4 绘制完整的圆

绘制一个完整的圆非常简单:

javascript
// 绘制一个圆心在 (150, 150),半径 50 的圆
ctx.beginPath();
ctx.arc(150, 150, 50, 0, Math.PI * 2);  // 从 0 到 2π,完整的圆
ctx.fillStyle = '#4D7CFF';
ctx.fill();

让我们分解这个过程:

javascript
ctx.beginPath();  // 开始新路径

ctx.arc(
    150,          // 圆心 x
    150,          // 圆心 y
    50,           // 半径
    0,            // 起始角度:0 弧度(3点钟方向)
    Math.PI * 2   // 结束角度:2π 弧度(转一圈回到起点)
);

ctx.fill();       // 填充圆形

描边圆形

javascript
ctx.beginPath();
ctx.arc(150, 150, 50, 0, Math.PI * 2);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
ctx.stroke();  // 描边而不是填充

2.4.5 绘制圆弧

圆弧就是圆的一部分。通过调整 startAngleendAngle 来控制绘制哪一部分:

绘制上半圆

javascript
// 从 π 到 0(或 2π),即从 9 点钟到 3 点钟,经过 12 点钟
ctx.beginPath();
ctx.arc(150, 100, 50, Math.PI, 0);  // 或 Math.PI * 2
ctx.stroke();

// 图示:
//          ╭───────╮
//          │       │
//     ─────┴───────┴─────

绘制下半圆

javascript
// 从 0 到 π,即从 3 点钟到 9 点钟,经过 6 点钟
ctx.beginPath();
ctx.arc(150, 100, 50, 0, Math.PI);
ctx.stroke();

// 图示:
//     ─────┬───────┬─────
//          │       │
//          ╰───────╯

绘制四分之一圆

javascript
// 从 0 到 π/2,即从 3 点钟到 6 点钟
ctx.beginPath();
ctx.arc(150, 150, 50, 0, Math.PI / 2);
ctx.stroke();

// 图示:
//     ────●
//         │╲
//         │ ╲
//         │  ╲
//         ╰───

2.4.6 顺时针与逆时针

arc 方法的最后一个参数 counterclockwise 控制绘制方向:

javascript
// 顺时针(默认):从 0 到 π/2
ctx.beginPath();
ctx.arc(100, 150, 50, 0, Math.PI / 2, false);
ctx.stroke();
// 结果:绘制右下角的四分之一圆

// 逆时针:从 0 到 π/2
ctx.beginPath();
ctx.arc(250, 150, 50, 0, Math.PI / 2, true);
ctx.stroke();
// 结果:绘制剩下的四分之三圆!

图示对比

顺时针(false)从 0 到 π/2:    逆时针(true)从 0 到 π/2:

            │                          ╭───╮
            │                          │   │
    ────────●                  ────────●   │
            │╲                             │
            │ ╲                   ╰────────╯
            │  ╲
            ╰───╮                 绘制了"其他部分"

理解绘制方向的关键

给定起始角和结束角,Canvas 会沿着指定方向绘制。方向不同,绘制的圆弧部分就不同:

  • 顺时针(counterclockwise = false):从 startAngle 向角度增大的方向绘制到 endAngle
  • 逆时针(counterclockwise = true):从 startAngle 向角度减小的方向绘制到 endAngle

2.4.7 绘制扇形

扇形是"一块披萨"的形状,由两条半径和一段圆弧组成。

javascript
/**
 * 绘制扇形
 * @param {CanvasRenderingContext2D} ctx
 * @param {number} cx - 圆心 x
 * @param {number} cy - 圆心 y
 * @param {number} radius - 半径
 * @param {number} startAngle - 起始角度(弧度)
 * @param {number} endAngle - 结束角度(弧度)
 */
function drawSector(ctx, cx, cy, radius, startAngle, endAngle) {
    ctx.beginPath();
    
    // 1. 移动到圆心
    ctx.moveTo(cx, cy);
    
    // 2. 画线到圆弧起点(arc 会自动处理)
    // 3. 绘制圆弧
    ctx.arc(cx, cy, radius, startAngle, endAngle);
    
    // 4. closePath 会自动画线回到圆心
    ctx.closePath();
}

// 绘制一个四分之一扇形
drawSector(ctx, 200, 200, 100, 0, Math.PI / 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
ctx.strokeStyle = '#2952CC';
ctx.lineWidth = 2;
ctx.stroke();

绘制过程图解

步骤 1: moveTo(cx, cy)
        移动到圆心
        
步骤 2: arc(cx, cy, radius, 0, π/2)
        Canvas 自动从圆心画线到圆弧起点
        然后绘制圆弧
        
                  ╭──────

        ────────●  圆心

步骤 3: closePath()
        自动从圆弧终点画线回到圆心
        
                  ╭──────
                 ╱       │
        ────────●────────╯

应用:绘制饼图

javascript
function drawPieChart(ctx, cx, cy, radius, data) {
    // data 格式: [{ value: 30, color: '#FF6B6B' }, ...]
    
    const total = data.reduce((sum, item) => sum + item.value, 0);
    let currentAngle = -Math.PI / 2;  // 从 12 点钟方向开始
    
    data.forEach((item) => {
        // 计算这个扇形占的角度
        const sliceAngle = (item.value / total) * Math.PI * 2;
        
        // 绘制扇形
        ctx.beginPath();
        ctx.moveTo(cx, cy);
        ctx.arc(cx, cy, radius, currentAngle, currentAngle + sliceAngle);
        ctx.closePath();
        
        ctx.fillStyle = item.color;
        ctx.fill();
        
        // 可选:添加描边
        ctx.strokeStyle = '#fff';
        ctx.lineWidth = 2;
        ctx.stroke();
        
        // 更新角度
        currentAngle += sliceAngle;
    });
}

// 使用示例
const pieData = [
    { value: 35, color: '#FF6B6B' },
    { value: 25, color: '#4ECDC4' },
    { value: 20, color: '#45B7D1' },
    { value: 20, color: '#96CEB4' },
];

drawPieChart(ctx, 200, 200, 150, pieData);

2.4.8 绘制圆环

圆环是"甜甜圈"的形状,有内外两个半径。

javascript
/**
 * 绘制圆环
 * @param {number} outerRadius - 外半径
 * @param {number} innerRadius - 内半径
 */
function drawRing(ctx, cx, cy, outerRadius, innerRadius, startAngle, endAngle) {
    ctx.beginPath();
    
    // 1. 绘制外圆弧(顺时针)
    ctx.arc(cx, cy, outerRadius, startAngle, endAngle, false);
    
    // 2. 绘制内圆弧(逆时针,形成闭合区域)
    ctx.arc(cx, cy, innerRadius, endAngle, startAngle, true);
    
    ctx.closePath();
}

// 完整的圆环
drawRing(ctx, 200, 200, 100, 60, 0, Math.PI * 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();

// 部分圆环(进度条效果)
drawRing(ctx, 200, 200, 100, 60, -Math.PI / 2, Math.PI / 2);
ctx.fillStyle = '#4ECDC4';
ctx.fill();

原理解释

为什么内圆弧要逆时针绘制?这涉及到 Canvas 的填充规则。简单来说:

  1. 外圆弧顺时针绘制,定义了外边界
  2. 内圆弧逆时针绘制,定义了内边界("挖空"的部分)
  3. 两者组合形成一个环形区域
外圆弧(顺时针):定义外边界
        ╭───────────╮
       ╱             ╲
      ╱               ╲
      ╲               ╱
       ╲             ╱
        ╰───────────╯

内圆弧(逆时针):定义内边界
        ╭───────────╮
       ╱  ╭───────╮  ╲
      ╱   ╲       ╱   ╲
      ╲   ╱       ╲   ╱
       ╲  ╰───────╯  ╱
        ╰───────────╯

填充结果:环形区域
        ██████████████
       ██            ██
      ██              ██
      ██              ██
       ██            ██
        ██████████████

2.4.9 arcTo 方法

除了 arc,还有一个 arcTo 方法,用于绘制连接两条切线的圆弧。

javascript
ctx.arcTo(x1, y1, x2, y2, radius);

工作原理

arcTo 需要三个点:
1. 当前点(画笔位置)
2. 控制点 (x1, y1)
3. 终点方向 (x2, y2)

Canvas 会:
1. 从当前点画一条直线,与控制点相切
2. 从控制点画一条直线,指向 (x2, y2)
3. 在两条直线之间绘制指定半径的圆弧

当前点 ─────────────→ 控制点 (x1, y1)



                    终点方向 (x2, y2)

实际使用示例

javascript
ctx.beginPath();
ctx.moveTo(50, 100);          // 起点

ctx.arcTo(
    150, 100,                  // 控制点
    150, 200,                  // 终点方向
    30                         // 圆弧半径
);

ctx.lineTo(150, 200);         // 连接到终点
ctx.stroke();

// 结果:一个带圆角的直角转弯
//
// (50,100) ─────────────┐
//                       │  ← 30px 半径的圆角
//                       │
//                       ↓
//                   (150,200)

arcTo 最常用于绘制圆角折线或圆角矩形(我们在上一节已经展示过)。


2.5 直线与折线

2.5.1 moveTo 和 lineTo

这两个方法是绘制直线的基础:

  • moveTo(x, y):移动画笔到指定位置,不绘制任何内容
  • lineTo(x, y):从当前位置绘制直线到指定位置
javascript
// 绘制一条直线
ctx.beginPath();
ctx.moveTo(50, 50);    // 移动到起点
ctx.lineTo(200, 100);  // 画线到终点
ctx.stroke();          // 执行描边

理解 moveTo 的作用

moveTo 就像是把笔抬起来,移动到新位置,然后放下。它不会留下任何痕迹,但会更新"当前点"的位置。

javascript
// 不使用 moveTo:从上次的终点继续
ctx.beginPath();
ctx.lineTo(100, 50);   // 从 (0, 0) 画到 (100, 50)
ctx.lineTo(200, 50);   // 继续画到 (200, 50)
ctx.stroke();
// 结果:一条从左上角开始的折线

// 使用 moveTo:指定起点
ctx.beginPath();
ctx.moveTo(50, 100);   // 先移动到 (50, 100)
ctx.lineTo(150, 100);  // 从 (50, 100) 画到 (150, 100)
ctx.stroke();
// 结果:一条水平线

2.5.2 绘制折线

折线就是多个连续的线段:

javascript
// 方法 1:手动连续绘制
ctx.beginPath();
ctx.moveTo(50, 150);
ctx.lineTo(100, 50);
ctx.lineTo(150, 150);
ctx.lineTo(200, 50);
ctx.lineTo(250, 150);
ctx.stroke();

// 方法 2:使用数组和循环
function drawPolyline(ctx, points) {
    if (points.length < 2) return;
    
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    
    for (let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i].x, points[i].y);
    }
    
    ctx.stroke();
}

// 使用
const zigzag = [
    { x: 50, y: 150 },
    { x: 100, y: 50 },
    { x: 150, y: 150 },
    { x: 200, y: 50 },
    { x: 250, y: 150 },
];
drawPolyline(ctx, zigzag);

2.5.3 绘制正弦波

一个更复杂的折线示例:

javascript
function drawSineWave(ctx, startX, startY, width, amplitude, frequency) {
    ctx.beginPath();
    ctx.moveTo(startX, startY);
    
    // 每隔 2px 计算一个点
    for (let x = 0; x <= width; x += 2) {
        const y = startY + Math.sin(x * frequency) * amplitude;
        ctx.lineTo(startX + x, y);
    }
    
    ctx.stroke();
}

// 绘制正弦波
ctx.strokeStyle = '#4D7CFF';
ctx.lineWidth = 2;
drawSineWave(ctx, 50, 200, 500, 50, 0.02);

2.5.4 绘制网格

网格是由多条平行直线组成的:

javascript
/**
 * 绘制网格
 * @param {number} gridSize - 网格间距
 */
function drawGrid(ctx, width, height, gridSize, color = '#e0e0e0') {
    ctx.save();
    ctx.strokeStyle = color;
    ctx.lineWidth = 0.5;
    
    ctx.beginPath();
    
    // 绘制垂直线
    for (let x = 0; x <= width; x += gridSize) {
        ctx.moveTo(x + 0.5, 0);      // +0.5 使 1px 线条清晰
        ctx.lineTo(x + 0.5, height);
    }
    
    // 绘制水平线
    for (let y = 0; y <= height; y += gridSize) {
        ctx.moveTo(0, y + 0.5);
        ctx.lineTo(width, y + 0.5);
    }
    
    ctx.stroke();
    ctx.restore();
}

// 使用
drawGrid(ctx, canvas.width, canvas.height, 20);

注意 0.5px 偏移:还记得第一章讲的 1px 线条清晰度问题吗?这里我们使用了 + 0.5 来确保网格线清晰。

2.5.5 1px 线条清晰度问题详解

这是一个非常重要的问题,值得再次深入讨论。

问题根源

Canvas 的坐标指的是像素的边界,而不是像素的中心。当你在整数坐标上绘制 1px 线条时,线条会跨越两行/列像素:

整数坐标 y=50 画 1px 线:

  │   y=49   │   y=50   │   y=51   │
  │          │          │          │
  ├──────────┼──────────┼──────────┤
  │          │░░░░░░░░░░│          │
  │          │ 线条中心 │          │
  │          │░░░░░░░░░░│          │
  ├──────────┼──────────┼──────────┤

线条中心在 y=50 像素边界上
向上扩展 0.5px 进入 y=49 像素
向下扩展 0.5px 进入 y=50 像素
两个像素各填充 50%,颜色变淡

解决方案

偏移 0.5px,y=50.5 画 1px 线:

  │   y=49   │   y=50   │   y=51   │
  │          │          │          │
  ├──────────┼──────────┼──────────┤
  │          │██████████│          │
  │          │ 线条中心 │          │
  │          │██████████│          │
  ├──────────┼──────────┼──────────┤

线条中心在 y=50 像素中心
完整填充 y=50 这一行像素
颜色 100%,线条清晰

封装函数

javascript
/**
 * 绘制清晰的直线
 */
function crispLine(ctx, x1, y1, x2, y2) {
    // 奇数线宽需要偏移 0.5
    const offset = ctx.lineWidth % 2 === 1 ? 0.5 : 0;
    
    ctx.beginPath();
    ctx.moveTo(
        Math.round(x1) + offset,
        Math.round(y1) + offset
    );
    ctx.lineTo(
        Math.round(x2) + offset,
        Math.round(y2) + offset
    );
    ctx.stroke();
}

// 清晰的 1px 线
ctx.lineWidth = 1;
crispLine(ctx, 50, 50, 200, 50);

// 清晰的 3px 线
ctx.lineWidth = 3;
crispLine(ctx, 50, 100, 200, 100);

// 2px 线不需要偏移
ctx.lineWidth = 2;
crispLine(ctx, 50, 150, 200, 150);  // 自动不偏移

2.6 多边形绘制

Canvas 没有直接绘制多边形的方法,我们需要使用路径来构建。

2.6.1 三角形

三角形是最简单的多边形,由三个顶点组成:

javascript
/**
 * 绘制三角形
 */
function drawTriangle(ctx, x1, y1, x2, y2, x3, y3) {
    ctx.beginPath();
    ctx.moveTo(x1, y1);  // 第一个顶点
    ctx.lineTo(x2, y2);  // 第二个顶点
    ctx.lineTo(x3, y3);  // 第三个顶点
    ctx.closePath();     // 自动连接回第一个顶点
}

// 普通三角形
drawTriangle(ctx, 100, 50, 50, 150, 150, 150);
ctx.fillStyle = '#4D7CFF';
ctx.fill();

// 描边三角形
drawTriangle(ctx, 250, 50, 200, 150, 300, 150);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 3;
ctx.stroke();

closePath() 的作用

closePath() 会自动绘制一条从当前点回到路径起点的直线。对于多边形来说,这确保了图形是闭合的:

javascript
// 不使用 closePath:图形不闭合
ctx.beginPath();
ctx.moveTo(100, 50);
ctx.lineTo(50, 150);
ctx.lineTo(150, 150);
// 没有 closePath
ctx.stroke();
// 结果:一个开口的 V 形

// 使用 closePath:图形闭合
ctx.beginPath();
ctx.moveTo(100, 50);
ctx.lineTo(50, 150);
ctx.lineTo(150, 150);
ctx.closePath();  // 自动连接回 (100, 50)
ctx.stroke();
// 结果:完整的三角形

2.6.2 等边三角形

绘制等边三角形需要一点数学计算:

javascript
/**
 * 绘制等边三角形
 * @param {number} cx, cy - 中心点坐标
 * @param {number} size - 边长
 */
function drawEquilateralTriangle(ctx, cx, cy, size) {
    // 等边三角形的高度 = 边长 × √3 / 2
    const height = size * Math.sqrt(3) / 2;
    
    // 计算三个顶点
    // 顶点在上方,距离中心 2/3 的高度
    const topY = cy - height * 2 / 3;
    
    // 底边两个顶点在下方,距离中心 1/3 的高度
    const bottomY = cy + height / 3;
    const leftX = cx - size / 2;
    const rightX = cx + size / 2;
    
    ctx.beginPath();
    ctx.moveTo(cx, topY);           // 顶点
    ctx.lineTo(rightX, bottomY);    // 右下
    ctx.lineTo(leftX, bottomY);     // 左下
    ctx.closePath();
}

// 使用
drawEquilateralTriangle(ctx, 150, 120, 100);
ctx.fillStyle = '#4ECDC4';
ctx.fill();

几何原理图解

等边三角形的几何关系:

        顶点

        /│\
       / │ \      高度 h = 边长 s × √3/2
      /  │  \
     /   │   \    中心到顶点 = h × 2/3
    /    │    \   中心到底边 = h × 1/3
   /     ●     \
  /    中心     \
 /               \
●─────────────────●
左下            右下
      边长 s

2.6.3 正多边形

正多边形是所有边长相等、所有角度相等的多边形。我们可以用通用公式来绘制任意正多边形:

javascript
/**
 * 绘制正多边形
 * @param {number} cx, cy - 中心点坐标
 * @param {number} radius - 外接圆半径
 * @param {number} sides - 边数(至少 3)
 * @param {number} rotation - 旋转角度(弧度)
 */
function drawRegularPolygon(ctx, cx, cy, radius, sides, rotation = -Math.PI / 2) {
    if (sides < 3) {
        console.error('正多边形至少需要 3 条边');
        return;
    }
    
    ctx.beginPath();
    
    for (let i = 0; i < sides; i++) {
        // 计算每个顶点的角度
        // 默认 rotation = -π/2 使第一个顶点在正上方
        const angle = rotation + (i * 2 * Math.PI / sides);
        
        // 计算顶点坐标
        const x = cx + radius * Math.cos(angle);
        const y = cy + radius * Math.sin(angle);
        
        if (i === 0) {
            ctx.moveTo(x, y);
        } else {
            ctx.lineTo(x, y);
        }
    }
    
    ctx.closePath();
}

// 绘制各种正多边形
const polygons = [
    { sides: 3, name: '三角形' },
    { sides: 4, name: '正方形' },
    { sides: 5, name: '五边形' },
    { sides: 6, name: '六边形' },
    { sides: 8, name: '八边形' },
    { sides: 12, name: '十二边形' },
];

polygons.forEach((polygon, index) => {
    const cx = 100 + (index % 3) * 150;
    const cy = 100 + Math.floor(index / 3) * 150;
    
    drawRegularPolygon(ctx, cx, cy, 50, polygon.sides);
    ctx.fillStyle = `hsl(${index * 50}, 70%, 60%)`;
    ctx.fill();
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 2;
    ctx.stroke();
});

理解正多边形的数学原理

正多边形的顶点均匀分布在一个圆上(外接圆)

以正五边形为例:
每个顶点之间的角度 = 360° / 5 = 72°

           顶点0 (0°)

             /│\
            / │ \
   顶点4   /  │  \   顶点1
     ●────/───●───\────●
      \   中心    /
       \        /
        \      /
         \    /
          \  /
           ●●
        顶点3 顶点2

每个顶点的坐标:
x = cx + radius × cos(角度)
y = cy + radius × sin(角度)

2.6.4 星形

星形是一种更复杂的形状,由外顶点和内顶点交替组成:

javascript
/**
 * 绘制星形
 * @param {number} cx, cy - 中心点
 * @param {number} outerRadius - 外半径(角尖到中心的距离)
 * @param {number} innerRadius - 内半径(凹点到中心的距离)
 * @param {number} points - 角数
 * @param {number} rotation - 旋转角度
 */
function drawStar(ctx, cx, cy, outerRadius, innerRadius, points, rotation = -Math.PI / 2) {
    ctx.beginPath();
    
    // 星形有 points * 2 个顶点(外顶点和内顶点交替)
    const totalVertices = points * 2;
    
    for (let i = 0; i < totalVertices; i++) {
        // 奇数索引是内顶点,偶数索引是外顶点
        const radius = i % 2 === 0 ? outerRadius : innerRadius;
        const angle = rotation + (i * Math.PI / points);
        
        const x = cx + radius * Math.cos(angle);
        const y = cy + radius * Math.sin(angle);
        
        if (i === 0) {
            ctx.moveTo(x, y);
        } else {
            ctx.lineTo(x, y);
        }
    }
    
    ctx.closePath();
}

// 五角星
drawStar(ctx, 100, 100, 80, 35, 5);
ctx.fillStyle = '#FFD700';  // 金色
ctx.fill();
ctx.strokeStyle = '#B8860B';
ctx.lineWidth = 2;
ctx.stroke();

// 六角星(大卫之星)
drawStar(ctx, 250, 100, 80, 45, 6);
ctx.fillStyle = '#4ECDC4';
ctx.fill();

// 八角星
drawStar(ctx, 400, 100, 80, 50, 8);
ctx.fillStyle = '#FF6B6B';
ctx.fill();

星形的几何原理

五角星的顶点分布:

外顶点(outerRadius):● 
内顶点(innerRadius):○

            ●  (0°)
           /│\
          / │ \
         /  │  \
    ○───/───┼───\───○  (36°, 324°)
       /    │    \
      /     │     \
     /      │      \
   ●────────┼────────●  (72°, 288°)


            ○  (180°)

角度计算:
- 外顶点:0°, 72°, 144°, 216°, 288°(每 72° 一个)
- 内顶点:36°, 108°, 180°, 252°, 324°(在外顶点之间)

2.6.5 任意多边形

有时候我们需要绘制不规则的多边形,可以直接指定顶点坐标:

javascript
/**
 * 绘制任意多边形
 * @param {Array<{x: number, y: number}>} points - 顶点数组
 * @param {boolean} closed - 是否闭合
 */
function drawPolygon(ctx, points, closed = true) {
    if (points.length < 2) return;
    
    ctx.beginPath();
    ctx.moveTo(points[0].x, points[0].y);
    
    for (let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i].x, points[i].y);
    }
    
    if (closed) {
        ctx.closePath();
    }
}

// 绘制一个箭头形状
const arrowPoints = [
    { x: 50, y: 100 },
    { x: 150, y: 100 },
    { x: 150, y: 70 },
    { x: 200, y: 120 },
    { x: 150, y: 170 },
    { x: 150, y: 140 },
    { x: 50, y: 140 },
];

drawPolygon(ctx, arrowPoints);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
ctx.strokeStyle = '#2952CC';
ctx.lineWidth = 2;
ctx.stroke();

2.7 填充与描边

到目前为止,我们一直在使用 fill()stroke() 方法。让我们深入理解它们。

2.7.1 fill() - 填充

fill() 方法填充当前路径包围的区域:

javascript
ctx.beginPath();
ctx.arc(150, 150, 100, 0, Math.PI * 2);
ctx.fillStyle = '#4D7CFF';
ctx.fill();

fillStyle 支持的值类型

javascript
// 1. 颜色字符串
ctx.fillStyle = 'red';
ctx.fillStyle = '#FF6B6B';
ctx.fillStyle = 'rgb(255, 107, 107)';
ctx.fillStyle = 'rgba(255, 107, 107, 0.5)';
ctx.fillStyle = 'hsl(0, 100%, 71%)';

// 2. 渐变(后续章节详细讲解)
const gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, 'red');
gradient.addColorStop(1, 'blue');
ctx.fillStyle = gradient;

// 3. 图案(后续章节详细讲解)
const pattern = ctx.createPattern(image, 'repeat');
ctx.fillStyle = pattern;

2.7.2 stroke() - 描边

stroke() 方法描绘当前路径的轮廓:

javascript
ctx.beginPath();
ctx.arc(150, 150, 100, 0, Math.PI * 2);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 5;
ctx.stroke();

相关属性

属性说明默认值
strokeStyle描边颜色'#000000'
lineWidth线条宽度1
lineCap线端样式'butt'
lineJoin连接样式'miter'
miterLimit斜接限制10

2.7.3 lineCap - 线端样式

lineCap 控制线条端点的形状:

javascript
ctx.lineWidth = 20;

// butt:平直端点(默认)
ctx.lineCap = 'butt';
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(200, 50);
ctx.stroke();

// round:圆形端点
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(50, 100);
ctx.lineTo(200, 100);
ctx.stroke();

// square:方形端点(比 butt 长半个线宽)
ctx.lineCap = 'square';
ctx.beginPath();
ctx.moveTo(50, 150);
ctx.lineTo(200, 150);
ctx.stroke();

图示对比

lineWidth = 20

butt(默认):
     端点与路径端点齐平
     ══════════════════
     50                200

round:
     端点是半圆
     ●════════════════●
   40                  210

square:
     端点是半个线宽的方形
     ■════════════════■
   40                  210

2.7.4 lineJoin - 连接样式

lineJoin 控制两条线段连接处的形状:

javascript
ctx.lineWidth = 20;

// miter:尖角连接(默认)
ctx.lineJoin = 'miter';
ctx.beginPath();
ctx.moveTo(50, 50);
ctx.lineTo(100, 100);
ctx.lineTo(150, 50);
ctx.stroke();

// round:圆角连接
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(200, 50);
ctx.lineTo(250, 100);
ctx.lineTo(300, 50);
ctx.stroke();

// bevel:斜角连接
ctx.lineJoin = 'bevel';
ctx.beginPath();
ctx.moveTo(350, 50);
ctx.lineTo(400, 100);
ctx.lineTo(450, 50);
ctx.stroke();

图示对比

lineWidth = 20

miter(尖角):           round(圆角):          bevel(斜角):
      ╱╲                     ╱╲                      ╱╲
     ╱  ╲                   ╱  ╲                    ╱  ╲
    ╱    ╲                 ╱    ╲                  ╱────╲
   ╱      ╲               ╱      ╲                ╱      ╲

2.7.5 填充与描边的顺序

当你需要同时填充和描边时,顺序很重要

javascript
// 方案1:先填充后描边(推荐)
ctx.beginPath();
ctx.rect(50, 50, 100, 100);
ctx.fillStyle = '#4D7CFF';
ctx.fill();
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 10;
ctx.stroke();
// 结果:描边完整可见,覆盖在填充上

// 方案2:先描边后填充
ctx.beginPath();
ctx.rect(200, 50, 100, 100);
ctx.strokeStyle = '#FF6B6B';
ctx.lineWidth = 10;
ctx.stroke();
ctx.fillStyle = '#4D7CFF';
ctx.fill();
// 结果:描边的内半部分被填充覆盖

为什么会这样?

因为描边是以路径为中心向两侧扩展的。描边有一半在路径内部,一半在路径外部。

如果先描边后填充,填充会覆盖描边的内部部分:

先填充后描边:                先描边后填充:

┌──────────────┐             ┌──────────────┐
│██ 描边外半 ██│             │██ 描边外半 ██│
│██┌────────┐██│             │  ┌────────┐  │
│██│ 填 充  │██│             │  │填充覆盖│  │
│██│        │██│             │  │描边内半│  │
│██└────────┘██│             │  └────────┘  │
│██ 描边外半 ██│             │██ 描边外半 ██│
└──────────────┘             └──────────────┘
   描边完整显示                只显示外半边框

2.7.6 填充规则

fill() 方法接受一个可选参数来指定填充规则:

javascript
ctx.fill();           // 默认:'nonzero'
ctx.fill('nonzero');  // 非零环绕规则
ctx.fill('evenodd');  // 奇偶规则

这两个规则有什么区别?

当路径相互交叉或包含时,填充规则决定哪些区域被填充。

非零环绕规则(nonzero)

想象从某个点向外发射一条射线。计算路径穿过射线的次数,顺时针穿过 +1,逆时针穿过 -1。如果总数不为 0,该点在填充区域内。

奇偶规则(evenodd)

同样发射一条射线。简单统计路径穿过射线的次数。如果是奇数,该点在填充区域内;偶数则不在。

实际演示

javascript
// 绘制两个同心圆(同向)
ctx.beginPath();
ctx.arc(100, 150, 80, 0, Math.PI * 2, false);  // 外圆,顺时针
ctx.arc(100, 150, 40, 0, Math.PI * 2, false);  // 内圆,顺时针

ctx.fillStyle = '#4D7CFF';
ctx.fill('nonzero');  // 内圆也被填充

// 使用 evenodd 规则
ctx.beginPath();
ctx.arc(280, 150, 80, 0, Math.PI * 2);  // 外圆
ctx.arc(280, 150, 40, 0, Math.PI * 2);  // 内圆

ctx.fillStyle = '#FF6B6B';
ctx.fill('evenodd');  // 内圆变成空洞

图示

nonzero 规则(同向路径):     evenodd 规则:

   ╭─────────────────╮         ╭─────────────────╮
  ╱  ╭─────────────╮  ╲       ╱  ╭─────────────╮  ╲
 ╱  ╱               ╲  ╲     ╱  ╱               ╲  ╲
│  │  █ 内圆也填充 █ │  │   │  │    空  洞      │  │
│  │  ███████████████ │  │   │  │               │  │
 ╲  ╲               ╱  ╱     ╲  ╲               ╱  ╱
  ╲  ╰─────────────╯  ╱       ╲  ╰─────────────╯  ╱
   ╰─────────────────╯         ╰─────────────────╯

利用填充规则创建空心图形

javascript
// 方法1:使用 evenodd
ctx.beginPath();
ctx.rect(50, 50, 200, 150);   // 外矩形
ctx.rect(80, 80, 140, 90);    // 内矩形
ctx.fill('evenodd');

// 方法2:使用反向路径
ctx.beginPath();
// 外矩形:顺时针
ctx.moveTo(300, 50);
ctx.lineTo(500, 50);
ctx.lineTo(500, 200);
ctx.lineTo(300, 200);
ctx.closePath();
// 内矩形:逆时针
ctx.moveTo(330, 80);
ctx.lineTo(330, 170);
ctx.lineTo(470, 170);
ctx.lineTo(470, 80);
ctx.closePath();
ctx.fill('nonzero');

2.8 综合实战

让我们用本章学到的知识创建一些实用的图形。

2.8.1 绘制一个按钮

javascript
function drawButton(ctx, x, y, width, height, text, options = {}) {
    const {
        backgroundColor = '#4D7CFF',
        textColor = '#FFFFFF',
        borderRadius = 8,
        fontSize = 16,
    } = options;
    
    // 绘制圆角矩形背景
    ctx.beginPath();
    roundRect(ctx, x, y, width, height, borderRadius);
    ctx.fillStyle = backgroundColor;
    ctx.fill();
    
    // 绘制文字
    ctx.fillStyle = textColor;
    ctx.font = `${fontSize}px sans-serif`;
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(text, x + width / 2, y + height / 2);
}

// 使用
drawButton(ctx, 50, 50, 120, 40, '点击我');
drawButton(ctx, 200, 50, 120, 40, '取消', {
    backgroundColor: '#FF6B6B'
});

2.8.2 绘制进度条

javascript
function drawProgressBar(ctx, x, y, width, height, progress, options = {}) {
    const {
        backgroundColor = '#E0E0E0',
        fillColor = '#4ECDC4',
        borderRadius = 4,
    } = options;
    
    // 背景
    ctx.beginPath();
    roundRect(ctx, x, y, width, height, borderRadius);
    ctx.fillStyle = backgroundColor;
    ctx.fill();
    
    // 进度
    const fillWidth = width * Math.max(0, Math.min(1, progress));
    if (fillWidth > 0) {
        ctx.beginPath();
        roundRect(ctx, x, y, fillWidth, height, borderRadius);
        ctx.fillStyle = fillColor;
        ctx.fill();
    }
    
    // 文字
    ctx.fillStyle = '#333';
    ctx.font = '12px sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(`${Math.round(progress * 100)}%`, x + width / 2, y + height / 2);
}

// 使用
drawProgressBar(ctx, 50, 100, 300, 24, 0.75);

2.8.3 绘制简易图表

javascript
function drawBarChart(ctx, x, y, width, height, data) {
    const maxValue = Math.max(...data.map(d => d.value));
    const barCount = data.length;
    const barWidth = (width * 0.6) / barCount;
    const gap = (width * 0.4) / (barCount + 1);
    
    // 绘制坐标轴
    ctx.strokeStyle = '#333';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(x, y);
    ctx.lineTo(x, y + height);
    ctx.lineTo(x + width, y + height);
    ctx.stroke();
    
    // 绘制柱子
    data.forEach((item, index) => {
        const barHeight = (item.value / maxValue) * height * 0.9;
        const barX = x + gap + index * (barWidth + gap);
        const barY = y + height - barHeight;
        
        // 柱子
        ctx.fillStyle = item.color || '#4D7CFF';
        ctx.fillRect(barX, barY, barWidth, barHeight);
        
        // 标签
        ctx.fillStyle = '#333';
        ctx.font = '12px sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText(item.label, barX + barWidth / 2, y + height + 15);
        
        // 数值
        ctx.fillText(item.value.toString(), barX + barWidth / 2, barY - 5);
    });
}

// 使用
const chartData = [
    { label: 'Jan', value: 65, color: '#FF6B6B' },
    { label: 'Feb', value: 85, color: '#4ECDC4' },
    { label: 'Mar', value: 45, color: '#45B7D1' },
    { label: 'Apr', value: 95, color: '#96CEB4' },
    { label: 'May', value: 70, color: '#FFD93D' },
];

drawBarChart(ctx, 50, 150, 400, 200, chartData);

2.8.4 绘制仪表盘

javascript
function drawGauge(ctx, cx, cy, radius, value, options = {}) {
    const {
        minValue = 0,
        maxValue = 100,
        startAngle = Math.PI * 0.75,
        endAngle = Math.PI * 2.25,
        backgroundColor = '#E0E0E0',
        fillColor = '#4D7CFF',
        lineWidth = 20,
    } = options;
    
    // 计算角度
    const progress = (value - minValue) / (maxValue - minValue);
    const currentAngle = startAngle + (endAngle - startAngle) * progress;
    
    // 背景弧
    ctx.beginPath();
    ctx.arc(cx, cy, radius, startAngle, endAngle);
    ctx.strokeStyle = backgroundColor;
    ctx.lineWidth = lineWidth;
    ctx.lineCap = 'round';
    ctx.stroke();
    
    // 进度弧
    ctx.beginPath();
    ctx.arc(cx, cy, radius, startAngle, currentAngle);
    ctx.strokeStyle = fillColor;
    ctx.stroke();
    
    // 中心数值
    ctx.fillStyle = '#333';
    ctx.font = 'bold 32px sans-serif';
    ctx.textAlign = 'center';
    ctx.textBaseline = 'middle';
    ctx.fillText(Math.round(value).toString(), cx, cy);
    
    // 单位
    ctx.font = '14px sans-serif';
    ctx.fillText('%', cx, cy + 25);
}

// 使用
drawGauge(ctx, 200, 250, 80, 72);

2.9 本章小结

恭喜你完成了 Canvas 基础图形绘制的学习!让我们回顾关键知识点:

核心知识

图形类型方法/实现要点
矩形fillRect, strokeRect, clearRect唯一不需要路径的图形
圆角矩形arcTo + 路径需要手动实现或使用原生 roundRect
圆/圆弧arc(x, y, r, start, end, ccw)角度使用弧度,方向默认顺时针
扇形moveTo 圆心 + arc用于饼图
圆环外弧顺时针 + 内弧逆时针利用路径方向创建空心
直线moveTo + lineTo注意 0.5px 偏移问题
多边形循环 lineTo + closePath需要计算顶点坐标
星形交替内外半径顶点数 = 角数 × 2

绘制模式

概念说明
fill()填充闭合区域
stroke()描绘路径轮廓
顺序先填充后描边,描边完整可见
填充规则nonzero(默认)和 evenodd

常见问题

问题解决方案
1px 线条模糊坐标偏移 0.5px
图形连在一起每个图形前调用 beginPath()
描边被裁切预留 lineWidth/2 的边距
多边形不闭合使用 closePath()

2.10 练习题

基础练习

  1. 绘制一个棋盘(8×8 的黑白格子)

  2. 绘制一个笑脸(圆形脸 + 两个眼睛 + 嘴巴弧线)

  3. 绘制一组圆角按钮,鼠标悬停时改变颜色

进阶练习

  1. 实现一个可配置的饼图组件:

    • 支持任意数量的数据项
    • 支持自定义颜色
    • 显示百分比标签
  2. 实现一个动态进度环:

    • 圆环形式显示进度
    • 支持动画效果
    • 中心显示百分比

挑战练习

  1. 实现一个简易绘图工具:
    • 支持绘制矩形、圆形、直线
    • 支持填充和描边模式
    • 支持选择颜色和线宽

下一章预告:在第3章中,我们将深入学习 Canvas 的路径系统和贝塞尔曲线。你将学会绘制平滑的曲线、复杂的自定义形状,以及理解路径的工作原理。


文档版本:v2.0
字数统计:约 16,000 字
代码示例:50+ 个

如有转载或 CV 的请标注本站原文地址