【canvas】図形の選択と移動処理の実装

最終更新日:2024-09-30

前回投稿した記事ではcanvas要素に基本的な図形をただ描画するだけでした。今回は円と四角形の図形をオブジェクト化して、それぞれクリック(スマホ上ではタップ)して選択状態に遷移させて、選択状態のオブジェクトはドラッグして移動できるように処理を実装していきます。

完成形

 今回の機能を実装したコードになります。描画したい図形をそれぞれオブジェクトとして定義しています。各オブジェクトは自身の描画情報、状態を管理するためのフラグなどを持っています。

ある図形がクリック(またはタップ)されると、その図形は選択状態に状態が遷移して、その状態のままさらにドラッグするとそれに連動して図形も移動します。ドラッグ中のマウス位置(またはタップ位置)が変化する毎に、選択された図形の位置情報も更新され、位置情報が更新される毎に画面をクリアして全図形の再描画が行われ、図形がドラッグされているように見せています。

const ObjStatus = Object.freeze({
    NONE: 0,
    READY: 1,
    SELECT: 2,
    MOVE: 3,
});
const ObjShapeType = Object.freeze({
    CIRCLE: 1,
    RECTANGLE: 2,
});

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

var positionOffsetX = 0;
var positionOffsetY = 0;
var selectObjectNum = -1;

var objectsData = [
    {
        shapeType: ObjShapeType.CIRCLE,
        x: 120,
        y: 160,
        color: 'magenta',
        isTouch: false,
        status: ObjStatus.NONE,
        radius: 80
    },
    {
        shapeType: ObjShapeType.RECTANGLE,
        x: 280,
        y: 85,
        color: 'lime',
        isTouch: false,
        status: ObjStatus.NONE,
        w: 150,
        h: 150
    }
];

// ****************** 描画に関する関数 ******************
function drawCircle(circle) {
    ctx.beginPath();
    ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2);
    ctx.fillStyle = circle.color;
    ctx.fill();
    ctx.closePath();
}
function drawRectangle(rectangle) {
    ctx.fillStyle = rectangle.color;
    ctx.fillRect(rectangle.x, rectangle.y, rectangle.w, rectangle.h);
}
function drawObjects() {
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    for (let index = 0; index < objectsData.length; index++) {
        switch (objectsData[index].shapeType) {
            case ObjShapeType.CIRCLE: drawCircle(objectsData[index]); break;
            case ObjShapeType.RECTANGLE: drawRectangle(objectsData[index]); break;
            default:
                break;
        }
        if (objectsData[index].status >= ObjStatus.SELECT) {
            drawObjGuideLine(objectsData[index]);
            drawObjPivots(objectsData[index]);
        }
    }
}

const guideLineDiff = 10;
function drawRectangleGuideLine(rectangle) {
    ctx.strokeStyle = "skyblue";
    ctx.strokeRect(
        rectangle.x - guideLineDiff,
        rectangle.y - guideLineDiff,
        rectangle.w + (guideLineDiff * 2),
        rectangle.h + (guideLineDiff * 2));
}
function drawCircleGuideLine(circle) {
    const whLength = (circle.radius * 2) + (guideLineDiff * 2);
    const xyDiff = circle.radius + guideLineDiff;
    ctx.strokeStyle = "skyblue";
    ctx.strokeRect(
        circle.x - xyDiff,
        circle.y - xyDiff,
        whLength,
        whLength);
}
function drawObjGuideLine(obj) {
    switch (obj.shapeType) {
        case ObjShapeType.CIRCLE: drawCircleGuideLine(obj); break;
        case ObjShapeType.RECTANGLE: drawRectangleGuideLine(obj); break;
        default:
            break;
    }
}

