D3.js の Data-Driven な DOM 操作がおもしろい

ここんところ D3.js を触ってみているんだけど、これがなかなか面白い。

D3.js は「ビジュアライズ用のライブラリー」だと紹介されがちなんだけども、意外にも D3.js にはグラフを描画する機能がない。

D3.js のトップページには次のように書いてある。

D3.js はデータからドキュメントを生成するためのライブラリーです。D3 は HTML, SVG, CSS を使ってデータに命を吹き込みます。Web 標準を重要視しているので、独占的なフレームワークに縛られません。強力なビジュアライズ用のコンポーネントと data-driven な DOM 操作手順を組み合わすことで、モダン ブラウザーの能力を最大限に活用できます。

D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG and CSS. D3’s emphasis on web standards gives you the full capabilities of modern browsers without tying yourself to a proprietary framework, combining powerful visualization components and a data-driven approach to DOM manipulation.

曰く、

  • ビジュアライズ用のコンポーネント
  • data-driven な DOM 操作手順

がウリらしい。

順番に見ていこう。

ビジュアライズ用のコンポーネントの役割

「ビジュアライズ用のコンポーネント」は「データ」と「チャートの種類」から「どこに描画すればいいか」を計算する機能だけを実装している。

バブルチャートの例

ギャラリーBubble Chart をみてみよう。

このチャートでは d3.layout.pack() という Pack Layout コンポーネントを利用している。

この例では、描画するノードの情報を、こんな形の JavaScript 配列をコンポーネントに渡している。

[
  {
    className: "AgglomerativeCluster",
    packageName: "cluster",
    value: 3938
  },
  {
    className: "CommunityStructure",
    packageName: "cluster",
    value: 3812
  },
  {
    className: "HierarchicalCluster",
    packageName: "cluster",
    value: 6714
  },
  //...
]

これを pack.nodes() 関数に渡すと、value の値を元に計算して、ノードの位置情報をこんな感じで返す。

[
  { /* root node の情報 */ },
  {
    className: "AgglomerativeCluster",
    depth: 1,
    packageName: "cluster"
    parent: { /* root node */ },
    r: 22.159142384561406,
    value: 3938,
    x: 489.9621260527267,
    y: 532.5795625707091
  },
  {
    className: "CommunityStructure",
    depth: 1,
    packageName: "cluster",
    parent: { /* root node */ },
    r: 21.80175917979169,
    value: 3812,
    x: 535.3554715791261,
    y: 532.5795625707091
  },
  {
    className: ""HierarchicalCluster"",
    depth: 1,
    packageName: "cluster",
    parent: { /* root node */ },
    r: 28.933819019424202,
    value: 6714,
    x: 513.070926102582,
    y: 485.410700287188
  },
  // ...
]

いろんなプロパティーが追加されているけど、表示位置 (x, y) と半径 (r) の情報が返ってきている点に注目!

このように、ビジュアライズ用のコンポーネントは「データ」と「チャートの種類」から「どこに描画すればいいか」を計算するだけである。

描画するには...

この情報をどう画面に落とし込むかは実装側に任されている。

と聞くと、面倒そうに思えるかもしれないけど、Bubble Chart のソースを見ると、描画処理はたったの 15 行。

それだけ簡単に描画できてしまうのは、D3.js の「data-driven な DOM 操作手順」を使って SVG を生成しているのが大きい。

Data-Driven な DOM 操作を実感してみよう

D3.js には jQuery ライクな DOM 操作やイベント・アニメーション関係の API が用意されてる。jQuery を使ったことがある人なら簡単にマスターできるだろう。

jQuery にはないユニークな機能が data()enter()exit() 関数である。この関数の使い方を簡単なサンプルでみていこう。

JavaScript 上にこんな配列が定義されているとする。

var shiritori = ['りんご', 'ゴリラ', 'ラッパ'];

HTML には入れ物の <ul> タグだけを用意しておく。

<ul id="shiritori_list">
</ul>

<ul> タグの中に shiritori 配列の中身を表示してみよう、というのがお題である。

DOM の作成処理は data()+enter()

配列の中身を <ul> に追加するには、D3.js では次のように書く。

// ul#shiritori_list を選択
d3.select('ul#shiritori_list')
  // その下の <li> を列挙
  .selectAll('li')
  // それぞれに shiritori 配列の要素を割り当てる
  .data(shiritori)
  // data より <li> が少ない場合は、足りない分について
  .enter()
  // <li> を追加する
  .append('li')
  // <li> の中身の文字を設定する
  .text(function(d, i) { return (i + 1) + '番目は' + d; });

これを実行すると、HTML はこうなる (なりそうですよね? ね?)。

<ul id="shiritori_list">
  <li>1番目はりんご</li>
  <li>2番目はゴリラ</li>
  <li>3番目はラッパ</li>
</ul>

再度同じ JavaScript 処理を走らせるとどうなるだろう。<li> は 6 個になるだろうか。答えは「ならない」だ。

というのも、enter() 以降の処理は「shiritori 配列に対応する <li> がないとき」のみ実行される。「<li> の個数 ≦ shiritori 配列の要素数」である限りは何度実行しても enter() 以降は実行されない。

では、shiritori.push('パイナップル') として配列側を増やしてから、再度、上の JavaScript を走らせてみるとどうなるだろう。

そうすると、新規追加分の 'パイナップル' に対してだけ、enter() 以降の処理が実行される。つまり、HTML は

<ul id="shiritori_list">
  <li>1番目はりんご</li>
  <li>2番目はゴリラ</li>
  <li>3番目はラッパ</li>
  <li>4番目はパイナップル</li>
</ul>

となる。

enter() は配列の増加分に対して、DOM を生成してくれるわけだ。

