可視化圖表實現揭祕

1. 介紹

1.1 什麼是數據可視化?

可視化是利用計算機圖形學和圖像處理技術,將數據轉換成圖形或者圖像在屏幕上顯示出來,再進行交互處理的理論、方法和技術。

數據可視化並不是簡單的將數據變成圖表,而是以數據爲視角,看待世界。數據可視化就是將抽象概念形象化表達,將抽象語言具體化的過程。

1.2 爲什麼要用數據可視化

  1. 首先我們利用視覺獲取的信息量絕對遠遠的比別的感官要多得多。

  2. 它能幫助分析的人對數據有更全面的認識,下面舉個🌰

我們看下面幾組數據:

對數據進行簡單的數據分析,每組數據都有兩個變量 X 和 Y,然後用常用的統計算法評估其特點。

猛一看,你會覺得數據都是同一個特點。但如果通過可視化方式展示出來,就會有不同效果

  1. 人類大腦在記憶能力的限制。實際上我們觀察物體的時候,我們大腦和計算機一樣有長期的記憶(memory 硬盤)和短期記憶(cache 內存),只要我們讓短期記憶中的文字、物體等一遍遍的鞏固,它們纔可能進入長期記憶。很多研究表明,在進行理解和學習的時候,圖文更有效的幫助我們記憶,也更有趣,容易理解。

1.3 常見的前端開發中有什麼可視化工具

對於在 Data 部門或者做跟數據相關工作的同學,一定對可視化不陌生,常見的場景有大屏、3D 展示等等。同樣,現階段前端層面湧現出多種可視化方案,這裏簡單羅列幾種:

1.4 前端可視化圖表是怎麼繪製出來的

這裏我們只簡單介紹 2D 的繪製方案。

  1. Canvas。其基於位圖的圖像。其使用 JavaScript 程序繪圖(動態生成),提供的功能更原始,適合圖像處理、動態渲染以及大數據量繪製。優點如下:

  2. 性能高,可以自己控制繪製過程。

  3. 可控性高(像素級別)。

  4. 內存佔用恆定(與像素點個數有關)。

  5. Svg。其基於矢量的圖像。適合用來做動態生成,且容易編輯。

  6. 不失真,放大縮小都清晰。

  7. 學習成本低,其也是一種 DOM 結構。

  8. 使用方便,設計軟件即可導出(icon 就是這樣實現的)。

B 樣條基有如下性質:

看完上面的一連串專業名稱,先彆着急腦袋暈,下面我們看看怎麼用 Canvas 繪製一條線

2.1 繪製一條線

線是可視化中最常見的圖形元素了,最常見的就是折線圖

一條線是由多個點來定義,按照點和點之間的連接方式不同,我們可分爲 “折線” 和 “曲線”,在可視化渲染時又能分爲 “虛線” 和 “實線”。

換個思路,我們用線來繪製閉合的路徑,從而形成封閉區域,就能實線面積圖和雷達圖,就像這樣。

下面我們來看看到底如何繪製一個線圖呢?

2.1.1 什麼是線?

我們都知道,線是由點組成的,兩個相鄰的點連接起來就成爲一個 “段”,多個段拼裝組成一條線,就像這樣。

轉化成程序思維我們可以得知:

2.2 實現折線

2.2.1 獲取段

折線拆分爲段的實現很簡單,根據傳入的點數據,相鄰兩點劃爲一段。下面簡單演示一下(大概寫個邏輯):

getSegment(points, defined) {
  segCache ← [];
  totalLength ← 0;
  for p, i
    pnext ← points[i + 1]
    if pnext
      // 兩個點確定一條段 調用對應函數
      segment = CreateSegment
      // 緩存數據
      segCache ← segment
      // 計算段的長度
      segment.length ← distance
      // 計算總長度
      totalLen ·····
      // 判斷是否空段
      if ···
        // 一些邏輯
  // 返回段和總長度      
}

