LoginSignup
0
2

D3 v7 応用 - Enter / Update / Exit

Last updated at Posted at 2023-12-27

【関連記事】
React 再入門 (React hook API) - Qiita
D3 v7 入門 - Enter / Update / Exit - Qiita
D3 v7 応用 - Enter / Update / Exit - Qiita
D3 v7 グラフ - d3-scale、d3-axis、d3-shape - Qiita
React+D3 アプリ作成入門 - Qiita
D3 v7 棒グラフのいろいろ - Qiita
D3 v7 都道府県別人口の treemap - Qiita

この前の記事では D3 の基本概念である Data Join - Enter / Update / Exit の基礎について説明しました。これはデータセット(JavaScriptの配列)とView要素(DOM 要素)を結びつけるものです。 ハッキリ言ってこの基本概念は、使用例を見ないとわかりにくいと思います。本記事の目的は Data Join - Enter / Update / Exit の使用例をみることで、この基本概念の持つ意味を探ることです。

D3 は、提供されたデータのグラフを書き、その後、時々刻々と変化するデータを反映して、グラフも更新していきます。それを効率よく行うために考えられたのが Enter / Update / Exit です。

ここでは、以下の過去記事で取り上げたサンプルプログラムを再度取り上げながら説明していきたいと思います。
D3.jsの enter-updata-exit パタンでLive Data表示 Qiita

1.selection.data(data, key)

selection.data(data, key) に関しては、今まで key 関数はオプションということで説明を避けてきましたが、今回は重要なので以下に解説します。
以下、選択 DDM 要素とは、select() や selectAll() で選ばれた DOM 要素で、selection オブジェクトの _groups プロパティのことです。

selection.data(data, key) :

選択された DOM 要素 (selection オブジェクト) に data 配列をバインドし、新しく更新された selection オブジェクト を返します。
返された selection オブジェクト では update selection でデータがバインドされた DOM 要素を表し、データ数と DOM 要素数の大小に応じて enter selectionexit selection のプロパティが作られます。前者は DOM 要素を追加、後者は DOM 要素を削除するために使われます。
指定される data は任意の値(数値やオブジェクトなど)の配列です。もしくは データを返す関数 でもいいです。データが割り当てられれば _data_ プロパティに蓄えられます。

  • key 関数が指定されなければ、dataの中の最初のデータが最初の DOM 要素に割り当てられます。そして2番目のデータが2番目の DOM 要素に割り当てられます。3番目以降も同様です。 (join-by-index 方式)

  • key 関数を指定することで、どのデータをどの DOM 要素に割り当てるかを指定することができます。 デフォルトの join-by-index 方式ではなく、データと DOM 要素の識別子(文字列)を、以下の3ステップで、計算し割り当てる方式です。

    • (1) まず key 関数は選択 DOM 要素 (_groups ) のそれぞれに対して評価されます。 評価の順番は、カレントデータ、カレントindex、カレントnodes (nodes[i] がカレント DOM 要素)が渡される順番です。返り値が DOM 要素の識別子です。

    • (2) 次に key 関数は data のデータ のそれぞれに対して評価されます。 カレントデータ、カレントindex、新データ(グループの親 DOM 要素を引き継ぐ)が渡され、返り値がデータの識別子となります。

    • (3) 最後に、識別子を持つデータは、同じ識別子を持つ 選択 DOM 要素 (_groups) に割り当てられます。 (もし複数の DOM 要素が同じ識別子を持っていたら、複製 DOM 要素が exit selection に入れられる。模試複数のデータが同じ識別子を持っていたら、複製データが enter selection に入れられる。)

<!DOCTYPE html>
<html lang="en">
  <head></head>
    <meta charset="utf-8">
    <title>D3.jsの enter-updata-exit パタンについて</title>
    <link rel="stylesheet" type="text/css" href="./styles.css"/>
  </head>
  <body>
    <div id="Ford"></div>
    <div id="Jarrah"></div>
    <div id="Kwon"></div>
    <div id="Locke"></div>
    <div id="Reyes"></div>
    <div id="Shephard"></div>
  </body>
  <script type="module">
    import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";

    const data = [
        {name: "Locke", number: 4},
        {name: "Reyes", number: 8},
        {name: "Ford", number: 15},
        {name: "Jarrah", number: 16},
        {name: "Shephard", number: 23},
        {name: "Kwon", number: 42}
    ];

    let div = d3.selectAll("div")
        .data(data, function(d) { return d ? d.name : this.id; })
        .text(d => d.number);

    console.log(div);
  </script>
</html>

ここで data メソッドは selection.data(data, key) の仕様通り以下の動作となります。

