OpenRouteServiceとLeafletで坂道がわかるルート検索アプリを作ってみた
2025-06-30 勉強会

こんにちは。クリエイティブ戦略部デザインユニットの松本です。
今回の勉強会では、趣味のサイクリングから着想を得て「ルート上の坂がひと目でわかる地図アプリ」を作ってみました。
ルート検索の度に感じていたちょっとしたストレスをどうにかすべく、ChatGPTの力も借りながらローカル環境で動くWEBアプリの実装に挑戦しました!
今回の勉強会では、趣味のサイクリングから着想を得て「ルート上の坂がひと目でわかる地図アプリ」を作ってみました。
ルート検索の度に感じていたちょっとしたストレスをどうにかすべく、ChatGPTの力も借りながらローカル環境で動くWEBアプリの実装に挑戦しました!
普段のルート検索で感じていたこと
普段は Google マップでルート検索をしているのですが、サイクリングで使うには少し不便だな…と感じる点がいくつかありました。
1. 坂道かどうかがわからない!
サイクリングでルート検索する際は土地勘のない場所を走ることが多いため、提案されたルートをそのまま使うことがほとんどです。
その結果、現地に行って初めて坂道と知り苦しむことが何度かありました。
Googleマップでは標高のグラフが表示されますが、ルートの距離によってグラフの傾きの見え方が変わるため、実際の坂のきつさと印象がずれてしまうことがあります。
また、グラフ上の標高とルート上の地点との対応も把握しづらいと感じることがありました。
その結果、現地に行って初めて坂道と知り苦しむことが何度かありました。
Googleマップでは標高のグラフが表示されますが、ルートの距離によってグラフの傾きの見え方が変わるため、実際の坂のきつさと印象がずれてしまうことがあります。
また、グラフ上の標高とルート上の地点との対応も把握しづらいと感じることがありました。
2.サイクリングロードがルートに反映されないことが多い
交通手段を「自転車」に設定してルート検索をしても、サイクリングロードが反映されないことが多いです。
例えば以下のルートだと豊平川のサイクリングロードは使われていません。
私はあまり一般道を通りたくないので、「サイクリングロードの入り口まで」「出口から目的地まで」と、ルートを分けて検索するという面倒なことをしていました。
例えば以下のルートだと豊平川のサイクリングロードは使われていません。
私はあまり一般道を通りたくないので、「サイクリングロードの入り口まで」「出口から目的地まで」と、ルートを分けて検索するという面倒なことをしていました。
作る機能を整理
以上を踏まえて今回作るアプリの目標は、次の2点に絞ることにしました。
一つ目は「地図上のルートを見ただけで勾配のきつさがわかること」、
二つ目は「ルートにサイクリングロードを含めること」です。
勾配の表示については、カーナビの渋滞情報のように、ルート線の色を変えることで直感的に坂のきつさを伝える方法がよさそうだと考えました。そこで、坂の勾配に応じて線の色が変わる仕組みの実装に挑戦します。
サイクリングロードを含めたルート検索については、使用する地図サービスの精度に左右されると思うので、「できたら達成できたらいいな〜」くらいの目標にします。
一つ目は「地図上のルートを見ただけで勾配のきつさがわかること」、
二つ目は「ルートにサイクリングロードを含めること」です。
勾配の表示については、カーナビの渋滞情報のように、ルート線の色を変えることで直感的に坂のきつさを伝える方法がよさそうだと考えました。そこで、坂の勾配に応じて線の色が変わる仕組みの実装に挑戦します。
サイクリングロードを含めたルート検索については、使用する地図サービスの精度に左右されると思うので、「できたら達成できたらいいな〜」くらいの目標にします。
どうやって作るか
今回は、HTML・CSS・JavaScript を使って、ローカル環境で動作する Webアプリとして構築することにしました。
どれも業務で触れている技術だったので、「これなら自分でもなんとか作れそうだな」と思ったのが理由です。
使用した主なライブラリとAPIは以下の通りです。
・Leaflet:JSで地図を表示・操作するためのライブラリ
・OpenStreetMap:地図タイル(地図の見た目)として使用
・OpenRouteService:ルート検索や標高データの取得に使用
以下のようなイメージです。
どれも業務で触れている技術だったので、「これなら自分でもなんとか作れそうだな」と思ったのが理由です。
使用した主なライブラリとAPIは以下の通りです。
・Leaflet:JSで地図を表示・操作するためのライブラリ
・OpenStreetMap:地図タイル(地図の見た目)として使用
・OpenRouteService:ルート検索や標高データの取得に使用
以下のようなイメージです。