實現很簡單,依次遍歷點數據,初始化段對象,這裏有個計算段長度的邏輯,段的長度要用後面會說到,至於長度怎麼算,很簡單就不說了。上面有個判斷是否爲空段的邏輯,之所以做這個操作是因爲在實際應用中,有些業務場景需要隱藏某些段,可以看看下面的圖:

2.2.2 使用 Canvas 繪製線段

Canvas 提供了兩個 API —— moveTo 和 lineTo,具體操作中我們需要調用 moveTo 將畫筆定位到線段的起點,然後通過 lineTo 繪製到線段的終點即可,如果多個首尾相接的線段可以忽略 moveTo(Canvas 內部存儲當前上下文),直接 lineTo。

基於上述方法,我們只需要遍歷一條線中所有段,依次連接就可以了,爲了處理空段,我們需要設置一個 start 的標記變量,如果處於 start 狀態,會先 moveTo 到新的點,而不是 lineTo,大概代碼如下:

drawLine(ctx) {
  defined ← false
  // 設置開始標誌(先moveTo)
  lineStart
  for i ← 0 to len
    seg ← segCache[i]
    ...
    if i = len
      lineEnd
      strokeLine
    else
        // 判斷是否爲空段
        if ...
          drawSeg //否
        else
          lineStart // 是
}
drawSeg(seg, ctx) {
  if lineStart
    moveTo
  ····
  drawLine
}
drawLine(x, y, ctx) {
  lineTo
}

這塊可能會有個疑惑,感覺把線拆成段繪製好像更麻煩了,多了一個拆段的步驟,爲什麼不直接連接點呢?這樣劃分相當於拆分了不同結構,那麼每個結構下的元素都有自己的定製化,可視化層面可能展示的樣式等等不同。比如說下面的,通過這樣的靈活拼裝,提升了擴展性,同時在其他方面也有優勢,下面會具體介紹。

2.3 實現曲線

2.3.1 貝塞爾曲線

前面我們簡單介紹了貝塞爾曲線,Canvas 也支持貝塞爾二次和三次曲線,通常使用三次貝塞爾曲線畫法。下面我們詳細講解一下。

Bézier curve(貝塞爾曲線)是應用於二維圖形應用程序的數學曲線。貝塞爾曲線點的數量決定了曲線的階數,一般 N 個點構成的 N-1 階貝塞爾曲線,即 3 個點爲二階。一般我們都會要求曲線至少包含 3 個點,因爲兩個點的貝塞爾曲線是一條直線。按順序,第一個點爲 起點 ,最後一個點爲 終點 ,其餘點都爲 控制點 。

下面以二次貝塞爾曲線爲例。

2.3.1.1 二次貝塞爾曲線

給定點 P0,P1,P2,P0 和 P2 爲起點和終點,P1 爲控制點。從 P0 到 P2 的弧線即爲一條二次貝塞爾曲線。

在這裏我們要將整個曲線的繪製量化爲從 0~1 的過程,用 t 爲當前過程的進度,t 的區間即 0~1。每一條線都需要根據 t 生成一個點,如下圖,一個點從 P0 移動到 P1,這是這條線從 0~1 的過程。

下面我們還原一下一個二次貝塞爾曲線的生成過程。

  1. 首先我們鏈接 P0P1,P1P2,得到兩條線段。然後我們對進度 t 進行取值,比如 0.3,取一個 Q0 點,使得 P0Q0 的長度爲 P0P1 總長度的 0.3 倍。

  1. 同時我們在 P1P2 上取一點 Q1,使得 P0Q0: P0P1 = P1Q1: P1P2。接下來我們再在 Q0Q1 上取一點 B,使得 P0Q0: P0P1 = P1Q1: P1P2 = Q0B:Q0Q1

現在我們得到的點 B 就是二次貝塞爾曲線的上的一個點,如果我們使 t=0 開始取值,逐步遞增進行插值,就會得到一系列的點 B,進行連接就會形成一條完整的曲線。

最終經過數據推導,我們得到了二次貝塞爾曲線公式(具體推導我們不搞了,感興趣可以去百度看看)。

2.3.1.2 三次貝塞爾曲線

