2
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?

Reactでcanvasの全体をドラッグして移動、拡大縮小

Posted at

はじめに

canvasに絵を描いて、それをドラッグで移動したい場面があります。
以前、svgの場合はreact-zoom-pan-pinchを使うとさらっとできますよというお話でした。

一方、canvasの場合はそういうのがなさそうなので、自作してみました。
長くなりますが、このQiitaの記事としては、いきなり答えではなく考えた順番に実装していく感じで解説します。その方が実践っぽいでしょ。ちゃんと理解したいとか技術力をUPしたいとかの方は、自分で手を動かしながらじっくり読んでみてください。

動くサンプルはこちら

一気に最終系を知りたい人は、githubをご覧ください。
ソースはこちら

実装手順

1. 単純にcontextで描く

まずは通常のJavaScriptで描くように、contextで線を描きます。
原点から、X方向に50px、Y方向に100pxの線を引いただけです。向きが分かるように長さを変えました。

DraggableCanvas.tsx
import { useRef, useEffect } from "react";

export function DraggableCanvas() {
    // canvasへのref
    const refCanvas = useRef<HTMLCanvasElement>(null);

    useEffect(() => {
        const canvas = refCanvas.current;

        if (!canvas) return;
        const context = canvas.getContext("2d");
        if (!context) return;

        // 描画
        context.lineWidth = 10;
        context.beginPath();
        context.moveTo(0, 0);
        context.lineTo(50, 0);
        context.moveTo(0, 0);
        context.lineTo(0, 100);
        context.stroke();
    }, []);

    return (
        <>
            <canvas
                ref={refCanvas}
                width={300}
                height={200}
                style={{border: "1px solid #000", margin: "10px"}}
            />
        </>
    )
}

結果
image.png
左上が原点で、右がx、下がy。
とりあえず絵は、最後までこのままでいきます。ドラッグの話なので、移動と拡大縮小がわかればいいだけなので。

2. 原点と拡大率を変数化

細かいアップデートですが、原点と拡大率を変数化します。

ここで、このプログラムの中で使う言葉を定義します。
座標系A:canvasの座標。左上が原点(0, 0)で、右下が(300, 200)の固定。
座標系B:ドラッグして移動する絵の原点。拡大縮小もする。(回転はしない)

座標系Bの原点がある座標系Aの座標値をoriginB、拡大率をscale
originBの初期値を、(50, 50)にしてみました。
あとPoint型を定義しました。

DraggableCanvas.tsx (一部)
// x,y値を保持するためのtype
type Point = {
    x: number;
    y: number;
};

