IkeqIkeq

The whole problem with the world is that fools and fanatics are always so certain of themselves, but wiser people so full of doubts.

使用 Canvas 绘制 ⌚

Apr 3, 2016Programming1940 words in 10 min

最近看到 canvas,不如来实践一下,画个表,学以致用。

From wikipedia.

Traditionally, watches have displayed the time in analog form, with a numbered dial upon which are mounted at least a rotating hour hand and a longer, rotating minute hand. Many watches also incorporate a third hand that shows the current second of the current minute. Watches powered by quartz usually have a second hand that snaps every second to the next marker. Watches powered by a mechanical movement appears to have a gliding second hand, although it is actually not gliding; the hand merely moves in smaller steps, typically 1/5 of a second, corresponding to the beat (half period) of the balance wheel.

A truly gliding second hand is achieved with the tri-synchro regulator of Spring Drive watches.

表由刻度盘 (numbered dial),时针 (hour hand),分针 (minute hand),秒针 (second hand) 组成。石英表 (quartz) 的秒针通常 1 秒钟走一格;机械表 (mechanical) 的秒针 1/5 秒走一格;Grand Seiko 的 Spring Drive 机芯实现了真正的平滑秒针。

(eh…)

Watch 类

一个简单的 Watch 类实现如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Watch {
constructor(mountNode, config = {}) {
if (!mountNode) return;

const canvas = document.createElement('canvas');

this.size = 800;
canvas.width = canvas.height = this.size;
canvas.style.maxWidth = '100%';

// 计时器实例
this._instance = null;
this._ctx = canvas.getContext('2d');

// 设置 FPS 并启动
return this.setFPS(config.fps);
}

update() {
// Draw the magic
}

stop() {
clearInterval(this._instance);
this._instance = null;

return this;
}

start() {
if (this._instance !== null) {
console.warn('Watch is already running.');
return this;
}

// 取整
const freq = ~~(1000 / this.fps);

this._instance = setInterval(this.update.bind(this), freq);

return this;
}

setFPS(fps) {
// FPS 需大于 0 且小于 60
this.fps = fps > 0 ? fps < 60 ? fps : 60 : 60;

// 重启
return this.stop().start();
}
}

核心是 Watch.update() 方法,用来绘制具体帧,通过 setInterval 重复调用来实现动画。

Draw the magic

开始前

为方便起见,变换坐标系原点为 canvas 中心点(canvas 为正方形);设置线条边缘及线条交汇的边缘样式为圆角;同时居中字体。

1
2
3
4
5
ctx.translate(canvas.width / 2, canvas.width / 2);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';

lineCaplineJoin 这两个属性挺有意思。

lineCap,顾名思义,线帽,表示线条末端的样式,取值如下:

butt 平直边缘,默认
round 圆形,会多出长度为线宽度一半的半圆形
square 平直边缘,会多出长度为线宽度一半的矩形

lineJoin 表示线条连接部位的样式,取值如下:

round 交汇处以线宽为半径绘制扇形,填充以线条色
bevel 连接交汇处远端,填充以线条色
miter 延长交汇处远端,使其相交,填充以线条色,默认

表盘

绘制圆形路径,填充背景色、描边。

1
2
3
4
5
6
7
8
// r 为半径
ctx.fillStyle = bgColor;
ctx.lineWidth = frameWidth;
ctx.strokeStyle = frameColor;
ctx.arc(0, 0, r, 0, PI * 2);
ctx.fill();
ctx.arc(0, 0, r - frameWidth / 2, 0, PI * 2);
ctx.stroke();

为提升难度,给背景增加网格线,即画出交叉平行线。第一条平行线与圆左边相切,坐标为 (-r - r × √2, -r)(r, r),横坐标递增,直到铺满整个区域;取反纵坐标的值,绘出对称方向平行线。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const grids = 50,
perGrid = r * 2 / grids;

ctx.strokeStyle = gridColor;
for (let i = 0; i < grids * sqrt2; i++) {
const x = i * perGrid - r * sqrt2;
ctx.beginPath();
ctx.moveTo(-r + x, -r);
ctx.lineTo(r + x, r);
ctx.stroke();

ctx.beginPath();
ctx.moveTo(-r + x, r);
ctx.lineTo(r + x, -r);
ctx.stroke();

i++;
}

效果如下:

曲线以外的网格线是不应该出现的,这时候就需要 globalCompositeOperation 这个属性了。该属性用来设置在绘制新图像时,所采取的混合方式(类似 Photoshop 的混合模式),取值为 26 种字符串枚举,研究了一下(试了好久),source-atop 正好:

source-atop 只在与现有画布内容重叠的位置绘制新图像。

