LoginSignup
47
48

More than 5 years have passed since last update.

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

Posted at

はじめに

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

参考

47
48
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
48