写真と地図が一体化!LaravelとLeafletでドラッグ&ドロップ保存の実現方法

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

さてさて、今年も暑い夏がやってきたので、またしても「自由研究」的な気分になっている今日この頃です。

そして、そんなときにある「やってみたいこと」が天から降ってきました。

それは・・・・・・

地図上に写真をドラッグ・アンド・ドロップすると、そこに画像を保存できる

機能です。

これは、実際にファイルを保存するのはLaravelのストレージですが、位置情報を一緒にDBに保存することで地図の上から写真を見ることができるようにしたかったんですね。

というのも、私はいろいろと出かけては訪問先の「B級スポット」の写真をとるのが好きなんですが、あまりにたくさん行き過ぎて「あれ、あの写真どこいったっけ…😅」となることが多いからなんですね。

そこで❗

今回は「Laravel + Leaflet」で写真を以下のように保存できる機能を実装してみます。

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

「この間は片道5時間
電車に乗りっぱなし
でした。腰が…」

開発環境: Laravel 10.x、Alpine.js 3.12、Axios 1.4.0、leaflet 1.9.4

DBまわりを準備する(モデル&マイグレーション)

では、まずはじめにDBの方から準備を進めていきましょう。
以下のコマンドを実行してください。

php artisan make:model MapPhoto -m

すると、モデルとマイグレーションが一気に作成されるので、中身を以下のように変更します。

app/Models/MapPhoto.php

<?php

namespace App\Models;

use App\Events\MapPhotoDeleted;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class MapPhoto extends Model
{
    use HasFactory;

    protected $appends = [
        'file_path',
    ];
    protected $dispatchesEvents = [
        'deleted' => MapPhotoDeleted::class,
    ];

    // Accessor
    public function getFilePathAttribute()
    {
        return storage_path('app/public/map_photos/' . $this->filename);
    }
}

