MapLibre で GeoJSON タイルを読む

## はじめに

国土地理院からは、2019年7月より、[地理院地図Vector提供実験](https://github.com/gsi-cyberjapan/gsimaps-vector-experiment)として、Mapbox Vector Tile 形式のデータが提供されています。

一方、それ以前は、[ベクトルタイル提供実験](https://github.com/gsi-cyberjapan/vector-tile-experiment)として、GeoJSON をタイル状に分割した **GeoJSON タイル** の提供実験がされておりました（現在も配信は続いています）。また、GeoJSON タイルは、この提供実験以外にも、地理院地図等にて各データの表示に利用されています。

:::note
地理院地図で GeoJSON タイルが使われている例：
指定緊急避難場所データ、自然災害伝承碑、各災害対応時のデータ（写真撮影位置や判読結果）等
:::

一方、私の知る限り、現行の主要なライブラリ（Mapbox/MapLibre、Leaflet、OpenLayers 等）では、ネイティブで GeoJSON タイルに対応しておりませんので、利用するには何らかの対応が必要です。

現在、地理院地図Vector では、Mapbox GL JS v1 系（を改造したもの？）が利用されており、これまで地理院地図で提供されている GeoJSON タイルも読めるようになっています。実装としては、タイル１枚１枚を `geojson` source として取り込んでいるようです。（以下は、GeoJSON タイルを読み込んだ状態の地理院地図Vector のスタイルを console へ出力してみた例です。例えば、`10/909/405` のタイルは、`__geibv_layer__{管理番号}-10:909:405` という名前で source へ設定されています。）

![地理院地図VectorにおけるGeoJSONタイルの読込方法.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/6bb72ca6-5f24-b68a-38e2-e99cb813fc4c.png)

一方、MapLibre GL JS では、`addProtocol()` という、Mapbox GL JS にはない機能がありますので、こちらを用いてもう少し簡便にタイルを読み込めないか試してみたいと思います。

なお、`addProtocol()` の概要や使い方については、以下の記事をご覧ください。

https://qiita.com/Kanahiro/items/2058c74df7149595d569
> `addProtocol()`という言葉だけだと意味がわかりづらいので、ユーザーがタイルデータを自由に生成出来る`custom source`と理解するのが良いと思います。

https://qiita.com/mg_kudo/items/cc88be1ed46fa77871fd

## GeoJSON を Mapbox Vector Tile へ変換する

### コンセプト

今回の実装のコンセプトは、`addProtocol()` を用いて、GeoJSON タイルを Mapbox Vector Tile（Protocol Buffers）へ１対１で変換することです。目標とする処理の流れは以下の通りです。

1. GeoJSON の URL を受け取る
1. GeoJSON タイルデータを fetch する
1. 取得した GeoJSON を Mapbox Vector Tile へ変換する
1. Mapbox Vector Tile を Protocol Buffers としてバイナリ化する
1. バイナリの array buffer を返す

これに加え、[スタイル](https://maplibre.org/maplibre-style-spec/)上で source に `geojson` ではなく `vector` と登録することで、GeoJSON タイルでありながら、 Mapbox Vector Tile 形式のデータと同等のスタイル設定ができるようになります。

### 利用するライブラリ等

今回の処理の肝はどうやって GeoJSON を Mapbox Vector Tile（および Protocol Buffers のバイナリ）へ変換するかです。

最近、[ベクトルタイルのバイナリをオンメモリで生成する記事]()が投稿されておりましたので、こちらを大いに参考にさせていただきながらいろいろと試したところ、pbf + geojson-vt + vt-pbf の組み合わせで実現できました。

流れとしては、geojson-vt を用いて、GeoJSON を Mapbox Vector Tile 形式相当の Object へ形式変換（＋分割）し、それを vt-pbf にて Protocol Buffers のバイナリへ変換します。

https://github.com/mapbox/pbf

https://github.com/mapbox/geojson-vt

https://github.com/mapbox/vt-pbf

:::note warn
なお、今回はモジュールベースの開発ではなく、ブラウザだけで実験していたので、特に vt-pbf については、ライブラリからソースコードをそのまま移植して来るような使い方をしています。
:::

### 実装例

GeoJSON の取得と変換を行う最低限の実装をご紹介します。

```JavaScript:GeoJSON の取得と変換
const processGeojsonTile = async (params) => {
  // Pbf ライブラリの使用準備
  const pbf = new Pbf();
  // URL から タイル座標の取得
  const m = params.url.match(/\/(\d+)\/(\d+)\/(\d+)\.geojson/);
  const [z, x, y] = [+m[1], +m[2], +m[3]];
  // 実際の GeoJSON データを取得
  const url = params.url.replace("geojson-tile://", "");  
  const geoJSON = await fetch(url)
    .then((response) => {
        return response.json();
    });
  // geojson-vt を用いて、GeoJSON を Mapbox Vector Tile（相当の Object）へ変換
  const tileIndex = geojsonvt(geoJSON, {
    generateId: true,
    indexMaxZoom: z,
    maxZoom: z, 
  });
  // タイルセットができるので、必要なタイルのみ取得
  const tile = tileIndex.getTile(z, x, y);
  // vt-pbf（の fromGeojsonVt() 関数）を用いて、タイルを protocol Buffers へ
  // 変換の上、バイナリ化して、Promise の履行結果として渡す
  return new Promise((resolve) => {
    const buffer = fromGeojsonVt({ "v": tile }) 
    // 注：source-layer 名は "v" としています
    resolve(buffer);
  });
}
```

```JavaScript:プロトコルを MapLibre へ登録
maplibregl.addProtocol('geojson-tile', async (params, abortController) => {
  const arrayBuffer = await processGeojsonTile(params)
    .then((arrayBuffer) => {
      return arrayBuffer;
    })
  return {data: arrayBuffer}
});
```

スタイルの設定は以下の通りです。プロトコルは上記で設定した `geojson-tile`を使います。また、source の `type` は、`geojson` ではなく、 `vector` を設定し、通常のバイナリベクトルタイルと同様に `tiles` や `minzoom`/`maxzoom` を設定すれば OK です。スタイルレイヤの設定も通常のベクトルタイルと同様です。

```JavaScript:スタイルの設定例
map.addSource("geojson-vt", {
  "type": "vector",
  "minzoom": 5,"maxzoom": 16,
  "tiles": [ 
    "geojson-tile://https://cyberjapandata.gsi.go.jp/xyz/experimental_landformclassification1/{z}/{x}/{y}.geojson"
  ],
  "attribution": "地理院タイル（ベクトルタイル提供実験（自然地形））"
});

map.addLayer({
  id: "polygon", type: "fill", source: "geojson-vt",
  "source-layer": "v", // 上記コードで変換時、source-layer 名は "v" としている
  layout: {},
  paint: {
    "fill-opacity": 0.5, "fill-color": "#FF0000", "fill-outline-color": "#FFFFFF",
  }
});  
```

## オーバーズーミング時の劣化対策

さて、上記の実装で一通りの処理はできましたが、いくつか課題があります。大きなものですとオーバーズーミングによる劣化対策となります。

地理院地図では、軽量なデータの場合、ZL2 のタイルのみを用意し、最大 ZL（ZL18）までオーバーズーミングするという手法が見られます。

このようなタイルデータを上記コードで利用する場合、以下のような処理となります。

* ZL2 の GeoJSON を読込み、**ZL2 を基準に Mapbox Vector Tile へ変換**したうえで、ZL2 の Mapbox Vector Tile をオーバーズーミングして表示する

ここで何が問題になるかというと、Mapbox Vector Tile は、GeoJSON と異なり、**経緯度をそのまま保持するのではなく、タイルの内部座標（通常は4096×4096のグリッド）へ変換**されます。いわば量子化されるようなイメージです。

すると、ZL2 で4096×4096のグリッドへ変換された場合、１辺は2の12乗ですから、その間隔は ZL12 のタイル間隔と同じになります。ですから、ZL2 の Mapbox Vector Tile を最大 ZL までオーバーズーミングして利用しようとすると、かなり小さい ZL の段階でデータが劣化して表示されてしまうことになります。

このオーバーズーミングの劣化を対策しようとすると、geojson-vt を用いて GeoJSON を Mapbox Vector Tile（相当の Object）へ変換する際に、適切に ZL を設定する必要があります。

```javascript
  const tileIndex = geojsonvt(geoJSON, {
    generateId: true,
    indexMaxZoom: z, // <-- ここの ZL を適切に設定する必要がある
    maxZoom: z,      // <-| 
  });
```

そうする場合、

* タイルを実際に表示したい ZL（画面上の ZL）
* GeoJSON タイルが存在する最大 ZL 

の２つの ZL が必要となります。

このとき、`addProtocol()` では、引数として URL しか利用できないため、この両方の ZL を手に入れるためには、何らかの設定を URL に組み込んでおく必要があります。

そこで、今回は、以下のように、カスタムプロトコル（ここでは `geojson-tile`）と `https://` の間に、タイルが存在する最大 ZL を示す maxNativeZoom というパラメータを `maxNativeZoom={最大 ZL};` の形で挿入することにしてみました。

なお、その場合、タイル URL テンプレート内の `{z}/{x}/{y}` には、実際に表示するべきタイル座標が入ってほしいので、source の `maxzoom` は最大値に設定する必要があります。

```JavaScript:パラメータ "maxNativeZoom" を組み込んだスタイルの設定例
map.addSource("geojson-vt", {
  "type": "vector",
  "minzoom": 5,"maxzoom": 22, // maxzoom は最大値に設定
  "tiles": [ 
    "geojson-tile://maxNativeZoom=14;https://cyberjapandata.gsi.go.jp/xyz/experimental_landformclassification1/{z}/{x}/{y}.geojson"
  ],
  "attribution": "地理院タイル（ベクトルタイル提供実験（自然地形））"
});
```

このようにスタイルを設定すれば、`addProtocol()` へ設定する関数の中で、タイルが存在する最大 ZL と、そのタイルを表示すべきタイル座標の両方が手に入ります。

```javascript:パラメータ "maxNativeZoom" を考慮した GeoJSON の取得と変換
const processGeojsonTile = async (params) => {
  const pbf = new Pbf();
  // パラメータ（maxNativeZoom）と GeoJSON の URL を分離
  // URL は "geojson-tile://maxNativeZoom={nz};https://～/{z}/{x}/{y}.geojson" を想定
  // 同じデータに何度もアクセスするため、ブラウザのキャッシュを頼ることになる
  const info = params.url.split("https://");
  const url = "https://" + info[1];
  const add = info[0].replace("geojson-tile://", "");
  // 「表示すべきタイル座標」を取得
  const m = url.match(/\/(\d+)\/(\d+)\/(\d+)\.geojson/);
  const [z, x, y] = [+m[1], +m[2], +m[3]];
  // "maxNativeZoom" パラメータ（があれば）取得
  // （URL に組み込まれるパラメータは maxNativeZoom しかないことを想定）
  const q = add.split(";")[0];
  const maxNativeZoom = q ? +q.split("=")[1] || z : z;
  // オリジナルの GeoJSON の URL を復元
  const dz = (maxNativeZoom < z) ? z - maxNativeZoom : 0;
  const nativeZ = z - dz;
  const nativeX = x >> dz;
  const nativeY = y >> dz;
  const nativeUrl = url.replace(/\/(\d+)\/(\d+)\/(\d+)\.geojson/, 
    `/${nativeZ}/${nativeX}/${nativeY}.geojson`);
 
  const geoJSON = await fetch(nativeUrl)
    .then((response) => {
        return response.json();
    });
  const tileIndex = geojsonvt(geoJSON, {
    generateId: true,
    indexMaxZoom: z, // maxNativeZoom ではなく、表示すべきタイルの ZL を設定
    maxZoom: z, // maxNativeZoom ではなく、表示すべきタイルの ZL を設定
  });
  const tile = tileIndex.getTile(z, x, y); // 表示すべきタイルの座標を設定
  return new Promise((resolve) => {
    const buffer = fromGeojsonVt({ "v": tile })
    resolve(buffer);
  });
}
```

:::note warn 
なお、この方法の場合、**表示すべきタイル毎に元データへのアクセスが発生する**ことになります。今回は、ブラウザのキャッシュに任せて対応しておりませんが、キャッシュを無効化するとかなり重くなります。ご注意ください。
:::

以下は劣化への対応の様子です。座標値の劣化は改善されていますし、ブラウザキャッシュがきちんと効いているのか、パフォーマンスもあまり気になりません。

![ポイントデータの例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/555e1a9d-4c4e-d057-2bf7-87abd28643d0.png)
![ポリゴンデータの例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/f4077aa6-eeb5-4cb3-15c5-ffb1c260bd88.png)
※サンプルは地理院地図から提供されている令和6年能登半島地震関係のデータ

## 課題

課題として、以下のようにエラーが生じることがあります。たしか、Mapbox GL JS の v0 系でも同じようなエラーを見たことがありますが、同じような原因かもしれません。

![表示エラー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/97f01282-b614-0614-b46c-381853ee9f10.png)
※国土地理院　ベクトルタイル提供実験　地形分類（自然地形）

また、タイルデータが存在しない場合、Mapbox GL JS/MapLibre GL JS では、小さい ZL のデータをオーバーズーミングして表示する挙動がありますが、このような挙動をさせたくない場合（例：縮尺のレベルが異なる複数データが１つのタイルセットに混在し、それぞれ適当な ZL までの表示にとどめたい場合）、何らかの対応が必要となるでしょう。

以下の例は、左側と右側でデータの精度が異なる例です。左側のデータは ZL14 で表示させたくないかもしれませんが、ZL14 で相当するタイルがないため、精度の粗いデータがそのままオーバーズーミングで表示されています。

![精度の異なるデータが混在する場合の例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/862a9687-8a25-2bbc-29b2-96e2f253883d.png)
※国土地理院　ベクトルタイル提供実験　地形分類（自然地形）

## レポジトリとデモサイト

デモサイト

https://mghs15.github.io/maplibre-geojson-tile/

レポジトリ

https://github.com/mghs15/maplibre-geojson-tile

## おわりに

これまでぼんやり構想を考えていた GeoJSON タイルの読込ですが、形にすることはできました。すばらしいライブラリや知見を提供してくださるみなさまに感謝です。

[こちらの記事](https://qiita.com/Kanahiro/items/2058c74df7149595d569)で、以下のような記述がありますが、`addProtocol()` を通すことで、簡便にプラグインのような形で機能を追加できるのは魅力です。

> `addProtocol()`がユーザー定義のsourceの作成を可能としたことで、図らずもいわゆる「プラグイン機構」を提供するものとなったということでしょう。

GeoJSON タイルを１枚ずつ別 source として読み込む方法と比較して、精度の劣化等に気を配る必要はあるものの、一度プロトコルの登録さえすれば、あとはスタイル設定だけすればよく、画面領域に応じた source の管理をしなくて済むので利便性はあるかと思います。