[Mapbox,React,Next.js] MapboxのDirectionsAPIを使って、Reactで2地点間のルートを描画してみた

# はじめに
こんにちは！
今回は、MapboxのDirectionsAPIを使って現在地からドラッグしたピンまでのルートを算出して描画する方法を紹介します。
おまけで中間地点も経由できるようにしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/a33d20a0-9861-a93e-294a-ea479537804d.png)
※位置情報を許可しない場合は上記のように東京駅が始点になります。

コードは全て筆者のGithubにあります。
https://github.com/ironkicka/map-navigation-sample

# おすすめの読者
- 地図アプリをなんでもいいから作ってみたい
- mapbox-gl-jsは使ったことあるけど、Reactではやったことない
- mapboxでルート検索してみたい

# 動かし方

## Mapboxのアカウントを作成する
以下からアカウントを作成します。
https://account.mapbox.com/auth/signup/

## APIトークンを取得する
ここでマップの描画に必要なトークンを取得することができます。
![Screen Shot 2022-12-20 at 0.08.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/88de29d3-734b-1b4a-dc64-a93cf79844b4.png)

## 筆者レポジトリをクローン
```
git clone git@github.com:ironkicka/map-navigation-sample.git
```

## プロジェクトディレクトリの直下に`.env.local`を配置
中身は以下のように設定してください
```
NEXT_PUBLIC_MAP_BOX_TOKEN=YOUR_TOKEN
```

## 実行
以下を実行すればアプリケーションが立ち上がるはずです。
```
npm run dev
```
# 遊び方
## 現在地から任意の点までのルートを描画してみる
1. 地図上の適当なところをクリックして赤い目的地ピンを配置
青色が現在地です。
実際には実行しているデバイスの現在地が表示されますが、デモのために東京駅を始点としています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/836cd62a-6b16-e647-2916-2d2eebb570d9.png)
2."Start Navigation"をクリック
![Screen Shot 2022-12-20 at 0.40.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/cbfe0279-b8cd-97d7-789f-f969cdf9d5f1.png)
すると、以下のようにルートが描画されます。この際、ルートの大きさに合わせて地図の大きさが調節されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/e8db7739-b147-e255-9787-6e7765824e62.png)


また、移動方法を変更することで同じ２地点間でも異なるルートが描画されます(同じルートになる場合もあります)
↓移動方法を車に変えた場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/30101eb9-b535-152b-950a-b8816204954f.png)

## 現在地から任意の点までのルートを中間地点を経由して描画してみる
1. 画面左上のセレクタでWayPointをEnabledにする
すると、現在地ピン(青色)の近くに中間地点ピン(緑色）がドロップされます
![Screen Shot 2022-12-20 at 0.50.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/8ba33440-b334-db05-ca97-c487e9373574.png)
2. 中間地点ピンを適当な場所にドラッグし、地図をクリックして目的地ピンを配置
3. "Start Navigation"をクリック
すると中間地点を経由したルートが算出されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/527de21c-0c03-fa73-599b-3e9ba0e00ad2.png)


# 解説
今回のアプリケーションは以下の５つの要素からなっています。これらの要素を順に解説していきます.
- マップの描画
- マーカーの描画
- ルートの描画
- ルートの取得
- 描画したルートの大きさに地図をフィットさせる

## マップの描画
マップは以下のようにreact-map-glが提供するMapコンポーネントにトークンとマップのスタイルを渡すだけで描画することができます。これだけで地図自体の描画は完了し、子要素として追加で描画したい要素を追加していきます。


`mapStyle`に渡すURLにはmapboxが用意しているものを渡すこともできますし、MapboxのStudio機能を使って、自分のオリジナルのスタイルを設定することもできます。
今回は個人的にシンプルさが気に入ってるのでlight-v10を利用しました。

```tsx
      <Map
        id='myMap'
        initialViewState={{
          longitude: currentUserPosition.lng,
          latitude: currentUserPosition.lat,
          zoom: 14,
        }}
        style={{width: '100%', height: '100vh'}}
        mapStyle={"mapbox://styles/mapbox/light-v10"}
        mapboxAccessToken={process.env.NEXT_PUBLIC_MAP_BOX_TOKEN}
        onClick={onClick}
      >
      ...
      </Map>
```
参考
https://visgl.github.io/react-map-gl/docs/api-reference/map

