TypeScript + Mapbox-GL-JS⑤ 軌跡の可視化 - アニメーション編

シリーズ一覧 (更新:2023/2/9)
[① 環境構築 ~ 地図の表示](https://qiita.com/onk/items/864bf80d66afda9fb57e)
[② 地図のスタイルの変更](https://qiita.com/onk/items/362876cbce29dc2fdaf0)
[③ 円のプロット・ラベルの表示](https://qiita.com/onk/items/ba0cb9e79fd481ea0576)
[④ 軌跡の可視化 - 基本編](https://qiita.com/onk/items/7c528bf4d7791049666b)
[⑤ 軌跡の可視化 - アニメーション編](https://qiita.com/onk/items/e37232f52ea4d2244856)
[⑥ 軌跡の可視化 - Space Time Cube編](https://qiita.com/onk/items/77687712aec730e40046)

# 概要
前回は時系列位置データを軌跡として可視化する方法を紹介しました。
今回は時刻の表現のためにアニメーションを導入した軌跡を表示する方法を紹介します。

![02c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2902914/c1efa080-2cb4-5982-40df-53c8738d8442.gif)

# 扱うデータ
前々回と同じです。

https://qiita.com/onk/items/ba0cb9e79fd481ea0576#%E6%89%B1%E3%81%86%E3%83%87%E3%83%BC%E3%82%BF

# 軌跡のアニメーション
まず、データの持つ開始時刻から任意の時刻までの軌跡を描画する処理を作成します。
そして、任意の時刻をデータの持つ開始時刻から終了時刻まで少しずつ変化させる処理によってアニメーションを行います。

## 任意の時刻までの軌跡を描画する
任意の現在時刻を $\text{crntDate}$、データの持つ開始時刻を $\text{firstDate}$、終了時刻を $\text{lastDate}$という変数で与える。 このとき$\text{firstDate}\leq\text{crntDate}\leq\text{lastDate}$ を満たす。

$\text{crntDate}$が変化するとき、軌跡を描画する処理パターンは以下の2つのケースに分けることができる。
![スクリーンショット 2023-02-08 16.07.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2902914/8ec9bcdd-d79a-599f-3a52-fe40bcd3ea24.png)

### Case1.
この場合は単純に座標を格納すれば良い。
```ts
let positions: number[][] = [];

const date0: number = new Date(data[i].date).valueOf();
const date1: number = new Date(data[i + 1].date).valueOf();

if (firstDate <= date0 && date1 < crntDate) {
    positions.push([d0.lng, d0.lat]);
}
```

### Case2.
この場合はi番目とi+1番目の時刻の間における、$\text{crntDate}$ の割合を求め、そこから座標を求める必要がある。

```ts
let positions: number[][] = [];

const date0: number = new Date(data[i].date).valueOf();
const date1: number = new Date(data[i + 1].date).valueOf();

if (date0 <= crntDate && crntDate <= date1) {
    const ratio: number = (crntDate - date0) / (date1 - date0);
    const lng: number = (d1.lng - d0.lng) * ratio + d0.lng;
    const lat: number = (d1.lat - d0.lat) * ratio + d0.lat;

    positions.push([d0.lng, d0.lat], [lng, lat]);
}
```

### 描画関数の全容
```ts
function draw(data: MyData[], crntDate: number, firstDate: number) {
    let positions: number[][] = [];
    
    for (let i = 0; i < data.length - 1; i++) {
        const d0: MyData = <MyData>data[i];
        const d1: MyData = <MyData>data[i + 1];
    
        const date0: number = new Date(d0.date).valueOf();
        const date1: number = new Date(d1.date).valueOf();
    
        if (date0 <= crntDate && crntDate <= date1) {
            const ratio: number = (crntDate - date0) / (date1 - date0);
            const lng: number = (d1.lng - d0.lng) * ratio + d0.lng;
            const lat: number = (d1.lat - d0.lat) * ratio + d0.lat;
    
            positions.push([d0.lng, d0.lat], [lng, lat]);
        }
        else if (firstDate <= date0 && date1 < crntDate) {
            positions.push([d0.lng, d0.lat]);
        }
    
    }
    const feature: Turf.Feature = Turf.lineString(positions);
    const featureCollection: any = Turf.featureCollection([feature]);
    const trajSource: mapboxgl.GeoJSONSource = <mapboxgl.GeoJSONSource>map.getSource('traj');
    trajSource.setData(featureCollection);
}
```

## アニメーション
$\text{crntDate}$ を変化させるたびに再描画することでアニメーションする。

```ts
const firstDate: number = new Date(data[0].date).valueOf();
const lastDate: number = new Date(data[data.length - 1].date).valueOf();

let crntDate: number = firstDate;
const step: number = 60 * 1000;

setInterval(() => {
    if (crntDate > lastDate) {
        crntDate = firstDate;
    }

    draw(data, crntDate); // 軌跡の描画関数

    crntDate += step;
}, 60);
```

## コードの全容
```ts
import mapboxgl from 'mapbox-gl';
import * as Turf from '@turf/turf';

mapboxgl.accessToken = /* your tokens */;

interface MyData {
    date: string, lng: number, lat: number
}

window.addEventListener('load', () => {
    const map: mapboxgl.Map = new mapboxgl.Map({
        container: 'map',
        style: 'mapbox://styles/mapbox/dark-v10',
        center: [139.7670516, 39],
        zoom: 5
    });

    map.on('load', async () => {
        map.addSource('traj', {
            type: 'geojson',
            data: {
                type: 'FeatureCollection',
                features: []
            }
        });

        map.addLayer({
            id: 'tarj',
            type: 'line',
            source: 'traj',
            layout: {
                'line-cap': 'round',
                'line-join': 'round'
            },
            paint: {
                'line-width': 2,
                'line-color': '#f1ff57',
                'line-opacity': 0.8
            }
        });


        fetch('./trajectory.json')
            .then(res => res.json())
            .then(data => {
                const $timer: HTMLElement = <HTMLElement>document.querySelector('#timer');
                const firstDate: number = new Date(data[0].date).valueOf();
                const lastDate: number = new Date(data[data.length - 1].date).valueOf();
                let crntDate: number = firstDate;
                const step: number = 60 * 1000;
                setInterval(() => {
                    if (crntDate > lastDate) {
                        crntDate = firstDate;
                    }
                    $timer.textContent = new Date(crntDate).toISOString();
                    draw(data, crntDate, firstDate);
                    crntDate += step;
                }, 60);
            });

    });

    function draw(data: MyData[], crntDate: number, firstDate: number) {
        let positions: number[][] = [];

        for (let i = 0; i < data.length - 1; i++) {
            const d0: MyData = <MyData>data[i];
            const d1: MyData = <MyData>data[i + 1];

            const date0: number = new Date(d0.date).valueOf();
            const date1: number = new Date(d1.date).valueOf();

            if (date0 <= crntDate && crntDate <= date1) {
                const ratio: number = (crntDate - date0) / (date1 - date0);
                const lng: number = (d1.lng - d0.lng) * ratio + d0.lng;
                const lat: number = (d1.lat - d0.lat) * ratio + d0.lat;

                positions.push([d0.lng, d0.lat], [lng, lat]);
            }
            else if (firstDate <= date0 && date1 <= crntDate) {
                positions.push([d0.lng, d0.lat]);
            }

        }
        const feature: Turf.Feature = Turf.lineString(positions);
        const featureCollection: any = Turf.featureCollection([feature]);
        const trajSource: mapboxgl.GeoJSONSource = <mapboxgl.GeoJSONSource>map.getSource('traj');
        trajSource.setData(featureCollection);
    }
});
```

# グラデーションの軌跡のアニメーション
```ts
function draw(data: MyData[], crntDate: number, firstDate: number) {
    const features: Turf.Feature[] = [];

    for (let i = 0; i < data.length - 1; i++) {
        const d0: MyData = <MyData>data[i];
        const d1: MyData = <MyData>data[i + 1];

        const date0: number = new Date(d0.date).valueOf();
        const date1: number = new Date(d1.date).valueOf();

        if (date0 <= crntDate && crntDate <= date1) {
            const ratio: number = (crntDate - date0) / (date1 - date0);
            const lng: number = (d1.lng - d0.lng) * ratio + d0.lng;
            const lat: number = (d1.lat - d0.lat) * ratio + d0.lat;

            const positions: number[][] = [[d0.lng, d0.lat], [lng, lat]];
            const feature: Turf.Feature = Turf.lineString(positions, { 'opacity': 1 });
            features.push(feature);
        }
        else if (firstDate <= date0 && date1 < crntDate) {
            const opacity: number = (date1 - firstDate) / (crntDate - firstDate);

            const positions: number[][] = [[d0.lng, d0.lat], [d1.lng, d1.lat]];
            const feature: Turf.Feature = Turf.lineString(positions, { 'opacity': opacity });
            features.push(feature);
        }
    }

    const featureCollection: any = Turf.featureCollection(features);
    const trajSource: mapboxgl.GeoJSONSource = <mapboxgl.GeoJSONSource>map.getSource('traj');
    trajSource.setData(featureCollection);
}
```

# HTMLとCSS
```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" href="style.css">
    <script src="main.js"></script>
    <title>Mapbox-gl-js Typescript Sample - Tomoya Onuki</title>
</head>
<body>
    <div id="map"></div>
    <div id="timer"></div>
</body>
</html>
```
```css
body {
    margin: 0;
    padding: 0;
}
#map {
    position: fixed;
    width: 100%;
    height: 100vh;
    overflow: hidden;
}

.mapboxgl-control-container {
    display: none;
}
#timer {
    width: auto;
    height: auto;
    color: #eee;
    position: fixed;
    top: 10px;
    left: 10px;
    padding: 2px 10px;
    border-radius: 5px;
    background-color: rgba(0, 0, 0, 0.3);
}
```