【入門者向け】Canvas入門講座#19 簡易ペイントを作ってみよう【JavaScript】

#問題19

いよいよ最終問題です。要件(満たしてほしい条件)を箇条書きします。
[言葉の定義]
四角形と円をまとめて「オブジェクト」と呼ぶことにします。
[要件]
・canvasの背景は#c0c0c0とすること
・modeがdraw lineの時は、線分群が描画できること(色(color)、線(lineWidth)の太さは描画時の設定内容とする)
・modeがmove objectの時は、オブジェクトがドラッグできること
・modeがdelete objectの時は、クリックしたオブジェクトが削除されること
・modeがcreate rectの時は、クリック位置に四角形が作成されること(色(color)、1辺の長さ(size)は描画時の設定内容とする)
・modeがcreate circleの時は、クリック位置に円が作成されること(色(color)、直径(size)は描画時の設定内容とする)

・オブジェクトは作成した順に手前から表示されること(最後に作成したオブジェクトが一番手前)
・線分群はオブジェクトより常に手前に描画されること
・描いた内容を保存できること
・保存した内容を復元できること

以下のHTMLを使用すること。
事前に準備されているプログラム(イベントハンドラなど)は使わなくてもよい。書き換えてもよい。

```html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>問題19</title>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<style>
#object-canvas, #line-canvas {
    position: absolute;
    left: 0px;
    top: 0px;
}
#settings-div {
    position: absolute;
    left: 0px;
    top: 500px;
}
</style>
<script>
$(() => {
    $('#line-canvas').mousedown(e => {
    });
    
    $('#line-canvas').mousemove(e => {
    });

    $('#line-canvas').mouseup(e => {
    });

    $('#line-canvas').mouseout(e => {
    });

    $('#line-canvas').click(e => {
    });

    $('#mode-select').change(e => {
    });

    $('#color-select').change(e => {
    });

    $('#line-width-select').change(e => {
    });

    $('#size-select').change(e => {
    });

    $('#save-button').click(e => {
    });

    $('#load-button').click(e => {
    });  
});
</script>
</head>
<body>
    <canvas id="object-canvas" width="720" height="480"></canvas>
    <canvas id="line-canvas" width="720" height="480"></canvas>
    <div id="settings-div">
        mode:&nbsp;
        <select id="mode-select">
            <option value="draw-line" selected>draw line</option>
            <option value="move-object">move object</option>
            <option value="delete-object">delete object</option>
            <option value="create-rect">create rect</option>
            <option value="create-circle">create circle</option>
        </select>
        <br><br>
        color:&nbsp;
        <select id="color-select">
            <option value="#000000" selected>black(#000000)</option>
            <option value="#ff0000">red(#ff0000)</option>
            <option value="#00ff00">green(#00ff00)</option>
            <option value="#0000ff">blue(#0000ff)</option>
        </select>
        &nbsp;
        lineWidth:&nbsp;
        <select id="line-width-select">
            <option value="1" selected>1</option>
            <option value="2">2</option>
            <option value="5">5</option>
            <option value="10">10</option>
            <option value="20">20</option>
        </select>
        &nbsp;
        size:&nbsp;
        <select id="size-select">
            <option value="10" selected>10</option>
            <option value="20">20</option>
            <option value="50">50</option>
            <option value="100">100</option>
        </select>
        <br><br>
        <button id="save-button">save</button>
        &nbsp;
        <button id="load-button">load</button>
    </div>
</body>
</html>
```

#ヒント
線分群は#line-canvasにオブジェクトは#object-canvasに描画すると
常に線分がオブジェクトの前に描画されます。

#解答

