Edited at

Vue.jsでD3.jsを使わずにグラフを実装する

More than 1 year has passed since last update.

Vue.jsでグラフ:chart_with_upwards_trend: を実装するサンプルコードを探すとD3.jsを用いている例が結構見つかります。

しかし本当にD3.jsが必要なのでしょうか?

この記事ではD3.jsやその他グラフライブラリを用いずにVue.jsのみで実装したグラフについて解説します。


TL;DR


  • SVGでおk :ok_woman:

  • グラフコンポーネントにSVGのテンプレートを書く :pen_fountain:

  • テンプレートに必要な値を computed でじゃんじゃん作る :hammer:


デモとリポジトリ

折れ線グラフ、棒グラフ、円グラフ、の3つのグラフを実装してみました。

データの内容はプロ野球:baseball:における直近5シーズンの年間入場者数です。

NPBが公開している統計データからJSONを用意しました。

あ、ワイちな猫:lion_face:につきパ・リーグがデフォルトの表示になってるやで。すまんな。

Chromeで動作確認済み。

Polyfillが足らなくて動かないブラウザがあるかも。:bow:


D3.jsの問題点

一見面倒に思えるグラフをVue.jsのみ、すなわち純粋なVueコンポーネントとして実装することの意味を、D3.jsやその他グラフライブラリを用いることの問題点から考えてみます。

D3.jsはそもそもグラフライブラリじゃない、って話は今は一旦置いておきましょー。


DOM が破壊される

D3.jsはHTML/SVGを操作するのでVue.js が構築した実DOMを破壊します。

状態および仮想DOMとの不一致が発生するということですね。

確かに実DOMへのアクセス性の良さもVue.jsの良さの一つではあるのですが、実DOMを破壊せずにすむのならばその方が良いはずです。


見た目のカスタマイズが難しい

D3.jsの場合はスタイルも操作できるのでさほど問題にはなりませんが、その他のグラフライブラリの場合には見た目をカスタマイズする方法にかぎりあることも多いですよね。

グラフの見た目でサービスの差別化を図ることもあるでしょうから、見た目のカスタマイズをよりしやすくしておくのが良いのではないでしょうか。


テストしづらい

上記のようにDOMが破壊されて状態の不一致が発生するとテストがしづらくなっちゃいますよね。

純粋なVueコンポーネントとして実装すればグラフを描画する機能の仕様もテストに書いておけるので、特に長期運用を見据えている場合により良い備えができるのではないでしょうか。


SVGでおk

純粋なVueコンポーネントとしてグラフを実装するにはグラフィック情報をテンプレートにコーディングする必要があります。

すなわちグラフィック情報のマークアップ言語であるSVGを用いることでそれが実現可能です。

Vue.jsでSVGというと Sarah Drasner さんによる VueConf 2017 での発表であったり はっしゅろっくさんのブログ だったりと個人的には結構盛り上がってる気がします。:muscle:

今回は原理的なところを解説するためのデモなのでアニメーション機能やレスポンシブ対応は実装していませんが、Vue.jsでのSVGの使い手がより一層増えれば良いなあと思います。

前置きが長い!:rage:

次章からそれぞれのグラフの実装について解説します。


折れ線グラフ :chart_with_upwards_trend:

折れ線グラフを実装するには 折れ線を表現するための要素 を用います。

基本的にはそれを適切な座標にプロットするだけです。


コンポーネントに渡すデータの仕様を決める

折れ線グラフにプロットしたいデータはマトリクスであることが一般的です。

なので、コンポーネントに渡すデータは2次元配列の形式が良いでしょう。

[

[100, 200, 300, 400, 500], // 1行が1本の線になる
[200, 300, 400, 500, 100],
[300, 400, 500, 100, 200]
]

コンポーネントにデータを受け取るための props を用意します。

export default Vue.extend({

name: 'ChartLine',
props: {
series: { type: Array, default() { return [] } }
}
})


polyline 折れ線を表現するための要素

<svg viewBox="0 0 600 400" width="600" height="400">

<polyline points="10 200 126 100 242 150 358 50 474 300 590 250" fill="none" stroke="#333" stroke-width="2"></polyline>
</svg>

polyline 要素は points 属性で指定した座標を経由する一連の直線を生成します。

points 属性の値は x y x y x y ... と2値セットで指定します。

ちなみに区切り文字はスペースじゃなくてカンマでもおk。

で、要するにグラフの元となるデータからこの points 属性に与えるべき一連の座標が求められれば良いわけです。

polyline - SVG | MDN


グラフの描画サイズを決める

polyline 要素のプロット座標を求めるために基準となるグラフの描画サイズを決めます。

