原文地址:canvas圖表(3) - 餅圖
這幾天把canvas圖表都優化了下,動畫效果更加出色了,可以說很逼近Echart了。剛剛寫完的餅圖,非常好的實現了既定的功能,互動的動畫效果也是很棒的。
效果請看:餅圖https://edwardzhong.github.io/sites/demo/dist/chartpie.html
功能點包括:
- 組織資料;
- 畫面繪製;
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