地理院地図Vectorで地図注記の引っ越し

これは [FOSS4G Advent Calendar 2020](https://qiita.com/advent-calendar/2020/foss4g) の 22日目の記事です。

# はじめに

地図の注記だけを別の場所に持っていってみましょう。要するにこういう話です。

![Hello](https://repository-images.githubusercontent.com/323254205/4ae43000-43a7-11eb-9ca6-847c55ad85df)

# できあがり

今回は [Leaflet](https://leafletjs.com/) 上で [地理院地図Vector](https://github.com/gsi-cyberjapan/gsimaps-vector-experiment) と [@mapbox/vector-tile](https://www.npmjs.com/package/@mapbox/vector-tile) と [proj4js](http://proj4js.org/) を使って実装してみました。

完成品のデモはこちらからどうぞ。

- <https://frogcat.github.io/gsimaps-vector-annotation-hold/>

ソースコードはこちらに置いてあります。

- <https://github.com/frogcat/gsimaps-vector-annotation-hold>

## 使い方


1) デモページを開くと地理院地図 Vector の注記が表示されています。この画像ではたまたま都道府県名レベルが表示されていますが、ズーム＆パンするとそのズームや場所に応じた注記が表示されます。
![04.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/1a7bca62-f487-308f-3d00-a5c22e1eabc4.jpeg)

2) 「今見えている注記をどこか別の場所で表示したい」と思ったら、画面下の **HOLD** ボタンを押します。
![11.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/bc36e6d7-4cc7-09d4-58d5-55b07cd5736a.jpeg)

3) **HOLD** がチェックされた状態では、ズーム＆パンしても注記が画面内に維持されます。

ただし、単純に画面に付箋のように貼り付いているわけではなく、注記の画面中心からの距離が維持されるように都度再計算されている、というのがポイントです。上の画像と下の画像は同じズームレベルですが、上の画像は中心に京都、左端に長崎が表示されているのに対して、下の北海道あたりにズーム＆パンした画像では、中心の京都は変わらずですが、左端の長崎が見切れていますね。

![26.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/dcf3e0ac-3f1f-c7b9-04c2-de365488ca43.jpeg)

4) 注記をクリックすると、その注記と同カテゴリのものを残して他は見えなくなります。再度クリックすると元に戻ります。「駅だけ表示したい」みたいな場合にどうぞ。

![37.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/845aa846-f3f4-4a91-0a58-126d7512582f.jpeg)

5) **HOLD** ボタンのとなりのスライダーを操作すると、画面中心を原点として注記が回転します。お好みでどうぞ。

![53.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/82494/a3648d6c-46c5-f3d9-4f57-1c3748ed74b9.jpeg)


# 解説

ソースコードはHTML+JS+CSSすべてひとつのファイルにおさめて200行くらいという簡単なものなので、見てもさほど苦にならないと思いますが、以下ポイントのみ。

## 1. 基本

いずれも拙稿ですがこのあたりを前提としています。