```html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>問題19</title>
<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
<style>
#object-canvas, #line-canvas {
    position: absolute;
    left: 0px;
    top: 0px;
}
#settings-div {
    position: absolute;
    left: 0px;
    top: 500px;
}
</style>
<script>
$(() => {
    // _で始まる変数はスコープが外側である
    const _FILL_BACKGROUND_STYLE = '#c0c0c0',   // canvasの背景色            
        _LOCAL_STORAGE_KEY = 'q19'; // ローカルストレージのキー

    let _mode,      // modeを保持
        _color,     // colorを保持
        _lineWidth, // line-widthを保持
        _size,      // sizeを保持
        _data = {   // データを保持
            linesArray: [], // 線分群の配列
            objects: []
        },
        _drawLineFlag = false,  // 線分を描画中かどうかのフラグ
        _movingObject = null,   // ドラッグ中のオブジェクト
        _delta = { x: 0, y: 0 };

    init();

    // mousedown時の処理
    $('#line-canvas').mousedown(e => {
        if(_mode !== 'draw-line' && _mode !== 'move-object') {// 線分描画モード且つオブジェクト動作モードでない
            return;
        }
        // カーソルの座標を取得
        const cursorX = e.offsetX,
            cursorY = e.offsetY;

        if(_mode === 'draw-line') {

            _drawLineFlag = true;

            // 現在の色と線の幅と現在の座標を格納
            _data.linesArray.push({
                color: _color,
                lineWidth: _lineWidth,
                positions: [
                    { x: cursorX, y: cursorY }
                ]
            });
        } else if(_mode === 'move-object') {

            const ret = hitObjects(_data.objects, cursorX, cursorY);

            if(!ret) {// オブジェクトがヒットしなかった
                return;
            }

            _movingObject = ret.obj;

            // ダウン位置を始点とし、円の中心位置を終点とするベクトルを求める
            _delta = {
                x: _movingObject.x - cursorX,
                y: _movingObject.y - cursorY
            };
        }
    });

    // mousemove時の処理
    $('#line-canvas').mousemove(e => {
        if(_mode !== 'draw-line' && _mode !== 'move-object') {// 線分描画モード且つオブジェクト動作モードでない
            return;
        }
        // カーソルの座標を取得
        const cursorX = e.offsetX,
            cursorY = e.offsetY;

        if(_mode === 'draw-line') {
            
            if(!_drawLineFlag) {// 線分描画中でない
                return;
            }

            // 現在の座標を追加
            const lastLines = _data.linesArray[_data.linesArray.length - 1],
                positions = lastLines.positions;
            positions.push({ x: cursorX, y: cursorY });

            // 1つ前の点と現在の点とを描画
            const prePos = positions[positions.length - 2];
            const curPos = positions[positions.length - 1];
            drawLine(prePos.x, prePos.y, curPos.x, curPos.y, lastLines.color, lastLines.lineWidth);
        } else if(_mode === 'move-object') {
            if(!_movingObject) {// オブジェクト動作中でない
                return;
            }
            _movingObject.x = cursorX + _delta.x;
            _movingObject.y = cursorY + _delta.y;
            drawObjects(_data.objects);
        }
        
    });

    // mouseup時の処理
    $('#line-canvas').mouseup(e => {
        _drawLineFlag = false;
        _movingObject = null;
    });

    // mouseout時の処理
    $('#line-canvas').mouseout(e => {
        _drawLineFlag = false;
        _movingObject = null;
    });

    // click時の処理
    $('#line-canvas').click(e => {
        // カーソルの座標を取得
        const cursorX = e.offsetX,
            cursorY = e.offsetY;

        if(_mode === 'delete-object') {
            const ret = hitObjects(_data.objects, cursorX, cursorY);
            if(!ret) {// オブジェクトがヒットしなかった
                return;
            }
            // オブジェクト配列からヒットしたオブジェクトを削除
            _data.objects.splice(ret.index, 1);

            // オブジェクトをすべて描画
            drawObjects(_data.objects);
        } else if(_mode === 'create-rect') {
            // 四角形を追加
            _data.objects.push({
                type: 'rect',
                color: _color,
                size: _size,
                x: cursorX,
                y: cursorY
            });

            // オブジェクトをすべて描画
            drawObjects(_data.objects);
        } else if(_mode === 'create-circle') {
            // 円を追加
            _data.objects.push({
                type: 'circle',
                color: _color,
                size: _size,
                x: cursorX,
                y: cursorY
            });

            // オブジェクトをすべて描画
            drawObjects(_data.objects);
        }
    });

    // <select>に変化があった時の処理
    $('select').change(getSettings);

    // saveボタン押下時の処理
    $('#save-button').click(e => {
        const item = JSON.stringify(_data);
        localStorage.setItem(_LOCAL_STORAGE_KEY, item); 
    });

    // loadボタン押下時の処理
    $('#load-button').click(e => {
        loadData();
        drawLinesArray(_data.linesArray);
        drawObjects(_data.objects);
    });  

    // 初期化関数
    function init() {
        getSettings();
        drawLinesArray(_data.linesArray);
        drawObjects(_data.objects);
    }

    // データをロードする
    function loadData() {
        const item = localStorage.getItem(_LOCAL_STORAGE_KEY);
        if(!item) {
            return;
        }
        _data = JSON.parse(item);
    }

    // オブジェクト配列を逆順に辿ってヒットすればそのオブジェクトを返す
    function hitObjects(objects, posX, posY) {
        let ret = null;
        for(let i = objects.length - 1; i >= 0; i -= 1) {
            const obj = objects[i];
            if(obj.type === 'rect') {// rect
                const minX = obj.x - obj.size / 2,
                    maxX = obj.x + obj.size / 2,
                    minY = obj.y - obj.size / 2,
                    maxY = obj.y + obj.size / 2;
                if(minX <= posX && posX <= maxX && minY <= posY && posY <= maxY) {
                    ret = { obj: obj, index: i };
                    break;
                }
            } else if(obj.type === 'circle') {// circle
                const dist = Math.sqrt((posX - obj.x) * (posX - obj.x) + (posY - obj.y) * (posY - obj.y));
                if(dist <= obj.size / 2) {
                    ret = { obj: obj, index: i };
                    break;
                }
            }
        }
        return ret;
    }

    // 設定内容を変数に反映
    function getSettings() {
        _mode = $('#mode-select').val();
        _color = $('#color-select').val();
        _lineWidth = parseInt($('#line-width-select').val(), 10);
        _size = parseInt($('#size-select').val(), 10);
    }

    // 線分(1つ)を描画
    function drawLine(startX, startY, endX, endY, color, lineWidth) {
        // コンテキストを取得
        const ctx = $('#line-canvas')[0].getContext('2d');

        // 現在の描画状態を保存する
        ctx.save();   

        ctx.lineCap = 'round';
        ctx.lineWidth = lineWidth;
        ctx.strokeStyle = color;

        ctx.beginPath();                // 現在のパスをリセットする
        ctx.moveTo(startX, startY);     // パスの開始座標を指定する
        ctx.lineTo(endX, endY);         // 座標を指定してラインを引く
        ctx.stroke();                   // 現在のパスを描画する

        // 描画状態を保存した時点のものに戻す
        ctx.restore();
    }

    // 線分を全て描画
    function drawLinesArray(linesArray) {
        // コンテキストを取得
        const ctx = $('#line-canvas')[0].getContext('2d');

        // 現在の描画状態を保存する
        ctx.save();   

        // canvasをクリアする
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        linesArray.forEach(lines => {
            ctx.lineCap = 'round';
            ctx.lineWidth = lines. lineWidth;
            ctx.strokeStyle = lines.color;

            for(let i = 0; i < lines.positions.length - 1; i += 1) {
                const startPos = lines.positions[i],
                    endPos = lines.positions[i + 1];
                ctx.beginPath();                // 現在のパスをリセットする
                ctx.moveTo(startPos.x, startPos.y);     // パスの開始座標を指定する
                ctx.lineTo(endPos.x, endPos.y);         // 座標を指定してラインを引く
                ctx.stroke();                   // 現在のパスを描画する
            }
        });

        // 描画状態を保存した時点のものに戻す
        ctx.restore();
    }

    // オブジェクトをすべて描画
    function drawObjects(objects) {
        // コンテキストを取得
        const ctx = $('#object-canvas')[0].getContext('2d');

        // 現在の描画状態を保存する
        ctx.save();   

        // 塗りつぶしの色を設定
        ctx.fillStyle = _FILL_BACKGROUND_STYLE;

        // 塗りつぶす
        ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

        objects.forEach(obj => {
            // 現在の描画状態を保存する
            ctx.save();   
            
            ctx.setTransform(1, 0, 0, 1, obj.x, obj.y);
            ctx.fillStyle = obj.color;
            if(obj.type === 'rect') {
                ctx.fillRect(-obj.size / 2, -obj.size / 2, obj.size, obj.size);
            } else if(obj.type === 'circle') {
                ctx.beginPath();        // 現在のパスをリセットする
                ctx.arc(0, 0, obj.size / 2, 0, Math.PI*2, true);  // 円を描画する
                ctx.closePath();        // パスを閉じる  
                ctx.fill();          
            }            

            // 描画状態を保存した時点のものに戻す
            ctx.restore();
        });

        // 描画状態を保存した時点のものに戻す
        ctx.restore();
    }
});
</script>
</head>
<body>
    <canvas id="object-canvas" width="720" height="480"></canvas>
    <canvas id="line-canvas" width="720" height="480"></canvas>
    <div id="settings-div">
        mode:&nbsp;
        <select id="mode-select">
            <option value="draw-line" selected>draw line</option>
            <option value="move-object">move object</option>
            <option value="delete-object">delete object</option>
            <option value="create-rect">create rect</option>
            <option value="create-circle">create circle</option>
        </select>
        <br><br>
        color:&nbsp;
        <select id="color-select">
            <option value="#000000" selected>black(#000000)</option>
            <option value="#ff0000">red(#ff0000)</option>
            <option value="#00ff00">green(#00ff00)</option>
            <option value="#0000ff">blue(#0000ff)</option>
        </select>
        &nbsp;
        lineWidth:&nbsp;
        <select id="line-width-select">
            <option value="1" selected>1</option>
            <option value="2">2</option>
            <option value="5">5</option>
            <option value="10">10</option>
            <option value="20">20</option>
        </select>
        &nbsp;
        size:&nbsp;
        <select id="size-select">
            <option value="10" selected>10</option>
            <option value="20">20</option>
            <option value="50">50</option>
            <option value="100">100</option>
        </select>
        <br><br>
        <button id="save-button">save</button>
        &nbsp;
        <button id="load-button">load</button>
    </div>
</body>
</html>
```

