canvas实现自定义动画圆角环状饼图

1,680 阅读1分钟

由于业务需求,echart并不能满足圆角环状饼图,故自定义实现之。效果如下:

js代码:

    function drawCircle(id,arr,options,colors = ["#007FDD", "#379CDD", "#63BAF2", "#C3DCED", "#E9F6FF"]) {
        const {radius, lng, title, titleColor, horizon} = options;
        let dom = document.getElementById(id);
        dom.innerHTML="";
        let c = document.createElement('canvas');
        c.height = dom.clientHeight;
        c.width = dom.clientWidth;
        let x = dom.clientWidth / 2;
        let y = dom.clientHeight / 2;
        dom.appendChild(c);
        let ctx = c.getContext("2d");
        //求总和
        let sum = 0;
        for(let i=0;i<arr.length;i++){
            let item = arr[i];
            sum += item.value;
        }
        //绘制标题
        ctx.beginPath();
        ctx.fillStyle=titleColor;
        //文字居中
        ctx.textAlign = 'center';
        //相对原点垂直居中
        ctx.textBaseline="middle";
        //文字样式:加粗 16像素 字体Arial
        ctx.font = 'normal 18px 微软雅黑';
        ctx.fillText(title,x,y);
        //累计角度
        let sumAngle = 0;
        //设置开始的位置
        let start = 1.5*Math.PI;
        function drawLine(ctx, angle, color, item, rate) {
            let label = item.name + ":"+rate +"%";
            //累计角度 - 当前的一半角度
            let bjAngle = sumAngle-angle;
            ctx.beginPath();
            ctx.globalCompositeOperation = 'destination-over';
            let chgAngle = 0.017453293 * bjAngle;
            //设置起点状态
            let tmp1 = {
                x: x-radius*Math.sin(chgAngle),
                y: y-radius*Math.cos(chgAngle)
            };
            //设置末端状态
            let tmp2 = {
                x: x-lng*Math.sin(chgAngle),
                y: y-lng*Math.cos(chgAngle)
            };
            ctx.moveTo (tmp1.x, tmp1.y);
            ctx.lineTo (tmp2.x, tmp2.y);
            //设置线宽状态
            ctx.lineWidth = 1;

            let tmp3 = {};
            let tmp4 = {};
            //向左绘制横线
            if(bjAngle<180){
                tmp3.x = tmp2.x-horizon;
                tmp3.y = tmp2.y;
                tmp4.x = tmp3.x-6*label.length;
                tmp4.y = tmp3.y;
            }else{
                tmp3.x = tmp2.x+horizon;
                tmp3.y = tmp2.y;
                tmp4.x = tmp3.x+6*label.length;
                tmp4.y = tmp3.y;
            }
            ctx.lineTo (tmp3.x, tmp3.y);
            //进行绘制
            ctx.stroke();
            ctx.beginPath();
            ctx.fillStyle = color;
            ctx.arc(tmp3.x, tmp3.y, 2, 0, 2*Math.PI, true);
            ctx.fill();

            //绘制标题
            ctx.beginPath();
            ctx.fillStyle= '#fff';
            //相对原点垂直居中
            ctx.textBaseline="middle";
            //文字样式:加粗 16像素 字体Arial
            ctx.font = 'normal 12px 微软雅黑';
            ctx.fillText(label,tmp4.x, tmp4.y);

            if(item.name1){
                //绘制标题
                ctx.beginPath();
                ctx.fillStyle= '#CDFFEF';
                //相对原点垂直居中
                ctx.textBaseline="middle";
                //文字样式:加粗 16像素 字体Arial
                ctx.font = 'normal 12px 微软雅黑';
                ctx.fillText("("+item.name1+")",tmp4.x, tmp4.y+12);
            }
        }

        //动画
        function sleep(ms, callback) {
            setTimeout(callback, ms)
        }
        let lastItem = arr[arr.length-1];
        for(let key=0;key<arr.length;key++){
            let item = arr[key];
            let rate = item.value/sum;
            let curColor = colors[key];
            sleep(80*(key+1), function () {
                ctx.beginPath();
                ctx.lineWidth = 20;
                ctx.strokeStyle = curColor;
                if(item.value==lastItem.value&&item.name==lastItem.name){
                    //先渲染一半
                    let add = rate*Math.PI;
                    ctx.arc(x,y,radius, start, start-add, true);
                    ctx.lineCap = 'round';
                    ctx.globalCompositeOperation = 'source-over'
                    start -= add;
                    ctx.stroke();
                    ctx.beginPath();
                    ctx.globalCompositeOperation = 'destination-over';
                    ctx.arc(x,y,radius, start, start-add, true);
                    ctx.lineCap = 'butt';
                    ctx.stroke();
                } else {
                    let add = rate*2*Math.PI;
                    ctx.arc(x,y,radius, start, start-add, true);
                    ctx.lineCap = 'round';
                    start -= add;
                    ctx.stroke();
                }
                sumAngle += rate*180*2;
                let angle = rate*180;
                drawLine(ctx, angle, colors[key], item, Math.round(rate*100));
                ctx.globalCompositeOperation = 'source-over'
            })
        }
    }

