D3.jsのenter()が分からなかったのでコードリーディングしたメモ

前回、D3.jsでレーダーチャート書いてて、enter() で何が起きてるのかよく分からなかったのでソースコードを読んでみました。そのときのメモです。
長くなりました。一番下の方にまとめもあります。
読んだD3.jsのバージョンは、3.4.2です。

読んでみたらenter()はただのgetterメソッドで特に何の処理もしてませんでした。代わりにdata()ががんばってたんですねー。
ソースコード読んでみて、やっとD3 - セレクションの仕組みに書いてある意味が分かった気がします。

下のような円を描くコードを元にソースコードを読んでみました。

var svg = d3.select('body')
            .append('svg')
            .attr('width', w)
            .attr('height', h);

svg.selectAll('path')
   .data(dataset)
   .enter()
   .append("circle")
   .attr("cx", function(d, i) {
     return (i * 50) + 25;
   })
   .attr("cy", h/2)
   .attr("r", function(d) {
     return d;
   });

セレクションを取得する部分を読む

まずは、select()が何をしてるか見てみる。

var svg = d3.select('body')

d3.jsの関連してる部分をのぞく。

// d3.js
// 9行目
var d3_document = document, d3_documentElement = d3_document.documentElement, d3_window = window;

// 38行目
var d3_select = function(s, n) {
  return n.querySelector(s);
},
d3_selectAll = function(s, n) {
  return n.querySelectorAll(s);
},

// 534行目
d3.select = function(node) {
  var group = [ typeof node === "string" ? d3_select(node, d3_document) : node ];
  group.parentNode = d3_documentElement;
  return d3_selection([ group ]);
};
d3.selectAll = function(nodes) {
  var group = d3_array(typeof nodes === "string" ? d3_selectAll(nodes, d3_document) : nodes);
  group.parentNode = d3_documentElement;
  return d3_selection([ group ]);
};

まずは、document.querySelectorを使って、selectの引数で指定したDOMのElementオブジェクトの配列を作る。今回の例だとbody要素のElementオブジェクトを保持する配列。その配列をgroup変数に代入して、parentNodeプロパティを設定して、d3_selection([group])で処理してる。d3_selection関数を呼ぶときに、[[element1, element2, ... ]]のような2次元配列を引数にしてるんですね。

d3_selection関数は以下。

// d3.js
// 478行目
var d3_subclass = {}.__proto__ ? function(object, prototype) {
  object.__proto__ = prototype;
} : function(object, prototype) {
  for (var property in prototype) object[property] = prototype[property];
};
function d3_selection(groups) {
  d3_subclass(groups, d3_selectionPrototype);
  return groups;
}

d3_subclassで、groups、つまりElementオブジェクトの配列を要素に持つ2次元配列([[ element1, element2, ...]])に、d3_selectionPrototypeのプロパティをコピーして返却してる。
そして、d3_selectionPrototypeオブジェクトには以下のようなプロパティが設定されてる。