## Markerの描画
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/81218c67-8c1f-0e7e-93b4-e9e0bd70f517.png)
Markerはreact-map-glが提供するMarkerコンポーネントで描画できます。
子要素として画像を挟むことでその画像を表示することができます。
```tsx
<Marker key={'destination'} longitude={destination.lng} latitude={destination.lat} anchor="center">
    <Pin/>
</Marker>
```
参考
https://visgl.github.io/react-map-gl/docs/api-reference/marker

## ルート(GeoJSON)の描画
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/ce5ce489-4488-b24f-66a5-fb46f17c378b.png)
ルートの描画には、react-map-glの`Source`と`Layer`の２つのコンポーネントを使います

```tsx
<Source id='myRoute' type='geojson' data={routeGeoJson}>
    <Layer {...layerStyle} />
</Source>
```

参考
https://visgl.github.io/react-map-gl/docs/api-reference/source
https://visgl.github.io/react-map-gl/docs/api-reference/layer

Sourceコンポーネントのdataには以下のようなGeoJSONを渡します(コードから抜粋)
今回は経路なので`LineString`型を渡します。
```tsx
const geojson = {
  type: 'Feature' as const,
  properties: {},
  geometry: {
    type: 'LineString' as const,
    coordinates: route,
  },
};
```
参考:https://ja.wikipedia.org/wiki/GeoJSON

Layerコンポーネントのpropsには以下のような`LayerProps`型のオブジェクトを渡します。このオブジェクトで線の見た目を決定しています.
```tsx
const layerStyle: LayerProps = {
  id: 'route',
  type: 'line',
  layout: {
    'line-join': 'round',
    'line-cap': 'round',
  },
  paint: {
    'line-color': '#3887be',
    'line-width': 5,
    'line-opacity': 0.75,
  }
}
```
参考:https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/

## ルートの取得

### 概要
本アプリケーションの肝であるルートの取得にはMapboxの提供するDirections APIを利用します。
https://docs.mapbox.com/help/glossary/directions-api/

以下のような形をしていて必要に応じてパラメータを渡していきます。
```
https://api.mapbox.com/directions/v5/mapbox/{profile}/{coordinates}&access_token=${process.env.NEXT_PUBLIC_MAP_BOX_TOKEN}
```
今回は以下のパラメータを設定しています
|パラメータ|役割|参考|
|---|---|---|
|profile|移動方法の指定|https://docs.mapbox.com/help/glossary/routing-profile/|
|coordinate|カンマ区切りの座標群|https://docs.mapbox.com/api/navigation/directions/#retrieve-directions|
|steps|案内とそれに関連するデータを返すかどうか|https://docs.mapbox.com/api/navigation/directions/#optional-parameters|
|language|案内を表示する際の言語|同上|
|geometry|返却される地理データの形式|同上|

※今回はsteps=trueとしているので案内を取得できているのですが、時間の都合上画面への案内の表示までには至りませんでした。例えば、案内文言の文字列だけなら以下でresponseから取得できるので興味のある方は試してみてください
```tsx
const instructions = data.legs.flatMap((item:any)=>item.steps.map((step:any)=>step.maneuver.instruction))
```

今回の設定値を含むURL
profileとcoordsはUIから動的に算出してます。
```
https://api.mapbox.com/directions/v5/mapbox/${profile}/${coords}?steps=true&geometries=geojson&language=ja&access_token=${process.env.NEXT_PUBLIC_MAP_BOX_TOKEN}
```
### 中間地点の経由のさせ方
概要部分で触れたように`coordinate`パラメータを使って座標を指定します。
先頭と末尾は始点と終点の座標になるのでこの間に中間地点の座標を挟んであげればOKです！
```
/{profile}/{始点の経度、始点の緯度};{中間地点の経度、中間地点の緯度};{終点の経度、終点の緯度}
```
## 描画したルートの大きさに地図をフィットさせる
地図を特定の枠にフィットさせるには`fitBounds`メソッドを利用します.
このメソッドに渡す枠を求めるのには`turf`と呼ばれる空間解析に特化したライブラリの`bbox`メソッドを用います。このメソッドに経路のGeoJSONを食わせることでその経路がぴったり収まる矩形を取得しています。ただ、単に`bbox`に合わせるだけだと経路の端と画面の端にゆとりがなく窮屈な感じがするのでオプションで`padding`を設定しています。

`bbox`は地理情報を扱う際はよく出てくる単語で以下のような４つの値により矩形を決定するものです。

> bbox = 経度の最小値、緯度の最小値、経度の最大値、緯度の最大値 

https://wiki.openstreetmap.org/wiki/JA:Bounding_Box

