Maplibre GL JS の addProtocol の使い方を調べる

## はじめに

[Maplibre GL JS](https://github.com/maplibre/maplibre-gl-js) は、[Mapbox GL JS](https://github.com/mapbox/mapbox-gl-js) から派生したウェブ地図用のライブラリです。

Mapbox GL JS の v2 系のライセンス変更（2020年12月）をきっかけに、Mapbox GL JS の v1 系からフォークされましたが、その MapLibre GL JS も既に v2 となって久しく、もともとの Mapbox GL JS にはない機能も追加されています。

そのひとつが、`addProtocol` という機能であり、タイルをロードする際に、実行する処理を追加することができます。ただ、maplibre.org から出されている `addProtocol` の説明はあっさりしていたので、使ってみた挙動やこの機能を活用している参照実装等を通して、理解を深めてみます。

:::note
なお、`addProtocol` 自体は [MapLibre v1.15.0](https://github.com/maplibre/maplibre-gl-js/blob/main/CHANGELOG.md#1150) の時点で "Added custom protocol support to allow overriding ajax calls" として[リリース](https://github.com/maplibre/maplibre-gl-js/commit/492bec58c5684609af8fba81ef01e5f5a3ef0711)されているようです（本記事コメント参照）。
:::

### v3 → v4 の破壊的変更について
:::note warn 
この記事は、MapLibre GL JS v3 までを対象としています。
:::

**MapLibre GL JS v4 では`addProtocol` に破壊的変更が入りました**のでご注意ください。 

変更点として、v3 の場合は、引数に設定した callback へ、変換したタイルデータを渡していましたが、v4 の場合は、Promise の履行結果としてタイルデータが渡されるようになっています。

この破壊的変更の内容と既存コードの流用可否については、非常に簡易的ですが、以下の記事で調べています。`addProtocol` に渡される引数の中身のうち、主となる部分（タイルの URL やデータの type ）の形式は変更ないようです。

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

以下は、v4 を用いて実装してみた例です。

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

## 使い方

本機能は、Style JSON 内の source で指定する URL のプロトコル部分にオリジナルのプロトコルを設定でき、そのプロトコルが設定されたファイルがロードされる際に、処理を追加することができます。

たとえば、`custom://` というオリジナルプロトコルを設定してみます。ベクトルタイルの場合、Style JSON の記述は、以下の通りになるでしょう。設定のうち、`tiles` 内の URL に注目です。

```javascript:Style JSON
"sources": {
  "vector": {
    "type": "vector",
    "tiles": ["custom://localhost/xyz/vector/{z}/{x}/{y}.pbf"], 
    "minzoom": 4,
    "maxzoom": 16
  }
}
```

この `custom://` プロトコルが設定された URL からタイルをロードする際に行う処理を `addProtocol` で記載します。上記の maplibre.org から出されている例にならい、単純に https プロトコルで fetch する例を記載すると以下の通りです。

```javascript:addProtcolの記述
maplibregl.addProtocol('custom', (params, callback) => {
  
  console.log(params); // 本検証のために追加
  
  fetch(`https://${params.url.split("://")[1]}`)
  .then(t => {
    if (t.status == 200) {
      t.arrayBuffer().then(arr => {
        callback(null, arr, null, null);
      });
    } else {
      callback(new Error(`Tile fetch error: ${t.statusText}`));
    }
  })
  .catch(e => {
    callback(new Error(e));
  });
  
  return { cancel: () => { } };
  
});
```

まず、`addProtocol` の第１引数がオリジナルで設定したいプロトコル名になります。今回は `custom` ですね。

第２引数が、実行したい処理の関数になります。この関数は、params と callback を受け取ります。まず、params の内容は、source の type によって異なりますが、オリジナルのプロトコルを含んだ URL となります。この際、タイル番号は、`${z}`、`${x}`、`${y}` ではなく、既に実際の数字に置き換えられています。この URL をもとに、処理を行い、最終的なデータを callback に渡してあげると処理は終了になる、という形に見えます。

上記の例では、単純に、オリジナルプロトコルの `custom://` を `https://` へ置き換えて、その URL のデータを fetch API で受け取り、buffer array として、callback へ渡しています。（渡すときは、callback の第２引数に渡すようです。他の引数が何を意味しているのかは詳細に調査しておりませんが、[このソース](https://github.com/maplibre/maplibre-gl-js/blob/99ad3d7d0881cfa4b29207d9789c572bbc84fd0f/src/util/ajax.ts)の `ResponseCallback` の型を見ると、第１引数から、エラー、データ、[Cache-Control](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Cache-Control)？、[Expires](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Expires)？ と思われます。）

上記はベクトルタイルの例ですが、callback へ渡すときのデータ形式は、Source の type に応じて、変更すべきと理解しています。


### params の内容例

Source の type 別の params の中身を以下に記載してみます。

```javascript:raster
{
  "url": "custom://localhost/xyz/raster/13/7275/3227.png",
  "headers": {
      "accept": "image/webp,*/*"
  },
  "type": "arrayBuffer"
}
```
```javascript:vector
{
  "url": "custom://localhost/xyz/vector/13/7275/3227.pbf",
  "type": "arrayBuffer"
}
```
```javascript:geojson
{
  "url": "custom://localhost/data/example.geojson",
  "type": "json"
}
```

## 活用例

先人の `addProtocol` 活用例を以下紹介します。

### 国土地理院の標高タイルをデコード