三次貝塞爾曲線由四個點組成,通過更多的迭代步驟來確定曲線上的點。

2.3.2 使用 Canvas 繪製貝塞爾曲線

在 Canvas 中繪製三次貝塞爾曲線使用 bezierCurveTo() 方法,具體參數定義可以在 MDN 上查閱,這裏不羅列了。

2.3.3 樣條曲線與獲取段

瞭解瞭如何繪製三次貝塞爾曲線,我們回到實際場景,一個線圖會有若干個數量的點連接生成。但只使用 Canvas 提供的功能,並不能滿足這個需求。前面我們繪製折線是提出了段的概念,如果我們將一條完整的曲線拆分成多個段,每個段都是個三次貝塞爾曲線,問題好像就可以解決。那麼問題就轉化爲如何生成多個貝塞爾曲線且它們能平滑連接。

上面我們介紹概念時提出了樣條曲線,可能大家也沒看懂,是有些抽象。簡單將就是有一個點的集合,分成多段曲線,各曲線處的連接點處可以平滑連接,轉化成數學術語就是說連接點有連續的一次和二次導數且一次和二次導數相同。下面我們看個🌰

上面這個圖是由多個三次貝塞爾曲線拼接而成,我們要將其劃分前,需要確定幾個參數:

只有當我們選擇合適的起點、終點和控制點,相鄰的兩條曲線才能平滑連接。拆分算法很多,這裏不詳細介紹了(其實我也看不懂),我們實現可以直接用 d3-shape 的 Curves 接口。下面用 Basis 算法的實現用例,我們簡單瞭解一下。

getSegment(points, defined){
  segCache ← []
  totalLen ← 0
  if points.len < 3
    getSegment
  start, end, controll1, controll2
  for i ← 0 to points.len - 2
    first ← points[i]
    second ← points[i + 1]
    third ← points[i + 2]
    if i = 0
      start ← first
    else
      start ← end
    
    // 計算起點、終點、控制點
    // 計算長度
    // 補算最後點
}

這段邏輯也比較簡單,循環給到的點,從當前索引位置開始向後取三個點,根據這個三個點以及當前段的起始點計算結束點和控制點。每個新段的起點是上個段的終點。但是當前循環邏輯不會計算最後一個點,所以會少一段,最後加個單獨邏輯處理。

2.3.4 點的計算

我們用一個簡單的公式來計算各個點的值(公式結合 B 樣條曲線和三次貝塞爾曲線在端點處的一階和二階導出得到),這裏不介紹具體公式推導。

if (i === 0) {
  start = first
} else {
  start = end
}
end = Point((first.x + 4 * second.x + third.x) / 6, (first.y + 4 * second.y + third.y) / 6)
controll1 = Point((2 * first.x + second.x) / 3, (2 * first.y + second.y) / 3)
controll2 = Point((first.x + 2 * second.x) / 3), (first.y + 2 * second.y) / 3 )

2.3.5 曲線分割與長度計算

聽起來這不是一個容易的事情。由於貝塞爾曲線是插值函數,所以計算只能先對曲線進行切割,然後計算足夠小的這一小段的曲線近似長度,再累加。這個計算量有點大,不過有大神給了個思路 傳送門。

  1. 找到連接的點。假設我要在 t=0.25 的位置將當前曲線切分成兩條曲線,首先我們要知道點 B 的位置。根據公式代入即可。

  2. 獲取控制點。拿到點 B 之後,其爲第一段的終點,第二段的起點,我們需要計算控制點。根據數學邏輯,我們可以得出:

根據上面結論,拆分就很簡單了。(這塊代碼有點長,就不寫了)

  1. 長度計算。我們可以在任意位置對三次貝塞爾曲線進行拆分了,結合二分法,控制迭代次數,結合近似長度計算函數,我們可以得到想要精度的長度值了。(代碼也不寫了)

  2. 獲取段。現在我們需要處理最後一個點的特殊邏輯,這裏將第二個點和第三個點都用最後一個點表示。

first ← points[i - 2]
second ← points[i - 1]
third ← points[i -1]
start ← end
end ← third

