D3.jsとは、データを視覚化するためのドメイン固有言語である。JSライブラリだと思って取り組むと、足元を掬われて後頭部を強打し、もがき苦しむ羽目になる。

D3はData-Driven Documentsの略で、日本語ではデータ駆動文書となる。

D3.jsの概要

このライブラリは単にデータを視覚化するのではなく、データに命を吹き込み、文書化することを目指している。それを象徴するのが、標準のDOM APIをラップした、D3.js独自のDOM操作メソッド群である。

データを軸にHTML文書を丸ごと扱ってやろうという強固で誇り高い意志が伺える。

できること

D3.jsの守備範囲はとても広いため、一度に理解しようとすると大変である。懸命にドキュメントを読んでも、目が滑ることおびただしい。大枠を掴むために、まずはD3.jsで達成できることをまとめてみる。

できること 概要
DOM操作全般 D3.jsは、データ駆動と呼ぶに相応しい、独自のDOM操作メソッドを用意している。ブラウザ標準のDOM操作と大きく異なるため、まずはD3.js独自のDOM操作に慣れることが大切である。
ファイルの読み込み データ駆動というだけあって、データを読み込むためのメソッドも網羅されている。CSVやJSON、GeoJSON、TopoJSON、XMLにHTMLなど、テキストデータであればおおよそ読み込める。もちろんテキストファイルも読み込める。
データの視覚化 D3.jsでは、SVG、HTML(tableやcanvasなど)を使ってデータを柔軟に表示できる。ただ柔軟であるぶん、手仕事でのDOM操作が必要。メソッド一発でいい感じにグラフ化などはできない。少なくとも標準ではサポートされていない。
対話的なアニメーション データの視覚化と地続きである。D3.jsは、凝ったことをやろうと思えばいくらでもできる。ゼロからの自作は手間が掛かるが、Observable(データ視覚化作品を共有するプラットフォーム)などに膨大な参考例がまとめられている。勉強が捗る環境はある。

できないこと

D3.jsには、ブラウザ標準のDOM APIを置き換える勢いで豊富なメソッドが用意されている。そのため、データ視覚化の観点で言えばできないことはほぼない。

ただし、手間なくいい感じのグラフを出力したい、という需要には適さない。D3.jsのDOM操作はクセが強く、サンプルをコピペし、修正して使うのも楽ではない。

また、DOM操作に加えて、基本的なSVGの知識も求められる。SVGをゴリゴリ書くよりよほど楽だが、D3.jsをデータを手軽にグラフ化できるライブラリ、と評価できる人は多くないと想像する。

アメリカのジャーナリスト、アマンダ・コックスさんは、D3.jsに関して以下のような言葉を残している。

Use D3 if you think it’s perfectly normal to write a hundred lines of code for a bar chart.

(訳:棒グラフのために100行のコードを書くことが完璧に普通のことだと思うなら、D3をお使いなさい)

引用元:What is D3? | D3 by Observable

D3.jsの大まかな使い方

D3.jsのDOM操作は、宣言的なアプローチをとる。長々しく命令を書く代わりに、何のデータをどうしたいかを端的に書く。

以下のようなイメージである。

html
<!DOCTYPE html>
<html>
  <head>
    <!-- D3.jsの読み込み -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>
  <body>
    <!-- ここからサンプルコード -->
    <script>
      const data = ['こんにちは、','D3'];
      d3.select('body')
        .selectAll('p')
        .data(data)
        .join('p')
        .text(d => d);
    </script>
  </body>
</html>

端的すぎて、大部分が意味不明だ。ただ、何やら要素や要素の集合を選択して、データと対応させたり、要素を追加したりしていることは伝わるかと思う。

D3.jsでは、要素(の集合)とデータをSelectionオブジェクトというオブジェクトにラップして操作する。このSelectionは、Web APIのSelectionとは全くの別物である。

D3.jsのSelectionオブジェクトには、DOM操作用のメソッドとデータ操作用のメソッドが豊富に備わっている。これら2系統のメソッドをいい感じに繋いで望む結果を得るのが、D3.jsの大まかな使い方である。

基本はDOM操作

D3.jsによるグラフの描画は、基本的にSVGで行う。グラフ本体も軸もテキストもぜんぶSVGで描く。したがってSVGの知識が不可欠である。

グラフをSVGで地道にマークアップするのは絶望的な作業だ。D3.jsはこの作業を楽にする。例えば棒グラフは、以下のようなコードで書ける。

