はじめに
svgでとあるデータセットをもとにグラフを書きたいってなって、それをjsで書くときに今のとこ私はd3使って書いてます。
たとえばこんなの
で、ゴリゴリ書いてくと人々は軸やメモリやラベルとか全部d3で書くので超絶スパゲティコードになります。
コードの中に意味不明な座標計算が溢れて完成形がイメージしづらく、よく言えば職人芸、悪く言えば糞コードが誕生します。
でも、よくよくグラフについて考えてみたら、メモリや軸とかって煩雑な計算が必要ではないのでわざわざd3でがんばって書く必要はなくって、vueとかでsvgの最終型がイメージしやすいライブラリで書いちゃってもいいはずなんですよね。
今回はd3でやったほうがわかりやすいところはd3で、そうじゃないところはvueで棲み分けて書いてみようと思います。
vueで書くところ
- 軸
- メモリ
- ラベル
d3で書くところ
グラフの線分のd (<path d="xxxxx">
) の部分
データセット
なんでもいいけど何かないとあれなのでこんなデータを用意しました。特に意味のないデータです。
let dataset = [
{ "date": "1-May-12", "val": 58.13 },
{ "date": "30-Apr-12", "val": 53.98 },
{ "date": "27-Apr-12", "val": 67 },
{ "date": "26-Apr-12", "val": 89.7 },
{ "date": "25-Apr-12", "val": 99 },
{ "date": "24-Apr-12", "val": 130.28 },
{ "date": "23-Apr-12", "val": 166.7 },
{ "date": "20-Apr-12", "val": 234.98 },
{ "date": "19-Apr-12", "val": 345.44 },
{ "date": "18-Apr-12", "val": 443.34 },
{ "date": "17-Apr-12", "val": 543.7 },
{ "date": "16-Apr-12", "val": 580.13 },
{ "date": "13-Apr-12", "val": 605.23 },
{ "date": "12-Apr-12", "val": 622.77 },
]
実装の流れ
まずはグラフ領域を表現するgroup (gタグ)を作りたいと思います。
<div id="graph">
<svg width="600" height="400" :viewBox="`0 0 ${defaultSize.x} ${defaultSize.y}`">
<g class="graphBody" :transform="`translate(${graphBody.left}, ${graphBody.top})`">
<!-- グラフ領域 -->
</g>
</svg>
</div>
new Vue({
el: '#graph',
data: {
dataset: dataset,
defaultSize: {x: 600, y: 400},
graphBody: { // グラフ本体の位置
top: 20,
left: 80,
right: 60,
bottom: 60
},
},
})
svg全体のサイズ(正確にはviewboxのサイズ)を600,400にし、その中でグラフの線分が引かれるgroupを用意しました。
軸ラベルの位置関係も関係もあるので左側に80pxぐらい余白とりました。上も気分で20pxくらいあけておきました。
これでgraphBodyが上20, 左80の位置に配置されました。
次にx軸、y軸を作っていきます。
x軸はグラフ領域の横幅いっぱいに、y軸はグラフ領域の縦幅いっぱいに伸ばしたいのでgraphBodyの座標からgraphBodyのwidthとheightを算出するプロパティ(関数)のgrahBodyWidth
, grahBodyHeight
をcomputedに用意しときます。
svg全体の長さからgraphBodyの左余白、右余白を引いたものがgraphBodyの横幅です。 graphBodyの縦幅も同じように算出します。
ついでに軸の太さも設定しときます。
data: {
...
axis: { // 軸
strokeWidth: 4
},
},
computed: {
/**
* グラフ本体の幅
*/
graphBodyWidth() {
return this.defaultSize.x - this.graphBody.left - this.graphBody.right;
},
/**
* グラフ本体の高さ
*/
graphBodyHeight() {
return this.defaultSize.y - this.graphBody.top - this.graphBody.bottom;
},
}
<svg width="600" height="400" :viewBox="`0 0 ${defaultSize.x} ${defaultSize.y}`">
<!-- グラフ領域 -->
<g class="graphBody" :transform="`translate(${graphBody.left}, ${graphBody.top})`">
<!-- x軸 -->
<g class="axisX">
<line :x1="-axis.strokeWidth / 2" :y1="graphBodyHeight" :x2="graphBodyWidth" :y2="graphBodyHeight" :stroke-width="axis.strokeWidth"></line>
</g>
<!-- y軸 -->
<g class="axisY">
<line x1="0" y1="0" x2="0" :y2="graphBodyHeight" :stroke-width="axis.strokeWidth"></line>
</g>
</g>
</svg>
軸の出来上がりです。
次はメモリ線を書いていきます。
メモリ線を何本ひくかを定義して、graphBody(Width|Height)をそれで割ってあげてそれを積み上げていけば軸の座標位置が決定します。
それを算出するcomputed関数を用意してメモリを表示していきます。
data: {
...
ticks: { // メモリ
xSize: dataset.length,
ySize: 10
},
...
computed: {
...
/**
* x軸方向のメモリの座標一覧
*/
xTicksPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.ySize; i < iz; i++) {
posList.push((this.graphBodyHeight / iz) * i)
}
return posList;
},
/**
* y軸方向のメモリの座標一覧
*/
yTicksPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.xSize; i < iz; i++) {
posList.push((this.graphBodyWidth / iz) * (i + 1)) // 開始がy軸と重なるので一個ずらす
}
return posList;
},
computedで算出した座標配列をもとにぐるっと回してみます。
メモリ線分はgraphBody(Width|Height)分いっぱいに伸ばしてあげます。
<svg width="600" height="400" :viewBox="`0 0 ${defaultSize.x} ${defaultSize.y}`">
<g class="graphBody" :transform="`translate(${graphBody.left}, ${graphBody.top})`">
<!-- x軸方向のメモリ -->
<g class="ticksX">
<g class="tick" v-for="(pos, idx) in xTicksPosList" :transform="`translate(0, ${pos})`">
<line x1="0" y1="0" :x2="graphBodyWidth" :y2="0"></line>
</g>
</g>
<!-- y軸方向のメモリ -->
<g class="ticksY">
<g class="tick" v-for="(pos, idx) in yTicksPosList" :transform="`translate(${pos}, 0)`">
<line x1="0" y1="0" x2="0" :y2="graphBodyHeight"></line>
</g>
</g>
<!-- x軸 -->
....
</g>
すでにだいぶそれっぽくなりましたね、今度はこれにメモリラベルをつけていきます。
x軸はdatasetのdateの値を、y軸は数値を設定していきます。
y軸の目盛りの幅は好きにすればいいです(投げやり)
いい感じにする方法は腐るほどあるだろうけど今回はmaxが622のデータを使うので0〜700くらいを等分するメモリ作ろうと思います
computedにyのlabelを作る関数、yのlabel位置を作る関数、xのラベル位置を作る関数を用意します
computed: {
...
/**
* x軸方向のlabelの座標一覧
*/
xLabelPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.xSize; i < iz; i++) {
posList.push((this.graphBodyWidth / iz) * i)
}
return posList;
},
/**
* y軸方向のlabelの座標一覧
*/
yLabelPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.ySize; i <= iz; i++) {
posList.push((this.graphBodyHeight / iz) * i )
}
return posList;
},
/**
* y軸方向のlabel一覧
*/
yLabelList() {
let vals = this.dataset.map(v => v.val)
, maxVal = Math.max(...vals)
, tickValMax = Math.ceil(maxVal / 100) * 100
;
let labels = [];
for(let i = 0, iz = this.ticks.ySize; i <= iz; i++) {
labels.push((tickValMax / iz) * i)
}
return labels.reverse();
}
}
テンプレート上にラベルのグループを作り位置を決定します。X軸のラベルはgraphBodyの下にくるように、graphBody.top
とgraphBodyHeight
を足し合わせた位置に、Y軸ラベルもいい感じ。
<svg width="600" height="400" :viewBox="`0 0 ${defaultSize.x} ${defaultSize.y}`">
<g class="graphBody" :transform="`translate(${graphBody.left}, ${graphBody.top})`">
...
</g>
<!-- x軸のラベル -->
<g class="labelX" :transform="`translate(${graphBody.left}, ${graphBody.top + graphBodyHeight})`">
<g class="tick" v-for="(pos, idx) in xLabelPosList" :transform="`translate(${pos}, 0)`">
<text x="5" :y="10 + axis.strokeWidth" transform="rotate(45)">{{ dataset[idx].date }}</text>
</g>
</g>
<!-- y軸のラベル -->
<g class="labelY" :transform="`translate(0, ${graphBody.top})`">
<g class="tick" v-for="(pos, idx) in yLabelPosList" :transform="`translate(0, ${pos})`">
<text :x="graphBody.left - 5" y="0" text-anchor="end">{{ yLabelList[idx] }}</text>
</g>
</g>
</svg>
もうほとんどできましたね。あとはこれにグラフ線をひくだけです。
申し訳程度にグラフ線を表現する予定のpathだけ先に突っ込んどきます。
<svg width="600" height="400" :viewBox="`0 0 ${defaultSize.x} ${defaultSize.y}`">
<g class="graphBody" :transform="`translate(${graphBody.left}, ${graphBody.top})`">
...
<!-- グラフ線分 -->
<g class="paths">
<path class="line1" stroke="blue" stroke-width="2" fill="none"></path>
</g>
...
</g>
<!-- x軸のラベル -->
...
<!-- y軸のラベル -->
...
</svg>
vueがこれを描画し終わった後に呼ばれるcreated()
でこのpathタグのdをd3で計算して設定します。
x軸の領域は[0, graphBodyWidth]
y軸の領域は[graphBodyHeight, 0]
とわかりよい感じで表現できるのでそれぞれd3のscaleXXXのrangeに食わせてあげてd3.lineを実行してあげればpathの計算はできちゃいます。
created() {
// グラフ本体の幅/高さをrangeとして座標算出関数を作成
let xScale = d3.scaleBand()
.range([0, this.graphBodyWidth])
.domain(this.dataset.map(v => v.date));
let yScale = d3.scaleLinear()
.rangeRound([this.graphBodyHeight, 0])
.domain([0, Math.max(...this.yLabelList)]);
// d3のpath generatorでdを計算する関数を作る(補完は適当に曲線にした)
let line = d3.line()
.x((d, i) => xScale(d.date))
.y((d, i) => yScale(d.val))
.curve(d3.curveMonotoneX);
d3.select('.line1').attr('d', line(this.dataset))
},
コードの全体像
<!DOCTYPE html>
<html>
<head>
<link rel='stylesheet', href='./css/base/reset.css'></link>
<link rel='stylesheet', href='./css/base/main.css'></link>
<script src="https://unpkg.com/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/d3@5.9.1/dist/d3.js"></script>
<script src="./js/main.js"></script>
</head>
<body>
<div class="mod-graph" id="graph">
<svg width="600" height="400" :viewBox="`0 0 ${defaultSize.x} ${defaultSize.y}`">
<g class="graphBody" :transform="`translate(${graphBody.left}, ${graphBody.top})`">
<!-- x軸方向のメモリ -->
<g class="ticksX">
<g class="tick" v-for="(pos, idx) in xTicksPosList" :transform="`translate(0, ${pos})`">
<line x1="0" y1="0" :x2="graphBodyWidth" :y2="0"></line>
</g>
</g>
<!-- y軸方向のメモリ -->
<g class="ticksY">
<g class="tick" v-for="(pos, idx) in yTicksPosList" :transform="`translate(${pos}, 0)`">
<line x1="0" y1="0" x2="0" :y2="graphBodyHeight"></line>
</g>
</g>
<!-- グラフ線分 -->
<g class="paths">
<path class="line1" stroke="blue" stroke-width="2" fill="none"></path>
</g>
<!-- x軸 -->
<g class="axisX">
<line :x1="-axis.strokeWidth / 2" :y1="graphBodyHeight" :x2="graphBodyWidth" :y2="graphBodyHeight" :stroke-width="axis.strokeWidth"></line>
</g>
<!-- y軸 -->
<g class="axisY">
<line x1="0" y1="0" x2="0" :y2="graphBodyHeight" :stroke-width="axis.strokeWidth"></line>
</g>
</g>
<!-- x軸のラベル -->
<g class="labelX" :transform="`translate(${graphBody.left}, ${graphBody.top + graphBodyHeight})`">
<g class="tick" v-for="(pos, idx) in xLabelPosList" :transform="`translate(${pos}, 0)`">
<text x="5" :y="10 + axis.strokeWidth" transform="rotate(45)">{{ dataset[idx].date }}</text>
</g>
</g>
<!-- y軸のラベル -->
<g class="labelY" :transform="`translate(0, ${graphBody.top})`">
<g class="tick" v-for="(pos, idx) in yLabelPosList" :transform="`translate(0, ${pos})`">
<text :x="graphBody.left - 5" y="0" text-anchor="end">{{ yLabelList[idx] }}</text>
</g>
</g>
</svg>
</div>
</body>
</html>
let dataset = [
{ "date": "1-May-12", "val": 58.13 },
{ "date": "30-Apr-12", "val": 53.98 },
{ "date": "27-Apr-12", "val": 67 },
{ "date": "26-Apr-12", "val": 89.7 },
{ "date": "25-Apr-12", "val": 99 },
{ "date": "24-Apr-12", "val": 130.28 },
{ "date": "23-Apr-12", "val": 166.7 },
{ "date": "20-Apr-12", "val": 234.98 },
{ "date": "19-Apr-12", "val": 345.44 },
{ "date": "18-Apr-12", "val": 443.34 },
{ "date": "17-Apr-12", "val": 543.7 },
{ "date": "16-Apr-12", "val": 580.13 },
{ "date": "13-Apr-12", "val": 605.23 },
{ "date": "12-Apr-12", "val": 622.77 },
]
document.addEventListener('DOMContentLoaded', function () {
new Vue({
el: '#graph',
data: {
dataset: dataset,
defaultSize: {x: 600, y: 400},
graphBody: { // グラフ本体
top: 20,
left: 80,
right: 60,
bottom: 60
},
axis: { // 軸
strokeWidth: 4
},
ticks: { // メモリ
xSize: dataset.length,
ySize: 10
}
},
created() {
// グラフ本体の幅/高さをrangeとして座標算出関数を作成
let xScale = d3.scaleBand()
.range([0, this.graphBodyWidth])
.domain(this.dataset.map(v => v.date));
let yScale = d3.scaleLinear()
.rangeRound([this.graphBodyHeight, 0])
.domain([0, Math.max(...this.yLabelList)]);
// d3のpath generatorでdを計算する関数を作る(補完は適当に曲線にした)
let line = d3.line()
.x((d, i) => xScale(d.date))
.y((d, i) => yScale(d.val))
.curve(d3.curveMonotoneX);
d3.select('.line1').attr('d', line(this.dataset))
},
computed: {
/**
* グラフ本体の幅
*/
graphBodyWidth() {
return this.defaultSize.x - this.graphBody.left - this.graphBody.right;
},
/**
* グラフ本体の高さ
*/
graphBodyHeight() {
return this.defaultSize.y - this.graphBody.top - this.graphBody.bottom;
},
/**
* x軸方向のメモリの座標一覧
*/
xTicksPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.ySize; i < iz; i++) {
posList.push((this.graphBodyHeight / iz) * i )
}
return posList;
},
/**
* y軸方向のメモリの座標一覧
*/
yTicksPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.xSize; i < iz; i++) {
posList.push((this.graphBodyWidth / iz) * (i + 1))
}
return posList;
},
/**
* x軸方向のlabelの座標一覧
*/
xLabelPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.xSize; i < iz; i++) {
posList.push((this.graphBodyWidth / iz) * i)
}
return posList;
},
/**
* y軸方向のlabelの座標一覧
*/
yLabelPosList() {
var posList = [];
for(let i = 0, iz = this.ticks.ySize; i <= iz; i++) {
posList.push((this.graphBodyHeight / iz) * i )
}
return posList;
},
/**
* y軸方向のlabel一覧
*/
yLabelList() {
let vals = this.dataset.map(v => v.val)
, maxVal = Math.max(...vals)
, tickValMax = Math.ceil(maxVal / 100) * 100
;
let labels = [];
for(let i = 0, iz = this.ticks.ySize; i <= iz; i++) {
labels.push((tickValMax / iz) * i)
}
return labels.reverse();
}
}
})
}, false);
.mod-graph {
padding: 100px;
}
.mod-graph svg {
border: 1px dotted #dedede;
}
.mod-graph .graphBody {}
.mod-graph .graphBody .axisX line,
.mod-graph .graphBody .axisY line {
stroke: #dedede;
}
.mod-graph .graphBody .ticksX line,
.mod-graph .graphBody .ticksY line {
stroke: #dedede;
stroke-width: 1px;
stroke-dasharray: 2;
}
.mod-graph .labelX text {
font-size: 12px;
}
.mod-graph .labelY text {
font-size: 12px;
}
おわり
どうでしょう。 結局コード量は多いんですが最終型がイメージしやすくて処理の単位をきっちり把握しやすい感じがします。