Edited at

ライブラリ不要!Vue.jsとSVGで簡単に動かせるUIを作る


はじめに

Vue.jsを普段開発で使っていて、SVGとの相性がすごく良いと感じてます。

Vue.jsのドキュメントでもアニメーションなどの紹介もされていますが、

今回はブラウザで編集可能なGUIの土台として、

使えそうなTipsを書いていきたいと思います。


Vue.jsのプロジェクトを作成していく

今回使っていくのはVueCLI v3.xの環境で開発していきます。

標準でプロジェクト作成のUIなども付いており、非常に開発しやすいです。

$ vue ui

🚀 Starting GUI...
🌠 Ready on http://localhost:8000

vueui.png


SVG内でズーム・パン風の機能を実装してみる

早速ですが、SVG内でズーム・パン風の機能を作って見たいと思います。

下記のような感じになります。

(MacBookProなどのノートPCですと二本指で開いてズームしたり、

 二本指でスライドして、移動したりして、操作が実感しやすいと思います。)

svg1 (1).gif

コードで実際に実装するとこのような形です。

移動の場合は差分値分移動されるようdeltaX, deltaYで計算します。

※CSSは割愛してます。


SVGDemo.vue

<template>

<div class="container">
<!-- SVG定義 -->
<svg :width="width" :height="height" :viewBox="viewport" @wheel="zoomPan">
<rect fill="blue" x="10" y="10" width="100" height="100"></rect>
</svg>
</div>
</template>

<script>
export default {
name: 'SVGDemo',
data () {
return {
width: 500,
height: 500,
ratio: 1,
dx: 0,
dy: 0,
viewport: '0 0 500 500',
}
},
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(' ')
}
}
}
</script>



ドラッグ&ドロップでSVG内の図形を動かしてみる

図形を自由に動かしてみます。

下記のような感じの動きになります。

svg (1).gif

コードの実装としては、

ロジックが少し綺麗でないかもしれないですが、

図形をクリックした段階のものをindex指定し、

マウスが離されるまで、フラグを持っておきます。

フラグ中は、動かした時に差分を読み取りながら、

図形の座標を変更していきます。


SVGDemo.vue

<template>

<div class="container">
<!-- SVG定義 -->
<svg :width="width" :height="height" :viewBox="viewport" @wheel="zoompan">
<rect v-for="(r, idx) in rects" :key="idx"
@mousedown="move($event, idx)"
:fill="r.color"
:x="r.x" :y="r.y" :width="r.w" :height="r.h">
</rect>
</svg>
</div>
</template>

<script>
export default {
name: 'SVGDemo',
data () {
return {
width: 500,
height: 500,
ratio: 1,
dx: 0,
dy: 0,
viewport: '0 0 500 500',
isMove: false,
beforeMouseX: null,
beforeMouseY: null,
selectIdx: 0,
rects: [
{
x: 10,
y: 10,
w: 100,
h: 100,
color: 'green'
},
{
x: 200,
y: 150,
w: 100,
h: 100,
color: 'red'
},
{
x: 310,
y: 410,
w: 200,
h: 100,
color: 'blue'
},
]
}
},
// マウス操作関連
mounted () {
console.log('MOUNT LISTENER ON')
document.addEventListener('mouseup', this.mouseUp)
document.addEventListener('mousemove', this.mouseMove)
},
beforeDestroy () {
console.log('MOUNT LISTENER OFF')
document.removeEventListener('mouseup', this.mouseUp)
document.removeEventListener('mousemove', this.mouseMove)
},
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(' ')
},
// 図形を動かすフラグを立てる
move (e, i) {
this.isMove = true
this.selectIdx = i
e.preventDefault()
},
// マウスのクリックが終わった段階で初期化
mouseUp (e) {
this.isMove = false
this.beforeMouseX = null
this.beforeMouseY = 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()
}
}
}
</script>



最後に

いかがでしたでしょうか?

そこまで長いコードを書かずに動かしたり、

ズーム・パン風のものを実装することができました。

現在弊社では、HRモンスターと呼ばれる

採用の新しいスタイルを提供するサービスをローンチいたしました。

ローンチ後のさらなる機能追加、改善などのPDCAサイクルを回すべく、

エンジニアを募集しております。

https://www.wantedly.com/projects/341182

Kubernetes、Vue.js(Javascript)、Django(Python)といったモダンな技術を使って、

開発しておりますので、もしご興味がある方はぜひ、ご応募お待ちしております。