はじめに
こんにちは!
今回は、MapboxのDirectionsAPIを使って現在地からドラッグしたピンまでのルートを算出して描画する方法を紹介します。
おまけで中間地点も経由できるようにしています。
※位置情報を許可しない場合は上記のように東京駅が始点になります。
コードは全て筆者のGithubにあります。
https://github.com/ironkicka/map-navigation-sample
おすすめの読者
- 地図アプリをなんでもいいから作ってみたい
- mapbox-gl-jsは使ったことあるけど、Reactではやったことない
- mapboxでルート検索してみたい
動かし方
Mapboxのアカウントを作成する
以下からアカウントを作成します。
https://account.mapbox.com/auth/signup/
APIトークンを取得する
ここでマップの描画に必要なトークンを取得することができます。
筆者レポジトリをクローン
git clone git@github.com:ironkicka/map-navigation-sample.git
プロジェクトディレクトリの直下に.env.local
を配置
中身は以下のように設定してください
NEXT_PUBLIC_MAP_BOX_TOKEN=YOUR_TOKEN
実行
以下を実行すればアプリケーションが立ち上がるはずです。
npm run dev
遊び方
現在地から任意の点までのルートを描画してみる
- 地図上の適当なところをクリックして赤い目的地ピンを配置
青色が現在地です。
実際には実行しているデバイスの現在地が表示されますが、デモのために東京駅を始点としています。
2."Start Navigation"をクリック
すると、以下のようにルートが描画されます。この際、ルートの大きさに合わせて地図の大きさが調節されます
また、移動方法を変更することで同じ2地点間でも異なるルートが描画されます(同じルートになる場合もあります)
↓移動方法を車に変えた場合
現在地から任意の点までのルートを中間地点を経由して描画してみる
- 画面左上のセレクタでWayPointをEnabledにする
すると、現在地ピン(青色)の近くに中間地点ピン(緑色)がドロップされます
- 中間地点ピンを適当な場所にドラッグし、地図をクリックして目的地ピンを配置
- "Start Navigation"をクリック
すると中間地点を経由したルートが算出されます
解説
今回のアプリケーションは以下の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の描画
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)の描画
ルートの描画には、react-map-glのSource
とLayer
の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';
これを記述しないと、例えばMarker
やNavigationControl
コンポーネントは表示すらされなくなるので注意です。
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