DOM の削除処理は data()+exit()

次は、shiritori 配列が HTML より小さくなったときに対応しよう。

exit() で削除方法を書く。

// ul#shiritori_list を選択
d3.select('ul#shiritori_list')
  .select('ul#shiritori_list')
  // その下の <li> を列挙
  .selectAll('li')
  // それぞれに shiritori 配列の要素を割り当てる
  .data(shiritori)
  // data の数よりも多い <li> については
  .exit()
  // 削除する
  .remove();

はい。これで終わり。

shiritori が減ってないときは何も起こらないし、shiritori.pop(); してから実行すると末尾の <li> は消える。shiritori = []; してから実行すると <li> は消えてなくなる。

DOM の更新処理は data()

最後に更新処理。

// ul#shiritori_list を選択
d3.select('ul#shiritori_list')
  // その下の <li> を列挙
  .selectAll('li')
  // それぞれに shiritori 配列の要素を割り当てる
  .data(shiritori)
  // <li> の中身の文字を設定する
  .text(function(d, i) { return (i + 1) + '番目は' + d; });

enter()exit() なしにすれば、更新時の処理になる。

データが与えられたときに、それぞれの要素をどのように表示すべきかを記述している。配列を書き換えて、この処理を実行すると、中身の文字が適切に更新される。

ここでは text() しか使ってないが、jQuery 的な attr()style() を活用すれば、柔軟な指定が可能である。

全部まとめる

生成・削除・更新の処理をまとめてみる。

function update_shiritori() {
  var s = d3.select('ul#shiritori_list')
    .selectAll('li')
    .data(shiritori);

  // 作成
  s.enter().append('li');

  // 削除
  s.exit().remove();

  // 更新
  s.text(function(d, i) { return (i + 1) + '番目は' + d; });
}

(「作成」のところで text() を実行しなくなっているが、そのあとの「更新」のところでまとめて設定できるのでご安心を)

完成したソースを改めて見てみると、

  • 作成: 増えたデータに対して DOM を追加
  • 削除: 減ったデータに対して DOM を削除
  • 更新: データに対応する表示に更新

という処理がシンプルに書けているのが嬉しい。

同じような書き方を jQuery でやるのは難しい。たぶん「毎回 DOM ツリーを作り直す」という富豪的な手順をとると思う。それだと効率が悪いし、次の例にあるような増減に関係したアニメーションを実現するのは困難である。

Data-Driven なアニメーション

今度は、少し色気を加えて、アニメーションさせる。

使い方

  • 初期状態では 10 個の要素を持った配列を表示している。
  • 横軸が配列のインデックス、縦軸が要素の値 (0~1) をあらわす。
  • [random] ボタンを押すと、配列の中身がランダムな値で置き換わる。
  • [push] ボタンを押すと、配列の末尾に要素を追加する。
  • [pop] ボタンを押すと、配列の末尾から要素を取り除く。

ボタンを押すと、アニメーションつきで見た目が変更するのを確認していただけるだろうか (SVG をサポートしてる必要があるので、モダンではないブラウザーでは表示できない)。

コンソールからの変更にも対応

このページを開いている状態であれば、JavaScript コンソールで直接

values = [0.5, 0, 1];
update();

と入力しても、アニメーションつきで配列が反映されるはずだ。

更新部分のソースコード

では、その update() 関数をみてみよう。

function update() {
  // 配列の個数を n に代入
  var n = values.length;

  // <svg> の中の <circle> を列挙して、values を割り当てる
  var circles = d3.select("svg#sample2")
    .selectAll('circle').data(values);

  // 作成: 足りない <circle> を追加する
  circles.enter()
    .append('circle')
    .attr('fill', 'red')
    .attr('cx', function(d, i) { return i * 280 / n + 10; })
    .attr('cy', 0).attr('r', 0);

  // 削除: 余分な <circle> はアニメーションつきで削除
  circles.exit()
    .transition()
    .duration(300)
    .attr('cy', 0).attr('r', 0)
    .remove();

  // 更新: アニメーションで正しい位置とサイズに移動
  circles
    .transition()
    .duration(300)
    .attr('cx', function(d, i) { return i * 280 / n + 10; })
    .attr('cy', function(d, i) { return d * 280 + 10; })
    .attr('r', 6);

  // 線の位置も調整する
  d3.select('svg#sample2 polyline')
    .transition()
    .duration(300)
    .attr('points', values.map(function(d, i) {
      return (i * 280 / n + 10) + ' ' + (d * 280 + 10);
    }).join(','));
}

画面の描画は SVG で行っている。

削除と更新のときに transition() でアニメーションを指定してる点に注目。これがアニメーションの肝である。

円の大きさのアニメーションを例に説明する。

「追加」のときには <circle> の半径を 0 で初期化している。そのあとの「更新」で 6 にしてる。その結果、追加時には徐々に大きくなりながら画面に現れる。

ソースが長くなって、多少複雑になったが、「追加」「削除」「更新」の基本は変わっていない。

まとめ

D3.js の Data-Driven な DOM 操作を説明した。入力されたデータに対して、「追加」「削除」「更新」の処理を分けて書くことで、驚くほどシンプルに記述できることが分かった。

基礎が分かったら、ギャラリーAPI リファレンス を見比べれば、いろんな使い方が分かってくることだろう。あと、SVG の知識も必要にはなってくる。

D3.js は「表示機能がない」という異色のビジュアライズ用のライブラリーだけども、表示処理を自由に操れるということは、見た目のカスタマイズをやりやすくなる。この手のライブラリーで一番苦労するのが、ちょっとしたカスタマイズをやりにくいところなので、こういう設計は実はありがたいのかもしれない。