ライブラリ未使用で地図描画入門編(WPF)

この記事は [地震界隈 Advent Calendar 2020](https://adventar.org/calendars/5903) 6日目の記事です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/ab1c1917-7b54-727f-946e-3b7262f6d217.png)

## はじめるまえに
まず当たり前なのですが、**WPFでマップを描画するのは推奨しません**。
既存の資産(XAMLで書いたコントロールとか)を活用したいとしてもせめてマップの描画部分ぐらいはDirect2Dとかで組みましょう。
流石に重すぎる。

またこれを書いている人は主に数学に関する教養がありません。
ですので、地図についてわからない人もなんとなくわかるかもしれません。
完全に試行錯誤で得た技術のためめちゃくちゃ適当言っていたらごめんなさい。
間違っていたら指摘していただけると嬉しいです…。

## はじめに
地震情報などを自作ソフトでプロットしてみたいが日本地図を表示するのに断念してleafletなどで実装してしまおうとした方も多いと思います。
ラスタ画像でもいいですがメモリ食うし、汚いし、潰れるし。いいことは何もありませんね。
Formsでは綺麗に高DPIに対応させることも困難ですし、最近楽になったとはいえ高DPIに対応させてもUIをC#で記述していくのは非常に困難です。(諸説あり)

そんな中、国土数値情報の利用が楽になったということもあり、WPFでの描画をチャレンジしてみた記事です。
ポエムを書くことでしか文字数を稼げないのだ…すまんな…。

## 用語集
適切でない表現が含まれているかもしれません。乖離していたらコメントください。直します。

|名前|説明|
|:--|---|
|地物|地(図)上に存在するもの。建物だったり、地形だったり。|
|地理座標|緯度(latitude)･経度(longitude)で示される地球上の場所を示す座標|
|画面座標|画面/ウィンドウ上の場所を示す座標|
|投影法|地理座標を平面図等観測できる形として表すために座標を変換する法則|
|測地系|何を基準に地理座標を決めるか、表すかといった基準 ほぼ似た値にはなるが無視できない程度のズレは発生するため精度を求められる場合はしっかり考慮する必要がある(今回はやっていない)|
|GIS|地理情報を扱うジャンル自体の名前→[GISとは・・・](https://www.gsi.go.jp/GIS/whatisgis.html)|
|シェープファイル|地物を表現するフォーマットの1つ→[シェープファイルとは？](https://www.esrij.com/gis-guide/esri-dataformat/shapefile/)|
|GeoJSON|地物を表現するフォーマットの1つ 文字通りJsonで表現される|
|TopoJSON|地物を表現するフォーマットの1つ GeoJSONの拡張であり、様々な点から扱いやすい|

## 考え方
まずは描画方法について考えます。
ラスタデータ(画像)を使用せずにDPIなどに縛られない描画を行うためにはベクタデータとして描画するしかありません。
様々な方法はありますが、今回は地理座標から直接描画を行うことをチャレンジします。

ベクタデータとして扱うには、地物を構成する座標を変換した上で画面上に描画させるという処理が必要不可欠です。
GISの地形データはすべて点と線のポリゴンで構成されているため、逆に言ってしまえば座標の変換さえできればあとはそのポリゴンの描画を考えるだけです。

### 座標について
では、座標はどのような変換が必要でしょうか。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/f4233e46-4fa8-2e4e-17d3-9b869d05f208.png)
地理座標から画面座標に投影するわけですから、こう考えがちです。
正確には正しくなく、その投影法で表すことのできる座標に変換したあと、実際の画面座標に投影を行います。
3Dモデルの投影と似ているのではないでしょうか。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/9d326441-0382-65f2-7152-626bc29d64f8.png)
数字はあまりにも適当ですが、投影法はあくまで地理座標を他の座標系に投影しているだけに過ぎず、画面に表示するにはもう一度座標変換が必要だということを覚えておきましょう。
殆どの場合画面座標への投影はスケールの変更や一括移動(オフセット)などで済むため、そこまで難しい話ではありません。
今回もWPFの機能の1つであるTransformでサクッと行います。

採用した投影法についてですが、今回はオブジェクトのキャッシュや緯度と経度の関係性が画面上でのX/Y座標と一致しているため扱いやすい、GoogleMapなどのノウハウがそのまま活用できる、などの理由からWebメルカトル図法を採用することにしました。
いずれ他の投影法にもチャレンジしてみたいと思っています。(僕の組んだものは現状汎用的なものにできていません)

#### Webメルカトル図法について

検索してもらったほうが詳しいので検索してほしいのですが、簡単な話ズームレベル0で256x256ピクセル内の画像1枚に全世界が入り、ズームレベルが1上がるごとに全世界のピクセルが2乗(256x256の画像4枚)されていく投影法になります。

拙作のアプリケーションで座標の変換を行っているクラスはこちらになります。
https://github.com/ingen084/KyoshinEewViewerIngen/blob/develop/src/KyoshinEewViewer.Map/Projections/MercatorProjection.cs
LatLngが地理座標、Pixelが投影法による座標、Pointがズームレベル0におけるベースとして使用している座標になります。

### 描画について
描画については実はメチャクチャ簡単です。
ジオメトリを生成することになりますが、WPFにはGeometry抽象クラスが存在し、StreamGeometryクラスで簡単にジオメトリを作成･描画させることができます。
少なくともメルカトル図法においては基本的に座標を移動させるだけのため、StreamGeometryクラスで作成したジオメトリのTransformに現在見たい座標の分だけTranslateTransform(平行移動)を設定してずらして描画させてやります。(WPFのジオメトリは絶対座標を登録するため描画する際に座標を指定することができません。そのためTransformを使用するわけですが、そのせいでFreezeできなくて死ぬほど重くなるんですよね。)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/e2eedd31-a562-91b7-6be5-86b998556764.png)

