1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【JavaScript】canvasに描画した点をドラッグで動かす (タッチにも対応)

Posted at

今回の目的

HTML の canvas 上に点を描画し、この点をドラッグで動かせるようにする。
パソコン上でのマウスを使った操作だけでなく、スマートフォンでのタッチ操作にも対応する。

実装

点を描画する

まず、描画先の canvas を用意する。

<canvas id="thecanvas" width="640" height="480"></canvas>

canvas とコンテキストを取得する。

const canvas = document.getElementById("thecanvas");
const context = canvas.getContext("2d");

ドラッグする対象の点を用意する。
点の情報として、位置 (x座標、y座標) と色を保持する。

const thingRadius = 10;
const things = [];
for (let i = 0; i < 5; i++) {
	things.push({
		x: Math.random() * canvas.width,
		y: Math.random() * canvas.height,
		color: `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`,
	});
}

点を描画する。
canvas 全体を塗りつぶした後、arc() メソッドなどを用いて丸を描画する。

function draw() {
	context.fillStyle = "white";
	context.fillRect(0, 0, canvas.width, canvas.height);
	things.forEach((t) => {
		context.fillStyle = t.color;
		context.beginPath();
		context.arc(t.x, t.y, thingRadius, 0, Math.PI * 2);
		context.fill();
	});
}

draw();

ドラッグされた時の処理を行う

ドラッグにより点を動かせるよう、

  • ドラッグが開始された時
  • ドラッグ中
  • ドラッグが終了した時

それぞれの処理を記述する。
また、これらの処理で用いる、ドラッグの情報を格納する Map を用意する。

const activePointers = new Map();

処理を行う以下の関数は、以下の引数をとる。

引数 意味
dragId ドラッグしているポインタを識別するID
x ドラッグしているポインタのx座標
y ドラッグしているポインタのy座標

座標は、クライアント座標 (ビューポートの原点を基準とした座標) を用いる。

ドラッグが開始された時

まず、どの点を移動させるか、そもそも点を移動させるかを決定する。
最初に、getBoundingClientRect() メソッドを用いて canvas の左上のクライアント座標を取得し、これを用いてポインタの canvas 上での座標を求める。
この座標とそれぞれの点の座標を比較し、一番近い点を移動させることにした。
また、一番近い点でも距離が点の半径を超える場合は、点を移動させないことにした。

移動させる点が決まったら、ポインタのIDをキーとして、以下の情報を Map に格納する。

  • 移動させる点 (オブジェクト)
  • 移動を開始する時のポインタの座標
  • 移動を開始する時の点の座標
function beginDrag(dragId, x, y) {
	const canvasRect = canvas.getBoundingClientRect();
	const offsetX = x - canvasRect.x, offsetY = y - canvasRect.y;
	let target = null, targetScore = 0;
	things.forEach((t) => {
		const score = Math.sqrt((t.x - offsetX) * (t.x - offsetX) + (t.y - offsetY) * (t.y - offsetY));
		if (target === null || score < targetScore) {
			target = t;
			targetScore = score;
		}
	});
	if (target !== null && targetScore <= thingRadius) {
		activePointers.set(dragId, {
			target,
			pointerSx: x,
			pointerSy: y,
			targetSx: target.x,
			targetSy: target.y,
		});
		return true;
	}
	return false;
}

ドラッグ中

「ドラッグ開始時の点の座標」に、「今のポインタの座標とドラッグ開始時のポインタの座標の差」を足すことで、ドラッグ後の点の座標を求める。
そして、求めた座標を点に設定し、再描画を行う。

前回の移動時の座標ではなくドラッグ開始時の座標を基準にすることで、点が移動可能な範囲 (今回は canvas の範囲) の端に到達してポインタから離れても、その後またポインタを戻せば再び点がポインタに追従することができるようになる。

function moveDrag(dragId, x, y) {
	const info = activePointers.get(dragId);
	if (info) {
		info.target.x = Math.max(0, Math.min(canvas.width, info.targetSx + x - info.pointerSx));
		info.target.y = Math.max(0, Math.min(canvas.height, info.targetSy + y - info.pointerSy));
		draw();
		return true;
	}
	return false;
}

ドラッグが終了した時

終了したドラッグのIDに対応する情報を Map から削除する。
点の移動は「ドラッグ中」で行っているため、ここで行うことは無い。

