はじめまして。フロントエンドチームのDangと申します。ベトナム出身です。
今回はD3.jsチャートをAngularに埋め込むサンプル実装をご紹介します。
目的
このブログでは以下の機能を持つチャート実装を目的としています。
- 同じx、y軸で複数のラインチャートを描く
- D3.jsのcolorテーマを適用
- data変更時のアニメーション
- touchイベントのハンドリング
サンプルコード
- チャートロジック実装
- Angularコンポーネント
- liveデモ
- ソースコード
準備
- angular-cli
ng 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
図で説明すると、以下のようになります。
チャートを描く
基本的にチャート生成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チャートなど)、また別の機会にてよろしくお願いします!