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

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


はじめに

この記事はLIFULL Advent Calender2018 の3日目の記事です。

LIFULL HOME'Sの地図検索で採用しているタイルレイヤーについての紹介です。


タイルレイヤーとは

Google Maps APIは球体の地球をメルカトル図法を使って256x256の平面に書き起こし、それをズームレベル0の時の地図表現にしています。


スクリーンショット 2018-12-03 7.58.11.png


ズームレベルが1あがるごとにx,y方向の距離が2倍になっていき、地球全体を表現するために配置される256x256の画像の枚数も増えてタイルのように配置されていきます


スクリーンショット 2018-12-03 7.44.49.png


Google Mapsは詳細ズームレベルの時に全地理情報をブラウザに送るのは無理なので現在の表示領域にふくまれるであろう地理タイルを計算して、必要な分だけを画像としてブラウザに送りつけてレイヤーに敷き詰めることで地図を表現しています。


68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f31323830362f37373139393938362d396164392d336461642d376132612d3966636561663165646633652e706e67.png



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

LIFULL HOME'Sの地図検索はGoogle Maps API V3を用いて作られています。

もともと画面領域全体を検索範囲として結果を地図上に表示していました


スクリーンショット 2018-12-03 12.24.31.png


しかしながらこの画面領域全体を検索する実装は以下の問題を抱えていました。

  • 画面領域がでかくなればなるほど検索範囲も大きくなり、1回の検索負荷が大きくなる

    • 検索エンジンは複数台いるのに大きな負荷の検索を分割できない
  • 地図を少し動かすだけでまた画面領域分の検索が走る

    • 99%同じ領域でも再検索してしまう
  • 毎回検索座標が違うの検索結果をキャッシュしてもヒット率が望めない
そのため、Googleと同じようにタイルレイヤーを作り、検索をタイルごとに行う実装にし、一つ一つの検索の負荷を下げるように変えました。



スクリーンショット 2018-12-03 12.33.00.png


一回の表示で複数の検索クエリが発生しますが、一つ一つは小さくバックエンドの検索エンジンも複数台でさばくので体感としてはだいぶ高速に表示することができるようになりました。(タイルの座標は常に一定なのでキャッシュ効率も高まりました)

また、タイルの中にcanvasを配置し、それぞれの物件マーカーを絵として描画することで、DOMの総量を減らしてモタついた印象をなるべく排除するようにしています。
map.mov.gif



タイルレイヤーの作り方

タイルレイヤーの特性上、以下がわからないとこれを作ることが困難になります。

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


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

手順としては現在のMapBoundsから、北東、南西の緯度経度を取得します。

そして緯度経度をpixel空間上での座標に変換し、その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が採用してるメルカトル図法の変換式を利用して解くことができます。

メルカトル図法は球体の地球を赤道にそって紙でくるみ(円柱)、中からライトを当てて投射するイメージがありますが、

一般に変換式は

x = R(λ + π) 
y = R(π-\log [tan(\frac{π}{4} + \frac{π}{2})]) 
で表現されます。

少しコードっぽく表現すると

x = f(lng) = R * (lngRaidus + Math.PI) 
y = g(lat) = R * (Math.PI - Math.log(Math.tan(Math.PI/4 + latRadius / 2))) 
となります。

ここでいうRは地球をくるんだ円柱の円の半径になります。

円柱は投射される地図、つまり256x256の正方形となるので、円柱の円周は256pxとなります。

円周は直径xPIであることから逆算すると

256 = 2R * PI 
2R = 256 / PI 
R = 256 / 2 / PI 
と導くことができます。

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

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

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)
そのため (2^zoomLv)をそれぞれx, yにかけてあげると現在のズームレベルにおけるpixel座標が取得できます。

これらをふまえて、画面領域(北東・南西の緯度経度)からそこに含まれるタイル一覧を取得する流れを実装してみます

領域取得 -> 緯度経度からpixel座標に変換 -> それらをタイルサイズ(256)で割ることでタイルの番地を取得 -> 間のタイルを全部算出

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


適当な点A(map.getCenter()などでよい)の緯度経度からprojection.fromLatLngToDivPixelを用いて箱メガネからのpixel距離を取得し、同じ点のpixel平面距離Bをprojection.fromLatLngToPointを求めたものから引いて、箱メガネの左上のpixel平面からの距離Cを求めます。

タイルの左上Dのpixel平面上の距離はタイルの番地番号 * 256pxになるので、そこからCをひいてやると箱メガネの左上からタイルの左上までのx方向の距離Eが求まります。

同じ方法でy方向の距離をもとめてabsoluteのleft, topにそれぞれ適応させることでタイルの適切な配置が完成します。

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

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


終わりに

ちなみにいろいろ無視して単純にタイルレイヤつくるだけならmap.overlayMapTypes.insertAtでTileObjectを作ってやることで同様のことをすることは可能です。

ほかにも使ったことはないけど多分Leafletも内部で同じようなことしてるんだと思います。

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

コメント

このブログの人気の投稿

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2020-12-01 09:41:49 RSSフィード2020-12-01 09:00 分まとめ(69件)