Laravel + Geometry 型カラムで身近な夜景スポットをマップ表示する

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

さてさて、その昔とあるマーケティング本をよく読んでいたのですが、そこには「人は身近な情報ほど知りたがる」というルールが書かれていました。

これは、例えば地方に住んでる人間からすると、

「東京のオシャレで美味しい、安い店」

よりも

「そんなにオシャレじゃないし、安くもないけど、うまいと評判のすぐ近くの店」

の方がすぐ行けるので、より興味を持ちやすいということですね。

また、この本には「多くの人はメジャーリーグより地元の試合の方が興味があった」という例も書かれていて、「距離」というのは人間にとって重要な条件のひとつだと言っていいと思います。

そして❗

せっかくこの重要なルールを学んだのだから、プログラマとして有効活用したいなと思い、今回ある機能を実装してみたくなりました。

それは・・・・・・

現在地に近いスポットをリアルタイムでマップ表示する

というものです。

つまり、マップを移動するとその緯度/経度から近い順にスポットを見つけ出し、そのマップに表示するという機能です。

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

「うまいクラフトビールが近くにあるの❗❓」

開発環境: Laravel 8.x、Vue 2、MySQL 8

位置データの準備

まず最初に「どこに何がある」という緯度/経度とひもづいたデータが必要です。

いろいろ探した結果、以下のページから「夜景100選」のCSVyakei02.csv)をダウンロードさせていただきました。

まさにこういったデータを探してました。
ありがとうございます😊✨

📝 参考URL: 日本百選と座標値(経緯度数値)

※ 他にも「夕日」や「名水」のデータもあります。ナイスですね👍

なお、ダウンロードしたzipファイルは展開してstorage/app/csvフォルダへ移動させておいてください。

必要なファイルをつくる

続いて、Laravelで必要な以下のファイルをつくります。

  • マイグレーション: DBテーブル構成
  • モデル: DBテーブルへのアクセス
  • Seeder: 夜景データをテーブルへ入れる
  • コントローラー: ブラウザからのアクセス用

以下のコマンドを実行してください。

php artisan make:model NightView -msc

すると一気にファイルが4つ作成されるので、それぞれ中身を以下のように変更してください。

モデルの設定

では、まずはモデルです。

app/Models/NightView.php

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class NightView extends Model
{
    use HasFactory;

    protected $appends = ['map_url'];

    // Scope
    public function scopeSelectDistance($query, $longitude, $latitude) // 現在地との距離を取得できるようにしてます
    {
        $query->selectRaw(
            'id, name, address, '.
            'ST_Y(location) AS longitude, ST_X(location) AS latitude, '.
            'st_distance_sphere(POINT(?, ?), POINT(ST_Y(location), ST_X(location)))  AS distance',
            [ $longitude, $latitude,]
        );
    }

    // Accessor
    public function getMapUrlAttribute()
    {
        return 'https://www.google.com/maps/search/?api=1&query='. $this->latitude .','. $this->longitude;
    }

    // Mutator
    public function setLocationAttribute($values)
    {
        $point = 'POINT('. $values['latitude'] .' '. $values['longitude'] .')';
        $this->attributes['location'] = DB::raw('ST_GeomFromText("'. $point .'")');
    }
}

この中で重要なのが、scopeSelectDistance()の部分です。

ここでは、現在表示している位置と夜景スポットとの距離を計算しdistanceとして取得ができるようにしています。

また、geometry型のカラムから緯度・経度を取得するためにST_X()ST_Y()というMySqlの関数を使っています。

マイグレーションの設定

database/migrations/****_**_**_******_create_night_views_table.php

// 省略

public function up()
{
    Schema::create('night_views', function (Blueprint $table) {
        $table->id();
        $table->string('name')->comment('場所名');
        $table->string('address')->comment('所在地');
        $table->geometry('location')->comment('緯度・経度');
        $table->timestamps();

        $table->spatialIndex('location');
    });
}

この中で注意が必要なのがgeometry型のカラムlocationで、この1つのカラムに緯度・経度2つの情報をもたせることができます。

また、spatialIndexは「空間インデックス」と呼ばれるもので、位置データ用に検索の効率化ができるインデックスです。

Seederの設定

では、先ほどダウンロードしてきた「夜景データ」をそっくりDBテーブルnight_viewsテーブルへ保存するためにSeederを設定していきます。

database/seeders/NightViewSeeder.php

<?php

namespace Database\Seeders;

use App\Models\NightView;
use Illuminate\Database\Seeder;

class NightViewSeeder extends Seeder
{
    /**
     * Run the database seeds.
     *
     * @return void
     */
    public function run()
    {
        $csv_path = storage_path('app/csv/yakei02.csv');
        $lines = new \SplFileObject($csv_path);

        foreach ($lines as $index => $line) {

            if($index > 0) {

                $line = mb_convert_encoding($line, 'UTF-8', 'SJIS-win'); // 👈 文字コードを UTF-8 へ変換してます
                $values = str_getcsv($line);

                if(!is_null($values[0])) {

                    $night_view = new NightView();
                    $night_view->name = $values[1];
                    $night_view->location = [
                        'latitude' => $values[2],
                        'longitude' => $values[3]
                    ];
                    $night_view->address = $values[4];
                    $night_view->save();

                }

            }

        }

    }
}