html
<!DOCTYPE html><html><head><!-- D3.jsの読み込み --><script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script></head>
  <body>
    <script>
      // SVGコンテナの描画
      const svg = d3.select("body")
                    .append("svg")
                    .attr("width",200)
                    .attr("height",200);

      // 棒グラフの描画
      svg.selectAll("rect")
         .data([50, 100, 150])
         .join("rect")
         .attr("x", (d, i) => i * 50)
         .attr("y", d => 200 - d)
         .attr("width", 40)
         .attr("height", d => d);
    </script>
  </body>
</html>

これは、標準のSVG要素や属性値を、ほぼそのままD3.jsに置き換えたコードである。各メソッドの引数を見れば、どのメソッドが何をしているか類推できる。

ただ、これだけでは、簡単なグラフを作るのも一苦労である。

例えば棒グラフに軸やラベルを追加することを考える。まず<line>要素やら<text>要素やらを作らねばならない。描画エリアのサイズを踏まえて、各要素のサイズや表示位置を計算する必要もある。

叫び出したくなる。

こうした普遍的な視覚化要素を柔軟に出力できる仕組みが、D3.jsには備わっている。ただ、それでも数が多くて一度に扱うのが難しいので、ここでは取り上げない。

DOMをラップするSelection

D3.jsでのDOM操作は、Selectionオブジェクトを通して行う。

Selectionオブジェクトは、d3.select()あるいはd3.selectAll()で作成する。d3オブジェクトはSelectionオブジェクトではないため、データ視覚化の下準備として必ずこれらのメソッドを使う必要がある。

選択系 概要
select() セレクタにマッチする最初の要素をSelectionにして返す。
selectAll() セレクタにマッチするすべての要素をまとめ、Selectionにして返す。

両方とも、引数にはセレクタを指定する文字列を渡す。

これらは、内部でdocument.querySelector()document.querySelectorAll()をそれぞれ呼び出しており、取得したElementオブジェクト(あるいはNodeListオブジェクト)をもとにSelectionオブジェクトを作って返す。

このとき、Selectionオブジェクトは以下の2つのプロパティを持っている。

プロパティ 中身
_groups 取得したElementあるいはNodeListの配列。
_parents _groupsの各要素の親要素。初回の選択ではdocument.documentElementがpushされる。

_groupsプロパティが配列なのは、セレクタが複数(例:p.1st-section, p.2nd-section)の場合を想定してのものである。

アンダーバーの付いているプロパティは、D3.jsの内部でのみ使われる。ユーザーが触るものではないが、本記事では理解を深めるために、私が理解できた範囲で取り上げてみたいと思う。

Selectionオブジェクトは、この2つのプロパティでDOM要素を保持し、主に以下のようなメソッドを使って操作する(メソッドの完全なリストは公式ドキュメントのここで参照できる)。

属性値を取得・設定するメソッド

メソッド名 概要
style() 選択された要素のスタイルを取得または設定。
attr() 静的な属性値の取得または設定。
property() 動的な属性値(HTMLのformタグ内で使われる各種属性値など)の取得または設定。

それぞれの引数は、値を設定する場合は("属性名","属性値")。取得する場合は("属性名")とする。

また、引数に関数を指定することもできる。その関数は、現在のデータアイテム、現在のインデックス、現在のグループ(選択中のノードリスト)という3つの引数を受け取る。

関数の中では、ノードリスト[インデックス]という形でDOMの各属性値を操作できる。

コンテンツを取得・設定するメソッド

メソッド名 概要
text() 選択された要素のテキストコンテンツを取得または設定。
html() 選択された要素のHTMLコンテンツを取得または設定(HTMLソースを文字列として渡すと、選択された要素の子要素として追加してくれる)。

引数には、文字列と関数を指定できる。関数が受け取る引数は、現在のデータアイテム、現在のインデックス、現在のグループ(選択中のノードリスト)の3つである。

また、値を省略すると、現在のコンテンツが返る。

イベントリスナを設定するメソッド

メソッド名 概要
on() 選択された要素にイベントリスナーを設定または取得。

on()が受け取る引数は、DOM APIのaddEventListener()メソッドと共通している。ただ、D3のイベントリスナは、引数にイベントオブジェクトだけでなく、そのDOMに対応するデータアイテムも受け取れる。

引数を省略して呼び出すと、イベントリスナが返される。

DOM要素を追加・削除するメソッド

メソッド名 概要
append() 選択された要素に新しい要素を追加。
insert() 指定された要素の前に新しい要素を挿入。
remove() 選択された要素を削除。

append()メソッドの引数は追加する要素のタグ名、insert()メソッドは挿入する要素のタグ名と、挿入ターゲットとなる要素のセレクタである。

また、ともに関数も受け取れる。関数が受け取る引数は、現在のデータアイテム、現在のインデックス、現在のグループ(選択中のノードリスト)の3つである。

remove()メソッドは引数を取らない。

データとDOM要素を紐づけるメソッド

