高仿一個echarts餅圖

語言: CN / TW / HK

開頭

餅圖,很常見的一種圖表,使用任何一個圖表庫都能輕鬆的渲染出來,但是,我司的交互想法千奇百怪,佈局捉摸不透,本身餅圖是沒啥可變的,但是配套的圖例千變萬化,翻遍ECharts配置文檔都還原不出來,那麼有兩條路可以選,一是跟交互説實現不了,説服交互按圖表庫的佈局來,但是一般交互可能會對你靈魂拷問,為什麼別人都能做出來,你做不出來?所以我選第二種,自己做一個得了。

canvas實現一個餅圖很簡單,所以本文在介紹使用vue高仿一個ECharts餅圖的實現過程中會順便回顧一下canvas的一些知識點,先來看一下本次的成果:

佈局及初始化工作

佈局很簡單,一個div容器,一個canvas元素即可。

<template>
  <div class="chartContainer" ref="container">
    <canvas ref="canvas"></canvas>
  </div>
</template>
複製代碼

容器的寬高寫死,canvas的寬高需要通過本身的屬性widthheight來設置,最好不要使用css來設置,因為canvas畫布默認的寬高是300*150,使用css不會改變畫布原始的寬高,而是會將其拉伸到你設置的css寬高,所以會出現變形的問題。

// 設置為容器寬高
let { width, height } = this.$refs.container.getBoundingClientRect()
let canvas = this.$refs.canvas
canvas.width = width
canvas.height = height
複製代碼

繪圖的api都是掛在canvas的繪圖上下文中,所以先獲取一下:

this.ctx = canvas.getContext("2d")
複製代碼

canvas座標系默認的原點在左上角,餅圖的繪製一般都是在畫布中間,所以每次繪製圓弧的時候圓心都要換算一下設置到畫布的中心點,這個示例中只要換算一箇中心點並不麻煩,但是如果在更復雜的場景,所有都要換算是很麻煩的,所以為了避免,可以使用translate方法將畫布的座標系原點設置到畫布中心點:

this.centerX = width / 2
this.centerY = height / 2
this.ctx.translate(this.centerX, this.centerY)
複製代碼

接下來需要計算一下餅圖的半徑,畫的太滿不太好看,所以暫定為畫布區域短邊一半的90%:

this.radius = Math.min(width, height) / 2 * 0.9
複製代碼

最後看一下要渲染的數據的結構:

this.data = [
    {
        name: '名稱',
        num: 10,
        color: ''// 顏色
    },
    // ...
]
複製代碼

餅圖

餅圖其實就是一堆面積不一的扇形組成的一個圓,畫圓和扇形都是使用arc方法,它有6個參數,分別是圓心x、圓心y、半徑r、圓弧起點弧度、圓弧終點弧度、逆時針還是順時針繪製。

扇形的面積代表數據的佔比,可以用角度的佔比來表示,那就需要轉成弧度,角度轉弧度公式為:弧度=角度*(Math.PI/180)

// 遍歷數據進行轉換,total是所有數據的數量總和
let curTotalAngle = 0
let r = Math.PI / 180
this.data.forEach((item, index) => {
    let curAngle = (item.num / total) * 360
    let cruEndAngle = curTotalAngle + curAngle
    this.$set(this.data[index], 'angle', [curTotalAngle, cruEndAngle])// 角度
    this.$set(this.data[index], 'radian', [curTotalAngle * r, cruEndAngle * r])// 弧度
    curTotalAngle += curAngle
});
複製代碼

轉換為弧度之後再遍歷angleData來進行扇形繪製:

// 函數renderPie
this.data.forEach((item, index) => {
    this.ctx.beginPath()
    this.ctx.moveTo(0, 0)
    this.ctx.fillStyle = item.color
    let startRadian = item.radian[0] - Math.PI/2
    let endRadian = item.radian[1] - Math.PI/2
    this.ctx.arc(0, 0, this.radius, startRadian, endRadian)
    this.ctx.fill()
});
複製代碼

效果如下:

beginPath方法用來開始一段新的路徑,它會把當前路徑的所有子路徑都給清除掉,否則調用fill方法閉合路徑時會把所有的子路徑都首尾連接起來,那不是我們要的。