-  [Leaflet のボイラープレート](https://qiita.com/frogcat/items/9062de8ff0782269e3db) : Leaflet の基本的なボイラープレートです。Leaflet のバージョンが上がるたびにちまちまと更新しているのでたまに思い出してみてください。今年は v1.7.1 のリリースのみでしたね。
-  [Leaflet でバイナリベクトルタイル処理の流れを追ってみる](https://qiita.com/frogcat/items/d3d5efa0de299ef509f5) : Leaflet でバイナリベクトルタイル(MVT) を扱う方法を紹介した記事です。MVT を GeoJSON 変換して Leaflet で使おう、というものです。このへんはほとんど動きがなかったと思いますが、mapbox-gl-js のバージョンアップ＋ライセンス刷新で、今後波風くらいは立つかもしれません。

## 2. 地理院地図 Vector

地理院地図 Vector に関して注意する点があるとすると、以下のパートでしょうか。

> 本提供実験によるベクトルタイルにおけるズームレベル（{z}）は、現在「地理院地図」で提供している地理院タイル（ラスタ）（ https://maps.gsi.go.jp/development/ichiran.html ）のズームレベルと同一ではありません。
> 画面上で同じ大きさで表示される際のズームレベルは、ベクトルタイルにおける数値が、地理院タイル（ラスタ）のズームレベルと比べて1小さい数値となります。そのため、ベクトルタイルにおけるズームレベル11のデータは、ズームレベルが12の地理院タイル（ラスタ）と同じデータを用いて作成しています。
> また、ベクトルタイルにおいて画面に表示されるタイルの大きさは、地理院タイル（ラスタ）でズームレベルが1大きい（大きさが小さい）タイルの4枚分に相当します。
> (出典: [gh:gsi-cyberjapan/gsimaps-vector-experiment#ズームレベルについて](https://github.com/gsi-cyberjapan/gsimaps-vector-experiment#%E3%82%BA%E3%83%BC%E3%83%A0%E3%83%AC%E3%83%99%E3%83%AB%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6))

これを Leaflet の語彙で解釈すると、[L.TileLayer](https://leafletjs.com/reference-1.7.1.html#tilelayer) でいうところの

- `tileSize` は（デフォルトの256ではなく） 512 とせよ
- `zoomOffset` を -1 とせよ

ということになります。普通に tileSize=256, zoomOffset=0 で実装してしまうと、一画面を表示するのに４倍のタイルを取得する上、さらに本来欲しいものよりもズームレベルがひとつ大きいタイルを取得する、ということになってしまい、激重ｘ超過密、という悲惨な結果になります。注意しましょう。

あと `zoomOffset` は `L.TileLayer` に実装されているんですが、派生元である `L.GridLayer` ではサポートされていないことにはちょっと注意が必要です。今回のように `L.GridLayer` を拡張するケースでは DIY が必要です。

## 3. 座標系変換

注記の引っ越しに当たっては、以下のような座標変換が発生しています。

1. 注記は WGS84 の緯度経度 P0 を持っている
2. HOLD ボタンが押されたときに「画面中心を原点とした平面直角座標系(CS1)」が設定される
3. P0 の CS1 上における点 P1 が計算され、注記の属性として保持される
3. ズーム＆パンによって画面の中心が移動すると「そのときの画面中心を原点とした平面直角座標系(CS2)」が設定される
4. P1 は本来 CS1 上の点だが、これが CS2 上の同じ座標値の点に移動したものとみなして CS2 上の点 P2 が設定される
5. P2 の WSG84 における点 P3 が計算され、注記の新たな緯度経度として採用される（L.Marker の位置が変更される）

だいたいこういう処理は proj4js を使っておけばいいのですが、Leaflet には [Projection](https://leafletjs.com/reference-1.7.1.html#projection) というインターフェイスがあるので、それでラッピングするようにしています。
以下は CS1 や CS2 に対応する Projection を作っている部分です。EPSG:4326 から 所与の緯度経度 latlng を原点とした平面直角座標系への投影を行う projection オブジェクトを作り、 `Projection.project(latlng)` では緯度経度から平面直角座標系の座標への変換を、`Projection.unproject(point)` では平面直角座標系の座標から緯度経度への変換（逆変換）を行うというものです。

`+proj=tmerc +lat_0=...` の部分は <https://spatialreference.org/ref/epsg/2443/proj4/> などを参考にしました。

```js
const createProjection = function(latlng) {
  const projection = proj4("EPSG:4326", `+proj=tmerc +lat_0=${latlng.lat} +lon_0=${latlng.lng} +k=0.9999 +x_0=0 +y_0=0 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs `);
  return {
    project: function(latlng) {
      const xy = projection.forward([latlng.lng, latlng.lat]);
      return L.point(xy[0], xy[1]);
    },
    unproject: function(point) {
      const ll = projection.inverse([point.x, point.y]);
      return L.latLng(ll[1], ll[0]);
    }
  };
};
```

## 4. スタイリング

本当は地理院地図Vector のスタイルをもとに、地理院地図Vector の注記スタイルを模倣できればよいのですが、時間がたりませんでした。ここではとりあえず以下のポリシーとしました。

- フォントサイズは一律14px
- レイアウトも固定（文字の回転＝なし、text-align＝左寄せ）
- 背景色は各注記に付与された `annoCtg` (数字3文字) をもとに色相を計算した hsl 着色
- 注記のあたまに黒背景白文字で `annoCtg` を表示

`annoCtg` は注記の分類コードです。
実際にどのコードがなにを指すのかは <https://maps.gsi.go.jp/help/pdf/vector/dataspec.pdf> を参照。


# まとめ

いくつかの技術を寄せ集めてちょっとしたアプリを作ってみました。
枯れた感じのある Leaflet ですが、変則的なアプリを作るときの素体としては相変わらず便利だな、と思っています。あと文字に特化したユースケースだと mapbox-gl-js のフォントローディングのオーバーヘッドと文字品質はやっぱり課題だと思うので適材適所かな、と。

そういう話とは別に、地図注記の引っ越し、面白そうな使い方があったら教えてもらえるとうれしいです。