D3.jsの enter-updata-exit パタンについて

  • 0
    Like
  • 0
    Comment

     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 パタンがあります。今回はこれについてみたいと思います。今回示すコードは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;}); // ★①
    //                .data(data); // ★②
    
            // 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") 
                    .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) //★③
                    .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);   //★①の場合は右から左へ遷移するアニメーション効果、
                //★②の場合は単なる上下の動作
            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>