另外這裏使用moveTo方法將這個新路徑的起點移到了座標原點,為什麼要這樣可以先看不這樣的效果:

原因是因為arc方法只是繪製一段圓弧,所以把它的首尾相連就是上述效果,但是扇形是需要這段圓弧和圓心一起閉合,arc方法調用時如果當前路徑上已經存在子路徑會用一段線段把當前子路徑的終點和這段圓弧的起點連接起來,所以我們先把路徑的起點移到圓心,這樣最後閉合現成的就是一個扇形。

至於為什麼起始弧度和結束弧度都減了Math.PI/2,是因為0弧度是在x軸的正方向,也就是右邊,但是一般我們認為的起點在頂部,所以減掉1/4圓讓它的起點移到頂部。

動畫

我們在使用ECharts餅圖的時候會發現它渲染的時候是會有一小段動畫的:

canvas實現動畫的基本原理就是不斷改變繪圖數據,然後不斷刷新畫布,聽起來像是廢話,所以一種實現方式是動態修改當前繪製結束的圓弧的弧度,從0一直變化到2*Math.PI,這樣就可以實現這個慢慢變多的效果,但是這裏我們使用另外一種,用clip方法。

clip用來在當前路徑中創建一個剪裁路徑,剪裁之後,後續繪製的信息只會出現在該剪裁路徑內。基於此,我們可以創建一個從0弧度變化到2*Math.PI弧度的扇形剪裁區域,即可實現這個動畫效果。

先看一下清除畫布的方法:

this.ctx.clearRect(-this.centerX, -this.centerY, this.width, this.height)
複製代碼

clearRect方法用來清除以(x,y)為起點,寬widthheight範圍內的所有已經繪製的內容。清除原理就是將這個範圍內的像素都設置成透明,因為原點被我們移到了畫布中心,所以畫布左上角是(-this.centerX, -this.centerY)。

開源社區有很多動畫庫可以選擇,但是因為我們只需要一個簡單的動畫函數,引入一個庫沒必要,所以自己簡單寫一個就好了。

// 動畫曲線函數,更多函數可參考:http://robertpenner.com/easing/
// t: current time, b: begInnIng value, c: change In value, d: duration
const ease = {
    // 彈跳
    easeOutBounce(t, b, c, d) {
        if ((t /= d) < (1 / 2.75)) {
            return c * (7.5625 * t * t) + b;
        } else if (t < (2 / 2.75)) {
            return c * (7.5625 * (t -= (1.5 / 2.75)) * t + .75) + b;
        } else if (t < (2.5 / 2.75)) {
            return c * (7.5625 * (t -= (2.25 / 2.75)) * t + .9375) + b;
        } else {
            return c * (7.5625 * (t -= (2.625 / 2.75)) * t + .984375) + b;
        }
    },
    // 慢進慢出
    easeInOut(t, b, c, d) {
        if ((t /= d / 2) < 1) return c / 2 * t * t * t + b
        return c / 2 * ((t -= 2) * t * t + 2) + b
    }
}
/*
	動畫函數
	from:起始值
	to:目標值
	dur:過渡時間,ms
	callback:實時回調函數
	done:動畫結束的回調函數
	easing:動畫曲線函數
*/
function move(from, to, dur = 500, callback = () => {}, done = () => {}, easing = 'easeInOut') {
    let difference = to - from
    let startTime = Date.now()
    let isStop = false
    let timer = null
    let run = () => {
        if (isStop) {
            return false
        }
        let curTime = Date.now()
        let durationTime = curTime - startTime
        // 調用緩動函數來計算當前的比例
        let ratio = ease[easing](durationTime, 0, 1, dur)
        ratio = ratio > 1 ? 1 : ratio
        let step = difference * ratio + from
        callback && callback(step)
        if (ratio < 1) {
            timer = window.requestAnimationFrame(run)
        } else {
            done && done()
        }
    }
    run()
    return () => {
        isStop = true
        cancelAnimationFrame(timer)
    }
}
複製代碼

有了動畫函數就可以很方便實現扇形的變化:

