[JavaScript][D3] d3.js 入門 #1

  • 295
  • 0
  • D3
  • 2022-03-28

本文是針對 d3.js 的入門介紹 #1

d3.js 是一個知名的 JavaScript 程式庫。所謂的 "d3", 實際上是 "Data-Driven Documents" 的縮寫。根據官網上的定義, 這個程式庫主要是用來做「由資料驅動的文件操作」。不過, 它較為令人熟知的功能, 在於它令人驚艷的繪圖支援。大家可以在它的官網上看到眾多範例(多數都有原始碼可參考):

d3.js 可幫助開發者以更方便的方式在網頁中加上 CSS 和 SVG 元素, 使得數據可以很快地繪出為圖表。例如, 我們可以透過 d3.js 將一組資料以 HTML 表格呈現, 同樣地, 也可以繪製成 SVG 格式的圖形。這就是 d3.js 最擅長的事情。

選取

在我們要使用 D3 做些什麼之前, 我們通常需要選取某個 DOM 元素, 然後對它進行操作。

舉個例子, 我們可以很簡單地將文件中某一個或一群元件予以選取, 然後變更 style 設定:

<ol id="Countries" name="Countries">
  <li>
    <span class="CountryName">Taiwan</span>
    <span class="Area">36193</span></li>
  <li>
    <span class="CountryName">Japan</span>
    <span class="Area">377972</span></li>
  <li>
    <span class="CountryName">Philippines</span>
    <span class="Area">300000</span></li>
  <li>
    <span class="CountryName">Indonesia</span>
    <span class="Area">1904569</span></li>
</ol>

 

d3.selectAll('#Countries .CountryName')
	.style("color", "red");

跟許多 JavaScript 程式庫一樣, 元素的許多屬性都可以使用「資料函數」的型式予以指定。以最簡單的方法示例, 我們可以把上一段程式改寫成如下:

d3.selectAll("#Countries .CountryName").style("color", function() {
  return "red";
});

不過, 如果沒有資料的話, 上面這段程式一點意義也沒有。我們應該如何引進資料呢? 請將原程式改成如下:

// 程式1
d3.selectAll('#Countries .CountryName')
	.data(['#0f8','#f0f','#8f0','#880'])
	.style("color", function(d, i) { 
		return d;
	});

資料繫結

在上面這個例子中, 使用 '#Countries .CountryName' 可以選取到四個 DOM 元素, 然後我們使用 data(['#0f8','#f0f','#8f0','#880']) 指定一個資料陳列, 再透過 style() 函數, 以 function(d, i) 的方式依次把 '#0f8'、'#f0f'、'#8f0' 和 '#880' 這四筆資料「餵」給這四個 DOM 元素, 讓它們的前景色依所繫結的資料而變更。

如果你急著想看執行結果的話, 可以看看這個 jsFiddle

在前面範例裡 function() 或 function(d) 和 funcion(d, i) 是固定型式的函式寫法, 也是 JavaScript 的標準委派型式 (delegate)。如果帶參數的話, 第一個參數指的是資料本身(data), 第二個參數則是迴圈的順序 (index)。當然, 你不一定要把它們命名為 d 和 i, 你可以隨便命名, 只要順序對了就行。

在上一個例子中, 資料有四筆, 而寫死的 HTML 裡也剛好有四個 <li> 元素。在一個蘿蔔一個坑的情況下, 上面的範例程式的寫法一點問題都沒有。

但是, 萬一資料筆數跟 DOM 元素對不起來, 應該怎麼辦? 

假設我們的 HTML 像如下的樣子:

<ol id="Countries" name="Countries">
  <li id='info' name='info'></li>
</ol>

很明顯地, 在這裡只有一個 li 元素。不過, 如果我們的資料有四筆:

var data = [['Taiwan', 36193], ['Japan', 377972], ['Philippines', 300000], ['Indonesia', 1904569]];

我們要如何透過 D3 把這四筆資料「餵」給這一個元素, 讓它可以顯示四筆資料?

在這種狀況下, 我們必須使用 D3 所提供的 enter() 函式, 讓它幫我們建立原本不存在的 DOM 元素。原理是什麼呢?

基本上, 當你使用 D3 的 select() 函式之後, 它會傳回所有選取到的 DOM 元素的陣列物件。在本例中, 你可以透過 d3.select('body #Countries').selectAll('#info') 選取到一個只有一個元素的陣列。但是, 當你再下達 data(data) 指令後, 它會傳回一個 length = 4 的陣列, 但是只有第一個元素存在 (就是那個寫死的 <li> 元素):

