地図上で標高を解析!Leafletを使った指定ポイントの標高計算法

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

さてさて、個人的な趣味なのですが「折りたたみ自転車 + 電車」で各地を巡っています。

どちらかといえば私は長距離型なので、長いサイクリングもそれほど苦ではないのですが、それでも

うーん、これなんとかなんないですかね…😫

と思うことがあります。

それが・・・・・・

急坂

です。夏なんかホント意識が飛びそうになるぐらいしんどいですし、あまりに長い場合は降りて押すしかありません。

しかも、坂って地図だとあんまり分からないんですよね…。

そこで❗
せっかくシステムエンジニアとして活動しているので、この「急坂回避」をJavaScriptでシステム化してみることにしました。

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

連続する急坂でよく言う言葉
「あとで登るなら下るなよ…😫」

開発環境: Leaflet 1.9.4、Alpine.js 3.12、tailwindCSS 3.3.2

標高を取得する方法

まずコードに行く前に「標高の取得」についてご紹介します。

標高を取得するにはGoogle MapAPIから取得できるのですが、これが高くはないものの完全有料(1,000回につき 5ドル)なので、趣味として使うには「うーん…」となっていました。

そして、実は過去にOpen Elevation APIという無料サービスもあったのですが、どうやらアクセスが集中しすぎて反応しない状態が続いているようです。

そこで、さらに調査を進めると Open-Meteo というオープンソースなサイトが Elevation API というものを提供しているのを発見。

特徴としては、次のとおりです。(2023.6.8 現在)

  • 無料で使える
  • ライセンスはクリエイティブ・コモンズ 4.0
  • 複数地点の標高を一気に取得できる
  • データは、JSON形式
  • 元データは「欧州宇宙機関」が提供しているもの
  • 解像度は、90メートルごと
  • ユーザー登録(API キー)は不要

無料で利用できるならこれだけの精度で十分すぎるぐらいですね。

ということで、今回はこのElevation APIを利用することにしました。

標高差を計算して傾斜を計算するコードをつくる

では、コードをご紹介しましょう。

elevation_angle.html

<html>
<head>
    <meta name="viewport" content="width=device-width, height=device-height, initial-scale=1, maximum-scale=1, user-scalable=no">
    <!-- ① CDNを使っています -->
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
    <script src="https://cdn.tailwindcss.com/3.3.2"></script>
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
    <!-- ② Alpine.js は defer で最後に読み込む -->
    <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.1/dist/cdn.min.js"></script>
