サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Vue.js + ES6: SVGでレーダーチャートを操作する


Vue.js公式サイトの「SVGグラフの例」は、SVGでレーダーチャートを描き、値がスライダで動的に操作できます。さらに、データを新たに加えたり除いたりすることも可能です。この作例に少し手を加え、ECMAScript 2015 (ECMAScript 6)の構文で書いてみましょう。なお、公式作例をどう手直ししたかについては、「Vue.js公式サイトの『SVGグラフの例』を改善する」をお読みください。

01 SVGで円を描く

Vue.jsのライブラリはCDNから読み込んでおきます。本稿執筆時の最新バージョンは2.5.16です。


<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script>

まず、SVGで描くのは円です。<circle>要素に、属性として中心のxy座標と半径を定めます。


<circle cx=中心座標x cy=中心座標y r=半径></circle>

円の中心のxy座標と半径のデータは、つぎのようにオブジェクトのプロパティとして変数(circle)に与えておきます。それをVueアプリケーション(vm)がdataに加え、コンポーネント(polygraph)はpropsに受け取る流れです。

JavaScriptコード

const circle = {cx: 100, cy: 100, r: 80};
Vue.component('polygraph', {
	props: {
		circle: Object
	},
	template: '#polygraph-template'
});
const vm = new Vue({
	data: {
		circle: circle
	}
});
document.addEventListener('DOMContentLoaded', (event) =>
	vm.$mount('#demo')
);

<body>要素はアプリケーションの要素(id属性demo)は、SVG要素の中にコンポーネントの要素(polygraph)を加え、円のデータ(circle)をv-bindディレクティブ(省略記法:)でバインディングしておきます。これでコンポーネントは、propsにデータが受け取れるのです。

<body>要素

<div id="demo">
	<svg width="200" height="200">
		<polygraph :circle="circle"></polygraph>
	</svg>

コンポーネント(polygraph)のテンプレートは、円のデータ(circle)から中心のxy座標と半径を得て<circle>要素の属性にそれぞれバインディングすれば円が描かれます(図001)。<circle>要素には、以下のスタイルを与えました。

テンプレート

<script type="text/x-template" id="polygraph-template">
<g>
	<circle :cx="circle.cx" :cy="circle.cy" :r="circle.r"></circle>
</g>
</script>

<style>要素

circle {
	fill: transparent;
	stroke: #999;
}

図001■SVGの<circle>要素で描かれた円

図001

02 SVGで正六角形のレーダーチャートを描く

つぎにSVGで描くのは、レーダーチャートとなる多角形です。正六角形からはじめましょう。コンポーネントのテンプレートには、多角形を描く<polygon>要素を加えます。バインディングしたのは、頂点座標を与えるpoints属性です。

テンプレート

<script type="text/x-template" id="polygraph-template">
<g>
	<polygon :points="points"></polygon>

</g>
</script>

<polygon>要素のpoints属性には、多角形の頂点のxy座標を順に区切り文字でつなげます。区切り文字に用いるのは、カンマ(,)か半角スペースです。座標xとyの間にカンマ、つぎの頂点座標との間にはスペースを入れる場合が多いようです。最後の頂点は、最初の頂点と結ばれて閉じた多角形ができあがります。つぎのコードは、図002のような六角形を描きます。


<polygon points="60,20 100,40 100,80 60,100 20,80 20,40"/>

図002■<polygon>要素で描かれた六角形

半径1の円周上の点(x,y)がx軸の正方向と角度θをなすとき、その座標は(cosθ,sinθ)で表されます(図003)。半径がrなら、円周上のxy座標は(rcosθ,rsinθ)になるということです(「三角関数のsinとcosの定義」)。

図003■三角関数のsinとcosで半径1の円周上の座標を表す

図003

そこで、多角形の頂点座標が返される関数(valueToPoint())を以下のJavaScriptコードのように定めます。引数は3つで、(1)円周までの長さを100としたパーセンテージ、(2)0からはじまる頂点番号、(3)多角形の頂点数です。頂点のxy座標をプロパティに納めたオブジェクトが返されます。

valueToPoint(パーセンテージ, 頂点番号, 頂点数)

JavaScriptコード

