最近、 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 のxypx
staticmap 開発者は lat, lon と x, y の両方を使用するため、これらの違いをきちんと把握しておくことが必要となる。特に Y 座標については上下が逆転するので、例えば min を取る場合に lat では地図の下方側を取ればいいのに対して y については上方側を取らなければならない。
Bounds
bounds.rs に定義されており、このクレートの肝と言える部分である。地図の描画範囲を表す型であり、以下のフィールドで描画範囲は決定される。
heightwidth: 生成される png 画像の高さと幅(ピクセル)x_centery_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によって決まる整数値の間の値しか取れないが、地球は丸いので、任意の実数のxyについて、剰余を取るなどして所属する Slippy Map の tile を整数値として算出可能である。↩ - Fix center by hiratara · Pull Request #18 · danielalvsaaker/staticmap · GitHub↩
- Google Maps で北海道を表示してからズームアウトしていけば、ズームアウトすればするほど描画される北海道の大きさは小さくなるだろう。↩