export default Vue.extend({

name: 'ChartLine',
props: {
svgWidth : { type: Number, default: 1140 },
svgHeight : { type: Number, default: 640 },
paddingBottom: { type: Number, default: 24 },
paddingLeft : { type: Number, default: 80 },
paddingRight : { type: Number, default: 40 }
},
computed: {
viewBox (): string {
return `${0} ${0} ${this.svgWidth} ${this.svgHeight}`
},
chartWidth (): number {
return this.svgWidth - (this.paddingLeft + this.paddingRight)
},
chartHeight (): number {
return this.svgHeight - this.paddingBottom
}
}
})

svg* はSVG全体の大きさを示し、 padding* はラベル描画などのために確保するエリアの大きさを示します。

これらはレスポンシブ対応や使う場所に応じての利便性確保のために外部から指定可能な props として定義します。

svg*padding* から実際にグラフを描画するエリアのサイズが求められます。

computedchartWidthchartHeight をそれぞれ定義しました。

viewBox は円グラフのところでもう少し詳しく解説するので、とりあえず今はこういう属性が必要なのだってことで。

chart-line-1.png

各プロパティが示す数値はそれぞれ上記画像の箇所に対応する


X軸の描画間隔を求める

グラフの描画サイズが決まったので何px間隔でX軸に描画するかを求めましょう。

X軸は2次元配列で渡されたデータの列を表すので、列の長さから間隔を決めてやれば良いですね。

export default Vue.extend({

name: 'ChartLine',
computed: {
xAxisStep (): number {
return this.chartWidth / Math.max(...this.series.map(data => data.length - 1))
}
}
})

chart-line-2.png

折れ線は chartWidth を 列の長さで割った xAxisStep ごとに折れ曲がる。


Y軸の値を求める

Y軸、当該値を表すグラフ中での高さを導出するためにパーセンテージに変換します。

Y軸は2次元配列で渡されたデータの行を表すので、まず全行の最大値から100%とすべき値を求めそこから各行各列の値が何パーセントにあたるのかを求めてやれば良いです。

export default Vue.extend({

name: 'ChartLine',
computed: {
allValues (): number[] {
return this.series
.reduce((memo, data) => {
return memo.concat(data)
}, [])
},
_maxValue (): number {
return Math.max(...this.allValues)
},
maxValue (): number {
return ceil(this._maxValue, (getDigits(this._maxValue) - 2) * -1)
},
percentOfSeries (): number[][] {
return this.series
.map(data => data
.map(value => value / this.maxValue)
)
}
}
})

単純にデータ中の最大値を100%としてしまうと折れ線グラフが見にくくなってしまうので適当に丸めてます。

maxValue はまあ _maxValue から良い感じの最大値算出をやってくれる人です。

デモでは最小値も動的に決定する実装になっていますがここでは最小値は常に0として説明を省きます。


polyline 要素のためのプロパティを合成する

X軸の間隔とパーセンテージでのY軸が決まったのでこれらを用いて polyline 要素のためのプロパティを合成します。

export default Vue.extend({

name: 'ChartLine',
computed: {
seriesLinePointList (): number[][][] {
return this.percentOfSeries
.map(data => data
.map((value, index) => [
round(this.xAxisStep * index, 2),
round(this.chartHeight * value, 2)
]))
},
seriesLinePropsList (): { points: string }[] {
return this.seriesLinePointList
.map(points => {
return {
points: points.map(p => p.join(' ')).join(' ')
}
})
}
}
})

ここまでできたらあとは seriesLinePropsList をテンプレートに引き当ててやれば良いだけのハズですね。

デモでは線を表示すべきか否かのフラグや線の色などの情報を seriesLinePropsList で合成しています。


プロパティリストをテンプレートに引き当てる

用意した一連のプロパティをテンプレートに引き当ててみましょう。

<template>

<div class="chart-line">
<svg :viewBox="viewBox" :width="svgWidth" :height="svgHeight">
<g class="chart-line__series-line">
<polyline v-for="(line, i) in seriesLinePropsList" :key="i" :points="line.points"></polyline>
</g>
</svg>
</div>
</template>

スクリーンショット 2017-12-04 19.13.06.png

何かがおかしいですね?

黄色い線は福岡ソフトバンクホークスのデータですが、入場者数が多いはずなのにグラフでは低く表示されてしまっています。

これは一般的なグラフにおける座標系とSVGの座標系が異なっているためです。

また全体的に左側によってしまっていて見辛いのでこれもあわせて修正しなくてはなりません。


座標を変換する

下図にSVGとグラフにおける座標系の違いについて示します。

