Laravel + Canvas で座席位置を指定する機能をつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、開発効率を維持するために忙しくても週に一度はお休みを取ってリフレッシュするようにしているのですが、そんな関係でたまに映画館に行ったりしています。

先日も映画に行ったのですが、そこでこれまでは気にならなかったあるものの存在に気がつきました。

それが・・・・・・

(座席まで選べる)チケット自動購入機

です。

※ 若い方はイメージしにくいかもしれませんが、少し前までは映画館でチケットを買うときはスタッフさんに「大人●枚ください」と言ってチケットを買っていました。

ガソリンスタンドと同じく、「こんなところまでオートメーション化しているのか😳」と感動したのですが、それと同時に「これはぜひ自分でも作ってみたいな」と思いました。

そこで❗

今回はLaravel + Canvasで、座席位置を目で見ながら指定できる機能を作ってみることにします。

ぜひ何かの参考になりましたら嬉しいです😊✨

「シドニアの騎士、
面白かったです👍」

開発環境: Laravel 8.x、Vue 3

準備する

今回キャンバスには座席の位置を決めるために、以下のアイコン(Font-Awesome)を使うことにします。

なお、Font-AwesomenSVGファイルは、GitHubで公開されてるソースコードの中から取得することができます。

そのため、以下からZIPファイルをダウンロード&展開し、「Font-Awesome-master/svgs」の中からファイルを探して「public/images」フォルダに入れておいてください。

📝 ダウンロードURL: Font-Awesome

また、座席のマップ画像は以下のテンプレートを改造して使わせていただくことにしました。(感謝です✨)

📝 ダウンロードURL: 座席表

実際の画像はこちら

ルートをつくる

routes/web.php

Route::get('canvas_seats', fn() => view('canvas_seats'));

なお、上のコードで「おや❓」と思った方もいらっしゃるかもしれません。

そうです。今回はPHP 7.4から追加された「Arrow Function」を使っているんですね。

ちなみに、これまでの書き方だと次のようになります。

Route::get('canvas_seats', function(){

    return view('canvas_seats');

});

ずいぶんとコード量が減ることが分かると思います。

※ 私の場合、JavaScriptの方ではすでにほぼアロー関数ばかり使っているので、これからはPHPの方でも(使いどころを考えつつ👍)使っていきたいと考えています!

ビューをつくる

それでは今回のメインになるビューをつくります。

resources/views/canvas_seats.blade.php

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>

        #canvas {

            background-image: url("/images/seat_map.png");
            border: 1px solid #000;
            cursor: crosshair;

        }

    </style>
</head>
<body>
<div id="app" class="p-4">
    <canvas id="canvas" ref="canvas" width="640" height="640" @click="onCanvasClick"></canvas>
    <div class="p-3">
        <div v-if="movingSeatIndex >= 0">
            <div class="pb-2">
                クリック、もしくは十字キーを使って席を移動してください。
            </div>
            <button type="button" class="btn btn-primary" @click="settleSeat">確定する(or Enterキー)</button>
        </div>
        <div v-else>
            画面上の席をクリックして選択するか、<a href="#" @click.prevent="addSeat">席を追加</a> してください。
        </div>
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/js/bootstrap.min.js"></script>
<script src="https://unpkg.com/vue@3.1.1/dist/vue.global.prod.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script>

    Vue.createApp({
        data() {
            return {
                canvas: null,
                context: null,
                seatLocations: [],
                movingSeatIndex: -1,
                isShiftKeyDown: false
            }
        },
        methods: {
            drawCanvas() {

                this.clearCanvas();

                this.seatLocations.forEach(seatLocation => {

                    let image = new Image();
                    image.addEventListener('load', () => {

                        this.context.drawImage(
                            image,
                            seatLocation.x,
                            seatLocation.y,
                            this.seatIconLength,
                            this.seatIconLength
                        );

                    });
                    image.src = '/images/chair.svg';

                });

                if(this.isSeatMoving) { // 移動中の席を囲む

                    this.context.beginPath();
                    this.context.strokeStyle = 'red';
                    this.context.rect(
                        this.currentSeatLocation.x - 5,
                        this.currentSeatLocation.y - 5,
                        this.seatIconLength + 10,
                        this.seatIconLength + 10
                    );
                    this.context.stroke();

                }

            },
            clearCanvas() {

                this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);

            },
            addSeat() { // 新しい席を追加

                this.seatLocations.push({
                    x: 10,
                    y: 10
                });
                this.movingSeatIndex = this.seatLocations.length - 1;
                this.drawCanvas();

            },
            settleSeat() {  // 席の移動を完了させる

                this.movingSeatIndex = -1;
                this.drawCanvas();

            },
            onCanvasClick(e) {

                const x = e.offsetX, y = e.offsetY;

                if(this.isSeatMoving) {

                    const halfSeatIconLength = this.seatIconLength * 0.5;
                    this.seatLocations[this.movingSeatIndex] = {
                        x: x - halfSeatIconLength,
                        y: y - halfSeatIconLength
                    };

                } else {

                    this.seatLocations.forEach((seatLocation, index) => {

                        const rect = {
                            startX: seatLocation.x,
                            endX: seatLocation.x + this.seatIconLength,
                            startY: seatLocation.y,
                            endY: seatLocation.y + this.seatIconLength
                        };

                        if(rect.startX <= x && rect.endX >= x && rect.startY <= y && rect.endY >= y) { // 範囲内なら席を選択

                            this.movingSeatIndex = index;

                        }

                    });

                }

                this.drawCanvas();

            },
            onKeydown(e) {

                const key = e.key;

                if(key === 'Shift') {

                    this.isShiftKeyDown = true;

                } else if(key === 'Enter') {

                    this.settleSeat();

                }

                if(this.isSeatMoving) {

                    let x = this.currentSeatLocation.x;
                    let y = this.currentSeatLocation.y;
                    const movingLength = (this.isShiftKeyDown) ? 25 : 1; // Shift と一緒の場合はたくさん移動する

                    if(key === 'ArrowUp') {

                        y -= movingLength;

                    } else if(key === 'ArrowRight') {

                        x += movingLength;

                    } else if(key === 'ArrowDown') {

                        y += movingLength;

                    } else if(key === 'ArrowLeft') {

                        x -= movingLength;

                    }
                    this.seatLocations[this.movingSeatIndex] = {
                        x: x,
                        y: y
                    };

                    this.drawCanvas();

                }

            },
            onKeyup(e) {

                const key = e.key;

                if(key === 'Shift') {

                    this.isShiftKeyDown = false;

                }

            }
        },
        computed: {
            isSeatMoving() {

                return (this.movingSeatIndex > -1);

            },
            seatIconLength() {

                return this.canvas.width * 0.05;

            },
            currentSeatLocation() {

                return this.seatLocations[this.movingSeatIndex]

            }
        },
        mounted() {

            this.canvas = document.querySelector('#canvas');
            this.context = this.canvas.getContext('2d');
            document.onkeydown = this.onKeydown;
            document.onkeyup = this.onKeyup;

            // 初期表示する席(本来はDBなどから用意する)
            this.seatLocations = [
                { x: 74, y: 246 },
                { x: 162, y: 246 },
                { x: 263, y: 246 },
            ];
            this.drawCanvas();

        }
    }).mount('#app');

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