function valueToPoint(value, index, total) {
	const r = value * circle.r / 100;
	const angle = Math.PI * 2 / total * index - Math.PI / 2;
	const tx = r * Math.cos(angle) + circle.cx;
	const ty = r * Math.sin(angle) + circle.cy;
	return {
		x: tx,
		y: ty
	};
}

レーダーチャートのデータはつぎのように変数(stats)に定めます。アプリケーションのdataに加えて、コンポーネントのpropsが受け取り、computed算出プロパティ(points())から<polygon>要素のpoints属性に頂点座標データをバインディングするという流れです(前掲テンプレートpolygraph-template参照)。6件のパーセンテージ(value)がすべて100なので、かたちは正六角形になります。

JavaScriptコード

const stats = [
	{label: 'A', value: 100},
	{label: 'B', value: 100},
	{label: 'C', value: 100},
	{label: 'D', value: 100},
	{label: 'E', value: 100},
	{label: 'F', value: 100}
];
Vue.component('polygraph', {
	props: {
		stats: Array,

	},

	computed: {
		points() {
			const stats = this.stats;
			const total = stats.length
			return stats.map((stat, i) => {
				const point = valueToPoint(stat.value, i, total);
				return point.x + ',' + point.y;
			}).join(' ');
		}
	}
});

const vm = new Vue({
	data: {
		stats: stats,

	}
});

レーダーチャートのデータ(stats)は、コンポーネントが受け取れるようつぎのようにバインディングしておかなければなりません。

<body>要素

<svg width="200" height="200">
	<!--<polygraph :circle="circle"></polygraph>-->
	<polygraph :stats="stats" :circle="circle"></polygraph>
</svg>

これで、円周上の頂点を結んで。正六角形のレーダーチャートが描かれます(図004)。なお、<style>要素の定めは、あとにコード001としてまとめました。

図004■SVGで描かれた正六角形のレーダーチャート

図004

03 レーダーチャートのデータ表示とスライダコントロール

レーダーチャートのデータ(stats)は、中身が確かめられるようページに表示します(図005)。これはつぎのように、アプリケーションの要素(id属性demo)の中に要素を加えて、データバインディングするだけです。<style>要素の定めについては、後掲コード001をご覧ください。

<body>要素

<div id="demo">

	<pre id="raw">{{stats}}</pre>
</div>

図005■レーダーチャートのデータが示された

図005

レーダーチャートのデータ(stats)のパーセンテージ(valueプロパティ)の値は、スライダで変えられるようにします。スライダをつくる要素は、<input type="range">です。Internet Explorerの実装はバージョン10以降となることにご注意ください。v-forディレクティブで、つぎのようにラベル(labelプロパティ)とパーセンテージ(プロパティvalue)を要素のテキストにバインディングし、さらにパーセンテージは<input>要素のv-modelディレクティブに定めます。すると、スライダの操作でパーセンテージの値が変わるようになるのです(図006)。ここまでを、以下のコード001にまとめました。併せて、コードの動きが確かめられるように、jsdo.itにサンプル001を掲げてあります。

<body>要素

<div id="demo">
	<svg width="200" height="200">

	</svg>
	<div v-for="stat in stats">
		<label>{{stat.label}}</label>
		<input type="range" v-model="stat.value" min="0" max="100">
		<span>{{stat.value}}</span>
	</div>

</div>

図006■スライダを操作するとパーセンテージの値が変わる

図006

コード001■レーダーチャートの6つのパーセンテージをスライダの操作で変える

JavaSciptコード

const stats = [
	{label: 'A', value: 100},
	{label: 'B', value: 100},
	{label: 'C', value: 100},
	{label: 'D', value: 100},
	{label: 'E', value: 100},
	{label: 'F', value: 100}
];
const circle = {cx: 100, cy: 100, r: 80};
Vue.component('polygraph', {
	props: {
		stats: Array,
		circle: Object
	},
	template: '#polygraph-template',
	computed: {
		points() {
			const stats = this.stats;
			const total = stats.length
			return stats.map((stat, i) => {
				const point = valueToPoint(stat.value, i, total);
				return point.x + ',' + point.y;
			}).join(' ');
		}
	}
});
function valueToPoint(value, index, total) {
	const r = value * circle.r / 100;
	const angle = Math.PI * 2 / total * index - Math.PI / 2;
	const tx = r * Math.cos(angle) + circle.cx;
	const ty = r * Math.sin(angle) + circle.cy;
	return {
		x: tx,
		y: ty
	};
}
const vm = new Vue({
	data: {
		stats: stats,
		circle: circle
	}
});
document.addEventListener('DOMContentLoaded', (event) =>
	vm.$mount('#demo')
);

