Vue.jsでグラフ を実装するサンプルコードを探すとD3.jsを用いている例が結構見つかります。
しかし本当にD3.jsが必要なのでしょうか?
この記事ではD3.jsやその他グラフライブラリを用いずにVue.jsのみで実装したグラフについて解説します。
TL;DR
- SVGでおk
- グラフコンポーネントにSVGのテンプレートを書く
- テンプレートに必要な値を
computed
でじゃんじゃん作る
デモとリポジトリ
折れ線グラフ、棒グラフ、円グラフ、の3つのグラフを実装してみました。
データの内容はプロ野球における直近5シーズンの年間入場者数です。
NPBが公開している統計データからJSONを用意しました。
あ、ワイちな猫につきパ・リーグがデフォルトの表示になってるやで。すまんな。
Chromeで動作確認済み。
Polyfillが足らなくて動かないブラウザがあるかも。
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 での発表であったり はっしゅろっくさんのブログ だったりと個人的には結構盛り上がってる気がします。
今回は原理的なところを解説するためのデモなのでアニメーション機能やレスポンシブ対応は実装していませんが、Vue.jsでのSVGの使い手がより一層増えれば良いなあと思います。
前置きが長い!
次章からそれぞれのグラフの実装について解説します。
折れ線グラフ
折れ線グラフを実装するには 折れ線を表現するための要素 を用います。
基本的にはそれを適切な座標にプロットするだけです。
コンポーネントに渡すデータの仕様を決める
折れ線グラフにプロットしたいデータはマトリクスであることが一般的です。
なので、コンポーネントに渡すデータは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
要素のプロット座標を求めるために基準となるグラフの描画サイズを決めます。
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*
から実際にグラフを描画するエリアのサイズが求められます。
computed
に chartWidth
と chartHeight
をそれぞれ定義しました。
viewBox
は円グラフのところでもう少し詳しく解説するので、とりあえず今はこういう属性が必要なのだってことで。
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))
}
}
})
折れ線は 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>
座標を変換する
下図にSVGとグラフにおける座標系の違いについて示します。
通常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軸上に描画されていて画面には見えません。
これを画面に見える範囲に移動するには 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
ですね。
これで期待通りの位置に折れ線グラフが表示されるようになりました。
g - SVG | MDN
transform - SVG | MDN
棒グラフ
基本的な原理は折れ線グラフと同じです。
- グラフの描画サイズを決める
- X軸の描画間隔を求める
- Y軸の値を求める
- 棒を表現する要素のためのプロパティを合成する
- プロパティリストをテンプレートに引き当てる
- 座標変換する
折れ線グラフと違うのは与えるデータの形式と図形の表現に使用する要素です。
コンポーネントに渡すデータの仕様を決める
棒グラフではマトリクスでいうところの1行ないしは1列のいずれかを表現することが多いですよね。
なのでデータ形式は単純な配列で良いでしょう。
[100, 200, 300, 400, 500]
path
線形を表現するための要素
棒グラフの棒を表現できる要素には以下の様なものがあります。
-
line
要素:x1
,y1
を開始座標、x2
,y2
を終了座標とする直線を生成する -
rect
要素:x
,y
を開始座標としてwidth
,height
の大きさを持った四角形を生成する -
path
要素: 極めればどんな図形でも書ける
line
要素や rect
要素も手軽で良いのですが、ここでは path
要素を用いてSVGのより深い使い方に一歩踏み込んでみましょう。
<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>
これで棒グラフも実装できました!
円グラフ
円グラフは折れ線グラフや棒グラフとは描画の方法が異なります。
しかし、与えるデータの形式と用いる図形要素は棒グラフと同じ物が使えます。
描画において一番大きく異なるのが 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の大きさを決定します。
つまりこれでSVGの中央が 0 0
に設定されるというわけです。
path
要素で円弧を描く
円グラフは円弧の集合です。
path
要素と d
属性で円弧を描くには A/a
コマンドを用います。
A/a
コマンドが取る引数は以下の通り。
A rx ry xAxisRotate LargeArcFlag SweepFlag x y // 絶対値指定
a rx ry xAxisRotate LargeArcFlag SweepFlag dx dy // 相対値指定
うん、意味わからんですね。
こちらの記事でそれぞれの引数の意味が解説されています。
それで、この引数に適切な値を設定できれば円グラフが描けるという訳なんですがそんなんに複雑な処理が必要なわけでもないです。
データをパーセンテージに変換する
円グラフはデータを相対的に表現するものなので、まずはパーセンテージへの変換を行う必要があります。
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>
なんでしょうかこれは?
全ての円弧が重なっているうえにグラフの開始角が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)"
をかけてやればオッケー牧場!
これで円グラフも実装できました!
まとめ
今回は極々限定的な機能のみを実装しましたが実際の案件ではもう少し細かい機能を求められます。
- マウスオーバーしたときに当該値をバルーンで表示する
- アニメーション
- レスポンシブ対応
バルーンの表示についてはそのためのテンプレートを追加すれば良いですし、アニメーションとレスポンシブ対応についても、SVGの大きさとデータを渡してやればグラフが描画される、という機能が実装できている以上はそんなに難しくはないでしょう。
また今回は計算も全て自前で実装(あウソです繰り上げ・切り捨てとかはLodash使いました)したわけですが、例えばD3.jsのDOM操作以外の機能を使いたい場合もあるでしょう。
いずれにせよ、Vue.jsとSVGによるグラフの実装はデータドリブンなコンポーネント作成の良い実装訓練になると思います。
なによりSVGの書き方覚えて損するってことはないと思うんですよね。
さて明日はtackeyyさんです。どんな記事を書かれるのでしょうか。
それでは!