MapLibre GL JS v5.12.0 で、Mapbox Vector Tile (MVT) と加えて、 MapLibre Tiles (MLT) を使えるようになりました!
今回の記事では MapLibre Tiles について軽く説明した後に、内部構造も調べてみます。
MapLibre Tiles とは
MapLibre や Mapbox を使うと、おなじみのベクトルタイルは今まで Mapbox Vector Tile (MVT) 形式を使うのが標準でしたが、 MapLibre が独自に開発した形式を今年の FOSS4G Europe に発表されました。
MLT の定義を日本語に翻訳した記事 (非公式)があるので、詳細に気になる方はそちらを確認してください。英語の定義は公式サイトに公開されています。
ざっくりまとめると、次のようになります。
- 一つのタイルに、複数レイヤーがあります (MVTと同様に)
- それぞれのレイヤーは FeatureTable で定義されている
- FeatureTable に複数 Column がある
MVT は行指向だが、 MLT は列指向になります。MVT の場合は地物に対する情報はすべて地物ごとに格納されています。一方で、MLT の場合は列 (Column) 毎に情報を格納します。
列指向型の大きな利点としては圧縮性です。データを大量に格納したり、分析するためのシステム (Parquet, Google BigQuery, ClickHouse, Snowflake など) がすべて列指向型の格納を利用します。圧縮性が優れている理由を簡単に説明すると、「同じ性質のデータを物理的に近くに格納する」というところから生まれます。
例えば、一番シンプルな例として、ランレングス符号化 (RLE) は同じ値が続く繰り返し区間を (繰り返し数, 値) でまとめて表現する手法です。
簡単な例を説明すると、 [0, 0, 0, 1, 1, 0, 0] の配列を RLE で符号化すると [3, 0, 2, 1, 2, 0] になります。RLE の符号化と復号化の計算はシンプルの上、わかりやすくて圧縮性は十分なケースが多い。
ランレングス符号化と加えて、他の更に圧縮性がある符号化も使われていますが、今回の記事では深く触りません。詳しくはこちらを確認してください。
MLT を触ってみよう
公式のデモタイルは https://demotiles.maplibre.org/tiles-mlt/plain/tiles.json でホスティングしています。まずは、こちらを使いたいと思います。このタイルを使った例は「Display a map with MLT」 です。
TileJSON から確認すると、"format": "pbf", "encoding": "mlt" というのが確認できます。後、タイルの拡張子は .mlt となっています。
{
"format": "pbf",
"encoding": "mlt",
"tiles": [
"https://demotiles.maplibre.org/tiles-mlt/plain/{z}/{x}/{y}.mlt"
],
...
}
拡張子は動作には関係ない (content-type は application/octet-stream で、content-encoding: gzip で配信されます) が、分かりやすさのために拡張子 .mlt を使ったほうが良さそうですね。MVTの場合は .pbf と .mvt が混在していること前から気になってました。
MLT は protobuf をメタデータを管理するために利用する痕跡はありますが、今は使っていません。
次は、実際デモで使っているタイルをダウンロードし、maplibre-tile-spec レポジトリで用意しているツールでデコードしてみましょう。
# MLT レポジトリをクローン
git clone https://github.com/maplibre/maplibre-tile-spec
cd maplibre-tile-spec
# 検証対象のタイルをダウンロードしよう
mkdir ./tmp
curl https://demotiles.maplibre.org/tiles-mlt/plain/0/0/0.mlt > ./tmp/plain_0_0_0.mlt
# 検証ツールは Java 製なので JDK を忘れずに
cd ./java
./gradlew cli
java -jar mlt-cli/build/libs/decode.jar -mlt ../tmp/plain_0_0_0.mlt -printmlt
うまく行けば、下記のような出力が表示されます。
{
"layers": [
{
"extent": 4096,
"features": [
{
"geometry": "POINT (1252 1904)",
"id": 0,
"properties": {
"ABBREV": "Aruba",
"NAME": "Aruba"
}
},
...
出力の geometry は WKT 型ではありますが、座標系はタイルに相対する座標となります。この例だと、 0/0/0 なので EPSG:3857 の全域を (min_x, min_y, max_x, max_y) のバウンディングボックスにマッピングすると (0, 0, 4096, 4096) (レイヤーの extent 値) となります。
遊んでみよう?
以前、Creating Vector Tiles from Scratch using TypeScript (日本語版) という記事を書いて、そのような MLT 版を作ろうかなと思いましたが、 MLT が思った以上に複雑なので諦めました。
その代わり、フォーマット自体について調べて整理しようと思います。
全体的のレイアウト
詳細は定義で説明されていないので、ここからは、Java 実装を参照に説明します。
下記はレイヤー毎に繰り返します
-
tag+metadata+featureTableBodyのバイト長さ (参照)- 「このレイヤーはここからあそこまでだよ」フラグ
-
tag(参照)- レイヤーのバージョン?現在は
1にハードコードされている
- レイヤーのバージョン?現在は
-
metadata(参照)- レイヤー自体のメタデータ
- 内容は列毎に:
- 列型 (physical/logical の scalar または complex 型)
- null 許容するか
- 子があるか
- 列名 (あれば - 今のところは、id と geometry 列は列名がありません)
- 子列情報 (入れ子構造)
-
featureTableBody
列のレイアウト
全体はだいたい理解できたが、次は列に深堀りしよう。
値や id 列
列メタデータでは id → ジオメトリ → 値の順で列挙されますが、エンコード方法自体は同じなので一緒に解説します。(参照)
- 列型によってエンコーダーを選択する
- すべての地物 (feature) を列挙し、該当の値を配列 (
values) にコピーする- null 許容する場合は、
presentValues配列も作る
- null 許容する場合は、
-
presentValues配列が存在する場合、 boolean 配列としてエンコードする -
valuesをエンコードする
配列をエンコードする時、
- メタデータ (エンコードするコード)
- エンコードされたデータ
の順で格納します。
ジオメトリ列
GPU レンダリング最適化のため、予め三角形状に変換する「pre-tesselation」ということは任意でできますが、今回の説明から省略します。
- レイヤー内のすべての地物を列挙します:
- それぞれのジオメトリ型を配列に保管する
- ジオメトリの頂点を平坦化し、頂点の配列に保管する
- 頂点の最大・最小を計算する
- 頂点のエンコーディングをそれぞれ作成する
- ヒルベルト曲線ベースの辞書を計算
- モートン曲線 (Z階数曲線)ベースの辞書を計算
- 頂点を ZigZagDelta 符号化して、配列に保存
- ZigZag は Protobuf でよく使われる可変長整数形式
- "Delta" は、差分を指します。起点
(0, 0)からの差分を計算する- 例:
(100, 100), (150, 100), (150, 150), (100, 150)の頂点を持つジオメトリは(100, 100), (50, 0), (0, 50), (-50, 0)としてエンコードします
- 例:
- ヒルベルト辞書、モートン辞書、ZigZag符号頂点配列のパターンのうち、サイズが小さい方法を選んでタイルに保管
辞書型を利用することによって、同一座標を複数回持つジオメトリの最適化は期待できます。
まとめ
かなり長文になってしまいました。MLT を初めて見たときはそこまで複雑とは全然思ってなく、MVT みたいに数時間で理解できるようなものかなと思いましたが、数日定義とコードを眺めてもまだ完全に理解できていない。
MVT とか、PMTiles とか、バイナリ系ファイルフォーマットを調べるのが結構好きだけど、分かりやすい方がいいと思いました。MLT は必要以上に複雑だとは断言できるほど理解はまだまだ全然浅いですが、これから引き続き勉強していきたいと思います。
