Vue-mapbpx 入門 / mapbox GL JS を Vue + TypeScript で使用する
TweetMapbox GL JS は GoogleMap と同じく、地図アプリケーションを構築するためのライブラリだが、描画に WebGL を使っているため GoogleMap よりも高速であると思われる。また、Google が地図情報の提供をゼンリンから受けなくなり、代わりに Mapbpox がゼンリンの地図情報を用いることになったので、それによって Mapbox を使用した案件が増えるものと思われる。
この記事では Vue を用いたフロントエンド開発環境において、Mapbox GL JS を組み込む方法を解説する。
まずは Mapbox を描画してみる
yarn add vue-mapbox mapbox-gl
- mapbox の accessToken は公式サイトにログインして取得する。
- これは基本的には GitHub 等で公開しないほうがいい。
- とはいえ、どうせフロントの JS には含まれてしまう。
- TypeScript なので vue-mapbox の import に少し調整が必要。(詳しくは後述)
:center
の値を変更すれば地図が動く。
<template>
<div id="map-wrap">
<router-link to="/" class="mainMapLink">
<a>トップページへ移動する</a>
</router-link>
<MglMap
:accessToken="accessToken"
:mapStyle="mapStyle"
:zoom.sync="zoom"
:center="center"
>
<MglNavigationControl />
</MglMap>
</div>
</template>
<script lang="ts">
import "mapbox-gl/dist/mapbox-gl.css"; // mapbox 用の CSS
import * as vueMapbox from "vue-mapbox"; // typeScript 的な記述。内容については後述
export default {
name: "MainMap",
components: {
MglMap: vueMapbox.MglMap, // メインの地図
MglNavigationControl: vueMapbox.MglNavigationControl // 拡大縮尺等のコントローラーコンポーネント
},
data() {
return {
accessToken: process.env.VUE_APP_MAPBOX_KEY, // 環境変数として持つ。オープンな git リポジトリにキーを公開しないため。
zoom: 17,
mapStyle: "mapbox://styles/mapbox/streets-v10", // 見た目。色々あるが標準のものを採用
center: { lon: 139.7009177, lat: 35.6580971 } // 地図の中心地
};
}
};
</script>
<style>
.mainMapLink {
position: absolute;
z-index: 100;
left: 0;
padding: 5px;
margin: 10px;
background: #42b983;
border-radius: 10px;
text-decoration: none;
color: white;
}
.mapboxgl-canvas {
outline: transparent;
left: 0;
}
#map-wrap {
position: absolute;
height: 100%;
top: 0;
left: 0;
width: 100%;
overflow: hidden;
}
#map-wrap .mgl-map-wrapper {
position: absolute;
height: 100%;
top: 0;
left: 0;
width: 100%;
}
#map-wrap .mgl-map-wrapper .mapboxgl-map {
height: 100%;
left: 0;
overflow: visible;
position: absolute;
top: 0;
width: 100%;
}
.mapboxgl-ctrl-attrib-inner {
display: none;
}
.button {
position: absolute;
z-index: 100;
background: rebeccapurple;
}
</style>
TypeScript の対応
前提として、Vue CLI で作成した TypeScript プロジェクトで自分は開発を進めている。その場合に vue-mapbox を JS の場合と同様に読み込むと問題が発生する。そのための対処法について。ただし、TypeScript には全く習熟していないので、ベストプラクティスではない可能性が高い。
まず、ライブラリの型が用意されている場合には、@types/package-name というライブラリを読み込めばよい。それがない場合には以下のように対応する。
まず読み込み方が JS とは異なる。
import * as vueMapbox from "vue-mapbox";
さらに読み込めるように以下のような設定を書く。私は最初から用意されていた shims-vue.d.ts
に追加したが、本来は別のファイルにしたほうがいい気もする。これで module を読み込むことができる。
// 以下を追加
declare module "vue-mapbox";
dummy API Server の実装
json-server
を使ってダミーのレスポンスを返す API を作成する。
{
"scripts": {
"server-start": "json-server --watch db.json --port 4000"
}
}
このダミー API にプロキシする設定
module.exports = {
// devServerの設定
devServer: {
proxy: {
"/api": {
target: "http://localhost:4000",
pathRewrite: {
"^/api": ""
}
}
}
}
};
axios を使って値をフェッチする
<template>
<div class="home">
<h1>Vue + Vuex + Vue-router + TypeScript Demo</h1>
<button @click="clickHandler">クリック</button>
</div>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import axios from "axios";
type FetchData = {
author: string;
id: number;
title: string;
};
@Component
export default class Home extends Vue {
private async clickHandler() {
const data = await this.fetchData();
const { author, id, title } = data;
console.log(title);
}
public async fetchData(): Promise<FetchData> {
try {
const res = await axios.get("/api/posts");
const { data } = res;
return data;
} catch (e) {
throw new Error(e);
}
}
}
</script>
Marker を使う
:coordinates
に位置情報を与えると、そこに表示される:offset
は多少マーカーをずらすための値:draggable
を true にすればドラッグできる機能が最初から用意されている@dragend
にイベントハンドラーを与えれば、ドラッグ終了時に色々できるe.marker.getLngLat()
でドラッグ終わりの位置情報を取得できる- vue の機能である ref で強引に DOM を掴むこともできる(ただし、これはどうしても Vue で直接操作できない時にだけにするほうがいい)
<template>
<MglMarker
:coordinates="coordinates"
:offset="[-10, -40]"
ref="marker"
:draggable="true"
@dragend="dragEnd"
>
<!-- この名前の slot に与えることで、custom の UI を使える -->
<template slot="marker">
<div class="tempMarker">this is markerUI</div>
</template>
</MglMarker>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import * as vueMapbox from "vue-mapbox";
import randomcolor from "randomcolor";
@Component({
components: {
MglMarker: vueMapbox.MglMarker
}
})
export default class MapboxMarker extends Vue {
// マーカーの位置
get coordinates() {
return [139.7009177, 35.6580971];
}
dragEnd(e: any) {
const { lng, lat } = e.marker.getLngLat();
console.log({ lng, lat });
// DOM を強引に取得し、操作する場合
const dom = this.$refs.marker as any;
const markerDom = dom.marker._element;
console.log(markerDom);
markerDom.style.background = randomcolor();
}
}
</script>
<style scoped>
.tempMarker {
background: #42b983;
padding: 10px;
border-radius: 10px;
}
</style>
地図をクリックした際に仕事をさせる
@click
でクリック時に仕事をさせることができる。
<template>
<div id="map-wrap">
<MglMap
@click="clickHandler"
:accessToken="accessToken"
:mapStyle="mapStyle"
:zoom.sync="zoom"
:center="center"
>
<MglNavigationControl />
<MarkerWrapper :points="points" />
</MglMap>
</div>
</template>
取得したイベントから、クリックした位置情報を取得できる。これを用いて新たに地点を追加するといったことができる。
private async clickHandler(e: any) {
const { lng, lat } = e.mapboxEvent.lngLat;
console.log({ lng, lat });
}
Vuex store に地点の値を保持する
Vuex を導入する。フォルダ構造は公式ドキュメントを参考にした。
基本的には rootState を持たせず、全て module として管理する。
/store/index.ts
modules に、module をどんどん組み込んでいく。型の設定については以下記事を参考にした。
- https://codeburst.io/vuex-and-typescript-3427ba78cfa8
- https://qiita.com/yam0918/items/68d4d6c74b06d589a195
import Vue from "vue";
import Vuex, { StoreOptions } from "vuex";
import map from "@/store/modules/map";
Vue.use(Vuex);
export const NAME_SPACE = {
map: "map"
};
export const storeConfig: StoreOptions<{}> = {
modules: { map }
};
export default new Vuex.Store<{}>(storeConfig);
/store/modules/map/index.ts
state の設定と、mutations, actions, getters を読み込んでそれを設定する。 state の型をつけると良い。
import { Module } from "vuex"; // 型用
import { MapState } from "@/store/modules/map/types";
import mutations from "@/store/modules/map/mutations";
import actions from "@/store/modules/map/actions";
import getters from "@/store/modules/map/getters";
const initialState: MapState = {
pointList: [
{
coordinates: {
lng: 139.7009177,
lat: 35.6580971
},
name: "name1",
id: 1
},
{
coordinates: {
lng: 139.70163653204548,
lat: 35.65803607896774
},
name: "name2",
id: 2
}
]
};
// これを true にすること
const namespaced = true;
const map: Module<MapState, {}> = {
namespaced,
state: initialState,
getters,
mutations,
actions
};
export default map;
/store/modules/map/types.ts
store の型を設定する。
以下の type MapState がこの map module の state 全体の型である。type point は、pointList が持つ配列の中身用の型である。
type coordinates = {
lng: number;
lat: number;
};
export type point = {
coordinates: coordinates;
name: string;
id: number;
};
export type MapState = {
pointList: point[];
};
Vuex-class を導入する
Vuex-class を導入する。これは VueCLI で作成したプロジェクトにも入っていなかった。
$ yarn add vuex-class
- namespace メソッドで module にアクセするためのインスタンスを作成する
- @mapModule.State("pointList") で module の state にアクセスする。
- getter, action 等もある
private pointList!: point[]
は、poinstList というプロパティが、point という型の要素を持った配列であることを TypeScript で定義している- ただし、本当に値が型通りかは担保できない模様。その型であるように振る舞う、ようである。
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
// vuex 系をデコレイト
import { State, Getter, Action, Mutation, namespace } from "vuex-class";
import axios from "axios";
import { NAME_SPACE } from "@/store";
import { point } from "@/store/modules/map/types";
const mapModule = namespace(NAME_SPACE.map);
type FetchData = {
author: string;
id: number;
title: string;
};
@Component
export default class Home extends Vue {
@mapModule.State("pointList")
private pointList!: point[];
private mounted() {
console.log("mount");
const res = this.pointList.map(point => {
return point.coordinates;
});
console.log("res", res);
}
private async clickHandler() {
const data = await this.fetchData();
const { author, id, title } = data;
console.log(title);
}
public async fetchData(): Promise<FetchData> {
try {
const res = await axios.get("/api/posts");
const { data } = res;
return data;
} catch (e) {
throw new Error(e);
}
}
}
</script>
地図クリック時に地点を追加する
- 地図をクリックした時に、action を発行すればいい
- action もデコレーターで書く
- action でフェッチした値を用いて store を更新する
- getter で取得した値が変更され、地図に反映される
import { Component, Vue } from "vue-property-decorator";
import { State, Getter, Action, Mutation, namespace } from "vuex-class";
import axios from "axios";
import { NAME_SPACE } from "@/store";
import { IPayload } from "@/store/modules/map/actions";
import { Point } from "@/store/modules/map/types";
const mapModule = namespace(NAME_SPACE.map);
// store の getter
@mapModule.Getter("pointList")
private pointList!: Point[];
// action: フェッチしてきたデータを追加する
@mapModule.Action("setInitialPointList")
private setInitialPointList!: (pointList: Point[]) => void;
// action: 一地点を追加する
@mapModule.Action("addPointToList")
private addPointToList!: (payload: IPayload) => void;
private async created() {
const data: Point[] = await this.fetchData();
this.setInitialPointList(data);
console.log(data);
}
private async clickHandler(e: any) {
const { lng, lat } = e.mapboxEvent.lngLat;
const payload: IPayload = {
point: {
coordinates: {
lat,
lng
},
id: 0,
name: "dummyName"
}
};
this.addPointToList(payload);
}
現時点ではいくつか問題がある
現時点ではいくつか問題がある。それは、フェッチしてきた値なのか、それとも今追加して増えた地点なのかわからないことである。
そのためには、フェッチしてきた値と、新たに追加した地点を、区別できなくてはいけない。
また、新たに追加した地点に関しても、さしあたっての ID を決めなくてはいけない。そうしないと、ID が被ってしまう。
解決策
以下の三つの型を用意する。
- fetch してきた point の情報の型:FetchedPoint
- 上記データに、必要なプロパティを追加した StorePoint という型
- さらに getter を使って演算する PointForUI という型
詳細は追記する。
TypeScript メモ
const { data }: { data: FetchedPoint[] } = res;