chart-line-3.png

通常SVGでは左上を座標原点として右下方向にポジティブです。

それに対して一般的な折れ線グラフでは左下を座標原点として右上方向にポジティブです。

つまりSVGとグラフではY軸が逆になってしまっているのです。

なのでY軸座標を求める際に値を逆転させてやる必要があります。

export default Vue.extend({

name: 'ChartLine',
computed: {
seriesLinePointList (): number[][][] {
return this.percentOfSeries
.map(data => data
.map((value, index) => [
round(this.xAxisStep * index, 2),
round(this.chartHeight * value * -1, 2)
]))
}
})

- round(this.chartHeight * value, 2)

+ round(this.chartHeight * value * -1, 2)

単純に負の値にしてやれば良いですね。

ところがこのままでは折れ線がマイナスのY軸位置に描画されてしまうのでこれを移動させる必要があります。


transform 属性で表示位置を移動させる

今、折れ線はマイナスのY軸上に描画されていて画面には見えません。

chart-line-4.png

これを画面に見える範囲に移動するには transform 属性と translate コマンドを用います。

export default Vue.extend({

name: 'ChartLine',
computed: {
seriesLineTransform (): string {
return `translate(${this.paddingLeft} ${this.chartHeight})`
}
}
})

<g class="chart-line__series-line" :transform="seriesLineTransform">

<polyline v-for="(line, i) in seriesLinePropsList" :key="i" :points="line.points"></polyline>
</g>

g 要素というのはHTMLでの div 要素みたいなもので要素をグルーピングするための要素です。

リストレンダリングされる polyline 要素を g 要素でグルーピングして transform 属性を指定すれば全ての折れ線をまとめて移動することができます。

移動距離は x = paddingLeft , y = chartHeight ですね。

これで期待通りの位置に折れ線グラフが表示されるようになりました。:tada:

g - SVG | MDN

transform - SVG | MDN


棒グラフ :bar_chart:

基本的な原理は折れ線グラフと同じです。


  1. グラフの描画サイズを決める

  2. X軸の描画間隔を求める

  3. Y軸の値を求める

  4. 棒を表現する要素のためのプロパティを合成する

  5. プロパティリストをテンプレートに引き当てる

  6. 座標変換する

折れ線グラフと違うのは与えるデータの形式と図形の表現に使用する要素です。


コンポーネントに渡すデータの仕様を決める

棒グラフではマトリクスでいうところの1行ないしは1列のいずれかを表現することが多いですよね。

なのでデータ形式は単純な配列で良いでしょう。

[100, 200, 300, 400, 500]


path 線形を表現するための要素

棒グラフの棒を表現できる要素には以下の様なものがあります。



  • line 要素: x1,y1 を開始座標、 x2,y2 を終了座標とする直線を生成する


  • rect 要素: x,y を開始座標として width,heightの大きさを持った四角形を生成する


  • path 要素: 極めればどんな図形でも書ける

line 要素や rect 要素も手軽で良いのですが、ここでは path 要素を用いてSVGのより深い使い方に一歩踏み込んでみましょう。 :athletic_shoe:

<svg viewBox="0 0 600 400" width="600" height="400">

<path d="M 300 400 V 300 0" fill="none" stroke="#333" stroke-width="100"></path>
</svg>

path 要素では d 属性と各種コマンドを用いて線形を定義します。

コマンドは大文字と小文字で動作が変わるので注意が必要です(詳しくは後述のMDNを参照)。

今回の棒グラフの棒で使うのは以下の2つだけ。

- M コマンド: 座標を移動させる / 一番最初の M コマンドはすなわちパスの開始座標を指定する

- V コマンド: 垂直線を描く / 横向きの棒グラフの場合は水平線を描く H コマンドを用いる

<path> - SVG | MDN

d - SVG | MDN

基本的にはこの path 要素の d 属性を computed で合成していけば良いわけです。


path 要素のためのプロパティを合成してテンプレートに引き当てる

グラフの描画範囲やパーセンテージへの変換などは折れ線グラフの場合と基本的に同じです。

違うのは渡されるデータが1次元配列だってことぐらい。

Y軸のマイナス方向に描画させて transform するのも一緒です。

export default Vue.extend({

name: 'ChartBar',
computed: {
seriesLinePointList (): number[][] {
return this.percentOfSeries
.map((value, index) => [
round(this.xAxisStep * index, 2),
round(this.chartHeight * value * -1, 2)
])
},
seriesLinePropsList (): { d: string, transform: string }[] {
return this.seriesLinePointList
.map(points => {
const [x, h] = points

return {
d: `M${0},${0} V${h}`,
transform: `translate(${x})`
}
})
}
}
})

<g class="chart-bar__series-bar" :transform="seriesLineTransform">

<path v-for="(line, i) in seriesLinePropsList" :key="i" :d="line.d" :transform="line.transform"></path>
</g>

これで棒グラフも実装できました! :tada:


円グラフ :doughnut:

円グラフは折れ線グラフや棒グラフとは描画の方法が異なります。

しかし、与えるデータの形式と用いる図形要素は棒グラフと同じ物が使えます。

描画において一番大きく異なるのが viewBox属性による表示座標のオフセットです。


コンポーネントに渡すデータの仕様を決める

棒グラフと同じく単純な配列で良いでしょう。

[100, 200, 300, 400, 500]


viewBox で表示座標をオフセットする

折れ線グラフのところで「通常SVGでは左上を座標原点」としましたが、実はこれを決定していたのが viewBox 属性です。

折れ線グラフでの viewBox は以下の様に設定されていました。

export default Vue.extend({

name: 'ChartLine',
computed: {
viewBox (): string {
return `${0} ${0} ${this.svgWidth} ${this.svgHeight}`
} // -> as default: viewBox="0 0 1140 640"
}
})

viewBox 属性に設定されている最初の2値 0 0 これが左上の座標を決定しています。

折れ線グラフや棒グラフではこれで特に問題ありません(Y軸を反転しなきゃいけなかったけど)でしたが、円グラフでは回転座標系を扱うためにSVGの中央を 0 0 にした方が都合が良い。

export default Vue.extend({

name: 'ChartPie',
props: {
svgWidth : { type: Number, default: 640 },
svgHeight: { type: Number, default: 640 }
},

computed: {
viewBox (): string {
return `${this.svgWidth / -2} ${this.svgHeight / -2} ${this.svgWidth} ${this.svgHeight}`
} // -> as default: viewBox="-320 -320 640 640"
}
})

前の2値 -320 -320 が左上の座標を決定し、後の2値 640 640 がSVGの大きさを決定します。

chart-pie-1.png

つまりこれでSVGの中央が 0 0 に設定されるというわけです。

viewBox - SVG | MDN


path 要素で円弧を描く

円グラフは円弧の集合です。

path 要素と d 属性で円弧を描くには A/a コマンドを用います。

A/a コマンドが取る引数は以下の通り。

A rx ry xAxisRotate LargeArcFlag SweepFlag x y // 絶対値指定

a rx ry xAxisRotate LargeArcFlag SweepFlag dx dy // 相対値指定

うん、意味わからんですね。 :innocent:

こちらの記事でそれぞれの引数の意味が解説されています。

それで、この引数に適切な値を設定できれば円グラフが描けるという訳なんですがそんなんに複雑な処理が必要なわけでもないです。


データをパーセンテージに変換する

円グラフはデータを相対的に表現するものなので、まずはパーセンテージへの変換を行う必要があります。

export default Vue.extend({

name: 'ChartPie',
computed: {
totalValue (): number {
return this.series.reduce((memo, value) => memo + value, 0)
},
percentOfSeries (): number[] {
let subTotal = 0
return this.series
.map((value, i) => {
if (i === this.series.length - 1) {
return 1 - subTotal
}
const percentage = value / this.totalValue
subTotal += percentage
return percentage
})
}
}
})

渡されたデータの合計を求めて、それに対する各値の割合を算出します。

データ中最後の値だけは、1からそれまでの小計値を引くことで丸め処理を適用します。

で、これを円弧を描くためのプロパティに合成する。


円弧を描くためのプロパティを合成する

データのパーセンテージが求められたのでこれを用いて円弧を描くためのプロパティを合成します。

export default Vue.extend({

name: 'ChartPie',
props: {
chartR: {
type: Number,
default: 160
}
},
computed: {
seriesPiePropsList (): { d: string }[] {
const x1 = this.chartR * Math.cos(0) // => as default: 160
const y1 = this.chartR * Math.sin(0) // => as default: 0
return this.percentOfSeries
.sort((a, b) => b - a)
.map(value => {
const x2 = this.chartR * Math.cos(PIE2 * value)
const y2 = this.chartR * Math.sin(PIE2 * value)

return {
d: `M ${x1} ${y1} a ${this.chartR} ${this.chartR} ${0} ${value > .5 ? 1 : 0} ${1} ${round(x2 - x1, 2)} ${round(y2 - y1, 2)}`
}
})
}
}
})

M コマンドに渡している ${x1} ${y1}a コマンドの最後に渡している ${round(x2 - x1, 2)} ${round(y2 - y1, 2)} について、これは円弧の始点と終点を求める公式を当てはめています。

x1,y1は半径に cos(開始角),sin(開始角) とすることで円弧の開始座標を算出。

x2,y2は半径に cons(2π x パーセンテージ),sin(2π x パーセンテージ) とすることで各値ごとの回転角を算出。

円弧における100%とは360度で、JSでは角度をラジアンで表すので 2π(=360度) にパーセンテージをかけてやれば良い。

a コマンドを用いているので、 最後の ${round(x2 - x1, 2)} ${round(y2 - y1, 2)} で相対値に変換しています。

さて、これをテンプレートに引き当ててみましょう。

<svg :viewBox="viewBox" :width="svgWidth" :height="svgHeight">

<g class="chart-pie__series-pie">
<path v-for="(pie, i) in seriesPiePropsList" :key="i" :d="pie.d"></path>
</g>
</svg>

スクリーンショット 2017-12-05 0.38.10.png

なんでしょうかこれは? :thinking:

全ての円弧が重なっているうえにグラフの開始角が3時の方向で不自然です。

まあそれもそのはずで、先ほど合成したプロパティでは全ての円弧の開始位置を同じ位置に設定しています。

全ての円弧が繋がった一連の円のように見せるにはもう一工夫必要なようです。

seriesPiePropsList (): { value: number, d: string, transform: string }[] {

const x1 = this.chartR * Math.cos(0)
const y1 = this.chartR * Math.sin(0)
let subTotal = 0
return this.percentOfSeries
.sort((a, b) => b - a)
.map(value => {
const x2 = this.chartR * Math.cos(PIE2 * value)
const y2 = this.chartR * Math.sin(PIE2 * value)

const matrix = [round(Math.cos(PIE2 * subTotal), 4), round(Math.sin(PIE2 * subTotal), 4)]
matrix.push(matrix[1] * -1, matrix[0], 0, 1)

subTotal += value

return {
value,
d: `M ${x1} ${y1} a ${this.chartR} ${this.chartR} ${0} ${value > .5 ? 1 : 0} ${1} ${round(x2 - x1, 2)} ${round(y2 - y1, 2)}`,
transform: `matrix(${matrix.join(' ')})`
}
})
}

subTotal はデータの小計値を集計する変数です。

つまり subTotal は直前のデータの終了角を示すと言えます。

この直前のデータの終了角を transform 属性に回転を指示するコマンドで与えてやれば、必然的に直前の円弧と繋がったように見える円弧を描画することが可能です。

matrix コマンドは座標の変換行列を与えます。

詳しい解説は後述のMDNに譲りますが、単純に図形を回転したいときに引数に渡すべき値は以下の通りです。

matrix(cos(θ) sin(θ) -sin(θ) cos(θ) 0 1)

上記コードにて

const matrix = [round(Math.cos(PIE2 * subTotal), 4), round(Math.sin(PIE2 * subTotal), 4)]

matrix.push(matrix[1] * -1, matrix[0], 0, 1)

としている箇所が matrix コマンドの引数を合成しているところですね。

最後にグラフが3時の位置から始まってしまっているのを12時の位置に修正します。

<svg :viewBox="viewBox" :width="svgWidth" :height="svgHeight">

<g class="chart-pie__series-pie" transform="rotate(-90)">
<path v-for="(pie, i) in seriesPiePropsList" :key="i" :d="pie.d" :transform="pie.transform"></path>
</g>
</svg>

円弧をグルーピングしている g 要素に transform="rotate(-90)" をかけてやればオッケー牧場! :cow2: :sheep: :goat:

これで円グラフも実装できました! :tada:

transform - SVG | MDN


まとめ

今回は極々限定的な機能のみを実装しましたが実際の案件ではもう少し細かい機能を求められます。


  • マウスオーバーしたときに当該値をバルーンで表示する

  • アニメーション

  • レスポンシブ対応

バルーンの表示についてはそのためのテンプレートを追加すれば良いですし、アニメーションとレスポンシブ対応についても、SVGの大きさとデータを渡してやればグラフが描画される、という機能が実装できている以上はそんなに難しくはないでしょう。

また今回は計算も全て自前で実装(あウソです繰り上げ・切り捨てとかはLodash使いました)したわけですが、例えばD3.jsのDOM操作以外の機能を使いたい場合もあるでしょう。

いずれにせよ、Vue.jsとSVGによるグラフの実装はデータドリブンなコンポーネント作成の良い実装訓練になると思います。

なによりSVGの書き方覚えて損するってことはないと思うんですよね。

さて明日はtackeyyさんです。どんな記事を書かれるのでしょうか。

それでは! :wave: