React + D3 的正確姿勢

分享會大綱

  1. D3 基本介紹
  2. scale and range
  3. domain and endpoint
  4. time format and number format
  5. select, append, and attr
  6. data
  7. Axis、ticks
  8. chart event and tooltip
  9. transform
  10. DEMO

什麼是 D3.js

D3.js 是一套使用動態圖形進行資料視覺化的 JavaScript 函式庫,透過 D3 的細部設定可以讓工程師設計各種圖表,在 D3 官網中的 Gallery 可以看各種神人設計出來的圖表。

這次的分享會主要會是以 d3v4 為主,再加上網路上很多圖表的分享也幾乎都是以 v4 而不是 v5 為主,要注意的一點是 d3 每一個版本的寫法基本上都有點落差,所以這邊的寫法不見得可以完美的套用在 v5 上面喔!

scale and range

scale: 可以想像成比例尺的概念,比例尺就是為了讓我們能在有限的空間內顯示完整的內容,其實很多圖表都會有比例尺,像 google map 底下就會有個比例尺方便使用者進行畫面的調整。

所以在使用 D3 開始繪製圖表前我們必須要先定義圖表的比例,這樣圖表才會依照我們想要的方式顯示出來,如果不定義比例的話隨便一張圖表應該都會超過網頁能顯示的範圍了XD

D3 的比例尺有非常多的設定方式,以下會用最常用的兩種比例尺來做說明,想了解更多其他的比例尺可以參考這個網站

d3.scaleBand()

非連續性的比例尺,適用於非連續性質的資料,舉例來說:性別分為男、女,兩者是沒有連續性的是獨立的這種就適用於非連續性的比例尺。

d3.scaleLinear()

連續性的比例尺,適用於連續性質的資料,舉例來說:時間、數值等等每個時間或數字其實都可以經過一些算法而找到彼此的關聯性,這種就是連續性的資料就適用於連續性的比例尺。

簡單小總結一下,當你的資料是無法利用一套計算方法找到彼此關聯性的,像是男生無法經由計算得到女生,這種就是非連續性的資料,反之可以利用一些計算方法找到關聯性的,像是數字與數字之間可以用減法找到關聯性,這種就是連續性的資料。

range: 當我們設定完圖表比例尺後,要將這個圖表輸出至這個 range 的範圍內,至於內部的做法可以用映射的方式來思考,簡單來說我們會將輸入的資料一個一個照比例的方式對應到 range 中被切割好的範圍上,至於 range 中間是怎麼切割的我們不用理他 D3 都會幫我們處理好.

通常在撰寫 range 的設定時都會用一個陣列來代表範圍, array[0] 代表範圍的起點, array[1] 代表範圍的終點,根據 x 以及 y 的不同會設定不同的起點以及終點,在 x 軸通常起點會設為 0 而終點會設為該圖表的寬度,畢竟我們在看 x 方位的時候是由左到右。而 y 軸起點則是會設定成圖表高度而終點會設定為 0 ,因為數值越高一定會在越上面.

range 寫法:

              
                const x = d3.range([0, width]);
                
                const y = d3.range([height, 0]);
              
            

domain and endpoint

domain 是 D3 資料的輸入區域範圍,還記得上面提到有了 domain 的輸入區域才能把資料映射到 range 的輸出區域上,就像下面這張圖。

既然 range 有輸出範圍想當然 domain 也會有輸入範圍,接下來要來談談最常用的幾種方法來取得資料的範圍,以下幾種方法都必須要把資料中想擺在座標上的數值都組合成一個陣列並且傳進去當參數,這邊建議用 array.map() 的方式進行資料的組合。

d3.max()

取出陣列的最大值。

              
                const maxValue = d3.max([1, 2, 3, 4, 5]);

                console.log(maxValue); // 輸出 5
              
            

d3.min()

取出陣列的最小值。

              
                const minValue = d3.min([1, 2, 3, 4, 5]);
                
                console.log(minValue); // 輸出 1
              
            

d3.extent()

d3.max() 以及 d3.min() 的綜合體,同時取出陣列的最大以及最小值。

              
                const extentValue = d3.max([1, 2, 3, 4, 5]);
                
                console.log(extentValue); // 輸出 [1, 5]
              
            

scaleLinear v.s. scaleBand

至所以這邊會突然提到上面提到的 scale 比例尺問題,是因為這兩種比例尺最大的差別就在於 domain 要傳入的東西,接下來就會好好的把這兩種比例尺的差別告訴大家!

d3.scaleLinear()

由於是線性比例尺,因此比例尺上的點都可以經由計算去給出最適當的映射比例,因此在 `d3.scaleLinear()` 上只要給定 domain 的範圍即可。

d3.scaleBand()

由於不是線性比例尺,因此比例尺上的點無法經由計算去給出最適當的映射比例,因此在 `d3.scaleBand()` 上只要是要擺在 domain 上的資料全部都要餵給 domain 才行。

接下來這個部分跟圖表的設定就比較沒有關係了,純粹是我有點強迫症,有時候看到圖表 y 軸沒有完整的把數值都列出來就會覺得很阿雜,就像下圖這樣:

不曉得大家看到這種圖表的時候會不會很想修改一下,至少把 y 軸完整的畫出來,所以我就想了一個方法來解決這個問題,也順便把這個點稱作為 endpoint 代表範圍的終點,接下來就自肥一下把取得這個 endpoint 的方法稱作為 `getSmartEndpoint()` 吧XD

這邊以 step 代表最大值位數一樣的最小值,count 代表位數

  1. 先計算出這個最大值是幾位數。以 10 的次方為單位湊出這個 step,例如 123 就會是 100 。
  2. 為了讓最後 y 軸的最大值離我們原始資料的最大值最接近,這邊會以 5 的倍數為基準去改變剛剛算出來最大值位數的最小值。
  3. 重新計算最大值與新 step 的差距倍數,並利用 Math.ceil() 進行無條件進位。
  4. 最後再利用剛剛算出來的倍數以及新的 step 的相乘得到最後的 endpoint 。

最終寫法如下:

              
                // val 為 d3.max() 得到的資料最大值
                function getSmartEndpoint(val) {
                  // 先取得最大值的位數,並算出這個位數的最小值
                  let count = Math.floor(val).toString().length - 1
                  let step = Math.pow(10, count)
                
                  // 以 5 的倍數為基準,假如最大值除以此位數的最小值小於 5
                  if (val / step < 5) {
                  // 將這個位數最小值砍半,這樣之後就會是以 5 為基準了
                    step = step / 2
                  } 

                  count = Math.ceil(val / step)
                
                  return count * step
                }
              
            

time format

一般來說 JS 用來處理時間的套件相信大家首選都是 Moment.js 或者是 Day.js,既然都在講 D3.js 了,所以接下來要來介紹 D3 自己做的用來處理時間的 method : d3.timeParse()。

d3.timeParse()

d3.timeParse() 是 D3 用來處理時間格式的 method ,如果要在 D3 的圖表中繪製跟時間有關的圖表都必須要利用 d3.timeParse() 先進行時間的轉換, d3.timeParse() 本身是一個 function 而且也會回傳一個 function ,寫法像下面這樣:

              
                const parseTime = d3.timeParse();
                parseTime(time);
              
            

常用一些 time format 工具的你應該會發現好像少了什麼東西,一般來說這種處理時間的 method 應該都要帶一些參數進去才對,像 Moment.js 在設定時間時會這樣寫:

              
                moment().format('MMMM DD YYYY, h:mm:ss a');
              
            

介紹幾個常用的參數及作用

知道這些參數之後就可以套用到 d3.timeParse() 中啦!

              
                const date = '2021-09-09';
                const parseTime = d3.timeParse('%Y-%m-%d');
                const newDate = parseTime(date);
                console.log(newDate);   // Mon Sep 09 2021 00:00:00 GMT+0800 (台北標準時間)
              
            

溫馨小提醒:

在使用 D3 進行時間轉換的時候要記得參數格式必須要跟傳進來的資料格式一樣,舉例來說今天有個日期是 '09-09' 這時候參數就必須要設定成 '%m-%d' ,如果格式不一樣的話最後得到的結果就會是 NULL 喔!