国土地理院から公開されている[標高タイル](https://maps.gsi.go.jp/development/demtile.html)ですが、こちらの標高のエンコード方式は、MapLibre GL JS（や Mapbox GL JS）では対応していません。しかし、`addProtocol` を使うことで、タイルロード時に、デコードを行い、MapLibre GL JS が解釈できる形式に変換することで、type が `raster-dem` のSource 等として利用することが可能になります。

https://qiita.com/Kanahiro/items/1e9c1a4ad6be76b27f0f

### PMTiles の読み込み

[PMTiles](https://github.com/protomaps/PMTiles/) は、一つのファイルの中にタイルデータ一式が格納されており、タイル座標に対応する位置のデータを HTTP 範囲リクエストで取得します。利用するにはクライアント側での実装が必要ですが、MapLibre GL JS で利用できるような環境が提供されています。この実装においては、 `addProtocol` を利用して、タイル座標から PMTiles で取得すべきデータの範囲を割り出し、タイルのデータを取得してきて、MapLibre GL JS へ渡しています。

https://github.com/protomaps/PMTiles/tree/main/js

## 少し使いづらいところ

色々試す中で `addProtocol` で使いづらいと思われるポイントを共有してみます。

### タイル座標は直接受け取れない

受け取れる情報に、タイル座標（x, y, z）はなく、URL だけとなります。そのため、タイル座標を取り出そうとすると、URL をパースする必要があります。そのため、変則的なURLだと難儀しそうです。以下は、PMTiles の[参照実装](https://github.com/protomaps/PMTiles/blob/main/js/adapters.ts)におけるパース例となります。

```javascript
  const re = new RegExp(/pmtiles:\/\/(.+)\/(\d+)\/(\d+)\/(\d+)/);
  const result = params.url.match(re);
  // （中略）
  const z = result[2];
  const x = result[3];
  const y = result[4];
```

このパース方法だと、以下のような URL のタイルは、誤ったパースを行うことになります。

```
custom://localhost/xyz/{y}/folder/test-10{z}/{x}/2/3/1/image.png
```

この URL のタイルに対して、`addProtocl` で受け取れる params は以下の通りですが、params.url のどの数字がタイル座標の z, x, y を示すのか判別できません。
```
{
  "url": "custom://localhost/xyz/806/folder/test-1011/1819/2/3/1/image.png",
  "headers": {
    "accept": "image/webp,*/*"
  },
  "type": "arrayBuffer"
}
```

### もともとの Source の type の影響を受ける

もともとの Style の Source に影響を受けているので、例えば、タイル形式の GeoJSON をそのまま GeoJSON Source として使おうとすると無理があります。

呼び出すファイルと MapLibre GL JS へ渡すデータを１対１で変換するということを基本に考える必要があります。GeoJSON タイルの場合は、Source の type が `geojson` であると `tiles` の設定はできませんので、Source の type を `vector` か `raster` に指定し、`addProtocol` 内で、GeoJSON を pbf や 画像等の  array buffer に変換する必要がありそうです。

（勉強不足なので、もしかしたら、もう少し機能を紐解けばできる可能性もありますが……。）

【追記】
GeoJSON タイルを `addProtocol` を使って読み込む実装を試してみました。`addProtocol` 内で、GeoJSON を pbf へ変換しています。

https://qiita.com/mg_kudo/items/832ee30ed39b6c25df60


## 感想

この機能により、色々なことができそうで期待が膨らみます。Mapbox GL JS ではなく、MapLibre GL JS を使う大きなモチベーションになりそうな機能です。Contributor の方々に感謝と敬意を表します。

今まで自分は Mapbox GL JS の v1 系を使い続けていましたが、MapLibre GL JS へのマイグレーションを本格的に考えていきたいですね。

### 余談

Mapbox GL JS には、Mapbox 社のサービスで利用される `mapbox://` というプロトコルが実装されていますが、MapLibre GL JS の v2 では削除されています。最初、`addProtocol` を聞いたときに、このプロトコルを一般化するためのものかと想像しました。

実際のところを調べようとして、GitHub レポジトリの issue や discussion を探ってみました。「addProtocol」をキーワードに検索して、色々と見ていくと、v2 をリリースするまでに、`mapbox://` プロトコルへの対応を残すかどうかは議論があり、コードを残すとMapbox 社のライセンスに抵触する可能性があるという懸念があった様子です。結局、`mapbox://` プロトコルへの対応は v2.0 で削除されて、どうしても使いたければ、「~~同時に追加された~~ `addProtocol` を用いて、**自己責任**で対応することは可能では？」という旨のコメントが残っています。

:::note warn
記事投稿当初の「同時に追加された `addProtocol`～」は、私の勘違い（`addProtocol` が v2 から追加されたと思い込んでいた）でしたので、訂正いたします（2024/12/15）。 
:::

https://github.com/maplibre/maplibre-gl-js/issues/610#issuecomment-1008231047

https://github.com/maplibre/maplibre-gl-js/discussions/71#discussioncomment-1279643

~~しかし、`addProtocol` の機能を加えた直接のきっかけやモチベーションは見つけることができませんでした。（もちろん、私が見つけられていないだけの可能性は高いです。だれかご存じであれば、ご教授願います……。）~~

【追記】
@shizu 様から、`addProtocol` の機能を加えたきっかけと思われる issue（[Support custom source #29](https://github.com/maplibre/maplibre-gl-js/issues/29)）を紹介いただきました。

こちらの issue を見ると、`addProtocol` は、`mapbox://` プロトコルの取り扱いとは別に提案されたもののようです。ユースケースとして、「データベース等からもオフラインでデータを取得するような機能」が提示されています。

なお、この issue は [MapLibre v1.15.0 の時点でリリース](https://github.com/maplibre/maplibre-gl-js/commit/492bec58c5684609af8fba81ef01e5f5a3ef0711)されていますので、`addProtocol` は MapLibre が v2 になる前から実装されていたのですね。

【さらに追記】

`addProtocol` 周りのエピソードについては、こちらの記事も参考になります。

https://qiita.com/Kanahiro/items/2058c74df7149595d569