Posted at
d3.jsDay 23

D3.js on AngularJSの実装パターン比較

More than 3 years have passed since last update.


はじめに

以前, 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


main.js

function MainCtrl(){

this.datum = [
{cx: 100, cy: 50, r: 50},
{cx: 100, cy: 150, r: 30},
{cx: 100, cy: 250, r: 20}
];
}


main.html

<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.chordd3.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に委譲するため, ユーザインタラクションにngClickngMouseoverが利用出来ない


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主体の実装例を見ていこうと思う.

cap01.png


準備:可視化と関係の無い共通部分実装

データをいじるアプリ部分.


app/main/main.controller.js

'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);
};

});



app/main/main.html

<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主体とした場合の可視化領域実装

jsfiddleに動作コードあり

<svg> 要素配下のDOM(SVG)の構築, Collectionとのバインディング, 要素の増減追跡すべてをD3のメソッドで実現する.

DOMに直接アクセスするため, Angularとの糊付け部分はDirectiveで実現する形となる.


components/d3/d3Bar.directive.js

'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主体とした場合の可視化領域実装

jsfiddleに動作コードあり

上述の通り, Angular主体の方法では, 可視化部分もD3は使わずに, ngRepeatやAngularJSのバインディング({{...}})を用いてViewを構築していく:


app/viz/barLayout.html

<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を使っている程度.


app/viz/barLayout.controller.js

'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に動作コード有り)


app/biz/pieLayout.html

<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>


app/biz/pieLaytou.controller.js

'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通りがある


    1. 可視化領域をDirectiveとし, Directive内部ではD3.jsメインで実装する

    2. CollectionとのデータバインディングはAngularに一本化し, D3.jsはSVGのユーティリティとしてのみ用いる



結局, 1. と 2., どちらがいいのか? という疑問に対してであるが, 僕は個人的には 2.の Angular主体とする方法が良いと思っている.

「ViewをHTML + α で宣言的に構築できる」はAngularJSでの気に入っている点の一つだし, View構築方法が2通りで混在していると, アプリケーション全体の統一感というか, 見通しが悪くなるのは避けたいと考えているからだ.


参考