JavaScript
d3.js
AngularJS
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通りで混在していると, アプリケーション全体の統一感というか, 見通しが悪くなるのは避けたいと考えているからだ.

参考