Canvas 繪製雷達圖

清夜發表於2019-05-28

最近做的一個需求,場景之一是繪製一個雷達圖,找了一圈,似乎 AntV 下的 F2 很適合拿來主義:

Canvas 繪製雷達圖

但是接著又考慮了一下,我當前所做的專案並不是視覺化專案,今後大概率也不會有這種視覺化圖表的需求,只是為了單個需求一兩個圖表就引入一個視覺化庫,價效比有點低,辛辛苦苦優化下來的程式碼體積,就因為一個冗餘的程式碼庫一下子回到解放前,那可真要不得(雖然 F2已經夠精簡的了)

再加上我剛好對於 canvas這塊有點興趣,送上門的練手機會更不可能錯過了,除此之外,我看了一下 F2的文件 看得有點腦殼疼,有看這文件的時間我還不如直接去看 Canvas原生 Api呢,於是決定自己來搞定這個東西

繪製多邊形

這個雷達圖看起來好像挺簡單,實際上還是有點門道的,我考慮了一下,將其分成三部分:

  • 正多邊形
  • 正多邊形頂點處文案
  • 雷達區域

多邊形示意如下(這裡以正五邊形作為示例):

Canvas 繪製雷達圖

這個圖其實由多個尺寸不同的正五邊形巢狀而成,並且通過 5條線將正五邊形的對角頂點連在了一起,關鍵在於需要知道正五邊形的五個頂點座標,這其實就是求解幾何數學題

如圖,旋轉正五邊形,令其中心點位於座標軸原點,其最左側的一條邊平行於 y軸,在此狀態下的其 x座標最大的點(即最右側的點)位於 x軸上,然後再畫一個此正五邊形的外接圓,接下來就可以進行求解了

Canvas 繪製雷達圖

這裡之所以這麼旋轉正五邊形,只是為了更方便的求解座標,你當然也可以令正五邊形的一條邊平行於 x軸或者其他任意的旋轉進行求解,只要能取得正多邊形各個頂點的相對座標即可

顯而易見神特麼顯而易見,正五邊形的每個頂點的座標就是:

(radius * cosθ, radius * sinθ)
複製程式碼

這裡的 radius就是正五邊形外接圓半徑,θ是頂點與原點之間連線和 x的夾角

其中 radius是我們自己規定的,只剩下 θ的求解了,按照上圖,如果正多邊形最右側(即 x座標最大)的頂點為第一個頂點,逆時針旋轉依次為 第二、第三...第 n

顯而易見的是,這個 θ值其實就是正五邊形內角角度的一半,正多邊形(n)內角角度(mAngle)為 Math.PI * 2 / n, 則第 n個點的座標為:

(radius * cos(θ * (n - 1)), radius * sin(θ * (n - 1)))
複製程式碼

這裡只是拿正五邊形舉個例子,放寬到正n邊形都是這個道理

頂點拿到了,正多邊形就很好畫了,不過還有一點需要注意,實際需求中,一般是要求正多邊形是正著放置,即底邊與 x平行,而按照本文這裡的求解方式得到的頂點座標畫出來的正多邊形,是側邊與 y平行,所以需要將得到的 正多邊形的座標進行一定的對映,將之轉換為正著放置

canvas也提供了這種操作,即 rotate,只要先把或者後把 canvas的座標系旋轉一下,那麼畫出來的多邊形在視覺上看就是 正著放置的了

function drawPolygon () {
  // #region 繪製多邊形
  const r = mRadius / polygonCount
  let currentRadius = 0
  for (let i = 0; i < polygonCount; i++) {
    bgCtx.beginPath()
    currentRadius = r * (i + 1)
    for (let j = 0; j < mCount; j++) {
      const x = currentRadius * Math.cos(mAngle * j)
      const y = currentRadius * Math.sin(mAngle * j)
      // 記錄最外層多邊形各個頂點的座標
      if (i === polygonCount - 1) {
        polygonPoints.push([x, y])
      }
      j === 0 ? bgCtx.moveTo(x, y) : bgCtx.lineTo(x, y)
    }
    bgCtx.closePath()
    bgCtx.stroke()
  }
  // #endregion

  // #region 繪製多邊形對角連線
  for (let i = 0; i < polygonPoints.length; i++) {
    bgCtx.moveTo(0, 0)
    bgCtx.lineTo(polygonPoints[i][0], polygonPoints[i][1])
  }
  bgCtx.stroke()
  // #endregion
}
複製程式碼

正多邊形頂點處文案

文案的位置其實就是在頂點附近,按照頂點的座標進行一定的偏移即可,但前面說過了,由於 canvas座標系已經通過 rotate進行了旋轉,這裡想要讓繪製出來的文字是正著放置的,就需要再次將座標系旋轉回去

