React と Mapbox GL JS を使ってベクトルタイルを表示する

こんにちは、TileCloud というサービスの開発チームの鎌田です。

[https://geolonia.com](https://geolonia.com)

[Mapbox GL JS](https://www.mapbox.com/mapbox-gl-js/api/) は WebGL を使って [MBTiles 形式](https://www.mapbox.com/help/define-mbtiles/)のベクトルタイルの地図をレンダリングする JavaScript のライブラリです。タイルのベンダーを選び、地図の見た目を定義するためのスタイルを JSON 形式で指定することで、ベクトルタイルの地図をブラウザ上に簡単に表示することができます。

この記事では、web フロントエンドのフレームワークとしてよく使われている React を使ってアプリを作成し、Mapbox GL JS を組み込んで、ベクトルタイルの地図をレンダリング方法について解説します。また、地図のスタイルの変更などのインタラクティブな操作を実装することも試します。

## 開発環境

この記事では以下のツールを使うことを想定しています。

- LTS な Node.js (12/4 現在では v10)
- [Yarn](https://yarnpkg.com/) v1.12.3
- WebGL をサポートしているブラウザ。 [Can I use](https://caniuse.com/#feat=webgl)

## ベクトルタイルの選定

今回は、 Mapbox が提供するベクトルタイルを利用します。これは OpenStreetMap がベースになっているものです。 [mapbox.com](https://mapbox.com) にサインアップし、アクセストークンを取得しておいてください。

### PR

Mapbox GL JS は、Mapbox 社のベクトルタイルのみではなく、標準的な MBTiles 形式のベクトルタイルを扱うことができます。TileCloudでもパブリックベータとしてベクトルタイルの提供サービスの開始を目指しており、これらはこの記事と同等あるいはより単純な手順で扱うことができるようになる予定です。

ベクトルタイル以外にも、痒いところに手が届く様々な拡張機能など準備中です！

[https://tilecloud.io](https://geolonia.com)
[https://github.com/tilecloud](https://github.com/geolonia)

<img width="1244" alt="Screenshot 2018-12-05 11.04.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/125062/8649fb76-58e1-bfe1-a43c-421c7ec3a752.png">

## React アプリのセットアップ

`create-react-app` コマンドを使って React アプリの雛形を作成します。

```shell
$ npx create-react-app sandbox-react-mapbox-gl-js
$ cd sandbox-react-mapbox-gl-js
$ yarn start
```

`npx` コマンドは、引数で指定したライブラリの CLI コマンドを即時実行してくれる便利ツールです。npm に同梱されています。

`yarn start` でホットリロードされる開発サーバーとブラウザが起動し、くるくる回る React のアイコンが表示されることが確認できます。これで React の web アプリのセットアップが完了しました。

<img width="1392" alt="Screenshot 2018-12-04 11.04.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/125062/181139f4-f3ae-df40-b58a-946eee127692.png">

この時点でのプロジェクト構成はこんな感じ。

```
$ tree -I node_modules
.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   └── manifest.json
├── src
│   ├── App.css
│   ├── App.js
│   ├── App.test.js
│   ├── index.css
│   ├── index.js
│   ├── logo.svg
│   └── serviceWorker.js
└── yarn.lock
```

## Mabox GL JS vs. ラッパーライブラリ

Mapbox GL JS を React アプリに組み込んでいきます。

[react-mapbox-gl](https://www.npmjs.com/package/react-mapbox-gl) のようなラッパーライブラリも npm コミュニティによって公開されていますが、これらは使わず Mapbox GL JS を直接組み込みます。これは [Mapbox の公式ブログ](https://blog.mapbox.com/mapbox-gl-js-react-764da6cc074a)に書かれている方法です。ラッパーライブラリを使わない理由についてもブログ内で以下のように説明されています。

（抜粋、翻訳、補足）

> React のエコシステム内で `<Mapbox />` みたいなコンポーネントを探すのは当然のことで、[react-map-gl](https://github.com/uber/react-map-gl) や[react-mapbox-gl](https://github.com/alex3165/react-mapbox-gl) などがすでにあります。しかし、今回作ろうとしているものは規模が小さいし、抽象化の機能を提供するこれらのコンポーネントライブラリを使うことには大きなトレードオフがあります。機能が足りなかった時はメンテナンスコストが発生してしまうからです。 今回のような用途には、Mapbox GL JS のみで十分な抽象化の機能が提供されています。

ライブラリ選定の際には JavaScript のファイルが肥大することと合わせてよく考えておきたい点ですね。逆に、このようなラッパーライブラリが提供する機能が十分だと判断できるのであれば積極的に使っていきたいです。

## Mapbox GL JS を組み込む

ライブラリをインストールします。

```shell
$ yarn add mapbox-gl
```

Mapbox のタイルを使う場合は、 Mapbox のアクセストークンを埋め込みます。

```javascript
// src/index.js
import mapboxgl from 'mapbox-gl'
mapboxgl.accessToken = 'pk.xxxxxxxx.yyyyyyyyyy' // あなたの Mapbox のパブリックアクセストークン
```

地図の入れ物になる要素をレイアウトするスタイルを書きます。

```css
/* src/App.css */
html,
body {
  margin: 0;
  padding: 0;
}

.map {
  width: 100vw;
  height: 100vh;
}
```

最初に Mapbox GL JS の css も import しておきます。また、地図に適用するスタイルは、 Mapbox が提供している `streets-v9` というものを使います。
Mapbox GL JS の Map クラスのインスタンス化の際に HTML エレメントを指定する必要があります。React コンポーネントの中でHTMLエレメントを取得するには `ref` props を使います。ポイントは、コンポーネントのマウントが完了し、 DOM が出来上がっている `componentDidMount` 以降のライフサイクルでインスタンス化を行う点です。

```jsx
// src/App.js
import React, { Component } from 'react'
import mapboxgl from 'mapbox-gl'
import './App.css'
import 'mapbox-gl/dist/mapbox-gl.css'

class App extends Component {
  componentDidMount() {
    this.map = new mapboxgl.Map({
      container: this.container,
      style: 'mapbox://styles/mapbox/streets-v9',
    })
  }

  componentWillUnmount() {
    this.map.remove()
  }

  render() {
    return <div className={'map'} ref={e => (this.container = e)} />
  }
}

export default App
```

これで React のコンポーネントに内包される形でベクトルタイルをレンダリンングしたものが表示されました。

<img width="1392" alt="Screenshot 2018-12-04 12.54.57.png" src="https://qiita-image-store.s3.amazonaws.com/0/125062/448c4996-f176-6387-6b20-bf73d08ca26c.png">

## 地図のスタイル

地図のスタイル指定の際に `"mapbox://styles/mapbox/streets-v9"` という Mapbox 独自のスキームが指定されています。

```javascript
new mapboxgl.Map({
  container: this.container,
  style: 'mapbox://styles/mapbox/streets-v9',
})
```

ここで読み込まれることになるスタイルの実態は、単なる JSON ファイルです。中を覗いてみましょう。

```shell
$ curl https://api.mapbox.com/styles/v1/mapbox/streets-v9?access_token=pk.xxxxxxxx.yyyyyyyyyy #  あなたのパブリックアクセストークン
```

```javascript
// 抜粋
{
  "version": 8,
  "name": "Mapbox Streets",
  "sources": {
    "composite": {
      "url": "mapbox://mapbox.mapbox-terrain-v2,mapbox.mapbox-streets-v7",
      "type": "vector"
    }
  },
  "layers": [
    {
      "id": "landcover_snow",
      "type": "fill",
      "metadata": {
        "mapbox:group": "1456970288113.8113"
      },
      "source": "composite",
      "source-layer": "landcover",
      "filter": [
        "==",
        "class",
        "snow"
      ],
      "layout": {},
      "paint": {
        "fill-color": "hsl(0, 0%, 100%)",
        "fill-opacity": 0.2,
        "fill-antialias": false
      }
    }
  ]
}
```

例えば `landcover_snow` という id の地物を見ると、透過度 0.2、`hsl(0, 0%, 100%)`での塗りつぶしで表現されている、などのスタイルが定義がされていることが伺えます。ちなみにこれは氷雪地帯などでみられる雪で覆われた地面ということですね。詳細なスタイルの指定方法はこちらに仕様があります。
https://www.mapbox.com/mapbox-gl-js/style-spec/

## React のデータバインディングに Mapbox GL JS のスタイル指定を組み込む

`style` オプションは `mapbox://`スキームだけでなく、オブジェクトをそのまま指定することができます。
スタイルのオブジェクトを state に格納してその値をインタラクティブに更新することを試してみます。以下の例では、ボタンを押した時に氷雪地帯のスタイルを変更しています。

```jsx
// src/App.js
import React, { Component } from 'react'
import mapboxgl from 'mapbox-gl'
import './App.css'
import 'mapbox-gl/dist/mapbox-gl.css'

const BASE_URL = 'https://api.mapbox.com/styles/v1/mapbox/streets-v9'

class App extends Component {
  constructor(props) {
    super(props)
    this.state = { style: false }
  }

  componentDidMount = async () => {
    const url = `${BASE_URL}?access_token=${mapboxgl.accessToken}`
    const style = await fetch(url).then(res => res.json())

    this.map = new mapboxgl.Map({
      container: this.container,
      style,
    })

    this.setState({ style })
  }

  componentDidUpdate(prevProps, prevState) {
    if (prevState.style !== this.state.style) {
      this.map.setStyle(this.state.style)
    }
  }

  componentWillUnmount() {
    this.map.remove()
  }

  onClick = () => {
    const prevStyle = this.state.style
    const nextStyle = {
      ...prevStyle,
      layers: prevStyle.layers.map(
        layer =>
          layer.id === 'landcover_snow'
            ? { ...layer, paint: { ...layer.paint, 'fill-color': 'red' } }
            : layer,
      ),
    }
    this.setState({ style: nextStyle })
  }

  render() {
    return (
      <div className={'map'} ref={e => (this.container = e)}>
        <button
          style={{ position: 'absolute', zIndex: 1 }}
          onClick={this.onClick}
        >
          {'氷雪地帯は？'}
        </button>
      </div>
    )
  }
}

export default App
```

`componentDidUpdate` のライフサイクルメソッドで state の更新を検知して Mapbox GL JS の `setStyle` メソッドを呼んでいます。 React のライフサイクルと `Map` インスタンスのスタイルの更新を結びつけることができます。

なお、この例ではコンポーネントをマウントする際に Mapbox の公式のスタイルを `fetch` して state に格納していますが、ベクトルタイルの地図をカスタマイズして表示したいときは独自の `style.json` を利用することになるでしょう。[Maputnik](https://maputnik.github.io/) という GUI のスタイルエディタも存在します。

![Dec-04-2018 13-56-58.gif](https://qiita-image-store.s3.amazonaws.com/0/125062/1aa5ee9c-1e34-b764-b3a1-903ccada9ce2.gif)

# まとめ

- Mabox GL JS を使うとベンダーが提供するベクトルタイルを簡単に表示することができる
- React のアプリに Mapbox GL JS を組み込んで使うことができる
- React のコンポーネントライフサイクルに Mapbox GL JS を組み込んでインタラクションを実装することができる

# リポジトリ

https://github.com/kamataryo/sandbox-react-mapbox-gl-js