介紹了 d3.parseTime() 後,雖然時間是順利轉換了 D3 也可以順利的讀懂了,但是有個問題是這個轉換過的格式我們工程師看不懂啊!!! 所以接下來要來介紹 d3.timeFormat() 這樣就可以順利地轉換成我們看得懂的格式了,不過使用 d3.timeFormat() 的時機是在 Axis 上,這個會在之後提到,這裡對 Axis 稍微有個印象就好。

d3.timeFormat() 的寫法就跟 d3.parseTime() 一樣,差別在於 d3.timeFormat 是自己可以設定之後日期的顯示格式,所以就不用按照原始資料的日期格式來進行參數的配置,這邊想怎麼寫就怎麼寫,寫法如下:

              
                const date = '2021-09-09';
                const parseTime = d3.timeParse('%Y-%m-%d');
                const newDate = parseTime(date);
                console.log(newDate);      // Mon Sep 09 2021 00:00:00 GMT+0800 (台北標準時間)

                const timeFormat = d3.timeFormat('%Y/%m/%d');
                const normalDate = timeFormat(newDate);
                console.log(normalDate);   // 2021/09/09
              
            

與 domain 的結合

最後就來做個簡單的組合並且把處理過後的資料丟到 domain 吧!還記得前面提到 scale 中有一個 d3.scaleLinear() 嗎?通常用到 d3.timeParse() 的都會使用連續比例尺,藉由 d3.timeParse() 我們就可以把普通的時間字串轉為 D3 可以進行計算用的時間單位,如此一來便可以利用 d3.scaleLinear() 的特性讓 domain 只需要填入範圍值即可,接下來就用個簡單的範例碼來組合這幾個內容吧!

              
              // 設定 x 座標的比例尺
              const width = 300;
              const x = d3.scaleLinear().range([0, width]);

              // 進行時間轉換讓 D3 在繪製的時候不會出錯
              const originData = ['2021-09-09', '2021-09-10', '2021-09-11'];
              const parseTime = d3.timeParse('%Y-%m-%d');
              const newData = originData.map(data => parseTime(data));

              // 將轉換過的時間丟進 domain 中
              x.domain(d3.extent(newData));
              
            

number format

number format 基本上跟剛剛講的 time format 是差不多的觀念,只差在一個是用來處理數字另一個是用來處理時間

參數

轉換結果

但看了這些轉換應該會覺得,有些結果後面真的太多 0 了,有沒有辦法可以一鍵刪除沒有用處的 0 ,其實 D3 也幫你想好了,只要在參數前面加個 ~ 就可以刪掉那些多餘的 0 了,不過這個 ~ 只適用在 e 、 r 、 % 以及 s 這四種參數上喔!

              
                const eFormat = d3.format('~e');
                const rFormat = d3.format('~r');
                const percentFormat = d3.format('~%');
                const sFormat = d3.format('~s');

                const originNumber = 12345;

                console.log(eFormat(originNumber));          // 1.2345e+4
                console.log(rFormat(originNumber));          // 12345
                console.log(percentFormat(originNumber));    // 1234500%
                console.log(sFormat(originNumber));          // 12.345k
              
            

溫馨小提醒:

經由 d3.format() 轉換後的數值,其型態會是字串而非原本的數字,這樣就不能使用 d3.scaleLinear() 這個連續比例尺了!

select、append、attr

select 在做的事情就是負責選取,選取的東西就是 DOM 上的節點,其實 D3 可以選取的東西有兩種,一種是 selector 另一種是 node 非常的彈性,

  • selector: 利用 CSS selector 的方式來取得元素,所以寫法就跟以前在寫 jQuery 的時候很像,只是把 $ 改成 d3.select() 而已。
  • node: 透過 DOM 操作來取得元素,推薦使用 `document.querySelector` 的方式來取得元素
              
                const selectedNode = d3.select('body');
              
            

append 顧名思義就是添加的意思,在 d3.append() 中就是負責新增 Node 的 method ,一定要先選取過後才可以進行添加 Node 的動作,不然 D3 也不知道要把這個 Node 新增在哪邊。

              
                const selectedNode = d3.select('body');
                const appendNode = selectedNode.append('svg');
              
            

