bitbank techblog

Angular with D3.js

はじめまして。フロントエンドチームのDangと申します。ベトナム出身です。

今回はD3.jsチャートをAngularに埋め込むサンプル実装をご紹介します。

目的

このブログでは以下の機能を持つチャート実装を目的としています。

  • 同じx、y軸で複数のラインチャートを描く
  • D3.jsのcolorテーマを適用
  • data変更時のアニメーション
  • touchイベントのハンドリング

サンプルコード
 - チャートロジック実装
 - Angularコンポーネント
 - liveデモ
 - ソースコード

準備

  • angular-cling newコマンドで空のAngularプロジェクトを生成
  • D3.jsが依存する関連ライブラリなどをinstall
  npm i --save d3 d3-interpolate-path hammerjs
  npm i --save-dev @types/d3
  • ng g componentコマンドで空コンポーネントを作成。

上記が完了した前提でD3.jsの実装に着手します。

チャートの実装

チャートを書くためには以下の要素が必要です。

  • svg要素(コンテナー)
  • チャートdata
  • チャートオプション

dataとオプションはtypescriptで定義すると以下のようになります。

// interface定義
// ポイント
export interface ChartPoint {
  x: number;
  y: number;
}

// chartデータはポイントの配列
export type ChartData = ChartPoint[];

// オプション
export interface ChartOptions {
  width?: number | string;
  height?: number | string;
  strokeColor?: string;
  // 配列の場合は[上、右、下、左]の順番
  margin?: number | [number, number, number, number];
  animateDuration?: number;
}

チャートクラスは以下の通りです。

// multi-line-chart.ts
export class MultiLineChart {

  constructor(svgElement: any, options: ChartOptions, multiData: ChartData[]) {
    this.init(svgElement);
    this.update(options, multiData);
  }
  
  private init(svgElement: any): void;
  
  update(chartOptions: ChartOptions, multiData: ChartData[]): void;
  
}

D3.jsはsvgを使ってチャートを描き、最終的にこのような構成でチャートを表現します。

<svg>
  <g class="charts-container">
    <path class="chart" d="..."/>
    <path class="chart" d="..."/>
    <path class="chart" d="..."/>
  </g>
</svg>

静的要素の追加

private init(svgElement: any): void {
  this.svgElement = svgElement;
  this.svg = select(svgElement);

  // g.charts-containerを最初一回追加
  this.svg
    .append('g')
    .attr('class', 'charts-container');
}

上記の要素を追加すると、下記の要素が生成されます。

<svg>
  <g class="charts-container"></g>
</svg>

次は動的な要素を作りましょう。

<path class="chart" d="..."/>
<path class="chart" d="..."/>
<path class="chart" d="..."/>
...

Data join

データとsvg要素を動的にsyncしたりデータbindしたりするために、データジョインという概念があります。

データジョインのプロセスはupdate、 enter、 exitという三つの種類があります。

//update要素だけ
要素[el0, el1, el2], データ[d0, d1, d2] => [el0(d0), el1(d1), el2(d2)]

// updateとenter要素
要素[el0, el1, el2], データ[d0, d1, d2, d3, d4]
  => [el0(d0), el1(d1), el2(d2), enter0(d3), enter1(d4)]

// updateとexit要素
要素[el0, el1, el2, el3, el4], データ[d0, d1, d2]
  => [el0(d0), el1(d1), el2(d2), el3exit(), ele4exit()]

実装してみると、

// containerをselect
const chartsContainer = this.svg.select('g.charts-container');
// 返す要素はupdate要素と呼ばれます
let charts = chartsContainer
  .selectAll('path.chart') // 複数要素選択、m件数
  .data(multiData) // それぞれの要素に順番で該当のデータ(arrayの一つ)をbindする
  ;

この段階で、要素の件数(m)と配列のデータのlength(n)が違う場合はenterとexitを実行します。

// html要素の方が長いm > n
charts.exit() // 該当データのない要素(n+1 => m)
  //..bottom lineに変化する
  .remove() // 消す

// データの方が長いm < n、d3v4からupdateとenter要素はマージしなければならない
charts = charts.enter() // 新しいデータによって要素を追加する
  .append('path')
  .attr('class', 'chart')
  // はじめにbottom lineを描く
  // 上記で取得したupdate要素とmergeする
  .merge(charts)
  ;

現在の要素と新しく追加した要素をマージし、マージされた要素を使用して次のstepでチャート描くことにより、チャートを変化させます。

Scale

もとのデータ(domain)からchart上の座標位置(range)を計算するには、scaleLinearを使います。

const scale = scaleLinear()
  .domain(domain)
  .range(range);

bindされたデータのvalueをsvgポジション属性にマッピングします。

例えばx軸の座標を計算するときxScaleを設定しておきます。

const xScale = scaleLinear()
  .domain([minX, maxX])
  .range([0, width]);

console.log(xScale(minX)); // => 0, データvalue(minX) => svgポジション属性(0)
console.log(xScale(maxX)); // => width, 

yScaleの方はrangeが逆です。

const yScale = scaleLinear()
  .domain([minY, maxY])
  .range([height, 0]);

console.log(xScale(minY)); // => height
console.log(xScale(maxY)); // => 0

図で説明すると、以下のようになります。
d3xyscale_guide--3-

チャートを描く

基本的にチャート生成functionはxScaleとyScaleから成り立ちます。

const chartGenerator = line<ChartPoint>()
  .x((d) => xScale(d.x))
  .y((d) => yScale(d.y))
  ;
const data = [{x: 15300000, y: 2}, {x:1540000, y: 4},...];
console.log(chartGenerator(data)); // 'Mx0y0Lx1y1Lx2y2...'

このfunctionを使用して上記で準備したpath.chart要素のセットに適用して複数のチャートを描きます。