function drawRectanglePivots(rectangle) {
    const pivotLength = 8;
    ctx.fillStyle = "skyblue";
    ctx.fillRect(
        rectangle.x - guideLineDiff,
        rectangle.y - guideLineDiff,
        pivotLength, pivotLength);
    ctx.fillRect(
        rectangle.x - guideLineDiff + (pivotLength / 2) + (rectangle.w / 2),
        rectangle.y - guideLineDiff,
        pivotLength, pivotLength);
    ctx.fillRect(
        rectangle.x + guideLineDiff - pivotLength + rectangle.w,
        rectangle.y - guideLineDiff,
        pivotLength, pivotLength);
            
    ctx.fillRect(
        rectangle.x - guideLineDiff,
        rectangle.y - guideLineDiff + (pivotLength / 2) + (rectangle.h / 2),
        pivotLength, pivotLength);
    ctx.fillRect(
        rectangle.x + guideLineDiff - pivotLength + rectangle.w,
        rectangle.y - guideLineDiff + (pivotLength / 2) + (rectangle.h / 2),
        pivotLength, pivotLength);
            
    ctx.fillRect(
        rectangle.x - guideLineDiff,
        rectangle.y + guideLineDiff - pivotLength + rectangle.h,
        pivotLength, pivotLength);
    ctx.fillRect(
        rectangle.x - guideLineDiff + (pivotLength / 2) + (rectangle.w / 2),
        rectangle.y + guideLineDiff - pivotLength + rectangle.h,
        pivotLength, pivotLength);
    ctx.fillRect(
        rectangle.x + guideLineDiff - pivotLength + rectangle.w,
        rectangle.y + guideLineDiff - pivotLength + rectangle.h,
        pivotLength, pivotLength);
}
function drawCirclePivots(circle) {
    const pivotLength = 8;
    ctx.fillStyle = "skyblue";
    ctx.fillRect(
        circle.x - circle.radius - guideLineDiff,
        circle.y - circle.radius - guideLineDiff,
        pivotLength, pivotLength);
    ctx.fillRect(
        circle.x - (pivotLength / 2),
        circle.y - circle.radius - guideLineDiff,
        pivotLength, pivotLength);
    ctx.fillRect(
        circle.x + circle.radius + guideLineDiff - pivotLength,
        circle.y - circle.radius - guideLineDiff,
        pivotLength, pivotLength);
            
    ctx.fillRect(
        circle.x - circle.radius - guideLineDiff,
        circle.y - (pivotLength / 2),
        pivotLength, pivotLength);
    ctx.fillRect(
        circle.x + circle.radius + guideLineDiff - pivotLength,
        circle.y - (pivotLength / 2),
        pivotLength, pivotLength);
            
    ctx.fillRect(
        circle.x - circle.radius - guideLineDiff,
        circle.y + circle.radius + guideLineDiff - pivotLength,
        pivotLength, pivotLength);
    ctx.fillRect(
        circle.x - (pivotLength / 2),
        circle.y + circle.radius + guideLineDiff - pivotLength,
        pivotLength, pivotLength);
    ctx.fillRect(
        circle.x + circle.radius + guideLineDiff - pivotLength,
        circle.y + circle.radius + guideLineDiff - pivotLength,
        pivotLength, pivotLength);
}
function drawObjPivots(obj) {
    switch (obj.shapeType) {
        case ObjShapeType.CIRCLE: drawCirclePivots(obj); break;
        case ObjShapeType.RECTANGLE: drawRectanglePivots(obj); break;
        default:
            break;
    }
}

        
// 初期描画
drawObjects();
        
// ****************** クリックイベントリスナーを追加 ******************
function mtDown(event) {
    event.preventDefault();
    var rect = canvas.getBoundingClientRect();
    var mouseX = 0;
    var mouseY = 0;
    if (event.type === 'mousedown') {
        mouseX = event.clientX - rect.left;
        mouseY = event.clientY - rect.top;
    } else if (event.type === 'touchstart') {
        const touch = event.touches[0];
        mouseX = touch.clientX - rect.left;
        mouseY = touch.clientY - rect.top;
    }

    for (let index = objectsData.length - 1; index >= 0; index--) {
        if (isInsideObject(mouseX, mouseY, objectsData[index])) {
            selectObjectNum = index;
            objectsData[selectObjectNum].isTouch = true;
            positionOffsetX = mouseX - objectsData[selectObjectNum].x;
            positionOffsetY = mouseY - objectsData[selectObjectNum].y;
            if (objectsData[selectObjectNum].status == ObjStatus.NONE) {
                objectsData[selectObjectNum].status = ObjStatus.READY;
            }
            break;
        }
    }
}
canvas.addEventListener('mousedown', mtDown);
canvas.addEventListener('touchstart', mtDown);
        
