はじめに
以前, AngularJSでD3.jsをラップしてみようのエントリで, 「いかにしてAngularJSとD3.jsを統合するか」を記載した.
その後, 自分で幾つかAngularJSとD3.js両方を使ったApplicationを作ったり, http://alexandros.resin.io/angular-d3-svg/ のブログを読んだりした上で, 双方のライブラリ両方を使う際の知見が増えたので, これを機にもう一度整理してみようかと.
ライブラリとしての比較
AngularJSとD3.js, 双方を触ってみて思うのは, 特にコアな部分について, 備えている機能が重複していたり, 同等機能への実装アプローチが異なるなぁ、ということ.
# | 比較項目 | Angular's way | D3's way |
---|---|---|---|
1. | View構築スタンス | Declarative(宣言的) | Functional(手続的) |
2. | データバインディング |
ngModel や {{...}}
|
d3.selectAll(〜).data(〜) |
3. | Collection CRUD追跡 | ngRepeat |
enter() , exit()
|
4. | Layout機能 | 無 |
d3.layout.force 等 |
5. | SVG utility | 無 |
d3.svg.arc 等 |
特徴的なのは, 1.〜3.の部分だろう.
まぁ, 4.と5.は可視化に特化したライブラリであるD3.jsだけが備えているのは違和感無いし...
以下で, それぞれの機能毎に考え方を見ていく.
View構築スタンス
まずはViewに対するスタンスから.
「SVGの領域へ (x, y)=(100, 100)に半径20の円を描画する」を例に考えてみる.
Angular's way
宣言的. HTMLテンプレートを用いて, マークアップしていく.
<svg>
<circle cx="100" cy="100" r="20"></circle>
</svg>
D3's way
手続的. jQueryライクなDOM操作関数でDOMを組み立てて行く.
var svg = d3.select('body').append('svg');
svg.append('circle')
.attr('cx', 100)
.attr('cy', 100)
.attr('r', 20);
データバインディング
Angular's way
ngModel
や, {{...}}
等のDirectiveをHTMLテンプレートに記述することで, ScopeのデータがViewへバインドされる.
裏側では, Directiveの実装により, $scope.$watch
や$scope.$watchCollection
が動作する.
D3's way
D3の場合, バインディングはCollectionに対して行う.
d3.selectAll(セレクタ).data(コレクション)
とすることで, セレクタ対象のDOMとコレクションが1:1で紐付けられる.
こいつがあってこその"Data Driven Document"と言っても過言ではないくらい.
data()
メソッドでCollectionとの紐付けを実施した後は, attr
メソッド等をメソッドチェーンで呼び出すことで, データ値の加工 -> DOM属性値へのセット をクロージャで処理できる.
d3.selectAll('circle').data([100, 200, 300])
.attr('cx', function (d) { return d;})
.attr('r', function (d) {return Math.sqrt(d);});
Collection CRUD追跡
AngularJS, D3.js共にコレクションを扱うための仕組みがある.
特にどちらのライブラリも, 配列要素 <-> DOM要素 を1:1にマッピングする機構と, Collection側の要素数増減まで含めてサポートしている.
Angular's way
ngRepeat
Directiveが, Collection
function MainCtrl(){
this.datum = [
{cx: 100, cy: 50, r: 50},
{cx: 100, cy: 150, r: 30},
{cx: 100, cy: 250, r: 20}
];
}
<div ng-controller="MainCtrl as main">
<svg>
<circle ng-repeat="data in main.datum"
ng-attr-cx="{{data.cx}}"
ng-attr-cy="{{data.cy}}"
ng-attr-r="{{::data.r}}" >
</circle>
</svg>
</div>
ngRepeat
は Collection(上記の場合, main.datum
)の要素数が変動した場合, 増分(減分)のみを再描画するように実装されている.
なお, 上記のViewのサンプルで2点補足:
-
ng-attr-*
<circle>
等, SVG要素中のattributeにScopeの値をバインディングする際,cx={{...}}
とすると, 一見何の問題もなくRenderingされるが, コンソールにError: Invalid value for <circle> attribute cx=以下略
のようなエラーを吐くことがある.
このエラーの原因は, AngularJSのDirectiveガイドにもある通り, SVGのDOM APIが厳密にバリデーションを行うため.
回避するためには,ng-attr-
をバインドしたい属性に付与すればよい. -
{{::〜}
AngularJS 1.3.xから導入されたOne Time Binding. 一回だけ評価すればよい式に用いる.
D3's way
var datum = [
{cx: 100, cy: 50, r: 50},
{cx: 100, cy: 150, r: 30},
{cx: 100, cy: 250, r: 20}
];
var render = function (datum) {
var svg = d3.select('svg#viz'), selection = svg.selectAll('circle').data(datum);
selection.enter()
.append('circle')
.attr('r', function(d){return d.r});
selection
.attr('cx', function(d){return d.cx})
.attr('cy', function(d){return d.cy});
selection.exit()
.remove();
};
render(datum);
D3.jsの場合、d3.selectAll(data)
に、以下の部分集合を取り扱う為のAPIが用意されている:
-
enter()
: 現時点ではDOMに存在しないが, 描画されるべきCollection要素の部分集合 -
exit()
: Collection要素には既に存在しないが, DOMツリーに取り残されているDOM要素集合
したがって, enter()
の結果はDOMに append
, exit()
の結果はDOMからremove()
という様に, 描画用の関数(上記のrender()
)を作成しておくことで,
Collectionの状態を意識することなく, Collection変更発生時に都度都度render()
を呼び出すことで, DOMとCollectionの状態を同期し続けることが出来る.
Layout, SVG utility
まぁ, Angularに、そこまで可視化に特化した機能はないですよね...
D3のよいところは, さくっと使えるLayout(d3.layout.chord
やd3.layout.force
)が揃っていたり, 面倒なSVGの各種属性を計算するためのユーティリティ関数がビルトインされている点.
実装パターン
とまぁ, ここまででAngularJS, D3.jsの似ている点・異なる点を見てきた.
これらを踏まえた上で, 両者を同時に利用する際の実装パターンを考えてみる.
D3を主体とする方法
可視化領域配下のView構築, データバインディング, コレクション制御ついて, D3.js側の機能を利用するパターン.
上記で, 「D3.js's way」としてきた実装方法を組み合わせていく.
書籍のAngularJSリファレンス, D3 on AngularJS に記載されている方法も, こちらのパターンに属している.
このパターンの特徴は下記のようになる.
- 可視化領域に対してDirectiveを作成し, Directive配下のDOMをD3.jsのメソッドで手続的に組み立てる.
- Directiveの引数として, 可視化対象のデータコレクションが格納されたScopeのプロパティを受け取る.
-
$scope.$watchCollection
や$scope.$watch(... , ... , true)
(deep watch) を用いて,$scope
の値変動時に動作するListenerを登録. - 上記Listenerの内部で,
d3.selectAll(...).data(...)
のenter()
やexit()
を呼び出し, 要素の追加/削除のDOM操作をd3的に記述.
上記のパターンが有用なのは, 下記だろうか.
- 実装したい可視化アプリがD3.jsのコードとして出来あがっており, AngularJSへの繋ぎ部分を作ってしまえば良いケース
裏を返すと, このパターンには以下のデメリットが伴う.
- 可視化部分の実装変更にはDirective修正が必ず伴う.
- DirectiveのLink関数(それも
$scope.$watch
に登録するListener)にコードの大半が集中し, Directiveのコードが巨大化しがち. - DOM操作をD3.jsに委譲するため, ユーザインタラクションに
ngClick
やngMouseover
が利用出来ない
Angularを主体とする方法
もう一つのパターンは, View構築, バインディング, コレクション変動時のDOM制御全てをAngularJSベースで実装するパターン.
思想的には Radian はこちらのパターンに近い(ExpressionがAngularJSのものとは異なるが).
このパターンの特徴は下記である:
- 可視化領域内部も含め, DOMはAnguarJSのHTMLテンプレートで構成していく.
- SVGの属性についても,
ng-attr-cx={{...}}
のようにし, HTMLに直接記載していく. - D3の利用は, Layout構築やSVG便利機能(d3.svg.line())に留める.
メリットは以下あたりか:
- 可視化領域含め, HTMLテンプレートでViewが構成されるため, 変更が容易.
- D3.jsのメソッドチェーンで頻発していたクロージャが減る分, コードはすっきりする.
Angular主体の方法で実装した場合のデメリットは下記か.
- アニメーション(
ngAnimate
)との相性が微妙.
D3主体の方法であれば,.attr('width', functoin(){...})
にtransition()
を噛ますだけで, ニュルニュル幅を増やしたりが簡単であった. Angular主体の場合,ngAnimate
モジュールを使うことになるのだが, CSS属性ベースのアニメーションが基本のため, SVG要素の属性をtweenさせるようなアニメーションには不向きな印象がある.
実装サンプル
以前のエントリでも取り上げたが, 下図のようなサンプルに対して, D3主体の実装例, Angular主体の実装例を見ていこうと思う.
準備:可視化と関係の無い共通部分実装
データをいじるアプリ部分.
'use strict';
angular.module('angularD3SampleApp').controller('MainCtrl', function () {
var main = this;
main.sampleData= [
{name:'Chrome', users: 500},
{name:'Internet Explorer', users: 200},
{name:'Safari', users: 300}
];
main.newData = {name: '', users: 0};
main.add = function(){
main.sampleData.push(main.newData);
main.newData = {name: '', users: 0};
};
main.remove = function($index){
main.sampleData.splice($index, 1);
};
});
<div class="header">
<h3>D3 Sample</h3>
</div>
<div class="row marketing" ng-controller="MainCtrl as main">
<div class="col-md-12">
<table class="table">
<thead>
<tr><th>#</th><th>name</th><th>users</th><th>action</th></tr>
</thead>
<tbody>
<tr ng-repeat="data in main.sampleData"><!-- data iteration {{{ -->
<td>{{$index + 1}}</td>
<td>{{data.name}}</td>
<td> <input type="number" size="5" class="form-control input-sm" ng-model="data.users"> </td>
<td>
<button class="btn btn-sm btn-danger" ng-click="main.remove($index);">
<span class="glyphicon glyphicon-minus-sign"></span>
remove
</button>
</td>
</tr><!-- }}} end iteration -->
<tr><!-- new data {{{ -->
<td>new</td>
<td> <input type="text" size="10" placeholder="input name" class="form-control input-sm" ng-model="main.newData.name"> </td>
<td> <input type="number" size="5" class="form-control input-sm" ng-model="main.newData.users"> </td>
<td>
<button class="btn btn-success btn-sm" ng-click="main.add()">
<span class="glyphicon glyphicon-plus-sign"></span>
add
</button>
</td>
</tr><!--}}} new data -->
</tbody>
</table>
</div>
<div class="col-md-12">
<div>
<!-- ★★ D3主体の場合 ★★-->
<d3-bar data="main.sampleData" key="name" value-prop="users" label="name"></d3-bar>
<!-- ★★ Angular主体の場合 ★★-->
<div ng-include="'app/viz/barLayout.html'"></div>
</div>
</div>
</div>
上記の<!-- ★★ ★★ -->
部分は, D3主体 or Angular主体のどちらか一方を残すべし.
D3主体とした場合の可視化領域実装
<svg>
要素配下のDOM(SVG)の構築, Collectionとのバインディング, 要素の増減追跡すべてをD3のメソッドで実現する.
DOMに直接アクセスするため, Angularとの糊付け部分はDirectiveで実現する形となる.
'use strict';
angular.module('angularD3SampleApp').directive('d3Bar', function (d3, $parse) {
return{
restrict: 'EAC',
scope: {data: '=', key: '@', valueProp: '@', label: '@'},
link: function (scope, element) {
var svg = d3.select(element[0]).append('svg').style('width', '100%');
var colorScale = d3.scale.category20();
var watched = {};
var getId = $parse(scope.key || 'id');
var getValue = $parse(scope.valueProp || 'value');
var getLabel = $parse(scope.label || 'name');
scope.$watchCollection('data', function () {
if(!scope.data){
return;
}
var dataSet = svg.selectAll('g.data-group').data(scope.data, getId);
var createdGroup = dataSet.enter()
.append('g').classed('data-group', true)
.each(function(d){
var self = d3.select(this);
watched[getId(d)] = scope.$watch(function () {
return getValue(d);
}, function(v){
self.select('rect').attr('width', v);
});
});
createdGroup.append('rect')
.attr('x', 130)
.attr('height', 18)
.attr('fill', function (d) {
return colorScale(d.name);
});
createdGroup.append('text').text(getLabel).attr('height', 15);
dataSet.exit().each(function(d){
var id = getId(d);
watched[id]();
delete watched[id];
}).remove();
dataSet.each(function (d, i) {
var self = d3.select(this);
self.select('rect').attr('y', i * 25);
self.select('text').attr('y', i * 25 + 15);
});
});
}
};
});
上記のd3Bar
Directiveについては, 以前のエントリでも説明した通りのため, 解説については, こちらを参照されたし.
Angular主体とした場合の可視化領域実装
上述の通り, Angular主体の方法では, 可視化部分もD3は使わずに, ngRepeat
やAngularJSのバインディング({{...}}
)を用いてViewを構築していく:
<svg ng-controller="BarLayoutCtrl as barLayout" style="width:100%;">
<g ng-repeat="data in main.sampleData track by data.name">
<text ng-attr-y="{{$index*25+15}}" height="15">
{{::data.name}}
</text>
<rect
x="130"
ng-attr-y="{{$index*25}}"
ng-attr-width="{{data.users}}"
height="18"
fill="{{::barLayout.fill(data.name)}}"
>
</rect>
</g>
</svg>
この例のような, 単純な棒グラフであれば, D3の活躍する余地は殆どなく, 辛うじて棒の塗りつぶし色(fill
属性)を決定する部分で, d3.scale.category20
を使っている程度.
'use strict';
angular.module('angularD3SampleApp').controller('BarLayoutCtrl', function (d3) {
var barLayout = this;
var colorScale = d3.scale.category20();
barLayout.fill = function (id) {
return colorScale(id);
};
});
Layoutの利用例(ドーナツチャート)
前回も, Layoutを利用する例として, ドーナツチャート(d3.layout.pie
)を内部的に利用したDirectiveを示した.
今回も同じく, Angular主体の方法でドーナツチャートに置き換えたら, どのようになるかを示してみる.
...というより, 棒グラフの例だと, 「D3.js無くてもよくね?」という結論になってしまいそうなので, D3に存在するがAngularには存在しない「Layout, SVG utility」を利用するサンプルも例示することとする.
上記のapp/biz/barLayout.html
, app/biz/barLayout.controller.js
を下記で置き換える:
(jsfiddleに動作コード有り)
<svg ng-controller="PieLayoutCtrl as pieLayout" style="width:100%; height:420px">
<g transform="translate(200,200)">
<path
ng-repeat="part in pieLayout.parts track by part.data.name"
ng-attr-d="{{pieLayout.arc(part)}}"
fill="{{::pieLayout.fill(part.data.name)}}"
>
</path>
</g>
</svg>
'use strict';
angular.module('angularD3SampleApp').controller('PieLayoutCtrl', function (d3, $scope) {
var pieLayout = this;
var colorScale = d3.scale.category20();
var arc = d3.svg.arc().outerRadius(200).innerRadius(100);
pieLayout.fill = function (id) {
return colorScale(id);
};
pieLayout.arc = function (part) {
return arc(part);
};
$scope.$watch('main.sampleData', function (datum) {
pieLayout.parts = d3.layout.pie().value(function (data) {
return data.users;
}).sort(null)(datum);
}, true);
});
ドーナツチャートの扇型部分のSVGは, <path d="...">
で表されるが, このd属性をD3無しにAngularだけで計算するのは相当面倒くさいが, d3.svg.arc()
とd3.layout.pie
をうまく使えば瞬殺である.
Controller分割
先の棒グラフの例でも意図的に分割していたのだけれど, Angular主体の方法では, 可視化部分がScopeとなるように子Controllerを用意していた.
これは、「Scopeのデータに従属する可視化要素(=座標情報, 色, etc...)を取り仕切るViewModel」を別途, メインのScopeの下位に用意しておきたかったらだ.
今回のサンプルで言うなら, MainCtrl
は「棒グラフの個別の色」に関心を抱くべきではないが, 色は棒グラフのデータ名称から決定している.
アプリケーション自体が必ずしも, 「データの編集部分」 + 「データの可視化部分」で構成されるとは限らないので, 常に編集部分を親, 可視化部分を子としたController分割が成り立つとは言えないが, AngularJS主体の方法で実装した場合に, Fat Controllerとならないためにも、心がけておきたいポイントではないだろうか.
まとめ
- AngularJSアプリケーションでD3.jsを用いた可視化を行う場合, 以下の2通りがある
- 可視化領域をDirectiveとし, Directive内部ではD3.jsメインで実装する
- CollectionとのデータバインディングはAngularに一本化し, D3.jsはSVGのユーティリティとしてのみ用いる
結局, 1. と 2., どちらがいいのか? という疑問に対してであるが, 僕は個人的には 2.の Angular主体とする方法が良いと思っている.
「ViewをHTML + α で宣言的に構築できる」はAngularJSでの気に入っている点の一つだし, View構築方法が2通りで混在していると, アプリケーション全体の統一感というか, 見通しが悪くなるのは避けたいと考えているからだ.
参考
- http://alexandros.resin.io/angular-d3-svg/ 1年近く前 & 英語 ですが, 導入部の説明は読み物としてもおもしろいので是非