JavaScript
d3.js
chart
bigdata
グラフ

D3.jsの enter-updata-exit パタンでLive Data表示

 Reactにはd3.jsを使い易くするためのライブラリRechartsがあるようですが、ここでは生のd3.jsを使うことにします。d3.jsは単なるグラフを描くライブラリではなく、もっと奥深い機能が豊富に提供されているので、React越しに間接的に見るより、生のd3.jsに直接触る方が興味深いからです。
http://recharts.org/

 以下のサイトのchapter6のコードをベースに進めていきます。(All source code in this repository are licensed under MIT license)
https://github.com/NickQiZhu/d3-cookbook-v2

 d3.jsの特徴としてenter-updata-exit パタンがあります。これはデータセット(JavaScriptの配列)とView要素(DOM)を結びつけるものです。今回示すコードは20個の棒グラフを描き、定期的に、左端のグラフを消して、右端に新しいグラフを追加するものです。これは時間の経過とともに、新しい要素が追加され、古くなった要素が削除されている現象をシュミレートしています。グラフが時々刻々右から左へ遷移していくことを期待しています。

 d3.jsライブラリは、提供されたデータのグラフを書き、その後、時々刻々と変化するデータを反映して、グラフも更新していきます。それを効率よく行うために考えられたのがenter-updata-exit パタンです。

 さて、各要素が{id:xxx,value:yyy}(xxxとyyyは整数値)の形をしている、2つの集合を考えます。データセットの集合とView要素の集合です。データセットとは例えばdataなどの配列名でJavaScriptコード内で保持しているデータの集合です(現在の新鮮なデータです)。View要素の集合とは、既にDom上に描かれたデータを表していて、Dom上のPropertiesに__data__として保存されています(古いデータです)。update, enter, exitは以下のような集合演算で定義されます。

  1. update : データセットの集合とView要素の集合の共通部分
  2. enter : データセットの集合 - View要素の集合
  3. exit : View要素の集合 - データセットの集合

 もっと分かりやすく言うと以下のように定義できます

  1. update : 現在のデータセットで既に描かれているもの
  2. enter : 新しくデータセットに追加され、まだ描かれていないもの
  3. exit : 既に描かれているが、データセットから削除されているもの。

 さて、基本的な問題なのですが、集合の各要素はどのように特定できるのでしょうか。例えばupdateを計算する時にデータセットとView要素の両方にその要素が含まれていることを判断する必要があり、そのためには要素にユニークなidを指定する必要があります。updateやenter、exitを計算する時に、どの要素が新たに追加されたとか、どの要素が削除されているとかを判断するために、要素を特定する方法が必要です。

 プログラムの全体の流れは、「最初にdata配列に20個のデータを入れてrenderし、次に以下の動作を繰り返すものです。data配列の先頭要素を削除し、その代わりにお尻に新要素を追加し、renderし直す。」というものです。ソースコードの以下の(行1)と(行2)を変えるだけで、内部データや表示が大きく異なります。

 まず以下の行を見てください。以下のdata()関数のcallbackで集合の要素をidで特定するように指定しています。

(行1)
var selection = d3.select("body").selectAll("div.v-bar")
            .data(data, function(d){return d.id;});// ★idで特定

 
 もちろんView要素はDomに存在します。Chromeブラウザで見ると、Elementsのdiv.v-bar要素のPropertiesに__data__があって、例えば{id:15,value:99}という値がバインドされています。このidで集合の要素を特定しています。データセットとView要素を通してidで要素を特定できるわけです。ある要素のidがデータセットにはあるが、View要素にはなければ、それはenterに属します。データセットに無くて、View要素にあれば、exitに属します。

 上の(行1)を実行すると、基本的にはselectionはupdateを表現します。同時にその構造体内部にはenterやexitが計算されて保持されています。つまり以下のように考えられます。

  1. selection.op1 : updateに関数op1を適用します
  2. selection.enter().op2: enterに関数op2を適用します
  3. selection.exit().op3: exitに関数op3を適用します。