どれも無料で使えるオープンソースのサービスだったため、まずはこちらを使ってみることにしました。
いざ制作!
まずは地図を表示してみる
HTMLを作成し Leaflet の CSS と JavaScript を読み込みます。
また、地図を表示するための div 要素を用意します。
また、地図を表示するための div 要素を用意します。
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>地図表示だけ</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="js/map_only.js" defer></script>
</body>
またJSファイルを作成し、地図の中心座標と使用する地図タイルを設定します。
const map = L.map('map').setView([43.05, 141.35], 11); // 札幌駅
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
実行すると…?

札幌駅を中心とした地図が表示できました!
ルートを表示する
続いて、地図上にルートを表示してみます。
ルート情報の取得には、ORS(OpenRouteService)が提供する Directions API を使用します。
まずは ORS のアカウントを作成し、以下の画面でAPIキーを取得しました。
ルート情報の取得には、ORS(OpenRouteService)が提供する Directions API を使用します。
まずは ORS のアカウントを作成し、以下の画面でAPIキーを取得しました。

ルートを取得する
ルート検索では、以下の情報を URL に含めてリクエストを送ります。
・取得した API キー
・移動手段
・出発地と到着地の緯度・経度
ちなみに、ORS では移動手段を9種類から選ぶことができ、自転車だけでも以下の4タイプがあります。
・普通の自転車(cycling-regular)
・ロードバイク(cycling-road)
・マウンテンバイク(cycling-mountain)
・電動自転車(cycling-electric)
今回は試しに「普通の自転車」= cycling-regularを使用します。
取得したルート情報をもとに、Leaflet を使って地図上に線を描くことでルートを表示します。
※ORSから返ってくる座標は「経度・緯度」ですが、Leaflet では「緯度・経度」で指定する必要があるため、変換が必要です。
・取得した API キー
・移動手段
・出発地と到着地の緯度・経度
ちなみに、ORS では移動手段を9種類から選ぶことができ、自転車だけでも以下の4タイプがあります。
・普通の自転車(cycling-regular)
・ロードバイク(cycling-road)
・マウンテンバイク(cycling-mountain)
・電動自転車(cycling-electric)
今回は試しに「普通の自転車」= cycling-regularを使用します。
取得したルート情報をもとに、Leaflet を使って地図上に線を描くことでルートを表示します。
※ORSから返ってくる座標は「経度・緯度」ですが、Leaflet では「緯度・経度」で指定する必要があるため、変換が必要です。
const start = "141.3507553782371, 43.068815115306776";//札幌駅
const end = "141.34269888506552, 42.99818954646824";//真駒内公園
fetch(`https://api.openrouteservice.org/v2/directions/cycling-regular?api_key=${apiKey}&start=${start}&end=${end}`)
.then((res) => res.json())
.then((data) => {
const coords = data.features[0].geometry.coordinates;
const latlngs = coords.map(coord => [coord[1], coord[0]]); // [lat, lng] に変換
L.polyline(latlngs, {
color: 'blue',
weight: 5
}).addTo(map);
map.fitBounds(L.polyline(latlngs).getBounds());
});
ルート検索で使用できる交通手段の一覧はこちら:Openrouteservice Profiles
https://giscience.github.io/openrouteservice-r/reference/ors_profile.html取得結果はいかに!
試しに、札幌駅から真駒内公園までのルートを表示してみました。

きちんと川沿いのサイクリングロードを通っていることが確認できました!
自転車のモードを全て試してみましたが、大きな違いは見られなかったため今回は「cycling-regular」を使用します。
自転車のモードを全て試してみましたが、大きな違いは見られなかったため今回は「cycling-regular」を使用します。
勾配で色分けをする
ルートの表示ができたので、次は勾配に応じて線の色を変えることに挑戦しました。
そのために、先ほど取得したルートデータを ORS の Elevation API(標高取得用API) に渡して、各地点の標高を取得します。
ルート表示に使っていたJSに標高取得の処理を追加し、実行してみたところ……
なんと 「CORSエラー」 が発生してしまいました。
そのために、先ほど取得したルートデータを ORS の Elevation API(標高取得用API) に渡して、各地点の標高を取得します。
ルート表示に使っていたJSに標高取得の処理を追加し、実行してみたところ……
なんと 「CORSエラー」 が発生してしまいました。

