MapLibre でタイルの差分確認

## はじめに

最近、２つのタイルデータの差分を手軽に確認する方法はないかと調査しておりました。例えば、データ更新した際に、どの部分が更新されているか強調したくなるかもしれませんし、単純に２種類のタイルでどこが違うのか、把握したくなるかもしれません。

* ２種類のタイルの比較イメージ
![差分比較の例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/e645ecdf-0711-9ec3-fc79-72c921230dc0.png)

そこで、タイルの差分確認に [MapLibre GL JS](https://github.com/maplibre/maplibre-gl-js) の `addProtocol()` が使えるのではないかと思い至りました。

この `addProtocol()` 機能を使うことで、個別のタイル URL を用いて各タイルを取得し、その結果の ArrayBuffer を返す過程に干渉することができます。そのため、この機能を用いて、別のリソースを参照したり、タイルの中身を変換（例えば、画像タイルの RGB 値を変更）することができます。

一方、最新の MapLibre GL JS（v4）で画像タイルを加工するサンプルが見当たりませんでしたので、今回は自分の備忘録も含めて、ユースケースとともに記事に残したいと思います。

:::note warn
なお、コードの作成において、ChatGPT の支援を受けています。
:::

## addProtocol についておさらい

MapLibre GL JS の v3 までの `addProtocol()` について調べた結果を以下の記事に残しています。

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

また、`addProtocol()` を用いて、画像タイルを加工する具体的な方法は、以下の記事がわかりやすいです。

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

一方、2024年2月に MapLibre GL JS は v4 となり、`addProtocol()` に破壊的変更が入りました。変更点としては、引数に設定した callback へタイルデータの ArrayBuffer を渡していましたが、v4 では、Promise の履行結果としてタイルデータの ArrayBuffer を渡すようになっています。

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

今回は、この v4 に対応した方法を試してみたいと思います。

## 標高タイルのデコード（特定の標高帯だけ表示する）

本題の差分確認に行く前に、まずは、オーソドックスな国土地理院の標高タイルのデコードから試してみます。

MapLibreでは、ラスタのスタイルも、ある程度スタイル設定で弄れますが、標高タイルのような高度なデコードが必要なものは、現状この方法が有力な選択肢となります。

今回は、以下のようにある標高帯を強調表示する地図を実現してみます。

* 東京周辺で、標高10 m～15 m の範囲を緑色で着色した例
![標高タイル表示の例（東京）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/8af167e9-d3d2-9892-e8c7-9834960f268c.png)

まずは、標高タイルを取得する関数と、RGB 値を標高値へ変換する関数を準備します。画像タイルを取得できなかった場合は、無効値 `rgb(128,0,0)` で塗りつぶした画像データを返すようにしています。

```JavaScript:画像タイルの取得
const getGsiDemTile = (url) => {
  return new Promise((resolve, reject) => {
    const img = new Image(); 
    img.crossOrigin = "Anonymous";
    img.onload = () => {
      const _canvas = document.createElement("canvas");
      _canvas.width = 256;
      _canvas.height = 256;
      const ctx = _canvas.getContext("2d");
      ctx.drawImage(img, 0, 0);
      const imgData = ctx.getImageData(0, 0, 256, 256);
      //console.log(imgData);
      resolve(imgData);
    }
    img.onerror = () => {
      const _canvas = document.createElement("canvas");
      _canvas.width = 256;
      _canvas.height = 256;
      const ctx = _canvas.getContext("2d");
      const imgData = ctx.createImageData(256, 256);
      // 無効値（rgb=128,0,0）で塗りつぶし
      for (let i = 0; i < imgData.data.length; i += 4) {
        imgData.data[i] = 255;     
        imgData.data[i + 1] = 0;   
        imgData.data[i + 2] = 0;   
        imgData.data[i + 3] = 255; 
      }
      resolve(imgData);
    }
    img.src = url;
  });
}
```
```JavaScript:RGB 値の変換
const convertDemRgb = (r, g, b, a) => {
  const pow2_8 = Math.pow(2, 8);
  const pow2_16 = Math.pow(2, 16);
  const pow2_23 = Math.pow(2, 23);
  const pow2_24 = Math.pow(2, 24);
  let h = 0;
  if (r != 128 || g != 0 || b != 0) {
    const d = r * pow2_16 + g * pow2_8 + b;
    h = (d < pow2_23) ? d : d - pow2_24;
    if (h == -pow2_23) h = 0;
    else h *= 0.01;
    h = Math.floor(h * 100)/100;
  }else {
    // h = 0;
  }
  return h;
}

```

次に、今回ポイントとなる、`addProtocol()` へ登録する関数を作成します。URL（を含む `param` という Object）を受け取り、ピクセル毎に計算を行った結果を、新たな画像データとして、PNG 形式の ArrayBuffer として返すという処理になります。ここでは、直接 ArrayBuffer を返すのではなく、ArrayBuffer を履行する Promise を返すようにしています。

```JavaScript:ArrayBufferを履行するPromise を返す
const processDemTile = async (params) => {
  const url1 = params.url.replace('gsidem://', '');
  // 画像タイルを取得
  const imageData1 = await getGsiDemTile(url1);

  const canvas = document.createElement('canvas');
  // 大きさは、画像タイルに合わせて決め打ちで 256 px
  canvas.width = 256; canvas.height = 256;
  const context = canvas.getContext('2d');
  const imageData = context.getImageData(0, 0, 256, 256);
  for (let i = 0; i < imageData.data.length / 4; i++) {
    // RGB 値を標高値へ変換
    const h = convertDemRgb(
      imageData1.data[i * 4]    ,
      imageData1.data[i * 4 + 1],
      imageData1.data[i * 4 + 2],
      imageData1.data[i * 4 + 3]
    );
    
    let bh = 10; // 基準とする標高
    let dh = 10; // 色塗りする標高の幅
    // 標高値が特定の範囲（ここでは、bh～bh+dh）に収まっていれば着色する
    if( bh < h && h < bh + dh ){
      imageData.data[i * 4]     = 0;
      imageData.data[i * 4 + 1] = 255;
      imageData.data[i * 4 + 2] = 0;
      imageData.data[i * 4 + 3] = 255;
    } else {
      imageData.data[i * 4]     = 0;
      imageData.data[i * 4 + 1] = 0;
      imageData.data[i * 4 + 2] = 0;
      imageData.data[i * 4 + 3] = 0;
    }
  }
  context.putImageData(imageData, 0, 0);
  // ArrayBuffer を履行する Promise を返す
  return new Promise((resolve) => {
    canvas.toBlob((blob) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.readAsArrayBuffer(blob);
    }, "image/png");
  });
}
```

この関数を用いて、最終的に ArrayBuffer を返す処理を `addProtocol()` を通して MapLibre へ登録します。

```JavaScript:addProtocol による登録
maplibregl.addProtocol('gsidem', async (params, abortController) => {
  const arrayBuffer = await processDemTile(params)
    .then((arrayBuffer) => {
      return arrayBuffer;
    })
  return {data: arrayBuffer}
});
```

以下のようにスタイル設定すれば、実際に、表示できるようになります。

```JavaScript:スタイル設定
map.addSource("gsidem", {
  "type": "raster",
  "minzoom":2, "maxzoom":14,
  "tiles":["gsidem://https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png"],
  "tileSize": 256,
  "attribution":"<a href='https://maps.gsi.go.jp/development/ichiran.html#dem' target='_blank'>標高タイル</a>"
});
map.addLayer({ 
  "id":"gsidem",
  "type": "raster",
  "source": "gsidem",
  "paint":{ "raster-opacity": 0.8 }
});
```

この例を応用することで、たとえば、地理院地図の「自分で作る色別標高図」も実現できそうです。

## ２つのタイルの差分を表示する

本題です。今回は、ハザードマップポータルサイトを例に、差分比較を実装してみます。

ハザードマップポータルサイトでは、[洪水浸水想定区域図のオープンデータが提供されています](https://disaportal.gsi.go.jp/hazardmapportal/hazardmap/copyright/opendata.html)が、全国の河川をマージしたもの（全国統合版）と、河川管理者（国または各都道府県）毎に分けられているものがあります。全国統合版のデータは、各管理者のデータで、浸水深が大きい方を採用していると思われるので、各地において、河川管理者に限定すると全国統合版よりも低い地点が出てきますので、それを抽出してみます。

* 全国統合版と国管理河川のみの洪水浸水想定区域の比較
![差分比較の例.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842832/e645ecdf-0711-9ec3-fc79-72c921230dc0.png)

まずは、タイルを取得する関数と、RGB 値を指標値へ変換する関数を準備します。タイル取得の際にエラーが生じた際は、黒色透明（`rgba(0,0,0,0)`）を返すようにしています。また、標高値とは異なり、RGB 値を指標値へ変換する際は、色と指標値との対応表が必要です。

```JavaScript:画像タイルの取得
const getRasterTile = (url) => {
  return new Promise((resolve, reject) => {
    const img = new Image(); 
    img.crossOrigin = "Anonymous";
    img.onload = () => {
      const _canvas = document.createElement("canvas");
      _canvas.width = 256; _canvas.height = 256;
      const ctx = _canvas.getContext("2d");
      ctx.drawImage(img, 0, 0);
      const imgData = ctx.getImageData(0, 0, 256, 256);
      resolve(imgData);
    }
    img.onerror = () => {
      const _canvas = document.createElement("canvas");
      _canvas.width = 256; _canvas.height = 256;
      const ctx = _canvas.getContext("2d");
      // 黒色透明（rgba=0,0,0,0）画像を返す
      const imgData = ctx.getImageData(0, 0, 256, 256);
      resolve(imgData);
    }
    img.src = url;
  });
}
```
```JavaScript:RGB 値の変換
const convertColor = (r, g, b, a) => {
  // 色と指標値との対応表（洪水浸水想定区域）
  const shinsuiColorTable = [
    { r:255, g:255, b:179, rank: 1 , note:"0.3m 未満"     },
    { r:247, g:245, b:169, rank: 2 , note:"0.5m 未満"     },
    { r:248, g:225, b:166, rank: 3 , note:"0.5m ～ 1.0m"  },
    { r:255, g:216, b:192, rank: 4 , note:"0.5m ～ 3.0m"  },
    { r:255, g:183, b:183, rank: 5 , note:"3.0m ～ 5.0m"  },
    { r:255, g:145, b:145, rank: 6 , note:"5.0m ～ 10.0m" },
    { r:242, g:133, b:201, rank: 7 , note:"10.0m ～ 20.0m"},
    { r:220, g:122, b:220, rank: 8 , note:"20.0m 以上"    },
  ];
  if(a < 1) return -1;
  for(let i=0; i <  shinsuiColorTable.length; i++){
    const t = shinsuiColorTable[i];
    if(t.r == r && t.g == g && t.b == b) return t.rank;
  }
  return 999;
}
```

今回の事例の注意点としては、スタイルには１つのタイル URL テンプレートしか指定できないという点です。当然、`addProtocol()` には、スタイルで設定したタイル URL テンプレートをもとにした URL しか流れてきません。そのため、２つのタイルをどのように読み込むか考える必要があります。汎用性が高そうなものとして、以下の２通りが思いつきます。

* URL1 は固定、URL2はスタイル設定の URL テンプレートに合わせる
* スタイル設定で URL テンプレート中にパラメータを設定して、それを変換する（例：`～/{t}/{z}/{x}/{y}.png` として、`{t}` を URL1, 2 でそれぞれ別のものへ変換する）

今回は、一方の URL は全国統合版に固定し、もう一方はスタイルに設定した河川管理者毎のタイルとし、全国統合版を基準に比較できるような設計としてみました。比較すべき２種類のタイル URL を準備すれば、あとは、標高タイルのコードを自然に拡張できるかと思います。

```JavaScript:ArrayBuffer を履行する Promise を返す
const processImage = async (params) => {
  const m = params.url.match(/\/\d+\/\d+\/\d+\.png/);
  // 比較基準のタイルは固定とする
  const url1 = tileUrl.replace(/\/{z}\/{x}\/{y}\.png/, m[0]);
  // 比較対象のタイルは、スタイル設定の URL テンプレートから指定
  const url2 = params.url.replace('diff://', '');
  // 画像タイルをそれぞれ取得
  const imageData1 = await getRasterTile(url1);
  const imageData2 = await getRasterTile(url2);
  
  const canvas = document.createElement('canvas');
  // 大きさは、画像タイルに合わせて決め打ちで 256 px
  canvas.width = 256; canvas.height = 256;
  const context = canvas.getContext('2d');
  contextfillStyle = "rgba(0 255 0 0)";
  const imageData = context.getImageData(0, 0, 256, 256); 
  for (let i = 0; i < imageData.data.length / 4; i++) {
    const v1 = convertColor(
      imageData1.data[i * 4]    ,
      imageData1.data[i * 4 + 1],
      imageData1.data[i * 4 + 2],
      imageData1.data[i * 4 + 3]
    );
    const v2 = convertColor(
      imageData2.data[i * 4]    ,
      imageData2.data[i * 4 + 1],
      imageData2.data[i * 4 + 2],
      imageData2.data[i * 4 + 3]
    );
    // ２つのタイルの比較結果によってデザインを変更
    if( v1 > v2 ){
      imageData.data[i * 4]     = i % 2 ? 0: 255;
      imageData.data[i * 4 + 1] = i % 2 ? 0: 255;
      imageData.data[i * 4 + 2] = 255;
      imageData.data[i * 4 + 3] = i % 4 ? 255 : 0;
    } else if( v1 < v2 ){
      imageData.data[i * 4]     = 255;
      imageData.data[i * 4 + 1] = i % 2 ? 0: 255;
      imageData.data[i * 4 + 2] = i % 2 ? 0: 255;
      imageData.data[i * 4 + 3] = i % 4 ? 255 : 0;
    } else {
      imageData.data[i * 4]     = 0;
      imageData.data[i * 4 + 1] = 0;
      imageData.data[i * 4 + 2] = 0;
      imageData.data[i * 4 + 3] = 0;
    }
  }
  context.putImageData(imageData, 0, 0);
  // ArrayBuffer を履行する Promise を返す
  return new Promise((resolve) => {
    canvas.toBlob((blob) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result);
      reader.readAsArrayBuffer(blob);
    }, "image/png");
  });

}
```
```JavaScript:addProtocol による登録
maplibregl.addProtocol('diff', async (params, abortController) => {
  const arrayBuffer = await processImage(params)
    .then((arrayBuffer) => {
      return arrayBuffer;
    })
  return {data: arrayBuffer}
});
```

スタイル設定は以下の通りです。

```JavaScript:スタイル設定
map.addSource("diff", {
  "type": "raster",
  "minzoom":2, "maxzoom":17,
  // 国管理河川のタイルを設定
  "tiles":["diff://" + "https://disaportaldata.gsi.go.jp/raster/01_flood_l2_shinsuishin_kuni_data/{z}/{x}/{y}.png"],
  "tileSize": 256,
  "attribution":"<a href='https://disaportal.gsi.go.jp/hazardmapportal/hazardmap/copyright/opendata.html' target='_blank'>重ねるハザードマップ</a>"
});
map.addLayer({ 
  "id":"diff",
  "type": "raster",
  "source": "diff",
  "paint":{ "raster-opacity": 1 },
  "layout":{ "visibility": "none" }
});
```

## デモサイト

今回の成果を利用したデモサイトはこちらです。

https://mghs15.github.io/diff-raster-tile/

レポジトリはこちら。

https://github.com/mghs15/diff-raster-tile

## おわりに

MapLibre GL JS の `addProtocol()` は、PMTiles の読み込みでお世話になっていましたが、画像タイルの処理でも力を発揮することを（今更ながら）体験できました。

パフォーマンスについては、タイルデータをそのまま読み込むよりは落ちますが、個人的にはそこまで悪くないと考えています。

MapLibre GL JS には、Globe Projection や 3D Terrain 等、まだあまり触れていない機能がありますので、今後もいろいろと試していこうと思います。