<body>要素

<div id="demo">
	<svg width="200" height="200">
		<polygraph :stats="stats" :circle="circle"></polygraph>
	</svg>
	<div v-for="stat in stats">
		<label>{{stat.label}}</label>
		<input type="range" v-model="stat.value" min="0" max="100">
		<span>{{stat.value}}</span>
	</div>
	<pre id="raw">{{stats}}</pre>
</div>

テンプレート

<script type="text/x-template" id="polygraph-template">
<g>
	<polygon :points="points"></polygon>
	<circle :cx="circle.cx" :cy="circle.cy" :r="circle.r"></circle>
</g>
</script>

<style>要素

body {
	font-family: Helvetica Neue, Arial, sans-serif;
}
polygon {
	fill: #42b983;
	opacity: .75;
}
circle {
	fill: transparent;
	stroke: #999;
}
text {
	font-family: Helvetica Neue, Arial, sans-serif;
	font-size: 10px;
	fill: #666;
}
label {
	display: inline-block;
	margin-left: 10px;
	width: 20px;
}
#raw {
	position: absolute;
	top: 0;
	left: 300px;
}

サンプル001■Vue.js + ES6: Radar chart with SVG controlled by sliders

04 レーダーチャートにラベルを加える

レーダーチャートのSVGにデータラベルのテキストを加えましょう。ラベルもコンポーネント(axis-label)にして、レーダーチャート(polygraph)の子として定めます。そのとき、インスタンスオプションのcomponentsを用いるとローカル登録され、使えるのは親コンポーネントインスタンスのスコープの中だけとなるのです。テキストの位置は、算出プロパティ(point())から前掲座標を求める関数(valueToPoint())を呼び出して与えます。スライダから渡されるパーセンテージの値(value)は文字列なので、数値として扱いたいときは変換しなければなりません。加算演算子+を前に添えるのはそのやり方のひとつです。

JavaScriptコード

Vue.component('polygraph', {

	components: {
		'axis-label': {
			props: {
				stat: Object,
				index: Number,
				total: Number
			},
			template: '#axis-label-template',
			computed: {
				point() {
					return valueToPoint(
						+this.stat.value + 10,
						this.index,
						this.total
					);
				}
			}
		}
	}
});

レーダーチャートコンポーネントのテンプレートには、v-forディレクティブでラベルコンポーネント(axis-label)の要素を差し込みます。バインディングするのは、ラベルの座標を求めるために前掲算出プロパティ(point())で用いられる値です。ラベルコンポーネントのテンプレートは、<text>要素でラベル(label)のテキストをバインディングし、算出プロパティから位置座標を得て、xおよびy属性にバインディングで与えます。これで、レーダーチャートの各頂点にデータラベルがテキストで示されます(図007)。

テンプレート

<script type="text/x-template" id="polygraph-template">
<g>

	<axis-label
		v-for="(stat, index) in stats"
		:stat="stat"
		:index="index"
		:total="stats.length">
	</axis-label>
</g>
</script>
<script type="text/x-template" id="axis-label-template">
	<text :x="point.x" :y="point.y" dx="-3" dy="3">{{stat.label}}</text>
</script>

図007■レーダーチャートの頂点にデータラベルが示される

図007

05 レーダーチャートに新たなデータを加える

レーダーチャートに新たなデータが加えられるようにしましょう。アプリケーションに、メソッド(add())をつぎのように加えます。追加データのラベル(label)に定めるのは、v-modelディレクティブでバインディングした<input>要素の入力文字列(newLabel)です。メソッドはボタン(<button>要素)のclickイベントに定めるので、メソッドはイベントオブジェクト(event)を引数に受け取ります。Event.preventDefault()メソッドを呼び出すのは、ページの再読み込みを避けるためです。