function mtMove(event) {
    event.preventDefault();
    var rect = canvas.getBoundingClientRect();
    var mouseX = 0;
    var mouseY = 0;
    if (event.type === 'mousemove') {
        mouseX = event.clientX - rect.left;
        mouseY = event.clientY - rect.top;
    } else if (event.type === 'touchmove') {
        const touch = event.touches[0];
        mouseX = touch.clientX - rect.left;
        mouseY = touch.clientY - rect.top;
    }

    for (let index = 0; index < objectsData.length; index++) {
        if (objectsData[index].isTouch && objectsData[index].status >= ObjStatus.SELECT) {
            var diffX = mouseX - positionOffsetX;
            var diffY = mouseY - positionOffsetY;
            if (!(objectsData[index].x == diffX) || !(objectsData[index].y == diffY)) {
                objectsData[index].x = diffX;
                objectsData[index].y = diffY;
                objectsData[index].status = ObjStatus.MOVE;
            }
            // キャンバスをクリアして再描画
            drawObjects();
        }
    }
}
canvas.addEventListener('mousemove', mtMove);
canvas.addEventListener('touchmove', mtMove);
        
function mtUp(event) {
    event.preventDefault();
    var rect = canvas.getBoundingClientRect();
    var mouseX = 0;
    var mouseY = 0;
    if (event.type === 'mouseup') {
        mouseX = event.clientX - rect.left;
        mouseY = event.clientY - rect.top;
    } else if (event.type === 'touchend') {
        const touch = event.changedTouches[0];
        mouseX = touch.clientX - rect.left;
        mouseY = touch.clientY - rect.top;
    }

    for (let index = 0; index < objectsData.length; index++) {
        objectsData[index].isTouch = false;
        if (index == selectObjectNum && isInsideObject(mouseX, mouseY, objectsData[selectObjectNum])) {
            switch (objectsData[selectObjectNum].status) {
                case ObjStatus.READY:
                case ObjStatus.MOVE:
                    objectsData[selectObjectNum].status = ObjStatus.SELECT;
                    break;
                default:
                    objectsData[selectObjectNum].status = ObjStatus.NONE;
                    break;
            }
        } else {
            objectsData[index].status = ObjStatus.NONE;
        }
    }
    drawObjects();
}
canvas.addEventListener('mouseup', mtUp);
canvas.addEventListener('touchend', mtUp);
        
// 点が円の中にあるかどうかを判定する関数
function isInsideCircle(x, y, circle) {
    var dx = x - circle.x;
    var dy = y - circle.y;
    return dx * dx + dy * dy <= circle.radius * circle.radius;
}
function isInsideRectangle(x, y, rectangle) {
    return x >= rectangle.x && (rectangle.x + rectangle.w) >= x
        && y >= rectangle.y && (rectangle.y + rectangle.h) >= y;
}
function isInsideObject(x, y, obj) {
    switch (obj.shapeType) {
        case ObjShapeType.CIRCLE: return isInsideCircle(x, y, obj);
        case ObjShapeType.RECTANGLE: return isInsideRectangle(x, y, obj);
        default: return false;
    }
}

各実装の詳細は次のようになります。

オブジェクトと定数

 はじめに、描画するオブジェクトの種類と各オブジェクトの状態を管理するための定数を定義しました。

// オブジェクトの状態
const ObjStatus = Object.freeze({
    NONE: 0,    // 未選択
    READY: 1,   // 中間状態
    SELECT: 2,  // 選択状態
    MOVE: 3,    // 移動中
});
// 描画するオブジェクトの種類
const ObjShapeType = Object.freeze({
    CIRCLE: 1,    // 円
    RECTANGLE: 2, // 四角形
});

描画対象となる図形はそれぞれオブジェクトとして定義し、配列で管理します。

/**
 * 描画するオブジェクトを管理する配列<br> * 配列の後ろにあるオブジェクトが手前に描かれる
 */
