D3.js —— 繪製柱狀圖(一)

tonychen發表於2023-02-04

一、前言

1.1、D3.js 是什麼?

D3.js 是一個基於 JavaScript 開發的庫,主要是用於在瀏覽器中操作 SVGHTMLCSS ,通常我們可以利用它來進行一些圖表繪製的工作。

1.2、為什麼要學 D3.js

在我們學某種技術之前,最好是能夠帶有一些比較明確的目的,那麼在學習的過程中就不容易喪失目標,最後只學到皮毛。

在我看來,學 D3 主要是有這幾個方面:

  1. 感興趣,希望在業餘時間能夠學習自己感興趣的一些技術;同時 D3 也具有一定的複雜度,也可以拓寬自己的技術廣度
  2. D3 可以很方便的繪製一些互動式的圖表,同時相比業界現成的元件庫更加的定製化,可以靈活的根據自己的需求來實現一些功能
  3. 工作中的一部分內容涉及到了圖表互動,苦於一些元件庫的定製化程度不高,或者是文件太過於繁雜,難以滿足一些需求

因此,在這些動機的促使下我決定學習 D3 這個庫是怎麼使用的。

二、基礎知識

2.1、常用 API

2.1.1、選擇節點

API功能描述
d3.select用於選中某個 dom 元素,類似於 document.querySelector
d3.selectAll用於選中多個 dom 元素,類似於 document.querySelectorAll

2.1.2、修改節點

API功能描述
selection.text("content")獲取或者返回當前選中節點的文字內容
selection.append("element name")新增節點到當前已選中節點的末尾處
selection.insert("element name")插入節點到當前已選中節點
selection.remove移除指定節點
selection.html("content")獲取或返回當前已選中節點的 html
selection.attr("name", value)獲取或設定當前已選中節點的屬性
selection.property("name", value)同上
selection.style("name", value)獲取或設定當前已選中節點的樣式
selection.classed("css class", bool)獲取、刪除或者新增類名到當前已選中的節點

2.1.3、增刪節點

API功能描述
selection.data將 data 繫結到節點上
selection.join基於繫結的 data 可實現 enter、update、exit 三種函式,更加精細的控制實際的效果
selection.enter獲得進入的 selection
selection.exit獲得退出的 selection
selection.datum獲取/設定節點資料

更多 API 請參考 官方文件

2.2、鏈式呼叫

為了方便使用,D3 支援了鏈式呼叫。如上面的 API 所示,D3 裡面分成這兩類方法:

  1. d3.select / d3.selectionAll
  2. d3.selection.function
  3. 其他(以後再講)

因此,我們必須要有一個 selection 物件才能夠使用其他操作方法。如:

d3.select('div')
  .append('svg')
  .attr('width', 100)
  .attr('height', 100)

2.3、座標系

需要注意的是,在 D3.js 繪製的 svg 中,座標原點是在左上角的。

座標系.drawio.svg

三、實戰

3.1、第一步 —— 建立 svg

建立一個寬高為 400 x 33 的 svg

const width = 400
const height = 33
const svg = d3.create("svg")
  .attr("width", width)
  .attr("height", height)
  .attr("viewBox", `0 -20 ${width} 33`);

3.2、第二步 —— 生成隨機字元

定義一個生成隨機字元的函式。如下所示,我們會生成隨機的字元,最多為 26 個大寫的字母。程式碼如下:

function randomLetters() {
    return d3
      .shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''))
      .slice(0, Math.ceil(Math.random() * 26))
      .sort()
  }

目前到這裡還算比較簡單。

3.3、第三步 —— 更新字串

根據生成的隨機字元,把資料繫結到對應的節點上。其中,我們把新增、更新、刪除的節點顏色分別設定為:

綠色、紅色、金色

const t = svg.transition().duration(750);

svg.selectAll("text")
  // 繫結資料
  .data(randomLetters(), d => d)
  .join(
    // 設定新增的字元
    enter => enter.append("text")
      // 設定顏色
      .attr("fill", "green")
      // 設定位置
      .attr("x", (d, i) => i * 16)
      .attr("y", -30)
      // 設定字元到節點上
      .text(d => d)
      .call(enter => enter.transition(t)
        .attr("y", 0)),
    // 設定更新的字元
    update => update
      .attr("fill", "red")
      .attr("y", 0)
      .call(update => update.transition(t)
        .attr("x", (d, i) => i * 16)),
      // 設定刪除的字元
    exit => exit
      .attr("fill", "gold")
      .call(exit => exit.transition(t)
        .attr("y", 30)
        .remove())
);