メソッド名 概要
data() 引数に渡したデータを、選択された各要素にバインドした、新しいSelectionを返す。
join() データに応じて要素の追加・更新・削除を行う。

data()メソッドは、データの配列のほかに、第二引数に関数も受け取れる。この関数が受け取る引数も、現在のデータアイテム、現在のインデックス、現在のグループ(選択中のノードリスト)の3つである。

また、join()メソッドには、データに応じて要素を挿入、更新、削除する関数をそれぞれ渡せる。第一引数に渡す挿入関数は、挿入したいタグの文字列を代わりに指定することができる。更新、削除に使うメソッドも、省略すればデータに応じて処理してくれる。

そのため、多くはjoin("挿入したいタグ名")のような形で呼び出す。

data()メソッドとjoin()メソッドを組み合わせると、データに応じて自動で要素を追加、更新、削除を行う処理が直感的に書ける。

html
<!DOCTYPE html>
<html>
  <head>
    <!-- D3.jsの読み込み -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  </head>
  <body>
    <body>
    <div class="buttons">
      <button class="push">Push</button>
      <button class="pop">Pop</button>
    </div>
    <ul class="num-list"></ul>
    <script>
      <!-- ここからサンプルコード -->
      const a = [0, 1, 2, 3, 4];
      d3.select(".num-list")
        .selectAll("li")
        .data(a)
        .join("li")
        .text((d) => d);

      d3.select(".push").on("click", (e) => {

        if (a.length == 0){
          d3.select(".pop").property("disabled", false);
        }

        a.push(a.length);

        d3.select(".num-list")
          .selectAll("li")
          .data(a)
          .join("li")
          .text((d) => d);
      });

      d3.select(".pop").on("click", (e) => {

        a.pop();

        if (a.length == 0){
          d3.select(".pop").property("disabled", true);
        }

        d3.select(".num-list")
          .selectAll("li")
          .data(a)
          .join("li")
          .text((d) => d);
      });
    </script>
  </body>
</html>

昔と今をつなぐはなし

join()が登場する前は、以下メソッドが使われていた。直感的でないためユーザーが書くことは推奨されないが、現在も内部ではこれらのメソッドが使われている。

メソッド名 概要
enter() データが関連付けられた新しいSelectionを返す。
exit() データが関連付けられなくなった新しいSelectionを返す。

属性値に静的な値ではなく関数を渡せる(動的に値を設定できる)、複数のDOM要素を配列操作の要領で一括操作できる、など特徴的な部分はあるが、おおよそのメソッドは名称から使い方を類推できるかと思う。

ただし、データを紐づけるタイプのメソッドは注意が必要である。上記の3つのメソッドが返すSelectionオブジェクトは、Selectionオブジェクトではあるもののタイプが異なる。

data()は、DOM要素とデータを結びつける。この操作によって返されるのは、既存のDOM要素を保持するSelectionオブジェクト(Update selection)である。そのままtext()style()で操作できる。

一方、enter()は、データに対して不足するDOM要素(のプレースホルダ)を保持するSelectionオブジェクト(Enter selection)を返す。このため、enter()が返すSelectionオブジェクトに対しては、新たなDOM要素を追加するappend()メソッドを呼び出す。

exit()は、データが削除されて余分になったDOM要素(exit selection)を保持する。これらの要素は、もはやデータに対応していないため、通常はremove()を使って削除する。

こうした仕組みの背景には、D3.js特有の「General Update」パターン(=「Update-Enter-Exit」パターン)がある。

General Updateパターン

ブラウザ標準のDOM APIでは、DOM要素を作ってからそこにデータを挿入していく。D3.jsでは、これが逆転する。データ駆動という名前の通り、データに基づいて要素が作成される。

データに基づいて要素を管理するためには、データとDOM要素を紐づける必要がある。そのための仕組みとして用いられる設計が、D3.js特有のGeneral Updateパターンである。

このパターンは、以下のような流れでDOM要素の更新、作成あるいは削除を管理する。

  1. データ表示に使うDOM要素を受け取る。
  2. 表示するデータを受け取る。
  3. DOM要素とデータを照らし、データ表示に使うDOM要素の過不足を判断する
    • 足りている部分は、DOM要素の更新に必要な情報を作成・保持する。
    • 足りない場合、新たなDOM要素を作るための情報を作成・保持する。
    • 余る場合、該当のDOM要素を削除するための情報を作成・保持する。

データ表示に使う一連の要素をまず指定し、次いで表示するデータを指定する。これによりデータと表示用の要素が照合され、要素の新規追加、更新、削除に必要な情報が整理される。この段階では、要素の追加や削除は行われない。あくまで、指定されたデータに対して、表示用要素の追加・更新・削除に必要な情報が明らかにされるのみである。