selection.data(data, key) の動作

  • (1) まず 選択 DOM 要素 (_groups) のそれぞれに対して key 関数が適用される。各 div 要素は、データがバインドされていないから d==nullthis.id (div要素のid) が識別子となる
  • (2) 次に data のそれぞれのデータに対して key 関数が適用される。d!=null で識別子は d.name となる。
  • (3) つまり、div要素の id とデータの d.name のマッチングで、data が DOM 要素に割り振られる。

この場合、_enter は空で、_groups に全データが入っていることに注目してください。

image.png

出力HTML
image.png

ちなみに data(data, function(d) { return d ? d.name : this.id; }) でなく単に data(data) の場合は以下のようになります。id の値とは関係なく、単純に DOM 要素の順に data[0], data[1], data[2]... が割り振られます。

key 関数を省略した場合

    let div = d3.selectAll("div")
        //.data(data, function(d) { return d ? d.name : this.id; })
        .data(data)
        .text(d => d.number);

出力HTML(データの順番が変わる)
image.png

マッチしないデータがある場合

データとマッチしない DOM 要素がある場合や DOM 要素とマッチしないデータがある場合を確認します。

  <body>
    <div id="Ford"></div>
    <div id="Jarrah"></div>
    <div id="Kwon"></div>
    <div id="Locke"></div>
    <div id="Aaaaa"></div>  <!- マッチしない DOM 要素を追加 ->
    <div id="Reyes"></div>
    <div id="Shephard"></div>
  </body>
  
  ---
  
    const data = [
        {name: "Xxxxx", number: 7}, // マッチしないデータを追加
        {name: "Locke", number: 4},
        {name: "Reyes", number: 8},
        {name: "Ford", number: 15},
        {name: "Jarrah", number: 16},
        {name: "Shephard", number: 23},
        {name: "Kwon", number: 42}
    ];

データとマッチする選択 DOM 要素は、そのまま _groups に残ります。
マッチしない DOM 要素は _exit に移り、マッチしないデータは _enter に残ります。

image.png

出力HTML(オリジナルと同じ)
image.png

3.Enter / Update / Exit のサンプルプログラム

以下、enter, update, exit のイメージです。集合が分かりづらかったらスキップしてください。

enter, update, exit が表すもの

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

集合の言葉で書けば、以下のような感じ。

\displaylines{
update =  DATA \cap DOM (共通部分)\\
enter = DATA \setminus DOM (差集合)\\
exit  = DOM \setminus DATA (差集合)\\
\\
ここで以下のように定義する。\\
DATA \equiv データ集合、DOM \equiv DOM 要素集合
}

3-1.ソースコード