export function DraggableCanvas() {
// 以下は略
DraggableCanvas.tsx ( useEffect(() => { } の中 )
        // 座標系Bの原点
        let originB: Point = {x: 50, y: 50};

        // 拡大率
        let scale: number = 1.0;

        // 描画
        context.lineWidth = 10;
        context.beginPath();
        context.moveTo(originB.x, originB.y);
        context.lineTo(originB.x + 50*scale, originB.y);
        context.moveTo(originB.x, originB.y);
        context.lineTo(originB.x, originB.y + 100*scale);
        context.stroke();

結果
image.png
座標系Bの原点が(0,0)にあったときは、線の半分が画面外にあったけど、(50,50)へ移動して全体見えました。
家具を買うとついてくる工具にしか見えません。

3. マウスイベントリスナーの実装(空で)

マウスボタンを押した瞬間のmousedown、マウスを動かしているときのmousemove、マウスボタンを離したときのmouseupの、イベントハンドラーを実装します。ハンドル3兄弟です。

DraggableCanvas.tsx(useEffectのところ)
    useEffect(() => {
        const canvas = refCanvas.current;

        if (!canvas) return;
        const context = canvas.getContext("2d");
        if (!context) return;

        // 座標系Bの原点
        let originB: Point = {x: 50, y: 50};

        // 拡大率
        let scale: number = 1.0;

        // 描画
        function draw() {
            if (!context) return;

            context.lineWidth = 10;
            context.beginPath();
            context.moveTo(originB.x, originB.y);
            context.lineTo(originB.x + 50*scale, originB.y);
            context.moveTo(originB.x, originB.y);
            context.lineTo(originB.x, originB.y + 100*scale);
            context.stroke();
        }
        draw();

        function handleMouseDown(event: MouseEvent){
            console.log("mouseDown");
        }
        function handleMouseMove(event: MouseEvent){
            console.log("mouseMove");
        }
        function handleMouseUp(event: MouseEvent){
            console.log("mouseUp");
        }
        
        canvas.addEventListener("mousedown", handleMouseDown);
        canvas.addEventListener("mousemove", handleMouseMove);
        canvas.addEventListener("mouseup", handleMouseUp);
        canvas.addEventListener("mouseleave", handleMouseUp);
        return () => {
            canvas.removeEventListener('mousedown', handleMouseDown);
            canvas.removeEventListener('mousemove', handleMouseMove);
            canvas.removeEventListener('mouseup', handleMouseUp);
            canvas.removeEventListener('mouseleave', handleMouseUp);
        };
    }, []);

長くなるのでいったん空で一区切り。
一時的に、console.log()を入れて、動いているのを確認してますがすぐ消します。

説明なしにmouseleaveのリスナーも入れてました。でも呼ばれる関数は、handleMouseUpという不思議な感じですが、これはミスではないです。
ドラッグして絵を動かすときに、ドラッグしたままcanvasの外にマウスが出ることがありますよね。そのとき、mouseupの動作をして、ちゃんと終了するための仕組みです。

あ、あと描画関係を、draw()に入れてその直後に呼んでます。ほぼ字下げしただけですが。

4. マウスイベントリスナーの中身を実装①

(useEffectの上の方)
        // ドラッグしている状態
        let isDragging: boolean = false;
        // ドラッグの開始位置
        let dragStartPoint: Point | null = null;
(ハンドル3兄弟)
        function handleMouseDown(event: MouseEvent){
            console.log("mouseDown");

            // ドラッグ開始
            isDragging = true;
            dragStartPoint = {x: event.clientX, y: event.clientY};
        }
        function handleMouseMove(event: MouseEvent){
            console.log("mouseMove");

        }
        function handleMouseUp(event: MouseEvent){
            console.log("mouseUp");

            originB = {x: event.

            // ドラッグ終了
            isDragging = false;
            dragStartPoint = null;
        }

今ドラッグしている最中であることを示すisDraggingと、ドラッグを開始した場所を示すdragStartPointを定義して、mousedownで始めてmouseupで終わるようにしました。

5. マウスイベントリスナーの中身を実装②

本筋からいったん離れ、マウスをぐっと押し下げてドラッグしている途中で呼ばれるgetOriginBDragging()関数を定義します。
この関数は、今のマウス位置を与えると、まさに今ドラッグ途中のoriginBの位置を返す関数です。

前提として、ドラッグ中にはoriginBは更新せず、mouseupしたら更新するつもりなので、ここでのoriginBはまだ更新されていない、ドラッグ開始時の位置です。

(useEffectの中に定義)
        // ドラッグ中のoriginBを返す
        function getOriginBDragging(curMousePos: Point): Point|void{
            if (!isDragging) return;
            if (!dragStartPoint) return;

            return {
                x: originB.x + curMousePos.x - dragStartPoint.x,
                y: originB.y + curMousePos.y - dragStartPoint.y,
            };
        }

image.png
足し引きがややこしいですが、落ち着いて考えれば大丈夫です。
dragStartPosからcurMousePosへのベクトルを、originBに足してます。

ベクトルA→Bは、Bの座標マイナスAの座標でしたね。懐かしすぎる。

6. マウスイベントリスナーの中身を実装③

もう1つ寄り道。
さきほど字下げしただけのdraw()関数に引数を定義します。
元々、originBを元に描画していましたが、引数のparamOriginBを指定したらそれを使って、指定しなかったらこれまでと同じoriginBを使います。

(useEffectの中のdraw()定義と、直後の呼び出し)
        // 描画
        function draw(paramOriginB?: Point) {
            if (!canvas) return;
            if (!context) return;
            
            // この関数で使うoriginBを設定
            // パラメータがあればそれを、なければoriginBを使う
            const curOriginB = paramOriginB? paramOriginB: originB;

            // 初期化
            context.clearRect(0, 0, canvas.width, canvas.height);

            context.lineWidth = 10;
            context.beginPath();
            context.moveTo(curOriginB.x, curOriginB.y);
            context.lineTo(curOriginB.x + 50*scale, curOriginB.y);
            context.moveTo(curOriginB.x, curOriginB.y);
            context.lineTo(curOriginB.x, curOriginB.y + 100*scale);
            context.stroke();
        }
        draw();

これは、mousemoveしているときに、originBを更新しないけど、ドラッグ途中も絵を描き続ける必要があるためです。mousemoveしているときは、一時的なoriginB相当の位置をgetOriginBDragging()で計算して、それをここに渡すことができるようになりました。

あとついでに、context.clearRectでcanvas全体を初期化もしてます。この関数はドラッグしてちょっと移動するたびに繰り返し呼ばれるので、これをやらないと、前の絵の上に重ね書きしてしまいます。

7. マウスイベントリスナーの中身を実装④

ようやくmousemoveの処理です。

(mousemoveのハンドル)
        function handleMouseMove(event: MouseEvent){
            console.log("mouseMove");
            if (!isDragging) return;

            // 今のoriginBを取得
            const movingOriginB = getOriginBDragging(
                {x: event.clientX, y: event.clientY}
            ); 
            if (!movingOriginB) return;

            // 描画
            draw(movingOriginB)
        }

getOriginBDragging()で、今のマウスの位置を元に移動中のoriginBを仮計算して、それをdraw()に渡しています。道草の2つの呼び出しです。

ここまで実装すると、ようやくドラッグで移動するようになります。

試してみると~・・・
out.gif
動くけども、ドラッグするmousedownのタイミングで、初期位置に戻る。

gif画像の動画、クリックのタイミングがわからないのと、動画のスタート位置がわからないから、今回ものすごく相性が悪いですねw ぴっと飛ぶ感じになって、まさにこの動画で見ている通りなんですが、まぁいいや。

8. マウスイベントリスナーの中身を実装⑤

原因は、mouseup時にoriginBを更新していないからなので、更新します。

(mouseupハンドル)
        function handleMouseUp(event: MouseEvent){
            console.log("mouseUp");

            // 今のoriginBを取得
            const finalOriginB = getOriginBDragging(
                {x: event.clientX, y: event.clientY}
            ); 
            if (!finalOriginB) return;

            // originBを更新
            originB = finalOriginB;

            // ドラッグ終了
            isDragging = false;
            dragStartPoint = null;
        }

mouseup時点のfinalOriginBを取得して、それを本物のoriginBへ上書きする処理を追加しました。mousemoveと似たような感じです。

mv2_out.gif
これで、ドラッグで移動は完成です。

8. スライドバーで拡大縮小①

移動が①~④とありましたが、拡大縮小も刻んでいきます。
最初は、拡大縮小について、仕様と動きを考えることから始めます。

拡大縮小の仕様は、2つの方法でできるようにします。
1つは、canvas内でマウスのホイール(ころころ)をトリガーに、上に回転させたら拡大、下に回転させたら縮小です。もう1つは、スライドで、左にスライドさせたら縮小、右にスライドさせたら拡大。
2つあるけど共通処理としたいので、その辺を考慮しながら作っていきます。

動きは、「指定した位置を中心に拡大縮小」です。
マウスホイールの場合は、マウスの位置を中心に、スライドの場合はcanvasの中心を中心に、拡大縮小します。意外と、拡大縮小には中心が存在しているということは、忘れがち。

拡大とは、絵を拡大することに加え、originBを動かことになります。なぜならそれは拡大中心から、originBが遠ざかるから。その2つの変形を同時にします。縮小は逆に絵の縮小と、originBを近づける動きですね。

幸いscaleoriginBの2つとも独立した変数になっているので、それぞれの動きに合わせて変数を更新します。

scaleの値は、単純に値を増やせばいいだけから大丈夫でしょう。問題は、originBをどれだけ動かすかということです。

拡大中心→originBのベクトルを利用して、拡大中心→newOriginBを導きます。元々拡大率がS1だったところから、S2に変わったときを考えると、ベクトルはS2/S1倍になります。

image.png

コードだとこう。

(zoomAround関数を新設)
        // 拡大によるoriginBとscale値の更新
        function zoomAround(newScale: number, propCenter?: Point){
            if (!refCanvas) return;
            if (!refCanvas.current) return;
    
            // 拡大中心点(座標系A)
            const zoomCenter: Point = propCenter
                ? propCenter
                : {
                    x: refCanvas.current.width/2,
                    y: refCanvas.current.height/2
                }
            ;
    
            // 拡大中心点→originBinAを newScale/scale 倍した先が
            // 新しいoriginBとなる
            
            // zoomCenter → OriginB ベクトル
            const vecZC2OB: Point = {
                x: (originB.x - zoomCenter.x) * newScale / scale,
                y: (originB.y - zoomCenter.y) * newScale / scale,
            };
    
            // 新しいoriginBの座標値
            const newOriginB = {
                x: zoomCenter.x + vecZC2OB.x,
                y: zoomCenter.y + vecZC2OB.y,
            };

            // 計算結果で更新
            originB = newOriginB;
            scale = newScale;
        }

vecZC2OBというのが絵で描いた紫のベクトルです。拡大中心zoomCenterにそのベクトルを足すと、newOriginBが計算できます。縮小の場合でもまったく同じ計算なので問題なし。

引数のpropCenterが指定されなかった場合は、canvasの中心の座標値をcenterとする処理も入れています。これは、スライドで拡大されることを想定しています。

なお関数名のzoomAroundは英語的に良いのか微妙ですが、ネーミングの意図は、「指定した位置を中心に拡大縮小する」という感じです。

9. スライドバーで拡大縮小②

さて、準備ができたところで、スライドバー自体の実装。

input type="range"
        <div style={{display:"flex",flexDirection:"column", alignItems: "flex-start", padding: "10px"}}>
            <canvas
                ref={refCanvas}
                width={300}
                height={200}
                style={{border: "1px solid #000"}}
            />
            <input
                type="range"
                ref={refRange}
                min={SLIDERRANGE_MM.min}
                max={SLIDERRANGE_MM.max}
                defaultValue={0}
            />
        </div>

もともと、canvasだけだったところの下に、inputタグのtype="range"を追加します。HTML5で追加されたタイプです。これは便利。

1目盛り動かすと、valueの数値が1上下します。属性にstepを設定して、valueの増減の幅も設定できるタグですが、ここでは1とします。そして、minmaxで、スライドの左端・右端のときの値、defaultValueはReact特有の属性で初期値です。

そのinputを置いたら、横に並んでしまったので、上位のタグをdivにして、display:"flex", flexDirection:"column"などのスタイルをつけて縦に並べています。見た目の問題なのであまり気にしなくて大丈夫です。

上の方で、SLIDERRANGE_MMを定義します。定義するだけ。
10のときに10倍、-10のときに0.1倍になったらいいなぁという気持ちで。

(上の方)
const SLIDERRANGE_MM = {
    min: -10,
    max: 10,
};

DraggableCanvas()の先頭で、ref属性で指定していたrefを、元々あったrefCanvasの下に、追加します。
ついでに、useEffect()の中でrefRange.currentからrangeを取得する処理も書いておきます。後でrangeをさっと使えるように。(rangeは言語によっては予約語として存在してそうだから、別の名前にした方がよかったか🤔)

(DraggableCanvas()の先頭)
export function DraggableCanvas() {
    // canvasへのref
    const refCanvas = useRef<HTMLCanvasElement>(null);
    // input rangeへのref
    const refRange = useRef<HTMLInputElement>(null);

    useEffect(() => {
        const canvas = refCanvas.current;
        if (!canvas) return;
        const context = canvas.getContext("2d");
        if (!context) return;
        const range = refRange.current;
        if (!range) return;

いったんここまで。スライドは動かせるけど、スライドを動かしても絵は何も動かない状態です。それでOK。
mv4_out.gif

10. スライドバーで拡大縮小③

スライドバーのinput値は、-1010でした。
それに対する拡大率は、大体こんな風に対応付けたいです。
-10の時0.1倍 ・・・ 0の時1.0倍 ・・・ 10の時10.0倍

image.png

どうせ手でスライドさせるようなことだし、わかりやしないので適当に、数式的には下記にしてみました。

scale = 10^{\frac{value}{10}}

ただの数式なので、外に関数を定義します。

(DraggableCanvasの外)
// スライドバーのスケール値から、座標系Bのscale値へ変換する
function rangeValue2Scale(value: number) {
    return Math.pow(10, value/10);
}

11. スライドバーで拡大縮小④

ようやく、スライドを動かしたら動くハンドラー関数を追加します。

まずイベントリスナーを追加。

(ハンドル3兄弟に仲間が増えました)
        canvas.addEventListener("mousedown", handleMouseDown);
        canvas.addEventListener("mousemove", handleMouseMove);
        canvas.addEventListener("mouseup", handleMouseUp);
        canvas.addEventListener("mouseleave", handleMouseUp);
        range.addEventListener("input", handleRangeChange); // input range
        return () => {
            canvas.removeEventListener('mousedown', handleMouseDown);
            canvas.removeEventListener('mousemove', handleMouseMove);
            canvas.removeEventListener('mouseup', handleMouseUp);
            canvas.removeEventListener('mouseleave', handleMouseUp);
            range.removeEventListener('input', handleRangeChange); // input range

        };

handleRangeChangeを追加しています。

ここで、listenするイベントは、inputを指定しています。最初、changeで実装したところ、スライドを動かしてみると、動かした後のmouseupのタイミングだけでしか発動しないのです。拡大縮小は、ぐりぐり動かしながら適切なところを探るので、これはイカンと思って調べた結果です。
関数名は、"change"の方が分かりやすいからそうしてます。

リスナーから呼び出される関数handleRangeChangeです。

        function handleRangeChange(){
            if (!range) return;

            // 値から拡大率を計算
            const newScale = rangeValue2Scale(range.valueAsNumber);
            // 拡大縮小
            zoomAround(newScale);

            // 再描画
            draw();
        }

ここまで実装して、下記の状態。いい感じ。
mv5_out.gif

12. マウスホイールで拡大縮小

スライドバーの次はマウスホイールからの拡大縮小。とはいえ、共通処理が多いので、新たな処理は少ないですよ。長丁場ですがもう少し。

まずリスナーを登録します。

(ハンドル3兄弟、もはやもう何人いるのかわからなくなってきた)
        canvas.addEventListener("mousedown", handleMouseDown);
        canvas.addEventListener("mousemove", handleMouseMove);
        canvas.addEventListener("mouseup", handleMouseUp);
        canvas.addEventListener("mouseleave", handleMouseUp);
        canvas.addEventListener("wheel", handleMouseWheel);  // wheel
        range.addEventListener("input", handleRangeChange);
        return () => {
            canvas.removeEventListener('mousedown', handleMouseDown);
            canvas.removeEventListener('mousemove', handleMouseMove);
            canvas.removeEventListener('mouseup', handleMouseUp);
            canvas.removeEventListener('mouseleave', handleMouseUp);
            canvas.removeEventListener("wheel", handleMouseWheel);  // wheel
            range.removeEventListener('input', handleRangeChange);
        };

"wheel"というイベントをlistenするようにします。マウスホイールによるイベントのことです。

それによって呼び出される関数がこちら。引数のイベントは、WheelEvent型です。

handleMouseWheel()
        function handleMouseWheel(event: WheelEvent){
            if (!range) return;

            // マウスホイールの方向による更新値
            const wheelUp = (event.deltaY<0)? 1: -1;

            // 今のinputRangeから1つ上げ下げする
            const newInputRangeValue = range.valueAsNumber + wheelUp;
            range.value = String(newInputRangeValue);

            // inputRangeValueからscale値に変換
            const newScale: number = rangeValue2Scale(
                newInputRangeValue
            );

            // マウス位置を中心に拡大縮小
            zoomAround(
                newScale,
                {x: event.clientX, y: event.clientY}
            );
            
            // 再描画
            draw();
        }

まずWheelEventから、特有のプロパティdeltaYを見て、マイナスの場合は上へ回しているので、その場合はwheelUp = 1として、逆は-1とします。実際は、deltaY100とか入ってるのですが、回した方向だけ利用したいため無視します。
次に、現在のスライドバーの数値にwheelUpの値を足して、更新します。つまり、上へ回すと、スライドバーの値が+1されるので、スライドバーを1目盛り右へずらしたのと同じ効果。
それと同時にそのスライドバーの値を元にしたスケール値をrangeValue2Scale()で得て、それを使ってzoomAround()で拡大縮小します。
拡大縮小の時には、マウスの位置を拡大縮小の中心とするため、{x: event.clientX, y: event.clientY}を渡します。
そして再描画。

結果
mv6_out.gif

いやーうれしくてつい長いgifアニメになってしまいました。

canvasをドラッグで移動、拡大縮小をする説明はおしまいです!長い記事にお付き合いくださいましてありがとうございました。

もう一度、動くサンプルはこちら

おわりに

調べるまでもなく過去イチで長い記事になりました。コーディング中は、そうでもなかったんですが、考えていることを逐一書き出すとこうなっちゃいますね。

コーディングの考え方とか進め方を共有するのはYouTubeだといいのかなぁとも思いますが、検索で途中のコードとかは出てこないし、見つけづらいので、がんばって書いてみたのですが、どうだったんでしょう。ちょっと時間を置いて様子をみてみようと思います。
必要とされてそうだったらまたやるかも。体力と気力があるときに。

2
0
0

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
2
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?