database/migrations/****_**_**_******_create_map_photos_table.php

// 省略

public function up(): void
{
    Schema::create('map_photos', function (Blueprint $table) {
        $table->id();
        $table->string('filename')->comment('ファイル名');
        $table->string('original_filename')->comment('ファイル名');
        $table->double('latitude', 9, 6)->comment('緯度');
        $table->double('longitude', 9, 6)->comment('経度');
        $table->timestamps();
    });
}

// 省略

では、この状態でデータベースを初期化しましょう。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

すると、実際のテーブルはこうなりました。

削除イベントをつくる

先ほどMapPhotoモデルをつくりましたが、もしデータが削除されたときに「自動的にファイルを削除」できるようにしておきましょう。

こんなときは、Laravelの「イベント」を使います。以下のコマンドを実行してイベント・ファイルを作成してください。

php artisan make:event MapPhotoDeleted

すると、ファイルが作成されるので中身を以下のように変更します。

app/Events/MapPhotoDeleted.php

// 省略

public function __construct(MapPhoto $map_photo)
{
    $file_path = $map_photo->file_path;

    if(file_exists($file_path)) {

        @unlink($file_path); // ファイルを削除

    }
}

// 省略

なお、このイベントをセットした部分は以下になります。

app/Models/MapPhoto.php

protected $dispatchesEvents = [
    'deleted' => MapPhotoDeleted::class,
];

コントローラーをつくる

続いてはコントローラーです。
以下のコマンドを実行してください。

php artisan make:controller MapPhotoController

すると、コントローラー・ファイルが作成されるので中身を次のように変更します。

app/Http/Controllers/MapPhotoController.php

<?php

namespace App\Http\Controllers;

use App\Models\MapPhoto;
use Illuminate\Http\Request;

class MapPhotoController extends Controller
{
    public function index()
    {
        return view('map_photo.index');
    }

    public function list(Request $request)
    {
        $max_latitude = $request->north;
        $min_latitude = $request->south;
        $max_longitude = $request->east;
        $min_longitude = $request->west;

        return MapPhoto::query() // 本来は API Resource を使うべきですが、今回は省略しています
            ->where('latitude', '<=', $max_latitude)
            ->where('latitude', '>=', $min_latitude)
            ->where('longitude', '<=', $max_longitude)
            ->where('longitude', '>=', $min_longitude)
            ->get();
    }

    public function image(MapPhoto $map_photo)
    {
        return response()->file($map_photo->file_path);
    }

    public function store(Request $request)
    {
        // 注: バリデーションは省略しています

        $result = false;

        if($request->hasFile('photo')) {

            $file = $request->photo;
            $path = $file->store('public/map_photos');
            $filename = basename($path);
            $original_filename = $file->getClientOriginalName();

            $map_photo = new MapPhoto();
            $map_photo->filename = $filename;
            $map_photo->original_filename = $original_filename;
            $map_photo->latitude = $request->latitude;
            $map_photo->longitude = $request->longitude;
            $result = $map_photo->save();

        }

        return ['result' => $result];
    }

    public function destroy(MapPhoto $map_photo)
    {
        // 注: バリデーションは省略しています

        $result = $map_photo->delete();

        return ['result' => $result];
    }
}

※ バリデーションは省略しているので注意してください。

ビューをつくる

では、コントローラー内で指定したビュー・ファイルをつくっていきましょう。
ここが今回で一番複雑なところです。

resources/views/map_photo/index.blade.php

<html>
    <head>
        <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.1/dist/cdn.min.js"></script>
        <script src="https://cdn.tailwindcss.com/3.3.2"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/1.4.0/axios.min.js"></script>
        <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
        <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
    </head>
    <body>
        <div x-data="mapPhoto">
            <div class="relative">
                <div id="map" class="h-full"></div>
                <div class="absolute text-center top-2 left-0 right-0" style="z-index:1000;">
                    <span class="bg-gray-100 px-4 py-1 text-2xl">ドラッグアンドドロップで地図に写真を保存するサンプル</span>
                </div>
            </div>
        </div>
        <script>

            const mapPhoto = {

                // 地図
                map: null,
                setMarkers(photos) {

                    photos.forEach(photo => {

                        const originalFileName = photo.original_filename;
                        const popUpHtml = `<div>
                                <p>${originalFileName}</p>
                                <a href="/map_photo/image/${photo.id}" target="_blank">
                                    <img src="/map_photo/image/${photo.id}" class="w-32 mx-auto mb-5">
                                </a>
                                <div class="text-center">
                                    <button type="button" class="mt-1 px-2 py-1 bg-red-500 text-white" @click="deletePhoto(${photo.id})">写真を削除</button>
                                    <button type="button" class="mt-1 px-2 py-1 bg-gray-300 text-black" @click="map.closePopup();">閉じる</button>
                                </div>
                                </div>`;

                        L.marker([photo.latitude, photo.longitude])
                            .addTo(this.map)
                            .bindPopup(popUpHtml);

                    });

                },
                clearAllMarkers() {

                    this.map.eachLayer(layer => {

                        if(layer instanceof L.Marker) { // マーカーのみ削除

                            this.map.removeLayer(layer);

                        }

                    });

                },
                getPhotos() {

                    const bounds = this.map.getBounds();
                    const northEast = bounds.getNorthEast();
                    const southWest = bounds.getSouthWest();

                    const url = '/map_photo/list';
                    const data = {
                        params: {
                            north: northEast.lat,
                            east: northEast.lng,
                            south: southWest.lat,
                            west: southWest.lng,
                        }
                    };

                    axios.get(url, data)
                        .then(response => {

                            this.clearAllMarkers();

                            const photos = response.data;
                            this.setMarkers(photos);

                        });

                },
                initMap() {

                    const defaultLocation = [35.43745244091463, 135.11052025291264]; // 福知山のあたり
                    this.map = L.map('map').setView(defaultLocation, 11);

                    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                        attribution: '<a href="https://www.openstreetmap.org/">OpenStreetMap</a>',
                    }).addTo(this.map);

                    // 地図が動いたらマーカーを再取得して表示
                    this.map.on('moveend', () => this.getPhotos());
                    this.map.on('zoomend', () => this.getPhotos());

                    const mapElement = document.getElementById('map');

                    // ドラッグ・アンド・ドロップのイベント
                    mapElement.addEventListener('dragover', e => {

                        e.preventDefault();

                    });
                    mapElement.addEventListener('drop', e => {

                        e.preventDefault();
                        this.savePhoto(e);

                    });

                    // 初期表示時にマーカーを取得して表示
                    this.getPhotos();

                },

                // 写真の保存・削除
                getLocationFromMousePosition(x, y) {

                    const pointXY = L.point(x, y);
                    const location = this.map.containerPointToLatLng(pointXY);

                    return {
                        latitude: location.lat,
                        longitude: location.lng,
                    };

                },
                savePhoto(e) {

                    const files = e.dataTransfer.files;

                    if (files.length > 0) {

                        const file = files[0];
                        const location = this.getLocationFromMousePosition(e.clientX, e.clientY);

                        const url = '/map_photo';
                        const formData = new FormData();
                        formData.append('photo', file);
                        formData.append('latitude', location.latitude);
                        formData.append('longitude', location.longitude);

                        axios.post(url, formData)
                            .then(response => {

                                if(response.data.result === true) {

                                    this.getPhotos();

                                }

                            });

                    }

                },
                deletePhoto(photoId) {

                    if(confirm('写真を削除しますか?')) {

                        const url = `/map_photo/${photoId}`;

                        axios.delete(url)
                            .then(response => {

                                if(response.data.result === true) {

                                    this.getPhotos();

                                }

                            });

                    }

                },

                // 初期化
                init() {

                    this.initMap();

                }

            };

        </script>

    </body>
</html>

基本的にはleafletの使い方をAlpine.jsの中で実行しているだけなのですが、特に重要なのが、savePhoto()の部分です。

ここは、以下の流れのときに実行されます。

  1. ファイルをドラッグ・アンド・ドロップする
  2. マウスの位置から GPS 位置情報を取得する
  3. ファイル& GPS 位置情報を Laravel へ送信
  4. 保存

そして、マウス位置から地図の位置情報を取得するのがgetLocationFromMousePosition()です。

この中では、leafletが用意してくれているcontainerPointToLatLng()を使っているのですが、間違ってlayerpointtolatlng()を使ってしまうと、地図を移動したときにうまくいかなくなってしまって30分ほど時間を無駄にするので注意してください。(←私です😂)

ルートをつくる

では、最期にルートです。
以下を追加してください。

routes/web.php

use App\Http\Controllers\MapPhotoController;

// 省略

Route::prefix('map_photo')->controller(MapPhotoController::class)->group(function(){

    Route::get('/', 'index')->name('map_photo.index'); // 地図を表示
    Route::get('/list', 'list')->name('map_photo.list'); // 写真データを取得
    Route::get('/image/{map_photo}', 'image')->name('map_photo.image'); // 写真データ
    Route::post('/', 'store')->name('map_photo.store'); // 写真データを保存
    Route::delete('/{map_photo}', 'destroy')->name('map_photo.destroy'); // 写真データを削除

});

これで作業は完了です。
お疲れ様でした。😄👍

テストしてみる

では、実際に写真をドラッグ・アンド・ドロップして地図上に保存できるかをテストしてみましょう❗

ちなみに今回使うのは、私がこの前行った福知山周辺の3写真です。(※ なので、今回地図の初期位置は福知山あたりになっています)

1.福知山駅に到着

2.宮津駅で待ち合わせしてるときに買った「飲む冷麺」(味は…😅)

3.もちろん最期はクラフトビール

では、ブラウザで「https://******/map_photo」へアクセスします。