D3.jsの文脈では、このように表示用の要素に任意のデータを紐づける処理を結合(join)と呼ぶ。また、結合により整理される3つの状態を、それぞれ「Enter」、「Update」、「Exit」と呼ぶ。

静的なデータはもちろん、動的なデータであっても、これら3つの状態を再計算(=データ結合)することでリアクティブに視覚化できる。この更新パターン(General Updateパターン)が、D3.jsの大きな特徴である。

結合とバインド

結合は、データと表示用の要素を照合し、Enter(作成する要素)、Update(更新する要素)、Exit(削除する要素)という3つの情報を整理することである。バインドもこれと同じニュアンスで使われることが多い。

ただ、より正確には、結合の過程で表示用のDOM要素に_data_プロパティを追加し、直接データを持たせることである(と私は解釈した)。

Selectionに対するデータの結合は、data()によって行う。例えばd3.selectAll('p').data(['0','1'])のように書く。

selectAll()select()が返すSelectionオブジェクトは、2つのプロパティ(_parents_groups)しか持たない。data()が返すSelectionは、これらに_enter_exitが追加されている。

いずれも配列だが、中身のオブジェクトの種類は異なる。

プロパティ名 概要
_enter データに対して不足するDOM要素分の、EnterNodeオブジェクトの配列
_exit データに対して余分なDOM要素の配列

EnterNodeオブジェクトは、新規に作られるSelectionオブジェクトのプレースホルダ的なオブジェクトである。_data_プロパティを持ち、データを保持している。

ちなみに_updateプロパティは存在しない。データの数に対し足りている分のDOM要素は、_groupsの中身が該当する。

selection.data()の処理の流れ

data()は、以下のような流れで上記の各プロパティを整理する。

  1. _groupsプロパティで保持している各DOM要素に_data_プロパティを追加して、そこに引数で受け取ったデータを格納する。
  2. DOM要素が不足する場合は_enterプロパティに、_data_プロパティを持つEnterNodeオブジェクトをpushする。
  3. DOM要素が余る場合は、_exitプロパティに該当のDOM要素をpushする。
  4. 上記を適用した新たなSelectionオブジェクト(Update Selectionと呼ぶらしい)を返す。

data()は引数に渡されたデータに基づいて新たなSelectionを生成する。このSelectionは、それぞれのDOM要素とデータに基づく更新(update)、追加(enter)、削除(exit)の情報を表現している。

ややこしいのはこの後である。

_groupsプロパティに含まれるDOM要素にデータを反映する場合は、そのまま設定用のメソッド(text()style()attr()など)を呼ぶ。

一方、_enterに分類されたデータは、このままではどうにもできない。まずDOM要素を作成する必要がある。

このために、_enterの中身からSelectionオブジェクト(Enter Selection)を作成して返すenter()をまず実行し、そのSelectionオブジェクトのappend()を呼び出して、データを紐付けたDOM要素を作成・選択する。ここでようやく設定用のメソッドが実行できる。

_exitの場合も同様に、_exitの中身からSelectionオブジェクト(Exit Selection)を作って返すexit()を実行して、その上でremove()を呼び出して余分なDOM要素を削除する。

以下は、3つのメソッドを使ったサンプルコードである。

html
<button id="change">Change!</button>
<button id="push">Push!</button>
<button id="pop">Pop!</button>

<!-- D3.jsの読み込み -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.8.5/d3.min.js" integrity="sha512-M7nHCiNUOwFt6Us3r8alutZLm9qMt4s9951uo8jqO4UwJ1hziseL6O3ndFyigx6+LREfZqnhHxYjKRJ8ZQ69DQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

<!-- ここからサンプルコード -->
<script>
  const data = [0];
  const btns = ['change', 'push', 'pop']
    .map(id => document.querySelector(`#${id}`));

  btns[0].addEventListener('click', () => (data.forEach((_, i) => data[i] = Math.floor(Math.random() * 10)), render()));
  btns[1].addEventListener('click', () => (data.push(data.length), render()));
  btns[2].addEventListener('click', () => (data.pop(), render()));

  render();

  function render()
  {
    // Update データ数と要素数が合致している部分を更新する
    const p = d3.select('body')
      .selectAll('p')
      .data(data)
      .text(d => d);

    // Enter データ数に対し、要素が足りなければ追加する
    p.enter()
      .append('p')
      .text(d => d);

    // Exit データ数に対し、要素が余っていれば削除する
    p.exit()
      .remove();
  }

  render();
</script>

えらい長くなってしまった。ともかくD3.jsはこうしたユニークな仕組みを土台にデータをビジュアル化している。

参考資料