ちなみにD3.jsが提供する色セットも使用します。

const color = scaleOrdinal(schemeCategory10);
charts
  // アニメーション設定
  .transition()
  .duration(options.animateDuration)
  // 色指定
  .attr('stroke', (d, i) => color(i + ''))
  // chart描く
  .attr('d', chartGenerator)
  ;

こうすると、それぞれのpath要素にバインドしたデータをchartGeneratorに渡し、それが返す結果をそのpath要素のd属性にassignしてチャートを描くことになります。

しかし、チャートの長さが違う場合はデフォルトのアニメーションではスムーズに動かないので、interpolatePathというcustomアニメーションを使用します。

charts
  // アニメーション設定
  .transition()
  .duration(options.animateDuration)
  // 色指定
  .attr('stroke', (d, i) => color(i + ''))
  // customアニメーションでchartを描く
  .attrTween('d', function (d) {
    const previous = select(this).attr('d');
    const current = chartGenerator(d);
    return interpolatePath(previous, current);
  })
  ;

Angularコンポーネントに適用

export class MultiLineChartComponent implements OnInit, OnChanges {
  @ViewChild('chart') svgElement: ElementRef;
  @Input() options: ChartOptions;
  @Input() multiData: ChartData[];
  private chart: MultiLineChart;
  
  ngOnInit() {
    this.chart = new MultiLineChart(this.svgElement.nativeElement, this.options, this.multiData);
  }
  
  ngOnChanges() {
    if (this.chart) {
      this.chart.update(this.options, this.multiData);
    }
  }

これで基本となる複数ラインチャートができました。

次はより高度なtouchイベントに対応する機能を追加します。

Touchイベントのハンドリング

スマートフォンでチャートをtouchしたところ(画面を長押ししたところ)のチャートポイントにマークを付けてみましょう(PCの場合はチャートをクリックしてドラッグします)。

下記のような構成のsvg要素を動的に作るのが目的です。

<svg>
  // チャート要素
  <g class="charts-container">
    <path class="chart" d="..."/>
    <path class="chart" d="..."/>
    ...
  </g>
  // touchにより表示マーク
  <g class="focuses-container">
    <circle class="focus" r="3" cx=".." cy=".." />
    <circle class="focus" r="3" cx=".." cy=".." />
    ...
  </g>
</svg>

まずはinit時に静的マークのcontainer要素を追加します。

this.svg
  .append('g')
  .attr('class', 'focuses-container');

touchしたところに横軸で一番近いチャートポイントを計算するには、xScale.invertとD3.jsユーティリティーbisectorを使用します。

// touchX: touchしたところのx、この段階の前にhammerjsで確定
touch(touchX: number): number {
    const data0 = this.multiData[0];
    const margin = this.getMargin(this.options.margin);

    // 該当なindexデータを計算
    const x0 = this.xScale.invert(touchX);
    const bisect = bisector<ChartPoint, any>((d) => d.x).left;
    // touchところはこのiポイントの左です
    const i = bisect(data0, x0, 1);
    // touchところ右と左比べて近い方を選択
    const d0 = data0[i - 1];
    const d1 = data0[i];
    const touchIndex = d1 && x0 - d0.x > d1.x - x0 ? i : i - 1;
    // focus要素のxは同じでyはデータにより後で計算
    const circleX = this.xScale(data0[touchIndex].x);

    const focusesContainer = this.svg
      .select('g.focuses-container');

    let focuses = focusesContainer
      .selectAll('circle.focus')
      .data(this.multiData)
      ;

    focuses.exit().remove();

    focuses = focuses.enter()
      .append('circle')
      .attr('class', 'focus')
      .attr('fill', 'white')
      .attr('stroke', (d, ind) => this.d3Color('' + ind))
      .attr('r', 3)
      .merge(focuses)
      ;

    // update focuses position
    focuses
      .attr('cx', circleX)
      .attr('cy', (data) => this.yScale(data[touchIndex].y))
      ;

    return circleX;
  }
  
  // 手が外すとき非表示します
  untouch(): void {
    this.svg
      .select('g.focuses-container')
      .style('display', 'none');
  }

angularコンポーネントの方に応用するには、hammerjsを使用して上記関数inputのtouchXを確定します。

angular.json

"scripts": [
  "node_modules/hammerjs/hammer.min.js"
]

html

<svg #chart
  (panmove)="panmove($event)"
  (panstart)="panstart($event)" (panend)="panend($event)"
  (press)="press($event)" (pressup)="pressup($event)">>
</svg>

tsクラス

  press(event) {
    this.changeTouchMode(true);
    const posX = event.center.x - this.containerLeft;
    this.chart.touch(posX);
  }

  pressup(event) {
    this.changeTouchMode(false);
  }

  panmove(event) {
    if (!this.touchMode) {
      return;
    }
    const posX = event.center.x - this.containerLeft;
    this.chart.touch(posX);
  }

  panstart(event) {
    if (!this.touchMode) {
      return;
    }
  }

  panend(event) {
    if (!this.touchMode) {
      return;
    }
    this.changeTouchMode(false);
  }
  ngOnInit() {
    // コンテナー(svg)の左位置を計算する
    this.containerLeft = +this.svgElement.nativeElement.getBoundingClientRect().left;
  }

結論

このブログではAngularでのD3.jsの実装について、簡単に説明させて頂きました。

完全なサンプルコードはGitHubにありますのでぜひそちらをご覧ください。

もし分からないところ、気になる箇所がありましたら、Issueにて質問を受け付けております。

ここまで読んでいただいてありがとうございました!

まだまだD3.jsについて共有したいことがたくさんありますので(pie、barチャートなど)、また別の機会にてよろしくお願いします!

Author image
About Dang
expand_less