var objectsData = [
    // 円のオブジェクト
    {
        shapeType: ObjShapeType.CIRCLE,   // 図形の種類
        x: 120,                           // 円の中心座標 X
        y: 160,                           // 円の中心座標 Y
        color: 'magenta',                 // 円の色
        isTouch: false,                   // オブジェクトに触れているかどうかのフラグ
        status: ObjStatus.NONE,           // オブジェクトの状態
        // 固有データ
        radius: 80   // 半径
    },
    // 四角形のオブジェクト
    {
        shapeType: ObjShapeType.RECTANGLE,
        x: 280,                           // 四角形の左上の座標 X
        y: 85,                            // 四角形の左上の座標 Y
        color: 'lime',
        isTouch: false,
        status: ObjStatus.NONE,
        // 固有データ
        w: 150,   // 幅
        h: 150    // 高さ
    }
];

描画は配列の先頭([0])にあるオブジェクトから順番に描画されるため、後ろに追加されるオブジェクトが一番手前に描画されます。

オブジェクトの描画

 一旦画面全体をclearRect関数でクリアしてからobjectsData配列をfor文で回して各オブジェクトの図形の描画を行います。

// オブジェクトを描画する関数
function drawObjects() {
    // 画面のクリア
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // 各オブジェクトを描画する
    for (let index = 0; index < objectsData.length; index++) {
        switch (objectsData[index].shapeType) {
            // 円の場合
            case ObjShapeType.CIRCLE: drawCircle(objectsData[index]); break;
            // 四角形の場合
            case ObjShapeType.RECTANGLE: drawRectangle(objectsData[index]); break;
            default:
                break;
        }
        // オブジェクトが選択されていた場合は選択表示用の枠線とピボットを描画する
        if (objectsData[index].status >= ObjStatus.SELECT) {
            drawObjGuideLine(objectsData[index]);
            drawObjPivots(objectsData[index]);
        }
    }
}

各オブジェクトのshapeTypeを見て、円の場合はdrawCircle関数を、四角形の場合はdrawRectangle関数を呼んで実際に描画を行います。

// オブジェクト(円)を描画する関数
function drawCircle(circle) {
    ctx.beginPath();
    ctx.arc(circle.x, circle.y, circle.radius, 0, Math.PI * 2);
    ctx.fillStyle = circle.color;
    ctx.fill();
    ctx.closePath();
}
// オブジェクト(四角形)を描画する関数
function drawRectangle(rectangle) {
    ctx.fillStyle = rectangle.color;
    ctx.fillRect(rectangle.x, rectangle.y, rectangle.w, rectangle.h);
}

また、描画するオブジェクトが選択されていた場合や移動中の場合には、そのオブジェクトの周りに枠線とピボット(8個の小さい四角形)を描きます。
(ただし、今回のプログラムではピボットの描画は行いますが、各ピボットを選択して図形の大きさを変化させる機能までの実装は行っていません。)

// ----- 選択オブジェクトの枠線の描画 -----
const guideLineDiff = 10;
// 四角形用の枠線描画
function drawRectangleGuideLine(rectangle) {
    ctx.strokeStyle = "skyblue";
    ctx.strokeRect(
        rectangle.x - guideLineDiff,
        rectangle.y - guideLineDiff,
        rectangle.w + (guideLineDiff * 2),
        rectangle.h + (guideLineDiff * 2));
}
// 円用の枠線描画
function drawCircleGuideLine(circle) {
    const whLength = (circle.radius * 2) + (guideLineDiff * 2);
    const xyDiff = circle.radius + guideLineDiff;
    ctx.strokeStyle = "skyblue";
    ctx.strokeRect(
        circle.x - xyDiff,
        circle.y - xyDiff,
        whLength,
        whLength);
    }
