MapLibre GL JSでダムに沈んだ村を可視化する

[<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/5c826335-5048-bf96-0a8d-10bb8eb00f5b.gif">](https://satoshi7190.github.io/tokuyama-village-map/)


DEMOはこちら
https://satoshi7190.github.io/tokuyama-village-map/

ソースコードはこちら

https://github.com/satoshi7190/tokuyama-village-map

# はじめに
[MapLibre GL JS](https://maplibre.org/maplibre-gl-js/docs/)とWebGLでダムに沈んだ岐阜県徳山村を地図に可視化してみます。

## 岐阜県徳山村とは

[岐阜県徳山村（とくやまむら）](https://ja.wikipedia.org/wiki/%E5%BE%B3%E5%B1%B1%E6%9D%91_(%E5%B2%90%E9%98%9C%E7%9C%8C))は、かつて岐阜県揖斐郡に存在していた村です。しかし現在は廃村となっており、村の全域が[徳山ダム](https://ja.wikipedia.org/wiki/%E5%BE%B3%E5%B1%B1%E3%83%80%E3%83%A0)（日本最大級の多目的ダム）の建設に伴い水没しました。徳山村は、[揖斐川](https://ja.wikipedia.org/wiki/%E6%8F%96%E6%96%90%E5%B7%9D)の上流に位置する山深い地域で、周囲を山々に囲まれた自然豊かな村でした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/2ede34ab-01aa-6baf-0cae-857e49b9343a.png)
> 国土数値情報　行政区域データ 1980 年（昭和 55 年）




廃村となった徳山村の文化や歴史は、「[徳山民俗資料収蔵庫](https://www.town.ibigawa.lg.jp/kankoujyouhou/0000001329.html)」などを通じて保存・展示されています。

# 使用データ



[地理院タイル](https://maps.gsi.go.jp/development/ichiran.html)より「全国最新写真（シームレス）と「年代別写真 1974年〜1978年」のラスタータイルを使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/a01ec73d-51cc-e3e8-266e-6bfd917b9674.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/23f2a823-b519-567d-4c97-18d153321a46.png)



|データ名  |タイルURL  |
|---|---|
|全国最新写真（シームレス） |https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg |
|年代別写真　1974年〜1978年 |https://cyberjapandata.gsi.go.jp/xyz/gazo1/{z}/{x}/{y}.jpg  |

年代別写真は他にもいくつかの年代がありますが、徳山ダムに水が放流される前の徳山村が映っている空中写真は地理院タイルのなかでこれしかなかったです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/3e879327-0c1a-53cf-64df-97efc69dd8fd.png)


また、データ加工に[地理院ベクトルタイル](https://github.com/gsi-cyberjapan/gsimaps-vector-experiment)の水域のポリゴン(`waterarea`)を使用します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/ff536ae2-1f1a-83a3-4544-d547463ffcc2.png)


# 地図の描画

まずは普通に全国最新写真のタイルを描画します。
```
npm install maplibre-gl
```
```ts:main.ts
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';

// 地図の表示
const map = new maplibregl.Map({
    container: 'map',
    style: {
        version: 8,
        glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
        sources: {
            seamlessphoto: {
                type: 'raster',
                tiles: ['https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg'],

                tileSize: 256,
                maxzoom: 18,
                attribution: "<a href='https://www.gsi.go.jp/' target='_blank'>国土地理院</a>", // 地図上に表示される属性テキスト
            },
        },
        layers: [
            {
                id: 'seamlessphoto_layer',
                source: 'seamlessphoto',
                type: 'raster',
                layout: {
                    visibility: 'visible',
                },
                paint: {
                    'raster-brightness-max': [
                        // 画像の明るさ
                        'step',
                        ['zoom'],
                        0.9,
                        12.5,
                        0.7,
                    ],
                    'raster-saturation': -0.2, // 画像の彩度
                },
            },
        ],
    },
    center: [136.485452, 35.69576],
    zoom: 13,
    minZoom: 8.5,
    attributionControl: false,
    maxBounds: [135.636349, 34.892866, 138.273067, 36.754462], // 地図の表示範囲
});
```

描画しましたがダム湖しか写ってません。ここに徳山村を描画します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/8c573dd8-abc1-b78a-4c8e-0967319fdb6f.png)

# ラスタータイルをWebGLで切り抜く

この記事の本題になりますが、今回はラスタータイルを動的にクリッピングすることで、ダム湖の部分にのみ古い空中写真を描画させることで、村を復活させます。
そのために[addProtocol](https://maplibre.org/maplibre-gl-js/docs/API/functions/addProtocol/)メソッドを使用しつつ、処理の負荷を軽減させるため、WebGLと[Webワーカー](https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API)を使って動的加工処理をしていきます。


`WorkerProtocol`というクラスを作り、ここでワーカーに必要なデータ（空中写真のラスタータイル、地理院ベクトルタイル、タイルURL）を送信します。タイルURLはワーカーの送信受信時に違うタイル座標のデータを間違って処理してしまう挙動を防ぐために、idの代わりにしてます。


```ts:main.ts
// ベクトルタイルの読み込み
const loadVector = async (src: string, signal: AbortSignal): Promise<ArrayBuffer> => {
    const response = await fetch(src, { signal: signal });
    if (!response.ok) {
        throw new Error('Failed to fetch pbf');
    }
    return await response.arrayBuffer();
};

// 画像の読み込み
const loadImage = async (src: string, signal: AbortSignal): Promise<ImageBitmap> => {
    const response = await fetch(src, { signal: signal });
    if (!response.ok) {
        throw new Error('Failed to fetch image');
    }
    return await createImageBitmap(await response.blob());
};

export class WorkerProtocol {
    private worker: Worker;
    private pendingRequests: Map<
        string,
        {
            resolve: (value: { data: Uint8Array } | PromiseLike<{ data: Uint8Array }>) => void;
            reject: (reason?: any) => void;
            controller: AbortController;
        }
    >;

    constructor(worker: Worker) {
        this.worker = worker;
        this.pendingRequests = new Map();
        this.worker.addEventListener('message', this.handleMessage);
        this.worker.addEventListener('error', this.handleError);
    }

    async request(url: string, controller: AbortController): Promise<{ data: Uint8Array }> {
        try {
            const regex = /(\d+)\/(\d+)\/(\d+)\.pbf/;
            const match = url.match(regex);

            if (!match) return Promise.reject(new Error('Invalid URL'));
            const z: number = parseInt(match[1], 10);
            const x: number = parseInt(match[2], 10);
            const y: number = parseInt(match[3], 10);

            // 年代別空中写真のURL
            const imageUrl = `https://cyberjapandata.gsi.go.jp/xyz/gazo1/${z}/${x}/${y}.jpg`;

            // ラスタータイルとベクトルタイルのロード
            const [tile, image] = await Promise.all([
                loadVector(url, controller.signal), // タイルデータのロード
                loadImage(imageUrl, controller.signal), // 画像データのロード
            ]);

            return new Promise((resolve, reject) => {
                this.pendingRequests.set(url, { resolve, reject, controller });

                this.worker.postMessage({
                    tile,
                    url,
                    image,
                });
            });
        } catch (error) {
            return Promise.reject(error);
        }
    }

    private handleMessage = (e: MessageEvent) => {
        const { id, buffer, error } = e.data;
        if (error) {
            console.error(`Error processing tile ${id}:`, error);
        } else {
            const request = this.pendingRequests.get(id);
            if (request) {
                request.resolve({ data: new Uint8Array(buffer) });
                this.pendingRequests.delete(id);
            }
        }
    };

    private handleError = (e: ErrorEvent) => {
        console.error('Worker error:', e);
        this.pendingRequests.forEach((request) => {
            request.reject(new Error('Worker error occurred'));
        });
        this.pendingRequests.clear();
    };
}
```

`WorkerProtocol`クラスを呼び出してworkerを引数に渡します。またこのクラスの処理を呼び出す`customProtocol`関数も作ります。

```ts:main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' });
const workerProtocol = new WorkerProtocol(worker);

export const customProtocol = (protocolName: string) => {
    return {
        request: (params: { url: string }, abortController: AbortController) => {
            const imageUrl = params.url.replace(`${protocolName}://`, '');

            return workerProtocol.request(imageUrl, abortController);
        },
    };
};
```

worker内の処理を書きます。ラスタータイルを動的に加工するためのWebGLの初期化をします。


```ts:worker.ts
import fsSource from './shader/fragment.glsl?raw';
import vsSource from './shader/vertex.glsl?raw';

let gl: WebGL2RenderingContext | null = null;
let program: WebGLProgram | null = null;
let positionBuffer: WebGLBuffer | null = null;
let texture: WebGLTexture | null = null;

// WebGLの初期化
const initWebGL = (canvas: OffscreenCanvas) => {
    gl = canvas.getContext('webgl2');
    if (!gl) {
        throw new Error('WebGL not supported');
    }

    const loadShader = (gl: WebGL2RenderingContext, type: number, source: string): WebGLShader | null => {
        const shader = gl.createShader(type);
        if (!shader) {
            console.error('Unable to create shader');
            return null;
        }
        gl.shaderSource(shader, source);
        gl.compileShader(shader);

        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error('An error occurred compiling the shaders: ' + gl.getShaderInfoLog(shader));
            gl.deleteShader(shader);
            return null;
        }
        return shader;
    };

    const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
    const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
    if (!vertexShader || !fragmentShader) {
        throw new Error('Failed to load shaders');
    }

    program = gl.createProgram();
    if (!program) {
        throw new Error('Failed to create program');
    }
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);

    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.error('Unable to initialize the shader program: ' + gl.getProgramInfoLog(program));
        throw new Error('Failed to link program');
    }

    gl.useProgram(program);

    positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);

    const positionLocation = gl.getAttribLocation(program, 'a_position');
    gl.enableVertexAttribArray(positionLocation);
    gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);

    // テクスチャ関連の設定を追加
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

    return texture;
};