今回は「xy座標」を使っているので少し複雑になっていますので、一つずつご紹介していきます。

drawCanvas()

ここでは、席を表示する座標データseatLocationsの座標(x, y)をループでひとつずつ取り出し、その位置に席のアイコンを描画するようにしています。

ちなみにCanvasに画像を描画するには、drawImage()というメソッドが用意されていますが、これは少しクセがあって、位置情報とURLをセットすればいいというわけではありません。

やりかたとしては、以下の流れになります。

  1. new Image() で画像インスタンスをつくる
  2. 画像インスタンスに「画像を読み込んだとき」のイベントをセット
  3. 画像インスタンスに画像のURLをセット
  4. すると、2番でセットしたイベントが起動
  5. ここでやっと drawImage() を読んで描画する

また、移動中の席がどれなのかをわかりやすくするため、赤枠で囲むようにしているのが、後半部分です。

(イメージはこんな感じです。)

ここでは、Canvasrect()メソッドを使って四角を描画していますが、余白を少しつけたいので、少しだけ描画する位置を調整しています。

onCanvasClick()

Canvasがクリックされたときに実行される部分です。

この中では、以下2つのパターンに大きく分かれています。

  • 席を移動していないとき
  • 席を選択し、移動中のとき

席を移動していないとき

クリックされた位置をチェックし、「もしすでに描画している席の範囲内に入っていたら」その席を選択状態にします(movingSeatIndexに席のインデックス番号を入れます)

席を移動しているとき

もしすでに席が選択されている場合は、その席がクリック位置へ移動するようseatLocationsの中身を変更します。

onKeydown()

キーボードが押されたときに実行される部分です。
イベントがセットされているのは、mounted()の中、つまり「ページが読み込まれたらすぐ」です。

※ ちなみに、onkeypressではありません。

この中では、押されたキーによって処理が変わるのですが内容は以下のようになっています。

  • 十字キー: 選択中の席を移動
  • Enterキー: 席の移動を完了させる
  • Shift: 十字キーと連携してたくさん移動できるようにする

onKeyUp()

キーボードが押されて元に戻ってきたとき実行される部分です。

ここでは、Shiftキーが解除になった場合、その状態を保持するisShiftKeyDownfalseになるようにしています。

逆にtrueになるのは、onKeyDown()の中です。

seatIconLength()

先ほど紹介した以下の席アイコンの長さを取得できるようにしています。

デモを用意しました

今回の機能は実際に体験してもらった方が早いかなと思いましたので、デモページを用意しました。

ぜひお試しください。m(_ _)m

📝 デモページ

※ ということで今回は「テストしてみる」はありません。皆さんの手でテストしてみてください。😂👍

企業様へのご提案

今回の機能を使うと、以下のような機能を実現することができます。

  • 商品ストックの棚レイアウトがよく変わる場合でも位置情報をすぐに変更できる
  • 席の画像をクリック→予約というシステムに応用することができます
  • 席の画像を自由に変更できるので、より多様なマップに適用しやすいです。
  • 単一の席だけなく、「VIP席」「スタンダード席」のようにいくつかの種類を共存させることもできます。
  • 作成されたマップは、画像として保存することもできるので、毎回配置がかわる会場図として使うこともできます。

もしこういった機能をご希望でしたら、ぜひお問い合わせからご連絡ください。
どうぞよろしくお願いいたします。m(_ _)m

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はCanvasを使って移動できる席情報を作ってみました。

Canvasは、粒で描画しているので、少しでも変更があると1から描画しなおさないといけないため、数が多くなるとパフォーマンスが落ちる可能性がありますが、高スペックなPCが当たり前になった現在、よほどのことが無い限り問題はないのではないかと感じています。(こういったことからも、背景のマップはCanvasで描画せず、要素の背景としてCSSで指定しています)

ぜひ皆さんも試してみてくださいね。

ではでは〜❗


「やっとピアノでリズム感が
整ってきました(長かった…💦)」

このエントリーをはてなブックマークに追加       follow us in feedly