attr 是 attribute 的縮寫,所以 d3.attr() 就是負責替這個 Node 添加 attribute 的 method, d3.attr() 一共會帶兩個參數,順序為: attributeName 以及 attributeValue

              
                const selectedNode = d3.select('body');
                const appendNode = selectedNode.append('svg');
                const attrNode = appendNode.attr('width', 300);
              
            

最後來組合上面提到的內容吧

              
                const width = 300;
                const height = 300;

                const svg = d3
                  .select('body')
                  .append('svg')
                  .attr('width', width)
                  .attr('height', height)
              
            

data

selectAll: 跟 select 很像,只是 selectAll 會把所有的元素都取出來,不像 select 只會取第一個匹配的元素

data 就是要把資料帶給 D3 讓他能正式繪製這些圖表,這邊有個小觀念是 d3.data() 這個 method 只支援 array type,所以在傳遞資料前一定要用陣列包起來,而且 d3.data() 只會解析陣列第一層的元素

              
                const data1 = [1, 2, 3, 4, 5]
                const svgData1 = d3.data(data1)    // 傳進去的為陣列中的元素: 1, 2, 3, 4, 5

                const data2 = [[1, 2, 3, 4, 5]]
                const svgData2 = d3.data(data2)    // 傳進去的為 [1, 2, 3, 4, 5]
              
            

data 內部操作

D3 在處理 data 時一共有三個操作,分別是 enter、update、exit

enter: 當原始資料的數量 大於 選取元素數量時,必須要使用 d3.enter() 的方式才能將資料正確的餵給元素, d3.enter() 會自動建立與原始資料數量相同的元素,以便於資料可以正確地輸入到每個元素中。

update: 當原始資料的數量 等於 選取元素數量時,這些元素以及資料相互對應就稱為 update。

exit: 當原始資料的數量 小於 選取元素數量時,必須要使用 d3.exit() 的方式才能將資料正確的餵給元素, d3.exit() 會自動刪除元素直到與原始資料數量相同,以便於資料可以正確地輸入到每個元素中。

原始資料數量大於選取元素數量

原始資料數量小於選取元素數量

其實 d3.data() 是可以利用 callback 的方式來幫助解析到更深層的陣列結構

              
                const data = [[1, 2, 3, 4, 5]]
                const svgData = d3
                  .data(data)
                  .enter()       // 這時候傳進去的 data 為 [1, 2, 3, 4, 5]
                  .data(d => d)  // 這個 d 為 [1, 2, 3, 4, 5]
                  .enter()       // 這時候傳進去的 data 為 1, 2, 3, 4, 5
              
            

Axis、ticks

還記得前面在介紹 D3 的時候有講到 domain 這個輸入區域嗎?其實 domain 跟 Axis 可說是非常息息相關的存在,我們會將設定好的 domain 傳入 Axis ,這樣就可以順利的把 domain 的資訊擺到座標軸上了,而座標軸一共有四種方向可以擺放,分別為: d3.axisTop() 、 d3.axisBottom() 、 d3.axisLeft() 以及 d3.axisRight() ,寫法如下:

              
                // 設定 x 軸 domain
                const x = d3.scaleLinear().domain()
                // 將 x 軸資料擺放在底部
                const xAxis = d3.axisBottom(x)

                // 設定 y 軸 domain
                const y = d3.scaleLinear().domain()
                // 將 y 軸資料擺放在左邊
                const yAxis = d3.axisLeft(y)
              
            

ticks 是用來決定這個座標軸需要顯示哪些東西,有的時候為了要讓座標軸看起來比較乾淨一點就會想省略一些中間的座標,這時候就可以透過 ticks 進行調整,而 D3 針對 ticks 也提供了許多 method 來設定:

axis.ticks()

用來設定座標軸上座標的數量,這邊數量也不是想設多少就能顯示多少,基本上 D3 對於 ticks 的數量只有 5 的倍數可以使用,假如今天設定 axis.ticks(6) 這時候的座標其實顯示不止 6 個,這邊要特別注意一下喔!

axis.tickValues()

