(2016.9.20更新)
本記事はv3の場合です。v4はかなり挙動が異なります。
D3.js v4のデータバインド
http://qiita.com/mojaie/items/be0a3eaf84273e2d42e7
独特なメソッドチェーンとデータバインディング機構で使いづらさに定評のあるD3.jsを利用したアプリケーションの設計について。
- 環境
- D3.js version 3.5.17
- Google Chrome version 51.0.2704.79
D3の記法は一見清々しく気持ちの良いコードが書けて、プログラミング初心者がプレゼン用図表を用意するには持って来いなのであるが、そこそこの規模のウェブアプリケーションの設計ともなると次のような課題がでてくる。
- グラフの色やマーカーの形を変える多数のセレクトボックスやボタン、よく使用するグラフのテンプレートなど、UIコンポーネントの再利用を検討する。
- ユーザの操作によってデータが変更される際に、常に最新の状態が描画に反映されなければならない。
- 多数の描画オブジェクトを同時に表示して操作する際に、オブジェクトの振る舞いが互いに干渉しないようにしなければならない。
次のサンプルコードは一般的なD3のメソッドチェーン記法によるものである。
const frame = d3.select('body').append('svg')
.attr("width", 400)
.attr("height", 200);
const seq = [1, 2, 3, 4, 5];
frame.selectAll('circle')
.data(seq)
.enter()
.append('circle')
.attr('r', d => d * 5)
.attr('cx', d => d * 50)
.attr('cy', 50);
例えば、データ(上記コードのseq)をユーザが入力して変更できるようにする場合、変更がある度に一度描画したcircleを消して再度データをバインドし、circleを再描画するという操作を付け足す必要がある。
あるいはデータポイントのマーカーをcircleからrectに変更するセレクトボックスを設置する場合、circleのサイズや座標の設定をrectに再利用し、circleと同じように表示させられると楽ではないだろうか。
enter以降を関数に切り出す
D3での描画は、おおよそ次の手順で行われる。
- 親要素の指定(select, selectAll)
- データの束縛(data)
- データとDOMの整合性チェック(enter, exit)
- DOM操作(append, remove, attr, classedなど)
公式のサンプルコードを見ると、ほとんどの例でこれらの操作を一気にチェーンして描画しているものが多い。コードが直感的でシンプルであるのは良いことなのだが、再利用性を考えるとデータバインドとDOM操作は分けたほうが良さそうである。
上記のサンプルコードから、DOMを扱うenter以降を関数に切り出してみた。
const frame = d3.select('body').append('svg')
.attr("width", 400)
.attr("height", 200);
const seq = [1, 2, 3, 4, 5];
function drawCircle(data) {
data.enter().append('circle')
.attr('r', d => d * 5)
.attr('cx', d => d * 50)
.attr('cy', 50);
data.exit().remove();
}
const seqData = frame.selectAll('circle').data(seq);
drawCircle(seqData);
enter関数により、現在データに存在していてDOMが持っていない要素が選択される(つまりenterが返すのは実はselection)。そこにappendが呼ばれることでcircleタグが作成される。
exitはデータに存在しない不要なDOM要素のselectionを返す(ので続けてremoveで削除)。enterとexitは対になっていて、2つを呼び出すことでDOMへのデータの反映が行われる。
よって、enter-append-exit-removeの一連の流れを関数にしておけば、これは常にDOM要素が最新のデータの状態を反映していること保証する(データのみに依存する関数になっている)。Reactで言うところのコンポーネントに近いかもしれない。
試しにcircleをrectに変えるチェックボックスを付けてみた。
// UI
const frame = d3.select('body').append('svg')
.attr('width', 400)
.attr('height', 200);
d3.select('body').append('input')
.attr('type', 'checkbox')
.attr('onchange', 'toggleRect()');
d3.select('body').append('span').text('rect')
// イベント
function toggleRect() {
const flag = d3.select('input').node().checked;
if (flag) {
frame.selectAll('circle').attr('visibility', 'hidden');
frame.selectAll('rect').attr('visibility', 'visible');
} else {
frame.selectAll('circle').attr('visibility', 'visible');
frame.selectAll('rect').attr('visibility', 'hidden');
}
}
// データ
const seq = [1, 2, 3, 4, 5];
// 描画
function drawCircle(data) {
data.enter().append('circle')
.attr('r', d => d * 5)
.attr('cx', d => d * 50)
.attr('cy', 50);
data.exit().remove();
}
function drawRect(data) {
data.enter().append('rect')
.attr('x', d => d * 50)
.attr('y', d => 50)
.attr('width', d => d * 5)
.attr('height', d => d * 5);
data.exit().remove();
}
// 初期化
const seqData = frame.selectAll('circle').data(seq);
drawCircle(seqData);
drawRect(seqData);
frame.selectAll('circle').attr('visibility', "visible");
frame.selectAll('rect').attr('visibility', "hidden");
イベントやUIコンポーネントが増えてくると徐々に有り難みがでてくる気がする。
ボタンの押下状態なども含めて全ての状態をデータとして一元管理するとか、全てのイベントはデータのみを変更し、データの更新を即時一括してDOMに伝えるようにするなど、やることはまだ山程ありそう。
D3のForce layoutのサンプルコード
ついでに、D3の公式サイトにあるForce-Directed Graphのサンプルを上記と同じように書き直したもの
Force-Directed Graph
http://bl.ocks.org/mbostock/4062045
データは上記サイトのmiselable.json
<!DOCTYPE html>
<html>
<meta charset="utf-8">
<style>
.node {
stroke: #fff;
stroke-width: 1.5px;
}
.link {
stroke: #999;
stroke-opacity: .6;
}
</style>
<body>
<script src="//d3js.org/d3.v3.min.js"></script>
<script>
// 設定
const width = 960;
const height = 500;
const color = d3.scale.category20();
// 状態(データ)
const state = {};
// SVGタグの作成
const svg = d3.select("body").append("svg")
.attr("width", width)
.attr("height", height);
// d3.forceの設定
const force = d3.layout.force()
.charge(-120)
.linkDistance(30)
.size([width, height]);
force.on("tick", () => {
svg.selectAll(".link")
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y);
svg.selectAll(".node")
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});
// state.linksの更新をlinkクラスの要素に反映
function setLinkComponents(data) {
data.enter()
.append("line")
.attr("class", "link")
.style("stroke-width", d => Math.sqrt(d.value));
data.exit().remove();
}
// state.nodesの更新をnodeクラスの要素に反映
function setNodeComponents(data) {
data.enter()
.append("circle")
.attr("class", "node")
.attr("r", 5)
.style("fill", d => color(d.group))
.call(force.drag)
.append("title")
.text(d => d.name);
data.exit().remove();
}
// stateの更新をSVGの要素に反映してシミュレーションを再開
function update() {
setLinkComponents(
svg.selectAll(".link").data(state.links)
);
setNodeComponents(
svg.selectAll(".node").data(state.nodes)
);
force
.nodes(state.nodes)
.links(state.links)
.start();
}
// JSONを読み込んでstateに保存しておく->updateを呼んでグラフ描画
d3.json("miserables.json", (error, graph) => {
state.nodes = graph.nodes;
state.links = graph.links;
update();
});
</script>
</body>
</html>
(2016/6/9追記) enter.appendの後にattr等をチェーンするのも良くない
enter.appendの次にチェーンしたattr等はenterで選択されたセレクション、つまり新しく生成するDOMにしか影響しない。再描画が発生する場合は注意が必要。
また、dataメソッドのキー関数を利用して更新データを再バインドする場合は、既存のDOMの並び順が優先されるので、更新データの並びを反映させたい場合はorderメソッドを呼ぶ必要がある。
次のような流れをテンプレートとして使うのが良さそう。
const d = selector.data(更新データ);
d.enter().append(要素);
d.exit().remove();
d.order();
// 属性、プロパティの変更処理