但是, 當你對上述這個物件串上 enter() 函式之後, 它會傳回一個雖然 length 也是 4, 但是由於剛才第一筆資料被用掉了, 剩下其餘三筆可以被繫結:

基本上, 記得你必須先下達 enter() 才能下達 append(); 順序不能搞錯。

附加與移除DOM元素

在上例中, 如果我們再串上 append('li') 函式, 它會在選取到的 DOM 元素之後新增我們指定的元素; 在本例中即是 <li></li>。換句話說, 原本我們只寫死了一個 <li> 元素, 到這裡為止, 它會變成四個。當然, 如果你指定的資料有五筆, 它就變成五個, 依此類推。

現在, 我們可以使用 text() 函式把資料套在剛建立的 <li> 元素上面。我們在第一個範例中已經看過 style() 函式的寫法; text() 函式的寫法是一樣的。

可是問題來了。你會發現我們原本寫死的那個 <li> 元素並不會被套用資料。因為當我們使用 append() 函式之後, 只有新增的 DOM 元素會被影響, 但新增之前的原始元素並不會受影響。因此, 我們必須回頭把原始元素重新套用一次。最後的寫法如下:

// 程式2
d3.select('body #Countries')
	.selectAll('#info')
	.data(data)
  .text(function(d) { 
	return d[0] + ' ' + d[1];
  })
  .enter().append('li')
  .text(function(d) { 
	return d[0] + ' ' + d[1];
  })

範例程式的原始碼請看這個 jsFiddle

上一個範例所處理的是選取的 DOM 少於資料數量的情況; 如果倒過來呢?

基本上, 我們只需記得 enter() 和 exit() 方法是相反的。參考以下範例:

let uEnter = d3.select('body #Countries').selectAll('li').data(data).enter(),
  uExit = d3.select('body #Countries').selectAll('li').data(data).exit();

假設這裡 data 陣列裡有五個元素, 而你的 #Countries 底下已經有三個 li 項目, 那麼這裡的 uEnter 就會記錄多出來的兩個項目 (5-3 = 2)。這時你再對 uEnter 下達 append('li') 就會新增兩個 li 元素。

但假設你的 #Countries 底下已經有八個 li 項目, 那麼這裡的 uExit 就會記錄不夠的三個項目 (5-8 = -3)。這時你再對 uExit 下達 remove() 就會把那三個 li 元素移除。

因此, 如果選取的 DOM 元素個數大於資料數量, 我們就可以使用 exit().remove() 函式, 把多餘的 DOM 元素拿掉。範例程式請看這個 jsFiddle。這個範例程式雖然可以正確說明 exit().remove() 函式的效果, 它所展示的情境並不合理。或許你可以從這個範例中看出問題的所在, 以及如何更正確地應用在適切情境的方法。

一般來講, 我們可以使用一種情境來說明 enter() 與 exit() 的合理使用方式。假設我們在設計一個動畫, 移進場景中的元素, 我們就利用 enter().append() 把它新增進來; 至於移出場景的元素, 我們就用 exit().remove() 把它們移除。這樣是不是好記多了?

上一個範例只是用來示範 D3 的選取方法而已。實際上, 我們並不需要以那種方法來特別針對第一個元素做動作。我們通常都讓 D3 直接建立新元素, 如這個 jsFiddle 所示。或者, 我們也可以使用 remove() 方法將原容器中既有元素全部清除, 然後再重新產生子元素, 如下範例:

// 程式3
var data = [['Taiwan', 36193], ['Japan', 377972], ['Philippines', 300000], ['Indonesia', 1904569]];

let c = d3.select('body #Countries');
c.selectAll('li').remove();
c.selectAll('li')
  .data(data)
  .enter()
  .append('li')
  .text(function(d) { 
	return d[0] + ' ' + d[1];
  });

上述程式可以很簡單地使用 join() 方法來取代。join() 是在 d3 的第五版之後才提供的, 它可以將 data 的內容很明確地擊結到指定的地方。換句話說, 你無需考慮原來有多少元素, 以及元素的個數與資料的長度是否吻合; 你的資料有幾個, 它就自動生成幾個元素。

因此, 上述程式可以使用 join() 改寫如下:

