MapLibre GL JSと地理院標高タイルで3D地形を表示する

![スクリーンショット 2022-05-24 13.06.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/e2912d18-cc05-cf6e-ffb8-8f6882cc46d9.png)

## 2024-06-30追記

https://github.com/mug-jp/maplibre-gl-gsi-terrain

本記事の内容は上記リポジトリにて公開されているライブラリに統合されています。簡単に利用できます。

## TL;DR

https://jsbin.com/vonixulezo/edit?html,output

- MapLibre GL JSの`v2.2.0-pre.2`で3D-Terrainが実装された
- MapLibre GL JSの`addProtocol()`を使う事で読み込んだタイルデータを加工できる
- `addProtocol()`で地理院標高タイルをTerrainRGBへ変換することで、標高タイルを使って3D-Terrainを表示できた

## MapLibre GL JSと3D-Terrain

https://github.com/maplibre/maplibre-gl-js/pull/1022

3D-Terrainの実装はかなり長い時間がかけられており、早い段階からデモを触ったりしてワクワクしていたものでした。
それが最近マージされ、`2.2.0-pre.2`でプレリリースとして公開されました。

### 必要なデータ

DEMタイルが必要で、`TerrainRGB`（もしくは`Terrarium`）でエンコードされた画像である必要があります。
一方で地理院標高タイルはこのいずれでもない特殊なエンコーディングです。

> 標高エンコーディングの考え方については下記を参照
https://qiita.com/Kanahiro/items/e22594b738655a189c1d#rgb%E5%80%A4%E3%81%AE%E6%A8%99%E9%AB%98%E6%8F%9B%E7%AE%97

## MapLibre(Mapbox) GL JSと標高タイル

地理院タイルは標高エンコーディングが独特であるため、MapLibreで素直に利用する事ができません。
それでもなお標高タイルをMapLibre(Mapbox) GL JSで使おうとした先行事例がありました。

### 先行事例

https://qiita.com/tattii88/items/3d88907c7116d1aeb028
Mapbox GL JS自体をフォークして、標高タイルのエンコーディングを実装しています。

https://qiita.com/frogcat/items/d12bed4e930b83eb3544
`ServiceWorker`で`fetch()`を監視して標高タイル画像をTerrainRGBに変換しています。

https://qiita.com/T-ubu/items/c35023e1df2362bd8e7f
`v2.2.0-pre.2`で標高タイルをそのまま使って3D-Terrainを表示しています。

しかし最近MapLibreには`addProtocol()`という関数が実装されました。これを用いれば、もっとラクに標高タイルをTerrainRGBに変換する事ができます。

## addProtocol()

この関数は名前からは使い道がわかりにくいのですが、要はカスタムした`source`を定義する事ができます。
ここでいうプロトコルとは、`sample://`で言う`sample`です。タイルURLでこのような特定のプロトコルを接頭辞とすることで、タイルへのリクエストなどを`addProtocol()`で定義したとおりの振る舞いとする事ができます。

### 標高タイルをTerrainRGBに変換する

MapLibreの`Map`インスタンスを初期化します。なお標高タイル(gsidem)の`tiles`の書き方に注意してください(`gsidem://`がプロトコル)。

```typescript
import maplibregl, { Map } from 'maplibre-gl';

new Map({
    container: 'map',
    maxPitch: 85,
    style: {
        version: 8,
        sources: {
            gsi: {
                type: 'raster',
                tiles: [
                    'https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg',
                ],
                attribution: '地理院タイル',
            },
            gsidem: {
                type: 'raster-dem',
                tiles: [
                    'gsidem://https://cyberjapandata.gsi.go.jp/xyz/dem_png/{z}/{x}/{y}.png',
                ],
                tileSize: 256,
                maxzoom: 14,
            },
        },
        layers: [
            {
                id: 'gsi',
                type: 'raster',
                source: 'gsi',
            },
        ],
        terrain: {
            source: 'gsidem',
            exaggeration: 1.2,
        },
    },
});
```

`addProtocol()`で振る舞いを定義します。

```typescript
const gsidem2terrainrgb = (r, g, b): number[] => {
    let height = r * 655.36 + g * 2.56 + b * 0.01;
    if (r === 128 && g === 0 && b === 0) {
        height = 0;
    } else if (r >= 128) {
        height -= 167772.16;
    }
    height += 100000;
    height *= 10;
    const tB = (height / 256 - Math.floor(height / 256)) * 256;
    const tG =
        (Math.floor(height / 256) / 256 -
            Math.floor(Math.floor(height / 256) / 256)) *
        256;
    const tR =
        (Math.floor(Math.floor(height / 256) / 256) / 256 -
            Math.floor(Math.floor(Math.floor(height / 256) / 256) / 256)) *
        256;
    return [tR, tG, tB];
};

maplibregl.addProtocol('gsidem', (params, callback) => {
    const image = new Image();
    image.crossOrigin = '';
    image.onload = () => {
        const canvas = document.createElement('canvas');
        canvas.width = image.width;
        canvas.height = image.height;

        const context = canvas.getContext('2d');
        context.drawImage(image, 0, 0);
        const imageData = context.getImageData(
            0,
            0,
            canvas.width,
            canvas.height,
        );
        for (let i = 0; i < imageData.data.length / 4; i++) {
            const tRGB = gsidem2terrainrgb(
                imageData.data[i * 4],
                imageData.data[i * 4 + 1],
                imageData.data[i * 4 + 2],
            );
            imageData.data[i * 4] = tRGB[0];
            imageData.data[i * 4 + 1] = tRGB[1];
            imageData.data[i * 4 + 2] = tRGB[2];
        }
        context.putImageData(imageData, 0, 0);
        canvas.toBlob((blob) =>
            blob.arrayBuffer().then((arr) => callback(null, arr, null, null)), // ここで返すデータは、画像のArrayBuffer()でなければならない
        );
    };
    image.src = params.url.replace('gsidem://', '');
    return { cancel: () => {} };
});

```

すると冒頭のスクリーンショットのように3D-Terrainを表示する事ができます。

![スクリーンショット 2022-05-24 13.29.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73197/49fda6fd-51a3-2233-10c5-68f81a29cce2.png)

## 所感

- 画像の変換が入るからと言ってパフォーマンスが明らかに悪くなる、とかはありませんでした。
  - ImageDataをPNGに変換する際、上記例ではcanvasの機能を使っているが、[fast-png](https://github.com/image-js/fast-png)を使った方が速いかもしれない。
  - 標高タイルからTerrainRGBへの変換は、先行事例にあった関数の方がぱっと見で速そうに見える。
- `addProtocol()`によりタイルデータを改変出来るようになったので使い道は無限大。ちなみにこの関数はMapboxでは実装されていない。
  - `type: 'custom'`のレイヤーでタイルデータが読めるようになったらうれしいなぁ…。