AngularにD3を組み込んでみよう

こんばんは、たーせるです。

ずっと Angular 単独ネタばかり書いてきましたが、ときには別なライブラリと組み合わせてみたくなることがあります。 でも、使いたいライブラリと Angular の相性が必ずしも良いとは限りません。

今日はそんなシチュエーションに遭遇したときのためのお話です。


今回のテーマ

今日やることはこれだけです。

  • Pure JS で実装された簡単な D3 のサンプルプログラムを、Angular + TypeScript に移植する
D3; Data-Driven Document とは

D3 を一言で表すと、Web アプリケーション向けのお絵描きライブラリです。 言葉で説明をするよりも、むしろ以下のページで紹介されているサンプルをご覧いただければ「あーなるほど」となるはずです(手抜き)。

github.com

データのバインドや座標計算のための便利な関数がひととおり整っており、その自由度の高さと使い勝手の良さからGitHub スター数の総合ランキングでも 13 位、topic: visualization の中では堂々1位という王者の風格が漂っております。

ただし、Angular と D3 はそもそも思想が異なるため、正直、両者の相性は良くありません。 最も厄介なのが DOM 操作に対する思想の違いです*1

だからと言って D3 を諦めるには余りにも惜しいので、できるだけお行儀のよい方法で両者を共存させる道を探っていきましょう。

題材サンプル

ドットインストールのサンプルコードを参考に、配列 data = [15, 20, 5]; の要素に基づいて半径の異なる円を描画するコードを書いてみました。

まずは移植前のオリジナル (Pure JS) 版サンプルコードを以下に示します。

index.html

<svg width=600 height=240></svg>
<script>
  function updateSvg(data) {
    const svgHeight = 240;
    
    const circles = d3.select('svg')
      .selectAll('circle')
      .data(data);
    
    circles
      .enter()                            // 追加されたデータを表示に反映
      .append('circle')
      .merge(circles)                     // 更新されたデータを表示に反映
      .attr('cx', (_, i) => i * 50 + 50)  //   cx  : 円のx座標を設定
      .attr('cy', svgHeight / 2)          //   cy  : 円のy座標を設定
      .attr('r', d => d)                  //   r   : 円の半径を設定
      .attr('fill', 'green');             //   fill: 円の背景色を設定

    circles
      .exit()                             // 削除されたデータを表示に反映
      .remove();
  }

  const data = [15, 20, 5];
  updateSvg(data);
</script>

これを Angular + TypeScript ではどう書くのかを見ていきましょう。

Angular + TypeScript で書き直す

開発環境(ライブラリのバージョン)

今回の記事を書くにあたり、@angular/cli 8.0.0 を用いました。 また、D3 5.9.2、uuid 3.3.2 をそれぞれ npm install しています。 package.json の該当箇所を以下に示します。

package.json(抜粋)

  "dependencies": {
    "@angular/animations": "~8.0.0",
    "@angular/common": "~8.0.0",
    "@angular/compiler": "~8.0.0",
    "@angular/core": "~8.0.0",
    "@angular/forms": "~8.0.0",
    "@angular/platform-browser": "~8.0.0",
    "@angular/platform-browser-dynamic": "~8.0.0",
    "@angular/router": "~8.0.0",
    "@types/d3": "^5.7.2",
    "@types/uuid": "^3.4.4",
    "d3": "^5.9.2",
    "rxjs": "~6.4.0",
    "tslib": "^1.9.0",
    "uuid": "^3.3.2",
    "zone.js": "~0.9.1"
  },
実装例

続いて実装例を示します。 ここではソースコード全量を掲載し、次節で Angular + TypeScript で実装する際のポイントをつまんで紹介していきます。

app.component.html

<svg [id]="svgId"
     [attr.width.px]="svgWidth"
     [attr.height.px]="svgHeight">
</svg>

app.component.ts

import { Component, AfterViewInit } from '@angular/core';
import * as d3 from 'd3';
import * as uuid from 'uuid';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements AfterViewInit {
  // svgのid属性にバインドするランダム値
  readonly svgId = `svg_${uuid.v4()}`;

  // svgの幅と高さ
  readonly svgWidth  = 600;
  readonly svgHeight = 240;

  // svgのルート要素を取得します
  private get svgRootElement() {
    return d3.select(`#${this.svgId}`);
  }

  // svg配下のcircle要素を取得します
  private get svgCircleElement() {
    return this.svgRootElement
      .selectAll<SVGCircleElement, number>('circle');
  }

  /**
   * 初期化処理
   */
  ngAfterViewInit() {
    const data = [15, 20, 5];
    this.updateSvg(data);
  }

  /**
   * 与えられたデータに基づき、表示を更新します
   * @param data データ配列
   */
  updateSvg(data: number[] = []) {
    const circles = this.svgCircleElement
      .data(data);                        // 与えられたデータをバインド

    circles
      .enter()                            // 追加されたデータを表示に反映
      .append('circle')
      .merge(circles)                     // 更新されたデータを表示に反映
      .attr('cx', (_, i) => i * 50 + 50)  //   cx  : 円のx座標を設定
      .attr('cy', this.svgHeight / 2)     //   cy  : 円のy座標を設定
      .attr('r', d => d)                  //   r   : 円の半径を設定
      .attr('fill', 'green');             //   fill: 円の背景色を設定

    circles
      .exit()                             // 削除されたデータを表示に反映
      .remove();
  }
}

Angular ならではのポイント

DOM 操作対象の HTML 要素にランダム ID を与える

これは D3 に限った話ではないですが、僕が普段よく使う裏技のひとつに「Angular 以外の手段で DOM 操作を行う HTML 要素には、id 属性に UUID を与える」というバッドノウハウがあります。