d3_selectionPrototype.attr = function(name, value) { ...
d3_selectionPrototype.append = function(name, value) { ...
d3_selectionPrototype.data = function(name, value) { ...
// 他にもいろいろ

ふむふむ。

selectでは、[[element1, element2, ...]]というような2次元配列を返してて、この2次元配列にはd3_selectionPrototypeからプロパティがコピーされてる。この拡張された2次元配列を、セレクションって呼んでいるんだな。

そんでもって、セレクションが保持してる[element1, element2, ...]という配列にparentNodeプロパティが追加されたオブジェクトをグループと呼ぶと。

今回の例のd3.select('body')で取得するセレクションは以下のようなイメージ。

f:id:bati11:20140222191408p:plain

一般化すると以下のようなイメージ。たぶん、この記事(Nested Selections)を読むとより理解が深まりそうな気がする。

f:id:bati11:20140222191413p:plain

SVG要素をDOMとして追加する部分を読む

d3.select('body').append('svg')っていう感じでセレクションのappendメソッドを呼んでる。
appendソースコードを読んだところ、createElementNSでElementオブジェクトを生成してappendChildしてる。appendの返り値はappendChildしたElementオブジェクトを保持するセレクションになる。

ここまでが、以下のコードでbody要素内にsvg要素が生成される部分。

var svg = d3.select('body')
            .append('svg')
            .attr('width', w)
            .attr('height', h);

データバインドしてる部分を読む

これからは、datasetを使って、円を描く部分。

svg.selectAll('path')
   .data(dataset)
   .enter()
   .append("circle")
   .attr("cx", function(d, i) {
     return (i * 50) + 25;
   })
   .attr("cy", h/2)
   .attr("r", function(d) {
     return d;
   });

selectAllは、さっきのselect('body')で見たのとほとんど同じで、違うのはquerySelectorではなくquerySelectorAllが使われてる点。つまり、Elementオブジェクト1件ではなく、複数件取得する場合がある。
ただ、svg要素はさっき作ったばかりで、svg要素内は空だから、svg.selectAll('path')はElementオブジェクトを1つも保持していないセレクションを返すことになる。
このセレクションのdataメソッドを呼び出す。dataメソッドは大きめの関数。

// d3.js
// 749行目
d3_selectionPrototype.data = function(value, key) {
  var i = -1, n = this.length, group, node;

  // 70行くらい

  var enter = d3_selection_enter([]), update = d3_selection([]), exit = d3_selection([]);
  if (typeof value === "function") {
    while (++i < n) {
      bind(group = this[i], value.call(group, group.parentNode.__data__, i));
    }
  } else {
    while (++i < n) {
      bind(group = this[i], value);
    }
  }
  update.enter = function() {
    return enter;
  };
  update.exit = function() {
    return exit;
  };
  return update;
}

inの値を見ると、bind関数が1回だけ呼ばれそう。bind関数は、dataメソッドの中で定義されてる。

// d3.js
// 760行目
function bind(group, groupData) {
  var i, n = group.length, m = groupData.length, n0 = Math.min(n, m), updateNodes = new Array(m), enterNodes = new Array(m), exitNodes = new Array(n), node, nodeData;
  if (key) {
    // 省略
  } else {
    for (i = -1; ++i < n0; ) {
      node = group[i];
      nodeData = groupData[i];
      if (node) {
        node.__data__ = nodeData;
        updateNodes[i] = node;
      } else {
        enterNodes[i] = d3_selection_dataNode(nodeData);
      }
    }
    for (;i < m; ++i) {
      enterNodes[i] = d3_selection_dataNode(groupData[i]);
    }
    for (;i < n; ++i) {
      exitNodes[i] = group[i];
    }
  }
  enterNodes.update = updateNodes;
  enterNodes.parentNode = updateNodes.parentNode = exitNodes.parentNode = group.parentNode;
  enter.push(enterNodes);
  update.push(updateNodes);
  exit.push(exitNodes);
}

// 831行目
function d3_selection_dataNode(data) {
  return {
    __data__: data
  };
}

bind関数内で、セレクションのElementオブジェクトの数とdatasetの要素数に応じて、updateNodesenterNodesexitNodes、それぞれの配列にオブジェクトがpushされる。

pushされるオブジェクトには、__data__プロパティが設定されてる。これがデータバインド。

どういう風にオブジェクトがpushされるかというと、Elementオブジェクトの数のよりdatasetの要素数の方が多いとき、例えばElementオブジェクトの数が3で、datasetの要素数が7のとき

  • updateNodesには
    • 3つのElementオブジェクトそれぞれの__data__プロパティに、datasetの最初から3つの値をそれぞれセットしてから、push
  • enterNodesには
    • datasetの残りの4つの値を使って、{__data__: data}というオブジェクトを生成し、push
  • exitNodes
    • なにもpushしない

逆に、Elementオブジェクトの数が7で、datasetの要素数が3のとき

  • updateNodesには
    • 7つのElementオブジェクトのうち、最初から3つのオブジェクトに__data__プロパティをセットしてpush
  • enterNodesには
    • なにもpushしない
  • exitNodes
    • 残りの4つのElementオブジェクトをpush

これら3つの配列は、bind関数ではなく、外側のdataメソッドで定義されている3つのセレクション、enterupdateexitpushされる。

dataメソッドをもう一度見てみると、これらの値がdataメソッドの返り値として使われてることが分かる。

// d3.js
// 749行目
d3_selectionPrototype.data = function(value, key) {

  // 省略

  var enter = d3_selection_enter([]), update = d3_selection([]), exit = d3_selection([]);

  // 省略

  update.enter = function() {
    return enter;
  };
  update.exit = function() {
    return exit;
  };
  return update;
}

dataメソッドで返ってくるのは、セレクションで、保持してるElementの配列はupdateになっている。さらに、dataメソッドで返ってくるセレクションには、enterexitのgetterメソッド、enter()exit()が設定されている。

はぁー、なるほどー。enter()って既存のDOMよりdatasetの数が多い場合に、余分なデータの配列を持つセレクションを返却してるだけなのか。
[[{__data__: 10}, {__data__: 20}, ...]] こんな感じの。
そんでもって、余分なデータの配列はenter()を呼んだ時じゃなくて、data()を呼び出した時点で作られてるんだね。

enter()後の処理を読む

appendではグループが保持するオブジェクトの数だけappendChildしている。enter()は上記の通りenterNodesを保持するセレクションを返すだけ。つまり、enter().append('circle')とするとenterNodesの数だけcircle要素がappendChildされることになる。

まとめ

  • セレクションは、Elementの配列の配列である2次元配列に、色々なプロパティを追加して拡張したもの。
  • セレクションが保持するElementの配列にはparentNodeプロパティが追加されていて、この配列をグループと呼ぶ。
  • セレクションのdataメソッドは仕事が多い。超ざっくり言うと新たなセレクションupdateを生成し返却する。
    • updateのグループが保持するオブジェクトには、__data__プロパティが追加されてる。これがデータバインド。
    • 元々のセレクション(dataメソッド内におけるthis)のグループの要素数datasetの要素数に応じて、3つのセレクション、updateenterexitにオブジェクトを追加していく。
    • updateには、enterメソッドとexitメソッドが追加されていて、それぞれenterexitのgetterメソッドになっている。

なんか理解が少し進んだ気がします!

今日、下の本を読んでたらコラムにdata、enter周りについて書いてあるのを見つけました!

エンジニアのための データ可視化[実践]入門 ~D3.jsによるWebの可視化 (Software Design plus)

エンジニアのための データ可視化[実践]入門 ~D3.jsによるWebの可視化 (Software Design plus)