</head>
<body>
    <div x-data="elevationAngle">
        <h1 class="p-3">📐 複数地点をクリックすると傾斜を計算します</h1>
        <div id="map" class="h-1/2 border-2 border-gray-300"></div>
        <div class="grid md:grid-cols-2 p-5">
            <div class="mb-6">
                <div class="mb-2">📌 日本の3大傾斜スポットで試してみる</div>
                <ul>
                    <template x-for="place in examplePlaces">
                        <li>
                            <a
                                href="#"
                                class="text-blue-700 underline"
                                x-text="place.name"
                                @click.prevent="onExamplePlaceClick(place)"></a>
                        </li>
                    </template>
                </ul>
            </div>
            <div class="mb-20">
                📱 計算結果
                <template x-if="results.length > 0">
                    <span>
                        <span>(<span x-text="results.length"></span> 件)</span>
                        <small>
                            <a href="#" class="text-blue-700 underline" @click.prevent="removeMarkers">クリア</a>
                        </small>
                    </span>
                </template>
                <template x-for="(result,index) in results">
                    <div class="mt-2 p-3 border rounded flex items-center bg-gray-50 whitespace-nowrap" x-show="! isElevationLoading">
                        <span class="bg-gray-300 rounded-full px-2 mr-2" x-text="index + 1"></span>
                        標高:<span x-text="result.elevations.first"></span>m → <span x-text="result.elevations.second"></span>m|
                        傾斜:<span x-text="result.percent"></span>%|
                        距離:<span x-text="result.distance"></span>メートル
                    </div>
                </template>
                <template x-if="results.length == 0">
                    <div class="mt-2 p-3 border rounded flex items-center bg-gray-50 text-gray-500">
                        計算結果はありません
                    </div>
                </template>
            </div>
        </div>
    </div>
    <div class="fixed bottom-0 right-0 left-0 p-3 text-center bg-green-200 border-t border-t-green-300">
        <small>
            このコードを使ったウェブサイトは、 <a href="https://machi-collection.com/" class="text-blue-700 underline">こちら</a>(📏 定規のアイコンです)
        </small>
    </div>
    <script>

        const elevationAngle = {

            // ③ 標高
            isElevationLoading: false,
            elevations: [],
            getElevations() {

                this.$nextTick(() => {

                    if(this.isElevationLoading === true) return;

                    this.isElevationLoading = true;

                    let latitudes = this.selectedLocations.map(location => location.latitude);
                    let longitudes = this.selectedLocations.map(location => location.longitude);

                    const url = `https://api.open-meteo.com/v1/elevation?latitude=${latitudes.join(',')}&longitude=${longitudes.join(',')}`;

                    fetch(url)
                        .then(response => response.json())
                        .then(data => {

                            this.elevations = data.elevation;

                        })
                        .finally(() => {

                            this.isElevationLoading = false;

                        });

                });

            },

            // ④ 結果
            get results() {

                let data = [];

                for(let i = 0 ; i < this.selectedLocations.length ; i++) {

                    const firstLocation = this.selectedLocations[i];
                    const SecondLocation = this.selectedLocations[i + 1];
                    const firstElevation = this.elevations[i];
                    const secondElevation = this.elevations[i + 1];

                    if(SecondLocation === undefined || secondElevation === undefined) break;

                    const distanceInMeters = this.calcDistance(firstLocation, SecondLocation);
                    const slopePercent = Math.round((secondElevation - firstElevation) / distanceInMeters * 10000) / 100;

                    data.push({
                        percent: slopePercent,
                        distance: this.getFormatNumber(distanceInMeters),
                        elevations: {
                            first: this.getFormatNumber(firstElevation),
                            second: this.getFormatNumber(secondElevation),
                        }
                    });

                }

                return data;

            },
            calcDistance(coordinates1, coordinates2) {

                // 引用: https://stackoverflow.com/questions/14560999/using-the-haversine-formula-in-javascript

                const latitude1 = coordinates1.latitude;
                const longitude1 = coordinates1.longitude;
                const latitude2 = coordinates2.latitude;
                const longitude2 = coordinates2.longitude;

                const toRadian = angle => (Math.PI / 180) * angle;
                const distance = (a, b) => (Math.PI / 180) * (a - b);
                const RADIUS_OF_EARTH_IN_KM = 6371;
                const diffLatitude = distance(latitude2, latitude1);
                const diffLongitude = distance(longitude2, longitude1);
                const latitudeRadian1 = toRadian(latitude1);
                const latitudeRadian2 = toRadian(latitude2);

                const a =
                    Math.pow(Math.sin(diffLatitude / 2), 2) +
                    Math.pow(Math.sin(diffLongitude / 2), 2) * Math.cos(latitudeRadian1) * Math.cos(latitudeRadian2);
                const c = 2 * Math.asin(Math.sqrt(a));
                const originalKilometers = RADIUS_OF_EARTH_IN_KM * c;

                return Number(
                    Math.round(originalKilometers * 1000) / 1000 * 1000
                );

            },

            // ⑤ 地図
            map: null,
            selectedLocations: [],
            markers: [],
            initMap() {

                const initialLocation = [35.70990329289358, 139.81077267654356]; // 東京スカイツリー
                this.map = L.map('map')
                    .setView(initialLocation, 15)
                    .on('click', (e) => this.onMapClick(e));

                L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
                    minZoom: 0,
                    maxZoom: 19,
                    attribution: '<a href="https://open-meteo.com/" class="text-blue-500 underline" target="_blank">Open-Meteo</a> | <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
                })
                .addTo(this.map);

            },
            onMapClick(e) {

                const latitude = e.latlng.lat;
                const longitude = e.latlng.lng;

                this.selectedLocations.push({latitude, longitude});
                this.addMarker(latitude, longitude);

            },
            addMarker(latitude, longitude) {

                if(this.isElevationLoading === true) return;

                const marker = L.marker([latitude, longitude]).addTo(this.map);
                this.markers.push(marker);

                this.moveToCenterLocation();
                this.getElevations();

            },
            removeMarker(marker) {

                this.map.removeLayer(marker);

            },
            removeMarkers() {

                this.selectedLocations = [];
                this.markers.forEach(marker => this.removeMarker(marker));

            },
            moveToCenterLocation() {

                let totalLatitude = 0;
                let totalLongitude = 0;

                this.selectedLocations.map(location => {

                    totalLatitude += location.latitude;
                    totalLongitude += location.longitude;

                });

                const locationLength = this.selectedLocations.length;
                const centerLatitude = totalLatitude / locationLength;
                const centerLongitude = totalLongitude / locationLength;

                this.map.panTo([centerLatitude, centerLongitude]);

            },

            // ⑥ サンプル地点
            examplePlaces: [
                {
                    name: '暗峠(大阪〜奈良)',
                    locations: [
                        {latitude: 34.67242604283984, longitude: 135.6530518681816},
                        {latitude: 34.66577279112899, longitude: 135.67163421205817},
                    ],
                    zoom: 15,
                },
                {
                    name: 'きゃあまぐる坂(長崎)',
                    locations: [
                        {latitude: 32.743338326993985, longitude: 129.85538950437464},
                        {latitude: 32.74474381081751, longitude: 129.85551690930515},
                    ],
                    zoom: 16,
                },
                {
                    name: 'のぞき坂(東京)',
                    locations: [
                        {latitude: 35.71723769630826, longitude: 139.71368553335617},
                        {latitude: 35.71798791275797, longitude: 139.71409524078575},
                    ],
                    zoom: 17,
                },
            ],
            onExamplePlaceClick(place) {

                if(this.isElevationLoading === true) return;

                this.removeMarkers();

                const locations = [...place.locations]; // 配列をコピーしないと、参照されて追加になってしまう
                this.selectedLocations = locations;

                locations.forEach(location => {

                    this.addMarker(location.latitude, location.longitude)

                });

                this.map.setZoom(place.zoom);
                this.getElevations();

            },

            // その他
            getFormatNumber(number) {

                return number.toString().replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,');

            },

            // 初期化
            init() {

                this.initMap();

            }
        };

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

① CDN を使っています

最近ブログを書いていて「やっぱすぐ試せるようにした方がいいよね…🤔」と思うことも多くあるので、できるだけビルドなどはしない方法で提供していくことにしました。

そのため、今回は何もしなくてもJavaScriptCSSのライブラリが使えるようフルでCDNを使っています。

CDNとは「別サイトで提供されているライブラリ」で、インターネットを通して読み込みます。そのため、もしかすると今後提供が停止される可能性がなくもないので、本番サイトでは使わない方がベターです。

② Alpine.js は defer で最後に読み込む

Alpine.jsはすべてのコードをセットし終わってから読み込むようにしないとエラーになります。

そのため、以下のようにscriptタグにはdeferをつけています。

<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.12.1/dist/cdn.min.js"></script>

 ③ 標高

標高データの取得は、先程書いたようにOpen-MeteoからAjaxを通して取得します。

なお、URLは以下のとおりで(ここに緯度)(ここに経度)の部分を置き換えてアクセスするだけでOKです。

https://api.open-meteo.com/v1/elevation?latitude=(ここに緯度)&longitude=(ここに経度)ま、たt

なお、データ取得したい地点の指定は「複数OK」で、例えば以下のように感まで区切ることでパラメータをセットできます。

https://api.open-meteo.com/v1/elevation?latitude=34.67242604283984,34.66577279112899&longitude=135.6530518681816,135.67163421205817

なお、this.$nextTickは、Vueにも同様の機能がありますが、これは「データ内容が変わって表示の変更が終わったら実行する」機能です。

イメージとしては「引っ越して、落ち着いたらご飯でも行こう」的な感じです😉

④ 結果

ここは数学がそれほど得意ではない私には鬼門なんですが、stackoverflowなどで紹介されている公式を使って2点間の距離・傾斜を計算しています。

手順としては次のとおりです。

  1. 2点間の距離を取得(位置情報 から計算)
  2. 2地点の標高を取得(Open-Meteo から取得)
  3. 上記のデータを使って傾斜(%)を取得

⑤ 地図

地図はGoogle Mapではなく完全フリーのLeaflet + OpenStreetMapを使っています。(最近OpenStreetMapの地図の情報量がホントにしっかりしてきました👍)

なお、このブロックで実行しているのは以下のとおりです。

  • 地図の表示
  • 地図をクリックしたときのマーカーを表示
  • マーカーのクリア
  • マーカーが追加されたときに地図を中心地に移動させる

⑥ サンプル地点

住んでいる地域のどこに急坂があるか分からない方もいらっしゃるかと思いましたので、以下3つの「日本3大急坂?」とも呼ばれる以下3つの場所を用意し、クリックするだけで傾斜が計算できるようにしています。

  • 暗峠(大阪〜奈良)
  • きゃあまぐる坂(長崎)
  • のぞき坂(東京)

ちなみに、暗峠は、花園ラグビー場あたりから見ると、進撃の巨人に登場する「ウォールマリア」のようなたたずまいでした(笑)もう二度と登りません😫

デモページをつくりました

せっかくなので今回のコードを試せるようデモページを作成しました。
ぜひ以下から試してみてください。(サイクリング用に使えると思います👍)

📝 デモページ

企業様へのご提案

今回のように地図に関連するシステムは工夫をすればフリーで実装することができます。

例えば、今回利用したOpen-MeteoのAPIを使うと以下のような機能をフリーで実装することができます。

  • 都市名から GPSデータを取得する
  • 天気情報
  • 空気がきれいかどうか(PM 2.5など)
  • 洪水情報

もしこういったデータを使ってのシステム構築をご希望でしたらお気軽にお問い合わせからご連絡ください。お待ちしております。😄✨

おわりに

ということで、今回は「標高データから傾斜を計算し急坂を回避できる」機能を実装してみました。

なお、今回Ajaxにはaxiosではなく、標準関数のfetchを使っていますが、これはどうやらaxiosContent-Typeが自動でセットされる(🤔??)ために、corsに引っかかったためです。

そして、fetchで試してみたらうまくいったので、「じゃ、ま、それでいっか👍」となりました。(なのでおそらくaxiosでもいけるはずです)

深く掘り下げてはいないんですけど、以下の記事で解説されてました。

📝 参考ページ: Axios fails CORS but fetch works fine

ChatGPTが人気になってもstackoverflowにはこれまでどおり頑張ってほしいものです。

ではでは〜❗

「空調ファン、まじで
涼しくなりました❗」

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

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