最近、 Rust の staticmap
クレートにいくつか PR を送ったので、忘れないように staticmap
についてメモっておく。
staticmap
とは
事前知識なしに、簡単に地図画像を生成するためのライブラリである。例えば以下は、与えられた緯度経度の列を plot した画像を生成するためのプログラムである。
use std::{env::args, error::Error}; use staticmap::{tools::{CircleBuilder, Color}, StaticMapBuilder}; fn main() -> Result<(), Box<dyn Error>> { let mut args = args().skip(1); let mut map = StaticMapBuilder::new() .width(720) .height(480) .build()?; while let (Some(lat), Some(lon)) = (args.next(), args.next()) { let circle = CircleBuilder::new() .lat_coordinate(lat.parse::<f64>()?) .lon_coordinate(lon.parse::<f64>()?) .color(Color::new(true, 255, 0, 0, 255)) .radius(5.) .build()?; map.add_tool(circle); } map.save_png("examples/results/plot_points.png")?; Ok(()) }
以下が実行結果である 1 。画像の大きさだけ指定し、生成した StaticMap
型の値 map
に緯度経度をひたすらプロットするだけで、いい感じの縮尺で地図を作ってくれる。非常に便利だ。
> cargo run --example plot_points 43.07860134016368 141.34119085145716 33.59907205202905 130.22409863072042 35.71622219061979 139.76355588070456 35.129339438616675 136.09212718953398 35.154769245737306 136.97056270511507 38.27242398344753 141.000820278268 34.82219993583771 135.52507877778308
OpenStreetMap
staticmap
は OpenStreetMap の地図データを使っている。地図は四角形の区域ごとに URL で指定される。以下が詳しい。
結論だけ言ってしまえば、 https://tile.openstreetmap.org/zoom/x/y.png という形式の URL にアクセスすれば、 256×256 のサイズの地図の画像が手に入る。 zoom
は 0
から 18
で、 0
の場合は一枚で世界地図となる。
x
と y
は wiki に書かれている方法で計算可能で、メルカトル図法となっている。 0/0
は緯度経度で言えば 85.051100, -180.000000 の地図画像ということになる。地図で言えば、緯度経度の原点 Null Island を真ん中に書いた場合、左上を Slippy Map の (x, y) の原点 (0, 0) としてとることになる。
地図の取得は attohttpc を rayon を使って並列に回している。 rayon
は並行処理用のライブラリのはずなので、 http client に使うのはいかがなものかと思うのだけど、残念ながら現状の実装では密結合されているため、差し替えることはできない。
tiny-skia
画像の生成には tiny-skia というライブラリを使っている。こちらも密結合されており、差し替えられない。このライブラリは SKIA という Chrome で使われている画像ライブラリの移植らしいが、クラス名などは割と違うので、別途使い方を覚えたほうが良い。
tools
地図上への書き込みは、 tools::*
型を使って行う。 Circle
Line
などの型があり、これを StaticMap
の add_tool
経由で追加することで円や線を書くことができる。 tools
とはなっているが、実際は shape に近い概念となっている。
Builder pattern
Rust や Go では常套手段ではあるが、 staticmap
も Builder Pattern が使われている。 StaticMap
や tools::Circle
の値が欲しい場合は、対応する Builder
の build
メソッドを経由して生成することになる。
座標系
ここから、内部実装の話となる。 staticmap
は内部的には 3 つの座標系があり、 staticmap
本体の開発者はこれらを意識する必要がある。
lat
,lon
: 緯度経度lat
は-90
から90
、lon
は-180
から180
- 地図の右方向、 上 方向が正の方向
staticmap
の利用者が意識するのはこれだけ
x
,y
: Slippy Map のx
y
px
staticmap
開発者は lat
, lon
と x
, y
の両方を使用するため、これらの違いをきちんと把握しておくことが必要となる。特に Y 座標については上下が逆転するので、例えば min
を取る場合に lat
では地図の下方側を取ればいいのに対して y
については上方側を取らなければならない。
Bounds
bounds.rs に定義されており、このクレートの肝と言える部分である。地図の描画範囲を表す型であり、以下のフィールドで描画範囲は決定される。
height
width
: 生成される png 画像の高さと幅(ピクセル)x_center
y_center
:x
,y
座標系における描画の中心点tile_size
: Slippy Map における 1 枚のタイルのサイズで、前述した通りほとんどの場合は256
ピクセル
height
width
を tile_size
で割ってやれば縦横何枚分のタイルが必要かがわかり、 x_center
y_center
からその枚数分までの範囲が描画されることになる。
Bounds
は zoom
も保持しており、これによって tools::*
の各図形が持つ lat
, lon
の座標系を x
y
に変換することができる。 Slippy Map の tile を得るための URL にも必要である。
一方で、 x_min
x_max
y_min
y_max
は Slippy Map のどの範囲を get
してくるべきかを表すフィールドだが、他の情報から簡単に計算可能なので、実は不要なフィールドであると言える。
Tool
trait
tools/mod.rs に定義されている。 draw
に関しては解説不要だろう。察しの通り、 tiny_skia
を使って自身地図上に描画する。
問題は extent
の方だ。このメソッドは、自身を完全に描画するための範囲を lat
, lon
の座標系で返す。この値は、地図の zoom
や描画範囲を自動で決めるために必要となる。登録された tools::*
がすべて画像内に収まるように、 Bounds
を生成すればいいのである。
extent
の呼び出しは zoom
が引数となっていることには注意が必要だ。例えば、 tools.Circle
の radius
は円の半径だが、この値はピクセル単位で指定をする。画像上の 3 pixel が lat
lon
でどの範囲になるのかは、 zoom
によって大きく変わってしまうのだ。
Bounds
の生成
save_png
が呼び出されたタイミングで、すべての Tool
が収まるような範囲 Bounds
を生成する。
中心座標 x_center
y_center
を算出するのは簡単で、 add_tool
によって追加されたすべての Tool
の extent
を呼び出し、すべてを収めるための四角形の矩形を lat
lon
で算出した後に、 x
y
座標系に変換してからその四角形の中心を求めれば良い。 lat
は最終的に生成される画像に対して線形ではないため、 lat
lon
のまま中心を求めてはいけないということには注意が必要である 3 。
zoom
を算出するには、総当りする必要がある。 zoom
を大きい側 17
から 0
まで順に変化をさせていくと、すべての Tool
について extent
の呼び出しで求められる範囲を x
y
座標で表した場合の四角形の一辺の長さは、逆に小さくなっていく 4 。これと画像の大きさ width
height
を比較して、四角形が画像内にすべて収まったところでズームアウトをやめると、最適な zoom
を決定することができる。具体的には、以下の実装である。
この方法がうまくいくのは、 lat
lon
の座標が 2 個以上 add_tool
で追加されている場合だけである。 lat
lon
の座標が 1 個の場合は、 zoom
を変化させてもなんら状況は変化しない。極端な例として、半径 radius
が 100 pixel の円を縦横 100x100 pixel の画像に描画しようとした場合は、 zoom
をどんな値にしても入らないものは入らない。すべての試行に失敗して zoom = 0
を返す。逆に 10 pixel の場合は zoom
に関係なく入るので、zoom = 17
だ。
やっかいなのが radius
を 50
にした場合で、理論上はギリギリ画像内に書けるのだが、小数点以下の誤差によって画像内に入ると判定されたり入らないと判定されたり判定がランダムにブレる。その結果、決定される zoom
は適当な任意の値となる。
- これがなんの plot かは読者への宿題とする。ドラゴンボールの位置ではない。↩
-
Slippy Map では 0 ~
zoom
によって決まる整数値の間の値しか取れないが、地球は丸いので、任意の実数のx
y
について、剰余を取るなどして所属する Slippy Map の tile を整数値として算出可能である。↩ - Fix center by hiratara · Pull Request #18 · danielalvsaaker/staticmap · GitHub↩
- Google Maps で北海道を表示してからズームアウトしていけば、ズームアウトすればするほど描画される北海道の大きさは小さくなるだろう。↩