···
···
  1. 曲線畫法。前面都準備好了,現在只需要調用 Canvas 的 API 就能畫線了。

2.4 怎麼處理動畫

前面我們遺留了一個問題,爲什麼需要計算長度?

我們已經完成了線的繪製,如何做少量的改動實現動畫呢?我們可以瞭解到不管直線和曲線,我們都分了很多段,而這些段都是和 t 相關的。

2.4.1 方案

動畫的本質就是在一定的時間內繪製某一部分區域,我們將整個線條區域劃分到 [0, 10] 區間,啓動一個循環,每次繪圖時更新 t 的值,在上面循環繪製 segment 的代碼中,將整條線圖的 t 轉化爲每一個段內部的 t 值,段內部根據 t 值對自身切割,只畫應該繪製的那部分即可。

由於我們已經計算了每個段的長度和總長度,所以每個段的佔比可以計算,此佔比再和整個線圖的 t 值進行換算即可。這個思路其實就是 局部繪製

但對於面積圖,其實會分爲兩組 segment 繪製,繪製時我們會發現在同一個 t 時,在 x 方向的位移是不同步的。繪製動畫從左向右推進,比如繪製第一段時,計算第一段應該被繪製的區間,最後填充上下兩段的閉合區間,但有個問題,如果相同的 t,代入不同組 segment 的函數中,產生的 x 值不一樣,那麼繪製的效果就不對了,切面會是斜的。

解決這個問題做法是根據 x 或者 y 值反求 t 值,再代入目標函數中。對於三次貝塞爾曲線來說,這又是一個大難題,由於篇幅所限及代碼實現的比較複雜,這裏不講了(其實我不會,但這有地方會)。

2.5 交互

交互無非是點一點,摸一摸。但從上面我們得知,一條線有那麼多點,怎麼知道鼠標觸發的是那個點呢?

2.5.1 Canvas 的拾取方案

繪製時 Canvas 不會保存繪製圖形的信息,一旦繪製完成用戶在瀏覽器中其實是一個由無數像素點組成的圖片,用戶點擊時無法從瀏覽器自帶的 API 獲取點擊到的圖形。常見的拾取方案有以下幾種:

上面的各種拾取方案各有利弊,下面來詳細的介紹各種方案的實現方式和一些問題,最後對比一下性能。

2.5.1.1 使用緩存 Canvas 方案

使用緩存的 Canvas 來進行圖形的拾取步驟如下:

優缺點
適合的場景和不適宜的場景
性能檢測

2.5.1.2 使用內置 API

Canvas 標籤提供了一個接口 isPointInPath() 來獲取對應的點是否在繪製的圖形內部,操作步驟如下:

優缺點
適合的場景
性能檢測

2.5.1.3 幾何包圍盒檢測方案

最開始我們提到了包圍盒,現在有了使用的地方。

Canvas 上繪製的圖形都是標準的幾何圖形,點、線、面的檢測在幾何算法中比較成熟,每個圖形在繪製時都會給其生成一個包圍盒並保存,當拾取圖形時可以直接使用數據運算檢測。

檢測過程如下:

優缺點
適合的場景
性能檢測:

2.5.1.4 混雜拾取

在實例的應用過程中並非使用某一種拾取方案,通常將多種拾取方案混合使用,大致分爲以下方案:

2.5.1.5 總結

在 Canvas 上拾取圖形時的方案選擇與用戶的場景密切相關,不同的場景適用的方案也不同:

3. 總結

上述全文介紹了什麼是可視化,緊接着我們分析了線圖的實現方案以及圖形的交互實現。總結來說,可視化無時無刻不存在在我們身邊,看起來好像充滿神祕色彩,但我們仔細研究會發現,實現可視化並不是一件難事,上述流程如果有出錯的地方,還請批評指正。

4. 本文引用

  1. G 渲染引擎文檔

  2. 貝塞爾曲線

  3. ByteCharts 實現文檔

  4. BizCharts

  5. D3

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/T6bn2B1ONFuMhqhm-x6upg