h.sakai(訳)  -- 原文: "How Selections Work"

セレクションの仕組み

"充分に発達したテクノロジーは魔術と見分けがつかなくなる" - アーサー・C・クラーク

D3 のセレクションについては、これまでにも "D3 Workshop""Thinking with Joins""Three Little Circles"(和訳「三つの小円」)などの記事で簡単な説明はしてきたが、いずれも入門編としての最低限の範囲の説明だった。この記事では、単なるセレクション使い方ではなく、それがどのように実装されているのかという観点から分かりやすく説明して行きたい。多少長文ではあるが、この記事を最後まで読み通した読者にとってはセレクションはもはや魔術ではなく、D3(Data-driven documents)の理解もずっと深まっているはずだ。

この記事の構成は一見まとまりがないように思われるかもしれない。しかしこの記事はデザインのためではなく、セレクションの内部の仕組みについて解説するためのものだ。そうしたメカニズムの知識がなぜ必要なのかをいぶかしむ読者もおられよう。答えは簡単だ。D3 のセレクションの仕組みは多くのピースがともに働くことで実現されており、最初にそのピースの一覧を提示しておく方が説明が容易だからだ。この記事を読み終える頃に読者は、セレクションがかく設計されている意図やセレクションの機能について、明確な理解に達っしているはずだ。

D3 はビジュアリゼーションのライブラリだ。だからこの記事にも、文章とあわせてビジュアルな解説を盛り込みたい。以降の図では、図の左側にはセレクションの構造が、右側にはデータの構造が示される。

左側にはセレクション 左右を結びつけるもの 右側にはデータ

角丸長方形 thing は JavaScript オブジェクトを示す。リテラルオブジェクト({foo: 16})やプリミティブ値("hello")、数の配列([1, 2, 3]) から DOM 要素に至るまでの様々な JavaScript オブジェクトだ。特定のオブジェクトについては別に色分けされている。 セレクション配列要素 のように。あるオブジェクトから別のオブジェクトへの参照は接続線()によって示される。たとえば数値 42 を含む配列は次のようになる。

var array = [42];

与えられたセレクションを生成するためのコードは、可能な限り図のすぐ上に表示した。上の図では "var array = [42 ] がそのコードだ。

本文を読み進める際はブラウザの JavaScript コンソール画面を開き、コードを打ち込んで結果を見てみよう。実際にセレクションを作成してみるのが、本文をきちんと理解しているかどうかのかの一番良い確認方法だ。

さぁ始めよう。

#配列のサブクラス

読者の皆さんはおそらくセレクションとは DOM 要素の配列のことだと教わってきただろう。実はそれは正しい説明ではない。正しくない一つ目の理由は、セレクションは配列のサブクラスだからだ。そのサブクラスが、選択した要素を操作するためのメソッドを提供している。 たとえば属性スタイル を設定するメソッドだ。セレクションは array.forEacharray.map のようなネイティブな配列メソッドも継承してはいる。 しかし D3 は selection.each のような便利な代替メソッドを提供しているため、ネイティブなメソッドを使う頻度はそう多くないだろう(selection.filterselection.sort のように、その動作をセレクションに適合させるため、ネイティブなメソッドをオーバーライドしたものもある)。

#要素のグループ化

セレクションが単なる要素の配列ではないもう一つの理由は、セレクションが実際には要素の配列の配列だからだ。 セレクションとは(要素ではなく)グループの配列であり、その一つ一つのグループが要素の配列となっているのだ。次の例では d3.select は選択した要素を含むグループ一つのセレクションを返している。select はパターンにマッチする最初の要素を返すメソッドなので、このセレクションの(たった一つの)グループに含まれる要素は一つだ。

var selection = d3.select("body");

JavaScript コンソール画面を開き、この var selection = d3.select("body") というコードを打ち込んでみよう。変数 selectionに含まれるグループを調べるには selection[0]、一つ目のグループ(selectionに含まれるグループは一つだけ)に含まれる最初の(唯一の)ノードを調べるには selection[0][0] とタイプする。このようにダイレクトにノードを調べる方法も D3 の API ではサポートされているが、通常は(すぐ後に明らかになる理由で)selection.node の方が使われる。