function drawObjGuideLine(obj) {
    switch (obj.shapeType) {
        case ObjShapeType.CIRCLE: drawCircleGuideLine(obj); break;
        case ObjShapeType.RECTANGLE: drawRectangleGuideLine(obj); break;
        default:
            break;
    }
}
// ----- 選択オブジェクトのピボットの描画 -----
// 円用のピボット描画
function drawRectanglePivots(rectangle) {
    const pivotLength = 8;
    ctx.fillStyle = "skyblue";
    /* 省略 */
    ctx.fillRect(
        rectangle.x + guideLineDiff - pivotLength + rectangle.w,
        rectangle.y + guideLineDiff - pivotLength + rectangle.h,
        pivotLength, pivotLength);
}
// 四角形用のピボット描画
function drawCirclePivots(circle) {
    const pivotLength = 8;
    ctx.fillStyle = "skyblue";
    /* 省略 */
    ctx.fillRect(
        circle.x + circle.radius + guideLineDiff - pivotLength,
        circle.y + circle.radius + guideLineDiff - pivotLength,
        pivotLength, pivotLength);
}
function drawObjPivots(obj) {
    switch (obj.shapeType) {
        case ObjShapeType.CIRCLE: drawCirclePivots(obj); break;
        case ObjShapeType.RECTANGLE: drawRectanglePivots(obj); break;
        default:
            break;
    }
}

枠線とピボットで円用、四角形用とそれぞれの描画関数を用意して、switch文でオブジェクトを振り分けて描画を行います。

イベントの追加

 クリック(タップ)された瞬間のイベント、ドラッグ中のイベント、クリック(タップ)が終わったタイミングのイベントそれぞれで定義を行っていきます。

クリック(タッチ)された瞬間のイベントでは、その位置情報を取得して保存すると同時にオブジェクトのステータスも更新します。

// クリック(タッチ)された瞬間のイベント
function mtDown(event) {
    event.preventDefault();
    var rect = canvas.getBoundingClientRect();
    var mouseX = 0;
    var mouseY = 0;
    // クリック時とタップ時では位置情報の取得方法が異なる。
    // イベントで取得できる位置情報は画面上の位置情報であるため、
    // canvasの位置情報を引くことで、canvas上の位置情報を取得する
    if (event.type === 'mousedown') {
        mouseX = event.clientX - rect.left;
        mouseY = event.clientY - rect.top;
    } else if (event.type === 'touchstart') {
        const touch = event.touches[0];
        mouseX = touch.clientX - rect.left;
        mouseY = touch.clientY - rect.top;
    }
    // 手前にある図形を優先して判定を行う
    for (let index = objectsData.length - 1; index >= 0; index--) {
        // 図形の範囲内か判定
        if (isInsideObject(mouseX, mouseY, objectsData[index])) {
            // 配列の何番目のオブジェクトかメモ
            selectObjectNum = index;
            // フラグの更新
            objectsData[selectObjectNum].isTouch = true;
            // 最初の位置情報を保存
            positionOffsetX = mouseX - objectsData[selectObjectNum].x;
            positionOffsetY = mouseY - objectsData[selectObjectNum].y;
            // 状態情報を更新する
            // クリックされた瞬間は、次に遷移するのが選択された状態なのか
            // 移動する状態なのか判断ができないため、READY状態にする
            if (objectsData[selectObjectNum].status == ObjStatus.NONE) {
                objectsData[selectObjectNum].status = ObjStatus.READY;
            }
            break;
        }
    }
}
canvas.addEventListener('mousedown', mtDown);   // PC用(クリックイベント)
canvas.addEventListener('touchstart', mtDown);  // スマホ用(タッチイベント)

ドラッグ中のイベントでは、現在のマウスポインタの位置情報と最初のマウスポインタの位置情報との差分を移動前のオブジェクトの位置情報に追加しています。