// 從-0.5到1.5的原因和上面繪製扇形時減去Math.PI/2一樣
move(-0.5, 1.5, 1000, (cur) => {
    this.ctx.save()
    // 繪製扇形剪切路徑
    this.ctx.beginPath()
    this.ctx.moveTo(0, 0)
    this.ctx.arc(
        0,
        0,
        this.radius,
        -0.5 * Math.PI,
        cur * Math.PI// 結束圓弧不斷變大
    )
    this.ctx.closePath()
    // 剪切完後進行繪製
    this.ctx.clip()
    this.renderPie()
    this.ctx.restore()
});
複製代碼

效果如下:

這裏使用了saverestore方法,save方法用來將當前的繪圖狀態保存起來,你在之後如果修改了狀態再調用restore方法可以又恢復到之前保存的狀態,這兩個方法是通過棧來進行保存,所以可以保存多個,只要restore方法正確對應上,在canvas中,繪圖狀態包括:當前的變換矩陣、當前的剪切區域、當前的虛線列表,繪圖樣式屬性。

這裏要使用這兩個方法是因為如果當前已經存在裁剪區域,再調用clip方法時會將剪切區域設置為當前裁剪區域和當前路徑的交集,所以剪切區域可能會越來越小,保險起見,在使用clip方法時都將它放在saverestore方法之間。

鼠標移上的突出顯示

ECharts的餅圖還有一個效果就是鼠標移上去所在的扇形會突出顯示,其實也是一個小動畫,突出的原理實際上就是這個扇形的半徑變大了,按之前的套路,只要把半徑的變化值交給動畫函數跑一下就可以了。

不過這之前需要先要知道鼠標移到了哪個扇形上,先給元素綁定一下鼠標移動事件:

<template>
  <div class="chartContainer" ref="container">
    <canvas ref="chart" @mousemove="onCanvasMousemove"></canvas>
  </div>
</template>
複製代碼

獲取一個座標點是否在某個路徑內可以使用isPointInPath,該方法可以檢測某個點是否在當前的路徑內,注意,是當前路徑。所以我們可以在之前的遍歷繪製扇形的循環方法里加上這個檢測:

renderPie (checkHover, x, y) {
    let hoverIndex = null// ++
    this.data.forEach((item, index) => {
        this.ctx.beginPath()
        this.ctx.moveTo(0, 0)
        this.ctx.fillStyle = item.color
        let startRadian = item.radian[0] - Math.PI/2
        let endRadian = item.radian[1] - Math.PI/2
        this.ctx.arc(0, 0, this.radius, startRadian, endRadian)
        // this.ctx.fill();--
        // ++
        if (checkHover) {
            if (hoverIndex === null && this.ctx.isPointInPath(x, y)) {
                hoverIndex = index
            }
        } else {
            this.ctx.fill()
        }
    })
    // ++
    if (checkHover) {
        return hoverIndex
    }
}
複製代碼

那麼在onCanvasMousemove方法裏要做的就是計算一下上面的(x,y),然後調用一下這個方法:

onCanvasMousemove(e) {
    let rect = this.$refs.canvas.getBoundingClientRect()
    let x = e.clientX - rect.left
    let y = e.clientY - rect.top
    // 檢測當前所在扇形
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
}
複製代碼

獲取到所在的扇形索引後就可以讓該扇形的半徑動起來,半徑變大可以乘一個倍數,比如變大0.1倍,那我們就可以通過動畫函數讓這個倍數從0過渡到0.1,再修改上面的遍歷繪製扇形方法裏的半徑值,不斷刷新重繪即可。

不過在此之前,要先去上面定義的數據結構里加一個字段:

this.data = [
    {
        name: '名稱',
        num: 10,
        color: '',
        hoverDrawRatio: 0// 這個字段表示當前扇形繪製時的倍數
    },
    // ...
]
複製代碼

要給每個扇形都單獨加一個倍數字段的原因是同一時刻不一定只有一個扇形的倍數在變化,比如我從一個扇形快速移到另一個扇形,這個扇形的半徑在變大的同時前一個扇形的半徑還在恢復,所以是會同時變化的。

