静的地図サービスを作ってみた

# 二行まとめ

[独自の静的地図サービス](https://task-jp.github.io/staticmap/) を作りました。
副産物として、[独自のタイルサーバー](https://task-jp.github.io/rastermap/) とか、[ベクタータイル版](https://task-jp.github.io/vectormap/) ができました。

# はじめに

## 静的地図サービスとは
静的地図サービスとは、URLのパラメーターを利用したカスタマイズ可能な**地図を画像として提供するサービス**です。

Google が提供する [Maps Static API](https://developers.google.com/maps/documentation/maps-static/intro?hl=ja) がとても有名です。

> https://maps.googleapis.com/maps/api/staticmap?center=Brooklyn+Bridge,New+York,NY&zoom=13&size=600x300&maptype=roadmap&markers=color:blue%7Clabel:S%7C40.702147,-74.015794&markers=color:green%7Clabel:G%7C40.711614,-74.012318&markers=color:red%7Clabel:C%7C40.718217,-73.998284&key=YOUR_API_KEY
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/b0a63825-e227-f29a-72d7-fce43616f933.png)

最近日本にやってきた mapbox も、[Static Images API](https://docs.mapbox.com/help/glossary/static-images-api/) を提供しています。

> https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/-122.4241,37.78,14.25,0,60/600x600?access_token=YOUR_ACCESS_TOKEN
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/4c0f21ee-37d7-f04e-224b-e2e2559bebfb.png)

## 今回作ったもの

[独自の静的地図サービス](https://task-jp.github.io/staticmap/) です。

> https://static.maps.rest/osm?size=256x256&zoom=14&scale=1&center=43.063678,141.342663&path=43.066489,141.336021|43.067210,141.340441|43.066959,141.344175|43.062211,141.345109|43.061300,141.337416|43.066489,141.336021
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/ad9bcc12-7c2f-2690-a0f6-670551dfc5b2.png)

## 動機

昔、とある位置情報を利用したゲームをしていた時期に、とある SNS 上で、ゲームに関するカスタマイズした地図を画像として表示するということを行っていました。

便利がエスカレートした結果として、月間のリクエストが１８万強、４万円弱ほどの意図しない出費をしてしまいました。（※某利用者のご好意により、なんとかなりました）

ということで、**自分で作ればタダ** 理論に則って、色々勉強をしながら車輪の再発明をしました。

# やったこと

- データベースの設計(1)
- 地図データの取り込み
- データベースの設計(2)
- 地図データの変換
- 地図の描画
- マーカーやポリゴンの描画
- Webサービスとして動かす

以前に、[既存の地図タイルを利用したそれっぽいサービス](https://github.com/task-jp/qstaticmap) を作ったことがあったのですが、地図タイルの利用条件等を考慮した結果、地図も自分で描いた方が良さそうだな。という結論に達しました。

ということで、[OpenStreetMap のデータ](https://learnosm.org/en/osm-data/getting-data/) を利用することにしました。

## データベースの設計(1)

使い慣れた PostGIS を使用しました。docker 上で動いています。

OSM のデータ形式に沿って、基底テーブルを作りました。

- element
    - id (primary key)
    - type (0 = node, 1 = way, 2 = relation)
    - version
    - timestamp
    - changeset

element の派生として、各要素に対応する以下のテーブルを作成しました

- node (inherits element)
    - lat
    - lon
- way (inherits element)
- relation (inherits element)

way -> node と relation -> way/node の関係性を表すテーブルも作成しました

- element_x_element
    - parent_id
    - parent_type
    - seq
    - child_id
    - child_type
    - role

最後に、タグ用のテーブルを作成しました

- tag
    - id (primary key)
    - k
    - v
- element_x_tag
    - element_id
    - element_type
    - tag_id

各テーブルの必要そうなところに適当に INDEX を追加しています。

## 地図データの取り込み

[Geofabrik](http://download.geofabrik.de/) にある XML 形式のデータを、データベースに取り込むための簡単なプログラムを作りました。

## データベースの設計(2)

地図を書くために、指定した領域に存在する描画対象（道路や建物）を高速に検索する必要があるため、上記のデータを、PostGIS 的な形式に変換して保持します。

ということで、以下のようなテーブルを作りました。

- point
    - id
    - element_id
    - element_type
    - key
    - value
    - tags
    - layer
    - geom
- line
    - id
    - element_id
    - element_type
    - key
    - value
    - tags
    - layer
    - geom
    - m
- area
    - id
    - element_id
    - element_type
    - key
    - value
    - tags
    - layer
    - geom
    - m2
 
`id` は独自のもの、`element_id`, `element_type` は `element.id`, `element.type` です。
`key`, `value` は、変換時に変換対象として指定したデータで、例えば、key=highway, value=primary のような値が入ります。
`tags` には、key, value 以外で描画時に利用するタグを JSONB 形式で保持していて、例えば、{"structure": "tunnel"} のような値が入ります。
`layer` は、タグの layer を切り出したものです。（tags 自体に取り込んでもいい気がしている）
`geom` は GEOMETRY(Point), GEOMETRY(LineString), GEOMETRY(Polygon) 形式の値が入っています。
`line.m` は、線の距離を、 `area.m2` は面の面積を保持しています。

## 地図データの変換

node, way, relation を、point, line, area に変換するためのツールを作りました。

以下のような変換の定義で変換を行っています。

```text:convert.rule
way place island area
node highway traffic_signals point
way highway  motorway line {"bridge.k": "structure", "tunnel.k": "structure"}
```

## 地図の描画

指定したエリアの地図を描画するライブラリを Qt で作りました。

[ST_Intersects](https://postgis.net/docs/ST_Intersects.html) で、指定したエリアのデータを検索し、 [QImage](https://doc.qt.io/qt-5/qimage.html) に [QPainter](https://doc.qt.io/qt-5/qpainter.html) で描画します。

描画対象の設定と、描画の仕方についてはスタイルごとに QML で定義を書いて管理をしています。

```qml:Map.qml
import MapTile 1.0

Map {
    id: map
    color: '#A9D3DF'
    Pen {
        id: borderPen
        color: 'black'
        width: 0.1
    }
    Area {
        filter: 'place=island'
        pen: borderPen
        brush: Brush { color: '#F2EFE9' }
        lower: true
    }
}
```

## マーカーやポリゴンの描画

上記のライブラリで生成した QImage 上に、QPainter を利用して、カスタムの要素を描画しています。

## Webサービスとして動かす

だいたい Qt で作っているので、[QtHttpServer](https://qiita.com/task_jp/items/fe1a4f5c4724a3eca303) を利用しました。

描画結果の QImage を [QImageWriter](https://doc.qt.io/qt-5/qimagewriter.html) クラスで PNG 形式の [QByteArray](https://doc.qt.io/qt-5/qbytearray.html) に変換して、"image/png" で返しています。

# 苦労した点

## OSM データの取り込み

最初に bounds 内の node, way をデータベースから検索してメモリ上にキャッシュし、既存のものは再登録をしないようにした。

## PostGIS 形式への変換

謎のデータが少なからずあり、整合性のチェックの仕組みの実装に結構苦労した。

結局、[GEOS](https://geos.osgeo.org/doxygen/index.html) という PostGIS も利用している専用のライブラリを利用することにしたけれど、日本語の情報がほとんどないし、英語のドキュメンテーションも不親切だし、開発環境の Mac と、サーバー側(Ubuntu)でバージョンが違うとかで色々色々苦労した。

way は line にするものと area にするものの判別が難しかった。

## データベースの設計(2)

対象のデータを高速に検索するとこでとても苦労した。

最初は素直に汎用的に作っていたが全然速度が出なかった。

- 検索対象のタグを、tag -> element_x_tag -> point/line/area から探すのではなく、あらかじめ key,value として保持しておくことにした。
- line.m や area.m2 などを用いて、描画対象にならない（=1px に満たない）対象物を簡単に除外できるようにした。
- line（特にhighway）と area（特にbuilding）を、key と m/m2 でパーティション分けをし、なるべく狭い範囲のテーブルから検索を行うようにした。

あたりの工夫で最初に比べて１０倍くらい検索が速くなりました。

## 地図の描画

線は、データとしては太さがないけれど、描画時には太さを持つので、描画範囲のすぐ外にある線は検索対象にしないといけないという知見を得た。

線を描く際に、道路の縁取りや、道路の接続が自然な見た目になるようにとても苦労をした。

レイヤー順に正しく描画しつつパフォーマンスが落ちないような工夫をした。

Zoom レベルに応じて、何を描くかが見た目もパフォーマンスに影響が大きく、調整にとても苦労した。

https://static.maps.rest/osm?size=400x400&center=35.835933,139.859422&zoom=16&scale=2
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/446ce555-8e84-9b91-4e86-60ad62615f64.png)

# まとめ

OpenStreetMap のデータを利用した静的地図サービスができました。
これで無料で静的地図が使い放題！！！？

開発に際してサーバーを [さくらのクラウド](https://cloud.sakura.ad.jp) 様からお借りすることができました。本当にありがとうございます。

ちなみに、自分に都合のいいように API の追加を色々していて、こんな画像も生成できるようになっています。（そのうち別途記事を書きます）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13867/d0e1cf5c-6b38-163b-d4e0-1e2ece26a10b.png)

# ついでにできたもの

GeoJSON 形式のデータを静的地図にしたいというリクエストがあったので、[簡単なインターフェース](https://task-jp.github.io/staticmap/geojson.html) を作ってみました。砂時計とか出ないので、我慢強くお待ちください。

いわゆるタイルサーバー的なものもできました。
> [Leaflet](https://leafletjs.com/) を使った [デモ](https://task-jp.github.io/rastermap/)

時代はベクタータイルらしいので、ベクター版も作ってみました。
> mapboxgl を利用した [デモ](https://task-jp.github.io/vectormap/)

# TODO

- 地図の描画内容を充実させる
    - （歩道）橋、トンネル
    - マイナーな要素
- 地図に文字を描けるようにする
    - 元々想定していた利用方法的には必要ない
    - 実装が多少めんどくさそう…
- 日本以外の地図も対応する
- 美しいスタイルを編み出す
- サービスとしてスケールするような仕組みにする
    - 目指せ月１８万アクセス…
    - 今は裏で5プロセスが頑張っているだけ
- 最低限のアクセス制限（？）の仕組みを作る

# FAQ (適宜追加予定)

- [shp2pgsql](https://github.com/openstreetmap/osm2pgsql) なぜ使わなかったの？
    - 自分で作って勉強したかったからです
    - boost がよくわからないとかではありません。
- [mapnik](https://mapnik.org/) なぜ使わなかったの？
    - 自分で作って勉強したかったからです
    - cairo (ry