同様に d3.selectAll は任意の個数の要素を含むグループを一つ返す。

d3.selectAll("h2");

d3.select の返すセレクションも、d3.selectAll の返すセレクションも、含まれるグループの数はどちらも一つだけだ。複数のグループを含むセレクションを作る唯一の方法は selection.selectAll だ。次の例ではまずテーブルの行全体を選択し、その次に行に含まれるセルを選択している。結果として各行にそれぞれ兄弟セルを含むグループ 4 つを含むセレクションが返される。

d3.selectAll("tr").selectAll("td");

selectAll メソッドを連続させると、元のセレクションの中の要素は、それぞれ次のセレクションの中のグループとなる。そしてその新たなグループの中に、元の要素の内部で selectAll のセレクタにマッチする子孫要素が含まれることになる。先ほどの例で考えてみよう。各セル( td 要素)の中に span 要素が一つずつ含まれているとする。このとき、次のように selectAll メソッドを 3度適用することで 16 個のグループが返され、それぞれのグループの中に 1 つずつの span 要素が含まれることになる。

d3.selectAll("tr").selectAll("td").selectAll("span");

グループには parentNode プロパティがあり、そのグループの要素に共通する親ノードが記録されている。親ノードがセットされるのはグループの生成時だ。したがって d3.selectAll("tr").selectAll("td") を呼ぶと、返されたセレクションには各 4 つの td 要素を含む 4 つのグループが含まれ、それぞれの td 要素の親は tr 要素となる。d3.select や d3.selectAll が返すセレクションの場合は、親要素は document 要素 だ。

セレクションがグループからなることを忘れていても、ほとんどの場合は特に問題はない。selection.attr や selection.style を設定する関数を使う場合も、関数は要素ごとに呼ばれるからだ。その差が問題になるのはそうした関数の二つ目の引数( i )を使う場合だ。この場合の i は各グループ内でのインデックス番号であり、セレクション内のインデックス番号ではないからだ。

#非グループ化オペレーション

グループ化に関してこうした特別の動作をするのは selectAll だけだ。select メソッドはグループに対しては何の変更も加えない。この違いの理由は select メソッドの返す新しいセレクションには元のセレクションの各要素に対応する要素が必ず一つしか含まれていないからだ。また、それゆえ select がデータを親から子へと受け渡すのに対し、selectAll はそうではない(これが後者にデータ結合が必要な理由だ)。

appendinsert の両メソッドは select のラッパーだ。したがって select 同様に、グループに変更を加えることはなく、データを親から子へと受け渡す。例として四つの section からなる document を考えてみよう。

d3.selectAll("section");

この各 section にパラグラフ( p )要素を追加しても、新しいセレクションに含まれるグループは同じく 4 つの要素からなるグループ一つだけだ。

d3.selectAll("section").append("p");

注意すべき点は、新しいセレクションの parentNode は元の document 要素のままであることだ。selection.selectAll メソッドが呼ばれていないためにセレクションの再グループ化が行われていないためだ。

#null 要素

グループは、要素が存在しない場合に null 値を取る( null 要素となる)ことができる。ほとんどの演算子は null を無視する。たとえば要素が存在しないグループに対しては、 D3 はスタイルや属性の適用をスキップする。

グループが null要素となるのは、selection.select が与えられたセレクタにマッチする要素を見つけられなかった場合だ。select メソッドはグループ構造を維持する必要があるため、空のグループを null で埋める。次の例は最後の 2 つの section しか aside 要素を持たない場合の例だ。

d3.selectAll("section").select("aside");

グループ化と同様、通常は null 要素のことを忘れていても問題はない。しかし、セレクションのグループ構造を維持するために null が使われること、null がグループ内でインデックス( [0] )を持つことは気に留めておこう。

#データへのバインド

意外と思われるかも知れないが、データはセレクションのプロパティでは無い。データは要素のプロパティだ。つまり、データをセレクションにバインドすると、データはセレクションにではなく DOM に保管される。具体的にはデータは各要素の __data__ プロパティに対して割り当てられる。要素にこのプロパティが無い場合は結び付けられたデータは undefined である。セレクションが一時的にしか存在しないのに対し、データには永続性があるということだ。そのため DOM から要素を選択し直してもその要素に前回バインドされていたデータはそのまま保持されている。

