Laravel + Vueでネットカフェの「棚の場所システム」を作ってみる(ダウンロード可)

さてさて、前回はVue.jsでページ離脱を阻止するという記事を公開しましたが、やっぱりVueは使いやすくていいですね。学習コストが少ないので新しく始めるのも簡単ですし、なにより環境を整えなくてもすぐ使えるのが嬉しいです。

そして、この流れで今回もVueの話題をお届けしようと考えていたのですが、ふとこの間行ったネットカフェにいった時のことを思い出しました。その時ブースにあるパソコンで漫画を検索したのですが、「その漫画がどこにあるか」も店内マップで表示してくれていたんですね。

これってすごく便利ですよね😊✨

そして、このシステムを私なりに作ってみたくなったので、今回は<canvas>タグを使って実装してみたいと思います。(なお、実際の検索システムはCSSで矢印を絶対位置に指定している方法でした)

ぜひ皆さんのお役に立てると嬉しいです!

開発環境: Laravel 5.8, Vue 2.6

やりたいこと

今回は、大きく分けて以下2つの機能を実装します。

  • 店内マップをクリックするとそこに赤丸を表示し、さらに漫画のタイトルを入力して送信すると、データベースにその情報が保存される
  • すでに保存された漫画のリストを表示し、クリックするとそれがどこの棚にあるかをオレンジの丸で表示する

つまり、こんなイメージです↓↓↓

※ 本物の漫画タイトルを勝手に使うと問題あるかもしれないので、今回はパロディっぽく変更しています😂

では、ひとつずつ見ていきましょう!

ルートをつくる

今回必要になるのは、以下3つのURLです。

  1. 店内マップを表示するURL
  2. Ajaxで登録済みの漫画データを取得するURL
  3. Ajaxで漫画データを新規保存するURL

ということでroutes/web.phpに次のルートを追加します。

Route::get('internet_cafe/index', 'InternetCafeController@index');
Route::get('internet_cafe/create', 'InternetCafeController@create');
Route::post('internet_cafe/store', 'InternetCafeController@store');

コントローラーをつくる

続いて、まだコントローラーが作成されていませんので、以下のコマンドで作成しましょう。

php artisan make:controller InternetCafeController

app/Http/Controllers/InternetCafeController.phpが作成されるので、開いて以下のように変更します。(太字が変更した部分です)

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class InternetCafeController extends Controller
{
    public function index() {

        return \App\Book::all();

    }

    public function create() {

        return view('internet_cafe_create');

    }

    public function store(Request $request) {

        $book = new \App\Book();
        $book->title = $request->title;
        $book->position_x = $request->position['x'];
        $book->position_y = $request->position['y'];
        $book->save();

    }
}

ちなみに、各メソッドの内容は次のとおりです。

  • index() ・・・ Ajaxで保存済みの漫画データ全てを返します
  • create() ・・・ 店内マップを表示し、追加&表示ができるようにします。今回ブラウザで表示されるのはここだけです。
  • store() ・・・ 後から作るAjax送信から「本のタイトル」と「本の位置(x, y)」を受取り、データベースに保存することになります。

モデル、DBテーブルを作る

続いて漫画のデータを管理するモデルとDBテーブルを作成します。
以下のコマンドでモデル + マイグレーションを一気に作成しましょう。

php artisan make:model Book -m

