canvas圖表(3) - 餅圖

Jeff.Zhong發表於2017-11-23

原文地址:canvas圖表(3) - 餅圖
這幾天把canvas圖表都優化了下,動畫效果更加出色了,可以說很逼近Echart了。剛剛寫完的餅圖,非常好的實現了既定的功能,互動的動畫效果也是很棒的。

效果請看:餅圖https://edwardzhong.github.io/sites/demo/dist/chartpie.html
canvas圖表(3) - 餅圖

功能點包括:

  1. 組織資料;
  2. 畫面繪製;
    3. 資料動畫的實現;
    4. 滑鼠事件的處理。

使用方式

餅圖的資料方面要簡單很多,因為不用多個分組的資料。把所有的資料相加得出總數,然後每個資料分別求出百分比,有了百分比再相乘360度的弧度得出每個資料在圓盤中對應的要顯示的角度。

    var con=document.getElementById('container');
    var pie=new Pie(con);
    pie.init({
        title:'網站使用者訪問來源',
        toolTip:'訪問來源',
        data:[
            {value:435, name:'直接訪問'},
            {value:310, name:'郵件營銷'},
            {value:234, name:'聯盟廣告'},
            {value:135, name:'視訊廣告'},
            {value:1548, name:'搜尋引擎'}
        ]
    });

程式碼結構

因為為了同時實現新增動畫和更新動畫,這次的程式碼結構經過了重構和優化,跟之前的有比較大的區別。

    class Line extends Chart{
        constructor(container){
            super(container);
        }
        // 初始化
        init(opt){

        }
        // 繫結事件
        bindEvent(){

        }
        // 顯示資訊
        showInfo(pos,arr){

        }
        // 清除內容再繪製
        clearGrid(index){

        }
        // 執行資料動畫
        animate(){

        }
        // 執行
        create(){

        }
        // 組織資料
        initData(){

        }
        // 繪製
        draw(){

        }
    }

組織資料

這次把組織資料的功能單獨拎了出來,這樣方便重用和修改。然後還要給動畫物件增加是否建立的屬性create和上次最後更新的度數last,為什麼呢?因為我們要同時實現建立和更新圖形的動畫效果。

    initData(){
        var that=this,
            item,
            total=0;
        if(!this.data||!this.data.length){return;}
        this.legend.length=0;
        for(var i=0;i<this.data.length;i++){
            item=this.data[i];
            // 賦予沒有顏色的項
            if(!item.color){
                var hsl=i%2?180+20*i/2:20*(i-1);
                item.color='hsla('+hsl+',70%,60%,1)';
            }
            item.name=item.name||'unnamed';

            this.legend.push({
                hide:!!item.hide,
                name:item.name,
                color:item.color,
                x:50,
                y:that.paddingTop+40+i*50,
                w:80,
                h:30,
                r:5
            });

            if(item.hide)continue;
            total+=item.value;
        }

        for(var i=0;i<this.data.length;i++){
            item=this.data[i];
            if(!this.animateArr[i]){//建立
                this.animateArr.push({
                    i:i,
                    create:true,
                    hide:!!item.hide,
                    name:item.name,
                    color:item.color,
                    num:item.value,
                    percent:Math.round(item.value/total*10000)/100,
                    ang:Math.round(item.value/total*Math.PI*2*100)/100,
                    last:0,
                    cur:0
                });
            } else {//更新                
                if(that.animateArr[i].hide&&!item.hide){
                    that.animateArr[i].create=true;
                    that.animateArr[i].cur=0;
                } else {
                    that.animateArr[i].create=false;
                }
                that.animateArr[i].hide=item.hide;
                that.animateArr[i].percent=Math.round(item.value/total*10000)/100;
                that.animateArr[i].ang=Math.round(item.value/total*Math.PI*2*100)/100;
            }
        }
    }

繪製