データを要素にバインドするには何通りかの方法がある。

selection.datum が使えるのに直接 __data__ プロパティを使ってデータをセットすべき理由はなにもないが、D3 がデータバインドをどのように実装しているのかを理解するのには役立つだろう。

document.body.__data__ = 42;

同じことを D3 らしい構文で書くには body を選択し datum メソッドを呼ぶ。

d3.select("body").datum(42);

ここで body に要素を追加すると、その子は自動的に親からデータを継承する。

d3.select("body").datum(42).append("h1");

さて、ここからいよいよ最後のデータバインド用メソッドの出番となる。そう、謎の join メソッドだ。しかし悟りを開くためには、その前にある、根源的問題を解決しておく必要がある。

#Data とは何か

D3 のデータは任意の値の配列を取り得る。次の例は数値の配列だ。

var numbers = [4, 5, 18, 23, 42];

オブジェクトの配列でもよい。

var letters = [
   {name: "A", frequency: .08167},
   {name: "B", frequency: .01492},
   {name: "C", frequency: .02780},
   {name: "D", frequency: .04253},
   {name: "E", frequency: .12702}
];

配列の配列でも大丈夫だ。

var matrix = [
   [ 0,  1,  2,  3],
   [ 4,  5,  6,  7],
   [ 8,  9, 10, 11],
   [12, 13, 14, 15]
];

セレクションの説明で使った視覚的表現が、鏡像のように反転させてデータの視覚表現にも使える。次の例は 5 つの数からなる単純な配列だ。

selection.style が定数文字列でスタイルプロパティを一律に指定することも(例:"red")、関数で要素ごとに動的に計算することもできるように(例:function(d) { return d.color; })、selection.data も定数値、関数のいずれも指定可能だ。

しかし、他のセレクションのメソッドとは異なり、selection.data はデータを要素ごとにではなくグループごとに設定する。データは各グループごとに値の配列の形式で指定されるか、そのような配列を返す関数として指定されるのだ。こうしてグループ化されたセレクションは、対応するグループ化されたデータを持つことになる。

図中の青線は data 関数が関連した配列を返すことを示している。データ関数にはそのグループの親ノード( parentNode )のデータ( d )とそのグループのインデックス番号( i )が渡され、そのグループに結合させるための任意のデータの配列を返す。このように、データが一般的に親データの関数として表されることにより、階層的データからの階層的 DOM 要素の作成が容易なものとなっている。

グループを一つしか持たないセレクションに対しては、対応する一つだけの配列を selection.data に直接渡せばよい。データ関数が必要となるのは、異なるデータを異なるグループにバインドする場合だけだ。

#悟りへの鍵

データを要素に結合するためには、どのデータをどの要素に割り当てるか知っておく必要がある。このために結合用のキーが用いられる。 キーとは「名前」のように単なる識別用の文字列だ。データと要素が同じキーを持つときにデータをその要素に割り当てる。

キーを割り当てるための一番簡単なメソッドはインデックスを使う方法だ。最初のデータと要素のキーが "0"、二番目のデータと要素のキーが "1"、、、となる。数字の配列と対応するパラグラフ( p )要素の配列の結合を表したのが以下の図だ。緑がキーを示している。

var numbers = [4, 5, 18, 23, 42];

この結果、セレクションの要素はデータにバインドされる。

d3.selectAll("div").data(numbers);

データと要素の並びが同一である限りインデックスを使って結合する方法は便利だ。しかし並びが異なる場合にはインデックスは使えなくなる。その場合には、selection.data の第二引数に key 関数を使う方法がある。key 関数は与えられたデータや要素の key を返す関数だ。次の例では、データは name プロパティを持つオブジェクトの配列であり、key 関数( name() )が name プロパティの値を返す。

var letters = [
   {name: "A", frequency: .08167},
   {name: "B", frequency: .01492},
   {name: "C", frequency: .02780},
   {name: "D", frequency: .04253},
   {name: "E", frequency: .12702}
];

   function name(d) {
      return d.name;
   }

これで再び、選択された要素はデータとバインドされ、セレクション中の要素はデータと一致するよう並び替えられた。

d3.selectAll("div").data(letters, name);