そして、作成されたマイグレーション/database/migrations/****_**_**_******_create_books_table.phpを開いて次のように変更します。

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateBooksTable extends Migration
{
    public function up()
    {
        Schema::create('books', function (Blueprint $table) {
            $table->bigIncrements('id');
            $table->string('title');
            $table->float('position_x', 8, 3);
            $table->float('position_y', 8, 3);
            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('books');
    }
}

変更したら忘れずマイグレーションを実行してください。

php artisan migrate

テーブルはこうなります。

ビューをつくる

では最後にビューです。/resources/views/internet_cafe_create.blade.phpで保存してください。

<html>
<head>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div id="app">
    <canvas id="canvas" @click="openModal"></canvas>
    <br>
    <ul>
        <li v-for="book in books">
            <a href="#" v-text="book.title" @click.prevent="showBookPosition(book)"></a>
        </li>
    </ul>
    <!-- モーダル -->
    <div class="modal fade" id="modal">
        <div class="modal-dialog" role="document">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">本のタイトルを入力してください</h5>
                    <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                        <span aria-hidden="true">&times;</span>
                    </button>
                </div>
                <div class="modal-body">
                    <input type="text" class="form-control" v-model="bookTitle" autofocus>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-light" data-dismiss="modal">キャンセル</button>
                    <button type="button" class="btn btn-primary" data-dismiss="modal" @click="save">保存する</button>
                </div>
            </div>
        </div>
    </div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.min.js"></script>
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.19.0/axios.min.js"></script>
<script>

    new Vue({
        el: '#app',
        data: {
            canvas: null,
            ctx: null,
            books: [],
            position: {
                x: 0,
                y: 0
            },
            bookTitle: ''
        },
        methods: {
            openModal(e) {

                this.clearCanvas(() => {

                    const x = Math.round(e.offsetX / this.canvas.width * 1000) * 0.001;
                    const y = Math.round(e.offsetY / this.canvas.height * 1000) * 0.001;
                    this.position = {
                        x: x,
                        y: y
                    };
                    this.drawPoint('red');
                    this.bookTitle = '';
                    $('#modal').modal('show');

                });

            },
            save() {

                const params = {
                    title: this.bookTitle,
                    position: this.position
                };

                axios.post('/internet_cafe/store', params)
                    .then((response) => {

                        this.getBooks();

                    });

                $('#modal').modal('hide');

            },
            getBooks() {

                axios.get('/internet_cafe/index')
                    .then((response) => {

                        this.books = response.data;

                    });

            },
            showBookPosition(book) {

                this.position = {
                    x: parseFloat(book.position_x),
                    y: parseFloat(book.position_y)
                };

                this.clearCanvas(() => {

                    this.drawPoint('orange');

                });

            },
            drawPoint(color) {

                const r = parseInt(canvas.height / 50);
                const x = Math.round(this.position.x * canvas.width);
                const y = Math.round(this.position.y * canvas.height);
                this.ctx.beginPath();
                this.ctx.arc(x, y, r, 0, 2 * Math.PI);
                this.ctx.fillStyle = color;
                this.ctx.fill();

            },
            clearCanvas(callback) {

                this.ctx.clearRect(0, 0, canvas.width, canvas.height);
                let img = new Image();
                img.src = '/images/room_layout.png';
                img.onload = () => {

                    canvas.width = img.width;
                    canvas.height = img.height;
                    this.ctx.drawImage(img, 0, 0);

                    if(typeof callback === 'function') {

                        callback();

                    }

                };

            }
        },
        mounted() {

            this.canvas = document.getElementById('canvas');
            this.ctx = canvas.getContext('2d');
            this.clearCanvas();
            this.getBooks();

            $('#modal').on('hide.bs.modal', () => {

                this.clearCanvas(() => {

                    this.getBooks();

                });

            });

        }

    });

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

重要なメソッドが次の3つです。

  • clearCanvas() ・・・ キャンバスをクリアして背景になる店内マップを描画しします。ちなみにこのメソッドでコールバック関数を有効にしていますが、これは画像が描画されるまでタイムラグによっては描画したはずの丸印が表示されないことを予防する意味があります。
  • drawPoint() ・・・ 丸印をキャンバスに描画するメソッドです。なお、丸印の位置はピクセルではなく割合(0.000 ~ 1.000)で管理するようにしています。これは、将来的に店内マップのサイズが変わっても問題なく対応できるようにするための処置です。また、丸印の大きさは高さの50分の1にしています。
  • openModal() ・・・ 店内マップがクリックされたときに実行されるメソッドで、クリックイベントから位置情報を取得、割合を計算し、少数第3位で四捨五入しています。

あとは、Ajaxとしてaxios、モーダルがbootstrap、リスト表示はVuev-forと、それほど複雑なものではありません。

テストしてみる

では実際にテストしてみましょう!

まずページを開くと店内マップが表示されています。

次に登録したい漫画の場所をクリックすると、その場所に赤丸が表示されモーダルが表示されますので、漫画のタイトルを入力して「保存する」ボタンをクリック。

すると、データベースに漫画の情報が保存されるので、リスト表示されます。

では、タイトルをクリックしてみましょう。

はい!登録したときにクリックした場所にオレンジの丸印が表示されました。成功ですね😊✨

お疲れ様でした!

おまけ:十字キーで赤丸を移動できるようにする

ちなみにこのままだと、クリックした位置がずれてしまった場合、いちいちキャンセルして再度クリックしないといけません。

これでは作業がめんどうになってしまいますので、おまけとして登録時の赤丸が上下左右キーで移動ができるようにしてみましょう。(モーダルに隠れて見えない場合もありますが、この機能は後付けの「おまけ」なので、そこはご容赦ください😅)

mounted() {

    // 省略

    // おまけ
    document.onkeydown = (e) => {

        const key = e.key;
        const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'];

        if(keys.includes(key)) {

            if(key === 'ArrowUp') {

                this.position.y -= 0.001;

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

                this.position.y += 0.001;

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

                this.position.x -= 0.001;

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

                this.position.x += 0.001;

            }

            this.clearCanvas(() => {

                this.drawPoint('red');

            });

        }

    };

}

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードすることができます。

※ ただし、マイグレーションなどはご自身で実行していただく必要があります。また、バリデーションなどは実装していません。

Laravel + Vueでネットカフェの「棚の場所システム」

おわりに

ということで今回はネットカフェのような位置管理の機能を作ってみました。

なお、JavaScriptソースコードの中にあるclearCanvas()は、画像のロードにタイムラグがあることから当初はPromiseawaitを使って実装していましたが、そうなるとasync表記を使わないといけなくなって複雑になってしまい、プログラムの紹介としてはあまり適さないと判断したのでコールバック関数を使う形に変更しました。

なので、より新しいコードを書いていきたい方はPromiseを使っていくといいでしょう。(ただし、いつものごとくですが、IEPromiseに対応していませんのでご注意を … 😫)

また、新しいJavaScriptの書き方といえば、最後におまけとしてつけた「十字キーで赤丸を移動する」部分ですが、これも過去のJavaScriptではよく使っていたe.keyCodeを使わずe.keyを使うようにしています。すでにkeyCodeは非推奨になってしまったんですね。ArrowUpとかが返ってくるのでこっちのほうがより直感的かと思います!

以上、ネットカフェの位置管理機能でした。
ぜひ皆さんも参考にしてみてくださいね。

ではでは〜!

この記事が役立ちましたらシェアお願いします😊✨