Mapboxでzoomレベルに応じた1pxあたりの地理的距離を考慮して円を描く

MapboxのLayerという機能を使うと、地図上に円、線、矩形など様々な図形を描画できます。地図上に円を描くときに、zoomレベルと緯度によって、1pxあたりの距離が変わるため、円の大きさが期待どおりの大きさにならないことがあり、これに対してどう対応すればよいかを調べてみました。

前提

以下の環境を前提としています。

  • mapbox-gl 2.10.0

円を描いてみる

まず、何も考えずに、ただ円を描いてみます。 東京駅あたりを中心とした、半径500pxの円を描いてみます。

次のような単純なGeoJSONファイルを用意します。

{
  "type": "Feature",
  "geometry": {
    "type": "Point",
    "coordinates": [
      139.7673068,
      35.6809591
    ]
  }
}

これをMapboxにsourceとして登録し、Layerを定義します。

map.current.on('load', () => {
  // sourceを登録
  map.current.addSource('circle', {
    type: 'geojson',
    data: '/geojson/circle.json',
  })
  // layerを登録
  map.current.addLayer({
    id: 'circle-layer',
    type: 'circle',
    source: 'circle',
    paint: {
      'circle-radius': 500,
      'circle-color': '#1e90ff',
      'circle-opacity': 0.5,
    },
  })
})

これを実際にブラウザで表示してみると、↓のようになります。 mapbox-circle-r500

確かに円は表示されますが、zoomレベルが変わっても円の大きさは変わらず一定となっていることがわかります。これは、circle-radiusで指定する円の半径の単位がpxであるためです。この例では、"circle-radius": 500としているため、zoomレベルを変えても常に半径500pxの円が表示されました。

では、Mapboxを使って、ある点を中心とした半径5kmの円を描きたい、という場合はどうすればよいでしょうか? まず、circle-radiusの単位がpxであるため、5kmをpxに変換する必要があります。また、zoomレベルに応じて1pxあたりの距離が変わるため、これも考慮する必要がありそうです。

zoomレベルと1pxあたりの地理的距離

Mapboxのドキュメント「zoom level | Help | Mapbox」を読んでみると、次のことがわかります。

  • zoomレベルは、0から22まで、全部で23のレベルがある
  • 地図上のピクセルに収まる地理的な距離は、緯度によって異なる

また、zoomレベルと緯度ごとの1pxあたりの地理的距離(メートル)が表として記載されています。この表から、0°(赤道)、20°、40°、60°、80°の各緯度でのzoomレベルごとに1pxが何メートルを表すかがわかります。ただ、0°、20°、40°、60°、80°以外の任意の緯度における1pxあたりの距離がわかりません。東京駅あたりの北緯35.6809591°では1pxは何メートルになるのか...

上記のMapboxのドキュメントのページに、

へのリンクがあったので読んでみます。これを読むに、

  • OpenStreetMapやMapboxでは、地図を描画する上で、一定のサイズを持った正方形のタイルを並べている

ということがわかります。また、次のような記述がありました。

Distance-per-pixel-math

これは、zoomレベルごとの、

  • 任意緯度におけるタイル1枚が表す地理的距離の計算方法
  • 任意緯度における1pxが表す地理的距離の計算方法

であり、まさに求めていたものです。

任意緯度におけるタイル1枚が表す地理的距離

任意緯度におけるタイル1枚が表す地理的距離は次の数式で計算できると書かれています。

タイル1枚が表す地理的距離 = C * cos(緯度) / 2**zoomレベル

この数式の定数 C は地球の赤道の円周で、OpenStreetMapでは、地球の赤道の円周は、 40075016.686m ≈ 2π ∙ 6378137.000m として扱っているということが書かれています。 この式でタイル1枚の地理的距離がわかるので、それをタイル1枚のピクセル数で割れば、1pxあたりの地理的距離が計算できます。

OpenStreetMapやMapboxは、一定の大きさの正方形タイルを敷き詰めて地図を表示していて、OpenStreetMapでは 256x256px のタイルが、Mapboxでは 512x512pxのタイルが使われているそうです。

したがって、

任意緯度における1pxが表す地理的距離 = C * cos(緯度) / 2**zoomレベル / 512

で計算できます。試しに、zoomレベル4での北緯35°の1pxの地理的距離を計算すると、4007.27mとなります。北緯20°では4596.946m、北緯40°では3747.465mであるので、正しそうです。

Layerに適用する

これでzoomレベルに応じた1pxあたりの地理的距離を計算することができるようになりました。そこで、冒頭の東京駅付近の緯度35.6809591°で、zoomレベルごとに、5kmが何pxになるのかを計算しました。

  • zoomレベル0: 0.07864332719px
  • zoomレベル1: 0.1572866544px
  • ...
  • zoomレベル3: 0.6291466175px
  • zoomレベル4: 1.258293235px
  • ...
  • zoomレベル10: 80.53076704px
  • ...
  • zoomレベル20: 82463.50545px
  • zoomレベル21: 164927.0109px
  • zoomレベル22: 329854.0218px

これをMapboxのLayerに適用していきます。

Expressions | Style Specification | Mapbox GL JS | Mapboxにある「Expressions」を使うと、Layerに対して、条件式やフィルターを適用することができるようです。今回は、zoomレベルに応じて、円の半径を動的に変更したいので、Camera expressionsが使えそうです。ドキュメントに従って、実際に書いたコードがこちらです。

map.current.addLayer({
  id: 'circle-layer',
  source: 'circles',
  type: 'circle',
  paint: {
    'circle-radius': [
      'interpolate',
      ['linear'],
      ['zoom'],
      3,
      1,
      4,
      1.2,
      5,
      2.5,
      6,
      5.0,
      7,
      10.1,
      8,
      20.1,
      9,
      40.3,
      10,
      80.5,
      11,
      161.1,
      12,
      322.1,
      13,
      644.2,
      14,
      1288.5,
      15,
      2577,
      16,
      5134,
      17,
      10307.9,
      18,
      20615.8,
      19,
      41231.7,
      20,
      82463.5,
      21,
      164927,
      22,
      329854,
    ],
    'circle-color': '#1e90ff',
    'circle-opacity': 0.5,
  },
})

zoomレベル3以下の場合、5kmを表すピクセル数が1未満となるため、半径は1pxを指定しました。zoomレベル4以上では、5kmを表すピクセル数の小数点第2位で四捨五入した値を指定しました。これをブラウザで表示した結果、このようになりました。

mapbox-circle-r5km

冒頭の例とは打って変わって、zoomレベルに応じて円の大きさが変わっていることがわかります。つまり、zoomレベルが変わっても、円の半径は地理的に常に5kmを保てていることになります。当初の目的が達成できました!

おわりに

Mapboxを使って、地理的な半径を決めて円を描く方法について調べました。よりスマートな方法があるかもしれません。それと、

  • zoomレベル
  • 緯度
  • 地理的半径

からpxを求める部分をプログラムで動的に計算できるようにできれば、より実用的になりそうなので、今後はそれについても検討してみようと思います。