はじめに
国土地理院からは、2019年7月より、地理院地図Vector提供実験として、Mapbox Vector Tile 形式のデータが提供されています。
一方、それ以前は、ベクトルタイル提供実験として、GeoJSON をタイル状に分割した GeoJSON タイル の提供実験がされておりました(現在も配信は続いています)。また、GeoJSON タイルは、この提供実験以外にも、地理院地図等にて各データの表示に利用されています。
地理院地図で GeoJSON タイルが使われている例:
指定緊急避難場所データ、自然災害伝承碑、各災害対応時のデータ(写真撮影位置や判読結果)等
一方、私の知る限り、現行の主要なライブラリ(Mapbox/MapLibre、Leaflet、OpenLayers 等)では、ネイティブで GeoJSON タイルに対応しておりませんので、利用するには何らかの対応が必要です。
現在、地理院地図Vector では、Mapbox GL JS v1 系(を改造したもの?)が利用されており、これまで地理院地図で提供されている GeoJSON タイルも読めるようになっています。実装としては、タイル1枚1枚を geojson
source として取り込んでいるようです。(以下は、GeoJSON タイルを読み込んだ状態の地理院地図Vector のスタイルを console へ出力してみた例です。例えば、10/909/405
のタイルは、__geibv_layer__{管理番号}-10:909:405
という名前で source へ設定されています。)
一方、MapLibre GL JS では、addProtocol()
という、Mapbox GL JS にはない機能がありますので、こちらを用いてもう少し簡便にタイルを読み込めないか試してみたいと思います。
なお、addProtocol()
の概要や使い方については、以下の記事をご覧ください。
addProtocol()
という言葉だけだと意味がわかりづらいので、ユーザーがタイルデータを自由に生成出来るcustom source
と理解するのが良いと思います。
GeoJSON を Mapbox Vector Tile へ変換する
コンセプト
今回の実装のコンセプトは、addProtocol()
を用いて、GeoJSON タイルを Mapbox Vector Tile(Protocol Buffers)へ1対1で変換することです。目標とする処理の流れは以下の通りです。
- GeoJSON の URL を受け取る
- GeoJSON タイルデータを fetch する
- 取得した GeoJSON を Mapbox Vector Tile へ変換する
- Mapbox Vector Tile を Protocol Buffers としてバイナリ化する
- バイナリの array buffer を返す
これに加え、スタイル上で 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 のバイナリへ変換します。
なお、今回はモジュールベースの開発ではなく、ブラウザだけで実験していたので、特に vt-pbf については、ライブラリからソースコードをそのまま移植して来るような使い方をしています。
実装例
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);
});
}
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 です。スタイルレイヤの設定も通常のベクトルタイルと同様です。
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のグリッドへ変換された場合、1辺は2の12乗ですから、その間隔は ZL12 のタイル間隔と同じになります。ですから、ZL2 の Mapbox Vector Tile を最大 ZL までオーバーズーミングして利用しようとすると、かなり小さい ZL の段階でデータが劣化して表示されてしまうことになります。
このオーバーズーミングの劣化を対策しようとすると、geojson-vt を用いて GeoJSON を Mapbox Vector Tile(相当の Object)へ変換する際に、適切に ZL を設定する必要があります。
const tileIndex = geojsonvt(geoJSON, {
generateId: true,
indexMaxZoom: z, // <-- ここの ZL を適切に設定する必要がある
maxZoom: z, // <-|
});
そうする場合、
- タイルを実際に表示したい ZL(画面上の ZL)
- GeoJSON タイルが存在する最大 ZL
の2つの ZL が必要となります。
このとき、addProtocol()
では、引数として URL しか利用できないため、この両方の ZL を手に入れるためには、何らかの設定を URL に組み込んでおく必要があります。
そこで、今回は、以下のように、カスタムプロトコル(ここでは geojson-tile
)と https://
の間に、タイルが存在する最大 ZL を示す maxNativeZoom というパラメータを maxNativeZoom={最大 ZL};
の形で挿入することにしてみました。
なお、その場合、タイル URL テンプレート内の {z}/{x}/{y}
には、実際に表示するべきタイル座標が入ってほしいので、source の maxzoom
は最大値に設定する必要があります。
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 と、そのタイルを表示すべきタイル座標の両方が手に入ります。
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);
});
}
なお、この方法の場合、表示すべきタイル毎に元データへのアクセスが発生することになります。今回は、ブラウザのキャッシュに任せて対応しておりませんが、キャッシュを無効化するとかなり重くなります。ご注意ください。
以下は劣化への対応の様子です。座標値の劣化は改善されていますし、ブラウザキャッシュがきちんと効いているのか、パフォーマンスもあまり気になりません。
※サンプルは地理院地図から提供されている令和6年能登半島地震関係のデータ
課題
課題として、以下のようにエラーが生じることがあります。たしか、Mapbox GL JS の v0 系でも同じようなエラーを見たことがありますが、同じような原因かもしれません。
また、タイルデータが存在しない場合、Mapbox GL JS/MapLibre GL JS では、小さい ZL のデータをオーバーズーミングして表示する挙動がありますが、このような挙動をさせたくない場合(例:縮尺のレベルが異なる複数データが1つのタイルセットに混在し、それぞれ適当な ZL までの表示にとどめたい場合)、何らかの対応が必要となるでしょう。
以下の例は、左側と右側でデータの精度が異なる例です。左側のデータは ZL14 で表示させたくないかもしれませんが、ZL14 で相当するタイルがないため、精度の粗いデータがそのままオーバーズーミングで表示されています。
レポジトリとデモサイト
デモサイト
レポジトリ
おわりに
これまでぼんやり構想を考えていた GeoJSON タイルの読込ですが、形にすることはできました。すばらしいライブラリや知見を提供してくださるみなさまに感謝です。
こちらの記事で、以下のような記述がありますが、addProtocol()
を通すことで、簡便にプラグインのような形で機能を追加できるのは魅力です。
addProtocol()
がユーザー定義のsourceの作成を可能としたことで、図らずもいわゆる「プラグイン機構」を提供するものとなったということでしょう。
GeoJSON タイルを1枚ずつ別 source として読み込む方法と比較して、精度の劣化等に気を配る必要はあるものの、一度プロトコルの登録さえすれば、あとはスタイル設定だけすればよく、画面領域に応じた source の管理をしなくて済むので利便性はあるかと思います。