餅圖的繪製功能很簡單,因為不用座標系,只需要繪製標題和標籤列表。

    draw(){
        var item,ctx=this.ctx;
        ctx.fillStyle='hsla(0,0%,30%,1)';
        ctx.strokeStyle='hsla(0,0%,20%,1)';
        ctx.textBaseLine='middle';
        ctx.font='24px arial';
        
        ctx.clearRect(0,0,this.W,this.H);
        if(this.title){
            ctx.save();
            ctx.textAlign='center';
            ctx.font='bold 40px arial';
            ctx.fillText(this.title,this.W/2,70);
            ctx.restore();
        }
        ctx.save();
        for(var i=0;i<this.legend.length;i++){
            item=this.legend[i];
            // 畫分類標籤
            ctx.textAlign='left';
            ctx.fillStyle=item.color;
            ctx.strokeStyle=item.color;
            roundRect(ctx,item.x,item.y,item.w,item.h,item.r);
            ctx.globalAlpha=item.hide?0.3:1;
            ctx.fill();
            ctx.fillText(item.name,item.x+item.w+20,item.y+item.h-5);
        }
        ctx.restore();
    }

執行繪製餅圖動畫

動畫區分了建立和更新,這樣使用者很容易就能看出資料的比例關係變化,也就更加的直觀。建立就是從0弧度到指定的弧度,只有數值的增加;而更新動畫就要區分增加和減少的情況,因為當使用者點選某個標籤的時候,會隱藏顯示某個分類的資料,於是需要重新計算每個分類的比例,那麼相應的分類百分比就會增加或減少。我們根據當前最新要達到的比例ang和已經執行完的當前比例last的進行對比,相應執行增加和減少比例,動畫原理就是這樣。

canvas繪製圓形context.arc(x,y,r,sAngle,eAngle,counterclockwise);只要我們指定開始角度和結束角度就會畫出披薩餅一樣的效果,所有的披薩餅加起來就是一個圓。

    animate(){
        var that=this,
            ctx=that.ctx,
            canvas=that.canvas,
            item,startAng,ang,
            isStop=true;

        (function run(){
            isStop=true;
            ctx.save();
            ctx.translate(that.W/2,that.H/2);
            ctx.fillStyle='#fff';
            ctx.beginPath();
            ctx.arc(0,0,that.H/3+30,0,Math.PI*2,false);
            ctx.fill();
            for(var i=0,l=that.animateArr.length;i<l;i++){
                item=that.animateArr[i];
                if(item.hide)continue;
                startAng=-Math.PI/2;
                that.animateArr.forEach((obj,j)=>{
                    if(j<i&&!obj.hide){startAng+=obj.cur;}
                });

                ctx.fillStyle=item.color;
                if(item.create){//建立動畫
                    if(item.cur>=item.ang){
                        item.cur=item.last=item.ang;
                    } else {
                        item.cur+=0.05;
                        isStop=false;
                    }
                } else {//更新動畫
                    if(item.last>item.ang){
                        ang=item.cur-0.05;
                        if(ang<item.ang){
                            item.cur=item.last=item.ang;
                        }
                    } else {
                        ang=item.cur+0.05;
                        if(ang>item.ang){
                            item.cur=item.last=item.ang;
                        }
                    }
                    if(item.cur!=item.ang){
                        item.cur=ang;
                        isStop=false;
                    }
                }

                ctx.beginPath();
                ctx.moveTo(0,0);
                ctx.arc(0,0,that.H/3,startAng,startAng+item.cur,false);
                ctx.closePath();
                ctx.fill();
            }
            ctx.restore();
            if(isStop) {
                that.clearGrid();
                return;
            }
            requestAnimationFrame(run);
        }());
    }

互動處理