function endDrag(dragId) {
	activePointers.delete(dragId);
}

実際のイベントを処理する

イベントが発生した際、上記のメソッドを呼び出すことで、実際のイベントの発生時に点の移動処理を行うようにする。

マウスによるドラッグを処理する

ポインターイベントを用いて、マウス (などのポインティングデバイス) によるドラッグを処理する。

ポインターイベントのリスナーには、PointerEvent オブジェクトが渡される。
このオブジェクトは、ポインターのIDである pointerId プロパティを持つ。
さらに、PointerEvent クラスは MouseEvent クラスを継承しており、この MouseEvent クラスにポインターの座標 (クライアント座標) を表す x プロパティおよび y プロパティがある。

ポインターイベントとして、具体的には以下のものがある。

状況 イベント
ドラッグ開始 pointerdown
ドラッグ中 pointermove
ドラッグ終了 pointerup
pointercancel

以下のコードでは、ポインターの識別子にポインターを表す p: を追加したものをIDとして渡すことで、タッチイベントと区別している。
pointerdown イベントでは、押されたのが主ボタン (左ボタン) であるかを button プロパティを用いて判定し、そうであればドラッグの処理を開始するとともに setPointerCapture() メソッドを用いてポインターがドラッグ中に canvas を外れてもドラッグの処理を続けることができるようにしている。

canvas.addEventListener("pointerdown", (event) => {
	if (event.button === 0) {
		if (beginDrag("p:" + event.pointerId, event.x, event.y)) {
			canvas.setPointerCapture(event.pointerId);
		}
	}
});

canvas.addEventListener("pointermove", (event) => {
	moveDrag("p:" + event.pointerId, event.x, event.y);
});

canvas.addEventListener("pointerup", (event) => {
	endDrag("p:" + event.pointerId);
});

canvas.addEventListener("pointercancel", (event) => {
	endDrag("p:" + event.pointerId);
});

タッチによるドラッグを処理する

タッチイベントを用いて、(スマートフォンなどでの) タッチによるドラッグを処理する。

タッチイベントのリスナーには、TouchEvent オブジェクトが渡される。
このオブジェクトは、イベントによって変化したタッチ点のリストである changedTouches プロパティを持つ。
このリストに対しては、forEach は使えないようである。
このリストの要素は Touch オブジェクトである。この Touch オブジェクトは、識別子を表す identifier プロパティと、タッチされた座標 (クライアント座標) を表す clientX プロパティおよび clientY プロパティを持つ。

タッチイベントとして、具体的には以下のものがある。

状況 イベント
ドラッグ開始 touchstart
ドラッグ中 touchmove
ドラッグ終了 touchend
touchcancel

以下のコードでは、それぞれのリスナーで changedTouches プロパティに格納されたリストの要素を for 文で走査し、変化したそれぞれのタッチ点を処理する。
タッチ点の識別子にタッチを表す t: を追加したものをIDとして用いることで、ポインターイベントと区別している。
さらに、touchstart イベントおよび touchmove イベントでは、ドラッグの処理を行った場合は preventDefault() メソッドを呼び出してタッチに対する通常の反応を行わないようにしている。
すなわち、タッチにより通常行われるスクロールなどの操作を行わず、ドラッグの操作としてのみ扱うようにしている。
これらのイベントリスナーで preventDefault() を呼び出さないと、点をドラッグしようとした際にスクロールなどが発生し、うまくドラッグによる移動ができないことがある。

canvas.addEventListener("touchstart", (event) => {
	let processed = false;
	for (let i = 0; i < event.changedTouches.length; i++) {
		const t = event.changedTouches[i];
		if (beginDrag("t:" + t.identifier, t.clientX, t.clientY)) processed = true;
	}
	if (processed) event.preventDefault();
});

canvas.addEventListener("touchmove", (event) => {
	let processed = false;
	for (let i = 0; i < event.changedTouches.length; i++) {
		const t = event.changedTouches[i];
		if (moveDrag("t:" + t.identifier, t.clientX, t.clientY)) processed = true;
	}
	if (processed) event.preventDefault();
});

canvas.addEventListener("touchend", (event) => {
	for (let i = 0; i < event.changedTouches.length; i++) {
		endDrag("t:" + event.changedTouches[i].identifier);
	}
});