CORSエラーとは
簡単に言うとCORSエラーとは、ウェブサイトが他のサーバーからデータを取りに行こうとしたときに発生するエラーです。
例えば、あなたがあるウェブサイト(例えば「サイトA」)を見ていて、そのサイトが他のサーバー(例えば「サーバーB」)から情報を取ってこようとするときに、このエラーが発生することがあります。
例えば、あなたがあるウェブサイト(例えば「サイトA」)を見ていて、そのサイトが他のサーバー(例えば「サーバーB」)から情報を取ってこようとするときに、このエラーが発生することがあります。
参考:Cross-Origin Resource Sharing(CORS)エラーとは?
https://sqlengineer.hatenablog.com/entry/2024/06/05/110028今回の場合は…?
アプリを動かしている場所(今回の場合http://127.0.0.1:5500)と、リクエストを送った先(https://api.openrouteservice.org)が別の場所だったため、ブラウザにブロックされてしまったようです。
じゃあどうしたらいいんだ!
ドメイン名とかを一緒にするのはどうやったって無理じゃないか?と思いましたが、
chatGPTに聞くと、サクッと「Node.js を使って API へのリクエストを中継するサーバーを用意すれば、CORSの制限を回避できる」と教えてくれました。
ここはエラーが頻発して少し時間がかかったのですが、Node.js 経由のサーバーを使ってルート上の各地点の「経度・緯度・標高」を取得することができました!
chatGPTに聞くと、サクッと「Node.js を使って API へのリクエストを中継するサーバーを用意すれば、CORSの制限を回避できる」と教えてくれました。
ここはエラーが頻発して少し時間がかかったのですが、Node.js 経由のサーバーを使ってルート上の各地点の「経度・緯度・標高」を取得することができました!

コードは以下になります。
const express = require('express');
const path = require('path');
const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args));
const app = express();
const PORT = 3000;
// ORS APIキー
const apiKey = "";
// メモリキャッシュ(Mapで簡易実装)
const elevationCache = new Map();
app.use(express.static(path.join(__dirname)));
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.get('/elevation', async (req, res) => {
const { start, end } = req.query;
const transportation = "cycling-regular";
if (!start || !end) {
return res.status(400).json({ error: "startとendの両方の座標が必要です" });
}
const cacheKey = `${start}_${end}`;
if (elevationCache.has(cacheKey)) {
console.log(`? キャッシュヒット: ${cacheKey}`);
return res.json(elevationCache.get(cacheKey));
}
try {
const directionsRes = await fetch(`https://api.openrouteservice.org/v2/directions/${transportation}?api_key=${apiKey}&start=${start}&end=${end}&simplify=false`);
if (!directionsRes.ok) {
const errText = await directionsRes.text();
throw new Error(`Directions API error: ${directionsRes.status} ${errText}`);
}
const directionsData = await directionsRes.json();
const coords = directionsData.features[0].geometry.coordinates;
const elevationRes = await fetch("https://api.openrouteservice.org/elevation/line", {
method: "POST",
headers: {
"Authorization": apiKey,
"Content-Type": "application/json"
},
body: JSON.stringify({
format_in: "geojson",
format_out: "geojson",
geometry: {
type: "LineString",
coordinates: coords
}
})
});
if (!elevationRes.ok) {
const errText = await elevationRes.text();
throw new Error(`Elevation API error: ${elevationRes.status} ${errText}`);
}
const elevationData = await elevationRes.json();
// キャッシュに保存する前に distance を追加
const responseData = {
geometry: elevationData.geometry,
distance: directionsData.features[0].properties.segments[0].distance
};
// キャッシュに保存
elevationCache.set(cacheKey, responseData);
res.json(responseData);
} catch (error) {
console.error("ORSエラー:", error);
res.status(500).json({ error: error.message });
}
});
app.listen(PORT, () => {
console.log(`サーバー起動中:http://localhost:${PORT}`);
});
今度こそ勾配に応じて色分けをする
今度こそ勾配にに応じて線の色を変えてみます。
まずは取得した標高データを使い、2点間の勾配を計算します。
※勾配とは、水平距離に対する高さの割合です。
たとえば、100m 進んで 10m 高くなる坂は「10%の勾配」となります。
まずは取得した標高データを使い、2点間の勾配を計算します。
※勾配とは、水平距離に対する高さの割合です。
たとえば、100m 進んで 10m 高くなる坂は「10%の勾配」となります。