onCanvasMousemove(e) {
   	// ...
    // 檢測當前所在扇形
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
    // 讓倍數動起來
    if (this.curHoverIndex !== null) {
        move(
            this.data[hoverIndex].hoverDrawRatio,// 默認是0
            0.1,
            300,
            (cur) => {
                // 實時修改該扇形的倍數
                this.data[hoverIndex].hoverDrawRatio = cur
                // 重新繪製
                this.renderPie()
            },
            null,
            "easeOutBounce"// 參考ECharts,這裏選擇彈跳動畫
        )
    }
}
// 獲取鼠標移到的扇形索引
getHoverAngleIndex(x, y) {
    this.ctx.save()
    let index = this.renderPie(true, x, y)
    this.ctx.restore()
    return index
}
複製代碼

接下來改造繪製函數:

renderPie (checkHover, x, y) {
    let hoverIndex = null
    this.data.forEach((item, index) => {
        this.ctx.beginPath()
        this.ctx.moveTo(0, 0)
        this.ctx.fillStyle = item.color
        let startRadian = item.radian[0] - Math.PI/2
        let endRadian = item.radian[1] - Math.PI/2
        // this.ctx.arc(0, 0, this.radius, startRadian, endRadian)--
        // 半徑從寫死的修改成加上當前扇形的放大值
        let _radius = this.radius + this.radius * item.hoverDrawRatio
    	this.ctx.arc(0, 0, _radius, startRadian, endRadian)
        if (checkHover) {
            if (hoverIndex === null && this.ctx.isPointInPath(x, y)) {
                hoverIndex = index
            }
        } else {
            this.ctx.fill()
        }
    });
    if (checkHover) {
        return hoverIndex
    }
}
複製代碼

然而上面的代碼並不會實現預期的效果,有個問題需要解決。在同一個扇形裏面移動onCanvasMousemove會持續觸發並檢測到當前所在索引調用move方法,可能是一個動畫還沒結束,而且在同一個扇形裏移動只要動畫一次就夠了,所以需要做個判斷:

onCanvasMousemove(e) {
   	// ...
    this.curHoverIndex = this.getHoverAngleIndex(x, y)
    if (this.curHoverIndex !== null) {
        // 增加一個字段來記錄上一次所在的扇形索引
        if (this.lastHoverIndex !== this.curHoverIndex) {// ++
            this.lastHoverIndex = this.curHoverIndex// ++
            move(
                this.data[hoverIndex].hoverDrawRatio,
                0.1,
                300,
                (cur) => {
                    this.data[hoverIndex].hoverDrawRatio = cur
                    this.renderPie()
                },
                null,
                "easeOutBounce"
            )
        }
    } else {// ++
        this.lastHoverIndex = null
    }
}
複製代碼

最後加一下由大變回去的動畫方法,遍歷數據,判斷哪個扇形當前的放大倍數不為0,就給它加個動畫,這個方法的調用位置是在onCanvasMousemove函數裏,因為當你從一個扇形移到另一個扇形,或從圓內部移到外部都需要判斷是否要恢復:

resume() {
    this.data.forEach((item, index) => {
        if (
            index !== this.curHoverIndex &&// 當前鼠標所在的扇形不需要恢復
            item.hoverDrawRatio !== 0 &&// 當前扇形放大倍數不為0代表需要恢復
            this.data[index].stop === null// 因為這個方法會在鼠標移動過程中不斷調用,所以要判斷一下當前扇形是否已經在動畫中了,在的話就不需要重複進行了,stop字段同樣需要在上述的數據結構裏先添加一下
        ) {
            this.data[index].stop = move(
                item.hoverDrawRatio,
                0,
                300,
                (cur) => {
                    this.data[index].hoverDrawRatio = cur;
                    this.renderPie();
                },
                () => {
                    this.data[index].hoverDrawRatio = 0;
                    this.data[index].stop = null;
                },
                "easeOutBounce"
            );
        }
    });
},
複製代碼

效果如下:

環圖

環圖其實就是餅圖中間挖了個洞,同樣可以使用clip方法來實現,具體就是創建一個圓環路徑:

所謂圓環也就是一大一小兩個圓,但是這樣會存在兩個區域,一個是小圓內部區域,一個是小圓和大圓之間的區域,那麼clip方法怎麼知道剪切哪個區域呢,clip方法其實是有參數的,clip(fillRule),這個fillRule表示判斷一個點是在路徑內還是路徑外的算法類型,默認是使用非零環繞原則,還有一個是奇偶環繞原則,非零環繞原則很簡單,就是在某個區域向外畫一條線段,這條線段與路徑會有交叉點,和順時針的線段交叉時加1,和逆時針線段交叉了減1, 最後看計數器是否是0,是0就不填充,非0就填充。

