Vue.js + d3.js で SVG のシンプルな線グラフを作る

この記事は [CivicTechテック好き Advent Calendar 2020](https://qiita.com/advent-calendar/2020/civictech_tech) の 3 日目の記事です。

## この記事について

この記事では Vue.js と d3.js を組み合わせて SVG のシンプルな線グラフを書く際の、各要素の実装方法をまとめています。
Vue.js と d3.js の役割は、データや DOM の更新に対する処理の部分で一部被っているところがあるため、それらをどちらに担当させるかによって、組み合わせ方が何通りか存在します。この記事の方法では、データの更新と各要素の計算タイミングを Vue.js に、数値計算や軸の DOM 更新を d3.js に担当させています。

最小限のコードで動く完成品が [CodePen](https://codepen.io/osoken/pen/abmOMYV) に置いてあります。各種プロパティを `input` で変更できるようにもしています。

### 余談: この記事と CivicTech の関係性（がないこと）について
記事で実現していること自体は CivicTech とは直接関係ありません。ただ、この記事の[元となったコード](https://github.com/tokyo-metropolitan-gov/covid19/blob/43e8bc7f9bbeba5930bf82a732c296250ef6a66a/components/PopulationLineChart.vue)を、今年の自分の CivicTech 活動の中で書く機会があり、もっと単純な作り方をどこかでまとめておきたいと思っていたので寄稿することにしました。

閑話休題。

## 環境

- Vue.js 2.6.11
- d3.js 6.2.0

## 作るもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/51853/2af03598-9213-6076-9b67-2254dbe2ec15.png)

縦軸、横軸と一本の線グラフで構成されたシンプルな線グラフです。これを、データや SVG とグラフ領域（軸の内側を本稿ではこう呼ぶことにする）の大きさを調整できる単一のコンポーネントとして構成します。
詳細に入る前に全体を先に示しておくと、コンポーネント全体としては、以下のようなコードになります:

```javascript

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` をプロパティとして与えてグラフに表示します。上の画像と同じ表示にするには、以下のデータを与えます:

```html
<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`)

```javascript
{
  ...,
  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 の幅高さ、グラフ領域のマージンに対してリアクティブである必要があるため、算出プロパティとして実現します:

```javascript
{
  ...,
  computed: {
    ...,
    graphWidth() {
      return this.width - this.marginLeft - this.marginRight
    },
    graphHeight() {
      return this.height - this.marginTop - this.marginBottom
    },
    ...
  },
  ...
}
```

#### グラフ領域の `g` 要素

SVG 要素内をマージン分だけ平行移動させます。グラフ領域のマージンに対してリアクティブです。

```html
<svg ...>
  <g :transform="\`translate(\$\{marginLeft\}, \$\{marginTop\})\`">
  ...
  </g>
</svg>
```


#### x 軸のスケール、y 軸のスケール

d3.js の [d3-scale](https://github.com/d3/d3-scale/blob/master/README.md) にあるものを使います。x 座標は `chartData` 配列のインデクスによって決めるため離散的スケールの [`d3.scalePoint`](https://github.com/d3/d3-scale/blob/master/README.md#point-scales) を、y 座標は `chartData` 配列の値によって決めるため連続的スケールの [`d3.scaleLinear`](https://github.com/d3/d3-scale/blob/master/README.md#linear-scales) をそれぞれ使います。`chartData` 配列、グラフ領域の高さ、幅に対してリアクティブです。

```javascript

{
  ...,
  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` だけアップデートするようにもできます。

```javascript
{
  ...,
  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](https://github.com/d3/d3-shape/blob/master/README.md) の [`d3.line`](https://github.com/d3/d3-shape/blob/master/README.md#lines) を使います。

```javascript

{
  ...,
  computed: {
    ...,
    line() {
      return d3
        .line()
        .x((d, i) => this.xScale(i))
        .y((d) => this.yScale(d))
    },
    d() {
      return this.line(this.chartData)
    },
  },
}
```

これをテンプレート側で `d` 属性にバインドします。

```html
    <path :d="d" />
```

`d3.line()` が毎回呼ばれるのを避けるため、データオブジェクトに `d3.line()` 自体を持ち、算出プロパティの再計算で `.x()` と `.y()` だけ書き換えるには以下のようにします。

```javascript

  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](https://github.com/d3/d3-axis/blob/master/README.md) を使います。`xScale`, `yScale` に対してそれぞれリアクティブにします。今回は[こちら](https://stackoverflow.com/questions/48726636/draw-d3-axis-without-direct-dom-manipulation)で紹介されている方法を使って、ディレクティブとして登録します。(この書き方だと、どの値が更新されても更新を試みられてしまうため、実際はスケールの `range` や `domain` に変更が無いかを前段でチェックしたほうが良いとは思います)

```javascript

{
  ...,
  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` 要素に適用します:

```html

    <g v-x-axis />
    <g v-y-axis />
```

ちなみに、この方法で作った軸の `g` 要素の下には [Scoped CSS](https://vue-loader.vuejs.org/guide/scoped-css.html#mixing-local-and-global-styles) のスコープを外れた子要素が作られます。なので、それらの子要素だけにスタイルを当てる際は [Deep Selector](https://vue-loader.vuejs.org/guide/scoped-css.html#deep-selectors) を使う必要があります。

## まとめ

Vue.js と d3.js を使ってシンプルな線グラフを実装する方法の一つを紹介しました。線グラフの実装に必要な処理のうち、スケール変換、SVG `path` 要素の `d` 属性の計算、軸の描画に d3.js を使い、全体としては Vue コンポーネントとして使えるように実装しました。