最近看到 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' ); return this .setFPS (config.fps ); } update ( ) { } 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 ) { 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' ;
lineCap
和 lineJoin
这两个属性挺有意思。
lineCap
,顾名思义,线帽,表示线条末端的样式,取值如下:
butt
平直边缘,默认
round
圆形,会多出长度为线宽度一半的半圆形
square
平直边缘,会多出长度为线宽度一半的矩形
lineJoin
表示线条连接部位的样式,取值如下:
round
交汇处以线宽为半径绘制扇形,填充以线条色
bevel
连接交汇处远端,填充以线条色
miter
延长交汇处远端,使其相交,填充以线条色,默认
表盘
绘制圆形路径,填充背景色、描边。
1 2 3 4 5 6 7 8 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 (); 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' );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 之间切换。