**描画は簡単**ですが、難しいのはどれだけTransformさせるか、どの座標を表示させるかです。
どれだけTransformさせるか、というのはそのウィンドウ(表示領域)上で基準となる描画領域がどこなのか、そこをしっかり管理した上でどれだけ表示領域が投影法による座標の原点(0,0)から離れているかを計算しずらす必要があります。
主にこのあたりの条件･考えを整理するのが大変でした。

例えばマウスホイールでマウスカーソルの位置を中心にパラメータを操作する場合、マウスカーソルの位置から表示領域として管理している座標までの距離を取ってきてパラメータ変更後のその管理している座標との差分を…など考えないといけなかったりします。まあぶっちゃけそういうロジックを考えるのが一番楽しいところなんですが。
https://github.com/ingen084/KyoshinEewViewerIngen/blob/b54eefe5f363d7b531adf80f229fd1ed73e74a00/src/Sandboxes/MapControlTest/MainWindow.xaml.cs#L43

考え方についての解説はここまでです。
いちばん重要な実装については僕の試行錯誤しながら書いたやばいコードを読んでもらうことになりますが、この考え方をベースに実装されてはいるものの、ズームレベルやオブジェクトのキャッシュなどがあるためかなり読みづらいと思います。

以下は実装の工夫や歴史についてです。

## 初回チャレンジ
WPFでの地図の描画自体は前職の研究としてチャレンジしており、当時はベクトルタイルとしてWPFのみでの描画は厳しいということで一旦お蔵入りになっていました。(当時掲載許可済)
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">やっと正常に都道府県境表示できた…(バグある) <a href="https://t.co/gKqQAZfCnS">pic.twitter.com/gKqQAZfCnS</a></p>&mdash; ingen084 (@ingen084) <a href="https://twitter.com/ingen084/status/833699254476500992?ref_src=twsrc%5Etfw">February 20, 2017</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

そんなある日、のた氏が成果をツイートされており、TopoJSONなどの活用方法を知りました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">TopoJSON を自分が扱いやすい構造に変えて MessagePack へシリアライズしたファイルから C# で描画できた。 <a href="https://t.co/NKo89LKTCd">pic.twitter.com/NKo89LKTCd</a></p>&mdash; のた (@oruponu) <a href="https://twitter.com/oruponu/status/1055826619003154433?ref_src=twsrc%5Etfw">October 26, 2018</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

DMなどで話を伺い、TopoJSONなどの仕様を見ながら見様見真似でパースし実装したのがこれでした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/92ddbd26-ada1-a49c-912f-f3eb426a4498.png)
この実装はTopoJSONをポリゴンとしてパースし、何もせず愚直にジオメトリに変換･描画させただけのものです。
縁の黒線はこれもWPFの機能の一つであるStrokeを雑に設定しました。
なんとかいい感じに地図の表示はできたのですが、やはり負荷などから実用は難しく、これも一旦お蔵入りになっていました。

## 蛇足: KyoshinEewViewer for ingen 初期バージョンについて
初期のバージョンでは僕が固定の緯度･経度･ズーム倍率をベースにして手書きで作成したジオメトリを安直にXAML上に貼り付け、擬似的に観測点を上書きすることでそれっぽく見せていました。
当時はマップ部分以外をウリにしたかったんですよね。そのウリにしたかった機能も未完成だけど。

## リッチに描画したい
そんなこともあった後ある日JQuakeがリリースされ、いずれ実装したいと考えていた動かせるマップが実装されていたためこれは僕のソフトでも実装したい！というのがきっかけでした。実装から完成まで1週間ぐらいでした。

### 要件を絞る
あれから数年経ち、僕個人としても設計なども新しいものに触れていたことから今回はまず、地図描画に関する要件を(頭の中で)整理しました。

