如何用 canvas 画一个小时钟

447 阅读5分钟

这篇文章通过画一个时钟,让你了解 Canvas 中最常用的 API 的使用方法。就算你从来没有使用过它,也能大概了解 Canvas 能做什么事,以后工作中遇到了就能快速想起如何去使用它。

首先,我们先在 html 初始化一个 canvas 元素:

<canvas id="myCanvas" width="200" height="200" style="border: 1px solid black"></canvas>

画表盘

我们的表盘是由一大一小的两个圆型构成的,画表盘的核心 API 就是 Context.arc

大致的使用如下:

ctx.arc(100, 100, 99, 0, 2 * Math.PI, true);

它表示着以(100,100)为圆心,以 99为半径画一个圆。坐标系的起点是我们页面中 canvas 的左上顶点。

第一二个参数分别是圆心的 x、y 轴的坐标,第三个参数是半径,第四个参数是画圆的起始弧度,第五个是画圆的终止弧度,最后一个参数是画圆是按照顺时针还是逆时针,true 的话是顺时针。

最终的效果如下:

为了更逼真一点,我们画两个圆来作为我们的表盘:

ctx.beginPath();
ctx.arc(100, 100, 99, 0, 2 * Math.PI, true);

ctx.moveTo(194, 100); // 为了防止画出多余的线段
ctx.arc(100, 100, 94, 0, 2 * Math.PI, true);
ctx.stroke();

你可能注意到了,我们多加了 ctx.moveTo(194, 100) 这一行代码,为什么呢?如果不加会如下图,画出多余的线段,

所以,我们要额外加那一行代码,先把画笔移动到内圆上。

画表心

有了上一步的基础,这一步就非常简单了,我们只是需要在圆心处画一个很小的实心圆。注意到没?上一步我们使用的是 ctx.stroke 来画圆,但是我们没有填充它的颜色。这一步我们就需要来填充颜色了,需要在最后一步使用 ctx.fill 来画。

ctx.beginPath(); // 这样也可以防止画出多余的线段
ctx.arc(100, 100, 4, 0, 2 * Math.PI, true);
ctx.fill();

效果如下图:

在不进行任何设置的时候,填充的默认颜色是黑色,你可以在调用 ctx.fill 之前使用 ctx.fillStyle 指定它的颜色:

ctx.fillStyle = "#ddd"

画时针分针

这一小节,我们学习如何画线段。使用到的 API 是 context.lineTo 和 context.moveTo。其实非常的简单粗暴。

刚开始,我们整个 canvas 的原点是在左上角,但是为了方便期间,现在把原点改为圆心处:

ctx.translate(100, 100);

接下来,我们就可以比较简单的画线了:

// 画分针
ctx.moveTo(0, 0);
ctx.lineTo(0, -80);

// 绘制时针
ctx.moveTo(0, 0);
ctx.lineTo(-45, 0);

ctx.stroke();

整体的效果如下:

有点模样了吧~

让表动起来

很多同学不知道,canvas 也是可以有动效的,这一小节我就为大家带来 canvas 最简单的动效如何实现。

为了讲述的简单,我们只让秒针动起来就可以了,因为只要了解了如何让一个针动起来,其他的也是易如反掌的事情。

我们把整个表表盘分成四个象限:

image.png

象限的划分和我们初中学的一样,只不过这里的 Y 轴朝下才是正向的。

首先我们需要两个工具函数:getTimeQuadrantcomputePoint

getTimeQuadrant 用来得到当前的时间在第几象限内:

function getTimeQuadrant(time) {
    if (time >= 60) {
        throw new Error('`time` is out of boundary');
    }
    return parseInt(time / 15) + 1;
}

我们可以使用 new Date().getSeconds() 来获取当前是第几秒,我们的 computePoint 函数,可以根据当前的秒数计算点的位置是什么:

