前提と注意事項
この記事では以下の記事の内容が完了していることを前提としています。
なお、この記事のコードはエラーハンドリングが十分ではありません。また、間違った意図を伝えないよう繰り返し同じコードを使ったり冗長な箇所が多数あります。実際に使用する場合はそれぞれの状況に合わせて修正してください。 関連するパッケージのバージョンは以下です。
"dependencies": {
"@mapbox/mapbox-gl-language": "^1.0.1",
"@types/mapbox-gl": "^2.7.17",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
"@types/node": "20.5.6",
"mapbox-gl": "3.0.0-beta.5",
"next": "14.0.0",
"react": "^18",
"react-dom": "^18",
"tailwindcss": "3.3.3",
"typescript": "5.2.2"
}
この記事のゴール
この記事では以下のように2つの地点間のルート検索する機能を実装します。地名の入力内容に応じて該当する地名と住所の情報を候補として表示します。
また、出発地と目的地にはドラッグ&ドロップ可能なマーカーを設置し、マーカーをドラッグ&ドロップすることでルートの再検索も行います。
検索したルートの距離と時間も取得して表示します。
作業の流れ
以下の3つのファイルを作成して使用します。
helpers/MapboxHelper.ts
: ルート情報や地理情報取得のために Mapbox API を使用するためのヘルパーcomponents/SearchSuggestionForm.tsx
:地名入力して地名候補を表示、検索するフォームcomponents/SearchRouteMap.tsx
: ルート検索結果を表示する地図
Mapbox API を使用するためのヘルパーを作成する
Mapbox には、ルート検索や緯度経度、地名の取得を行うための API が用意されています。 ここでは、ルート検索に必要な API を使うためのヘルパーを以下のように作成します。
const MAPBOX_API_URL = 'https://api.mapbox.com';
const MAPBOX_API_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;
// 地名文字列から緯度経度含む地理情報の候補を取得する
// Mapbox Geocoding API を使用
// https://docs.mapbox.com/api/search/geocoding/#forward-geocoding
export const getAutocompleteSuggestions = async (query: string) => {
if (!query) return [];
const url = `${MAPBOX_API_URL}/geocoding/v5/mapbox.places/${encodeURIComponent(
query,
)}.json?autocomplete=true&access_token=${MAPBOX_API_TOKEN}`;
try {
const response = await fetch(url);
if (!response.ok) {
console.error(
'Failed to fetch autocomplete suggestions:',
response.statusText,
);
return [];
}
const data = await response.json();
return data.features.map((feature: any) => ({
place_name: feature.place_name,
coordinates: feature.center,
}));
} catch (error) {
console.error('Error fetching autocomplete suggestions:', error);
return [];
}
};
// 2つの緯度経度から地名文字列を取得する
// Mapbox Directions API を使用
// https://docs.mapbox.com/api/navigation/directions/#retrieve-directions
// 以下では `driving` でのルート検索を行っているが、`cycling` や `walking` に変更することで自転車や徒歩でのルート検索も可能
export const getRouteInformation = async (start: any, end: any) => {
const startCoord = `${start[0]},${start[1]}`;
const endCoord = `${end[0]},${end[1]}`;
const url = `${MAPBOX_API_URL}/directions/v5/mapbox/driving/${startCoord};${endCoord}?access_token=${MAPBOX_API_TOKEN}&geometries=geojson`;
try {
const response = await fetch(url, {
method: 'GET',
});
if (!response.ok) {
console.error(
'Failed to fetch getRouteInformation:',
response.statusText,
);
return [];
}
const result = await response.json();
const route = result.routes[0];
return route;
} catch (error) {
console.error('Error fetching route information:', error);
return null;
}
};
// 地名文字列から緯度経度を取得する
// Mapbox Geocoding API を使用
// https://docs.mapbox.com/api/search/geocoding/#forward-geocoding
export const geocode = async (placeName: string) => {
const url = `${MAPBOX_API_URL}/geocoding/v5/mapbox.places/${encodeURIComponent(
placeName,
)}.json?access_token=${MAPBOX_API_TOKEN}`;
try {
const response = await fetch(url, {
method: 'GET',
});
const result = await response.json();
const { features } = result;
if (features && features.length > 0) {
const [longitude, latitude] = features[0].center;
return [longitude, latitude];
} else {
console.error('No coordinates found for place name:', placeName);
return null;
}
} catch (error) {
console.error('Error geocoding place name:', error);
return null;
}
};
// 緯度経度から地名文字列を取得する
// Mapbox Geocoding API を使用
// https://docs.mapbox.com/api/search/geocoding/#reverse-geocoding
export async function reverseGeocode(lngLat: number[]) {
const url = `${MAPBOX_API_URL}/geocoding/v5/mapbox.places/${lngLat[0]},${lngLat[1]}.json?access_token=${MAPBOX_API_TOKEN}`;
const response = await fetch(url);
const data = await response.json();
if (data.features && data.features.length > 0) {
return data.features[0].place_name;
} else {
console.error('No results found.');
return null;
}
}
上記それぞれの関数の目的や使っている Mapbox の API の詳細は、各関数のコメントに記載している公式ドキュメントの URL から確認できます。
ルート情報の取得にはMapbox Directions API
を使用し、その他の地名や緯度経度などの地理情報の取得にはMapbox Geocoding API
を使用しています。
Geocoding API
The Mapbox Geocoding API does two things: forward geocoding and reverse geocoding.
docs.mapbox.com
地名入力して地名候補を表示、検索するフォームを作成する
地名入力して地名候補を表示、検索するフォームを作成します。
import { useState, ChangeEvent, useEffect } from 'react';
import { getAutocompleteSuggestions } from '@/helpers/MapboxHelper';
interface Suggestion {
place_name: string;
coordinates: [number, number];
placeholder: string;
}
export default function SearchSuggestionForm({
onSelection,
value,
placeholder,
}: {
onSelection: (suggestion: Suggestion) => void;
value?: string;
placeholder?: string;
}) {
const [query, setQuery] = useState<string>('');
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
// フォームの入力内容が変更された時に実行する関数
const handleInputChange = async (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
const newSuggestions = await getAutocompleteSuggestions(e.target.value);
setSuggestions(newSuggestions);
};
// 地名候補がクリックされた時に実行する関数
const handleSuggestionClick = (suggestion: Suggestion) => {
setQuery(suggestion.place_name);
setSuggestions([]);
onSelection(suggestion);
};
useEffect(() => {
if (value === undefined) return;
setQuery(value);
}, [value]);
return (
<div className='relative flex items-center text-gray-700'>
<input
type='text'
placeholder={placeholder ?? '地名を入力してください'}
value={query}
className='text-sm border-2 border-gray-300 rounded-md p-1'
onChange={handleInputChange}
/>
{suggestions.length > 0 && (
<ul className='absolute left-0 top-6 mt-2 z-10 w-full bg-white border border-gray-300 rounded-md shadow-lg'>
{suggestions.map((suggestion, index) => (
<li
key={index}
className='text-sm p-2 hover:bg-gray-200 cursor-pointer'
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.place_name}
</li>
))}
</ul>
)}
</div>
);
}
上記は、地名を入力するためのテキストフォームになっており、地名を入力していくとその地名に関連する地名候補を表示します。
地名候補をクリックすると、その地名候補を選択したことを親コンポーネントに伝えるために onSelection
という props で渡された関数を呼び出します。
ルート検索結果を表示する地図を作成する
ルート検索結果を表示する地図を作成します。MapboxHelper
やSearchSuggestionForm
のインポートパスはそれぞれの環境に合わせて修正してください。
'use client';
import { useEffect, useState, useRef } from 'react';
import mapboxgl from 'mapbox-gl';
import MapboxLanguage from '@mapbox/mapbox-gl-language';
import 'mapbox-gl/dist/mapbox-gl.css';
import { getRouteInformation, reverseGeocode } from '@/helpers/MapboxHelper';
import SearchSuggestionForm from './SearchSuggestionForm';
export default function SearchRouteMap() {
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN ?? '';
const mapContainer = useRef(null);
const [map, setMap] = useState(null);
const [route, setRoute] = useState({
geometry: {
type: '' as any,
coordinates: [],
},
});
const [routeDistance, setRouteDistance] = useState(0);
const [departurePlace, setDeparturePlace] = useState('');
const [departureCoordinates, setDepartureCoordinates] = useState([]);
const [destinationPlace, setDestinationPlace] = useState('');
const [destinationCoordinates, setDestinationCoordinates] = useState([]);
const setSelectedDeparturePlace = (place: any) => {
setDeparturePlace(place.place_name);
setDepartureCoordinates(place.coordinates);
};
const setSelectedDestinationPlace = (place: any) => {
setDestinationPlace(place.place_name);
setDestinationCoordinates(place.coordinates);
};
// 出発地のピンがドラッグ&ドロップされた時に実行する関数
async function onDepartureDragEnd(event: any) {
const targetMarker = event.target;
const targetCoordinates = targetMarker.getLngLat().toArray();
const targetPlaceName = await reverseGeocode(targetCoordinates);
setDeparturePlace(targetPlaceName);
setDepartureCoordinates(targetCoordinates);
await handleRouteSearch(map, targetCoordinates, destinationCoordinates);
}
// 目的地のピンがドラッグ&ドロップされた時に実行する関数
async function onDestinationDragEnd(event: any) {
const targetMarker = event.target;
const targetCoordinates = targetMarker.getLngLat().toArray();
const targetPlaceName = await reverseGeocode(targetCoordinates);
setDestinationPlace(targetPlaceName);
setDestinationCoordinates(targetCoordinates);
await handleRouteSearch(map, departureCoordinates, targetCoordinates);
}
// ルート検索を実行する関数
async function handleRouteSearch(
map: any,
startCds: number[],
endCds: number[],
) {
if (startCds && endCds) {
const routeInformation = await getRouteInformation(startCds, endCds);
setRouteDistance(routeInformation.distance / 1000);
if (map.getSource('route')) {
map.getSource('route').setData({
type: 'Feature',
properties: {},
geometry: routeInformation.geometry,
});
} else {
// ルートが存在しない場合は、出発地と目的地のピンを作成
const departureLongitude = startCds[0];
const departureLatitude = startCds[1];
const destinationLongitude = endCds[0];
const destinationLatitude = endCds[1];
// 出発地のマーカー(ピン)を作成
const departureMarker = new mapboxgl.Marker({
color: 'green', // マーカーの色を指定
draggable: true, // true: ドラッグ可能, false: ドラッグ不可
});
// 目的地のマーカー(ピン)を作成
const destinationMarker = new mapboxgl.Marker({
color: 'red', // マーカーの色を指定
draggable: true, // true: ドラッグ可能, false: ドラッグ不可
});
// マーカーを地図に追加. dragendイベントに関数をバインド
departureMarker
.setLngLat([departureLongitude, departureLatitude])
.addTo(map)
.on('dragend', onDepartureDragEnd);
destinationMarker
.setLngLat([destinationLongitude, destinationLatitude])
.addTo(map)
.on('dragend', onDestinationDragEnd);
// ルートを描画
map.addLayer({
id: 'route',
type: 'line',
source: {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: routeInformation.geometry,
},
},
// ルートのスタイルを指定
layout: {
'line-join': 'round',
'line-cap': 'round',
},
// ルートの色と太さを指定
paint: {
'line-color': '#0000FF',
'line-width': 8,
},
});
// setPaintPropertyを使っての指定も可能
// ルートの色を変更
// map.setPaintProperty('route', 'line-color', '#0000FF');
}
if (
routeInformation.geometry &&
routeInformation.geometry.coordinates.length > 0
) {
// ルートの座標を取得
const coordinates = routeInformation.geometry.coordinates;
let minLng = coordinates[0][0];
let minLat = coordinates[0][1];
let maxLng = coordinates[0][0];
let maxLat = coordinates[0][1];
for (let i = 1; i < coordinates.length; i++) {
const [lng, lat] = coordinates[i];
if (lng < minLng) minLng = lng;
if (lat < minLat) minLat = lat;
if (lng > maxLng) maxLng = lng;
if (lat > maxLat) maxLat = lat;
}
// ルートが収まるように地図の表示範囲を変更
map.fitBounds(
[
[minLng, minLat],
[maxLng, maxLat],
],
{
padding: { top: 50, bottom: 50, left: 50, right: 50 },
},
);
}
} else {
console.error('Unable to geocode one or both place names.');
}
}
// Mapがクリックされた時に実行する関数
const onMapClick = (e: any) => {
console.log('onMapClick');
console.log(e.lngLat);
};
useEffect(() => {
const initializeMap = ({
setMap,
mapContainer,
}: {
setMap: any;
mapContainer: any;
}) => {
const map = new mapboxgl.Map({
container: mapContainer.current,
center: [139.7670516, 35.6811673],
zoom: 15,
style: 'mapbox://styles/mapbox/streets-v12',
});
// 言語変更設定
const language = new MapboxLanguage({ defaultLanguage: 'ja' });
map.addControl(language);
map.on('load', () => {
setMap(map);
map.resize();
if (route.geometry.type) {
map.addLayer({
id: 'route',
type: 'line',
source: {
type: 'geojson',
data: {
type: 'Feature',
properties: {},
geometry: route.geometry,
},
},
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#888',
'line-width': 8,
},
});
}
});
// Mapがクリックされた時に実行する関数をバインド
map.on('click', onMapClick);
};
if (!map) initializeMap({ setMap, mapContainer });
}, [map, route, departurePlace, destinationPlace]);
return (
<div className='flex flex-col p-24 w-full bg-white'>
<div className='flex text-base font-normal text-gray-700'>
距離: {routeDistance.toFixed(2)} km
</div>
<div>
<div className='flex flex-row items-center align-center gap-x-2 mb-2'>
<SearchSuggestionForm
onSelection={setSelectedDeparturePlace}
value={departurePlace}
placeholder={'出発地を入力してください'}
/>
<SearchSuggestionForm
onSelection={setSelectedDestinationPlace}
value={destinationPlace}
placeholder={'目的地を入力してください'}
/>
<button
className='bg-blue-600 hover:bg-blue-800 text-white text-sm font-bold py-1 px-2 rounded'
onClick={() =>
handleRouteSearch(
map,
departureCoordinates,
destinationCoordinates,
)
}
>
ルート検索
</button>
</div>
<div
ref={mapContainer}
className='rounded'
style={{ width: '100%', height: '80vh' }}
/>
</div>
</div>
);
}
上記では、作成したhelpers/MapboxHelper.ts
と、components/SearchSuggestionForm.tsx
を使用しています。
あとは上記のcomponents/SearchRouteMap.tsx
を適当なページで使用することで、ルート検索機能を実装できます。
import SearchRouteMap from './components/Maps/SearchRouteMap';
export default function Home() {
return (
<main>
<div>
<SearchRouteMap />
</div>
</main>
);
}
まとめ
Mapbox を使ってルート検索機能を実装しました。Mapbox には多数の API が用意されており、位置情報を扱うアプリケーションを作成する際には非常に便利です。