所以需要在勾出曲线路径并填充了背景色之后,设置 globalCompositeOperation='source-atop',接下来绘制的一切都只会显示在圆形内,绘制网格,复原,最后描边。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 绘制背景
ctx.save();
ctx.fillStyle = bgColor;
ctx.arc(0, 0, r, 0, PI * 2);
ctx.fill();

// 设置 globalCompositeOperation
ctx.globalCompositeOperation = 'source-atop';

/// 绘制网格
...

// 复原
ctx.restore();

// 描边
ctx.lineWidth = frameWidth;
ctx.strokeStyle = frameColor;
ctx.arc(0, 0, r - frameWidth / 2, 0, PI * 2);
ctx.stroke();

最终效果:

刻度线及数字

刻度线的精度为 6°,意即将圆周平分 60 份,其中每 30° 为一个数字刻度。坐标计算如下:

时刻 夹角 起始坐标 终点坐标
12 点 0 (0, from) (0, to)
其余时刻 deg (sin(deg) × from, --cos(deg) × from) (sin(deg) × to, --cos(deg) × to)

JS 的三角函数默认传参为弧长,为方便起见,转为角度作为参数。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const sin = deg => Math.sin(deg * 2 * Math.PI / 360 ),
cos = deg => Math.cos(deg * 2 * Math.PI / 360 );

ctx.font = '30px impact';

for (let i = 0; i < 60; i++) {
const deg = i * 6,
xWeight = sin(deg),
yWeight = -cos(deg);

ctx.beginPath();
ctx.moveTo(xWeight * from, yWeight * from);
ctx.lineTo(xWeight * to, yWeight * to);
// 绘制数字刻度,并且加粗刻度线
if (i % 5 == 0) {
ctx.fillText(i / 5 || 12, xWeight * digitPos, yWeight * digitPos);
ctx.lineWidth = 3;
ctx.stroke();
ctx.lineWidth = 1;
} else {
ctx.stroke();
}
};

效果如下:

指针

指针和刻度线类似,但需要从原点开始画,然而真实的表的指针尾部都会多出去一截,姑且算作半径 r 的 1/6,所以 12 点方向指针的坐标为 (0, -r/6)(0, 指针长度)

根据当前时间时分秒计算夹角:

degree per unit degree
second 360/60=6 6 * second
minute 360/60=6 6 * minute
hour 360/12=30 30 * hour

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const now = new Date,
degrees = {
second: 6 * now.getSeconds(),
minute: 6 * now.getMinutes(),
hour: 30 * now.getHours()
};

renderHand(ctx, degrees.hour, r * .5);
renderHand(ctx, degrees.minute, r * .8);
renderHand(ctx, degrees.second, r * .8, 1, 'red');

/**
* 绘制指针
*
* @param {CanvasRenderingContext2D} ctx
* @param {number} deg 夹角
* @param {number} length 指针长度
* @param {number} [width=4] 指针宽度
* @param {string} [color='black'] 指针颜色
*/
function renderHand(ctx, deg, length, width = 4, color = 'black') {
const xWeight = sin(deg), yWeight = -cos(deg);

ctx.save();
ctx.lineWidth = width;
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(xWeight * r / -6, yWeight * r / -6);
ctx.lineTo(xWeight * length, yWeight * length);
ctx.stroke();
ctx.restore();
}

最终效果:

至此,new Watch 的话,已经可以看到一个跑着的表了。

优化

Canvas 缓存

刻度盘的绘制存在大量计算,每一次调用 Watch.update() 都会重复计算,有损性能,若计算量非常大,动画很可能达不到流畅的 60 FPS,优化策略是使用 canvas 缓存,其原理是将更新频率较低的图像绘制在额外的未显示于网页上的 canvas 上(或称离屏 canvas),需要的时候再通过 CanvasRenderingContext2D.drawImage() 将缓存图像绘制于主 canvas 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Watch() {
constructor() {
...
this._cache = {};

// 缓存刻度
this.cache('dial');
}
update() {
...
// 绘制缓存
this.ctx.drawImage(this.cache.dial, -r, -r);
}
cache(id) {
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d');
...
this.renderers[id](ctx);
}
}

JS 优化

Watch.upadte() 应写的足够高效,为此,我们可以借助闭包将一些表达式计算,参数设置的代码隔离,只返回绘制的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Watch() {
constructor() {
...
this._cache = {};

// 缓存刻度
this.cache('dial');
this.renderers = this.createRender();
}
update() {
...
// 绘制刻度盘
this.ctx.drawImage(this.cache.dial, -r, -r);
// 绘制指针
this.renderers.hands(this._ctx, this.fps);
}
createRender(id) {
// 变量声明,参数设置,表达式计算

return { dial, hands };

// 刻度盘
function dial() { }
// 指针
function hands() { }
}
}

The end

经过了一波美化,终于看到效果了,点击表盘上方按钮可在模拟三种机芯的 FPS 之间切换。

Buy me a cup of milk 🥛.