GoogleMapsのタイルレイヤーの作り方

## はじめに
この記事は[LIFULL Advent Calendar2018](https://qiita.com/advent-calendar/2018/lifull)の3日目の記事です。
LIFULL HOME'Sの[地図検索](https://www.homes.co.jp/chintai/tokyo/map/)で採用しているタイルレイヤーについての紹介です。

## タイルレイヤーとは
Google Maps APIは球体の地球をメルカトル図法を使って256x256の平面に書き起こし、それをズームレベル0の時の地図表現にしています。
![スクリーンショット 2018-12-03 7.58.11.png](https://qiita-image-store.s3.amazonaws.com/0/12806/58a18e56-dd2a-1bdc-417c-a77e946667ee.png)


ズームレベルが1あがるごとにx,y方向の距離が2倍になっていき、地球全体を表現するために配置される256x256の画像の枚数も増えてタイルのように配置されていきます
![スクリーンショット 2018-12-03 7.44.49.png](https://qiita-image-store.s3.amazonaws.com/0/12806/8b3e05a3-6346-8e37-c05f-f2d5cdbf8ea5.png)

Google Mapsは詳細ズームレベルの時に全地理情報をブラウザに送るのは無理なので現在の表示領域にふくまれるであろう地理タイルを計算して、必要な分だけを画像としてブラウザに送りつけてレイヤーに敷き詰めることで地図を表現しています。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f31323830362f37373139393938362d396164392d336461642d376132612d3966636561663165646633652e706e67.png](https://qiita-image-store.s3.amazonaws.com/0/12806/9942471e-9835-6e03-6fcc-eaa1ae1aa11b.png)

## LIFULL HOME'Sの地図検索において

LIFULL HOME'Sの地図検索はGoogle Maps API V3を用いて作られています。
もともと画面領域全体を検索範囲として結果を地図上に表示していました
![スクリーンショット 2018-12-03 12.24.31.png](https://qiita-image-store.s3.amazonaws.com/0/12806/82d7fc7e-64f3-7af3-8c71-7b9b544f5c71.png)
しかしながらこの画面領域全体を検索する実装は以下の問題を抱えていました。

* 画面領域がでかくなればなるほど検索範囲も大きくなり、1回の検索負荷が大きくなる
  * 検索エンジンは複数台いるのに大きな負荷の検索を分割できない
* 地図を少し動かすだけでまた画面領域分の検索が走る
  * 99%同じ領域でも再検索してしまう
* 毎回検索座標が違うの検索結果をキャッシュしてもヒット率が望めない

そのため、Googleと同じようにタイルレイヤーを作り、検索をタイルごとに行う実装にし、一つ一つの検索の負荷を下げるように変えました。

![スクリーンショット 2018-12-03 12.33.00.png](https://qiita-image-store.s3.amazonaws.com/0/12806/69fe4ef5-4895-d46d-ce83-adf5fa786429.png)
一回の表示で複数の検索クエリが発生しますが、一つ一つは小さくバックエンドの検索エンジンも複数台でさばくので体感としてはだいぶ高速に表示することができるようになりました。(タイルの座標は常に一定なのでキャッシュ効率も高まりました)
また、タイルの中にcanvasを配置し、それぞれの物件マーカーを絵として描画することで、DOMの総量を減らしてモタついた印象をなるべく排除するようにしています。
![map.mov.gif](https://qiita-image-store.s3.amazonaws.com/0/12806/1b999048-55ed-3806-c638-b188b32c29ee.gif)

## タイルレイヤーの作り方

タイルレイヤーでキモになるのは以下の二つだけ。

1. 現在の表示領域に含まれるタイルの一覧
1. タイルを配置する座標

表示領域のタイルを求めて、それらを画面領域上の適切な座標に配置できればほとんど完成します。

### 現在の表示領域に含まれるタイルの一覧

手順としては現在のMapBoundsから、北東、南西の緯度経度を取得します。
そして緯度経度を<b>pixel平面上での座標に変換</b>し、そのx座標をタイル幅で割ってあげれば現在のタイルが世界の端からx軸方向に何番目のタイルかが分かります。
同様にしてy方向も求めればタイルの番地(tilecoord.{x, y})が求められます。
北東、南西のタイル番地がわかればあとはその範囲内のタイルを洗い出せます。

例えば北東が`{x: 120, y: 200}`, 南西が`{x: 118, y: 198}`だとすると含まれるタイルはその二点で作られる四角形に収まるものになるので

* `{x: 118, y: 198}`
* `{x: 119, y: 198}`
* `{x: 120, y: 198}`
* `{x: 118, y: 199}`
* `{x: 119, y: 199}`
* `{x: 120, y: 199}`
* `{x: 118, y: 200}`
* `{x: 119, y: 200}`
* `{x: 120, y: 200}`

の9つということになります。

緯度経度からpixel座標へはGoogle Maps API V3が採用してるメルカトル図法の変換式を利用して解くことができます。
メルカトル図法は球体である地球を赤道にそって紙でくるみ(円柱)、中からライトを当てて投写するイメージ(厳密には投写時に補正処理が入る)がありますが、

一般に変換式は

```math
x = R(λ + π)
```

```math
y = R(π-\log [tan(\frac{π}{4} + \frac{π}{2})])
```
で表現されます。
少しコードっぽく表現すると

```js
x = f(lng) = R * (lngRaidus + Math.PI)
y = g(lat) = R * (Math.PI - Math.log(Math.tan(Math.PI/4 + latRadius / 2)))
```

となります。(xRadiusはそれぞれ緯度経度のラジアン表現)
ここでいうRは地球をくるんだ円柱の円の半径です。
円柱は投写される地図、つまり256x256の正方形となるので、円柱の円周は256pxとなり、円周=直径xPIであることから逆算すると

```
256 = 2R * PI
2R = 256 / PI
R = 256 / 2 / PI
```

とRを導くことができます。

また、度からラジアンへの変換は

```
(度 * PI) / 180
```

で求められるのでlatRadius,lngRadiusにそれぞれ適応させるとメルカトル変換式はJavaScriptで以下のように求められます

```js
x = (256 / 2 / Math.PI) * ((lng * Math.PI / 180) + Math.PI)
y = (256 / 2 / Math.PI) * Math.log(Math.tan(Math.PI/4 + (lat * Math.PI / 180) / 2)) 
```

世の中のメルカトル図法で作られた地図はsin,cos表現のほうがtan表現より誤差が少ないことから`tan(A + B) = sin(A+B)/cos(A+B) = (sinA*cosB + cosA*sinB) / (cosA*cosB -sinA*sinB)`の交換法則を使ってsin,cosの表現で実装していることが多いです。
計算ややこしくなるので今回はこのままtan表現でいきます。

この計算式はあくまでもこれは256のタイル1枚で地球を投写したときの計算なので、実際はここにズームレベルに応じた補正処理をかけます。

ズームレベルとタイルの枚数、世界幅の対応は以下のように表現できます。

|zoom lv|x方向のタイル枚数|y方向のタイル枚数|総タイル枚数|px世界幅|
|---|---|---|---|---|
|0|1|1|1|256|
|1|2|2|4|256 * 2|
|2|4|4|16|256 * 4|
|n|2^n|2^n|4^n|256 * (2^n)|

x, y方向のタイル枚数は(2^zoomLv)と表現できるので、それぞれx,　yにかけてあげると現在のズームレベルにおけるpixel座標が取得できます。

これらをふまえて、画面領域(北東・南西の緯度経度)からそこに含まれるタイル一覧を取得する流れを実装してみます
領域取得 -> 緯度経度からpixel座標に変換 -> それらをタイルサイズ(256)で割ることでタイルの番地を取得 -> 間のタイルを全部算出

```js
let bounds = map.getBounds()
  , sw = bounds.getSouthWest()
  , ne = bounds.getNorthEast()
  , zoomLv = map.getZoom()
  ;

let swTileCoord = getTileCoordFromLatLng(sw.lat(), sw.lng(), zoomLv)
  , neTileCoord = getTileCoordFromLatLng(ne.lat(), ne.lng(), zoomLv)
  ;

let allTileCoords = [];
for (let i = swTileCoord.x; i <= neTileCoord; i++) {
  for (let j = neTileCoord.y; j <= swTileCoord.y; j++) {
    allTileCoords.push({x: i, y: j});
  }
}

function getTileCoordFromLatLng(lat, lng, zoomLv) {
  let pixelCoordX = (256 / 2 / Math.PI) * ((lng * Math.PI / 180) + Math.PI)
    , pixelCoordY = (256 / 2 / Math.PI) * Math.log(Math.tan(Math.PI/4 + (lat * Math.PI / 180) / 2))
    ;
  return {
    x: Math.floor(pixelCoordX / 256) * Math.pow(2, zoomLv),
    y: Math.floor(pixelCoordY / 256) * Math.pow(2, zoomLv),
  }
}
```
こんな感じで領域内の全タイル`allTileCoords`は求められますね。
あとはこれを地図の移動イベント(tiles_loadedとか)に紐づけて移動前と移動後の差分だけ更新していく実装をとればタイル表現はできあがりです。

### タイルを配置する座標

領域内に含まれるタイルがわかったら、今度はそれを画面上に配置していく作業になります。
Google Mapsはpixel平面を、水中をみる箱メガネのようにしてみている作りになっています。

箱メガネからタイルまでの相対的距離を導いて、absolute配置することでタイルを表示領域に配置していきます。
とはいえ、あるタイル番地が箱メガネから何pxの位置にあるかを調べるAPIはありません。
なので複雑ではありますが、以下のような手法で箱メガネからタイルの座標Eを求めます
![スクリーンショット 2018-12-03 16.01.19.png](https://qiita-image-store.s3.amazonaws.com/0/12806/7f390f6e-6315-8a70-4816-a6b301e5093d.png)

適当な点A(map.getCenter()などでよい)の緯度経度から`projection.fromLatLngToDivPixel`を用いて箱メガネからのpixel距離を取得し、同じ点のpixel平面距離Bを`projection.fromLatLngToPoint`を求めたものから引いて、箱メガネの左上のpixel平面からの距離Cを求めます。
タイルの左上Dのpixel平面上の距離はタイルの番地番号 * 256pxになるので、そこからCをひいてやると箱メガネの左上からタイルの左上までのx方向の距離Eが求まります。
同じ方法でy方向の距離をもとめてabsoluteのleft, topにそれぞれ適応させることでタイルの適切な配置が完成します。(メルカトル投影式の逆計算をして緯度経度を出してfromLatLngToDivPixelで出しても良いです)

計算はやや複雑ですがIOをほぼ発生させない単純な計算なので大量に計算させてもそれほど画面上でのつっかかりなどはほとんど発生しません。

これらの要素的な処理を組み合わせてLIFULL HOME'Sの地図検索は実装されています。

## 終わりに

ちなみにいろいろ無視して単純にタイルレイヤつくるだけなら`map.overlayMapTypes.insertAt`でTileObjectを作ってやることで同様のことをすることは可能です。
ほかにも使ったことはないけど多分Leafletも内部で同じようなことしてるんだと思います。

画面領域の全端を数px隠しとけば地図移動時に軽い先読みみたいなのも実現できるのでタイルレイヤーを使った地図でのデータ表示は結構メリットが大きいのでおすすめです。