<!DOCTYPE html>
<html lang="en">
  <head></head>
    <meta charset="utf-8">
    <title>D3.jsの enter-updata-exit パタンについて</title>
    <link rel="stylesheet" type="text/css" href="./styles.css"/>
  </head>
  <body>
  </body>
  <script type="module">
    import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";

    const push = (data) => {
        data.push({
            id: ++id,
            value: Math.round(Math.random() * chartHeight)
        })
    }

    const barLeft = (i) => i * (30 + 2)

    const barHeight = (d) => d.value

    let id= 0,
        data = [],
        duration = 500,
        chartHeight = 100,
        chartWidth = 680;

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

    const render = (data) => {
        let selection = d3.select("body").selectAll("div.v-bar")
                .data(data, (d) => 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", (d, i) => barLeft(i+1) + "px")
                .style("height", "0px") // 最初はゼロ、2秒後にupdateで正しい値にtransitionする
                .append("span");

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

        // exit
        selection.exit()
                .transition().duration(duration)
                .style("left", (d, i) => barLeft(-1) + "px")
                .remove(); 
    }

    setInterval(() => {
        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>
</html>

3-2.サンプルプログラムの概説

data はデータの配列で、インデックスにより順番付けられたデータの集合を表します。サイズは20です。グラフ化すると以下のようなものですが、render 関数によって、左から index=0,1,2,3...19 の順番にデータが描かれます。2秒おきに data は更新され、render 関数が呼ばれます。data の更新は、先頭データが削除され、末尾に新データが追加されることで行われます。つまり index=0 のデータが削除され、全体の index を -1 して、最後尾に新データを追加します。このように次々と新 data でグラフを再描画すると、グラフが右から左へ遷移していく動きになります。

D3 は、提供されたデータのグラフを書き、その後、時々刻々と変化するデータを反映して、グラフを更新していきます。それを効率よく行うために考えられたのが Data Join - Enter / Update / Exit パタンです。

image.png

3-3.selection.data(data, key)

        let selection = d3.select("body").selectAll("div.v-bar")
                .data(data, (d) => d.id); 

selection.data(data, key) :

  • (1) まず key 関数は選択 DOM 要素 (_groups )のそれぞれに対して評価されます。 評価の順番は、カレントデータ、カレントindex、カレントnodes (nodes[i] がカレント DOM 要素)が渡される順番です。返り値が DOM 要素の識別子です。

  • (2) 次に key 関数は data のデータのそれぞれに対して評価されます。 カレントデータ、カレントindex、新データ(グループの親 DOM 要素を引き継ぐ)が渡され、返り値がデータの識別子となります。

  • (3) 最後に、識別子を持つデータは、同じ識別子を持つ 選択 DOM 要素 (_groups) に割り当てられます。

初期の data でグラフを描画するために、トップから一度呼ばれます。

1回目

  • (1)選択 DOM 要素は空なので何もしない。
  • (2)data の全データに関して d.id を識別子として計算する。
  • (3)選択 DOM 要素は空なので、マッチングは全て失敗し、データの割り振りは行われない。マッチングに失敗した data の全データは _enter としてそのまま残る。これはその後、selection.enter() に続く一連メソッドによって div 要素が割り当てられていく。

全データは selection.enter(). .. .style("height", "0px") により高さゼロのグラフとなる。

setInterval で2秒毎にrender 関数が呼ばれます。render 関数を呼ぶ前に data の先頭のデータを削除し、最後尾にデータを追加します。 更新されたdataでグラフが再描画されます。再描画時にポイントとなるのは、前の回に描かれた DOM 要素と、新しい data のデータの紐づけです。data の index は -1 しているので、key 関数を指定しない join-by-index 方式ならば、前回の紐づけと今回の紐づけが1個ズレてきます。それを避け、前回と今回の紐づけを一致させるために key 関数で識別子を与えるようにします。この紐づけは次に説明する selection.transition() のアニメーション描画で効果を発揮します。

2回目以降

  • (1)全ての選択 DOM 要素に割り振られたデータ(カレントデータ)対して 識別子 d.id を計算する。
  • (2)data の全データの 識別子 d.id を計算する。
  • (3)選択 DOM 要素の先頭は対応するデータが無いので、_exit に入る。。data の最後尾のデータは対応する DOM 要素が無いので _enter に入る。それ以外は、i=1,2,3..18 として (i-1) 番目の 選択 DOM 要素に対して data[i] のデータが対応します。 配列 data はスライスされているので現時点では index が -1 されていることに注意してください。

末尾のデータは selection.enter(). .. .style("height", "0px") で DOM に対応付けられ描画されるが、高さはゼロとなる。このデータは、繰り返しの次で、正常な高さで描かれる。
先頭の DOM 要素は selection.exit(). .. .remove() によって削除される。
残りの DOM 要素は selection. .. .style("left", (d, i) => barLeft(i) + "px").style("height", (d) => barHeight(d) + "px") で描画される。 i は index だが、全体的に -1 されているので左に移動する形になる。

3-4.selection.transition()

selection.transition()
selection.transition() は、selection オブジェクトに含まれる要素に対して、時間をかけて変化するアニメーション効果を与えるためのメソッドです。

このメソッドを使うと、要素の属性やスタイルを指定した期間とイージング関数に従って補間できます。

例えば、次のコードは body 要素の背景色を赤に変えるアニメーションを作ります。

d3.select("body")
  .transition()
  .style("background-color", "red");

::

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

このコードは、選択 DOM 要素の div 要素(棒グラフ)に対しての更新を行っています。同一の棒グラフに対して、高さデータ(d)は変わらないが、x座標 (index, i) は -1 されています。この変更部分が、左へ移行するアニメーション効果で描かれています。

3-5.key 関数の効果について

しつこいようですが、selection.data(data, key) における key 関数の有無について、どのような違いがあるのかを復習してみたいと思います。

まず以下の行を見てください。key 関数で識別子として d.id を指定しています。

(行1)
        let selection = d3.select("body").selectAll("div.v-bar")
                .data(data, (d) => d.id); // ★(1)

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

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

それでは以下の(行2)の場合はどうでしょう?

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

key 関数を省いた場合は、左へ流れる効果は得られません。この場合は、任意の DOM 要素に対して、高さデータ(d)が変わって、x座標(index,i)が変わらないからです。左へ流れる代わりに、上下に伸び縮む動きになります。

key 関数を指定しない join-by-index 方式では、単に data 配列の index が要素の特定に使われます。join-by-index 方式では、「data の先頭の1個が削除され、お尻に1個追加されている」動きが反映されません。2 回目以降は、 DOM 要素も data もサイズが 20 個で固定されており、順番に紐づけしているだけなので、結果的に enter も exit も空になります。例えば index=5 の6番目の要素についていえば、時間とともに上下に伸び縮みするアニメーションになります。

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

★行1の場合

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

★行2の場合

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

今回は以上です。

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2