こうした処理はセレクション内のグループが多くなるにつれ非常に複雑なものになりかねないが、ある程度シンプルに保たれているのは各グループごとに独立して配列と結合されているからだ。キーのユニークさについても、セレクション全体ではなく、グループ内だけで考慮すればよい。

ここまでの例では、データと要素の個数が正確に 1:1 に対応することを前提にしてきた。もし与えられたデータに対応する要素が無い場合、あるいは与えられた要素に対応するデータが無い場合は何が起こるだろうか?

#Enter、Update、Exit

要素とデータをキーにより結合した場合、論理的には以下の三通りの結果が考えられる。

この結果が、selection.dataselection.enterselection.exit の返す三つのセレクションにそれぞれ対応している。次の例で考えてみよう。アルファベットの先頭五文字( ABCDE )をそれぞれ見出し(キー)に持つ棒グラフがあり、ここから好みの母音五文字( YEAOI )の棒グラフへ遷移したいとする。遷移の間、文字と棒グラフの結合を保つには key 関数が使える。データの結合は次の図のようになる。

遷移前のグラフのうち、母音は 2 文字( A と E )。したがってこの 2 文字の棒が update セレクションに入れられ、新しい値に更新される。

var div = d3.selectAll("div").data(vowels, name);

他の 3 つの文字( B、C、D )は子音なので、新しいデータセットには対応するデータが無い。したがってこれらの要素は exit(退場)セレクションに入れられる。ここで exit セレクション内では、元のセレクションの並びが維持されることを覚えておこう。削除の前にアニメーションさせたい場合、この並びの役に立つことがあるからだ。

div.exit();

最後に、3 つの母音( Y、O、I )のグラフは遷移前には表示されておらず、したがって対応する要素も存在していない。これが enter セレクションとなる。

div.enter();

update と exit が通常のセレクションであるのに対し、enter はセレクションのサブクラスだ。サブクラスでなければならない理由は、enter がまだ存在していない要素を表しているからだ。enter セレクションは DOM 要素ではなくプレイスホルダーを保持している。プレイスホルダーは __data__ プロパティを備えた単なるオブジェクトに過ぎない。そして enter.select の特殊な実装により、そのグループの親要素に対し、プレイスホルダーを置き換えながらノードを追加して行く。これがデータの結合の前に selection.selectAll メソッドを呼ぶことが重要である理由だ。selectAll メソッドにより、要素を追加するための親ノードが確定するからだ。

#Enter と Update のマージ

データ結合時の一般的な更新パターンは次の通りだ。enter(登場)した要素を追加し、exit(退場)する要素を削除し、同時に、update(継続)する要素に対してはその動的な属性、スタイル、その他のプロパティを修正する。この時、update する継続する要素と enter した要素のプロパティはしばしば重複してしまう。

同じコードの重複を避けるため、enter.append メソッドは効果的な副作用を備えている。enter.append は、update セレクションの null 要素を、enter セレクションに新しく追加された要素で置き換えしまうのだ。したがって enter.append メソッドの適用後、update セレクションには update する要素と enter した要素の両方が含まれることになる。すなわち、update セレクションには、現在画面に表示されている要素がすべて含まれていることになるわけだ。

こうしてセレクションとドキュメントが再び一致した時点で、データ結合のライフサイクルが完結する。

謝辞

本稿を査読し、ご意見をいただき、本稿の内容改善にご協力いただいた以下の方々に感謝する。
Anna Powell-Smith、Scott Murray, Nelson Minar、Tom Carden、Shan Carter、Jason Davies、Tom MacWright、John Firebaugh

参考文献

この記事が役に立った、あるいはどこか分かりにくところがあった、質問や意見がある、という方はぜひ私(マイク・ボストック)まで TwitterHacker News で知らせてほしい(もちろん英語で)()。セレクションについてもっと学びたいという方は D3’のセレクション関係のソースを読んでみることで自分がどこまで理解しているかが正確に分かるだろう。以下は各氏による優れた解説やチュートリアルだ。

:訳文についてのお問い合わせは訳者まで。内容について日本語での質問・疑問はD3.js 日本ユーザーグループまでどうぞ。

0 1 2 3
4 5 6 7
8 9 10 11
12 13 14 15