勾配を計算して線の色を変える
ルート上の各区間(隣り合う2点)について、以下の手順で処理を行います。
1. 2点間の距離を計算(
2. それぞれの標高から高さの差を計算
3. 勾配(= 高さの差 ÷ 水平距離 × 100)を求める
4. 勾配の値に応じて線の色を決定し、その区間を描画
この処理をルート全体に対して繰り返し、勾配に応じて色分けしたルートを描写していきます。
仮の色分けルールを以下のように設定し、勾配の計算が上手くいっているか確認してみました。
・上り坂(5%以上):赤
・やや上り(1〜5%):オレンジ
・平坦(±1%未満):グレー
・下り坂(-1%以下):青
その結果、絶対に下り坂ではないところが青く表示されてしまいました。
1. 2点間の距離を計算(
2. それぞれの標高から高さの差を計算
3. 勾配(= 高さの差 ÷ 水平距離 × 100)を求める
4. 勾配の値に応じて線の色を決定し、その区間を描画
この処理をルート全体に対して繰り返し、勾配に応じて色分けしたルートを描写していきます。
仮の色分けルールを以下のように設定し、勾配の計算が上手くいっているか確認してみました。
・上り坂(5%以上):赤
・やや上り(1〜5%):オレンジ
・平坦(±1%未満):グレー
・下り坂(-1%以下):青
その結果、絶対に下り坂ではないところが青く表示されてしまいました。

計算方法を修正する
2点間の計算では標高データの誤差で実際とは違う勾配になってしまうようだったので、三点間の平均を算出するように変更しました。
また、点の間隔が近すぎると勾配が大きく出やすくなってしまうため、3m未満の点同士だと計算しないように調整しました。
坂道なのに下り坂になっていた部分も、今回の調整で直すことができました!
また、点の間隔が近すぎると勾配が大きく出やすくなってしまうため、3m未満の点同士だと計算しないように調整しました。
坂道なのに下り坂になっていた部分も、今回の調整で直すことができました!

色分けの閾値を調整する
続いて、色分けの閾値を調整していきます。
普段自転車に乗っているときに「ここは○%の坂できつい!」と意識しているわけではないので、どの勾配をどの色で示すかは少し悩みました。
色々と調べていく中で、自転車の勾配感度についてまとめられている方がいらっしゃいました。
普段自転車に乗っているときに「ここは○%の坂できつい!」と意識しているわけではないので、どの勾配をどの色で示すかは少し悩みました。
色々と調べていく中で、自転車の勾配感度についてまとめられている方がいらっしゃいました。
色分けの定義
上記の記事を参考に、最終的には次のように色分けを定義しました。
・10%以上の激坂:暗い赤 #650008
・7%以上のきつい坂:赤 #BD000F
・4%以上のややきつい坂:オレンジ #D5520A
・1%以上のゆるやかな坂:明るいオレンジ #E89006
・-1%〜1%の平坦区間:黄色 #FFE100
・-1%未満の下り坂:青 #40A0F0
・10%以上の激坂:暗い赤 #650008
・7%以上のきつい坂:赤 #BD000F
・4%以上のややきつい坂:オレンジ #D5520A
・1%以上のゆるやかな坂:明るいオレンジ #E89006
・-1%〜1%の平坦区間:黄色 #FFE100
・-1%未満の下り坂:青 #40A0F0
コードはこんな感じ
let i = 0;
// ルートの始点から順に処理していく
while (i < coords.length - 1) {
const [lng1, lat1] = coords[i];
// 次に処理すべき、十分に離れた点を探す
let j = i + 1;
while (j < coords.length) {
const [lng2, lat2] = coords[j];
const dist = getDistanceFromLatLon(lat1, lng1, lat2, lng2);
// 3m以上離れていれば勾配を計算する
if (dist >= 3) {
// 周囲3点の平均標高を使って高低差を計算
const ele1 = avgElevation(i);
const ele2 = avgElevation(j);
let slope = ((ele2 - ele1) / dist) * 100;
// 高低差がほぼなければ平坦とみなす
if (Math.abs(ele2 - ele1) < 1) slope = 0;
// 勾配に応じて色を決定
let color = "gray";
if (slope >= 10) color = "#650008"; // 激坂
else if (slope >= 7) color = "#BD000F"; // きつい坂
else if (slope >= 4) color = "#D5520A"; // ややきつい
else if (slope > 1) color = "#E89006"; // ゆるい坂
else if (slope >= -1) color = "#FFE100"; // 平坦
else color = "#40A0F0"; // 下り
// 色付きの線(ポリライン)を描画
const slopeLine = L.polyline(
[
[lat1, lng1],
[lat2, lng2],
],
{
color,
weight,
opacity: 1.0,
}
).addTo(map);
slopeLines.push(slopeLine);
routeLayers.push(slopeLine);
// 今回使った点を次の始点にする
i = j;
break;
}
// 3m未満ならさらに次の点へ
j++;
}
// 最後まで見ても有効な点がなければ終了
if (j >= coords.length) break;
}
ここまでで自分が実現したいルート表示の機能は実装できました!

検索機能と距離の表示を追加する
始点と終点の緯度・経度を直接JSに書き込まないとルート検索ができない状態だったため、地名を検索して始点と終点を設定できるよう機能を追加しました。
検索にはNominatim APIを使用しました。Nominatim API は OpenStreetMapのデータを使って地名検索を行うためのAPIです。使用するにあたってAPIキーの取得は不要でした。
ORS のAPIでも検索できたようなのですが、制作の終盤までリクエストの回数制限を勘違いしていたためNominatim APIを使用しました。
これにより、地名や住所から緯度・経度を取得してルート検索に利用できるようになりました。
検索にはNominatim APIを使用しました。Nominatim API は OpenStreetMapのデータを使って地名検索を行うためのAPIです。使用するにあたってAPIキーの取得は不要でした。
ORS のAPIでも検索できたようなのですが、制作の終盤までリクエストの回数制限を勘違いしていたためNominatim APIを使用しました。
これにより、地名や住所から緯度・経度を取得してルート検索に利用できるようになりました。
// 地名検索
function searchLocation(type) {
const query = document.getElementById(`${type}Box`).value;
if (!query) return;
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(
query
)}`;
fetch(url)
.then((res) => res.json())
.then((data) => {
if (data.length === 0) {
alert("場所が見つかりませんでした");
return;
}
const lat = parseFloat(data[0].lat);
const lon = parseFloat(data[0].lon);
const displayName = data[0].display_name;
const placeName = displayName ? displayName.split(",")[0] : query;
// searchMarkerは常に1つ(最後に検索されたもの)だけ表示
if (searchMarker) map.removeLayer(searchMarker);
searchMarker = L.marker([lat, lon])
.addTo(map)
.bindPopup(
`${placeName}
緯度: ${lat.toFixed(5)} 経度: ${lon.toFixed(5)}`
)
.openPopup();
map.setView([lat, lon], 13);
if (type === "start") {
startLatLng = { lat, lon };
} else if (type === "end") {
endLatLng = { lat, lon };
}
})
.catch((err) => {
console.error("検索エラー:", err);
alert("検索に失敗しました");
});
}
次に、ルート全体の距離を画面上に表示する機能も追加しました。
ルートの標高を取得するのに使用したElevation API のレスポンスにはルート全体の距離(メートル単位)が含まれているため、これをキロメートルに換算して表示しました。
見た目の調整をする
最後に、見やすさと使いやすさの向上を目的として、アプリの見た目のスタイル調整を行いました。

地点検索でボタンを押すのが面倒に感じられたため、エンターキーで検索できるようにしました。

ルートの下に黒い太線を重ねて描画し、境界線のように見せることで視認性を向上させました。
完成品がこちら
デモ
おまけ
ORSの標高データの精度が気になったので、実際にルートを走行し、その時の体感と走行中に記録したGPXデータと比較してみました!
GPX(GPS Exchange Format)は、位置情報を記録するためのファイル形式で、登山やサイクリング用のスマホアプリなどでルートの軌跡や標高を保存できます。
今回はスマホアプリで走行データを記録し、それをもとに今回制作したルートアプリと同じ計算方法で地図上に表示してみました。
結果としては、ORSの標高データの方が体感に近く、違和感のない勾配表示ができている印象でした。
GPX(GPS Exchange Format)は、位置情報を記録するためのファイル形式で、登山やサイクリング用のスマホアプリなどでルートの軌跡や標高を保存できます。
今回はスマホアプリで走行データを記録し、それをもとに今回制作したルートアプリと同じ計算方法で地図上に表示してみました。
結果としては、ORSの標高データの方が体感に近く、違和感のない勾配表示ができている印象でした。

終わりに
ローカル環境とはいえ実際に動くアプリを作れたのは素直に嬉しく達成感がありました。
チャットGPTを活用することで、見た目や動きの検討にスムーズに取り組むことができたと感じています。
APIの利用やCORSエラーへの対応は初めてでしたが、実際に制作する中で触れることができてとても良い経験になりました。
拙い説明もあったかと思いますがここまで読んできただきありがとうございました!
チャットGPTを活用することで、見た目や動きの検討にスムーズに取り組むことができたと感じています。
APIの利用やCORSエラーへの対応は初めてでしたが、実際に制作する中で触れることができてとても良い経験になりました。
拙い説明もあったかと思いますがここまで読んできただきありがとうございました!