【React/mapboxの地図上で3Dモデルを動かす】(4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる

# はじめに

タイトルにある通り、ReactWebアプリに描画されたmapboxの地図上で3Dモデルを動かすことをゴールに記事にしていきます。
長いので 4 つの記事に分けて書きます。

- (1) 3DモデルをThree.jsを使ってブラウザ表示する
- (2) mapboxの地図上に3Dモデルを表示する
- (3) mapboxの地図上で3Dモデルをアニメーションさせる
- (4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる

今回は **(4)mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる** です。

## 最終的な完成形

mapboxの地形付きの地図上で、3Dモデルが地形に沿って移動していきます。

![完成形.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/104fdd8e-c808-a160-a316-20be1538b152.gif)


# 環境構築

2-3回目の記事で設定したプロジェクトを使います。
[(2) mapboxの地図上に3Dモデルを表示する](https://qiita.com/itouoti/items/90550c8d529d63596d96)
[(3) mapboxの地図上で3Dモデルをアニメーションさせる](https://qiita.com/itouoti/items/5a0e077ae492bb378017)

# mapboxの地形情報

mapboxでは地形情報を提供してくれていて、それを地図に設定することによって地図を3Dマップとして表示することができます。
それを利用して地図を立体的にしてみます。
[https://www.mapbox.jp/news/gl-js-v2-launch](https://www.mapbox.jp/news/gl-js-v2-launch)

# 3D 地図の実装

`mapbox://mapbox.terrain-rgb`という URL から地形(terrain)情報を取得することができます。
更に、実際の標高より高く描画することもできます。
今回は現実よりも1.5倍高く地形を表示します。

```diff_typescript:App.tsx
import { useRef, useState, useEffect } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./App.css";
import { droneLayer } from "./droneLayer";
import { getLineInfo, makeLineSeting } from "./functions";
import * as turf from "@turf/turf";

mapboxgl.accessToken = "[Your mapbox Token.]";

const App = () => {
  const mapContainer = useRef<HTMLDivElement>(null);
  const map = useRef<mapboxgl.Map | null>(null);
  const [lng, setLng] = useState(6.5873);
  const [lat, setLat] = useState(45.3967);
  const [zoom, setZoom] = useState(13.85);
  const animationDuration = 20000; // 20秒
+  const exaggeration = 1.5;
  const elevation = 100;
  let startTime = 0;
  let path: any = undefined;
  let pathDistance = 0;
  let dronePosition: mapboxgl.LngLatLike = [6.56674, 45.39881];

  // この中でアニメーションを描画していく
  const animation = (frame: number) => {
    if (!map.current) return;

    // アニメーション開始時間を設定
    if (!startTime) {
      startTime = frame;
    }

    if (map.current.getLayer("drone-model")) {
      let droneHeight = elevation + Math.sin(elevation + frame * 0.01) * 0.5;

      const animationPhase = (frame - startTime) / animationDuration;
      if (animationPhase > 1) {
        // まだドローンが移動する時間になっていなければ上下の揺れだけ表現
        droneLayer.updateLngLat({ altitude: droneHeight });

      // geoJsonの読み込み完了してからアニメーション開始
      } else if (path && pathDistance) {
        // ルートを受け取り、線上にある指定された距離の座標を返す。
        const alongPath = turf.along(path, pathDistance * animationPhase)
          .geometry.coordinates;
        const nextDroneLngLat = {
          lng: alongPath[0],
          lat: alongPath[1],
        };
        // ドローンを動かす
        droneLayer.updateLngLat({
          latLng: nextDroneLngLat,
          altitude: droneHeight,
        });
        dronePosition = nextDroneLngLat;
      }

      // 移動したラインの位置まで赤くする
      map.current.setPaintProperty("line", "line-gradient", [
        "step",
        ["line-progress"],
        "red",
        animationPhase,
        "rgba(255, 0, 0, 0)",
      ]);

    }
    requestAnimationFrame(animation);
  };

  useEffect(() => {
    async function init() {
      // マップの初期セットアップ
      if (map.current) return;
      map.current = new mapboxgl.Map({
        container: mapContainer.current as HTMLElement,
        style: "mapbox://styles/mapbox/streets-v11",
        center: [lng, lat],
        zoom: zoom,
        pitch: 76,
        bearing: 150,
        antialias: true,
      });

      // 移動する座標データを取得する
      const [pinRouteGeojson] = await Promise.all([
        fetch(
          "https://docs.mapbox.com/mapbox-gl-js/assets/route-pin.geojson"
        ).then((response) => response.json()),
        // データ取得後、mapのloadアクションを実行する
        map.current.once("load"),
      ]);

      // ルート用のライン追加
      map.current.addSource("route-line", {
        type: "geojson",
        lineMetrics: true,
        data: pinRouteGeojson,
      });
      // ラインを描画
      map.current.addLayer(makeLineSeting("baseLine", "rgba(0,255,0,1)"));
      map.current.addLayer(makeLineSeting("line", "rgba(0,0,0,0)"));

      // ルートの距離を計算する
      const routes = pinRouteGeojson.features[0].geometry.coordinates;
      const lineInfo = getLineInfo(routes);
      path = lineInfo.path;
      pathDistance = lineInfo.pathDistance;

+      // 標高データの追加
+      map.current.addSource("mapbox-dem", {
+        type: "raster-dem",
+        url: "mapbox://mapbox.terrain-rgb",
+        tileSize: 512,
+        maxzoom: 14,
+      });
+      map.current.setTerrain({ source: "mapbox-dem", exaggeration });

      // ドローンのレイヤーを最上位に移動する
      map.current.moveLayer("drone-model");

      animation(0);
    }
    init();
  }, []);

  useEffect(() => {
    // マップ読み込み後
    if (!map.current) return;
    map.current.on("move", () => {
      if (map.current) {
        setLng(Number(map.current.getCenter().lng.toFixed(4)));
        setLat(Number(map.current.getCenter().lat.toFixed(4)));
        setZoom(Number(map.current.getZoom().toFixed(2)));
      }
    });

    map.current.on("load", () => {
      // モデルを描画する
      if (map.current) {
        if (map.current.getLayer("drone-model")) {
          return;
        }
        map.current.addLayer(droneLayer);
      }
    });
  });

  return (
    <div>
      <div style={{ position: "relative" }}>
        <div className="sidebar">
          Longitude: {lng} | Latitude: {lat} | Zoom: {zoom}
        </div>
        <div ref={mapContainer} className="map-container" />
      </div>
    </div>
  );
};

export default App;
```

地図が立体的な表示になりました。
しかし、地形情報を読み込むと3Dモデルが表示されなくなりました。

![地形データ導入.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/88da10b6-aa88-7821-e42c-56a0477b34a3.gif)


# 地形情報の読み込みでなぜ3Dモデルが表示されなくなったのか

簡単に言うと、モデルを表示する高さが足りないからです。
イメージはこの画像のような感じです。
![なぜモデルが表示されないか.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/b152dc8f-879f-148f-497f-34faff73d9c8.png)


モデルを正しく表示するためには、その座標における地形の標高をモデルのy軸方向の位置に加算してあげる必要があります。

# 3Dモデルを地形の高さに合わせて表示させる

ついでに地形情報の読み込み後から、3Dモデルのアニメーションが始まるように調整します。

```diff_typescript:App.tsx
import { useRef, useState, useEffect } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./App.css";
import { droneLayer } from "./droneLayer";
import { getLineInfo, makeLineSeting } from "./functions";
import * as turf from "@turf/turf";

mapboxgl.accessToken = "[Your mapbox Token.]";

const App = () => {
  const mapContainer = useRef<HTMLDivElement>(null);
  const map = useRef<mapboxgl.Map | null>(null);
  const [lng, setLng] = useState(6.5873);
  const [lat, setLat] = useState(45.3967);
  const [zoom, setZoom] = useState(13.85);
  const animationDuration = 20000; // 20秒
  const exaggeration = 1.5;
  const elevation = 100;
  let startTime = 0;
  let path: any = undefined;
  let pathDistance = 0;
  let dronePosition: mapboxgl.LngLatLike = [6.56674, 45.39881];

  // この中でアニメーションを描画していく
  const animation = (frame: number) => {
    if (!map.current) return;

+    // ドローンの現在位置の標高を取得
+    const terrainElevation =
+      Math.floor(
+        map.current.queryTerrainElevation(dronePosition, {
+          exaggerated: false,
+        }) || 0
+      ) * exaggeration;

-    // アニメーション開始時間を設定
-    if (!startTime) {
+    // 標高データが読み込み完了してからアニメーション開始
+    if (!startTime && terrainElevation) {
      startTime = frame;
    }

    if (map.current.getLayer("drone-model")) {
-      let droneHeight = elevation + Math.sin(elevation + frame * 0.01) * 0.5;
+      let droneHeight =
+        terrainElevation + elevation + Math.sin(elevation + frame * 0.01) * 0.5;

      const animationPhase = (frame - startTime) / animationDuration;
      if (animationPhase > 1) {
        // まだドローンが移動する時間になっていなければ上下の揺れだけ表現
        droneLayer.updateLngLat({ altitude: droneHeight });

-      // geoJsonの読み込み完了してからアニメーション開始
-      } else if (path && pathDistance) {
+      // 標高データが読み込み完了してからアニメーション開始
+      } else if (path && pathDistance && terrainElevation) {
        // ルートを受け取り、線上にある指定された距離の座標を返す。
        const alongPath = turf.along(path, pathDistance * animationPhase)
          .geometry.coordinates;
        const nextDroneLngLat = {
          lng: alongPath[0],
          lat: alongPath[1],
        };
        // ドローンを動かす
        droneLayer.updateLngLat({
          latLng: nextDroneLngLat,
          altitude: droneHeight,
        });
        dronePosition = nextDroneLngLat;
      }

      // 移動したラインの位置まで赤くする
      map.current.setPaintProperty("line", "line-gradient", [
        "step",
        ["line-progress"],
        "red",
        animationPhase,
        "rgba(255, 0, 0, 0)",
      ]);

    }
    requestAnimationFrame(animation);
  };

  useEffect(() => {
    async function init() {
      // マップの初期セットアップ
      if (map.current) return;
      map.current = new mapboxgl.Map({
        container: mapContainer.current as HTMLElement,
        style: "mapbox://styles/mapbox/streets-v11",
        center: [lng, lat],
        zoom: zoom,
        pitch: 76,
        bearing: 150,
        antialias: true,
      });

      // 移動する座標データを取得する
      const [pinRouteGeojson] = await Promise.all([
        fetch(
          "https://docs.mapbox.com/mapbox-gl-js/assets/route-pin.geojson"
        ).then((response) => response.json()),
        // データ取得後、mapのloadアクションを実行する
        map.current.once("load"),
      ]);

      // ルート用のライン追加
      map.current.addSource("route-line", {
        type: "geojson",
        lineMetrics: true,
        data: pinRouteGeojson,
      });
      // ラインを描画
      map.current.addLayer(makeLineSeting("baseLine", "rgba(0,255,0,1)"));
      map.current.addLayer(makeLineSeting("line", "rgba(0,0,0,0)"));

      // ルートの距離を計算する
      const routes = pinRouteGeojson.features[0].geometry.coordinates;
      const lineInfo = getLineInfo(routes);
      path = lineInfo.path;
      pathDistance = lineInfo.pathDistance;

      // 標高データの追加
      map.current.addSource("mapbox-dem", {
        type: "raster-dem",
        url: "mapbox://mapbox.terrain-rgb",
        tileSize: 512,
        maxzoom: 14,
      });
      map.current.setTerrain({ source: "mapbox-dem", exaggeration });

      // ドローンのレイヤーを最上位に移動する
      map.current.moveLayer("drone-model");

      animation(0);
    }
    init();
  }, []);

  useEffect(() => {
    // マップ読み込み後
    if (!map.current) return;
    map.current.on("move", () => {
      if (map.current) {
        setLng(Number(map.current.getCenter().lng.toFixed(4)));
        setLat(Number(map.current.getCenter().lat.toFixed(4)));
        setZoom(Number(map.current.getZoom().toFixed(2)));
      }
    });

    map.current.on("load", () => {
      // モデルを描画する
      if (map.current) {
        if (map.current.getLayer("drone-model")) {
          return;
        }
        map.current.addLayer(droneLayer);
      }
    });
  });

  return (
    <div>
      <div style={{ position: "relative" }}>
        <div className="sidebar">
          Longitude: {lng} | Latitude: {lat} | Zoom: {zoom}
        </div>
        <div ref={mapContainer} className="map-container" />
      </div>
    </div>
  );
};

export default App;
```

地形データに合わせて 3D モデルを表示することができました。
![完成形.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/104fdd8e-c808-a160-a316-20be1538b152.gif)

# まとめ

React+Typescriptの環境上でmapbox-glを用いてmapboxの3D地図を表示し、
3DモデルをgeoJsonの座標を元に地図上で動かすことができました。


# 参考

[公式：Query terrain elevation](https://docs.mapbox.com/mapbox-gl-js/example/query-terrain-elevation/)
[公式：3D 地図について](https://www.mapbox.jp/news/gl-js-v2-launch)

# 連載

[(1) 3DモデルをThree.jsを使ってブラウザ表示する](https://qiita.com/itouoti/items/6c3f489e3e59f6a75635)
[(2) mapboxの地図上に3Dモデルを表示する](https://qiita.com/itouoti/items/90550c8d529d63596d96)
[(3) mapboxの地図上で3Dモデルをアニメーションさせる](https://qiita.com/itouoti/items/5a0e077ae492bb378017)
(4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる