本周准備一個分享,順便記錄一下入門時碰到的問題。
官方文檔:https://echarts.apache.org/zh/index.html
0.關於Echarts
ECharts.js是百度開源的一個數據可視化圖表庫。
2018年,全球著名開源社區 Apache 基金會宣布“百度開源的 ECharts 項目全票通過進入 Apache 孵化器”。這是百度第一個進入國際頂級開源社區的項目。
1.安裝
npm install echarts --save
2.引入
// 全部引入 const echarts = require('echarts') // 按需引入 // 引入 ECharts 主模塊 var echarts = require('echarts/lib/echarts'); // 引入柱狀圖 require('echarts/lib/chart/bar'); // 引入提示框和標題組件 require('echarts/lib/component/tooltip'); require('echarts/lib/component/title');
3.使用
筆者搭配vue使用echarts,以下代碼均包含vue.js相關代碼。
3.1 初始化
echarts的初始化使用很簡單,引入的echarts主模塊里,可以調用init()初始話函數,給這個函數傳入dom元素,再根據自己需要進行設置setOption即可。
<div id='test'/>
需要注意的是,你需要提前給定這個元素的寬高,特別是高度height,否則在初始化成功之后你會看不到它。
3.2 數據獲取與更新
echarts支持異步加載和更新數據,同樣只需要將新的數據傳入setOption即可。
/** option: any = { title: { text: '柱狀圖' }, tooltip: {}, xAxis: { data: ['襯衫', '羊毛衫', '雪紡衫', '褲子', '高跟鞋', '襪子'] }, yAxis: {}, series: [{ name: '銷量', type: 'bar', data: [] }] } */ initEchatrs() { this.ele = echarts.init(document.getElementById('test')) this.ele.setOption(this.option) this.ele.showLoading() window.onresize = this.ele.resize // 隨窗口尺寸變化調整自身尺寸 this.getAsyncData() } getAsyncData() { setTimeout(() => { // 此處修改對象屬性值,有些類似於能讓watch監聽到的變化才能生效 // 直接對this.option.xx進行增刪改是無法生效的,使用this.$set或者object.assign() this.$set(this.option, 'series', [{ name: '銷量', type: 'bar', data: [5, 20, 36, 10, 10, 20] }]) this.ele.setOption(this.option) this.ele.hideLoading() }, 1500)
3.3 事件和行為
echarts中綁定事件通過on方法
myChart.on('click', function (params) { // 控制台打印數據的名稱 console.log(params.name); });
echarts里的事件類型包括鼠標事件和使用可以交互的組件(如縮放數據區域等)后觸發的行為事件。
它支持常規的鼠標事件類型,包括click、dbclick、mousedown、mousemove、mouseup、mouseover、mouseout等,將對應的事件名傳入on方法即可,回調事件包含參數params,它包含一個點擊圖形的數據信息的對象
{ // 當前點擊的圖形元素所屬的組件名稱, // 其值如 'series'、'markLine'、'markPoint'、'timeLine' 等。 componentType: string, // 系列類型。值可能為:'line'、'bar'、'pie' 等。當 componentType 為 'series' 時有意義。 seriesType: string, // 系列在傳入的 option.series 中的 index。當 componentType 為 'series' 時有意義。 seriesIndex: number, // 系列名稱。當 componentType 為 'series' 時有意義。 seriesName: string, // 數據名,類目名 name: string, // 數據在傳入的 data 數組中的 index dataIndex: number, // 傳入的原始數據項 data: Object, // sankey、graph 等圖表同時含有 nodeData 和 edgeData 兩種 data, // dataType 的值會是 'node' 或者 'edge',表示當前點擊在 node 還是 edge 上。 // 其他大部分圖表中只有一種 data,dataType 無意義。 dataType: string, // 傳入的數據值 value: number|Array // 數據圖形的顏色。當 componentType 為 'series' 時有意義。 color: string }
on事件還支持query,query可以只對指定的圖形元素觸發回調。
chart.on(eventName, query, handler);
query可以為字符串
chart.on('click', 'series', function () {...}); chart.on('click', 'series.line', function () {...}); chart.on('click', 'dataZoom', function () {...}); chart.on('click', 'xAxis.category', function () {...});
也可以為對象object,它包含以下可選屬性
{ <mainType>Index: number // 組件 index <mainType>Name: string // 組件 name <mainType>Id: string // 組件 id dataIndex: number // 數據項 index name: string // 數據項 name dataType: string // 數據項 type,如關系圖中的 'node', 'edge' element: string // 自定義系列中的 el 的 name }
如
chart.setOption({ // ... series: [{ name: 'uuu' // ... }] }); chart.on('mouseover', {seriesName: 'uuu'}, function () { // series name 為 'uuu' 的系列中的圖形元素被 'mouseover' 時,此方法被回調。 });
其他支持的組件交互行為都會觸發對應的事件,在官方文檔中均有說明
3.4 繪制svg
echarts最開始是使用canvas繪制圖表的,目前echarts 4.0以上已經支持svg繪制。
只需要在init中的參數中設置render參數即可。
// init: Function (dom: HTMLDivElement|HTMLCanvasElement, theme?: Object|string, opts?: { devicePixelRatio?: number, renderer?: string, width?: number|string, height?: number|string }) => ECharts // dom: 實例的容器,一般是一個具有寬高的div元素 // theme: 應用主題 // opts: 附加參數,可選項有: // devicePixelRatio: 設備像素比,默認取瀏覽器的window.devicePixelRatio // renderer: 渲染器,可選'canvas'或者'svg' // width: 實例寬度,px // height: 實例高度,px
3.5 其他api
echarts的api文檔寫的十分詳盡,參考它的官方文檔,使用你想使用的組件即可。
4.canvas
canvas與svg都是瀏覽器端繪制圖形的手段,但是他們在根本上是不同的。僅在繪圖方面,svg的優勢在於不會失真,渲染性能略高(因此更適合移動端)、內存占用更低;canvas更適合繪制圖形元素數量非常大的圖表。
echarts的根本是對canvas/svg的操作,它的底層使用了zRender來繪制。
echarts的github:https://github.com/apache/incubator-echarts
這里僅討論canvas。
<canvas>標簽提供了一塊空白的畫布容器,它公開了一個或多個渲染上下文,需要通過腳本在上面繪制。使用原生的canvas繪制圖形,需要對canvas的api使用熟練。
canvas api:https://www.runoob.com/tags/ref-canvas.html
具體代碼可以參考后面,先記錄一下實際中踩到的幾個坑:
4.1 繪制1px線
參考:https://www.cnblogs.com/v-rockyli/p/3833845.html
下面兩張圖分別是處理前和處理后的效果
處理后
處理前
仔細觀察可以發現下圖坐標軸和對齊軸的線條,比起上圖看起來要粗一些、顏色淺一些。實際上兩張圖內繪制的線條都是1px。為什么會有這種區別呢?
這跟canvas的繪制邏輯有關,當我們試圖繪制一個線段時,canvas會讀取lineWidth,,然后嘗試將在坐標處兩邊各繪制一半的lineWidth。
比如我們想在坐標(0,3)處繪制一條橫線,canvas會以3為中軸線,在兩邊各畫0.5像素,深藍色就是我們期望的效果(2.5-3.5,1個像素),但實際上,淺藍色也會被繪制出來,因為canvas無法在整個像素寬內只繪制半個像素,所以坐標軸上下兩個方向都都會被擴展至整個像素寬度內(2-4,兩個像素),但是擴展的像素實際的值並不是原值相同,而是取其一半,所以最直接的視覺感受是:線條比預想的變寬了,但是顏色淺了很多。
還是以寬為1的橫線為例,我們如果將其繪制在縱坐標2.5處,即以半像素作為中軸線
同樣瀏覽器進行繪制時,在2.5上下各繪制0.5的像素寬度,但與上面的例子不同的是,圖像邊界正好落在整數像素邊界內,合起來正好為1個像素,這個時候,就不需要向兩邊擴展,而是我們預期的的1個像素寬度。
同理,我們分別使用兩種方式繪制寬度為2的線段時,效果恰恰相反,在坐標3處繪制的時候,像素正好擴展至2-4,即2個像素,符合我們的預期;而在坐標2.5處繪制時,像素擴展至1.5-3.5,未到邊界,需要補足,就變成了1-4,即3個像素。
因此在實際應用中,如果想得到更好的體驗,精確的像素值,如果線段的寬度是奇數像素,繪制時以n.5,即半數像素作為中軸線,如果線段的寬度為偶數像素,繪制時以n.0,即整數像素作為中軸線
4.2 視口與畫布

視口高度150
視口高度300
可以明顯看出效果會按照視口與畫布的尺寸進行等比縮放;當視口高度與畫布高度1:1的時候,看起來效果是最好。因此指定畫布大小的時候,最好也指定canvas視口寬高。
<canvas id="canvas" :width="width" :height="height"></canvas> width = 300 height = 300 #canvas { width: 300px; height: 300px; }
但是我們觀察echarts的視口與畫布大小,可以看出echarts默認視口:畫布=2:1,這樣做也是有道理的,視口是畫布的2倍,可以在縮放到200%的情況下不失真。
因此如果只是想簡單實現一個表格,不需要考慮縮放的效果的話,直接指定視口:畫布=1:1即可(移動端大多數可以如此);如果需要考慮縮放n倍不失真(比如pc端瀏覽器的縮放功能),就需要增加視口寬高。
4.3 繪制path
canvas的繪制方法中比較重要的一個手段就是繪制path,其中比較值得注意的是beginPath和closePath。
其中個人認為beginPath要比closePath要重要。從api的解釋來看,beginPath有“重置當前路徑”的功能,closePath只是創建一條新的路徑,幫忙把這個圖形閉合了而已。
實際操作中不注意begin和close會引起的問題:
圖1 期望效果
圖2 無closePath
圖3 無beginPath
這里圖1的繪制流程可以查看下面代碼
getAsyncData() { setTimeout(() => { this.yAxis = [5, 20, 36, 10, 10, 20] // y坐標軸 // 計算刻度 const num = Math.ceil((Math.max(...this.yAxis)) / this.minUnit) const yGap = this.yAxisLen / num const max = num * this.minUnit // 繪制刻度及對齊軸 for(let i = 0; i < num; i++) { const text = this.minUnit * (i + 1) + '' // 文本 this.context.fillText(text, this.x0 - text.length * this.fontsize / 1.5, this.height - (10 + yGap * (i + 1) + this.fontsize / 2)) // 刻度 this.context.beginPath() const yUnit = Math.floor(this.y0 - this.yAxisLen + yGap * i) + 0.5 this.context.moveTo(this.x0, yUnit) this.context.lineTo(this.x0 - 3, yUnit) this.context.strokeStyle = '#000000' this.context.stroke() // 對齊軸 this.context.beginPath() this.context.moveTo(this.x0, yUnit) this.context.lineTo(this.width, yUnit) this.context.strokeStyle = '#eeeeee' this.context.stroke() } // 繪制條狀圖 const xGap = (this.width - this.x0) / this.xAxis.length this.context.fillStyle = this.barColor this.barList = [] this.start = new Array(this.yAxis.length).fill(0) // 初始化幀數計數數組 this.yAxis.map((data, index) => { const x1 = this.x0 + xGap * (index + 0.5) - this.barWidth / 2 const h = data / this.minUnit * yGap const y1 = this.y0 - h const barData = {x: x1, y: y1, w: this.barWidth, h} // 保存色塊數據 this.barList.splice(index, 0, Object.assign({}, barData, { data, index, name: this.xAxis[index], color: this.barColor })) // 通過path繪制矩形 // this.drawRectByPath(x1, y1, this.barWidth, h) // 添加動畫效果 this.animate(barData, index) }) }, 1500); } drawRectByPath(x: number, y: number, w: number, h: number) { // 通過路徑繪制矩形,x,y為左上角,w為寬,h為高 // 如果不重置路徑(beginPath)那么會從上一次的beginPath開始執行 this.context.beginPath() this.context.moveTo(x, y) this.context.lineTo(x + w, y) this.context.lineTo(x + w, y + h) this.context.lineTo(x, y + h) // 如果不closePath,該路徑則無法閉合,只會影響描邊,不影響填充 this.context.closePath() this.context.strokeStyle = 'rgba(0, 0, 0, 0.1)' this.context.fillStyle = 'rgba(200, 0, 0, 0.1)' // 未閉合路徑也會自動回到開始路徑並填充,但是stroke不會 this.context.stroke() this.context.fill() }
簡單來說是,從上到下繪制刻度軸,從左到右依次繪制條狀,path的路徑是從左上角開始順時針連線,顏色是有透明度的。
圖1是正常效果,圖2是繪制條狀時未執行closePath,圖3是繪制條狀時未執行beginPath。
圖2中不執行closePath,可以看出是不影響fill的,對非閉合圖形執行fill,會幫你把圖形閉合(從最后一點回到起點的一條路徑),然后再填充。此處起點為上一次moveTo所在的點。對於stroke,繪制了多少線條就會描邊多少線條,並不會自動閉合。
圖3中不執行beginPath的影響就大多了,存在兩個問題:
1.最左邊的條狀顏色偏深,越往右越淺
2.最下面的刻度軸壓住了除了最右的條狀的所有條狀(圖上看不太出來,因為最右的顏色比較淺)
為什么會這樣呢?
自然我們會從beginPath開始入手,beginPath的作用是重置當前路徑,如果沒有重置路徑,那么這次的繪制是從哪里開始的呢?
答案是從上次beginPath之后重新執行。
如果繪制條狀的時候沒有beginPath,那么繪制過程就會如下:
繪制起點是上一次beginPath的時候,也就是最后一條刻度軸的起點(綠色點)。
繪制第n條的過程是這樣的:
1.從綠色點作為第一次起點,繪制一次刻度軸
2.紅色點作為第二次起點,繪制一次第一條條狀
3.依次繪制第二條、第三條,直到第n條
因此繪制第n條的時候,第一條已經被重復繪制、填充了n次,第二條n-1次,...,以此類推。
所以繪制完成6條,坐標軸被重復繪制了7次,第7次繪制時,蓋住了前面繪制的條狀,被后續繪制的條狀蓋住;第一條被重繪了6次,顏色疊加,因此顏色最深;第六條只繪制了1次,顏色最淺
因此使用path的時候一定要注意使用beginPath和closePath。
4.3 實現動畫效果
參考:https://m.html.cn/web/javascript/12369.html
實現動畫效果實際上是對部分區域的清除和重繪,每秒重繪24幀即可讓人看起來這個動作是連續的,因此每秒能實現24幀及以上的重繪,即可實現動畫效果。
原理我們知道了,但是如何做到每秒刷新24次及以上,還需要一個函數的支持:window.requestAnimationFrame
對此api的解釋可以查看mdn:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestAnimationFrame
animate(to: any, index: number) { // 計算此時的y, h const h = Math.floor(to.h / this.frames * this.start[index]) + 0.5 const y = this.y0 - h // 清除上一次繪制的矩形 this.context.clearRect(to.x, y, to.w, h) // 重繪矩形 this.context.fillRect(to.x, y, to.w, h) // 變化 this.start[index]++ if (this.start[index] <= this.frames) { window.requestAnimationFrame(() => this.animate(to, index)) } }
4.4 事件綁定
在開始的canvas與svg的區別中,w3school列出了他們的差異之處,其中提到了canvas是不支持事件處理器的,canvas繪制完成后就成為一整塊畫布,不再引起瀏覽器的注意,我們無法對其中某個色塊或者線段進行操作。
因此如果想給某個色塊添加事件,需要通過坐標的方式判斷點擊的是哪個色塊。
bindEvents() { // 給整個canvas綁定事件 const canvas = document.getElementById('canvas') as HTMLCanvasElement // mousedown canvas.addEventListener('mousedown', this.mouseDownEvent.bind(this)) } mouseDownEvent(e: any) { // 判斷坐標 const {offsetX, offsetY} = e // 對所有色塊遍歷,判斷點擊了哪個色塊 this.barList.map((bar, index) => { if (this.checkBoundary(offsetX, offsetY, bar.x, bar.y, bar.w, bar.h)) { console.log('you click this bar', bar) } }) } checkBoundary(x0: number, y0: number, x1: number, y1: number, w: number, h: number) { // x0, y0為點擊的坐標,x1,y1為色塊左上角坐標,w,h為色塊寬高 return x0 > x1 && x0 < (x1 + w) && y0 > y1 && y0 < (y1 + h) }
如果你的canvas繪制內容比較復雜,還需要考慮色塊重疊時的情況。如果還需要做元素的拖拽、縮放、旋轉、刪除等,可能會更復雜。個人感覺對canvas的事件綁定遠不如svg的簡單。
---------------------end--------------------
全部代碼(vue+ts):
<template> <div class="container"> <span class="zero">0</span> <span class="x">x</span> <span class="y">y</span> <canvas id="canvas" :width="width" :height="height"></canvas> </div> </template> <script lang="ts"> import { Component, Vue } from 'vue-property-decorator' @Component export default class Canvas extends Vue { // 視口 width = 300 height = 300 // 字體大小 fontsize = 14 // 坐標軸 minUnit = 10 // y軸最小刻度 barWidth = 30 // 條狀圖寬度 barList: any[] = [] // 條狀圖色塊數據,坐標、寬、高 barColor = 'rgb(200, 0, 0)' xAxis = ['襯衫', '羊毛衫', '雪紡衫', '褲子', '高跟鞋', '襪子'] // x軸數據 yAxis: number[] = [] // y軸數據 // 動畫 start: number[] = [] frames = 30 // canvas上下文 context: any get x0() { // 需要注意canvas的繪制問題 // canvas繪制時會讀取lineWidth,然后嘗試在坐標處兩邊各繪制一般的lineWidth // 當lineWidth=1時,canvas會嘗試在整數坐標處左右各繪制半個像素 // 因為canvas無法在一個像素內繪制半個像素,因此坐標處上下兩個方向都會被擴展至整個像素寬度內,即兩個像素 // 導致坐標處繪制1像素線條和2像素線條看起來時一樣的 只是1像素線條顏色淺了一些 // 解決辦法是在想在n繪制1像素線條時,最好是n.5處繪制 return 25.5 } get y0() { return this.height - 20.5 } get xAxisLen() { return this.width - this.x0 } get yAxisLen() { return this.y0 - 10.5 } mounted() { // canvas創造了一個固定大小的畫布,它公開了一個或多個渲染上下文 // canvas與svg一樣 存在視口的概念,不指定canvas標簽的寬高屬性時, 默認為300*150 // 在內部樣式中指定canvas畫布的大小,而不指定視口大小,在實際中會影響到繪制效果 const canvas: HTMLCanvasElement = document.getElementById('canvas') as HTMLCanvasElement // 獲取2d上下文,沒有這個方法則表示可能不支持canvas this.context = canvas.getContext('2d') // 繪制輔助坐標軸 this.drawSupport() // 繪制圖表 this.draw() } drawSupport() { // 開始繪制一段路徑,並指定這段路徑的樣式 this.context.beginPath() // 起始一段路徑,或重置當前路徑 this.context.moveTo(this.width, 0) this.context.lineTo(0, 0) this.context.lineTo(0, this.height) // this.context.closePath() this.context.strokeStyle = "#ff0000" this.context.stroke() } draw() { // 繪制坐標軸 this.context.beginPath() this.context.moveTo(this.x0, this.y0 - this.yAxisLen) this.context.lineTo(this.x0, this.y0) this.context.lineTo(this.width, this.y0) // this.context.closePath() this.context.strokeStyle = '#000000' this.context.stroke() // x坐標軸 this.context.font = `${this.fontsize}px Arial` const xGap = (this.width - this.x0) / this.xAxis.length this.xAxis.map((text, index) => { // 文本 this.context.fillText(text, this.x0 + xGap * (index + 0.5) - text.length * this.fontsize / 2, this.height - 5) // 刻度 this.context.beginPath() this.context.moveTo(this.x0 + xGap * (index + 1), this.y0) this.context.lineTo(this.x0 + xGap * (index + 1), this.y0 - 3) this.context.closePath() this.context.stroke() }) // 模擬異步獲取數據 this.getAsyncData() // 綁定事件 this.bindEvents() } getAsyncData() { setTimeout(() => { this.yAxis = [5, 20, 36, 10, 10, 20] // y坐標軸 // 計算刻度 const num = Math.ceil((Math.max(...this.yAxis)) / this.minUnit) const yGap = this.yAxisLen / num const max = num * this.minUnit // 繪制刻度及對齊軸 for(let i = 0; i < num; i++) { const text = this.minUnit * (i + 1) + '' // 文本 this.context.fillText(text, this.x0 - text.length * this.fontsize / 1.5, this.height - (10 + yGap * (i + 1) + this.fontsize / 2)) // 刻度 this.context.beginPath() const yUnit = Math.floor(this.y0 - this.yAxisLen + yGap * i) + 0.5 this.context.moveTo(this.x0, yUnit) this.context.lineTo(this.x0 - 3, yUnit) this.context.strokeStyle = '#000000' this.context.stroke() // 對齊軸 this.context.beginPath() this.context.moveTo(this.x0, yUnit) this.context.lineTo(this.width, yUnit) this.context.strokeStyle = '#eeeeee' this.context.stroke() } // 繪制條狀圖 const xGap = (this.width - this.x0) / this.xAxis.length this.context.fillStyle = this.barColor this.barList = [] this.start = new Array(this.yAxis.length).fill(0) // 初始化幀數計數數組 this.yAxis.map((data, index) => { const x1 = this.x0 + xGap * (index + 0.5) - this.barWidth / 2 const h = data / this.minUnit * yGap const y1 = this.y0 - h const barData = {x: x1, y: y1, w: this.barWidth, h} // 保存色塊數據 this.barList.splice(index, 0, Object.assign({}, barData, { data, index, name: this.xAxis[index], color: this.barColor })) // 通過path繪制矩形 // this.drawRectByPath(x1, y1, this.barWidth, h) // 添加動畫效果 this.animate(barData, index) }) }, 1500); } animate(to: any, index: number) { // 計算此時的y, h const h = Math.floor(to.h / this.frames * this.start[index]) + 0.5 const y = this.y0 - h // 清除上一次繪制的矩形 this.context.clearRect(to.x, y, to.w, h) // 重繪矩形 this.context.fillRect(to.x, y, to.w, h) // 變化 this.start[index]++ if (this.start[index] <= this.frames) { window.requestAnimationFrame(() => this.animate(to, index)) } } drawRectByPath(x: number, y: number, w: number, h: number) { // 通過路徑繪制矩形,x,y為左上角,w為寬,h為高 // 如果不重置路徑(beginPath)那么會從上一次的beginPath開始執行 this.context.beginPath() this.context.moveTo(x, y) this.context.lineTo(x + w, y) this.context.lineTo(x + w, y + h) this.context.lineTo(x, y + h) // 如果不closePath,該路徑則無法閉合,只會影響描邊,不影響填充 this.context.closePath() this.context.strokeStyle = 'rgba(0, 0, 0, 0.1)' this.context.fillStyle = 'rgba(200, 0, 0, 0.1)' // 未閉合路徑也會自動回到開始路徑並填充,但是stroke不會 this.context.stroke() this.context.fill() } bindEvents() { // 給整個canvas綁定事件 const canvas = document.getElementById('canvas') as HTMLCanvasElement // mousedown canvas.addEventListener('mousedown', this.mouseDownEvent.bind(this)) } mouseDownEvent(e: any) { // 判斷坐標 const {offsetX, offsetY} = e // 對所有色塊遍歷,判斷點擊了哪個色塊 this.barList.map((bar, index) => { // todo: 如果圖形有重疊,點擊重疊區域,如何選中上層元素 // 引入“層級”概念,為每個圖形添加層級屬性。越新創建的圖形層級越高。 // 點擊重疊區域,直接選中層級高的元素 // 點擊非重疊的圖形區域,將該圖形的層級提到最高 if (this.checkBoundary(offsetX, offsetY, bar.x, bar.y, bar.w, bar.h)) { console.log('you click this bar', bar) } }) } checkBoundary(x0: number, y0: number, x1: number, y1: number, w: number, h: number) { // x0, y0為點擊的坐標,x1,y1為色塊左上角坐標,w,h為色塊寬高 return x0 > x1 && x0 < (x1 + w) && y0 > y1 && y0 < (y1 + h) } } </script> <style scoped> .container { position: relative; } .container span { position: absolute; color: red; } .zero { top: -10px; left: 15px; } .x { top: -10px; right: 10px; } .y { top: 100%; left: 25px; } #canvas { width: 300px; height: 300px; } </style>