JavaScriptコード

const vm = new Vue({

	data: {
		newLabel: '',

	},
	methods: {
		add(event) {
			event.preventDefault();
			const newLabel = this.newLabel.trim();
			this.newLabel = '';
			if (!newLabel) {return}
			this.stats.push({
				label: newLabel,
				value: 100
			})
		},

	}
});

<body>要素

<div id="demo">

	<form id="add">
		<input name="newlabel" v-model="newLabel">
		<button @click="add">Add a Stat</button>
	</form>
	<pre id="raw">{{stats}}</pre>
</div>

これで、入力フィールドに打ち込んだテキストが、ボタンクリックでラベルとしてレーダーチャートに加わります(図008)。

図008■ボタンクリックでラベルがレーダーチャートに加わる

図008

06 レーダーチャートからデータを除く

レーダーチャートに入っているデータを、除くこともできるようにしましょう。スライダの脇に[X]ボタン(<button>要素)を添え、クリック(clickいべ)したら削除のメソッド(remove())を呼び出します。引数(stat)に受け取るのは、削除するデータです。ECMAScript 5.1で備わったArray.filter()メソッドは、引数の関数が定める条件に合った要素からなる新たな配列を返します。削除する要素以外というのがその条件です。データは3つ以上ないと多角形が描けません。それより少なくしようとすると、警告のダイアログが示されます。

JavaSciptコード

const vm = new Vue({

	methods: {

		remove(stat) {
			const stats = this.stats;
			if (stats.length > 3) {
				this.stats = stats.filter((_stat) => _stat !== stat);
			} else {
				alert(`Can't delete more!`);
			}
		}
	}
});

<body>要素

<div id="demo">

	<div v-for="stat in stats">

		<button @click="remove(stat)" class="remove">X</button>
	</div>

</div>

これで、レーダーチャートから、削除ボタン[X]でデータが除けるようになりました(図009)。JavaScriptコードと<body>要素およびテンプレートの記述を、以下のコード002にまとめます。コードの動きは、jsdo.itに掲げたサンプル002でお確かめください。

図009■削除ボタンを押したデータがレーダーチャートから除かれる

図009

コード002■データの追加と削除ができるレーダーチャート

JavaSciptコード

const stats = [
	{label: 'A', value: 100},
	{label: 'B', value: 100},
	{label: 'C', value: 100},
	{label: 'D', value: 100},
	{label: 'E', value: 100},
	{label: 'F', value: 100}
];
const circle = {cx: 100, cy: 100, r: 80};
Vue.component('polygraph', {
	props: {
		stats: Array,
		circle: Object
	},
	template: '#polygraph-template',
	computed: {
		points() {
			const stats = this.stats;
			const total = stats.length
			return stats.map((stat, i) => {
				const point = valueToPoint(stat.value, i, total);
				return point.x + ',' + point.y;
			}).join(' ');
		}
	},
	components: {
		'axis-label': {
			props: {
				stat: Object,
				index: Number,
				total: Number
			},
			template: '#axis-label-template',
			computed: {
				point() {
					return valueToPoint(
						+this.stat.value + 10,
						this.index,
						this.total
					);
				}
			}
		}
	}
});
function valueToPoint(value, index, total) {
	const r = value * circle.r / 100;
	const angle = Math.PI * 2 / total * index - Math.PI / 2;
	const tx = r * Math.cos(angle) + circle.cx;
	const ty = r * Math.sin(angle) + circle.cy;
	return {
		x: tx,
		y: ty
	};
}
const vm = new Vue({
	data: {
		newLabel: '',
		stats: stats,
		circle: circle
	},
	methods: {
		add(event) {
			event.preventDefault();
			const newLabel = this.newLabel.trim();
			this.newLabel = '';
			if (!newLabel) {return}
			this.stats.push({
				label: newLabel,
				value: 100
			})
		},
		remove(stat) {
			const stats = this.stats;
			if (stats.length > 3) {
				this.stats = stats.filter((_stat) => _stat !== stat);
			} else {
				alert(`Can't delete more!`);
			}
		}
	}
});
document.addEventListener('DOMContentLoaded', (event) =>
	vm.$mount('#demo')
);

<body>要素

