ウェブ地図で複数の PMTiles をひとつの Source として扱う方法

## はじめに

8月30日に国土地理院から **PMTiles 版**の最適化ベクトルタイルが公開されました。「より迅速で安定した提供」のため、ファイルシステム形式の配信からの移行を目指しているようです。

https://github.com/gsi-cyberjapan/optimal_bvmap

[PMTiles](https://github.com/protomaps/PMTiles/) は、一つのファイルの中にタイルデータ一式が格納されており、ファイル1つでタイル一式を管理できますので、ファイル管理やアップロードのコストを抑えることができます。PMTiles については、過去に自分の記事（[PMTiles の仕様と取扱いを勉強してみる](https://qiita.com/mg_kudo/items/3179639c79c1734fd373)）で紹介しています。

今回は、国土地理院の PMTiles 提供をきっかけに「ウェブ地図で複数の PMTilse をひとつの Source として扱う」方法を試行してみましたので、記事に残したいと思います。

## 国土地理院の PMTiles で驚いたこと

国土地理院による今回の PMTiles 提供を受けて、個人的に驚いたことは、以下のように、**日本全体の最適化ベクトルタイルが1ファイルの PMTiles** で配信されていることでした。

```
https://cyberjapandata.gsi.go.jp/xyz/optimal_bvmap-v1/optimal_bvmap-v1.pmtiles
```
:::note
PMTiles の URL を叩けば、日本の基本図データセットがすべて手に入ると考えるとすごい時代になっていると思います。しかし、まだ全体のデータ量がどのくらいになるか見当つかず、怖くて試していません。ちなみに、[過去の調査](https://qiita.com/mg_kudo/items/f6d3a1ec5c0117237fd3)では、地理院地図Vector のベクトルタイル（最適化ベクトルタイルでは**ない**）で、ZL15だけで合計約12 GB くらいありました。最適化で大雑把に3割削減と考えても相当なデータサイズになりそうです。
:::

PMTiles では、一部のタイルを差し替えるのは難しそうですので、データに変更があればその都度 PMTiles を生成し、アップロードする必要が生じるかと思います。変更頻度の低いデータや、容量の少ないデータであれば、それでも十分運用できるかもしれません。一方、国土地理院の最適化ベクトルタイルのような、日本全体の基本図という巨大なデータで、更新頻度も高い（※）ようなデータでは、単一の PMTiles での運用は難しいのではないか、と考えていたので、1つのファイルで提供されたことは意外でした。

※現在は、3か月に1度くらいのペースで更新しているようですが、地理院地図の迅速更新を見ると、今後は高頻度の更新も必要になってくるのではないかと思います。

国土地理院のデモサイトでは、MapLibre GL JS と PMTiles レポジトリ（protomaps/PMTiles）の参照実装を利用した、私の観測範囲で一般的な方法で提供されています。この国土地理院の技術選定を見るに、ベクトルタイルの PMTiles を利用する際は、MapLibre GL JS + protomaps/PMTiles の組み合わせが基本になるような気がしています。  

今回の最適化ベクトルタイル PMTiles は、1つのファイルで提供されているので、自分の Web 地図で利用する際の対応は至ってシンプルです。一方、もしデータ更新時の効率性を考えて、たとえば区画ごとにデータセットを分割して、それぞれの区画ごとに PMTiles を作成・ホストする場合、どのような実装が可能だろうかと気になりました。

## 複数の PMTiles を利用したい場合

想像と異なり、国土地理院の PMTiles 提供が1つのファイルで行われていましたので、それでは複数の PMTilse で提供された場合、クライアントである ウェブ地図側ではどのような実装が可能なのか、方法を考えてみました。

環境としては、MapLibre GL JS + protomaps/PMTiles の組み合わせを前提とします。

:::note
MapLibre GL JS は v3 を想定して記載しましたが、PMTiles 側のバージョンを適切なものに設定すれば、v4 でも動くと思います。
:::

### MapLibre GL JS の addProtocol 機能
MapLibre GL JS を利用する理由は、`addProtocol` という機能があり、タイルをロードする際に、実行する処理を追加することができるからです。なお、`addProtocol` の解説については、以前に記事を書いています。

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

この protomaps/PMTiles からは、`addProtocol` の機能を用いて、タイル座標から PMTiles で取得すべきデータの範囲を割り出し、タイルのデータを取得してきて、Maplibre GL JS へ渡す処理のプログラムが提供されています。以下は、そのプログラム（`protocol.tile`）の使い方です。
```JavaScript 
let protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
```

### 参照する PMTiles の URL を切り替える
上記のプログラム（protomaps/PMTiles の `protocol.tile`）をラップし、例えばタイル座標に応じて、参照する PMTiles の URL を切り替えるというアイデアを思いつきました。

内容としては、`addProtocol` で登録された処理においては、params と callback を受け取ります。このうち、params には、タイルの URL 等の情報を含むことから、この情報を変更し、`protocol.tile()` へ渡しながら、`protocol.tile()` をそのまま return すればよいという発想になります。

以下は、このアイデアをコードに落としたものです。この例では、処理対象の Source を登録する際のプロトコル名を `multi-pmtiles` としています。

```JavaScript
let protocol = new pmtiles.Protocol();
const urlConvert = (url) => {
  // convert url
  // ここに URL の変換プログラムを記載
  return url;
}
const myProtocol = (param, callback) => {
  console.log(param);
  // URL を変換
  if(param.type != "json"){
    param.url = urlConvert(param.url);
  }
  return protocol.tile(param, callback);
}
maplibregl.addProtocol("multi-pmtiles", myProtocol);
```
Source は、以下の通り登録を行います。ポイントとしては、`tiles` に対して`{プロトコル名}://{PMTiles の URL}/{z}/{x}/{y}` の形式で指定することです。Source の `url` に対して指定したり、最後の `/{z}/{x}/{y}` がないとうまくいきません。

```JavaScript
map.addSource('my-pmtiles', {
  type: 'vector',
  tiles: ["multi-pmtiles://https://example.com/path/to/your.pmtiles/{z}/{x}/{y}"],
  minzoom: 4,
  maxzoom: 10
});
```
```JavaScript:うまくいかない例
map.addSource('my-pmtiles', {
  type: 'vector',
  url: "multi-pmtiles://https://example.com/path/to/your.pmtiles",
  minzoom: 4,
  maxzoom: 10
});
```

以上のような設定をすると、上述の `param.url` として `multi-pmtiles://https://example.com/path/to/your.pmtiles/2/3/1` のような形でタイル URL を受け取ることになります。この URL をもとに、読込先の PMTiles の URL を変更すれば良いわけです。

あとは、URL の変換処理（上述の例では、`urlConvert()`）を実装すればタイル座標等に応じて、別々の PMTiles からタイルデータを読み込むことが可能となります。

### 具体例
実際に動くかどうかを試してみます。[最近作成した](https://qiita.com/mg_kudo/items/1d673a9f8d7de5ad0812)以下の2種類の PMTiles を使ってみます。

1. https://mghs15.github.io/flagment-inter-station/railway-station.pmtiles
    * 駅のポイントデータ
    * 属性値 `from` を持たない  
2. https://mghs15.github.io/flagment-inter-station/railway-section.pmtiles
    * 駅と駅を結ぶラインデータ
    * 属性値 `from` を持つ 

※出典は、いずれも[国土数値情報（鉄道データ）]( https://nlftp.mlit.go.jp/ksj/gml/datalist/KsjTmplt-N02-v2_3.html)と Wikipedia 

タイル座標のうち X 座標と Y 座標の合計値が2で割り切れるかどうかで、上記2種類の PMTiles のどちらを読み込むか変更してみます。具体的には、以下のように `urlConvert()` で URL を変更します。
```JavaScript
const urlConvert = (url) => {
  const re = new RegExp(/^(.+)\/(\d+)\/(\d+)\/(\d+)/);
  const m = url.match(re);  
  // タイルの X 座標と Y 座標の和が２で割り切れるかどうかで読込先の PMTiles を切り替える。
  const myUrl = (+m[3] + +m[4]) % 2 ? 
                 url : url.replace("section.pmtiles", `station.pmtiles`);  
  return myUrl;
}
```

Source と Layer については以下の通りセットします。ここで、重要視したいのは、**複数の PMTiles を読み込むのに、Source は1つ設定すればよい** という点です。スタイルの Source が分割されると、必然的にスタイルの Layer が分割されて複雑になるとともにパフォーマンスが劣化します（参考：[スタイルレイヤ数を減らすとベクトルタイルの描画が速くなるという検証](https://qiita.com/mg_kudo/items/f9b6ab97b2ab670b638c)）。そのような課題を防ぐため、Source を1つにできることの重要性は大きいと考えられます。

```JavaScript
map.on('load', () => {
 map.addSource("my-pmtiles", {
   type: 'vector',
   tiles: ["multi-pmtiles://https://mghs15.github.io/flagment-inter-station/railway-section.pmtiles/{z}/{x}/{y}/"],
   minzoom: 4,
   maxzoom: 10,
   attribution: "国土数値情報/Wikipedia"
 });
 map.addLayer({
   'id': 'my-main-layer-2',
   'type': 'circle',
   'source': 'my-pmtiles',
   'source-layer': 'railway',
   'layout': {
     'visibility': 'visible'
   },
   'paint': {
     'circle-color': [
       // 属性値 from の有無で色を切り替える
       "case",
       ['has', 'from'],
       ['rgb', 255, 255, 255],
       ['rgb', 255, 0, 0],
     ],
     'circle-opacity': 1,
     'circle-radius': 3,
     'circle-stroke-color': ['rgb', 0, 0, 0],
     'circle-stroke-width': 1,
   }
 });
});
```

こちらを表示した結果が以下の通りです。1.のタイルを赤、2.のタイルを白で表示していますが、期待通り、タイル座標に応じて交互に出現しています。なお、背景地図は PMTiles 形式の国土地理院最適化ベクトルタイルを読み込んでいます。

※ 2.のタイルのデータはもともとラインデータですが、ここでは、ラインの頂点がポイントデータ（白の `circle`）として表示するようなスタイル設定としています。

![multi-pmtiles-sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/4ed3b677-e1fd-17d5-d3a6-e4e595958c41.png)

#### デモサイト

https://mghs15.github.io/multi-pmtiles-in-single-source/

#### レポジトリ

全体を通したコード等は、以下のレポジトリの `index.html` に記載しています。

https://github.com/mghs15/multi-pmtiles-in-single-source

### 具体例２

地域を区画に分ける場合、Web 地図での利用を想定すると、特定のズームレベルのタイル座標に応じて分割するのが利用しやすいかと思います。

以下は、ズームレベル（例：ZL=6）に応じて分割された PMTiles のうち、タイル座標に応じてデータを取得するための URL 変換処理例です。

この方法を用いることで、データの取り回しが楽になる以外に、データ容量の制限（たとえば、gh-pages では、1レポジトリあたり1 GB）があるような場合でも、分散してデータ（PMTiles）を配置する、という使い方ができるようになるかもしれません。

```JavaScript
const urlConvert = (url) => {
  const re = new RegExp(/^(.+)\/(\d+)\/(\d+)\/(\d+)/);
  const m = url.match(re);  
  // タイル座標に基づいてモジュール分割されている場合
  const zl = m[2];
  const x = m[3];
  const y = m[4];
  const moduleZoom = 6; // 分割単位の ZL
  const _flag = (zl > moduleZoom);
  const dz = Math.abs(zl - moduleZoom);
  const moduleX = _flag ? x >> dz : x << dz;
  const moduleY = _flag ? y >> dz : y << dz;
  const myUrl = url.replace(/\.pmtiles/, `-${moduleZoom}-${moduleX}-${moduleY}.pmtiles`);
  return myUrl;
}
```

※コードのみの例示です。

## 感想

国土地理院最適化ベクトルタイルの PMTiles の提供方法は、複数の PMTiles で提供するという想定とは異なり、1つのファイルで公開されましたが、これをきっかけに、漠然と考えていた PMTiles をモジュールごとに分割して提供した際の消費方法を考えることができました。

データセットを丸ごと一つの PMTiles に入れて提供すべきか、それとも複数のモジュールで構成するべきか、正解は分かりません（少なくとも、現時点で国土地理院は前者を選択していますので、そちらにメリットがあるのかもしれません）が、今後運用が積み重なってくると、そのような比較の話も出てくるのかなと思っています。

なかなか日本全体の基本図のような巨大なデータを個人で運用することはありませんので、自分の手で検証していくというよりも、各データ提供者の今後の動きが楽しみです。