// 程式4
var data = [['Taiwan', 36193], ['Japan', 377972], ['Philippines', 300000], ['Indonesia', 1904569]];
let c = d3.select('body #Countries')
  .selectAll('li')
  .data(data)
  .join('li')
  .text(function(d) { 
    return d[0] + ' ' + d[1];
  });

採用這種寫法, 就不必再使用 enter()、append()、exit()、remove() 之類的方法了。

然而, 如果你要使用 filter() 對資料進行篩選的話, 它配合 join() 做出來的效果並不好,

// 程式5
var data = [['Taiwan', 36193], ['Japan', 377972], ['Philippines', 300000], ['Indonesia', 1904569]];
let c = d3.select('body #Countries')
c.selectAll('li')
  .remove();
c.selectAll('li')
  .data(data)
  .join('li')
  .filter(function(d,i) { return d[1] >= 300000; })
  .text(function(d) { 
    return d[0] + ' ' + d[1];
  });

結果會有空項跑出來:

因此, 你仍可考慮採用之前提到的做法, 也就是先以 selectAll('li').remove() 將容器清空, 然後再以 append() 以附加各個元素的做法, 如程式3所示。

不過, 在實務上, 將資料進行 filter() 之後再套用 text() 的做法是非常不適當的, 因為 filter() 並不會將 data 本身進行修改或刪除。所以如程式5的寫法基本上是錯誤的。如果你要讓輸出的項目有所改變, 你應該回頭去修改 data 本身再來做資料繫結 (無論是透過 append() 或者 join())。但反過來, 如果你套用的不是 text() 而是 style() (例如變成元素的字體或者顏色), 那麼在這種情境下使用 filter() 方法就很適合了。

轉場

D3 提供了很簡單易用的變型和轉場效果, 可以讓我們輕鬆做出吸引人的視覺效果。

當你選取某一個或幾個 DOM 元素之後, 我們可以使用 transition() 函式以套用轉場效果。接著, 我們可以串上 duration() 函式, 指定轉場效果持續的時間, 或者串上 delay() 函式指定轉場以前等待多久。如果你已經熟悉 jQuery 的話, 這個 transition() 基本上和 jQuery 的 animate() 函式很像, 只是寫法不太一樣而已。

在以下範例中, 我使用 transition() 函式做了一個具動畫效果的簡單的柱狀圖。你可以看到各種參數都是以串接的方式加上去的, 和前面幾個範例一樣:

d3.selectAll('#Countries .Area')
  .data([36193, 377972, 300000, 1904569])
  .transition()
  .duration(5000)
  .delay(10000)
  .style('width', function(d,i) {
  	return d/5000 + 'px';
  });

範例程式請看這個 jsFiddle

最後, 把本文介紹過的技巧綜合展示如下。

HTML:

<div id="divEntry">
  <svg id="mySvg" width="340" height="200">
  </svg>
</div>

JavaScript:

var svg = d3.select('#mySvg')

function genRandomInteger(min, max){ // Gen random integer in range
  if (min > max) // 防呆
    max = min+10;
  return Math.floor(Math.random() * (max - min + 1) ) + min;
};

function right(str, num) { // Return the right most chars
  if (str===undefined) return ''; // 防呆
  else
    return str.substring(str.toString().trim().length-num, str.length)
}

function run() {
  let data = [ 50, 43, 120, 87, 99, 167, 142, 8, 155 ],
    maxData = d3.max(data);
  let padLeft = 10,
    padDown = 10
    step = 35,
    barWidth = 30,
    height = d3.max(data)+20,
    width = data.length * barWidth + 10,
    randomB = right('0'+genRandomInteger(0,215).toString(16), 2),
    randomR = right('0'+genRandomInteger(0,215).toString(16), 2);
    
  let rect = svg.selectAll('rect')
    .data(data)
    .enter()
    .append('rect')
    .attr('fill', function(d) { // Fill with random color
      return '#' + randomR +
      	right('0' + (maxData-d).toString(16), 2) + randomB })
    .attr('stroke', 'black')
    .attr('x', function(d,i) {
      return padLeft + i * step;
    })
    .attr('y', function(d,i) {
      return height - padDown - d;
    })
    .attr('width', barWidth)
    .attr('height', function(d) { return d; })
    .transition()
    .duration(2000)
    .delay(500)
    .attr('fill', function() { // Morph bars to blue
      return 'blue';
    });
}

run();

範例程式請看這個 jsFiddle


Dev 2Share @ 點部落