※op1やop2,op3はd3のslection関数で、一般にはチェーン化されています。

 (行1)の場合、棒グラフは③のtransition()によって、右から左にアニメーション効果を発揮しながら遷移していきます。これはidで特定したそれぞれの棒グラフ要素が、時間の経過とともに右から左へ流れていく様子を表しています。それでは以下の(行2)の場合はどうでしょう?

(行2)
var selection = d3.select("body").selectAll("div.v-bar")
            .data(data);

 データセットをバインドするときにdata(data)で、特にデータセットの要素の特定方法を指定していません。この場合は実験の結果、単にdata配列のindexが要素の特定に使われるようです。dataは先頭の1個が削除され、お尻に1個追加されているのですが、その動きを反映していません。内部のデータセットに関して言えば、配列のサイズが同じで、indexも0〜19に固定されているので、enterもexitも空です。例えばindex=5で特定された6番目の要素についていえば、時間とともに上下に伸び縮みするアニメーションになります。

 逆に(行1)の場合のように右から左に流れるように遷移していくアニメーションはイメージに合います。言葉を換えれば正しいVisual Metaphorが描かれていると言えます。Visual Metaphor(視覚的メタファー)とは非言語的な視覚イメージによって表現するコミュニケーション手段のことです。D3.jsは単にグラフを描くライブラリではなく効果的なVisual Metaphorを作るツールでもあります。

★行1の場合

idでdata要素を特定しているので、配列のindexに依存せず要素を特定できる。
index=19の時はenter要素となり、index=18〜0まではupdate要素であり、
index=-1になるとexit要素となる。
特定の要素に対してtransition()でアニメ効果を追加できる。
各々の要素を特定できているので、各要素が右から左へ遷移していく様子を描画できる。

★行2の場合

最初は20個の要素が全てenter要素であり、
2度目以降はそのすべての要素がupdate要素になる。
各々の要素を特定する術はなく、indexで特定しているので、右から左への遷移は描画でき無い。

 以下がソースコードになります。コメントを追加したのと、行2のコードを追加した以外はオリジナルなままです。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>D3.jsの enter-updata-exit パタンについて</title>
    <link rel="stylesheet" type="text/css" href="../../css/styles.css"/>
    <script type="text/javascript" src="../../lib/d3.js"></script>
</head>

<body>

<script type="text/javascript">
    var id= 0,
        data = [],
        duration = 500,
        chartHeight = 100,
        chartWidth = 680;

    for(var i = 0; i < 20; i++) push(data);

    function render(data) {
        var selection = d3.select("body").selectAll("div.v-bar")
                .data(data, function(d){return d.id;}); // ★(1)
//                .data(data); // ★(2)

        // enter
        selection.enter()
                .append("div")
                .attr("class", "v-bar")
                .style("z-index", "0")
                .style("position", "fixed")
                .style("top", chartHeight + "px")
                .style("left", function(d, i){
                    return barLeft(i+1) + "px"; 
                })
                .style("height", "0px") // 最初はゼロ、次にupdateで正しい値にtransitionする
                .append("span");

        // update
        selection
                .transition().duration(duration) 
                .style("top", function (d) {
                    return chartHeight - barHeight(d) + "px";
                })
                .style("left", function(d, i){
                    return barLeft(i) + "px";
                })
                .style("height", function (d) {
                    return barHeight(d) + "px";
                })
                .select("span")
                .text(function (d) {return d.value;});

        // exit
        selection.exit()
                .transition().duration(duration) //★(3)
                .style("left", function(d, i){
                    return barLeft(-1) + "px"; 
                })
                .remove(); 
    }

    function push(data) {
        data.push({
            id: ++id,
            value: Math.round(Math.random() * chartHeight)
        });
    }

    function barLeft(i) {
        return i * (30 + 2);
    }

    function barHeight(d) {
        return d.value;
    }

    setInterval(function () {
        data.shift(); //★削除して追加
        push(data);   //★(1)の場合は右から左へ遷移するアニメーション効果、
            //★(2)の場合は単なる上下の動作
        render(data);
    }, 2000);

    render(data);

    d3.select("body")
       .append("div")
           .attr("class", "baseline")
           .style("position", "fixed")
           .style("z-index", "1")
           .style("top", chartHeight + "px")
           .style("left", "0px")
           .style("width", chartWidth + "px");
</script>
</body>
</html>