// ドラッグ中のイベント
function mtMove(event) {
    event.preventDefault();
    var rect = canvas.getBoundingClientRect();
    var mouseX = 0;
    var mouseY = 0;
    if (event.type === 'mousemove') {
        mouseX = event.clientX - rect.left;
        mouseY = event.clientY - rect.top;
    } else if (event.type === 'touchmove') {
        const touch = event.touches[0];
        mouseX = touch.clientX - rect.left;
        mouseY = touch.clientY - rect.top;
    }

    for (let index = 0; index < objectsData.length; index++) {
        // 選択状態または移動中のステータスのオブジェクトを判定
        if (objectsData[index].isTouch && objectsData[index].status >= ObjStatus.SELECT) {
            // 最初にクリック(タップ)された瞬間の位置からの差分(移動してた距離)を
            // 最初のオブジェクトの位置情報に追加
            // var diff = 今のマウスの位置情報 - positionOffset
            //          = 今のマウスの位置情報 - (最初のマウスの位置情報 - 最初のオブジェクトの位置情報)
            //          = (今のマウスの位置情報 - 最初のマウスの位置情報) + 最初のオブジェクトの位置情報
            //          = 移動した位置情報の差分 + 最初のオブジェクトの位置情報
            var diffX = mouseX - positionOffsetX;
            var diffY = mouseY - positionOffsetY;
            // オブジェクトをタップして選択解除する場合、
            // このif文がないと解除されない場合がある。
            //
            // ※反応の良いスマホなどでは押下して指を離すまでの間に
            //  このドラッグイベントが複数回呼ばれることがある。
            //  すると位置情報に変化が無くてもステータスがMOVEのままに
            //  なりタップしても中々選択解除されない事象が発生する。
            //  そのため位置情報に差分がある場合のみMOVEステータスにして
            //  情報を更新するようにする
            if (!(objectsData[index].x == diffX) || !(objectsData[index].y == diffY)) {
                // オブジェクトの位置情報を更新
                objectsData[index].x = diffX;
                objectsData[index].y = diffY;
                objectsData[index].status = ObjStatus.MOVE;
            }
            // キャンバスをクリアして再描画
            drawObjects();
        }
    }
}
canvas.addEventListener('mousemove', mtMove);  // PC用
canvas.addEventListener('touchmove', mtMove);  // スマホ用

クリックが終わったタイミング(またはタップして指が離れたタイミング)のイベントでは、その位置に該当するオブジェクトがあるか調べ、最初のダウンイベントで選択したオブジェクトと同じ場合はオブジェクトのステータス情報を更新して、それ以外のオブジェクトのステータスをNONEに変更しています。

function mtUp(event) {
    event.preventDefault();
    var rect = canvas.getBoundingClientRect();
    var mouseX = 0;
    var mouseY = 0;
    if (event.type === 'mouseup') {
        mouseX = event.clientX - rect.left;
        mouseY = event.clientY - rect.top;
    } else if (event.type === 'touchend') {
        const touch = event.changedTouches[0];
        mouseX = touch.clientX - rect.left;
        mouseY = touch.clientY - rect.top;
    }

    for (let index = 0; index < objectsData.length; index++) {
        objectsData[index].isTouch = false;
        // クリック(タップ)時のオブジェクト内でアップイベントが発生した場合
        // は現在のステータスに応じて次のステータスへ更新させる
        if (index == selectObjectNum && isInsideObject(mouseX, mouseY, objectsData[selectObjectNum])) {
            switch (objectsData[selectObjectNum].status) {
                case ObjStatus.READY:
                case ObjStatus.MOVE:
                    objectsData[selectObjectNum].status = ObjStatus.SELECT;
                    break;
                default:
                    objectsData[selectObjectNum].status = ObjStatus.NONE;
                    break;
            }
        } else {
            objectsData[index].status = ObjStatus.NONE;
        }
    }
    // キャンバスをクリアして再描画
    drawObjects();
}
canvas.addEventListener('mouseup', mtUp);  // PC用
canvas.addEventListener('touchend', mtUp); // スマホ用

位置情報がオブジェクト内の領域にあるか判定する関数

画面をクリック(タップ)した位置がオブジェクトの領域内にあるかどうかを判定する関数を定義しました。

// 円の中にあるか判定する関数
function isInsideCircle(x, y, circle) {
    var dx = x - circle.x;
    var dy = y - circle.y;
    return dx * dx + dy * dy <= circle.radius * circle.radius;
}
// 矩形の中にあるか判定する関数
function isInsideRectangle(x, y, rectangle) {
    return x >= rectangle.x && (rectangle.x + rectangle.w) >= x
        && y >= rectangle.y && (rectangle.y + rectangle.h) >= y;
}