```tsx
const {myMap} = useMap();
...
myMap.fitBounds(bbox(geojson) as [number, number, number, number], {
    duration: 1000,
  padding: 200
})
```
参考
https://docs.mapbox.com/jp/mapbox-gl-js/example/fitbounds/
https://www.npmjs.com/package/@turf/bbox

# ハマりどころ
## 地図のスタイルをimportすること
必ず以下をMapを描画するページでは記述してください
```tsx
import 'mapbox-gl/dist/mapbox-gl.css';
```
これを記述しないと、例えば`Marker`や`NavigationControl`コンポーネントは表示すらされなくなるので注意です。

## useMapはMapProviderで囲んだcomponentでしか使えない
`react-map-gl`では地図のインスタンスを取得するためにuseMapというhooksを使います。このhooksは`MapProvider`というコンポーネントで挟まれたコンポーネント以下でしか使えないので注意が必要です。
今回は1ページしかないですが次のようにしてメインのコンポーネントをMapProviderに挟んでいます
```tsx
const HomeContent = ()=>{
...
}

const Home = () => (
  <MapProvider>
    <HomeContent/>
  </MapProvider>
)

export default Home
```

## useMapで取得できるMapインスタンスの名前は自分がMapコンポーネントに付けたid

上記useMapで取り出されるMapインスタンスの名前は、mapみたいな一般的な名前ではなく、以下のように自分がコンポーネントに割り振ったidそのものになるので注意してください。

```tsx
const {myMap} = useMap()
return (
      <Map
        id='myMap'
        ....
      >
      ...
      </Map>
)
```
ドキュメントに普通に明記されているのですが、自分は以前これが見つけられなかったのか、当時ドキュメントがなかったのかで時間を溶かしたので念の為紹介させていただきました.

参考:https://visgl.github.io/react-map-gl/docs/api-reference/use-map

# おわりに
いかがでしたでしょうか。
地図アプリ,一度作ってみると思ったより簡単に綺麗な地図を描画できていじるのが楽しくなってくるのでぜひ試してみてください。



# コードのindex部分抜粋

