【React/mapboxの地図上で3D モデルを動かす】(3) mapboxの地図上で3Dモデルをアニメーションさせる

# はじめに

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

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

今回は **(3)mapboxの地図上で3Dモデルをアニメーションさせる**です。

## 最終的な完成形

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

![完成形.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/99f7082b-237e-bd18-c708-a74d22111c5c.gif)


# 環境構築

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

# 3Dモデルが移動するためのルート（座標群）について

こちらの公式サンプルのgeoJsonのルート座標を使います。
[https://docs.mapbox.com/mapbox-gl-js/example/query-terrain-elevation/](https://docs.mapbox.com/mapbox-gl-js/example/query-terrain-elevation/)

取得するgeoJsonのURL
[https://docs.mapbox.com/mapbox-gl-js/assets/route-pin.geojson](https://docs.mapbox.com/mapbox-gl-js/assets/route-pin.geojson)

# mapbox上で3Dモデルを移動させる実装

3D モデルの座標移動については、既に2回目の記事で`updateLngLat`という関数を作って実装することができているので、
geoJsonのデータが取得できたら座標に合わせてこの関数を叩いていくことになります。

## package install

[turf](https://turfjs.org/docs)というmapboxが作ったgeoJsonから空間解析をするためのライブラリを使います

```bash
yarn add @turf/turf
yarn add -D @types/turf
```

## functions.ts の作成

App.tsxに全部記載するとコードが長くなりそうだったので、少しでも短くするために`functions.ts`というファイルを新たに作りました。

```ts
import mapboxgl from "mapbox-gl";
import * as turf from "@turf/turf";

export const getLineInfo = (routes: number[][]) => {
  // turfで用いるインスタンスを生成する
  const path = turf.lineString(routes);
  // 線を取り、その長さを指定された単位で測定する
  const pathDistance = turf.lineDistance(path);
  return {
    path,
    pathDistance,
  };
};
```

## drone の初期位置を変更

座標の位置を変更 + モデルを少し大きくする。(大きさは好みです。)

```diff_typescript:droneLayer.ts

///省略///

// モデルが配置されるmap上の座標
- let modelOrigin: mapboxgl.LngLatLike = [148.9819, -35.39847];
+ let modelOrigin: mapboxgl.LngLatLike = [6.56674, 45.39881];
let modelAltitude = 0;
let modelRotate = [Math.PI / 2, 0, 0];

    ///省略///

    // オブジェクトの設定をしていく
    const fbxLoader = new FBXLoader();
    fbxLoader.setResourcePath("/models/Drone_Costum/Teturizer/");
    fbxLoader.load("/models/Drone_Costum/Material/drone_costum.fbx", (obj) => {
      // モデルが大きすぎるので縮小
-      obj.scale.set(0.025, 0.025, 0.025);
+      obj.scale.set(0.15, 0.15, 0.15);
      scene.add(obj);
    });

    layermap = map;

    ///省略///

```

## geoJson を取得

初期セットアップ・・・fetchするため、非同期での実行に変更する
地図の表示される座標も変更しておく

```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 } 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(148.9819);
-  const [lat, setLat] = useState(-35.3981);
-  const [zoom, setZoom] = useState(18);
+  const [lng, setLng] = useState(6.5873);
+  const [lat, setLat] = useState(45.3967);
+  const [zoom, setZoom] = useState(13.85);
  const elevation = 20;
+  let path: any = undefined;
+  let pathDistance = 0;

  // この中でアニメーションを描画していく
  const animation = (frame: number) => {
    if (!map.current) return;
    if (map.current.getLayer("drone-model")) {
      let droneHeight = elevation + (Math.sin(elevation  + frame * 0.01) * 0.5);

      droneLayer.updateLngLat({ altitude: droneHeight });
    }
    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: 60,
-          bearing: -18.6,
+          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"),
+        ]);

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

        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;

```

描画する座標を変更したので地形が変わりました。
画面上ではまだわかりませんが geoJson の取得もされています。
気になる方は`console.log()`などで取得した geoJson を確認してみてください。

![fetch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/8adbb1bf-d776-cf3e-7c58-ff14f095039e.png)


# geoJson に合わせてドローンをアニメーションさせる

`animation`関数の中で取得した geoJson を元に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 } 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 elevation = 20;
+  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;
+      }

-      droneLayer.updateLngLat({ altitude: droneHeight });
    }
    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"),
      ]);

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

      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;
```

取得した geoJson に沿って3Dモデルを移動させることができました。

![モデル移動.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/2b2c2a0e-868a-9a38-c930-19ca05f940d2.gif)


## 移動ルートがわかるようにラインを引く

現状だとドローンがどのようなルートで移動しているのか分かりにくいので、地図上にラインを引いて分かりやすくします。

### functions.ts に関数を追加

ライン用の Layer 設定を追加します。

```ts
export const makeLineSeting = (
  id: string,
  lineColor: string
): mapboxgl.AnyLayer => ({
  type: "line",
  source: "route-line",
  id: id,
  paint: {
    "line-color": lineColor,
    "line-width": 5,
  },
  layout: {
    "line-cap": "round",
    "line-join": "round",
  },
});
```

### App.tsx にラインを追加

見にくかったので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 } from "./functions";
+ 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 elevation = 20;
+  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.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;
```

ルートが緑のラインで表示され、ドローンが移動してきた部分を赤のラインで表現することができました。

![step3完成形.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/9320affd-f4a9-dfde-0f34-413fd3c7c432.gif)


# まとめ

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

次回は地図を3D表示にし、その上で3Dモデルを移動させてみます。

# 参考

[turf](https://turfjs.org/docs)
[公式：Query terrain elevation](https://docs.mapbox.com/mapbox-gl-js/example/query-terrain-elevation/)

# 連載

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