如果我們使用兩個arc方法畫兩個圓形路徑,這裏我們需要填充的是這個圓環部分,所以從圓環裏向外畫一條線只有一個交叉點,那麼肯定會被填充,但是從小圓內部畫出的線段最終的計數器是1+1=2,不為0也會被填充,這樣就不是圓環而是一個大圓了,所以需要通過arc方法最後一個參數來設置其中一個圓形路徑為逆時針方向:

clipPath() {
    this.ctx.beginPath()
    this.ctx.arc(0, 0, this.radiusInner, 0, Math.PI * 2)// 內圓順時針
    this.ctx.arc(0, 0, this.radius, 0, Math.PI * 2, true)// 外圓逆時針
    this.ctx.closePath()
    this.ctx.clip()
}
複製代碼

這個方法在調用遍歷繪製扇形的方法renderPie之前調用:

// 包裝成新函數,之前所有調用renderPie進行繪製的地方都替換成drawPie
drawPie() {
    this.clear()
    this.ctx.save()
    // 裁剪圓環區域
    this.clipPath()
    // 繪製圓環
    this.renderPie()
    this.ctx.restore()
}
複製代碼

這樣會有個問題,就是這個剪切圓環的外圓半徑是radius,而如果某個扇形放大了那麼就顯示不了了,所以需要實時遍歷扇形數據來獲取到當前最大的半徑,可以使用計算屬性來做這件事:

{
    computed: {
        hoverRadius() {
            let max = null
            this.data.forEach((item) => {
                if (max === null) {
                    max = item.hoverDrawRatio
                } else {
                    if (item.hoverDrawRatio > max) {
                        max = item.hoverDrawRatio
                    }
                }
            })
            return this.radius + this.radius * max
        }
    }
}
複製代碼

效果如下:

可以看到上圖有個bug,就是鼠標移到內圓裏還是會觸發凸出的動畫效果,解決方法很簡單,在之前的getHoverAngleIndex方法裏我們先檢查一下鼠標是否移到了內圓,是的話就不就行後續扇形檢測了:

getHoverAngleIndex(x, y) {
    this.ctx.save();
    // 移到內圓環不觸發,創建一個內圓大小的路徑,調用isPointInPath方法進行檢測
    if (this.checkHoverInInnerCircle(x, y)) 
        return null;
    }
    let index = this.renderPie(true, x, y);
    this.ctx.restore();
    return index;
}
複製代碼

南丁格爾玫瑰圖

最後再來實現一下南丁格爾玫瑰圖,由一個叫南丁格爾的人分明的,是一種圓形的直方圖,相當於把一個柱形圖拉成一個圓形,用扇形的半徑來表示數據的大小,實現上其實就是把環圖裏的扇形半徑也通過佔比來區分開。

要改造的是renderPie方法,繪製的半徑由統一的半徑乘上一個各自的佔比即可:

renderPie (checkHover, x, y) {
    let hoverIndex = null
    this.data.forEach((item, index) => {
        // ...
        // let _radius = this.radius + this.radius * item.hoverDrawRatio --
        // this.ctx.arc(0, 0, _radius, startRadian, endRadian)
        // ++
        // 該扇形和最大的扇形的大小比例換算成佔圓環的比例
        let nightingaleRadius =
            (1 - item.num / this.max) * // 圓環減去該佔後比剩下的部分
            (this.radius - this.radiusInner)// 圓環的大小
        let _radius = this.radius - nightingaleRadius// 外圓半徑減去多出的部分
        let _radius = _radius + _radius * item.hoverDrawRatio
        this.ctx.arc(0, 0, _radius, startRadian, endRadian)
        // ...
    });
    // ...
}
複製代碼

效果如下:

總結

本文通過一個簡單的餅圖來回顧了一下canvas的一些基礎知識,canvas還有很多有用和高級的特性,比如isPointInStroke可以用來檢測一個點是否在一條路徑上,矩陣變換同樣支持旋轉和縮放,也可以用來處理圖像等等,有興趣的可以自行了解。

代碼已上傳到github:github.com/wanglin2/pi…