使用:

    const options = {
        radius: 50,
        lng: 80,
        title: 'CPU',
        titleColor: '#73FBFD',
        horizon: 10
    };
    const arr = [{name1: '全辖:30%',name: '工作', value: 8},{name1: '全辖:30%',name: '看书', value: 4},{name1: '全辖:30%',name: '娱乐', value: 2}, {name1: '全辖:30%',name: '睡觉', value: 8},{name1: '全辖:30%',name: '吃饭', value: 3}];
    drawCircle('myCanvas', arr,options)

方法说明

  • 主要使用的是lineWidth宽度来形成环形;
  • ctx.lineCap = 'round'属性来实现圆角;
  • 对于最后一个数据的渲染要分两半,一半不是圆角,另一半设置圆角,并ctx.globalCompositeOperation = 'destination-over'来实现最后一部分圆弧的圆角不被覆盖;
  • 引导线绘制的时候要分左右两边;并且引导线的位置是从每段圆弧中间出发绘制的;
  • 动画部分的实现比较简单粗暴,利用每个数据作为分割点,一个数据一个数据的绘制就出现了动画的效果;
  • 兼容性:目前只兼容了IE8及以上。

优化

  • 对于最后一个数据的渲染要分两半,一半不是圆角,另一半设置圆角,并ctx.globalCompositeOperation = 'destination-over'来实现最后一部分圆弧的圆角不被覆盖;(这种方法会出现圆弧很小的时候,覆盖不明显的情况)如下图:

解决方法是:记录最大的数据,从最大数据开始从新绘制一遍圆弧。

  • 还有一个问题就是label会挤在一起。 解决方法是:先计算出label绘制的位置,动态调整,直到满足条件,再绘制。
  1. 对于这些label,首先确定它的外包矩形。 主要是宽度和高度的计算: 高度:字体大小 * 2 宽度:计算文字个数 * 字体大小 文字个数:中文是1,非中文为 0.5 计算方法:
    //统计汉字https://www.cnblogs.com/jkr666666/p/11645070.html
    function getByteLen(val) {
    	let len = 0;
    	for (let i = 0; i < val.length; i++) {
            let a = val.charAt(i);
    		if (a.match(/[^\x00-\xff]/ig) != null) {
    			len += 1;
    		} else {
    			len += 0.5;
    		}
    	}
    	return len;
    }

根据高度和宽度以及圆点的位置就可以求出矩形4个点的坐标。

  1. 计算2个矩形相交的算法
    参考:www.jianshu.com/p/a2d881847…
    满足下面3个条件:
    max(Xa1,Xb1) <= min(Xa2,Xb2)
    max(Ya1,Yb1) <= min(Ya2,Yb2)
    相交矩形面积 > 0

  2. 如果两个或多个矩形相交了,动态调整矩形的位置。这个时候分了左边和右边两种情况。

对于左边的圆弧,记录相交的两个或多个矩形坐标;保持第一个矩形4个点位置不变,根据矩形的高度,依次向下(Y轴坐标变大)动态调整其余矩形的坐标位置。重新遍历左边圆弧矩形,直到不出现彼此相交的矩形为止。
对于右边的圆弧类似。
Git地址:github.com/YY88Xu/draw…
效果图: