如何使用h5 canvs实现一个模拟时钟

注: 本例使用es6语法编写.

本例效果图

image

思路

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>
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容