【帶著canvas去流浪(4)】繪製散點圖
示例程式碼託管在: http://www.github.com/dashnowords/blogs
部落格園地址: 《大史住在大前端》原創博文目錄
華為雲社群地址: 【你要的前端打怪升級指南】
一. 任務說明
使用原生 canvasAPI
繪製散點圖。(截圖以及資料來自於百度Echarts官方示例庫 【檢視示例連結】 )。
二. 重點提示
學習過折線圖的繪製後,如果資料點只有座標資料,則通過基本的座標轉換在對應的點上繪製出散點並不難實現。而在氣泡圖中,當我們直接將百度 Echarts
示例中的資料拿來經過一定的線性縮小後作為半徑直接繪製散點時,就會出現一些問題,資料集的範圍跨度較大,導致大部分點呈現後都非常小,這個時候就需要使用某種方法從真實資料值對映到散點圓半徑進行對映,來縮小它們之間的差異,否則一旦資料集中有一個偏離度較大的點,就會造成其他點所對應的散點半徑都很大或者都很小,對資料呈現來說是不可取的。例如在下面的示例中,當使用幾種不同的對映方法來處理資料後,可以看到繪製的散點圖是不一樣的。
//求散點半徑時所使用的公式 //1.直接數值 r = value * 5 / 100000000; //2.求對數 r = Math.log(value); //3.求指數 r = Math.pow(value,0.4) / 100;
所繪製出的散點圖如下所示:
座標對映
的實現思路其實並不算複雜, 它的概念可以參考演算法的時間複雜度來進行理解 ,挑選一個增長更快的對映函式來區分相近的點,或者挑選一個增長更慢的對映函式來減小大跨度資料之間的差異,在資料視覺化中是非常實用的技巧。本文示例中的效果是筆者自己手動調的,如果要實現根據資料集自動挑選適當的對映函式,還需要設計一些計算方法,感興趣的讀者可以自行研究。
三. 示例程式碼
氣泡散點圖繪製示例程式碼(座標軸的繪製過程在前述博文中已經出現過很多次,故不再贅述,有需要的小夥伴可以直接翻看這個系列之前的博文或者檢視本篇的demo):
/*資料點來自於百度Echarts官方示例庫,每個數值分別表示[橫座標,縱座標,數值,國家,年份] *[28604,77,17096869,'Australia',1990] */ /** * 繪製資料 */ function drawData(options) { let data = options.data;//獲取資料集 let xLength = (options.chartZone[2] - options.chartZone[0]); let yLength = (options.chartZone[3] - options.chartZone[1]); let gap = xLength / options.xAxisLabel.length; //遍歷兩個年份 for(let i = 0; i < data.length ;i++){ let x,y,r,c; context.fillStyle = options.colorPool[i];//從顏色池中選取顏色 context.globalAlpha = 0.8;//為避免點覆蓋,採取半透明繪製 //遍歷各個資料點 for(let j = 0; j < data[i].length ; j++){ //計算座標 x = options.chartZone[0] + xLength * data[i][j][0] / 70000; y = options.chartZone[3] - yLength * (data[i][j][1] - 55) / (85 - 55); //直接數值 r = data[i][j][2] * 5 / 100000000; //求對數 r = Math.log(data[i][j][2]); //開根號 r = Math.pow(data[i][j][2],0.4) / 100; //繪製散點 context.beginPath(); context.arc(x, y , r , 0 , 2*Math.PI,false); context.fill(); context.closePath(); } } }
瀏覽器中可檢視效果:
四.散點hover互動效果的實現
4.1 基本演算法
在散點圖上實現hover互動效果的基本演算法如下:
- 在
canvas
元素上監聽滑鼠移動事件,將滑鼠座標轉換為canvas座標系的座標值。 - 遍歷資料點檢視是否存在當前滑鼠點距離某個資料中心點的距離小於其散點的繪製半徑,如果有則認為滑鼠在該點之上。
- 利用之前快取的該點繪圖資料,調整繪圖樣式,增大資料點的繪圖半徑覆蓋式繪圖即可。
- 當滑鼠距離任何資料點的距離都大於該點的繪圖半徑,或滑鼠從一個hover資料點移動到另一個hover點時,均需要呼叫一次
resetHover( )
方法清除之前的hover狀態。 - 為了恢復hover前的狀態,可以使用 【離屏canvas技術】 快取首次繪圖後的結果,然後使用
drawImage( )
方法將對應區域恢復到hover前的狀態。
4.2 參考程式碼
hover效果的關鍵程式碼如下,完整示例程式碼請在demo中獲取,或訪問 【我的github倉庫】
/*簡單hover效果*/ canvas.onmousemove = function (event) { //轉換滑鼠座標為相對canvas let pos = { x: event.clientX - rect.left, y: event.clientY - rect.top } //獲取當前hover點座標 let hoverPoint = checkHover(options, pos); /** * 如果當前有聚焦點 */ if (hoverPoint) { //如果當前點和上一次記錄的hover點是不同的點,則先調一次reset方法,然後把hover點更改為當前的點 let samePoint = options.hoverData === hoverPoint ? true : false; if (!samePoint) { resetHover(); options.hoverData = hoverPoint; } //繪製當前點的hover狀態 paintHover(); } else{ //第一次嘗試手動恢復 // resetHover(); //使用離屏canvas恢復 resetHoverWithOffScreen(); } } /*檢測是否hover在散點之上*/ function checkHover(options,pos) { let data = options.paintingData; let found = false; for(let i = 0; i < data.length; i++){ found = false; for(let j = 0; j < data[i].length; j++){ if (Math.sqrt(Math.pow(pos.x - data[i][j].x , 2) + Math.pow(pos.y - data[i][j].y , 2)) < data[i][j].r) { found = data[i][j]; break; } } if (found) break; } return found; } /*繪製hover狀態*/ function paintHover() { let {x,y,r,c} = options.hoverData; let step = 0.5; context.globalAlpha = 1; context.fillStyle = c; //逐幀增加hover點的繪圖半徑,重新繪製hover狀態的散點 for(let i = 0 ; i < 30; i++){ context.beginPath(); context.arc(x,y,r + i * step, 0 , 2*Math.PI,false); context.fill(); context.closePath(); } } /*首次嘗試的取消高亮狀態的函式*/ function resetHover() { if (!options.hoverData) return; let {x,y,r,c} = options.hoverData; let step = 0.5; context.globalAlpha = 1; for(let i = 29; i>0; i--){ context.save(); //繪製外圓範圍 context.beginPath(); context.arc(x,y,r + 30 * step, 0 , 2*Math.PI,false); context.closePath(); //設定剪裁區域 context.clip(); //用全域性背景色繪製剪裁區背景 context.globalAlpha = 1; context.fillStyle = options.globalGradient; context.fill(); //繪製內圓 context.beginPath(); context.arc(x,y,r + i * step, 0 , 2*Math.PI,false); context.closePath(); context.fillStyle = c; context.globalAlpha = 0.8; //填充內圓 context.fill(); context.restore(); } options.hoverData = null; console.log('清除hover效果'); } //利用離屏canvas恢復hover前的狀態 functionresetHoverWithOffScreen() { if (!options.hoverData) return; let {x,y,r,c} = options.hoverData; let step = 0.5; context.globalAlpha = 1; for(let i = 29; i>0; i--){ context.save(); //將hover狀態下資料點圓所在的正方形範圍恢復為hover前的狀態 context.drawImage(canvas2, x - r - 30 * step, y - r - 30 * step , 2 * (r + 30 * step),2*(r + 30 * step),x - r - 30 * step, y - r - 30 * step , 2*(r + 30 * step),2*(r + 30 * step)); //繪製內圓 context.beginPath(); context.arc(x,y,r + i * step, 0 , 2*Math.PI,false); context.closePath(); context.fillStyle = c; context.globalAlpha = 0.8; //填充內圓 context.fill(); context.restore(); } options.hoverData = null; console.log('清除hover效果'); }
4.3 Demo中的小問題
-
為了簡化程式碼,demo中的一些繪圖資料並沒有引數化,而是採取直接寫死的形式放在程式碼裡,尤其是逐幀繪圖的程式碼,一般開發中此處都會配合動畫來進行實現。
-
為了重置某個資料點的hover狀態,筆者最初的實現思路是在每一幀中,使用
context.clip( )
方法裁切出繪圖區域,先用全域性背景繪製出背景圖,縮小資料點半徑,然後再繪製資料點,直到半徑縮小至hover前的值。但在實現後發現這種方式存在一個問題,那就是資料點之間出現重疊時,如果只是簡單地背景重繪,就會將部分重疊區域清除掉,造成其他資料點無法復原,如下圖所示:
所以最終採用離屏canvas的方法,將初次繪製後的資料點先暫存下來,然後在清除hover狀態時,使用 context.drawImage( )
方法將有關區域的資料複製貼上過來,以替代原來的使用背景圖填充該區域的做法,這樣就可以在資料點之間有重疊時重現hover前的狀態。