執行完動畫後,我這裡再執行了一遍清除繪製,這個也是滑鼠觸控標籤和餅圖時的對應動畫方法,會繪製每個分類的名稱描述,更方便使用者檢視。

    clearGrid(index){
        var that=this,
            ctx=that.ctx,
            canvas=that.canvas,
            item,startAng=-Math.PI/2,
            len=that.animateArr.filter(item=>!item.hide).length,
            j=0,angle=0,
            r=that.H/3;
        ctx.clearRect(0,0,that.W,that.H);
        that.draw();
        ctx.save();
        ctx.translate(that.W/2,that.H/2);

        for(var i=0,l=that.animateArr.length;i<l;i++){
            item=that.animateArr[i];
            if(item.hide)continue;
            ctx.strokeStyle=item.color;
            ctx.fillStyle=item.color;
            angle=j>=len-1?Math.PI*2-Math.PI/2:startAng+item.ang;
            ctx.beginPath();
            ctx.moveTo(0,0);
            if(index===i){
                ctx.save();
                // ctx.shadowColor='hsla(0,0%,50%,1)';
                ctx.shadowColor=item.color;
                ctx.shadowBlur=5;
                ctx.arc(0,0,r+20,startAng,angle,false);
                ctx.closePath();
                ctx.fill();
                ctx.stroke();
                ctx.restore();
            } else {
                ctx.arc(0,0,r,startAng,angle,false);
                ctx.closePath();
                ctx.fill();
            }
            //畫分類描述
            var tr=r+40,tw=0,
                tAng=startAng+item.ang/2,
                x=tr*Math.cos(tAng),
                y=tr*Math.sin(tAng);

            ctx.lineWidth=2;
            ctx.lineCap='round';
            ctx.beginPath();
            ctx.moveTo(0,0);
            ctx.lineTo(x,y);
            if(tAng>=-Math.PI/2&&tAng<=Math.PI/2){
                ctx.lineTo(x+30,y);
                ctx.fillText(item.name,x+40,y+10);
            } else {
                tw=ctx.measureText(item.name).width;//計算字元長度
                ctx.lineTo(x-30,y);
                ctx.fillText(item.name,x-40-tw,y+10);
            }
            
            ctx.stroke();
            startAng+=item.ang;
            j++;
        }
        ctx.restore();
    }

事件處理

mousemove的時候,觸控標籤和觸控餅圖都是基本相同的效果,選中的分類擴大半徑,同時增加陰影,以達到凸出來的動畫效果,具體實現請看上面的clearGrid方法。判斷是否點中都是使用isPointInPath這個api,之前已經介紹過,不再細講。

mousedown某個擊標籤就會顯示隱藏對應分類,每次觸發就會看到餅圖的比例變化的動畫效果,這個和之前的柱狀圖和折線圖的功能一致。

    bindEvent(){
        var that=this,
            canvas=that.canvas,
            ctx=that.ctx;
        if(!this.data.length) return;
        this.canvas.addEventListener('mousemove',function(e){
            var isLegend=false;
            var box=canvas.getBoundingClientRect(),
                pos = {
                x:e.clientX-box.left,
                y:e.clientY-box.top
            };
            // 標籤
            for(var i=0,item,len=that.legend.length;i<len;i++){
                item=that.legend[i];
                roundRect(ctx,item.x,item.y,item.w,item.h,item.r);
                // 因為縮小了一倍,所以座標要*2
                if(ctx.isPointInPath(pos.x*2,pos.y*2)){
                    canvas.style.cursor='pointer';
                    if(!item.hide){
                        that.clearGrid(i);
                    }
                    isLegend=true;
                    break;
                }
                canvas.style.cursor='default';
                that.tip.style.display='none';
            }

            if(isLegend) return;
            // 圖表
            var startAng=-Math.PI/2;
            for(var i=0,l=that.animateArr.length;i<l;i++){
                item=that.animateArr[i];
                if(item.hide)continue;
                ctx.beginPath();
                ctx.moveTo(that.W/2,that.H/2);
                ctx.arc(that.W/2,that.H/2,that.H/3,startAng,startAng+item.ang,false);
                ctx.closePath();
                startAng+=item.ang;
                if(ctx.isPointInPath(pos.x*2,pos.y*2)){
                    canvas.style.cursor='pointer';
                    that.clearGrid(i);
                    that.showInfo(pos,that.toolTip,[{name:item.name,num:item.num+' ('+item.percent+'%)'}]);
                    break;
                }
                canvas.style.cursor='default';
                that.clearGrid();
            }

        },false);
        this.canvas.addEventListener('mousedown',function(e){
            e.preventDefault();
            var box=that.canvas.getBoundingClientRect();
            var pos = {
                x:e.clientX-box.left,
                y:e.clientY-box.top
            };
            for(var i=0,item,len=that.legend.length;i<len;i++){
                item=that.legend[i];
                roundRect(ctx,item.x,item.y,item.w,item.h,item.r);
                if(ctx.isPointInPath(pos.x*2,pos.y*2)){
                    that.data[i].hide=!that.data[i].hide;
                    that.create();
                    break;
                }
            }
        },false);

    }

最後

所有圖表程式碼請看chart.js

相關文章