Mapbox 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 の値を変更すれば地図が動く。
map 本体
<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 を読み込むことができる。

shims-vue.d.ts
// 以下を追加
declare module "vue-mapbox";

dummy API Server の実装

json-server を使ってダミーのレスポンスを返す API を作成する。

api/package.json
{
  "scripts": {
    "server-start": "json-server --watch db.json --port 4000"
  }
}

このダミー API にプロキシする設定

vue.config.js にプロキシを設定する
module.exports = {
  // devServerの設定
  devServer: {
    proxy: {
      "/api": {
        target: "http://localhost:4000",
        pathRewrite: {
          "^/api": ""
        }
      }
    }
  }
};

axios を使って値をフェッチする

Home.vue
<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 で直接操作できない時にだけにするほうがいい)
MapbpxMaker.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 でクリック時に仕事をさせることができる。

MainMap.vue
<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 を導入する。フォルダ構造は公式ドキュメントを参考にした。

Screen Shot 2019-04-16 at 17.05.51

基本的には rootState を持たせず、全て module として管理する。

/store/index.ts

modules に、module をどんどん組み込んでいく。型の設定については以下記事を参考にした。

/store/index.ts
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 の型をつけると良い。

/store/modules/map/index.ts
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 が持つ配列の中身用の型である。

/store/modules/map/types.ts
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 で定義している
  • ただし、本当に値が型通りかは担保できない模様。その型であるように振る舞う、ようである。
Main.vue
<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 で取得した値が変更され、地図に反映される
Map.vue
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;