1. 地形(日本列島･都道府県境)のみを描画する
2. テーマが設定できる(色を変更できる)
3. 観測点が独自に描画できる
4. **県境･海岸線で線のスタイルを変更したい**
5. それなりに軽い

僕なりに考えた結果、守りたいものはこの5つでした。
一番大変だったのは4/5で、これは今後のデータ形式の部分で解決していくことになります。

### データ形式を考える
次に地図データを含んで配布するにあたってデータ形式を考えました。
地形のデータについて把握しておきましょう。
ベクタデータとはいえ地図のデータには地理座標の点、2つ以上の点で構成される線、1つ以上の線で構成されるポリゴンの3種類のみ存在するとてもシンプルなものになっています。(穴開きポリゴンなどは存在しますが、今回は考慮していません)
そこにタグを設定して地物(地形や領土、道路や建物、バス停など)を表現しているわけです。

その線･ポリゴンをもとに境界線などを描画するのですが、困ったことに今回は要件に県境･海岸線の表示を変更するというものが存在するため、**ポリゴンとは別に県境･海岸線を判定し描画する形になる**のですが、ここでTopoJSONの利点を活用しました。

本来隣接するオブジェクト(県境など)は2つ以上のポリゴンが定義されているのですがTopoJSONでは容量削減のため点や線が共有されており、かなりコンパクトなものになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/884480aa-aa05-4822-443f-01b821aecd77.png)
この仕様を活用し、**他のポリゴンからも使用されている線は県境、使用されていない場合は海岸線**と区別することができます。
僕の実装では描画オブジェクト(Geometry)に変換する際にポリゴンか線か、海岸線か県境かを登録しそこから描画しています。

そんなTopoJSONですが、それでもJsonのため容量はもっと削減することができます。
最近の.NET界隈のシリアライザでおなじみのMessagePackを使用し、ポリゴン以外の形式で保存できなくするような機能を削減したバージョンを採用しTopoJSONから変換を行えるようにしました。

### 地図データの作成
ここまでなんとなく触れてこなかった地図データの作成ですが、比較的簡単です。
国土数値情報のシェープファイルをQGISで読み込み、いい感じ(都道府県ごととか)にマージして**そのまま**GeoJSONなどに出力を行います。
その出力されたファイルを https://mapshaper.org/ で読み込ませ、程々に簡略化させます。
この作業が重要で、これを省いてしまうと重くて使い物にならなくなってしまうため情報の密度とソフトウェアの負荷を天秤にかける必要があります。
(ちなみになぜ重くなるのかはちょっとまだわかってなくて、Direct2Dにしたら勝手に改善しないかなあとか考えてたりします)

そしてその簡略化したファイルをエクスポートし、MessagePack形式に変換します。
詳細な日本+雑な海外のデータで大体130KBぐらいがギリギリのラインでした。

### カスタムオブジェクトの描画
防災アプリとして活用するためにはその地点の情報を表示する必要があります。
簡単な話、マップと同じように移動･拡大ができるコントロールを上にかぶせ、それっぽく描画しているだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100304/cff04aea-4956-0975-2ade-ebc2d4c242ca.png)


### 工夫について
ここで紹介した以外にも実際のソフトウェアでは様々な最適化を試みています。
簡単に紹介できるものを紹介してみます。

#### オブジェクトのキャッシュ
実際のアプリケーションではホイールなどで自由にズーム倍率を変更することができますが、メルカトル図法に限らずズーム倍率を変更するということは投影法による座標も変化してしまいます。
そのたびにジオメトリを再生成してしまってはリソースがもったいないため、大体1.0ズームレベルごとにキャッシュを生成し、描画時に座標移動に合わせてスケールを変更することでいい感じに表示させています。
まあ、これはメルカトル図法だからできることであって、他の投影法では利用できないのですが…。

#### 描画前の簡略化
ズームアウトすると必然的に描画するポリゴン、オブジェクトが増えてゆきます。
一定以上細かいと視覚に差異はあまりないのにも関わらず負荷だけが増えていく状況が生まれてしまいます。
それを回避するために、TopoJSONのオブジェクト間の関連性を保ったままDouglas-Peuckerで簡略化し、ジオメトリを生成するようにしています。

#### 見えないオブジェクトは描画しない
これは当たり前の内容ではあるのですが、今回の場合ポリゴンや境界線の地理座標ベースのバウンドボックスを生成し、見えない場所はそもそもジオメトリを生成しないようにしました。
地理座標ベースでこういう事ができるのもメルカトル図法の利点ではないでしょうか。

## さいごに
読んでくださった方、ありがとうございました。
僕の頭の中をダンプしました。大体地形のデータから実際に描画するまでの流れをなんとなくつかめたのではないでしょうか…。
わからなかったらごめんなさい。僕の頭の中がイカれています。

今回の話題は防災アプリに限らず様々な分野でも活用できる内容ではありましたが、界隈をどんどん盛り上げていきましょう！