Loading...

2023-11-02(木) 15:00

🚏 Next.js で Mapboxを使ってルート検索機能を実装する

Next.jsMapbox
Next.js で Mapboxを使って任意の2地点間のルート検索する機能の実装手順を解説します。

目次

前提と注意事項

この記事では以下の記事の内容が完了していることを前提としています。

🗾 Next.js で Mapboxを使って日本語の地図を表示する

Next.js で Mapboxを使って日本語化した地図を表示をするための手順を解説します。

ritaiz.com

なお、この記事のコードはエラーハンドリングが十分ではありません。また、間違った意図を伝えないよう繰り返し同じコードを使ったり冗長な箇所が多数あります。実際に使用する場合はそれぞれの状況に合わせて修正してください。 関連するパッケージのバージョンは以下です。

package.json
"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 を使うためのヘルパーを以下のように作成します。

helpers/MapboxHelper.ts
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を使用しています。

Directions API

The Mapbox Directions API will show you how to get where you're going

docs.mapbox.com

Geocoding API

The Mapbox Geocoding API does two things: forward geocoding and reverse geocoding.

docs.mapbox.com

地名入力して地名候補を表示、検索するフォームを作成する

地名入力して地名候補を表示、検索するフォームを作成します。

components/SearchSuggestionForm.tsx
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のインポートパスはそれぞれの環境に合わせて修正してください。

components/SearchRouteMap.tsx
'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を適当なページで使用することで、ルート検索機能を実装できます。

pages/index.tsx
import SearchRouteMap from './components/Maps/SearchRouteMap';
 
export default function Home() {
  return (
    <main>
      <div>
        <SearchRouteMap />
      </div>
    </main>
  );
}

まとめ

Mapbox を使ってルート検索機能を実装しました。Mapbox には多数の API が用意されており、位置情報を扱うアプリケーションを作成する際には非常に便利です。