客製化座標軸上的值,如果使用了 axis.tickValues() 這時候的座標軸就不會以 domain 內的數值進行顯示而是以 axis.tickValues() 的數值進行顯示,所以 axis.tickValues() 吃的參數也是以陣列為主。

axis.tickFormat()

還記得上面提到的 d3.timeFormat() 以及 d3.format() 嗎?這兩個用來處理時間格式以及數字格式的 method ,想要在座標軸上面顯示這些客製化後的格式就要利用 axis.tickFormat() 啦!只要把上面兩個 method 當作參數傳進去 axis.tickFormat() 內就可以得到想要的格式了。

axis.tickSizeInner()

用來設定座標軸上每個座標的分隔線條,若 axis.tickSizeInner() 的參數設定為負值,分隔線條會往坐標軸上方繪製,反之則會往下方繪製。

axis.tickSizeOuter()

用來設定座標軸上的外框,若 axis.tickSizeOuter() 的參數設定為負值,外框會往坐標軸上方繪製,反之則會往下方繪製。

axis.tickSize()

axis.tickSizeInner() 與 axis.tickSizeOuter() 的結合,可以帶兩個參數,其順序為 Inner 之後才是 Outer ,所以如果懶得寫兩種 method ,不妨可以用 axis.tickSize() 來合併上面兩個設定座標軸線條以及外框的 method 。

如何正確繪製座標軸

這邊要用到 d3.call(),這個 D3 用來調用 function 的 method,我自己的習慣而言會先把座標軸的設定都寫好,這樣就不用一直複製很多程式碼,直接用 call function 的方式就可以了,或者懶得寫很多行也可以利用 d3.call() 的方式把這些 method 串在同一行來增加程式碼的美觀程度,寫法如下:

              
                svg.call(d3.axisBottom(x).tickSizeOuter(0));
              
            

chart event、tooltip

chart event 就是要幫圖表加上一些可以跟使用者互動的 event,寫法上也很簡單跟 jQuery 在綁定事件的寫法很像:

              
                svg
                  .on('mouseover', tooltip.show)
                  .on('mouseout', tooltip.hide)
              
            

tooltip 存在的意義就是為了讓圖表可以顯示更多的資訊,透過上面所教的圖表 event 讓使用者除了可以跟圖表進行互動外,同時也可以藉由這個互動得到更多的圖表資訊。

d3-tip

結合剛剛說的:

              
                // 將 d3-tip import 進來
                import d3Tip from 'd3-tip'
                              
                // 設定 tooltip
                function setTooltip() {
                  // 初始化 tooltip function
                  const tooltip = d3Tip()
                    .attr('class', 'd3-tip')
                    .offset([-14, 0])
                  
                  // 設定 tooltip 內容
                  tooltip.html(
                    d => `
                      
${d.name}
${d.value}
` ) // 回傳 tooltip function return tooltip } // 傳入設定好的 svg 中 const tooltip = setTooltip() svg.call(tooltip)

transform

transform 這個屬性就跟 CSS transform 一模一樣,做的就是移動位置,致所以用 transform 而不是用 top 這些屬性的原因是 transform 不會經過 reflow 可以增加網頁的效能.

未加 transform 的效果

加了 transform 的效果

              
                const margin = { top: 25, right: 25, bottom: 25, left: 25 }
                const width = 300
                const height = 300

                const svg = d3
                  .select('body')
                  .append('svg')
                  .attr('width', width - margin.left - margin.right)
                  .attr('height', height - margin.top - margin.bottom)
                  .append('g')
                  .attr('transform', `translate(${margin.left}, ${margin.top})`)
              
            

這邊可能會覺得奇怪怎麼這邊加了一個 group 的元素,其實道理很簡單因為今天要平移的是 svg 內全部的圖表,大家可以想想看假如我今天只有平移外層的 svg 容器但內層的圖表卻沒有平移,這樣一定無法完整顯示全部的圖表,所以在平移的時候都會平移內層的圖表,外層只需要當作容器進行擺放而已,所以這邊用個 group 包起來,這樣底下的圖表也都可以吃到這個 group 的設定了。

It's DEMO time

範例碼連結可以點這邊