この中で、気をつけていただきたいのが「文字コード」です。
ダウンロードしてきたデータはShift_JISで保存されているため、ループの中で変換を行っています。

そして、Seederは作成しただけでは有効になりませんので、Laravel側へ登録しておきましょう。

database/seeders/DatabaseSeeder.php

// 省略

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        // 省略

        $this->call(NightViewSeeder::class);
    }
}

これで、DBまわりの準備は完了しました。
以下のコマンドを実行してDBを再構築しましょう。

php artisan migrate:fresh --seed

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

コントローラーの設定

次にコントローラーを設定していきます。
必要なのは以下の2つのメソッドです。

  • index: ブラウザから直接アクセスする部分
  • list: Ajax で夜景データを取得する部分

app/Http/Controllers/NightViewController.php

<?php

namespace App\Http\Controllers;

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

class NightViewController extends Controller
{
    public function index()
    {
        return view('night_view.index');
    }

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

        $latitude = $request->latitude;   // 緯度
        $longitude = $request->longitude; // 経度
        $night_views = [];

        try {

            $night_views = NightView::selectDistance($longitude, $latitude)
                ->orderBy('distance', 'asc')
                ->take(10)
                ->get();

        } catch(\Exception $e) {}

        return [
            'result' => true,
            'night_views' => $night_views
        ];
    }
}

ちなみに、なぜtry ~ catchを使っているかというと、送信されてくる位置情報によってはエラーが発生する場合があるからです。(どうやら遠くの場所を表示するとエラーになるようでした)

ビューをつくる

では、先ほどのindex()で指定したビューをつくっていきましょう。

resources/views/night_view/index.blade.php

<html>
<head>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.7.1/dist/leaflet.css" />
</head>
<body>
<div id="app" class="p-4">
    <h3>近くの夜景スポットをマップ表示するサンプル</h3>
    <div class="text-secondary mb-1">
        <small>現在地から近い順に最大10件の夜景スポットを取得します(現在地を移動させてみてください)</small>
    </div>
    <div id="map" style="width:100%;height:500px;border:1px solid #000;"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.21.1/axios.min.js"></script>
<script src="https://unpkg.com/leaflet@1.7.1/dist/leaflet.js"></script>
<script>

    new Vue({
        el: '#app',
        data() {
            return {
                nightViews: [],
                markers: [],
                location: {
                    longitude: '135.18671362704455',
                    latitude: '34.68422333531551',
                },
                map: null
            }
        },
        methods: {
            getNightViews() {

                const url = '{{ route('night_view.list') }}';
                const params = {
                    params: this.location
                };
                axios.get(url, params)
                    .then(response => {

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

                            this.nightViews = response.data.night_views;
                            this.setMarkers();

                        }

                    });

            },
            setMarkers() {

                this.markers.forEach(marker => this.map.removeLayer(marker));
                this.markers = [];

                this.nightViews.forEach(nightView => {

                    const location = [nightView.latitude, nightView.longitude];
                    const marker = L.marker(location).addTo(this.map)
                        .bindPopup(`<a href="${nightView.map_url}" target="_blank">Googleマップで表示</a>`)
                        .bindTooltip(`${nightView.name}(${nightView.address})`);
                    this.markers.push(marker);

                });

            },
            setCenter(e) {

                const center = e.target.getCenter();
                this.location = {
                    latitude: center.lat,
                    longitude: center.lng
                };

            }
        },
        watch: {
            location: {
                deep: true,
                immediate: true,
                handler() {

                    Vue.nextTick(() => {

                        this.getNightViews();

                    });

                }
            }
        },
        mounted() {

            // マップを用意
            this.map = L.map('map', {
                zoomAnimation: false
            }).setView([ this.location.latitude, this.location.longitude ], 8);
            const layerUrl = 'https://cyberjapandata.gsi.go.jp/xyz/pale/{z}/{x}/{y}.png';
            const attribution = '<a href="https://www.gsi.go.jp/kikakuchousei/kikakuchousei40182.html" target="_blank" rel="noopener">国土地理院</a>';
            L.tileLayer(layerUrl, { attribution: attribution }).addTo(this.map);
            this.map
                .on('moveend', e => {

                    this.setCenter(e);

                })
                .on('zoomend', e => {

                    this.setCenter(e);

                });

        }
    });

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

