はじめに
以前にも書いたことがあるが、D3.jsをAngularJS向けにラップしたライブラリは下記等がある。
- Radian.js http://openbrainsrc.github.io/Radian/index.html
- Danglue.js http://www.fullscale.co/dangle/
しかしながら、いずれも、特定の用途に特化したAPIの提供であり、D3.jsの表現の幅としては大分狭められてしまう感ある。
良いものが無ければ自分で作ればいいじゃない、ということで、自分でD3.jsとAngularJS向けにラップして好きに使ってしまおう。どうすれば、良い感じでAngularJSとD3.jsが統合出来るか、色々試行錯誤している過程のメモである.
対象
このエントリの対象者は↓な人たちです(狭いなぁ...)
- データ可視化 + クライアントサイドMVCに興味がある.
- AngularJS:カスタムディレクティブの作り方を何となく知っている
(http://qiita.com/Quramy/items/dd4e7d2693c32d92048c にカスタムディレクティブの作成方法を記載しています. 参考まで). - D3.js:ある程度は触ったことがある.
作ってみる
作る物
下記の機能を持つアプリケーションを作ってみる.
- シンプルな横棒グラフ
- データ個数は画面から追加・削除が可能
- データ追加後も画面から、データ値を変更可能
下記の画面イメージ(データはWebブラウザのシェア的なものを想定. 値は適当)
準備
YeomanとBowerでさっくりと.
$ sudo npm -g install yo generator-angular
$ mkdir angular-d3-sample;cd angular-d3-sample
$ yo angular
$ bower install d3 --save
D3のService化
別に必須でも無いのだが、個々のディレクティブ等を一々グローバル変数 d3
に依存させたくないので factory
でService化しておく.
yo angular:factory d3Service
angular.module('angularD3SampleApp').factory('d3Service', function () {
return {d3: d3};
});
Directive
さて、本エントリのメインたる、D3.jsを使ったグラフ部分だ。
AngularJSにおいて、DOM操作が発生する処理はディレクティブで実装する一択なので、以下のカスタムディレクティブ d3Bar
を作成する.
yo angular:directive d3Bar
angular.module('angularD3SampleApp').directive('d3Bar', ['d3Service', '$parse', function (d3Service, $parse) {
var d3 = d3Service.d3;
return{
restrict: 'EAC',
scope:{
data: '=', // APLのController側とデータをやり取り.
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 = {}; // $watchリスナの登録解除関数格納用.
// (Angular) $parseでCollection要素へのアクセサを確保しておく.
var getId = $parse(scope.key || 'id');
var getValue = $parse(scope.valueProp || 'value');
var getLabel = $parse(scope.label || 'name');
// (Angular) Collectionの要素変動を監視.
scope.$watchCollection('data', function(){
if(!scope.data){
return;
}
// (D3 , Angular) data関数にて, $scopeとd3のデータを紐付ける.
var dataSet = svg.selectAll('g.data-group').data(scope.data, getId);
// (D3) enter()はCollection要素の追加に対応.
var createdGroup = dataSet.enter()
.append('g').classed('data-group', true)
.each(function(d){
// (Angular) Collection要素毎の値に対する変更は、$watchで仕込んでいく.
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);
// (D3) exit()はCollection要素の削除に対応.
dataSet.exit().each(function(d){
// (Angular) $watchに登録されたリスナを解除して、メモリリークを防ぐ.
var id = getId(d);
watched[id]();
delete watched[id];
}).remove();
// (D3) Collection要素変動の度に再計算する箇所.
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);
});
});
}
};
}]);
Directiveのポイント
このディレクティブを利用するView側のコード例は後述するとして、一旦、上記のディレクティブのポイントを解説.
親Scopeとの値バインド
scope.data
は親Scopeの配列型プロパティとバインドすることを想定している.
例えば、 $scope.items = [{code:'hoge', value:100}, {code:'foo', value:200}]
のようなデータが親Scopeから渡されるイメージだ.
ディレクティブの scope:{...}
定義にて、幾つかの属性を要求している.
このディレクティブは、棒グラフの幅をscopeのコレクション要素から取り出して可視化に用いる訳だが、 scope.valueProp
が、幅相当プロパティに対するアクセサに相当する。
例えば、上記の例であれば、 <div d3-bar data="items" value-prop="value" label="code" />
のように利用側で指定することで、親Controller側でScope構造の自由度を保ちつつ、ディレクティブ側では $parse(scope.data)(scope)
等のように $parse
Serviceを利用することでコレクションの値にアクセスが出来るようになっている。
今回のディレクティブでは、色情報等はハードコーディングしているが、適宜属性を追加していけば、必要なプロパティを切り出して汎用性を確保することが出来る。
コレクション要素の増減への対応
先述した通り、 scope.data
の要素は親Controller側にて、追加・削除が発生し得る。これに対応するために、ディレクティブのLink関数の主要部分が下記のコードに収まることとなる.
link: function(scope, element){
scope.$watchCollection('data', function(){
//
});
}
scope.$watchCollection
に登録しているリスナ関数は下記に対応する必要がある.
- コレクション要素の追加: SVG要素
<g class="data-group">
の追加 - コレクション要素の削除: SVG要素のDOMツリーからの削除
ここから先は、 d3.selectAll(...).data(...).enter()
と d3.selectAll(...).data(...).exit()
で操作対象のDOM要素を絞り込むのが良い.
-
enter()
セレクタの結果において、データに紐づいていないデータの組を返す →.append
する -
exit()
データが欠落したDOM要素集合を返す →.remove
する
Appendしているのは、SVGの <rect>
要素と <text>
要素であるが、それぞれのY座標値は、要素が削除された際に再計算が必要となるため、 scope.$watchCollection
が呼び出される度に、都度実行するようにしている.
D3とScopeデータ紐付け時の注意
scope.data
をD3でDOM要素に紐付ける際に用いる d3.selectAll(...).data()
関数であるが、第二引数を省略してしまうと、Scopeのコレクション要素削除時の挙動がおかしくなるので注意が必要.
D3側で、data()
関数の第二引数省略時は、配列のインデックス値で exit()
結果を決定するため、 ['hoge', 'foo', 'bar']
を ['hoge', 'bar']
のように要素を詰めた場合、削除されたのは 'bar'
であると解釈されてしまうのだ。
これに対応するため、 data()
の第二引数に、コレクション要素からトラッキング用のキー情報を得るためのCallbackを登録している.
.data(scope.data, getId)
の部分だ.
コレクション要素内の値変更に対応への対応
scope.$watchCollection
のみでは、[{value:100}]
→ [{value:200}]
のように、要素が保有している個別値の変動には対応できない.
このままでは、親Scope側で値を変動させても、棒グラフは、微動だにしない状態である。
所謂deep watch( scope.$watch
の第3引数をtrueにするパターン)を利用することで、個別要素値を含む、コレクションの全ての値変動でリスナを動作させることは可能である。
しかし、scope.data
に可視化と無関係な要素がどれだけ含んでいるか、ディレクティブ側からは判断できないことを考えると、Digestサイクルで無駄な値監視が多発し、性能が著しく劣化する懸念がある。
(ちなみに、"D3 on AngularJS"では、deep watchのパターンを推奨しているが、ディレクティブの設計としては微妙と思う)
従って、ここでは個別値変動への対応として、enter().append()
によるDOM要素の追加時に、 scope.$watch
で監視対象とする要素毎の値とリスナを登録する方法を選択している.
dataSet.enter().append(/* 略 */).each(function(d){
var self = d3.select(this),
scope.(function(){
return getValue(d);
}, function(newValue, oldValue){
// self.attr() など. 新旧の値両方が取れるため、dulation系の処理も可能
});
});
scope.$watch
の第1引数に(Angular Expressionでなく)監視対象特定用の関数を用いている.
個別値監視のメモリリークに注意
上記で利用している、Scope.$watch
について、第2引数であるリスナ関数は scope
オブジェクトに要素追加毎に格納されていく。
ここで考慮しなくてはならないのが、やはりコレクション要素の削除時の話である。
リスナ関数がコレクションの保有元である scope
に溜まっているため、 exit()
のタイミングでリスナを登録解除しないとヒープがどんどん増え続けることとなる.
しかも、上記のようにクロージャのレキシカルスコープ変数 self
にて、$watch
のリスナからDOM要素参照を握っているため、当該要素が remove
でDOMツリーから削除されても、GCで回収されない状況を作り出してしまう.
普段はあまり使わないが、scope.$wacth
の戻り値は、リスナの登録解除用関数である。
リスナ登録解除関数への参照を enter().append
時に確保しておき、 exit().each
にて、確保した登録解除関数を呼び出すことで、メモリリークを防ぐようにした(登録解除関数の特定には、 先述のgetKey
を再利用)。
Controller, View
作成したディレクティブを利用する側のコードは以下の通りだ。
ContorollerはScopeの編集を行うのみの単純な作りである。
angular.module('angularD3SampleApp').controller('MainCtrl', ['$scope', function($scope){
$scope.sampleData= [
{name:'Chrome', users: 500},
{name:'Internet Explorer', users: 200},
{name:'Safari', users: 300}
];
$scope.newData = {name: '', users: 0};
$scope.add = function(){
$scope.sampleData.push($scope.newData);
$scope.newData = {name: '', users: 0};
};
$scope.remove = function($index){
$scope.sampleData.splice($index, 1);
};
}]);
Viewとなるhtmlは下記.
Bootstrap使って記述しているため、若干記述量が多いが、 <table>
タグ配下が Scopeの編集系、html末尾付近の <div d3-bar>
が作成したカスタムディレクティブの利用箇所.
<div class="header">
<h3>D3 Sample</h3>
</div>
<div class="row marketing">
<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 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="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="newData.name"> </td>
<td> <input type="number" size="5" class="form-control input-sm" ng-model="newData.users"> </td>
<td>
<button class="btn btn-success btn-sm" ng-click="add()">
<span class="glyphicon glyphicon-plus-sign"></span>
add
</button>
</td>
</tr><!--}}} new data -->
</tbody>
</table>
</div>
<div class="col-md-12">
<div>
<div d3-bar data="sampleData" value-prop="users" key="name" label="name"></div>
</div>
</div>
</div>
grunt serve
でアプリを実行すれば、上述の画面イメージ通りのアプリケーションが実行される筈。
Layoutを組み合わせてみる
折角なので、もう一つ程サンプルをば。まったく同じScopeのデータを使って、ディレクティブだけを差し替えてみる.
差し替え後のディレクティブは、 d3.layout.pie
を使ったドーナツチャートだ。
angular.module('angularD3SampleApp').directive('d3Pie', ['d3Service', '$parse', function (d3Service, $parse) {
var d3 = d3Service.d3;
return {
restrict: 'EAC',
scope: {
data: '=',
key: '@',
valueProp: '@'
},
link: function postLink(scope, element) {
var svg = d3.select(element[0]).append('svg')
.style('width', '100%')
.style('height', '420px')
.append('g').attr('transform', 'translate(200,200)');
var colorScale = d3.scale.category20();
var arc = d3.svg.arc().outerRadius(200).innerRadius(100);
var getId = $parse(scope.key), getValue = $parse(scope.valueProp);
var watched = {};
scope.$watchCollection('data', function(){
var pie = d3.layout.pie().value(function(d){
return getValue(d);
}).sort(null);
var dataSet = svg.selectAll('g.data-group').data(pie(scope.data), function(d){
return getId(d.data);
});
var createdGroup = dataSet.enter().append('g').classed('data-group', true);
createdGroup.append('path').attr('fill', function(d){
return colorScale(getId(d.data));
});
createdGroup.each(function(d){
watched[getId(d)] = scope.$watch(function(){
return getValue(d.data);
}, function(){
svg.selectAll('g.data-group').data(pie(scope.data)).select('path').attr('d', arc);
});
});
dataSet.exit().each(function(d){
var id = getId(d.data);
watched[id]();
delete watched[id];
}).remove();
dataSet.select('path').attr('d', arc);
});
}
};
}]);
棒グラフとほぼ同一パターンの実装だ。Layoutを利用したとしても、データバインディングや要素の追加・削除関連処理の流れは、流用可能であることが伝われば幸いである。
まとめ とか考察とか
今回は、自作ディレクティブより下位のDOM(主にSVG)要素の組み立てには、全てD3.jsを利用する方向でディレクティブを設計している。
ちょうど、ディレクティブを境界として、AngularJSからD3.jsへ主たるライブラリが変更されているイメージ。
実際、作成したディレクティブのLink関数の内容は、Angularらしいコードはせいぜい、 Scope.$watch
と Scope.$watchCollection
程度だ。
この方法では、ディレクティブを実装する際のデータバインディング関連のポイントは主に下記であった。
- コレクション増減は
Scope.$watchCollection
で監視 - 要素増減への対応実装は、
d3.selectAll(...).data(...).enter()
とd3.selectAll(...).data(...).exit()
で絞り込み - コレクション要素値の変動は、
Scope.$watch
で監視、戻り値の登録解除を要素削除時に実施する. - 削除対象要素のトラッキング,
$watch
リスナ解除の特定に要素キーを割り出すコールバックが必要
ところで、AngularとD3、それぞれの画面に対する実装方法のスタンスは次の通りである。
- AngularJS:ViewによるテンプレートとディレクティブでHTMLを構築. DOM操作は基本的にControllerには意識させない
- D3.js:jQueryライクなメソッドチェーンでDOM操作
AngularJSでは、細かい粒度のディレクティブをガンガン組み合わせることで、再利用性の高さを確保している訳だが、これを考えると、今回作成したディレクティブはまだかなり微妙である。親Scopeに対する幾つかのプロパティに対して、アクセッサを設定する口は持たせてみたものの、Viewの拡張性はかなり低い。
例えば、棒グラフに横軸を表示させたいと思ったら、もはやディレクティブ本体のコードを修正するしかない。
逆に、画面要素の記述系(DOM操作コードと言ってもいい)を、もう少しAngularライクな方法に倒すことも出来るとは思う。
例えば、 <rect>
や <path>
のような個別のSVG要素はViewとなるHTMLテンプレートに記述するようなパターンである。
ディレクティブを作成する際に、 transclude:true
を指定して、子要素の記述を利用者側にある程度、委ねてしまうパターンだ。
いずれ、そのようなパターンでの統合方法についても検討出来ると良いなぁ。
2014.12.23追記
http://qiita.com/Quramy/items/4ed72ae91899b47fb167 に続編的なエントリを記載しました.
同様のサンプルについて, よりAngularライクな方法での実装との比較を載せています.