注: 本例使用es6语法编写.
本例效果图
源码地址:github:concise-clock
vue版本:github:vue-analog-clock
react版本:github:r-analog-clock
思路
1、性能节省: 将模拟时钟分成两个部分,表盘和指针,要使时钟“动”起来,需要每隔1秒重新绘制一次,但真正“动”的只有指针,所以使用两个canvas对象,一个用来绘制缓存表盘,且只需要绘制1次,另一个则用来绘制缓存好的表盘加指针。
2、坐标问题: canvas默认坐标轴原点在画布左上角,x轴水平向右,y轴竖直向下。为方便计算,可将坐标轴原点平移到画布中心。不管是时钟表盘的刻度或者是指针,都与角度有关,所以采用极坐标的方式更方便处理问题,这里的极坐标的极点在画布中心,极轴竖直向上(12点方向),角度正方向取顺时针。接下来,只需要提供一个极坐标上的点坐标转canvas平移后的坐标轴上的点的函数即可。
代码实现
- 实现坐标轴的转换方法.
/**
* 极坐标转平移后画布坐标
* ps:极坐标极轴水平向上,角度正方向顺时针
* ps:画布坐标是平移后的画布坐标,坐标原点画布中心,x轴水平向右,y轴竖直向下
* @param r 当前点到原点的长度
* @param radian 弧度
*/
polarCoordinates2canvasCoordinates(r, radian) {
//极轴竖直向上极坐标 转 极轴水平向右极坐标
radian -= Math.PI * 0.5; //角度向右旋转90度即可
//极轴水平向右极坐标转平移后画布坐标(x轴水平向右,y轴竖直向下)
let x = r * Math.cos(radian);
let y = r * Math.sin(radian);
return { x, y };
}
- 全部代码如下:
定义类Clock,构造方法参数有两个,第一个参数接收HTMLCanvasElement对象或id,用来作为显示模拟时钟的容器,第二个参数接收模拟时钟的一些属性,方便第三方使用者调用。并提供run、stop、show、setOptions方法,其中run方法让模拟时钟“动起来”,stop方法停止一个正在运行的模拟时钟,show方法显示一个时间(不会动),setOptions方法则是更新模拟时钟的一些属性。
class Clock{
constructor(canvas, options = {}) {
if (!canvas) {//参数为空验证
throw new Error("请传入canvas参数!");
}
let container = canvas;
if ("string" == typeof canvas) {
//如果是字符串,那么通过getElementById获取dom对象
container = document.getElementById(canvas);
}
if (!(container instanceof HTMLCanvasElement)) {//验证是否是HTMLCanvasElement对象
throw new Error("传入的canvas参数不是一个HTMLCanvasElement对象!");
}
/**默认选项 */
this.options = {
size: 300,//模拟时钟尺寸(px)
padding: 5,//内边距
borderWidth: 15,//边框宽度
borderColor: "black",//边框颜色
borderImage: undefined,//边框图,优先级高于borderColor
scaleType: "arabic",//刻度值类型(arabic、roman、none),arabic:阿拉伯数字;roman:罗马数字; none:不显示;
scaleColor: "#666",//刻度线颜色
hourColor: "#666",//刻度值颜色
backgroundColor: "white",//背景色
backgroundImage: undefined,//背景图,优先级高于backgroundColor
secondHandColor: "red",//秒针颜色
minuteHandColor: "#666",//分针颜色
hourHandColor: "black",//时针颜色
backgroundMode: "full",//背景图显示模式
backgroundAlpha: 0.5,//背景色透明度
showShadow: true,//是否显示阴影
onload: undefined,//图片加载完成回调,回调参数当前Clock对象
};
//用来缓存表盘的canvas对象
this.dialCanvas = document.createElement("canvas");
//这里获取下dialCanvas的上下文,方便在其他方法里使用
this.dialCtx = this.dialCanvas.getContext("2d");
this.container = container;
//同上,获取容器的context,方便在其他方法中用到
this.ctx = container.getContext("2d");
//设置模拟时钟属性
this.setOptions(options);
}
//提供此方法,方便使用者更新模拟时钟属性
setOptions(options = {}) {
let opts = {};
Object.keys(options).forEach(key => {
const val = options[key];
if (val !== undefined) { //过滤掉值为undefined的key
opts[key] = val;
}
});
//合并覆盖默认属性
this.options = Object.assign({}, this.options, opts);
//初始化操作
this.init();
}
/**
* 极坐标转平移后画布坐标
* ps:极坐标极轴水平向上,角度正方向顺时针
* ps:画布坐标是平移后的画布坐标,坐标原点画布中心,x轴水平向右,y轴竖直向下
* @param r 当前点到原点的长度
* @param radian 弧度
*/
polarCoordinates2canvasCoordinates(r, radian) {
//极轴竖直向上极坐标 转 极轴水平向右极坐标
radian -= Math.PI * 0.5; //角度向右旋转90度即可
//极轴水平向右极坐标转平移后画布坐标(x轴水平向右,y轴竖直向下)
let x = r * Math.cos(radian);
let y = r * Math.sin(radian);
return { x, y };
}
//加载一张图片,并得到Image对象
createImage(src) {
return new Promise((resolve, reject) => {
let img = new Image();
img.onload = () => {
resolve(img);
};
img.onerror = () => {
reject(new Error("图片加载出错!"));
this.stop(); //停止
};
img.src = src;
});
}
//模拟时钟的边框图会用到
createPattern(ctx, src, repetition) {
return new Promise((resolve, reject) => {
let img = new Image();
img.onload = () => {
resolve(ctx.createPattern(img, repetition));
};
img.onerror = () => {
reject(new Error("图片加载出错!"));
this.stop(); //停止
};
img.src = src;
});
}
async init() {
const { size, borderWidth, borderImage, padding, scaleType = "arabic", backgroundImage, onload } = this.options;
this.halfSize = size * 0.5;//画布尺寸的一半,多处地方有用到,故提出来
//设置两个画布的宽高均为size
this.dialCanvas.width = this.container.width = size;
this.dialCanvas.height = this.container.height = size;
//大刻度线的长度为内圈半径的十二分之一
this.largeScale = (this.halfSize - padding - borderWidth) / 12;
//小刻度线的长度为大刻度线的一半
this.smallScale = this.largeScale * 0.5;
this.hourFontSize = this.largeScale * 1.2;//刻度值的字体大小计算
this.headLen = this.smallScale * 1.5;//指针针头长度计算
this.secondHandLen = this.headLen * 12;//秒针长度计算
this.minuteHandLen = this.headLen * 10;//分针长度计算
this.hourHandLen = this.headLen * 7;//时针长度计算
//平移坐标轴,将左上角的(0,0)点平移到画布中心。
this.ctx.translate(this.halfSize, this.halfSize);
this.dialCtx.translate(this.halfSize, this.halfSize);
if ("roman" == scaleType) {//刻度值类型为罗马数字
this.hours = ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"];
}
else if ("arabic" == scaleType) {//刻度值类型为阿拉伯数字
this.hours = ["12", "1", "2", "3", "4", "5", "6", "7", '8', "9", "10", "11"];
} else {//用户没有设置,就设置为空数组,就不会显示刻度值
this.hours = [];
}
if (borderImage) {//用户有设置边框背景图
//使用es6语法糖async await比较方便,不然要写函数回调。
this.borderPattern = await this.createPattern(this.dialCtx, borderImage, "repeat");
}
if (backgroundImage) {//用户有设置表盘背景图
this.backgroundImage = await this.createImage(backgroundImage);
}
//绘制表盘
this.drawDial(this.dialCtx);
if (onload instanceof Function) {
onload(this);//若用户有定义onload回调函数,那么就回调一下
}
}
//绘制表盘
drawDial(ctx) {
const {
padding, borderWidth, borderColor, borderImage, scaleColor, backgroundColor,
backgroundImage, backgroundMode, backgroundAlpha, showShadow
} = this.options;
const hours = this.hours;
const halfSize = this.halfSize;
const shadowBlur = 10;
const shadowOffset = 5;
//--------外圈
ctx.save();
const x = 0;
const y = 0;
//若需要显示阴影,那么就再减去阴影的那部分,这样才能完全显示出阴影效果
const outsideR = halfSize - padding - (showShadow ? shadowBlur + shadowOffset : 0);
ctx.arc(x, y, outsideR, 0, 2 * Math.PI, true);
if (borderImage && this.borderPattern) { //边框背景图
ctx.fillStyle = this.borderPattern;
}
else { //边框颜色
ctx.fillStyle = borderColor;
}
//--------内圈 利用相反缠绕可形成内阴影
const insideR = outsideR - borderWidth;
ctx.arc(x, y, insideR, 0, 2 * Math.PI, false);
if (showShadow) {//如果需要显示阴影
ctx.shadowBlur = shadowBlur;
ctx.shadowColor = "#666";
ctx.shadowOffsetX = shadowOffset;
ctx.shadowOffsetY = shadowOffset;
}
ctx.fill();
ctx.restore();
//--------内圈的背景图或背景色
ctx.beginPath();
ctx.save();
if (backgroundImage && this.backgroundImage) { //背景图
const { width, height } = this.backgroundImage;
const r = "full" == backgroundMode ? insideR : insideR - this.largeScale - this.hourFontSize - 15;
ctx.globalAlpha = backgroundAlpha;
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.clip(); //按内圈区域裁剪图片
//最小的一边要刚好能显示完全 ,r * 2直径
const scale = r * 2 / Math.min(width, height);
ctx.drawImage(this.backgroundImage, -r, -r, width * scale, height * scale);
}
else if ("white" != backgroundColor) {
//背景色,若背景色是白色,就不必填充,因为原本就是白色,并且不填充可以渲染出内阴影效果
ctx.arc(x, y, insideR, 0, 2 * Math.PI);
ctx.fillStyle = backgroundColor;
ctx.fill();
}
ctx.restore();
//--------刻度线和刻度值
//一圈被分成60份,每一份的度数是360/60=6度,转换为弧度(Math.PI/180)*6=Math.PI/30
const unit = Math.PI / 30;
for (let scale = 0; scale < 60; scale++) { //从12点到11点59秒顺时针
const radian = unit * scale;
const start = this.polarCoordinates2canvasCoordinates(insideR, radian);
const len = 0 == scale % 5 ? this.largeScale : this.smallScale;
const end = this.polarCoordinates2canvasCoordinates(insideR - len, radian);
ctx.beginPath();
ctx.save();
if (0 == scale % 5) {
ctx.lineWidth = 3;
if (hours && hours.length == 12) {
const hourIndex = scale / 5;
//绘制刻度值
this.drawHours(ctx, hourIndex, hours[hourIndex], end);
}
}
else {
ctx.lineWidth = 1;
}
ctx.strokeStyle = scaleColor;
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
ctx.restore();
}
}
//绘制刻度值
drawHours(ctx, i, hour, end) {
ctx.save();
ctx.fillStyle = this.options.hourColor;
ctx.font = `${this.hourFontSize}px 微软雅黑`;
var w = ctx.measureText(hour).width;
var h = this.hourFontSize;
var { x, y } = end;
//i为 0-11 对应1-12个小时数字(12开始,11结束)
var padding = 5;
switch (i) {
case 0: //12
x -= w * 0.5;
y += h;
break;
case 1:
x -= w;
y += h;
break;
case 2:
x -= w + padding;
y += h - padding;
break;
case 3:
x -= w + padding;
y += h * 0.5;
break;
case 4:
x -= w + padding;
break;
case 5:
x -= w;
break;
case 6:
x -= w * 0.5;
y -= padding;
break;
case 8:
x += padding;
break;
case 9:
x += padding;
y += h * 0.5;
break;
case 10:
x += padding;
y += h - padding;
break;
case 11:
y += h;
break;
}
ctx.fillText(hour, x, y);
ctx.restore();
}
//绘制时针、分针、秒针
drawHand(ctx, time = new Date()) {
let { secondHandColor, minuteHandColor, hourHandColor } = this.options;
/*
* 一圈被分、秒成分了60份,每一份的度数为:6度 转换成弧度:Math.PI/30
* 一圈被时成了12份,每一份的度数为:30度 转换成弧度:Math.PI/6
* 分针每走完一圈,时针就会慢慢过度到一个大刻度,
* 那么分针每走一个小刻度,时针在每个大刻度(大刻度之间的度数为30度)之间过度的角度为:30/60 = 0.5度 转换成弧度:Math.PI/360
*/
const radHour = time.getHours() * Math.PI / 6 + time.getMinutes() * Math.PI / 360;
//绘制时针
this.drawNeedle(ctx, radHour , hourHandColor, this.hourHandLen);
//绘制分针
this.drawNeedle(ctx, time.getMinutes() * Math.PI / 30, minuteHandColor, this.minuteHandLen);
//绘制秒针
this.drawNeedle(ctx, time.getSeconds() * Math.PI / 30, secondHandColor, this.secondHandLen);
}
//绘制指针
drawNeedle(ctx, radian, color, len) {
const start = this.polarCoordinates2canvasCoordinates(-this.headLen, radian);
const end = this.polarCoordinates2canvasCoordinates(len, radian);
ctx.beginPath();
ctx.save();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.strokeStyle = color;
if (len == this.hourHandLen) {//若是时针,宽度要粗点
ctx.lineWidth = 3;
}
else if (len == this.minuteHandLen) {//若是分针,宽度要细点
ctx.lineWidth = 2;
}
ctx.stroke();
if (len == this.secondHandLen) {
ctx.beginPath();
ctx.fillStyle = color;
//表盘中心圆点
ctx.arc(0, 0, 3, 0, 2 * Math.PI);
ctx.fill();
ctx.beginPath();
//秒针针尾圆点
const { x, y } = this.polarCoordinates2canvasCoordinates(len - 10, radian);
ctx.arc(x, y, 2, 0, 2 * Math.PI);
ctx.fill();
}
ctx.restore();
}
//显示一个时间
show(time) {
const { size, borderImage, backgroundImage } = this.options;
const { ctx, hourFontSize } = this;
this.ctx.clearRect(-this.halfSize, -this.halfSize, size, size);
if ((borderImage && !this.borderPattern) || (backgroundImage && !this.backgroundImage)) {
ctx.save();
ctx.font = `${hourFontSize}px 微软雅黑`;
ctx.fillText("loading...", this.halfSize, this.halfSize);
ctx.stroke();
return;
}
//表盘
ctx.drawImage(this.dialCanvas, -this.halfSize, -this.halfSize);
if ("string" == typeof time) {
if (!/^\d{1,2}(:\d{1,2}){2}$/.test(time)) {//正则表达式匹配格式hh:mm:ss
throw new Error("参数格式:HH:mm:ss");
}
let [h, m, s] = time.split(":").map(o => parseInt(o));
time = new Date();
time.setHours(h);
time.setMinutes(m);
time.setSeconds(s);
}
//时针
this.drawHand(ctx, time);
return this;
}
//运行模拟时钟
run() {
this.show();
if (!this.interval) {
this.interval = setInterval(() => {
this.show();
}, 1000);
}
return this;
}
//停止模拟时钟
stop() {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
- 使用
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>例子</title>
<!--[if lt IE 10]>
<script src="https://as.alipayobjects.com/g/component/??console-polyfill/0.2.2/index.js,es5-shim/4.5.7/es5-shim.min.js,es5-shim/4.5.7/es5-sham.min.js,es6-shim/0.35.1/es6-sham.min.js,es6-shim/0.35.1/es6-shim.min.js,html5shiv/3.7.2/html5shiv.min.js,media-match/2.0.2/media.match.min.js"></script>
<![endif]-->
<script src="https://as.alipayobjects.com/g/component/??es6-shim/0.35.1/es6-sham.min.js,es6-shim/0.35.1/es6-shim.min.js"></script>
<script type="text/javascript" src="clock.js"></script>
<style>
html,body{
padding: 0;
margin: 0;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>
</head>
<body>
<canvas id="demo1"></canvas>
<canvas id="demo2"></canvas>
<canvas id="demo3"></canvas>
<script type="text/javascript">
//参数1可传入dom对象,options可不传
new Clock(document.getElementById("demo1")).run();
//参数1可传入dom id值,参数2可传入自己想要的样式
new Clock("demo2", {
scaleType: "roman",//显示罗马数字
borderColor: "brown",//边框颜色
backgroundColor: "black",//表盘背景色
hourHandColor: "white",//时针颜色
minuteHandColor: "white",//分针颜色
hourColor: "white",//小时数字颜色
scaleColor: "yellow"//刻度线颜色
}).run();
//使用边框图
new Clock("demo3", {
borderImage: "https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1545553805386&di=ec656215a2958d617ef30631e96304e0&imgtype=0&src=http%3A%2F%2Fimg1.ali213.net%2Fshouyou%2Fupload%2Fimage%2F2018%2F07%2F09%2F584_2018070952816881.png",
backgroundImage: "https://timgsa.baidu.com/timg?image&quality=80&size=b9999_10000&sec=1545553773235&di=1c768f80fc088c2edc20fa75af77c515&imgtype=0&src=http%3A%2F%2Fb-ssl.duitang.com%2Fuploads%2Fitem%2F201607%2F03%2F20160703164252_2WySB.jpeg"
}).run();
</script>
</body>
</html>