この中では以下の手順でマップ上にマーカーをセットしています。

  1. 地図を表示
  2. 現在の位置データを送信し、近くの夜景スポット・データを取得
  3. そのデータから緯度・経度を取得しマーカー表示

※ ちなみに、今回は初期の位置は私の地元・神戸にセットしています。
お好みでdata()内のlocationを変更して実行してみてください。

また、今回実はVueはバージョン2を使っています。
バージョン3を使っても表示はできるのですが、どうやら地図ライブラリleafletとの相性が悪いようでズームを変更したりするとエラーが発生することがわかり、急遽バージョン2を使うように変更しました😫

📝 参考ページ: https://stackoverflow.com/questions/65981712/uncaught-typeerror-this-map-is-null-vue-js-3-leaflet

ルートをつくる

では最後にルートをつくっていきましょう。

routes/web.php

use \App\Http\Controllers\NightViewController;

// 省略

Route::get('night_view', [NightViewController::class, 'index'])->name('night_view.index');
Route::get('night_view/list', [NightViewController::class, 'list'])->name('night_view.list');

さぁ、これですべて完了です。
お疲れ様でした😊✨

テストしてみる

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

まず「http://******/night_view」にブラウザでアクセスします。

はい❗
神戸周辺の夜景スポットが表示されていますね。

では、マップをドラッグ・アンド・ドロップして少し名古屋の方へ移動してみましょう。

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

はい❗
マップの移動に合わせて夜景スポットの場所も移動しました。

では、マーカーにセットしたツールチップがうまく動くかのチェックです。
マーカーの上にマウスカーソルを合わせてみましょう。

すると・・・・・・

夜景スポットの名前と所在地が表示されました❗

では、最後にマーカーをクリックして Googleマップへのリンクが表示されるかチェックします。

こうなりました。
↓ ↓ ↓

うまく表示されました。
全て成功です😊✨

※ 確かGoogleマップのスクリーンショットを撮って公開してはいけなかったかと思いますので、移動先は省略します。

お疲れ様でした❗

デモをつくりました

マップは実際に触ってもらった方が分かりやすいかと思いましたのでデモページをご用意しました。

ぜひお気軽にお試しください。✨😊👍

📝 デモページ: 近くの夜景スポットをマップ表示するサンプル

企業様へのご提案

今回利用した地図ライブラリLeaflet(&地理院地図)は無料で使うことができます。

もちろん精度で言うとGoogleマップの方が上ですが、有料(しかも他のAPIと比べても割高)になってしまいましたので、もしそれほど精度は不要とのことでしたらランニング・コストをカットすることができるかと思います。

また、geometry型のテーブルと空間インデックスを使っているのでより高速に近くのスポットを探し出すことができます。

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

おわりに

ということで今回は、「近くにある夜景スポット」が表示できるようにしてみました。

ちなみに、これまで地理院地図は中身がスカスカなのかと思っていましたが、今回使ってみるとなかなか地図もきれいですし、主要なランドマークは記載されているので企業案件でも使いどころはあるだろうなと感じました。

しかも、地理院地図には最近のものだけでなく、過去の航空写真まで使えるようになっていてホントに便利な世の中を実感しています。

つまり、時間をもてあましたときにはバーチャルで「時間旅行」ができるんですね。(私は地図を見るのが好きなので、あっという間に時間が過ぎちゃったりします😂)

ぜひ皆さんも地図を使った開発をしてみてくださいね。

ではでは〜❗

「奈良でおいしいクラフトビール見つけました🍺✨」

開発のご依頼お待ちしております 😊✨ お問い合わせ
また、こちらもお待ちしております。
  • 実案件の開発サポート: 詳細
  • ツイッターのフォロー: 詳細
どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly