Google map APIで独自地図を扱う

 [OPENLOGI AdventCalendar](https://qiita.com/advent-calendar/2018/openlogi) 3日目の記事です。

こんにちわ。「物流をもっとシンプルに」でおなじみオープンロジのエンジニアの@haradakunihikoです。
オープンロジの事業の一つである物流の代行サービスにおいて、提携している倉庫庫内作業のサポートをするためのWMSの開発を行なっています。

さて、今日の話題は地図です。

先日（といっても数ヶ月前の話ですが）新しく **Google Maps Platform** というサービスが発表されました。とはいえ既存の **Google Maps API** 機能が大きく変わるというわけではないようで、今までの18あったGoogle Maps APIが **Maps** 、 **Routes** 、 **Places** と三つの分類に単純化・整理され、料金体系が一新された、ということのようです。多分。

# **Maps API** でできること

さて、 **Google Maps Platforms** の内、 **Maps API** について。地図の操作はよく記事になりますが、実際のサービスで見るのは、

- サービスへの地図のはめ込み
- ルートの表示
- ピンの表示

など。基本的にはGoogleの提供している地図情報を、それぞれのサービスに沿ってわかりやすく表示させる、ということが多いイメージ。でも、本当はMap APIはかなり自由度が高く色々と応用することができます。
例えば、画像を使って自分だけのぬるぬる動く独自地図を作るなど。

![treasure_3_480.gif](https://qiita-image-store.s3.amazonaws.com/0/26136/5daa970c-f7c4-573f-e3b3-2a223e703f83.gif)
<a href="https://jp.freepik.com/free-vector/_1128660.htm">
Designed by Freepik</a>



と、いうことで、この記事では **Google Maps Platform** で地図を扱う方法について書いていきます。


- [Maps Javascript API の公式ドキュメント](https://developers.google.com/maps/documentation/javascript/basics)
- [Maps Javascript API のクラス仕様など](https://developers.google.com/maps/documentation/javascript/reference/map)

基本全てはこのあたりに書いてあることばかりですが、必要な要素について、順をおって説明していきます。

## 表示できる地図の種類について
**Maps API**では表示できる地図の種類を[Map Types](https://developers.google.com/maps/documentation/javascript/maptypes)を利用して定義します。デフォルトでは基本となる4種類のタイプが定義されており、それを拡張、新規に定義することで思い思いの地図を表示させることができます。

### 基本の4種類の地図を表示させて見る
Google Map Platformで扱える基本的な地図の種類は以下の4種類あります。

- `roadmap` 通常の地図
- `satellite` Google Earthのサテライト画像の表示
- `hybrid` 上記両方をミックスした地図
- `terrain` 地形情報を付加した地図。

<img width="431" alt="スクリーンショット 2018-12-02 1.22.34.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/671c5a7d-22cb-659b-fda6-d4e630805a83.png">
<img width="432" alt="スクリーンショット 2018-12-02 1.22.56.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/455f2f62-20c5-e533-3bf0-60df1275d98a.png">
<img width="431" alt="スクリーンショット 2018-12-02 1.23.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/6de49ec1-82f9-cc76-76e2-6a7094878e80.png">
<img width="434" alt="スクリーンショット 2018-12-02 1.24.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/0e6c57a1-1db2-d46a-cf95-1950434b56a7.png">

Google Mapでよく見るおなじみの地図です。
これらを表示するためのもっとも基本的なコードは以下のような感じです。

```html
    <div id="map" style="height: 100vh"></div>
    <script>
      function initMap() {
        var map = new google.maps.Map(document.getElementById('map'), {
          zoom: 10, // デフォルトのズームレベル
          center: {lat: 35, lng: 135}, // 中心の緯度・経度
          mapTypeId: 'terrain' // ここを変える
        });
      }
    </script>
    <script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&callback=initMap"
    async defer></script>
```

`google.maps.Map` に `MapType` を指定することで、期待する地図を表示させることができます。

## カスタム地図を表示させてみる
### MapTypeの定義
基本となるタイプでない地図を表示する場合、 [MapTypeインターフェース](https://developers.google.com/maps/documentation/javascript/reference/image-overlay#MapType) を実装して登録することで、利用できるようになります。
インターフェース仕様には様々なプロパティや関数がありますが、 `MapType` は以下のプロパティが必須となっているため、これらを実装します。

- tileSize （地図を構成するタイルのサイズ）
- maxZoom （ズームできる最大値）
- getTitle （各タイルを描画するための関数。APIから呼ばれる。）

### htmlによる地図を定義する
まずはhtmlを利用してカスタム地図を作って見ます。

```js

function initMap() {
  function Grid(size) {
    this.tileSize = size;
  }

  Grid.prototype.maxZoom = 10;
  Grid.prototype.getTile = function(coord, zoom, ownerDocument) {
    var div = ownerDocument.createElement('div');
    div.innerHTML = coord;
    div.style.width = this.tileSize.width + 'px';
    div.style.height = this.tileSize.height + 'px';
    div.style.borderStyle = 'solid';
    div.style.borderWidth = '1px';
    return div;
  };

  var map = new google.maps.Map(document.getElementById('map'), {
    zoom: 10,
    center: {lat: 35, lng: 135},
    mapTypeId: 'custom_type',
  });

  // `custom_type` というtypeを登録
  map.mapTypes.set('custom_type', new Grid(new google.maps.Size(256, 256)));
}
```

<img width="608" alt="スクリーンショット 2018-12-02 0.59.12.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/fb36eac9-7dfd-7198-e0f4-a570aa860be7.png">

大事なのは `tileSize` と `getTile` 関数です。
`tileSize`では256 * 256サイズを指定しました。が、これはなんでもいいです。長方形でも大丈夫。
`getTile` はその名の通りですが、地図を構成する `タイル` を定義しています。
左上の地図の切り替えと、ストリートビューが邪魔なので、これを消すためには、 `mapTypeControlOptions` `streetViewControl` を変更します。

```js

function initMap() {
  function Grid(size) {
    this.tileSize = size;
  }
  // 省略（同上）

  Grid.prototype.name = 'カスタム地図';  // 追加

  var map = new google.maps.Map(document.getElementById('map'), {
    zoom: 10,
    center: {lat: 35, lng: 135},
    mapTypeId: 'custom_type',
    streetViewControl: false, // 追加
    mapTypeControlOptions: {  // 追加
      mapTypeIds: ['custom_type'],
    }
  });

  map.mapTypes.set('custom_type', new Grid(new google.maps.Size(256, 256)));
}
```

<img width="538" alt="スクリーンショット 2018-12-02 22.41.59.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/80c7c32f-43f4-1c51-7076-03dddd3833ea.png">

いい感じになりました。

### カスタム地図をオーバーレイさせる
地図を既存の地図にオーバーレイで表示させることもできます。

```js

function initMap() {
  function Grid(size) {
    this.tileSize = size;
  }
  // 同上。省略

  var map = new google.maps.Map(document.getElementById('map'), {
    zoom: 10,
    center: {lat: 35, lng: 135},
    mapTypeId: 'roadmap', // 通常の地図を表示
  });
  // 個別の地図としてmapTypeに登録するのではなく、オーバーレイで表示させる
  map.overlayMapTypes.insertAt(0, new Grid(new google.maps.Size(256, 256)));
}
```

<img width="611" alt="スクリーンショット 2018-12-02 1.06.39.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/70d685b9-402e-b6d0-ae45-998d702ba17f.png">

## タイルについて
さて、上でタイルという表現をしました。
通信速度が遅い環境でGoogle Mapをzoom in/outすると、それぞれのタイルが遅延して表示されるのは見たことがあると思いますが、Google Map APIでは各zoopレベルにおいて、同じピクセルサイズの画像（=タイル）を利用します。
通常のGoogle Mapの画像は256px * 256pxの画像（タイル）で構成されています。
例えば上述の地図の各zoomレベルで比較してみると、

**zoom レベル0**
もっとも縮尺の小さい状態。256px * 256pxの世界地図が横に繋がって表示している
<img width="705" alt="スクリーンショット 2018-12-02 23.00.11.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/6711975d-bb7f-8262-920b-357d1a23a5ed.png">

**zoom レベル1**
倍の縮尺。256px * 256px * 4 で世界地図が構成されている
<img width="706" alt="スクリーンショット 2018-12-02 23.00.17.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/a8708e53-9c92-fcde-29cd-6591a114ee67.png">

以降、zoomレベルを1あげると、2倍ずつ縮尺が上がっていきます。

## 独自画像の地図を表示させる
ここまでで基本となる知識がそろったのでいよいよ独自画像の地図を表示させてみます。
Google Maps APIでは、各zoom levelにおける縮尺の画像を、特定のpixcelサイズで用意する必要があります。画像の加工が得意な人は頑張って用意すれば良いですが、 `google map tile generator` とかでググると色々ツールがあります。

### タイルを作成する

- [image-map-tiles](https://www.npmjs.com/package/image-map-tiles)
- [maptiler](https://www.maptiler.com)

例えば[image-map-tiles](https://www.npmjs.com/package/image-map-tiles)を使って画像を分割します。
画像を分割したりごにょごにょして表示するのに著作権とかよくわからないので最近生まれた我が子の写真を使おう。可愛い。

![20181025_5.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/7049c3af-0f18-bad4-902c-9f1f189716b9.jpeg)

2400px * 3600pxの写真です。これを元に、例えばzoomレベルを5まで作るとすると、 タイルのサイズは

- 横・・・ 2400 / 2^5 = 75px
- 縦・・・ 3600 / 2^5 = 112.5px

なので、各zoomレベルにおいて、75px * 112pxのタイルで構成される写真を作ります。

**zoomレベル0（75 * 112px / 1枚）**
![0.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/c7762611-96a2-a358-d75b-80648754c85a.jpeg)

**zoomレベル1（75 * 112px / 2*2枚）**
![0.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/2d84ebbb-35f3-f80f-ab72-b61c44138b5f.jpeg) ![0.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/f3b48403-643a-e1a4-10be-4a356f30b967.jpeg)
![1.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/7f385fc0-dc9f-92d8-c3e0-8d767dd96d6f.jpeg) ![1.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/3cd3d305-35dc-35cd-22f1-867e1bb2bbdf.jpeg)

**zoomレベル2（75 * 112px / 4*4枚）**
以下省略

ちなみにzoomレベル５（75 * 112px / 32*32枚）の目の部分
![9.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/2e0b5c49-45d5-099b-ca19-41e76f70df9a.jpeg) ![9.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/8c1cd56a-edf2-cc14-d76e-1841732f5283.jpeg)
![10.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/fd8720ef-52c8-5059-8532-98be681fca63.jpeg) ![10.jpg](https://qiita-image-store.s3.amazonaws.com/0/26136/b82f40a5-9ed3-d910-a3d3-d4f22f51b7a5.jpeg)

### 画像を利用したタイプを定義する
カスタム地図を定義する際、 `MapType` を実装しましたが、画像を利用した地図はすでにクラス（[google.maps.ImageMapType](https://developers.google.com/maps/documentation/javascript/reference/image-overlay#ImageMapType)）が提供されています。 
`google.maps.ImageMapType` のコンストラクタに渡すパラメータのインターフェースは [google.maps.ImageMapTypeOptions](https://developers.google.com/maps/documentation/javascript/reference/image-overlay#ImageMapTypeOptions) に定義されていますが、

- getTileUrl
- tileSize
- maxZoom

が必須です。


```js
function initMap() {
  function Grid(size) {
    this.tileSize = size;
  }
  // 同上・省略

  var imageMap = new google.maps.ImageMapType({
    // 各zoomレベル、タイル座標に対して表示させる画像を返す
    getTileUrl: function(coord, zoom) {
      return './daughter' +
          '/' + zoom + '/' + coord.x + '/' +
          coord.y + '.jpg';
    },
    tileSize: new google.maps.Size(75, 112),// タイルのサイズ（px）を指定
    maxZoom: 5, // 最大のzoomレベル
  });

  var map = new google.maps.Map(document.getElementById('map'), {
    zoom: 1, // 初期zoomレベルを指定
    center: {lat: 35, lng: 135},
    mapTypeId: 'image_map',
    streetViewControl: false,
    mapTypeControlOptions: {
      mapTypeIds: [],
    }
  });
  
  map.overlayMapTypes.insertAt(0, new Grid(new google.maps.Size(75, 112)));
  map.mapTypes.set('image_map', imageMap);
}
```

描画してみる。
<img width="415" alt="スクリーンショット 2018-12-03 0.30.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/bf66eb89-84cb-44c1-b479-f6f844ee18ac.png">

できた。
わかりやすくするために上同様にタイルサイズのグリッドを表示させています。
特定のzoomレベルのタイルの画像を返すだけなので、例えばzoomしたら詳細な地図を表示させる、といったことも少し変えれば実現できます。

・・・ただこれなんか端の方によってますね。真ん中に持ってきたい。

#### 緯度経度と座標の関係
右上の方によっているのは、 `center: {lat: 35, lng: 135},` と指定しているからです。写真で地図を表示できるとはいえ、基本的に地図なので、位置を指定する場合は基本的に緯度経度を指定します。でも画像の中心の緯度経度ってどこになるのでしょう。

タイルの位置と緯度経度を考えるには、Google Maps APIの座標系について知る必要があります。

##### 3つの座標系
Google Map APIでは、[緯度経度](https://en.wikipedia.org/wiki/World_Geodetic_System)の他に、[三つの座標系](https://developers.google.com/maps/documentation/javascript/coordinates)があります。

- 世界座標
- ピクセル座標
- タイル座標

**世界座標** は、zoomレベルによらず地図上のある一点を示す値として用いられ、 `{0-256}, {0-256}` の範囲で指定されます。256が利用されるのは、Google Map APIの基本となるタイルが `256px * 256px` であることからきています。
**ピクセル座標** は、zoomレベルに応じたピクセル値を示します。具体的には `世界座標 * 2^ズームレベル` の式で計算される値をとります。
**タイル座標** は、各タイルを表す値です。zoomレベル0の場合は`(0,0)`をとり、zoomレベル1では`(0,0)-(1,1)`をとります。

さて、Google Map APIは [メルカトル図法](https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%AB%E3%82%AB%E3%83%88%E3%83%AB%E5%9B%B3%E6%B3%95)を用いて投影されており、世界座標と緯度経度の変換は、[wiki](https://en.wikipedia.org/wiki/Mercator_projection#Derivation_of_the_Mercator_projection)にあるので計算できそうだし、実際変換式はぐぐると転がってるんですが、[変換のための関数が用意されている](https://developers.google.com/maps/documentation/javascript/reference/image-overlay#Projection)のでそれを使います。

要は、 **zoomレベル0の左上の点からのピクセル値が世界座標となる** ため、以下のように **画像の中心点の世界座標** を、 `projection` に定義されている関数を用いて **緯度経度に変換** してあげればよい。

```js
  google.maps.event.addListenerOnce(map, "projection_changed", function() {
    map.setCenter(map.getProjection().fromPointToLatLng(new google.maps.Point(
      imageMap.tileSize.width * (1 / 2),
      imageMap.tileSize.height * (1 / 2)
    )));
  });
```

なお、[イベントリスナー](https://developers.google.com/maps/documentation/javascript/events)を用いているのは、 `initMap` の時点ではまだ `projection` が初期化されていないためです。mapの初期化時に指定したいのであれば、自分で `projection` を生成するか、メルカトル図法における変換式を用いて計算してあげると良いのかもしれません。

<img width="411" alt="スクリーンショット 2018-12-03 1.38.40.png" src="https://qiita-image-store.s3.amazonaws.com/0/26136/b6bfb410-55af-d0a4-752d-ac2c5f43fb80.png">
センターに持ってこれた。

同様に、例えばピンを立てる場合なども、 **世界座標（つまりzoomレベル０におけるピクセル座標）** を緯度経度に変換することで、任意の座標を指定することができます。

最後におまじないを一つ
**タイル座標** が、y座標についてはマイナスで呼ばれたり、表示される領域分大きな値が呼び出されることもありますし、x座標については、256の約数でなければ**画像の存在しない領域**が発生します。ので、こちらの想定していないindexのタイルが呼ばれた場合の処理を追加。

```js

    getTileUrl: function(coord, zoom) {
      // この辺追加
      var tileRange = 1 << zoom;
      if ( coord.y < 0 || coord.y >= tileRange || coord.x < 0 || coord.x >= tileRange ) {
        return null;
      }
      // ここまで
      return './daughter' +
          '/' + zoom + '/' + coord.x + '/' +
          coord.y + '.jpg';
    },
```

多分なくても悪さしないですが、404エラーとかいっぱい出るので追加しておきます。

# まとめ
と、いうことで、無事画像から独自の地図をGoogle Map APIを利用して実装することができました:tada: 

Google Map APIではその他にも色々とカスタマイズできる要素がたくさんあります。が、基本的には **地図タイプ（MapType）**、**タイル**、**座標系** の３つを押さえておくとあとはそれの応用でなんとかなるように思います。

基本Googleのプロダクトはドキュメントや例がしっかりしているので　それを見るのが近道ですが、意外とこの辺りを日本語で整理されていることが少なかったので、ハンズオン的に簡単に流れだけ説明しました。

それぞれの要素はまだまだ深掘りする余地がたくさんありますので、興味があればぜひ遊んでみてください。

それでは長い文書を読んでいただいてありがとうございます。

# 参考
- https://developers.google.com/maps/documentation/javascript/tutorial
- https://developers.google.com/maps/documentation/javascript/maptypes