LoginSignup
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-12-19

はじめに

こんにちは!
今回は、MapboxのDirectionsAPIを使って現在地からドラッグしたピンまでのルートを算出して描画する方法を紹介します。
おまけで中間地点も経由できるようにしています。
image.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

筆者レポジトリをクローン

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
    2."Start Navigation"をクリック
    Screen Shot 2022-12-20 at 0.40.23.png
    すると、以下のようにルートが描画されます。この際、ルートの大きさに合わせて地図の大きさが調節されます
    image.png

また、移動方法を変更することで同じ2地点間でも異なるルートが描画されます(同じルートになる場合もあります)
↓移動方法を車に変えた場合
image.png

現在地から任意の点までのルートを中間地点を経由して描画してみる

  1. 画面左上のセレクタでWayPointをEnabledにする
    すると、現在地ピン(青色)の近くに中間地点ピン(緑色)がドロップされます
    Screen Shot 2022-12-20 at 0.50.28.png
  2. 中間地点ピンを適当な場所にドラッグし、地図をクリックして目的地ピンを配置
  3. "Start Navigation"をクリック
    すると中間地点を経由したルートが算出されます
    image.png

解説

今回のアプリケーションは以下の5つの要素からなっています。これらの要素を順に解説していきます.

  • マップの描画
  • マーカーの描画
  • ルートの描画
  • ルートの取得
  • 描画したルートの大きさに地図をフィットさせる

マップの描画

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

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

      <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
Markerはreact-map-glが提供するMarkerコンポーネントで描画できます。
子要素として画像を挟むことでその画像を表示することができます。

<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
ルートの描画には、react-map-glのSourceLayerの2つのコンポーネントを使います

<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型を渡します。

const geojson = {
  type: 'Feature' as const,
  properties: {},
  geometry: {
    type: 'LineString' as const,
    coordinates: route,
  },
};

参考:https://ja.wikipedia.org/wiki/GeoJSON

Layerコンポーネントのpropsには以下のようなLayerProps型のオブジェクトを渡します。このオブジェクトで線の見た目を決定しています.

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から取得できるので興味のある方は試してみてください

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は地理情報を扱う際はよく出てくる単語で以下のような4つの値により矩形を決定するものです。

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

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を描画するページでは記述してください

import 'mapbox-gl/dist/mapbox-gl.css';

これを記述しないと、例えばMarkerNavigationControlコンポーネントは表示すらされなくなるので注意です。

useMapはMapProviderで囲んだcomponentでしか使えない

react-map-glでは地図のインスタンスを取得するためにuseMapというhooksを使います。このhooksはMapProviderというコンポーネントで挟まれたコンポーネント以下でしか使えないので注意が必要です。
今回は1ページしかないですが次のようにしてメインのコンポーネントをMapProviderに挟んでいます

const HomeContent = ()=>{
...
}

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

export default Home

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

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

const {myMap} = useMap()
return (
      <Map
        id='myMap'
        ....
      >
      ...
      </Map>
)

ドキュメントに普通に明記されているのですが、自分は以前これが見つけられなかったのか、当時ドキュメントがなかったのかで時間を溶かしたので念の為紹介させていただきました.

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

おわりに

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

コードのindex部分抜粋

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2