除此之外,還要注意一下文字繪製的對其方式,這個通過 textAlign可以解決:

function drawVertexTxt () {
  bgCtx.font = 'normal normal lighter 16px Arial'
  bgCtx.fillStyle = '#333'
  // 奇數多邊形,距離裝置頂邊最近的點(即最高點的那一點),需要專門設定一下 textAlign
  const topPointIndex = mCount - Math.round(mCount / 4)
  for (let i = 0; i < polygonPoints.length; i++) {
    bgCtx.save()
    bgCtx.translate(polygonPoints[i][0], polygonPoints[i][1])
    bgCtx.rotate(rotateAngle)
    let indentX = 0
    let indentY = 0
    if (i === topPointIndex) {
      // 最高點
      bgCtx.textAlign = 'center'
      indentY = -8
    } else {
      if (polygonPoints[i][0] > 0 && polygonPoints[i][1] >= 0) {
        bgCtx.textAlign = 'start'
        indentX = 10
      } else if (polygonPoints[i][0] < 0) {
        bgCtx.textAlign = 'end'
        indentX = -10
      }
    }
    // 如果是正四邊形,則需要單獨處理最低點
    if (mCount === 4 && i === 1) {
      bgCtx.textAlign = 'center'
      indentY = 10
    }
    // 開始繪製文案
    mData[i].titleList.forEach((item, index) => {
      bgCtx.fillText(item, indentX, indentY + index * 20)
    })
    bgCtx.restore()
  }
}
複製程式碼

雷達區域

雷達區域就是文章開頭那個圖中的紅色線框內的區域,這個區域也是一個多邊形,只不過不是正的,但座標的求解其實和正多邊形是差不多的,只需要在求座標的過程中,對座標引數進行一定比例的縮放罷了,而這個比例就是所在的頂點代表的實際值與總值的比例(比如,100分是滿分,第一個點只有80分,那麼就是 80%

如果只是一個靜態圖,那麼到此為止也就沒什麼好說的了,求得雷達區域各個點座標,然後連線路徑、閉合路徑,再描邊就完事,但如果是想要做成文章開頭那種雷達區域動態填充的,就稍微要麻煩一點了

我一開始的想法是,動態求解每一幀雷達區域的各個頂點座標,後來算了半天發現也太麻煩了,怎麼扯出來那麼多數學公式,這就算是能求出來效能應該也好不到哪裡去吧

遂棄之,另尋他法,忽得一技

文章開頭的那種動態填充法,看起來很像是一個圓以扇形開啟的樣子啊,看了一下 canvas裡有個叫 clip的東西,於是想到,只要先將需要的雷達區域裁切(clip)好,再用一個足夠覆蓋這個裁切區域的圓放到這個裁切面上進行動態扇形展開,不就達到目的了嗎?

for (let i = 0; i < mCount; i++) {
  // score不能超過 fullScore
  score = Math.min(mData[i].score, mData[i].fullScore)
  const x = Math.cos(mAngle * i) * score / mData[i].fullScore
  const y = Math.sin(mAngle * i) * score / mData[i].fullScore
  i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
}
ctx.closePath()
ctx.clip()
// ...
ctx.moveTo(0, 0)
ctx.arc(0, 0, canvasMaxSize, 0, currentAngle)
ctx.closePath()
ctx.fill()
複製程式碼

效果如圖:

Canvas 繪製雷達圖

似乎可行,但是與文章開頭的那個圖對比了一下,發現還有點欠缺,頭圖的雷達區域是有紅色描邊的,並且在完整繪製完畢後,雷達區域的每個頂點處都有紅色小圓點

頂點處的紅色小圓點好辦,頂點座標是已知的,無非是在頂點處在畫個小圓罷了,但是描邊有點麻煩

描邊的長度是緊跟雷達區域繪製進度的,這就需要知道每一幀雷達區域每個頂點的座標,這不又回去了嗎?說好了不搞那麼多公式計算的

後來又想了下,如果事先把雷達圖最終態畫好,然後用一個蒙層遮住,接著再把這個蒙層動態開啟不也行嗎?

又看了一下canvas的文件,發現了一個叫 globalCompositeOperationAPI,就是它了

為了方便繪製,我重新劃分了一下,一共用了三個 canvas

第一個 canvas作為最終呈現效果的畫布,第二個用於繪製完整版的靜態雷達區域,第三個則繪製用於給完整版的靜態雷達區域進行遮罩的蒙層,三個canvas一組合,就達到了預期效果:

Canvas 繪製雷達圖

小結

本文完整示例 Live Demo示例程式碼 已經上傳,感興趣的可以親自試下

相關文章