MapboxのLayerという機能を使うと、地図上に円、線、矩形など様々な図形を描画できます。地図上に円を描くときに、zoomレベルと緯度によって、1pxあたりの距離が変わるため、円の大きさが期待どおりの大きさにならないことがあり、これに対してどう対応すればよいかを調べてみました。
以下の環境を前提としています。
まず、何も考えずに、ただ円を描いてみます。 東京駅あたりを中心とした、半径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,
},
})
})
これを実際にブラウザで表示してみると、↓のようになります。
確かに円は表示されますが、zoomレベルが変わっても円の大きさは変わらず一定となっていることがわかります。これは、circle-radius
で指定する円の半径の単位がpx
であるためです。この例では、"circle-radius": 500
としているため、zoomレベルを変えても常に半径500pxの円が表示されました。
では、Mapboxを使って、ある点を中心とした半径5kmの円を描きたい、という場合はどうすればよいでしょうか?
まず、circle-radius
の単位がpxであるため、5kmをpxに変換する必要があります。また、zoomレベルに応じて1pxあたりの距離が変わるため、これも考慮する必要がありそうです。
Mapboxのドキュメント「zoom level | Help | Mapbox」を読んでみると、次のことがわかります。
また、zoomレベルと緯度ごとの1pxあたりの地理的距離(メートル)が表として記載されています。この表から、0°(赤道)、20°、40°、60°、80°の各緯度でのzoomレベルごとに1pxが何メートルを表すかがわかります。ただ、0°、20°、40°、60°、80°以外の任意の緯度における1pxあたりの距離がわかりません。東京駅あたりの北緯35.6809591°
では1pxは何メートルになるのか...
上記のMapboxのドキュメントのページに、
へのリンクがあったので読んでみます。これを読むに、
ということがわかります。また、次のような記述がありました。
これは、zoomレベルごとの、
であり、まさに求めていたものです。
任意緯度におけるタイル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.27
mとなります。北緯20°では4596.946
m、北緯40°では3747.465
mであるので、正しそうです。
これでzoomレベルに応じた1pxあたりの地理的距離を計算することができるようになりました。そこで、冒頭の東京駅付近の緯度35.6809591°
で、zoomレベルごとに、5kmが何pxになるのかを計算しました。
0.07864332719
px0.1572866544
px0.6291466175
px1.258293235
px80.53076704
px82463.50545
px164927.0109
px329854.0218
pxこれを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位で四捨五入した値を指定しました。これをブラウザで表示した結果、このようになりました。
冒頭の例とは打って変わって、zoomレベルに応じて円の大きさが変わっていることがわかります。つまり、zoomレベルが変わっても、円の半径は地理的に常に5kmを保てていることになります。当初の目的が達成できました!
Mapboxを使って、地理的な半径を決めて円を描く方法について調べました。よりスマートな方法があるかもしれません。それと、
からpxを求める部分をプログラムで動的に計算できるようにできれば、より実用的になりそうなので、今後はそれについても検討してみようと思います。