<div id="demo">
	<svg width="200" height="200">
		<polygraph :stats="stats" :circle="circle"></polygraph>
	</svg>
	<div v-for="stat in stats">
		<label>{{stat.label}}</label>
		<input type="range" v-model="stat.value" min="0" max="100">
		<span>{{stat.value}}</span>
		<button @click="remove(stat)" class="remove">X</button>
	</div>
	<form id="add">
		<input name="newlabel" v-model="newLabel">
		<button @click="add">Add a Stat</button>
	</form>
	<pre id="raw">{{stats}}</pre>
</div>

テンプレート

<script type="text/x-template" id="polygraph-template">
<g>
	<polygon :points="points"></polygon>
	<circle :cx="circle.cx" :cy="circle.cy" :r="circle.r"></circle>
	<axis-label
		v-for="(stat, index) in stats"
		:stat="stat"
		:index="index"
		:total="stats.length">
	</axis-label>
</g>
</script>
<script type="text/x-template" id="axis-label-template">
	<text :x="point.x" :y="point.y" dx="-3" dy="3">{{stat.label}}</text>
</script>

サンプル002■Vue.js + ES6: Radar chart with SVG controlled dynamically

07 v-forディレクティブにはkeyを定める

もうひとつだけ手を加えます。前掲コード002を開発用のVue.jsで試すと、コンソールにつぎのような警告が示されます。これは、v-forディレクティブを定めた要素には、key属性を与えなければならないということです(「キー付きv-for」参照)。属性値は一意とします。

[Vue tip]: <axis-label v-for="stat in stats">: component lists rendered with v-for should have explicit keys. See https://vuejs.org/guide/list.html#key for more info.

そこで、レーダーチャートのデータに一意のプロパティ(id)を加えます。いちいち手打ちせず、Array.map()メソッドでインデックスの値を与えることにしましょう。このメソッドは、引数の関数が返した要素からなる新たな配列をつくります。関数が受け取る引数は、参照したもとの配列の要素とインデックスです。<body>要素とテンプレートのv-forディレクティブを定めた要素には、一意のプロパティ値をkey属性に与えます。key属性はバインディングするため、v-bindディレクティブ(省略記法:)を添えました。

JavaScriptコード

const stats = [
	{label: 'A', value: 100},
	{label: 'B', value: 100},
	{label: 'C', value: 100},
	{label: 'D', value: 100},
	{label: 'E', value: 100},
	{label: 'F', value: 100}
].map((stat, index) => {
	stat.id = index;
	return stat;
});

<body>要素

<div id="demo">

	<div v-for="stat in stats"
		:key="stat.id">

	</div>

</div>

テンプレート

<script type="text/x-template" id="polygraph-template">
<g>

	<axis-label
		v-for="(stat, index) in stats"

		:key="stat.id">
	</axis-label>
</g>
</script>

データを加えるときも、一意のプロパティ値を与えましょう。新しいプロパティ値を返すメソッド(getNewId())は、つぎのように定めます。Array.map()メソッドですでに割り当てられている値を取り出し、Math.max()メソッドにより最大値を得て1加えれば新たな値です。ECMAScript 2015から備わったスプレッド構文...は、配列をカンマ(,)区切りの引数として渡します。

JavaScriptコード

const vm = new Vue({

	methods: {
        add(event) {

            this.stats.push({

                id: this.getNewId()
            })
        },

		getNewId() {
			const ids = this.stats.map((stat) => stat.id);
			return Math.max(...ids) + 1;
		}
	}
});

これで、コンソールの警告は出なくなります。レーダーチャートの動きそのものは変わりません。けれど、データのプロパティ(id)を増やしましたので、新たにその値が表示の中に加わりました(図010)。書きあがったコードを、改めてすべて示すのも冗長です。jsdo.itに以下のサンプル003を掲げました。jsdo.itサイトで開いて[View Diff]ボタンをクリックすれば、サンプル002のコードとの違いも確かめられます。

図010■レーダーチャートのデータに新たなプロパティが加わった

図010

サンプル003■Vue.js + ES6: Radar chart with SVG controlled dynamically - final


作成者: 野中文雄
作成日: 2018年3月29日


Copyright © 2001-2018 Fumio Nonaka.  All rights reserved.