app.component.ts

  readonly svgId = `svg_${uuid.v4()}`;

app.component.html

<svg [id]="svgId"
  (略) >
</svg>

このようにすることで、仮令たとえ ドキュメント内に同じコンポーネントクラスから実体化された HTML 要素が複数存在しても、各 id 属性には必ず一意な値が自動で割り当てられますので、HTML 要素を Angular に依存しない方法で個体識別できるようになります。

app.component.ts

  // svgのルート要素を取得します
  private get svgRootElement() {
    return d3.select(`#${this.svgId}`);
  }

ちなみに、id属性の先頭が数字だった場合、d3.select() がエラーになってしまいますので、これを避けるためにsvg_という接頭辞を付けています。

svg の width, height は属性attributeバインディングを使う

続いて、<svg width="600" height="240"></svg> という svg タグのうち、widthheight に変数をバインドする際の注意点です。

ありがちな失敗ですが、<svg [width]="svgWidth" [height]="svgHeight"></svg> と書いてしまうと、実行時に怒られてマトモに動作しません。

svgwidthheightプロパティではなく属性ですので、[attr.width.px][attr.height.px] に対してバインディングを行う必要があります。

app.component.html

<svg [id]="svgId"
     [attr.width.px]="svgWidth"
     [attr.height.px]="svgHeight">
</svg>

app.component.ts

  readonly svgWidth  = 600;
  readonly svgHeight = 240;
selectAll() できちんと型引数type argumentsを明示しないと merge() で怒られる

Angular というよりは TypeScript の言語仕様上の注意点です。 型にルーズな Pure JS とは違い、TypeScript で D3 を書くときは selectAll() を行う際に型引数<SVGCircleElement, number>を明示的に指定する必要があります。

app.component.ts

  private get svgCircleElement() {
    return this.svgRootElement
      .selectAll<SVGCircleElement, number>('circle');  // 型引数を指定
  }
  updateSvg(data: number[] = []) {
    const circles = this.svgCircleElement
      .data(data);

    circles
      .enter()
      .append('circle')
      .merge(circles)                     // 型引数がないとここでエラーになる
      .attr('cx', (_, i) => i * 50 + 50)
      .attr('cy', this.svgHeight / 2)
      .attr('r', d => d)
      .attr('fill', 'green');

    ()
  }

もし、この型引数を省略すると、上記の merge() のところで以下のようなコンパイルエラーが出てしまいます。

型 'Selection<BaseType, number, BaseType, unknown>' の引数を型 'Selection<SVGCircleElement, number, BaseType, unknown>' のパラメーターに割り当てることはできません。
  型 'BaseType' を型 'SVGCircleElement' に割り当てることはできません。
    Type 'Element' is missing the following properties from type 'SVGCircleElement': cx, cy, r, pathLength, and 108 more.ts(2345)

別解として、merge()の手前のappend()<BaseType>を指定しても一応コンパイルを通すことができます。

app.component.ts

  private get svgCircleElement() {
    return this.svgRootElement
    .selectAll('circle');  // 型引数なし
  }
  updateSvg(data: number[] = []) {
    const circles = this.svgCircleElement
      .data(data);

    circles
      .enter()
      .append<BaseType>('circle')         // ここに<BaseType>型引数を指定してもよい
      .merge(circles)
      .attr('cx', (_, i) => i * 50 + 50)
      .attr('cy', this.svgHeight / 2)
      .attr('r', d => d)
      .attr('fill', 'green');

    ()
  }
svg 配下の要素に CSS を適用する場合は、スタイルのカプセル化を切る

以下のように、<circle> 要素に対して、外出しした CSS を当てたい場合も注意が必要です。

svg > .circle {
  fill: green;
}

app.component.ts

  updateSvg(data: number[] = []) {
    const circles = this.svgCircleElement
      .data(data);

    circles
      .enter()
      .append('circle')
      .merge(circles)
      .attr('cx', (_, i) => i * 50 + 50)
      .attr('cy', this.svgHeight / 2)
      .attr('r', d => d)
//    .attr('fill', 'green')
      .classed('circle', true);           //         .circle を適用

    circles
      .exit()
      .remove();
  }

この場合、残念ながら styleUrls で読み込ませた CSS のクラスをそのまま指定しても何も起こりません。 なぜそうなるかは Angular 公式ドキュメントのこの辺を参照。

この問題の最も簡単な解決策は、svg配下に適用したいスタイルは全部 styles.css に書くことです。 styles.css に書いたスタイルは、Angular によってカプセル化されないため、たったこれだけで大丈夫です。 デメリットとしては、コンポーネントとスタイル定義の配置が離れてしまうという点が挙げられます。

もう一つの方法として、スタイルは通常通り styleUrls で読み込まれるファイルに書き、代わりに SVG を内包するコンポーネントencapsulationViewEncapsulation.Noneを与えてカプセル化を無効化することで、グローバルな styles.css にすべてをまとめておく必要がなくなります。

app.component.ts

import { Component, AfterViewInit, ViewEncapsulation } from '@angular/core';
import * as d3 from 'd3';
import * as uuid from 'uuid';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css'],
  encapsulation: ViewEncapsulation.None  // スタイルのカプセル化を無効に
})
export class AppComponent implements AfterViewInit {
  ()
}

まとめ

だいぶ長くなってしまったので、そろそろ締めに入りましょう。

基盤となるフレームワークの上で宗教の違うライブラリを動かそうとすると、それなりの配慮が必要というのが今回のお話でした。

では、また。

*1:Angular は構造ディレクティブによってプログラマから DOM 操作を隠蔽する設計になっています。 一方、D3 は jQuery と同様、プログラマ自身がゴリゴリと DOM 操作を行うことを前提としたライブラリです。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.