この記事は CivicTechテック好き Advent Calendar 2020 の 3 日目の記事です。
この記事について
この記事では Vue.js と d3.js を組み合わせて SVG のシンプルな線グラフを書く際の、各要素の実装方法をまとめています。
Vue.js と d3.js の役割は、データや DOM の更新に対する処理の部分で一部被っているところがあるため、それらをどちらに担当させるかによって、組み合わせ方が何通りか存在します。この記事の方法では、データの更新と各要素の計算タイミングを Vue.js に、数値計算や軸の DOM 更新を d3.js に担当させています。
最小限のコードで動く完成品が CodePen に置いてあります。各種プロパティを input
で変更できるようにもしています。
余談: この記事と CivicTech の関係性(がないこと)について
記事で実現していること自体は CivicTech とは直接関係ありません。ただ、この記事の元となったコードを、今年の自分の CivicTech 活動の中で書く機会があり、もっと単純な作り方をどこかでまとめておきたいと思っていたので寄稿することにしました。
閑話休題。
環境
- Vue.js 2.6.11
- d3.js 6.2.0
作るもの
縦軸、横軸と一本の線グラフで構成されたシンプルな線グラフです。これを、データや SVG とグラフ領域(軸の内側を本稿ではこう呼ぶことにする)の大きさを調整できる単一のコンポーネントとして構成します。
詳細に入る前に全体を先に示しておくと、コンポーネント全体としては、以下のようなコードになります:
const lineChart = {
template: `<svg :width="width" :height="height">
<g :transform="\`translate(\$\{marginLeft\}, \$\{marginTop\})\`">
<g v-x-axis />
<g v-y-axis />
<path class="data" :d="d" />
</g>
</svg>`,
directives: {
xAxis(el, binding, vnode, oldVnode) {
d3.select(el)
.attr('transform', `translate(0, ${vnode.context.graphHeight})`)
.call(d3.axisBottom().scale(vnode.context.xScale))
},
yAxis(el, binding, vnode, oldVnode) {
d3.select(el).call(d3.axisLeft().scale(vnode.context.yScale))
},
},
props: {
width: { type: Number, default: 960, },
height: { type: Number, default: 500, },
marginTop: { type: Number, default: 20, },
marginBottom: { type: Number, default: 40, },
marginLeft: { type: Number, default: 40, },
marginRight: { type: Number, default: 40, },
chartData: { type: Array, required: true, },
},
computed: {
graphWidth() {
return this.width - this.marginLeft - this.marginRight
},
graphHeight() {
return this.height - this.marginTop - this.marginBottom
},
xScale() {
return d3
.scalePoint()
.domain(d3.range(this.chartData.length))
.range([0, this.graphWidth])
},
yScale() {
return d3
.scaleLinear()
.domain(d3.extent(this.chartData, (d) => d))
.range([this.graphHeight, 0])
},
line() {
return d3
.line()
.x((d, i) => this.xScale(i))
.y((d) => this.yScale(d))
},
d() {
return this.line(this.chartData)
},
},
}
これをコンポーネントとして登録し、最低限 chartData
をプロパティとして与えてグラフに表示します。上の画像と同じ表示にするには、以下のデータを与えます:
<line-chart :chart-data="[10, 20, 12, 22, 3, 2, 1, 4, 5, 6, 7]" />
以下、詳細なコードの説明が続きます:
プロパティについて
外から与えたいであろうパラメタとして、以下を想定しています:
-
svg
要素の幅、高さ (width
,height
) - グラフ領域の
g
要素のsvg
に対するマージン (marginTop
,marginBottom
,marginLeft
,marginRight
) - データ(今回は数値の一次配列) (
chartData
)
{
...,
props: {
width: { type: Number, default: 960, },
height: { type: Number, default: 500, },
marginTop: { type: Number, default: 20, },
marginBottom: { type: Number, default: 40, },
marginLeft: { type: Number, default: 40, },
marginRight: { type: Number, default: 40, },
chartData: { type: Array, required: true, },
},
...
}
各部品の実装について
表示には表れないものも含めて、コーディングには以下が必要です:
- グラフ領域の高さ、幅の計算
- グラフ領域の
g
要素 - x 軸のスケール、y 軸のスケール
- 線グラフの
path
要素のd
属性 - x 軸の
g
要素、y 軸のg
要素
以下でそれぞれ説明していきます:
グラフ領域の高さ、幅の計算
SVG の幅高さ、グラフ領域のマージンに対してリアクティブである必要があるため、算出プロパティとして実現します:
{
...,
computed: {
...,
graphWidth() {
return this.width - this.marginLeft - this.marginRight
},
graphHeight() {
return this.height - this.marginTop - this.marginBottom
},
...
},
...
}
グラフ領域の g
要素
SVG 要素内をマージン分だけ平行移動させます。グラフ領域のマージンに対してリアクティブです。
<svg ...>
<g :transform="\`translate(\$\{marginLeft\}, \$\{marginTop\})\`">
...
</g>
</svg>
x 軸のスケール、y 軸のスケール
d3.js の d3-scale にあるものを使います。x 座標は chartData
配列のインデクスによって決めるため離散的スケールの d3.scalePoint
を、y 座標は chartData
配列の値によって決めるため連続的スケールの d3.scaleLinear
をそれぞれ使います。chartData
配列、グラフ領域の高さ、幅に対してリアクティブです。
{
...,
computed: {
...,
xScale() {
return d3
.scalePoint()
.domain(d3.range(this.chartData.length))
.range([0, this.graphWidth])
},
yScale() {
return d3
.scaleLinear()
.domain(d3.extent(this.chartData, (d) => d))
.range([this.graphHeight, 0])
},
...
},
...
}
この書き方だと再計算の度にオブジェクトを生成することになると思いますが、オブジェクト自体はデータオブジェクトで保持し、domain
と range
だけアップデートするようにもできます。
{
...,
data() {
return {
xScale_: d3.scalePoint(),
yScale_: d3.scaleLinear(),
...
}
},
computed: {
...,
xScale() {
return this.xScale_
.domain(d3.range(this.chartData.length))
.range([0, this.graphWidth])
},
yScale() {
return this.yScale_
.domain(d3.extent(this.chartData, (d) => d))
.range([this.graphHeight, 0])
},
...
},
...
}
線グラフの path
要素の d
属性
d3.js の d3-shape の d3.line
を使います。
{
...,
computed: {
...,
line() {
return d3
.line()
.x((d, i) => this.xScale(i))
.y((d) => this.yScale(d))
},
d() {
return this.line(this.chartData)
},
},
}
これをテンプレート側で d
属性にバインドします。
<path :d="d" />
d3.line()
が毎回呼ばれるのを避けるため、データオブジェクトに d3.line()
自体を持ち、算出プロパティの再計算で .x()
と .y()
だけ書き換えるには以下のようにします。
data() {
return {
...,
line_: d3.line()
}
},
computed: {
...,
line() {
return this.line_
.x((d, i) => this.xScale(i))
.y((d) => this.yScale(d))
},
d() {
return this.line(this.chartData)
},
},
x 軸の g
要素、y 軸の g
要素
d3.js の d3-axis を使います。xScale
, yScale
に対してそれぞれリアクティブにします。今回はこちらで紹介されている方法を使って、ディレクティブとして登録します。(この書き方だと、どの値が更新されても更新を試みられてしまうため、実際はスケールの range
や domain
に変更が無いかを前段でチェックしたほうが良いとは思います)
{
...,
directives: {
xAxis(el, binding, vnode, oldVnode) {
d3.select(el)
.attr('transform', `translate(0, ${vnode.context.graphHeight})`)
.call(d3.axisBottom().scale(vnode.context.xScale))
},
yAxis(el, binding, vnode, oldVnode) {
d3.select(el).call(d3.axisLeft().scale(vnode.context.yScale))
},
},
...
}
これを g
要素に適用します:
<g v-x-axis />
<g v-y-axis />
ちなみに、この方法で作った軸の g
要素の下には Scoped CSS のスコープを外れた子要素が作られます。なので、それらの子要素だけにスタイルを当てる際は Deep Selector を使う必要があります。
まとめ
Vue.js と d3.js を使ってシンプルな線グラフを実装する方法の一つを紹介しました。線グラフの実装に必要な処理のうち、スケール変換、SVG path
要素の d
属性の計算、軸の描画に d3.js を使い、全体としては Vue コンポーネントとして使えるように実装しました。