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

# 今回の目的

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

# 実装

## 点を描画する

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

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

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

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

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

```javascript
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() メソッド](https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/arc)などを用いて丸を描画する。

```javascript
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 を用意する。

```javascript
const activePointers = new Map();
```

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

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

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

### ドラッグが開始された時

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

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

* 移動させる点 (オブジェクト)
* 移動を開始する時のポインタの座標
* 移動を開始する時の点の座標

```javascript
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 の範囲) の端に到達してポインタから離れても、その後またポインタを戻せば再び点がポインタに追従することができるようになる。

```javascript
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 から削除する。
点の移動は「ドラッグ中」で行っているため、ここで行うことは無い。

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

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

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

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

[ポインターイベント](https://developer.mozilla.org/ja/docs/Web/API/Pointer_events)を用いて、マウス (などのポインティングデバイス) によるドラッグを処理する。

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

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

|状況|イベント|
|:-:|:-:|
|ドラッグ開始|[pointerdown](https://developer.mozilla.org/ja/docs/Web/API/Element/pointerdown_event)|
|ドラッグ中|[pointermove](https://developer.mozilla.org/ja/docs/Web/API/Element/pointermove_event)|
|ドラッグ終了|[pointerup](https://developer.mozilla.org/ja/docs/Web/API/Element/pointerup_event)<br>[pointercancel](https://developer.mozilla.org/ja/docs/Web/API/Element/pointercancel_event)|

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

```javascript
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);
});
```

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

[タッチイベント](https://developer.mozilla.org/ja/docs/Web/API/Touch_events)を用いて、(スマートフォンなどでの) タッチによるドラッグを処理する。

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

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

|状況|イベント|
|:-:|:-:|
|ドラッグ開始|[touchstart](https://developer.mozilla.org/ja/docs/Web/API/Element/touchstart_event)|
|ドラッグ中|[touchmove](https://developer.mozilla.org/ja/docs/Web/API/Element/touchmove_event)|
|ドラッグ終了|[touchend](https://developer.mozilla.org/ja/docs/Web/API/Element/touchend_event)<br>[touchcancel](https://developer.mozilla.org/ja/docs/Web/API/Element/touchcancel_event)|

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

```javascript
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);
	}
});
```

# デモ

<p class="codepen" data-height="600" data-default-tab="result" data-slug-hash="xxvpvWZ" data-pen-title="drag test" data-user="mike_cat" style="height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/mike_cat/pen/xxvpvWZ">
  drag test</a> by MikeCAT (<a href="https://codepen.io/mike_cat">@mike_cat</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

<details><summary>1ファイルで動くサンプル</summary>

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

</details>

# まとめ

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