簡易ペイント完成です！！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163605/b17b2525-d6c0-b4fa-5913-9d964d389be8.png)

#解説
今回はcanvasを2つ重ねています。一般的にレイヤーと言われます。
手前のcanvasが「線分群レイヤー」
奥のcanvasが「オブジェクト群レイヤー」となります。

```html
<canvas id="object-canvas" width="720" height="480"></canvas>
<canvas id="line-canvas" width="720" height="480"></canvas>
```

canvasのイベント処理は#line-canvasのみで行っております。

```js
$('#line-canvas').mousedown(e => {
});
    
$('#line-canvas').mousemove(e => {
});

$('#line-canvas').mouseup(e => {
});

$('#line-canvas').mouseout(e => {
});

$('#line-canvas').click(e => {
});
```

描画関数ですが
drawLineは1本の線分を描画する関数(#line-canvasに描画)で
ドラッグによる線分描画中に呼ばれます。
drawLinesArrayはクリア後全ての線分群を描画する関数です。(#line-canvasに描画)
drawObjectsは背景色で塗りつぶした後、すべてのオブジェクトを描画します。(#object-canvasに描画)

```js
// 線分(1つ)を描画
function drawLine(startX, startY, endX, endY, color, lineWidth) {
    // コンテキストを取得
    const ctx = $('#line-canvas')[0].getContext('2d');

    // 現在の描画状態を保存する
    ctx.save();   

    ctx.lineCap = 'round';
    ctx.lineWidth = lineWidth;
    ctx.strokeStyle = color;

    ctx.beginPath();                // 現在のパスをリセットする
    ctx.moveTo(startX, startY);     // パスの開始座標を指定する
    ctx.lineTo(endX, endY);         // 座標を指定してラインを引く
    ctx.stroke();                   // 現在のパスを描画する

    // 描画状態を保存した時点のものに戻す
    ctx.restore();
}

// 線分を全て描画
function drawLinesArray(linesArray) {
    // コンテキストを取得
    const ctx = $('#line-canvas')[0].getContext('2d');

    // 現在の描画状態を保存する
    ctx.save();   

    // canvasをクリアする
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    linesArray.forEach(lines => {
        ctx.lineCap = 'round';
        ctx.lineWidth = lines. lineWidth;
        ctx.strokeStyle = lines.color;

        for(let i = 0; i < lines.positions.length - 1; i += 1) {
            const startPos = lines.positions[i],
                endPos = lines.positions[i + 1];
            ctx.beginPath();                // 現在のパスをリセットする
            ctx.moveTo(startPos.x, startPos.y);     // パスの開始座標を指定する
            ctx.lineTo(endPos.x, endPos.y);         // 座標を指定してラインを引く
            ctx.stroke();                   // 現在のパスを描画する
        }
    });

    // 描画状態を保存した時点のものに戻す
    ctx.restore();
}

// オブジェクトをすべて描画
function drawObjects(objects) {
    // コンテキストを取得
    const ctx = $('#object-canvas')[0].getContext('2d');

    // 現在の描画状態を保存する
    ctx.save();   

    // 塗りつぶしの色を設定
    ctx.fillStyle = _FILL_BACKGROUND_STYLE;

    // 塗りつぶす
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    objects.forEach(obj => {
        // 現在の描画状態を保存する
        ctx.save();   
        
        ctx.setTransform(1, 0, 0, 1, obj.x, obj.y);
        ctx.fillStyle = obj.color;
        if(obj.type === 'rect') {
            ctx.fillRect(-obj.size / 2, -obj.size / 2, obj.size, obj.size);
        } else if(obj.type === 'circle') {
            ctx.beginPath();        // 現在のパスをリセットする
            ctx.arc(0, 0, obj.size / 2, 0, Math.PI*2, true);  // 円を描画する
            ctx.closePath();        // パスを閉じる  
            ctx.fill();          
        }            

        // 描画状態を保存した時点のものに戻す
        ctx.restore();
    });

    // 描画状態を保存した時点のものに戻す
    ctx.restore();
}
```


#最後に
どうもお疲れさまでした！！！
この最後の問題19は、プログラム初心者には大変であったかもしれません。
感想などいただければ幸いです。:stuck_out_tongue_winking_eye: