
九保すこひです(フリーランスのITコンサルタント、エンジニア)
さてさて、個人的な趣味なのですが「折りたたみ自転車 + 電車」で各地を巡っています。
どちらかといえば私は長距離型なので、長いサイクリングもそれほど苦ではないのですが、それでも
うーん、これなんとかなんないですかね…
と思うことがあります。
それが・・・・・・
急坂
です。夏なんかホント意識が飛びそうになるぐらいしんどいですし、あまりに長い場合は降りて押すしかありません。
しかも、坂って地図だとあんまり分からないんですよね…。
そこで
せっかくシステムエンジニアとして活動しているので、この「急坂回避」をJavaScript
でシステム化してみることにしました。
ぜひ何かの参考になりましたら嬉しいです。
連続する急坂でよく言う言葉
「あとで登るなら下るなよ…」
開発環境: Leaflet 1.9.4、Alpine.js 3.12、tailwindCSS 3.3.2
目次 [非表示]
標高を取得する方法
まずコードに行く前に「標高の取得」についてご紹介します。
標高を取得するにはGoogle Map
のAPI
から取得できるのですが、これが高くはないものの完全有料(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 を使っています
最近ブログを書いていて「やっぱすぐ試せるようにした方がいいよね…」と思うことも多くあるので、できるだけビルドなどはしない方法で提供していくことにしました。
そのため、今回は何もしなくてもJavaScript
やCSS
のライブラリが使えるようフルで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点間の距離・傾斜を計算しています。
手順としては次のとおりです。
- 2点間の距離を取得(位置情報 から計算)
- 2地点の標高を取得(Open-Meteo から取得)
- 上記のデータを使って傾斜(%)を取得
⑤ 地図
地図はGoogle Map
ではなく完全フリーのLeaflet + OpenStreetMap
を使っています。(最近OpenStreetMap
の地図の情報量がホントにしっかりしてきました)
なお、このブロックで実行しているのは以下のとおりです。
- 地図の表示
- 地図をクリックしたときのマーカーを表示
- マーカーのクリア
- マーカーが追加されたときに地図を中心地に移動させる
⑥ サンプル地点
住んでいる地域のどこに急坂があるか分からない方もいらっしゃるかと思いましたので、以下3つの「日本3大急坂?」とも呼ばれる以下3つの場所を用意し、クリックするだけで傾斜が計算できるようにしています。
-
暗峠(大阪〜奈良)
-
きゃあまぐる坂(長崎)
-
のぞき坂(東京)
ちなみに、暗峠は、花園ラグビー場あたりから見ると、進撃の巨人に登場する「ウォールマリア」のようなたたずまいでした(笑)もう二度と登りません
デモページをつくりました
せっかくなので今回のコードを試せるようデモページを作成しました。
ぜひ以下から試してみてください。(サイクリング用に使えると思います)
企業様へのご提案
今回のように地図に関連するシステムは工夫をすればフリーで実装することができます。
例えば、今回利用したOpen-Meteo
のAPIを使うと以下のような機能をフリーで実装することができます。
- 都市名から GPSデータを取得する
- 天気情報
- 空気がきれいかどうか(PM 2.5など)
- 洪水情報
もしこういったデータを使ってのシステム構築をご希望でしたらお気軽にお問い合わせからご連絡ください。お待ちしております。
おわりに
ということで、今回は「標高データから傾斜を計算し急坂を回避できる」機能を実装してみました。
なお、今回Ajax
にはaxios
ではなく、標準関数のfetch
を使っていますが、これはどうやらaxios
のContent-Type
が自動でセットされる(??)ために、
cors
に引っかかったためです。
そして、fetch
で試してみたらうまくいったので、「じゃ、ま、それでいっか」となりました。(なのでおそらく
axios
でもいけるはずです)
深く掘り下げてはいないんですけど、以下の記事で解説されてました。
参考ページ: Axios fails CORS but fetch works fine
ChatGPT
が人気になってもstackoverflow
にはこれまでどおり頑張ってほしいものです。
ではでは〜
「空調ファン、まじで
涼しくなりました」