HTML5-Canvas时钟

Canvas 示例:时钟

最终效果图:

img

星期天趁空隙实现了个简单的一个时钟,下面回顾下其实现原理,和遇到的问题,对 H5 这块还是个菜鸟,只有不断通过练习去熟悉了。

设计

凡是从构思开始,而不是盲目的去实现代码。

设计图:

img

从上图中该时钟分为几个部分

  1. 画布,背景蓝色部分;
  2. 第一个圆:时钟边框,最外层 8 个像素的宽边框;
  3. 第二个圆:点圆圈,代表着时间划分,每两个点之间代表 12 分钟(60 / 5 = 12);
  4. 第三个圆:数字圆,上面依次显示`[3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2];`数字,代表小时数;
  5. 三个线条,分别是:时针(`r * 0.5`),分针(`r * 0.75`),秒针(`r * 0.85`);

实现

根据上面的设计图和部位划分,来逐步实现 UI。

时钟模块文件:`clock.js`

1
2
3
4
5
6
7
8
9
10
11
12
// clock.js
function Clock(canvas) {
this.canvas = canvas;
this.width = this.canvas.offsetWidth;
this.height = this.canvas.offsetHeight;
this.rem = this.width / 200;
this.ctx = this.canvas.getContext('2d');
this.r = this.width / 2;
this.digits = [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 1, 2];
this.dotCount = 60;
this.cellRad = 2 * Math.PI / this.digits.length;
}

上面定义了时钟构造函数,参数是一个画布对象,成员包含

  • this.canvas :缓存画布对象;
  • this.width :画布的实际宽度,这里选择让其变成成员,原因是后面会使用到画布宽高,这里创建时进行缓存,避免后面使用时才去计算;
  • this.height :画布实际高度,同上;
  • this.rem :相对画布的比例,参考值:`200px`,这个成员可以应对画布的大小缩放情况,使其他元素相应的做出响应;
  • this.ctx :画布上下文;
  • this.r =:外圆的半径;
  • this.digits :小时数字数组,用来显示在时钟表盘上的数字;
  • this.dotCount :定义时钟表盘上的点的数量;
  • this.cellRad :小时与小时数字之间的弧度,这里其实就是 `30 度` 角的弧度;

原型函数列表:(事先定义好可能需要的函数)

  • Clock.prototype._context :获取画布上下文,实际上就是得到 `this.ctx` 成员;
  • Clock.prototype.drawBg :绘制背景,也就是最外圈的时钟的边框圆;
  • Clock.prototype.drawDigits :绘制数字,根据 `单位角度 * 小时数字索引`,将数字绘制到相应的位置上,这里需要注意的是画布的起始位置默认是水平向右的位置开始,也就是 3 点钟方向;
  • Clock.prototype.drawDot :绘制点,分割成 60 个点,同样是根据每两个点之间的弧度来实现;
  • Clock.prototype.drawHourHand :绘制小时时针线;
  • Clock.prototype.drawMinuteHand :绘制分钟时针线;
  • Clock.prototype.drawSecondHand :绘制秒钟时针线;
  • Clock.prototype._drawHand :绘制时针线的统一函数,因为不管是小时,分钟,秒钟也好,最终都是画线条,因此有其共同点,不同点在于线的粗细,长短,和弧度,因此可统一函数接口,将不同点作为参数传入;
  • Clock.prototype.drawCenterDot :绘制三个时针线的中心空心点,让三条线在跳动的时候感觉像是被固定要中心点一样;
  • Clock.prototype.draw :对外的绘制接口;
  • Clock.prototype._update :将所有绘制接口放到这里面,然后每一秒调用一次去更新当前时间刷新 UI;
  • Clock.prototype.start :启动时钟计时;

所有准备工作都OK了,下面就可以进入具体的功能实现部分了。(**事先设计好 API 是个很不错的编程思路**)

时钟边框

时针边框的背景就是个具有边框的空心圆,原理是很简单的

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
// clock.js

Clock.prototype.drawBg = function () {

var ctx = this._context(), // 获取画布上下文
lineWidth = 8, // 指定线的宽度

// 这个半径需要考虑到边框
r = this.r - lineWidth * this.rem / 2;

// 重置画布原点至画布中心点,因为后面的绘制都需要以中心点为圆点
// 为了方便起见,这里直接将中心点作为原点是个不错的选择
ctx.translate(this.r, this.r);

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

// 指定线宽
ctx.lineWidth = lineWidth;

// 参数分别对应:圆点(x:0, y:0),半径:r,起始弧度:0,结束弧度:2π
ctx.arc(0, 0, r, 0, 2 * Math.PI);

// 结束路径,这里需要提醒的一点,最好先成对把 beginPath 和 closePah 先好
// 避免遗漏关闭路径
ctx.closePath();

// 绘制边框,如果填充则需要使用 ctx.fill();
ctx.stroke();
};