```tsx
import Map, {
  Layer,
  LayerProps,
  MapProvider,
  Marker,
  MarkerDragEvent,
  NavigationControl,
  Source,
  useMap
} from "react-map-gl";
import {ChangeEvent, useEffect, useState} from "react";
import {Feature} from "geojson";
import 'mapbox-gl/dist/mapbox-gl.css';
import {bbox} from '@turf/turf'
import {Profile} from "../types/Profile";
import {LatLng} from "../types/LatLng";
import Pin from "../components/pin";

const layerStyle: LayerProps = {
  id: 'route',
  type: 'line',
  layout: {
    'line-join': 'round',
    'line-cap': 'round',
  },
  paint: {
    'line-color': '#3887be',
    'line-width': 5,
    'line-opacity': 0.75,
  }
}
//https://mapfan.com/spots/SCH,J,VW0
//Tokyo Station
const defaultPosition = {lng: 139.7673068, lat: 35.6809591}

const latLngToCoordStr = ({lat, lng}: { lat: number; lng: number }) => {
  return `${lng},${lat};`
}

const HomeContent = () => {
  const {myMap} = useMap();
  const [profile, setProfile] = useState<Profile>('driving');
  const [destination, setDestination] = useState<LatLng | null>(null);
  const [waypoint, setWaypoint] = useState<LatLng | null>(null);
  const [useWayPoint,setUseWayPoint] = useState(false);
  const [routeGeoJson, setRouteGeoJson] = useState<Feature>();
  const [isNavigationMode, setIsNavigationMode] = useState(false)
  const [currentUserPosition, setCurrentUserPosition] = useState<LatLng | null>(null);

  const getCurrentPosition = (): Promise<{ lat: number, lng: number }> => {
    return new Promise<{ lat: number, lng: number }>((resolve, reject) => {
      navigator.geolocation.getCurrentPosition(position => {
          const {latitude, longitude} = position.coords;
          resolve({lat: latitude, lng: longitude})
        },
        (error) => {
          reject(defaultPosition)
        },
        {enableHighAccuracy: true, timeout: 10000, maximumAge: 0});
    })
  };

  const onStartNavigation = () => {
    setIsNavigationMode(true)
  }

  const onFinishNavigation = () => {
    setIsNavigationMode(false)
  }

  const onClick = (event: mapboxgl.MapLayerMouseEvent) => {
    if (isNavigationMode) return;
    setDestination({lat: event.lngLat.lat, lng: event.lngLat.lng})
  }

  const onChangeUseWaypoint = (e:ChangeEvent<HTMLSelectElement>)=>{
    setIsNavigationMode(false)
    setUseWayPoint(Number(e.target.value)===1)
    if(!myMap)return;
    const center = myMap.getCenter();
    setWaypoint({lng: center.lng + 0.001, lat: center.lat + 0.001})
  }

  const onDragWaypoint = (e:MarkerDragEvent)=>{
    setIsNavigationMode(false)
    setRouteGeoJson(undefined)
    setWaypoint(e.lngLat)
  }

  useEffect(() => {
    getCurrentPosition().then(setCurrentUserPosition).catch(setCurrentUserPosition)
  }, [])

  useEffect(()=>{
    if(!isNavigationMode) setRouteGeoJson(undefined)
  },[isNavigationMode])

  useEffect(() => {
    if (!myMap || !isNavigationMode) return;
    if (currentUserPosition && destination) {
      (async () => {
        const wayPointCoord = useWayPoint&&waypoint?latLngToCoordStr(waypoint):''
        const coords=`${currentUserPosition.lng},${currentUserPosition.lat};${wayPointCoord}${destination.lng},${destination.lat}`
        const query = await fetch(
          `https://api.mapbox.com/directions/v5/mapbox/${profile}/${coords}?steps=true&geometries=geojson&language=ja&access_token=${process.env.NEXT_PUBLIC_MAP_BOX_TOKEN}`,
          {method: 'GET'},
        );
        const json = await query.json();
        const data = json.routes[0];
        const route = data.geometry.coordinates;
        const geojson = {
          type: 'Feature' as const,
          properties: {},
          geometry: {
            type: 'LineString' as const,
            coordinates: route,
          },
        };
        setRouteGeoJson(geojson);
        myMap.fitBounds(bbox(geojson) as [number, number, number, number], {
          duration: 1000,
          padding: 200
        })
      })();
    }
  }, [profile, isNavigationMode, destination]);

  return (
    <div>
      <div style={{padding: 8, display: 'flex', alignItems: 'center', gap: 10}}>
        <div style={{display:'flex',gap:4}}>
          <p>By</p>
          <select onChange={(e) => setProfile(e.target.value as Profile)}>
          <option value="walking">walking</option>
          <option value="driving">car</option>
          </select>
        </div>
        <div style={{display:'flex',gap:4}}>
          <p>WayPoint</p>
          <select defaultValue={Number(useWayPoint)} onChange={onChangeUseWaypoint}>
            <option value={1}>Enabled</option>
            <option value={0}>Disabled</option>
          </select>
        </div>
        <div style={{display:'flex',gap:4}}>
          <button disabled={!destination || isNavigationMode} onClick={onStartNavigation}>Start Navigation</button>
          <button disabled={!isNavigationMode} onClick={onFinishNavigation}>End Navigation</button>
        </div>
        <span>Drop the destination pin by clicking on the map. You can also drop a draggable waypoint pin by enabling it.</span>
      </div>
      {currentUserPosition &&
      <Map
        id='myMap'
        initialViewState={{
          longitude: currentUserPosition.lng,
          latitude: currentUserPosition.lat,
          zoom: 14,
        }}
        style={{width: '100%', height: '100vh'}}
        mapStyle={"mapbox://styles/mapbox/light-v10"}
        mapboxAccessToken={process.env.NEXT_PUBLIC_MAP_BOX_TOKEN}
        onClick={onClick}
      >
        {destination &&
        <Marker key={'destination'} longitude={destination.lng} latitude={destination.lat} anchor="center">
          <Pin/>
        </Marker>
        }
        {routeGeoJson && (
          <Source id='myRoute' type='geojson' data={routeGeoJson}>
            <Layer {...layerStyle} />
          </Source>
        )}
        <NavigationControl/>
        {currentUserPosition &&
        <Marker key={'currentPosition'} longitude={currentUserPosition.lng} latitude={currentUserPosition.lat}
                anchor="center">
          <Pin color={'blue'}/>
        </Marker>
        }
        {useWayPoint && waypoint &&
        <Marker draggable={true} onDrag={onDragWaypoint} key={'dummyWaypoint'} longitude={waypoint.lng}
                latitude={waypoint.lat} anchor="center">
          <Pin color={'green'}/>
        </Marker>
        }
      </Map>
      }
    </div>
  )
}

const Home = () => (
  <MapProvider>
    <HomeContent/>
  </MapProvider>
)

export default Home

```