```

ワーカーの受信側の処理を書きます。受け取ったベクトルタイルはPBFなのでデコードしてWebGLの面を描き、その面のみにラスタータイルを貼り付けることでラスタータイルのクリッピングを実現してます。

この処理に必要なモジュールをインストールします。

https://github.com/mapbox/vector-tile-js

https://github.com/mapbox/pbf

https://github.com/mapbox/earcut
```
npm install @mapbox/vector-tile pbf earcut
```



```ts:maints
import Pbf from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import earcut from 'earcut';

// ベクトルタイルのデコード
const decodePBF = (arrayBuffer: ArrayBuffer) => {
    const pbf = new Pbf(arrayBuffer);
    return new VectorTile(pbf);
};

const canvas = new OffscreenCanvas(256, 256);

self.onmessage = async (e) => {
    const { url, tile, image } = e.data;

    const vectorTile = decodePBF(tile);

    // 水域データのレイヤーを取得
    const layer = vectorTile.layers['waterarea'];

    try {
        if (!gl) {
            texture = initWebGL(canvas);
        }

        if (!gl || !program || !positionBuffer) {
            throw new Error('WebGL initialization failed');
        }

        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);

        gl.clearColor(0.0, 0.0, 0.0, 0.0);
        gl.clear(gl.COLOR_BUFFER_BIT);

        if (layer) {
            for (let i = 0; i < layer.length; i++) {
                const feature = layer.feature(i);
                const geometry = feature.loadGeometry();

                for (const ring of geometry) {
                    // ポリゴンの場合は三角形に変換して描画
                    if (feature.type === 3) {
                        const flatCoords = ring.flatMap(({ x, y }) => [x, y]);
                        const triangles = earcut(flatCoords);
                        const trianglePositions = triangles.flatMap((index: number) => [(flatCoords[index * 2] / layer.extent) * 2 - 1, 1 - (flatCoords[index * 2 + 1] / layer.extent) * 2]);

                        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(trianglePositions), gl.STATIC_DRAW);
                        gl.drawArrays(gl.TRIANGLES, 0, trianglePositions.length / 2);
                    }

                    // ポイント、ラインは今回は無視
                }
            }
        }

        const blob = await canvas.convertToBlob();
        if (!blob) {
            throw new Error('Failed to convert canvas to blob');
        }
        const buffer = await blob.arrayBuffer();
        self.postMessage({ id: url, buffer });
    } catch (error) {
        if (error instanceof Error) {
            self.postMessage({ id: url, error: error.message });
        }
    }
};
```


Mapbox Vector Tile (PBF形式) の仕様はこちらに詳しく書かれてます。

https://github.com/mapbox/vector-tile-spec/tree/master/2.1



シェーダー側はそんなに多くは記述しませんが、UV座標の関係でラスタータイルが上下逆さまになるので、フラグメントシェーダー側でY軸を反転してます。

```glsl:vertex.glsl
#version 300 es
in vec2 a_position;
out vec2 v_texCoord;