这样时钟边框圆就绘制完成了,如下图:
img

点圆绘制

第二个圆:点圆的绘制,整个时钟会被分割成 60 份,包含 60 个点,也就是说两个小时数之间会有5个分割弧度如设计图中右下角部分的绿色线条;

这个点圆的绘制原理是:根据弧度来计算每个点中心点的的具体坐标(x, y),如下图

img

圆分割成60个圆弧后,每个圆弧的弧度:

rad = 2 * Math.PI / 60;

比如:

第一个点:12点上 (0,r)

第二个点: (x = r * cos(rad), y = r * sin(rad));

第三个点: (x = r * cos(2 * rad), y = r * sin(2 * rad));

第N个点: (x = r * cos((n - 1) * rad), y = r * sin((n - 1) * rad));

根据上面的结果,那么我们代码就很简单了

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
// clock.js

Clock.prototype.drawDot = function () {

var ctx = this._context(),
delta = 18,
r = this.r - delta * this.rem,
dotRad = 2 * Math.PI / this.dotCount,
x, y, i;

// 遍历所有的点,获取每个点的坐标(x,y),以该坐标为圆点绘制
for (i = 0; i < this.dotCount; i++ ) {

// 计算当前点弧度
rad = dotRad * i;

// 根据弧度得到点坐标
x = r * Math.cos(rad);
y = r * Math.sin(rad);

ctx.beginPath();
// 以 (x, y)为圆点绘制,半径为 1 的小圆点,采用填充方式
// 这里 * this.rem,是应对画布缩放的时候点圆的大小等比例缩放
ctx.arc(x, y, 1 * this.rem, 0, 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = 'gray';
if ( i % 5 === 0 ) {
// 小时数上的点,以黑色强调显示
ctx.fillStyle = 'black';
}
ctx.fill();
}
};

这样就完成了外圈圆,和中间点圆的绘制,效果图如下:

img

点圆的绘制,重点在于每个点的坐标的位置,根据被分割的单元弧度和点的索引即可计算出该点的弧度;

数字圆的绘制

数字圆的绘制和点圆的绘制原理是一样的,只是被分割的单元弧度不一样而已,另外需要注意的点是,数字坐标上的文字布局问题;

先来看下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// clock.js
Clock.prototype.drawDigits = function () {
var me = this,
ctx = this._context(),
delta = 30,
r = this.r - delta * this.rem,
rad = 0,
x, y;

this.digits.forEach(function (digit, index) {

rad = me.cellRad * index;

x = r * Math.sin(rad);
y = r * Math.cos(rad);

ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(digit + '', x, y);
});

return this;
}

img

这里绘制方式很简单,直接定位到点(x, y),使用 fillText 就会在该点处直接填充内容,主要看下这里两个属性:

ctx.textAlignctx.textBaseLine

  1. ctx.textBaseLine :文本基线对齐

    这个属性可以指定指定点上的文本基线对齐方式,默认:普通的字母基线(alphabetic)

    w3school 上的各种取值的基线对齐图:

    img

    可取值列表:

描述
alphabetic 普通的字母基线
top em 方框的顶端
hanging 悬挂基线,单词最高字母的顶部紧贴基线方式
middle em 方框的正中
ideographic 表意基线
bottom em 方框底部

图解:

img

除了 ideographic 这个没太理解之外,其他的都还好理解,而我们这里用到的就是 middle 根据 em 方框的正中对齐;

  1. ctx.textAlign : 文本位置

    这个相对就比较好理解了,看图

    img

时针绘制(小时,分钟,秒钟)

把小时,分钟,秒钟时针放一起,因为它们三个的绘制函数是一样的,只是需要控制其不通的角度和样式

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
/**
* 绘制线条(时针,分针,秒针线)
* @param {Number} rad 弧度,当前时间对应的弧度
* @param {Number} length 时间针的长度
* @param {String} style 时间针的填充样式
* @param {Number} width 时间针的宽度
* @return {[type]} [description]
*/
Clock.prototype._drawHand = function (rad, length, style, width) {
var ctx = this._context();

ctx.save();
ctx.beginPath();

// 这个负责根据弧度转动指针,也是根据当前时间实时更新时针的关键
ctx.rotate(rad);

// 线条两端样式,可取值:butt|round|square
ctx.lineCap = 'round';
ctx.lineWidth = width;
ctx.strokeStyle = style;

// 这里使用了个技巧,让起点位置往后突出了 10 个像素
ctx.moveTo(0, 10 * this.rem);
ctx.lineTo(0, -length);
ctx.closePath();
ctx.stroke();
ctx.restore();

return this;
}

接下来根据不同指针的弧度绘制线条

时针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Clock.prototype.drawHourHand = function ( hour, minute ) {

var rad, cellRad;

cellRad = 2 * Math.PI / this.digits.length;

// 小时数加上分钟数的弧度,如果不设置分钟数弧度,
// 时针只会指向特定的小时数位置
rad = cellRad * hour + minute / 60 * cellRad;

this._drawHand(rad, this.r * 0.5, 'black', 5);

return this;
};

分针

1
2
3
4
5
6
7
8
Clock.prototype.drawMinuteHand = function ( minute ) {

var rad = minute * (2 * Math.PI / this.dotCount);

this._drawHand(rad, this.r * 0.6, 'black', 5);

return this;
};

秒针

1
2
3
4
5
6
7
8
Clock.prototype.drawSecondHand = function ( second ) {

var rad = second * (2 * Math.PI / this.dotCount);

this._drawHand(rad, this.r * 0.78, 'gray', 2);

return this;
};

加上中间圆点

1
2
3
4
5
6
7
8
9
10
11
12
13
Clock.prototype.drawCenterDot = function () {

var ctx = this._context(),
x, y;

ctx.beginPath();
ctx.arc(0, 0, 2 * this.rem, 0, 2 * Math.PI);
ctx.closePath();
ctx.fillStyle = 'white';
ctx.fill();

return this;
}

最终效果:
img

到此时钟的 UI 界面算是完成了。

指针实时更新

动起来,这里就需要用到计时器,去每个一秒获取当前时间的时分秒,去刷新三个指针的位置

更新画布函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Clock.prototype._update = function (h, m, s) {

this.ctx.clearRect(0, 0, this.width, this.height);
this.drawBg();
this.drawDigits();
this.drawDot();
this.drawHourHand(h, m);
this.drawMinuteHand(m);
this.drawSecondHand(s);
this.drawCenterDot();
this.ctx.restore();

return this;
}

注意点:

  1. return this; 之前的 this.ctx.restore();

    还原状态,还记得绘制背景的 this.drawBg(); 里面我们将绘制的起点位置使用 ctx.translate(this.r, this.r); 重新设置到了 `(this.r, this.r)`。

    如果这里不进行还原,那么下一次重绘会在 (this.r, this.r) 的基础上再去重设起点,会导致刷新的时钟不断沿右下 45 度角上不断延伸;

    如果我们这里在重绘整个时钟之前进行还原,那么画布起点就会回到 (0, 0) 也就画布左上角位置,这样才能正确进入下一个时钟循环进行绘制

  2. this.ctx.clearRect(0, 0, this.width, this.height); 清除画布

    这一句的作用在于绘制下一个时钟之前,清除画布上的指定矩形区内的所有内容,如果不清除,会发生不断重叠的现象,如下图:

    img

通过 this.ctx.clearRect 通过这个清除函数,可以将画布内容清空,方便绘制下一个时钟,其实就是清空画布,重新绘制整个时钟

启动更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Clock.prototype.start = function () {

var date = new Date(),
hour, minute, second,
me = this;

// 得到当前时间的时分秒
hour = date.getHours();
minute = date.getMinutes();
second = date.getSeconds();

// 更新画布
this._update(hour, minute, second);

// 每个一秒刷新一次,这里用到了 requestAnimationFrame 动画帧请求函数
// 对于这个函数还没搞透,为啥就比 setTimeout 更准确的问题
requestAnimationFrame(function () {
me.start();
}, 1000);
};

最终效果动态图

img

总结

一个小时钟实现,原理其实很简单,主要实现步骤

  1. 绘制外圈
  2. 绘制点圈
  3. 绘制数字
  4. 绘制三个指针
  5. 最后设置定时器更新指针

涉及的属性:

属性名 描述
lineWidth 线条宽度
lineCap 线条两端样式,`butt`, `round`, `square`
font 字体
textAlign 文本水平对齐方式,`right`, `left`, `end`, `start`, `center`
textBaseLine 基线对齐方式,`alphabetic`, `top`, `hanging`, `middle`, `ideographic`, `bottom`

使用到的函数:

绘图函数:

  • ctx.arc(x, y, r, startRad, endRad); :绘制圆或圆弧;
  • ctx.moveTo(x, y);` 和 `ctc.lineTo(x, y); : 绘制线条;

状态函数:

  • ctx.save();
  • ctx.restore();

清空画布函数:

  • ctx.clearRect(x, y, w, h);

其他函数:

  • ctx.rotate(rad); : 旋转角度;
  • ctx.fill/fillStyle : 填充和填充样式;
  • ctx.stroke/strokeStyle :描边和描边样式;
0%