canvas.addEventListener("touchcancel", (event) => {
	for (let i = 0; i < event.changedTouches.length; i++) {
		endDrag("t:" + event.changedTouches[i].identifier);
	}
});

デモ

See the Pen drag test by MikeCAT (@mike_cat) on CodePen.

1ファイルで動くサンプル
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>点ドラッグテスト</title>
<style>
#thecanvas {
	border: 1px solid black;
}
</style>
</head>
<body>
<p>
<canvas id="thecanvas" width="640" height="480"></canvas>
</p>
<script>
"use strict";

const canvas = document.getElementById("thecanvas");
const context = canvas.getContext("2d");

const thingRadius = 10;
const things = [];
for (let i = 0; i < 5; i++) {
	things.push({
		x: Math.random() * canvas.width,
		y: Math.random() * canvas.height,
		color: `rgb(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255})`,
	});
}

function draw() {
	context.fillStyle = "white";
	context.fillRect(0, 0, canvas.width, canvas.height);
	things.forEach((t) => {
		context.fillStyle = t.color;
		context.beginPath();
		context.arc(t.x, t.y, thingRadius, 0, Math.PI * 2);
		context.fill();
	});
}

draw();

const activePointers = new Map();

function beginDrag(dragId, x, y) {
	const canvasRect = canvas.getBoundingClientRect();
	const offsetX = x - canvasRect.x, offsetY = y - canvasRect.y;
	let target = null, targetScore = 0;
	things.forEach((t) => {
		const score = Math.sqrt((t.x - offsetX) * (t.x - offsetX) + (t.y - offsetY) * (t.y - offsetY));
		if (target === null || score < targetScore) {
			target = t;
			targetScore = score;
		}
	});
	if (target !== null && targetScore <= thingRadius) {
		activePointers.set(dragId, {
			target,
			pointerSx: x,
			pointerSy: y,
			targetSx: target.x,
			targetSy: target.y,
		});
		return true;
	}
	return false;
}

function moveDrag(dragId, x, y) {
	const info = activePointers.get(dragId);
	if (info) {
		info.target.x = Math.max(0, Math.min(canvas.width, info.targetSx + x - info.pointerSx));
		info.target.y = Math.max(0, Math.min(canvas.height, info.targetSy + y - info.pointerSy));
		draw();
		return true;
	}
	return false;
}

function endDrag(dragId) {
	activePointers.delete(dragId);
}

canvas.addEventListener("pointerdown", (event) => {
	if (event.button === 0) {
		if (beginDrag("p:" + event.pointerId, event.x, event.y)) {
			canvas.setPointerCapture(event.pointerId);
		}
	}
});

canvas.addEventListener("pointermove", (event) => {
	moveDrag("p:" + event.pointerId, event.x, event.y);
});

canvas.addEventListener("pointerup", (event) => {
	endDrag("p:" + event.pointerId);
});

canvas.addEventListener("pointercancel", (event) => {
	endDrag("p:" + event.pointerId);
});

canvas.addEventListener("touchstart", (event) => {
	let processed = false;
	for (let i = 0; i < event.changedTouches.length; i++) {
		const t = event.changedTouches[i];
		if (beginDrag("t:" + t.identifier, t.clientX, t.clientY)) processed = true;
	}
	if (processed) event.preventDefault();
});

canvas.addEventListener("touchmove", (event) => {
	let processed = false;
	for (let i = 0; i < event.changedTouches.length; i++) {
		const t = event.changedTouches[i];
		if (moveDrag("t:" + t.identifier, t.clientX, t.clientY)) processed = true;
	}
	if (processed) event.preventDefault();
});

canvas.addEventListener("touchend", (event) => {
	for (let i = 0; i < event.changedTouches.length; i++) {
		endDrag("t:" + event.changedTouches[i].identifier);
	}
});

canvas.addEventListener("touchcancel", (event) => {
	for (let i = 0; i < event.changedTouches.length; i++) {
		endDrag("t:" + event.changedTouches[i].identifier);
	}
});

</script>
</body>
</html>

まとめ

まずドラッグ操作によって点を動かすための共通のメソッドを定義し、これらのメソッドをポインターイベントのリスナーとタッチイベントのリスナーからそれぞれ呼び出すことで、ポインターでもタッチでもドラッグで点を動かせるようにした。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?