はい❗
地図が表示されました。😄

では、まず最初に福知山駅に写真をドラッグ・アンド・ドロップしてみます。

すると・・・・・・

はい❗ピンが表示されました。

では、このピンをクリックしてみましょう。

どうなるでしょうか・・・・・・

はい❗
ポップアップもうまく表示されました。

では、残りの3つも登録してみます。

はい❗
複数マーカーもうまく表示されましたね。

では、最期に(ちょっとだけうーん…だった)飲む冷麺の「写真を削除」ボタンを押してみましょう。

うまくいくでしょうか・・・・・・

はい❗
宮津駅にあったデータが消えました。

念のため、ストレージフォルダもチェックしておきましょう。

はい❗
こちらもファイルが削除されています。

すべて成功です😄✨

企業様へのご提案

今回のようにLeaflet.jsを利用すれば、Googleマップではできないようなことを工夫次第でいろいろと実装することができます(※ 正確にはGoogleマップも可能ですが、有料でちょっとお高目です)

もしこういった独自機能を地図に持たせたい場合はぜひお問合せからご相談ください。

お待ちしております。😄

おわりに

ということで、今回は「Leafletを使ってドラッグ・アンド・ドロップした写真を地図上に保存する」という機能をつくってみました。

ちなみに、「もし、こんなサービスがあったら個人的にはうれしいのにな…🤔」とも思ったのですが、毎回写真を地図上に登録しないといけないとなると、めんどうかななんて考えてました。(ビジネスのアイデア出しって難しいですね)

最近はYouTubeの「令和の虎」が好きでよく見ていますが、ああやって新しいものを形にしようとする人たちはとても尊敬します。(もし私が出たら岩井社長にボロカスに怒られて終わりそう…😭)

ぜひみなさんもいろんなこと妄想しながら「自由研究」してみてくださいね。

ではでは〜❗

「マネーの虎に出てた
ロマンドロール、
おいしかったな…」

開発のご依頼お待ちしております 😊✨
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly  

開発効率を上げるための機材・まとめ