void main() {
    gl_Position = vec4(a_position, 0.0, 1.0);
    v_texCoord = (a_position + 1.0) * 0.5;
}
```

```glsl:fragment.glsl
#version 300 es
precision highp float;

uniform vec4 u_color;
uniform sampler2D u_image;

in vec2 v_texCoord;
out vec4 outColor;

void main() {
    // Y座標を反転
    vec2 flippedTexCoord = vec2(v_texCoord.x, 1.0 - v_texCoord.y);
    
    outColor = texture(u_image, flippedTexCoord);
}
```

一通り処理がかけたので、addProtocolで先ほど作った`customProtocol`関数を呼び出して初期化し、ソースとレイヤーを地図に追加して描画してみます。

```ts:main.ts
import { customProtocol } from './protocol';

const protocolName = 'custom';
const protocol = customProtocol(protocolName);
maplibregl.addProtocol(protocolName, protocol.request);

// ソースとレイヤーの追加
const map = new maplibregl.Map({
    container: 'map',
    style: {
        // 省略
        sources: {
            // 省略
            custom: {
                type: 'raster',
                tiles: ['custom://https://cyberjapandata.gsi.go.jp/xyz/experimental_bvmap/{z}/{x}/{y}.pbf'],
                tileSize: 256,
                minzoom: 4,
                maxzoom: 17,
                bounds: [136.290838, 35.635082, 136.537666, 35.795209], // 徳山村の範囲のみ描画
            },
        },
        layers: [
            // 省略
            {
                id: 'custom_layer',
                source: 'custom',
                type: 'raster',
                maxzoom: 24,
                paint: {
                    'raster-saturation': -0.3, // 画像の彩度
                },
            },
        ],
    },
    // 省略
});
```


ダム湖の形に切り取られた古い空中写真が描画されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/47ecef01-6d70-c5eb-e349-0ea7f301d852.png)

これを、全国最新写真のタイルを重ねれば、現代に徳山村が復活しました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/59e72aec-12d9-e7d2-6332-dc4a36545b38.png)







# おわりに
ラスタータイルを動的にクリッピングする処理は他の用途にも使えそうです。

# おまけ・関連資料

ちょうどインターネットが発展し始めた時期と重なるため、徳山村について調べるためにネットサーフィンをしてると古めの関連サイトがいくつか見つけることができ、ダムに沈む前の徳山村の写真を見ることができます。ブログやSNSがまだ発展する前の時代なので個人ホームページに写真をあげてる事例が多いです。


- [-岐阜県旧徳山村- | shinのお部屋](http://www.shin-yas.com/furusato-tokuyama.html)
- [岐阜県　徳山村その１ -underZero-](http://underzero.net/html/tz/tz_557_1.htm)
- [徳山村・水没直前の廃村の風景](https://www.din.or.jp/~heyaneko/gh11.html)
- [地図から消えた村　徳山村 - 新規サイト001](https://www.go.tvm.ne.jp/~ame04322/page089.html#tokuyamamura)
- [『沈み行く村、校舎』岐阜県の旅行記・ブログ by melonpanさん【フォートラベル】](https://4travel.jp/travelogue/10110964)
- [旧徳山村／徳山流津軽三味線公式サイト](http://www.t-gensen.com/tokuyamamura.html)
- [徳山村地内　１](https://yosuzumex.daa.jp/industrial_heritage/hai/tokuyama/tokuyama_1.htm)
- [門入村](http://x.gmobb.jp/t-kandomon/Nxlevel2/jamp8-11.html)
- [徳山小学校](http://teamkokudo.org/haikyo/gifu/tokuyama/tokuyama.htm)