Three.jsで新宿駅構内図を3Dで可視化してみる

:::note info 
これは MIERUNE AdventCalendar 2023 24日目の記事です! 昨日は@northprintさんによる[SvelteKitでURLクエリパラメーターの操作をする](https://qiita.com/northprint/items/2048675428d9b7726b4c)でした。 
:::

# はじめに
この記事では新宿駅の屋内地図データを使用して、[Three.js](https://threejs.org/)で3Dによる可視化をします。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/6ba76f99-9081-f4a6-2bcb-ce893ddae54e.png)

DEMOはこちら

https://satoshi7190.github.io/Shinjuku-indoor-threejs-demo/

サンプルコードはこちら

https://github.com/satoshi7190/Shinjuku-indoor-threejs-demo

# 使用するデータ

今回は、[G空間情報センター](https://front.geospatial.jp/)で公開されている「新宿駅屋内地図オープンデータ」の統合版（ShapeFile）を使用します。

https://www.geospatial.jp/ckan/dataset/mlit-indoor-shinjuku-r2

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/626d45f1-dfea-3c36-98da-b294017a0f79.png)


データについての詳細は製品仕様書に記載されています。

https://www.geospatial.jp/ckan/dataset/mlit-indoor-shinjuku-r2/resource/0c52874d-0954-4d69-b822-78cc6f5ec6c0


この記事のように、データの加工利用には以下の出典が必要となります。
>コンテンツを編集・加工等して利用する場合は、上記出典とは別に、編集・加工等を行ったことを記載してください。なお、編集・加工した情報を、あたかも国（又は府省等）が作成したかのような態様で公表・利用してはいけません。（コンテンツを編集・加工等して利用する場合の記載例）「新宿駅周辺屋内地図データ」（国土交通省）（https://www.geospatial.jp/ckan/dataset/mlit-indoor-shinjuku-r2）を加工して作成


統合版（ShapeFile）のデータから`ShinjukuTerminal`ディレクトリに入っている以下の地物データを使用します。

## 地物データ

|ファイル名  |データ名称  |地物タイプ  |
|---|---|---|
|ShinjukuTerminal_<階層名>_Floor.shp  |階層データ  |Polygon  |
|ShinjukuTerminal_<階層名>_Space.shp |物理的な空間データ  |Polygon  |
|ShinjukuTerminal_<階層名>_Fixture.shp  |固定設置物データ  |Polygon  |

[QGIS](https://www.qgis.org/ja/site/)での表示（※半透明にしてます）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/58955e90-bc7b-7a24-ab39-2d5c30a6cae3.png)

また、`nw`ディレクトリに入っているネットワークデータも使用します。

## ネットワークデータ
|ファイル名  |データ名称  |地物タイプ  |
|---|---|---|
|Shinjuku_node.shp  |ノードデータ |Point  |
|Shinjuku_link.shp  |リンクデータ  |LineString  |


QGISでの表示
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/cf74d20c-000c-16bd-1dd1-e8fc99c8952c.png)



# データ変換


これらのデータはすべて[ShapeFile](https://ja.wikipedia.org/wiki/%E3%82%B7%E3%82%A7%E3%83%BC%E3%83%97%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB)となっています。JavaScriptで扱いやすいように
[GDAL](https://gdal.org/)コマンドでShapeFileをGeoJsonに変換します。また、座標系は[EPSG6677](https://epsg.io/6677)に変換します。


```sh
mkdir geojson

for f in *.shp; do
  ogr2ogr -f GeoJSON -t_srs EPSG:6677 "geojson/${f%.*}.geojson" $f
done
```

# Three.jsで描画する
データの変換後にThree.js上で表示させます。




## シーンの作成

シーンを作成し、カメラとコントロールを追加します。コントロールの種類はお好みで構いませんが、この記事では移動操作を[MapControls](https://threejs.org/docs/#examples/en/controls/MapControls)、ズーム操作を[TrackballControls](https://threejs.org/docs/#examples/en/controls/TrackballControls)に割り当ててます。
```sh
npm install three
```
```js:main.js
import * as THREE from 'three';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js';

const sizes = {
    width: window.innerWidth,
    height: window.innerHeight,
};

// キャンバスの作成
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);

// シーンの作成
const scene = new THREE.Scene();

// カメラ
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100000);
camera.position.set(-190, 280, -350);
scene.add(camera);

// コントロール
const mapControls = new MapControls(camera, canvas);
mapControls.enableDamping = true;
mapControls.enableZoom = false;
mapControls.maxDistance = 1000;

const zoomControls = new TrackballControls(camera, canvas);
zoomControls.noPan = true;
zoomControls.noRotate = true;
zoomControls.noZoom = false;
zoomControls.zoomSpeed = 0.5;

// レンダラー
const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    alpha: true,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// 画面リサイズ時にキャンバスもリサイズ
const onResize = () => {
    // サイズを取得
    const width = window.innerWidth;
    const height = window.innerHeight;

    // レンダラーのサイズを調整する
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(width, height);

    // カメラのアスペクト比を正す
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
};
window.addEventListener('resize', onResize);

// アニメーション
const animate = () => {
    requestAnimationFrame(animate);

    const target = mapControls.target;
    mapControls.update();
    zoomControls.target.set(target.x, target.y, target.z);
    zoomControls.update();
    renderer.render(scene, camera);
};
animate();
```
また、WebGLRendererのオプションで`alpha: true`にすることで背景を透過させ、CSSで背景にグラデーションを適用させます。

```css:style.css
canvas {
    background-image: radial-gradient(#382c6e, #000000);
}
```

シーンができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/1071a3d1-cf16-9ddb-ad8e-8ad12ed3b12f.png)

## GUIとグループの作成

シーンの中に地物データを描画させます。今回は地物を階層ごとにグループ分けしたいと思うので、シーンのにあらかじめ[Group](https://threejs.org/docs/#api/en/objects/Group)を作成しておき、地物データによって作成したオブジェクトを各階層ごとのグループに追加することにします。そして、各階層の表示切り替えができるようにチェックボックスのGUIも追加します。

```js:main.js
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';

// dat.GUIのインスタンスを作成
const gui = new GUI({ width: 150 });

// グループの作成
const groupList = [4, 3, 2, 1, 0, -1, -2, -3];
const layers = ['4F', '3F', '2F', '1F', '0', 'B1', 'B2', 'B3'];

groupList.forEach((num, i) => {
    const group = new THREE.Group();
    group.name = `group${num}`;
    scene.add(group);
    const key = `group${num}`;

    // GUIにチェックボックスを追加
    gui.add(
        {
            [key]: true,
        },
        key,
    )
        .onChange((isVisible) => {
            scene.getObjectByName(key).visible = isVisible;
        })
        .name(layers[i]);
});
```
階層ごとのチェックボックス
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/75ccacad-9d38-5221-2af0-79612f018be0.png)


## 地物データの追加

作成した階層グループごとに地物データを追加していきます。地物データは２次元のポリゴンデータなので、[ExtrudeGeometry](https://threejs.org/docs/#api/en/geometries/ExtrudeGeometry)を使用して立ち上げることで立体化します。階層情報はファイル名にありますが、高さ情報はないため、地物データの種類で高さ分けをすることにします。

また、地物データは前述の変換処理によりEPSG:6677になっていますがThree.jsのワールド座標にそのままの座標値で乗っけてしまうと、原点0地点から遠くは慣れた場所に描画されてしまうので、地物にオフセットをかける必要があります。

まずは、シーンの原点０をEPSG:6677上のどの座標点にするかを決めます。今回は`-12035.29, -34261.85(x,y)`の地点をワールド座標の原点０に合わせます。この座標は新宿駅の中心あたりになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/31f6f028-7db9-0b56-a746-5e747fc57918.png)


地物データのポリゴンの座標値からExtrudeGeometryを作成するときに、各座標点から中心とする点（[-12035.29, -34261.85]）を減算することでオフセットをかけます。これにより地物がシーンの原点０の近くに描画されます。

もう一つ注意点として、Three.js上（ワールド座標）のY、Zのベクトル方向とGIS上（地理座標）のY、Zのベクトル方向が90度違うため、ExtrudeGeometryを作成後にx軸を起点に前方向（Z軸を正面として）に90度倒す必要があります（そうしないと地物が縦向きになってしまいます）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/f0e073a2-8bc3-8646-a427-6a5574f0269f.png)


これらを踏まえた上でGeoJsonからExtrudeGeometryを作成します。

Space、Floor、Fixtureのデータを各配列に記述してループ処理をし、`getFloorNumber`という関数で階層情報を取得し、`loadAndAddToScene`という関数で、引数にGeoJsonの情報と、階層情報、高さにする数値を渡します。


```js:main.js
// Spaceの配列
const SpaceLists = [
    './ShinjukuTerminal/ShinjukuTerminal_B3_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B2_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B1_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_0_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_1_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2out_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3out_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4out_Space.geojson',
];

// Spaceの読み込み
SpaceLists.forEach((geojson) => {
    const floorNumber = getFloorNumber(geojson, 'Space');
    if (floorNumber !== null) {
        loadAndAddToScene(geojson, floorNumber, 5);
    }
});


// Spaceの配列
const FloorLists = [
    './ShinjukuTerminal/ShinjukuTerminal_B3_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B2_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B1_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_0_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_1_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2out_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3out_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4out_Floor.geojson',
];

// Floorの読み込み
FloorLists.forEach((geojson) => {
    const floorNumber = getFloorNumber(geojson, 'Floor');
    if (floorNumber !== null) {
        loadAndAddToScene(geojson, floorNumber, 0.5);
    }
});

// Fixtureの配列
const FixtureLists = [
    './ShinjukuTerminal/ShinjukuTerminal_B3_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B2_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B1_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_0_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2out_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3out_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4out_Fixture.geojson',
];

// Fixtureの読み込み
FixtureLists.forEach((geojson) => {
    const floorNumber = getFloorNumber(geojson, 'Fixture');
    if (floorNumber !== null) {
        loadAndAddToScene(geojson, floorNumber, 5);
    }
});
```
`getFloorNumber`関数は以下になります。正規表現を使ってファイル名の数字の部分を抜き出し、地下である「B」が含まれている場合は負の数を返すようにしています。


```js:main.js
// 正規表現を用いて階層番号を取得
const getFloorNumber = (geojson, type) => {
    const regex = new RegExp(`ShinjukuTerminal_([-B\\d]+)(out)?_${type}`);
    const match = geojson.match(regex);
    if (!match) return null;

    let floor = match[1].replace('B', '-');
    return parseInt(match[2] === 'out' ? floor.replace('out', '') : floor, 10);
};
```

`loadAndAddToScene`関数は以下になります。[FileLoader](https://threejs.org/docs/#api/en/loaders/FileLoader)を使用してGeoJsonを読み込み、`createExtrudedGeometry`という関数でExtrudeGeometryを生成し、ExtrudeGeometryから[EdgesGeometry](https://threejs.org/docs/#api/en/geometries/EdgesGeometry)を作成することでアウトラインの立方体ポリゴンを作成します。このとき、立方体ポリゴンは縦向きになっているので[applyMatrix4](https://threejs.org/docs/#api/en/core/Object3D.applyMatrix4)で90度回転させています。
そして、階層情報ごとにY軸のどのあたりに地物を置くかを決め、各階層のGroupに追加しています。
```js:main.js

// 階ごとに離すY軸方向の距離
const verticalOffset = 30;

// FileLoaderをインスタンス化。JSON形式でデータを取得する
const loader = new THREE.FileLoader().setResponseType('json');

// ファイルを読み込んで、シーンに追加。geometryの情報がないものは除外
const loadAndAddToScene = (geojson, floorNumber, depth) => {
    loader.load(geojson, (data) => {
        // Lineのマテリアル
        const lineMaterial = new THREE.LineBasicMaterial({ color: 'rgb(255, 255, 255)' });

        // geometryの情報がないものは除外
        data.features
            .filter((feature) => feature.geometry)
            .forEach((feature) => {
                // ExtrudeGeometryを作成
                const geometry = createExtrudedGeometry(feature.geometry.coordinates, depth);

                // 90度回転させる
                const matrix = new THREE.Matrix4().makeRotationX(Math.PI / -2);
                geometry.applyMatrix4(matrix);

                // ExtrudeGeometryからLineを作成
                const edges = new THREE.EdgesGeometry(geometry);
                const line = new THREE.LineSegments(edges, lineMaterial);
                line.position.y += floorNumber * verticalOffset - 1;

                // Groupに追加
                const group = scene.getObjectByName(`group${floorNumber}`);
                group.add(line);
            });
    });
};
```

`createExtrudedGeometry`は以下になります。前述に説明した通り、ここでGeoJsonの`coordinates`(座標点)からExtrudedGeometryの元となる[ShapeGeometry](https://threejs.org/docs/#api/en/geometries/ShapeGeometry)を作成しています。各座標点はあらかじめ中心にする地理座標を減算しています。
また、地理空間データのポリゴンデータはポリゴンの閉じるための頂点が必要なので、頂点配列の最初と最後に同じ頂点座標を持っています（四角形のポリゴンは５つの頂点を持っている）。ShapeGeometryはこの最後の頂点は不要なので、最後の頂点は処理を飛ばしています。ExtrudeGeometryのオプションの`depth`は押し出す高さを示してます。

```js:main.js
// シーンの中心にする地理座標(EPSG:6677)
const center = [-12035.29, -34261.85];

// ポリゴンからExtrudeGeometryを返す関数
const createExtrudedGeometry = (coordinates, depth) => {
    const shape = new THREE.Shape();

    // ポリゴンの座標からShapeを作成
    coordinates[0].forEach((point, index) => {
        const [x, y] = point.map((coord, idx) => coord - center[idx]);
        if (index === 0) {
            // 最初の点のみmoveTo
            shape.moveTo(x, y);
        } else if (index + 1 === coordinates[0].length) {
            // 最後の点のみclosePathで閉じる
            shape.closePath();
        } else {
            // それ以外はlineTo
            shape.lineTo(x, y);
        }
    });
    return new THREE.ExtrudeGeometry(shape, {
        steps: 1,
        depth: depth,
        bevelEnabled: false,
    });
};
```

以上のような処理を記述することで、地物データがシーンに追加されました。要塞みたいですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/c5756a7a-64f0-e6c6-ae32-6b4ca1a00f8a.png)


## ネットワークデータの追加

続いて、ネットワークデータ（歩行者ネットワーク）をシーンに追加します。前述で変換したノードデータのGeoJsonを先ほどインスタンス化したFileLoaderで読み込み、`nodeId`と`ordinal`(階層情報)を取得して配列を作成し、`creatingLink`という関数に渡します。

```js:main.js
// ノードデータからnode_idと階層（ordinal）を取得
loader.load('./nw/Shinjuku_node.geojson', (data) => {
    const nodeIds = data.features.map((feature) => {
        return {
            node_id: feature.properties.node_id,
            ordinal: feature.properties.ordinal,
        };
    });

    // 歩行者ネットワークの作成
    creatingLink(nodeIds);
});
```

`creatingLink`関数は下記になります。リンクデータを読み込んで先ほど作成した配列から、リンクデータの始点と終点の階層を取得します。これはリンクデータに階層情報が含まれていないためです（事前にQGISのテーブル結合で階層情報を付与するのも良いかもしれません）。これで、リンクデータの始点と終点の階層はわかりますが、その途中のラインの階層がどこに当てはまるかがわかりません。そこで、条件分岐を用意して始点のノードデータしか見つからない場合は始点の階高さ、終点のノードデータしか見つからない場合は終点の階の高さにリンクデータのラインを作成します。もし始点と終点両方のノードデータが見つかる場合は、始点と終点の階が同じの場合は、その階にしかリンクデータのラインが存在しないことがわかりますが、始点と終点の階が違う場合は、その中間の階層にラインを仮で引くようにしました。

また、リンクデータのラインは幅のあるラインを描画できる[MeshLine](https://github.com/spite/THREE.MeshLine)を使用します。コードの後半で、ラインを２点間の頂点を持つラインにわざわざ分割して、[BufferGeometryUtils](https://threejs.org/docs/#examples/en/utils/BufferGeometryUtils)で、マージしてからシーンに追加していますが、その理由はこの後説明します。

https://github.com/spite/THREE.MeshLine


```sh
npm install three.meshline
```

```js:main.js
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { MeshLine, MeshLineMaterial } from 'three.meshline';

// マテリアル
const linkMaterial = new MeshLineMaterial({
    transparent: true,
    lineWidth: 1,
    color: new THREE.Color('rgb(0, 255, 255)'),
});

// MeshLineの配列
const meshLines = [];

// 歩行者ネットワークの作成
const creatingLink = (nodeId) => {
    loader.load('./nw/Shinjuku_link.geojson', (data) => {
        data.features.forEach((feature) => {
            const coordinates = feature.geometry.coordinates;

            // ノードデータからstart_idとend_idの取得
            const start_id = nodeId.find((node) => node.node_id === feature.properties.start_id);
            const end_id = nodeId.find((node) => node.node_id === feature.properties.end_id);

            // 3次元のpointの配列を作成
            const points = coordinates.map((point, index) => {
                let y;

                if (!start_id && !end_id) {
                    // start_idとend_idがない場合は、0階層に配置
                    y = 0;
                } else if (start_id && !end_id) {
                    // start_idのみある場合は、start_idの階層に配置
                    y = start_id.ordinal;
                } else if (!start_id && end_id) {
                    // end_idのみある場合は、end_idの階層に配置
                    y = end_id.ordinal;
                } else {
                    // start_idとend_idがある場合
                    if (index === 0) {
                        // 最初の点の場合はstart_idの階層に配置
                        y = start_id.ordinal;
                    } else if (index === coordinates.length - 1) {
                        // 最後の点の場合はend_idの階層に配置
                        y = end_id.ordinal;
                    } else if (start_id.ordinal === end_id.ordinal) {
                        // start_idとend_idの階層が同じ場合は、その階層に配置
                        y = end_id.ordinal;
                    } else {
                        // start_idとend_idの階層が異なる場合は、中間の階層に配置
                        y = Math.round((start_id.ordinal + end_id.ordinal) / 2);
                    }
                }
                return new THREE.Vector3(point[0] - center[0], y * verticalOffset + 1, -(point[1] - center[1]));
            });

            // pointの配列からMeshLineを作成
            points.forEach((point, index) => {
                // 最後の点の場合は処理を終了
                if (index + 1 === points.length) return;

                // MeshLineを作成。2点間のMeshLineを別々に作成する
                const geometry = new THREE.BufferGeometry().setFromPoints([point, points[index + 1]]);
                const line = new MeshLine();
                line.setGeometry(geometry);

                // MeshLineの配列に追加
                const mesh = new THREE.Mesh(line, linkMaterial);
                meshLines.push(mesh.geometry);
            });
        });

        // MeshLineをマージ
        const linkGeometry = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(meshLines), linkMaterial);
        linkGeometry.name = 'link';

        // シーンに追加
        scene.add(linkGeometry);
    });
};

```

歩行者ネットワークをうまく３次元化できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/52d5ad05-ddff-d5bf-b39c-253472767ef2.png)

ですが、このままでは面白くないので、歩行者ネットワーク感？を表現するためにシェーダーでアニメーションをつけることにします。

## シェーダーを書く

リンクデータは製品仕様書によると進行方向を示す情報(`direction`)をもっているので、これに合わせて違うアニメーションを適用させることにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/2d6dacc3-c3c3-2676-c886-0a3c084a101a.png)

今回使用したデータは両方向（１）と起点より終点方向（２）の２パターンしかなかったので２パターンの簡易的なシェーダーを用意しました。

#### 「両方向」のシェーダー
![Kapture 2023-12-24 at 10.13.46.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/a91076a2-ab3b-8059-c709-f7dc4c957699.gif)

https://neort.io/art/cm3hu1sn70rgn7l2si60?index=1&origin=my_profile

#### 「起点より終点方向」のシェーダー
![Kapture 2023-12-24 at 10.19.21.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/cedac3e5-9c75-2036-5714-558726c5605f.gif)

https://neort.io/art/cm3hv74n70rgn7l2siag?index=0&origin=my_profile

MeshLineにシェーダーを適用させます。まず、先ほど定義した`MeshLineMaterial`からカラー情報を削除します。

```diff_javascript:main.js
// マテリアル
const linkMaterial = new MeshLineMaterial({
    transparent: true,
    lineWidth: 1,
-   color: new THREE.Color('rgb(0, 255, 255)'),
});

```
そして、MeshLineの既存のシェーダーを上書きするために[onBeforeCompile](https://threejs.org/docs/#api/en/materials/Material.onBeforeCompile)を使用してシェーダーを追記していきます。

こちらの記事を参考にさせていただきました。

https://qiita.com/watabo_shi/items/bfffed0462c5e9768687


```js:main.js

// Shaderを追加
linkMaterial.onBeforeCompile = (shader) => {
    // userDataにuniformsを追加
    Object.assign(shader.uniforms, linkMaterial.userData.uniforms);

    const keyword1 = 'void main() {';
    shader.vertexShader = shader.vertexShader.replace(
        keyword1,
        /* GLSL */ `
        varying vec2 vUv;
        attribute float uDistance;
        attribute float uDirection;
        varying float vDistance;
        varying float vDirection;
        ${keyword1}`,
    );

    // 置換してシェーダーに追記する
    const keyword2 = 'vUV = uv;';
    shader.vertexShader = shader.vertexShader.replace(
        keyword2,
        /* GLSL */ `
        ${keyword2}
        vUv = uv;
        vDistance = uDistance;
        vDirection = uDirection;
        `,
    );

    const keyword3 = 'void main() {';
    shader.fragmentShader = shader.fragmentShader.replace(
        keyword3,
        /* GLSL */ `
        uniform float uTime;
        varying float vDirection;
        varying float vDistance;
        varying vec2 vUv;
        ${keyword3}`,
    );
    // 置換してシェーダーに追記する
    const keyword4 = 'gl_FragColor.a *= step(vCounters, visibility);';
    shader.fragmentShader = shader.fragmentShader.replace(
        keyword4,
        /* GLSL */ `${keyword4}
        vec2 p;
        p.x = vUv.x * vDistance;
        p.y = vUv.y * 1.0 - 0.5;

        float centerDistY = p.y; // 中心からのY距離
        float offset = abs(centerDistY) * 0.5; // 斜めの角度を制御

        float time = uTime;
        // 中心より上と下で斜めの方向を変える
        if(centerDistY < 0.0) {
            if(vDirection == 1.0){
                time = -uTime;
                offset = -offset;
            }else if(vDirection == 2.0) {
                offset = offset;
            }
        }

        // mod関数と中心からのy距離に基づくオフセットを使用して線を生成
        float line = mod(p.x - time + offset, 1.9) < 0.9 ? 1.0 : 0.0;
        vec3 mainColor;

        // 方向によって色を変える
        if(vDirection == 1.0) {
            mainColor = vec3(0.0, 1.0, 1.0);
        } else if(vDirection == 2.0) {
            mainColor = vec3(1.0, 1.0, 0.0);
        }
        vec3 color = mix(mainColor, mainColor, line);

        gl_FragColor = vec4(color, line * 0.7);
        `,
    );
};
```
そして`creatingLink`関数に追記します。各MeshLineのUV座標のアスペクト比を合わせるために[distanceTo](https://threejs.org/docs/#api/en/math/Vector3.distanceTo)で分割したラインの２点間の距離を取得して`uDirection`というattribute変数をシェーダーに渡します。さらにリンクデータの方向の情報も`uDirection`というattribute変数でシェーダーに渡します。アニメーションをさせるために`uTime`というuniforms変数を追加してます。

```diff_javascript:main.js

// メッシュラインの配列
const meshLines = [];

// 歩行者ネットワークの作成
const creatingLink = (nodeId) => {

    loader.load('./nw/Shinjuku_link.geojson', (data) => {
        data.features.forEach((feature) => {

        /*　省略　*/

            // pointの配列からMeshLineを作成
            points.forEach((point, index) => {
                // 最後の点の場合は処理を終了
                if (index + 1 === points.length) return;

                // MeshLineを作成。2点間のMeshLineを別々に作成する
                const geometry = new THREE.BufferGeometry().setFromPoints([point, points[index + 1]]);
                const line = new MeshLine();
                line.setGeometry(geometry);

+               // 2点間の距離を計算
+               const distance = point.distanceTo(points[index + 1]);
+
+               // MeshLineの頂点数を取得
+               const numVerticesAfter = line.geometry.getAttribute('position').count;
+
+               // 頂点数に基づいて distances 配列を生成しsetAttributeで頂点属性を追加。UV座標のアスペクト比の計算に使用
+               const distances = new Float32Array(numVerticesAfter).fill(distance);
+               line.setAttribute('uDistance', new THREE.BufferAttribute(distances, 1));
+
+               // 頂点数に基づいて directions 配列を生成しsetAttributeで頂点属性を追加。リンクデータの方向を表す
+               const directions = new Float32Array(numVerticesAfter).fill(feature.properties.direction);
+               line.setAttribute('uDirection', new THREE.BufferAttribute(directions, 1));
+                // uniforms変数にuTime（時間）を追加。アニメーションに使用
+               Object.assign(linkMaterial.userData, {
+                   uniforms: {
+                       uTime: { value: 0 },
+                   },
+               });

                // MeshLineの配列に追加
                const mesh = new THREE.Mesh(line, linkMaterial);
                meshLines.push(mesh.geometry);
            });
        });

/*　省略　*/
```

あとはanimate関数内で`uTime`に加算する処理を記述します。
```diff_javascript:main.js
// アニメーション
const animate = () => {
    requestAnimationFrame(animate);
    /*　省略　*/

+   // 歩行者ネットワークのアニメーション
+   if (linkMaterial.uniforms.uTime) {
+       linkMaterial.uniforms.uTime.value += 0.1;
+   }

    renderer.render(scene, camera);
};
animate();
```

動きのある歩行者ネットワークができました。
![Kapture 2023-12-24 at 10.38.23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/75d57b39-c3a5-0e31-4045-3ab1904d3baa.gif)

## 地上の可視化

仕上げに地上の部分も可視化します。[基盤地図情報](https://fgd.gsi.go.jp/download/menu.php)の基本項目から新宿駅が含まれている「533945」のメッシュを選択し、地物データをダウンロードします。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/334072a9-146d-9f17-12ff-781ab7f8532c.png)


ダウンロードした地物データから「道路縁（Road Edge）」と「道路構成線（Road Component）」をGDALコマンドでGeoJsonに変換し、座標系をEPSG:6677に変換します。

```sh
ogr2ogr -f GeoJSON -t_srs EPSG:6677 FG-GML-533945-RdEdg-20231001-0001.geojson FG-GML-533945-RdEdg-20231001-0001.xml

ogr2ogr -f GeoJSON -t_srs EPSG:6677 FG-GML-533945-RdCompt-20231001-0001.geojson FG-GML-533945-RdCompt-20231001-0001.xml
```


変換後にQGISに読み込んで加工します。プロセシングツールで「ベクタレイヤをマージ」で二つのデータをマージし、「バッファ」で中心点（[-12035.29, -34261.85]）から円ポリゴンを作成します。そのあと「切り抜く」でマージした道路データに対して円ポリゴンをオーバレイレイヤとして切り抜き処理をすることで、円形の道路ラインデータを作成します。



![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/c31a5d51-89a1-7f31-e136-ca5bccb9030d.png)

この加工したデータをThree.jsで読み込み、[LINE](https://threejs.org/docs/?q=line#api/en/objects/Line)で生成してシーンに追加することで完成となります。

```js:main.js
// 基盤地図情報道路データの読み込み;
loader.load('./road.geojson', (data) => {
    const material = new THREE.LineBasicMaterial({
        color: new THREE.Color('rgb(209, 102, 255)'),
    });
    data.features.forEach((feature) => {
        const coordinates = feature.geometry.coordinates;
        const points = coordinates[0].map((point) => {
            return new THREE.Vector3(point[0] - center[0], point[1] - center[1], 0);
        });

        const geometry = new THREE.BufferGeometry().setFromPoints(points);
        const matrix = new THREE.Matrix4().makeRotationX(Math.PI / -2);
        geometry.applyMatrix4(matrix);

        const line = new THREE.Line(geometry, material);
        scene.getObjectByName(`group0`).add(line);
    });
});
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/6bb2a293-3f4f-ad4c-f3d1-c063a8745fe8.png)

地上面を追加したことにより、歩行者ネットワークがより見やすくなったかと思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1575765/47ade728-7d59-a0b8-b4c4-6e38c2942fd2.png)




# おわりに
今回は２次元のGISデータを無理やり３次元化したものなので、高さ情報の正確性がないのが難点でした。正確性をもとめた３次元の可視化は[PLATEAU](https://www.mlit.go.jp/plateau/)のような３次元モデルのデータの使用を推奨します。

G空間情報センターの[「3D都市モデル（Project PLATEAU）新宿区（2023年度）」](https://www.geospatial.jp/ckan/dataset/plateau-13104-shinjuku-ku-2023)のデータの中に地下街モデルが公開されているようです。

このデータを可視化した記事はこちらになります。

https://qiita.com/satoshi7190/items/67148db8b3149e73c4b0

:::note info 
明日は@Kanahiroさんによる記事です！お楽しみに！！ 
:::