PMTiles の仕様と取扱いを勉強してみる

## はじめに

タイルデータの配信方法として、PMTiles を使うという試行錯誤を目にすることが多くなってきました。今回は、PMTiles の仕様とクライアント側での参照実装を勉強したので、その記録として記事に残してみたいと思います。以下が PMTiles のレポジトリになります。

https://github.com/protomaps/PMTiles/

PMTiles は、一つのファイルの中にタイルデータ一式が格納されています。この点だけだと、[MBTiles](https://github.com/mapbox/mbtiles-spec) と同じように感じられるかもしれませんが、PMTiles は、ファイル内の一部のデータだけを取得できる [HTTP 範囲リクエスト](https://developer.mozilla.org/ja/docs/Web/HTTP/Range_requests)に対応している点が特長です。つまり、PMTiles のファイル1つを、（範囲リクエストに対応した）Web サーバにアップロードしておけば、それだけでタイルを配信することができるようです。MBTiles のように取り扱えるけれども、サーバ側の特別な実装が不要な静的コンテンツとして扱えるようになります。

タイルデータは、その性質上ファイル数が大量になりがちなため、ファイル管理やアップロードのコストが大きくなってきます。そのため、ファイル1つでタイル一式を管理できる PMTiles の発展には期待しているところです。

また、こういった特徴から、以下の記事のように、サーバーレスであり、**Cloud Optimized** な形式として注目されているようです。

https://qiita.com/Kanahiro/items/577f7828c5a1e323721a

なお、PMTiles を利用したときの Web 地図からのリクエストの挙動を記事にしてくださった方がいらしたので、参考にさせていただきました。

https://qiita.com/T-ubu/items/c1f851dfbea4b0e8d268

また、同じ方の別記事を見ると、だいぶ簡単に PMTiles を作って消費できる環境になってきているのではないかと思いますが、自分はまだ実際に手を動かせてはいません。

https://qiita.com/T-ubu/items/93d84b9d4bf5be608fda

## PMTiles の仕様

PMTiles の仕様は以下のレポジトリで公開されています。最新は [v3](https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md) ですので、この記事でも v3 を中心に見ていきたいと思います。なお、以前の [v2](https://github.com/protomaps/PMTiles/blob/main/spec/v2/spec.md) とは、Header をはじめとして大きく変わっています。

https://github.com/protomaps/PMTiles/

また、PMTiles の構造については、以下の記事にも（この記事以上に）詳細が記載されています。

https://qiita.com/ciscorn/items/76e85a0faa2367548446

### PMTiles の構造

PMTiles の構造は、以下の5つの領域から構成されています。

1. Header
2. Root Directory
3. JSON metadata
4. Leaf Directories
5. タイルデータ

Header 以外は順番を変えることも可能ですが、今のところメリットはなさそうなので、この通りの順番での記載が推奨されています。

### Header 
固定長 127 bytes で、その PMTiles 内の情報がまとまっています。
他の領域の開始位置（オフセット）や長さが記載されていて、この情報を用いて、必要な情報を読み出していくことになりそうです。データや圧縮方法、タイルの種類も記載されています。地理データらしく、ズームレベルや経緯度の範囲、中心経緯度もセットされるようです。

:::note
これを見ると、Leaf Directories やタイルデータのオフセットと長さはそれぞれ1つ分しか記載できないため、Leaf Directory やタイルデータはまとめて1か所に記載されるであろうことがわかります。
:::

### Root Directory
それぞれのタイルデータが、PMTiles 内でどの位置に格納されているかという情報（Entry と言います）が記載されています。長さは、Header と合わせて、[圧縮](https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md#directory-serialization)後に16384 byte 未満である必要があります。Root Directory では収まらない場合等は、別途 Leaf Directory として格納しておくことができます。

Entry は、以下の4つの情報で構成されています。

* `TileId`：ズームレベル0 から連番（square Hilbert curves）でつけられた各タイルの番号。
* `Offset`：各タイルデータのPMTiles 内の開始位置。
* `Length`：各タイルデータの長さ。
* `RunLength`：同じデータの繰り返し回数。

特に `RunLength` の仕様は面白いです。まず、通常は同じタイルは1枚しかないので、`RunLength=1` となるかと思いますが、海上などの場合、同じデータのタイルが複数続くこともあるため、`RunLength>1` となるかもしれません。
また、`RunLength=0` となる場合は特別で、その場合は、次の Leaf Directory の情報が記載されていることを示します。この時、`Offset` と `Length` は、タイルデータの場所ではなく、次に参照するべき Leaf Directory の位置を、Leaf Directories 領域の中での`Offset` と `Length` として示すことになります。なお、この際に、その Leaf Directory の最初のタイルは、`RunLength=0` となった `TileId` のタイルと同じになります。

:::note 
以前の v2 では、Header と Root Directory は512 kb で固定されており、21,845件のタイルデータ（ズームレベル1～8のフルセットに相当）までを格納するという仕様でした。しかし、v3 では、タイルデータの格納数は特に制限がなく、圧縮後の Header と Root Directory が16,384 bytes 以下となれば良さそうです。そのため、PMTiles 作成時に工夫が求められそうです。仕様には、「費用や帯域、レイテンシといったトレードオフを考慮して設定可能」という旨が記載されています。
:::

### JSON metadata 
特筆点はないです。（仕様でも特に説明がありません。）

### Leaf Directories
Root Directory に収まらない分のタイル情報は、別途 Leaf Directory として格納しておくことができます。中の構造は、Root Directory と同じです。Leaf Directory から、さらに別の Leaf Directory へ格納することもできます。

### タイルデータ
各タイルのデータが格納されます。データ形式や圧縮方法等は Header に記載されています。Header の記述方法を見ると、形式の混在（画像＋ベクトルタイル、PNG＋JPG等）は許されていなさそうです。

## PMTiles からタイルデータを取り出す

PMTiles からどのように目的のタイルを取り出すのか、JavaScript での[参照実装](https://github.com/protomaps/PMTiles/blob/main/js/)を見ながら整理してみたいと思います。この中でも、[index.ts](https://github.com/protomaps/PMTiles/blob/main/js/index.ts) の `getZxyAttempt()` を中心に見ていくと、流れが追いやすいと思います。

※関連する関数は、文末の「→`hoge()`」で示しています。


1. タイル座標を `TileId` へ変換する。（→`zxyToTileId()`）
1. PMTiles の最初の16,384 bytes を範囲リクエストして取得・キャッシュする。（→`getHeaderAndRoot()`）
1. 取得した16,384 bytes から、Header を取得する。（→`getHeader()`）
1. 取得した16,384 bytes から、Header の情報を用いて Root Directory を取得する。Root Directory は圧縮されているので、[仕様](https://github.com/protomaps/PMTiles/blob/main/spec/v3/spec.md#directory-serialization)に基づいてデコードする。（→`getDirectory()`）
1. Root Directory 内から、目的の `TileId` を探して、Entry の情報を取得する。（→`findTile()`）
	* もし、目的の `TileId` が見つからず、最後の Entry が `RunLength=0` であれば、そこで指定されている範囲の Leaf Directory を範囲リクエストして取得・キャッシュし、その中から目的の `TileId` を探す。（参照実装では、Root を含めて4つ分の Directory を探すようになっている。）
1. Entry の情報をもとに、必要なタイルデータの開始位置と長さを指定して、範囲リクエストすることで、目的のタイルデータを取得する。（→`getBytes()`）
1. Header 内の情報に従い、タイルデータをデコードして利用する。（→`decompress()`）

### PMTiles からタイル一覧の情報を取得する
2024/01/21 追記：PMTiles からタイル一覧とタイルごとのデータサイズ（圧縮後）の情報を取得する方法をまとめています。

https://qiita.com/mg_kudo/items/9f87fca0c579fd2b645d

## 感想等

タイルデータは数が多くなると、管理が大変になる（特にアップロードやダウンロード）ので、一つのファイルで管理できるのは非常に便利なのではないかと思います。特に、サーバ側で特別な実装が必要ではなく、ほぼほぼ静的コンテンツと同等の扱いができるとなれば、タイルデータの配信にかかる労力をだいぶ減らせるのではないかと思います。

一方、一部のタイルを差し替えるのは難しそうですので、その都度PMTiles を生成したり、アップロードしたりしないといけないとなると、それはそれで労力がかかるような気がします。タイルの更新頻度や範囲によって、同じ静的コンテンツであっても、今までの複数ファイルによるタイルセット方式か、PMTiles を採用するかで選択肢が生まれることになります。

また、v2 から v3 にかけて、

* 最初に取得する Header + Root Directory のサイズを小さくする
* 経緯度やズームレベルの範囲等を、JSON ではなくそのまま記載することで JSON 解析コストを削減する

等といった、クライアント側のコストを下げるような変更が多いと感じています。一方、今回は触れていない作成側で、いろいろと工夫が求められるようになっています。特に、Root Directory や Leaf Directory にどのようにタイル情報を格納していくかは、今後悩むことになりそうです。

まだ、仕様を読む程度しかしていませんが、実際に作成や配信、消費まで手を動かしておきたいところです。