Vue.jsでサクサク！SVG内に線を引いてみる

## はじめに

[前回の記事](https://qiita.com/masa0209/items/27fb36e939d08b77def7)続きです。
SVGとVue.jsを組み合わせて今回は、Pathを使って線を引いてみるという
処理を実装して見たいと思います。

[前回の記事](https://qiita.com/masa0209/items/27fb36e939d08b77def7)と組み合わせることで、
マインドマップのようなWebアプリも作れるかもしれないです。
(その場合はpath要素と図形要素を保持して、線を設定する形になると思います。)

![aperture-video-95d3487f-079b-43c8-bca2-7f1eda3f892e (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258186/900aaf94-dea6-6fca-802a-480b099ca1e6.gif)

早速コードを見ていきましょう！


## Path要素を入れる

まず、線を表現するのに必要なPath要素を、
SVGタグ内に記載いたします。
v-model内にリストで表現し、線でつないだ分だけ可変的に生成できるようにします。

線を引いている時（確定前要素）と、線を引いた後（確定後要素）で、
Pathを分けております。
今回オプション要素で、線の太さや、色を全体に可変的に適用する設定もおまけで入れてます。

```vue:SVGDemo.vue
<template>
  <div class="container">
    <div class="setting-box">
      <!-- 色を変えるとこ -->
      <p>色</p>
      <input type="color" v-model="strokeFill" />
      <!-- 線の太さとか -->
      <p>線の太さ</p>
      <input type="range" v-model="strokeWidth" />
    </div>
    <!-- SVG定義 -->
    <svg :width="width" :height="height" :viewBox="viewport" @wheel="zoompan" @mousedown="lineNode($event, idx)"> 
      <!-- 線を引いた後 -->
      <path :d="path"
            fill="none"
            :stroke="strokeFill"
            :opacity="strokeOpacity"
            :stroke-width="strokeWidth"
            stroke-linecap="round"
            v-for="(path, idx) in pathList" :key="idx"
            v-if="pathList.length > 0"/>
      <!-- 線を引いている最中 -->
      <path :d="pathTemp"
            fill="none"
            :stroke="strokeFill"
            :opacity="strokeOpacity"
            :stroke-width="strokeWidth"
            stroke-linecap="round"
            v-if="isLineNode"/>
    </svg>
  </div>
</template>
```

## イベントハンドラを実装

今回は図形からドラッグしていくと
線が生成され、図形同士で繋げた場合に
生成されるようにしていきます。

```vue:SVGDemo.vue

<script>
export default {
  name: 'SVGDemo',
  data () {
    return {
      width: 500,
      height: 500,
      ratio: 1,
      dx: 0,
      dy: 0,
      viewport: '0 0 500 500',
      isLineNode: false,
      beforeMouseX: null,
      beforeMouseY: null,
      selectIdx: 0,
      pathList: [],
      pathTemp: '',
      strokeFill: '#525',
      strokeOpacity: 1,
      strokeWidth: '5',
      moveLineX: 0,
      moveLineY: 0,
      startLineX: 0,
      startLineY: 0,
    } 
  },
  // マウス操作関連
  mounted () {
    console.log('MOUNT LISTENER ON')
    document.addEventListener('mouseup', this.mouseUp)
    document.addEventListener('mousemove', this.mouseMakeLine)
  },
  beforeDestroy () {
    console.log('MOUNT LISTENER OFF')
    document.removeEventListener('mouseup', this.mouseUp)
    document.removeEventListener('mousemove', this.mouseMakeLine)
  },
  methods: {
    zoompan (e) {
      var [x, y, w, h] = this.viewport.split(' ').map(v => parseFloat(v))
      if (e.ctrlKey) {
        // 拡大（Y軸が上がる場合） 縮小（Y軸が下がる場合）
        if (e.deltaY > 0) {
           w = w * 1.01
           h = h * 1.01
        } else {
          w = w * 0.99
          h = h * 0.99
        }
        this.makeViewBox(x, y, w, h)
        this.ratio = w / this.width
        e.preventDefault()
      } else {
        // 移動
        if ((this.dx + e.deltaX > -this.width && this.dy + e.deltaY > -this.width) &&
            (this.dx + e.deltaX < this.width * 2 && this.dy + e.deltaY < this.width * 2)) {
          this.makeViewBox(x + e.deltaX, y + e.deltaY, w, h)
          this.dx += e.deltaX
          this.dy += e.deltaY
        }
      }
    },
    // viewboxを作成
    makeViewBox (x, y, w, h) {
      this.viewport = [x, y, w, h].join(' ')
    },
    // マウスのクリックが終わった段階で初期化
    mouseUp (e) {
      this.isLineNode = false
      this.beforeMouseX = null
      this.beforeMouseY = null
      this.pathList.push(this.pathTemp)
      this.pathTemp = null
      e.preventDefault()
    },
    // move中は前回まで動かした差分を取りながら座標を変化させていく
    mouseMove (e) {
      if (!this.isMove) return
      var mouseX = e.offsetX * this.ratio + this.dx
      var mouseY = e.offsetY * this.ratio + this.dy
      var dx = 0
      var dy = 0
      if (this.beforeMouseX && this.beforeMouseY) {
          dx = mouseX - this.beforeMouseX
          dy = mouseY - this.beforeMouseY
      }
      this.beforeMouseX = mouseX
      this.beforeMouseY = mouseY
      var tempX = dx + Number(this.rects[this.selectIdx].x)
      var tempY = dy + Number(this.rects[this.selectIdx].y)
      if (tempX > 0) this.rects[this.selectIdx].x = tempX
      if (tempY > 0) this.rects[this.selectIdx].y = tempY
      e.preventDefault()
    },
    lineNode (e, i) {
      this.startLineX = e.offsetX * this.ratio + this.dx
      this.startLineY = e.offsetY * this.ratio + this.dy
      this.moveLineX = e.offsetX * this.ratio + this.dx
      this.moveLineY = e.offsetY * this.ratio + this.dy
      this.isLineNode = true
      e.preventDefault()
    },
    mouseMakeLine (e) {
      if (!this.isLineNode) return
      var mouseX = e.offsetX * this.ratio + this.dx
      var mouseY = e.offsetY * this.ratio + this.dy
      var dx = 0
      var dy = 0
      if (this.beforeMouseX && this.beforeMouseY) {
          dx = mouseX - this.beforeMouseX
          dy = mouseY - this.beforeMouseY
      }
      this.beforeMouseX = mouseX
      this.beforeMouseY = mouseY
      var tempX = this.moveLineX
      var tempY = this.moveLineY
      tempX += dx
      tempY += dy
      if (tempX > 0) this.moveLineX = tempX
      if (tempY > 0) this.moveLineY = tempY
      this.pathTemp = 'M' + this.startLineX + ',' + this.startLineY + ' L' + this.moveLineX + ',' + this.moveLineY
    }
  }
}
</script>

```

## 完成

ここまでロジックを記載していくと
このような形になるかと思います。
![aperture-video-65ade8d9-c974-4505-9dfc-b07807b73ed1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258186/cc9a6e2f-3ef0-bb2c-a2d5-a0fbf49db5ea.gif)
ベジェ曲線など組み込んでカーブさせても
面白そうですね！