function computePoint(r, time) {
    let radian = time / 60 * 2 * Math.PI;

    let computePointObj = {
        1: () => {
            return {
                x: r * Math.sin(radian),
                y: -r * Math.cos(radian)
            }
        },
        2: () => {
            return {
                x: r * Math.sin(Math.PI - radian),
                y: r * Math.cos(Math.PI - radian)
            }
        },
        3: () => {
            return {
                x: -r * Math.cos(1.5 * Math.PI - radian),
                y: r * Math.sin(1.5 * Math.PI - radian)
            }
        },
        4: () => {
            return {
                x: -r * Math.sin(2 * Math.PI - radian),
                y: -r * Math.cos(2 * Math.PI - radian)
            }
        }
    }

    return computePointObj[getTimeQuadrant(time)]()
}

接下来也是很粗暴的事情了,设置一个定时器,每隔一秒花一条线就行:

setInterval(() => {
    ctx.beginPath()
    ctx.moveTo(0, 0);
    let { x, y } = computePoint(60, new Date().getSeconds());
    ctx.lineTo(x, y)
    ctx.closePath();

    ctx.stroke();
}, 1000)

但是事实上这样是不行的,我们会产生如下的结果:

虽说还挺好看的,但这不是我们要的结果,具体产生这样的原因是什么呢?我们没有在下一秒划线之前,清除掉上一次的结果,我们加上,最后这段代码就是:

setInterval(() => {
    // 清除操作,核心 API 是 clearRect
    ctx.arc(0, 0, 4, 0, 2 * Math.PI, true);
    ctx.clearRect(-60, -60, 120, 120);
    ctx.arc(0, 0, 4, 0, 2 * Math.PI, true);
    ctx.fill();

    // 画秒针
    ctx.beginPath()
    ctx.moveTo(0, 0);
    let { x, y } = computePoint(60, new Date().getSeconds());
    ctx.lineTo(x, y)
    ctx.closePath();

    ctx.stroke();
}, 1000)

这样就能得到一个每秒都动一下的动效了,依照此原理,我们可以加上分针、秒针。

我们的例子很简单,但是基本上把 Canvas 常用的 API 都涉及到了,包括画圆、画线段、动效。剩下的就需要同学你自己去探索啦,希望你能有所收获,谢谢!

所有代码:

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

ctx.beginPath();
ctx.arc(100, 100, 99, 0, 2 * Math.PI, true);

ctx.moveTo(194, 100);
ctx.arc(100, 100, 94, 0, 2 * Math.PI, true);
ctx.stroke();

ctx.beginPath(); // 这样也可以防止画出多余的线段
ctx.arc(100, 100, 4, 0, 2 * Math.PI, true);
ctx.fill();

ctx.translate(100, 100);

setInterval(() => {
    ctx.arc(0, 0, 4, 0, 2 * Math.PI, true);
    ctx.clearRect(-60, -60, 120, 120);
    ctx.arc(0, 0, 4, 0, 2 * Math.PI, true);
    ctx.fill();

    // 画秒针
    ctx.beginPath()
    ctx.moveTo(0, 0);
    let { x, y } = computePoint(60, new Date().getSeconds());
    ctx.lineTo(x, y)
    ctx.closePath();

    ctx.stroke();
}, 1000)

function getTimeQuadrant(time) {
    if (time >= 60) {
        throw new Error('`time` is out of boundary');
    }
    return parseInt(time / 15) + 1;
}

function computePoint(r, time) {
    let radian = time / 60 * 2 * Math.PI;

    let computePointObj = {
        1: () => {
            return {
                x: r * Math.sin(radian),
                y: -r * Math.cos(radian)
            }
        },
        2: () => {
            return {
                x: r * Math.sin(Math.PI - radian),
                y: r * Math.cos(Math.PI - radian)
            }
        },
        3: () => {
            return {
                x: -r * Math.cos(1.5 * Math.PI - radian),
                y: r * Math.sin(1.5 * Math.PI - radian)
            }
        },
        4: () => {
            return {
                x: -r * Math.sin(2 * Math.PI - radian),
                y: -r * Math.cos(2 * Math.PI - radian)
            }
        }
    }

    return computePointObj[getTimeQuadrant(time)]()
}