當然,這裡的程式碼比較長並且理解成本也比較高,我們可以形成幾個問題並嘗試透過解決問題來理解他。

3.3.1、為什麼 text 節點還沒有就能夠呼叫 selectAll

關於這點一開始我在學習 D3.js 也曾經想過,後來我去翻了下對應的原始碼感覺就能夠 get 到了。

如下所示,我們可以看到呼叫 selectAll 的時候,實際上會返回一個 Selection 物件。而傳入的 selector 則會被作為第一個引數接受,第二個引數是預設的 document.documentElement

import array from "./array.js";
import {Selection, root} from "./selection/index.js";

export default function(selector) {
  return typeof selector === "string"
      ? new Selection([document.querySelectorAll(selector)], [document.documentElement])
      : new Selection([array(selector)], root);
}

然後物件本身會儲存著兩個引數到 _groups_parents 這兩個變數中,如下所示:

export function Selection(groups, parents) {
  this._groups = groups;
  this._parents = parents;
}

所以這一步本身只是生成了 Selection 的例項並且把 selector 及其父節點繫結到這個例項上。

那麼問題就來了:

  1. 這一步可以用 select 函式來替代嗎? 答:不行,因為 selectselectAll 函式的區別在於:前者呼叫的是 document.querySelector,結果返回的是物件;後者則呼叫的是 document.querySelectorAll,結果返回的是“陣列”。那麼最後的資料結構一個是物件,另外一個是二維陣列。最直接的表現是,替換之後程式馬上就會報錯~
  2. 可以用其他函式來代替 selectAll 麼? 答:不行,雖然本質上只需要拿到一個 Selecton 物件並且繫結資料就可以了,但是目前 D3.js 並沒有把這個功能直接暴露出來。

綜上,我們只能使用 selectAll 來獲取 Selection 物件,即使沒有預先生成節點而呼叫而導致看起來很彆扭?。

3.3.2、data 函式是怎麼實現資料繫結的?

提到這個問題那麼就必須看一下 data 函式的內部實現了:

  1. data 會根據傳入的 key 來進行繫結,呼叫 bindKey 函式;否則則呼叫 bindIndex 函式

    1. bindIndex 按照下標分別將節點存入 updateenterexit 陣列中
    2. bindKey 傳入的 key 是一個函式,利用生成的 keyValue 來將節點存入 updateenterexit 陣列中

值得注意的是,如果需要針對 update 階段做處理(比如說動畫),那麼傳 key 是非常重要的。因為預設傳 index 的話,即使是節點更新了,那麼也有可能觸發不了 update 函式。

  1. 返回一個新的 Selection 物件
export default function(value, key) {
  if (!arguments.length) return Array.from(this, datum);

  var bind = key ? bindKey : bindIndex,
      parents = this._parents,
      groups = this._groups;

  if (typeof value !== "function") value = constant(value);

  for (var m = groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m), j = 0; j < m; ++j) {
    var parent = parents[j],
        group = groups[j],
        groupLength = group.length,
        data = arraylike(value.call(parent, parent && parent.__data__, j, parents)),
        dataLength = data.length,
        enterGroup = enter[j] = new Array(dataLength),
        updateGroup = update[j] = new Array(dataLength),
        exitGroup = exit[j] = new Array(groupLength);

    bind(parent, group, enterGroup, updateGroup, exitGroup, data, key);

    // Now connect the enter nodes to their following update node, such that
    // appendChild can insert the materialized enter node before this node,
    // rather than at the end of the parent node.
    for (var i0 = 0, i1 = 0, previous, next; i0 < dataLength; ++i0) {
      if (previous = enterGroup[i0]) {
        if (i0 >= i1) i1 = i0 + 1;
        while (!(next = updateGroup[i1]) && ++i1 < dataLength);
        previous._next = next || null;
      }
    }
  }

  update = new Selection(update, parents);
  update._enter = enter;
  update._exit = exit;
  return update;
}

戳我檢視完整原始碼

3.4、第四步 —— 收尾

Okay, 如果上面這些程式碼都看懂了,最後一步就很簡單了 —— 新增迴圈。

;(async () => {
    // ...
    while (true) {
      // ...
      await new Promise(resolve => setTimeout(resolve, 3000));
      document.body.appendChild(svg.node());
    }

那麼經過以上這些步驟,我們就實現了一個簡單的 demo。透過這個 demo,可以將前面提到的一些知識點融會貫通。

戳我看完整原始碼?

四、總結

這篇文章我們花了一些篇幅來了解 D3.js 中常見的 API 及其用法。目前涉及到的部分比較少,主要是為了為下一篇帶大家繪製柱狀圖做一下鋪墊。