Here Routing APIで取得した二輪用経路をアニメーション付きでGoogle Mapに表示する

個人開発アプリで二輪のルートを返してくれる[Here Routing API](https://www.here.com/docs/ja-JP/bundle/routing-api-developer-guide-v8/page/README.html)使ってGoogle Mapに経路を表示しました。
# 完成品
<img width="320" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95489/23af3b3d-e4c5-12b2-3bbd-6555608f959e.gif">

# 注意事項
- Google MapとHere APIでは地図データやアルゴリズムの違いから経路が完全に一致していない可能性があります。

# Here Routing APIのリクエスト
[色々なパラメータ](https://www.here.com/docs/ja-JP/bundle/routing-api-v8-api-reference/page/index.html)がありますが、必須パラメータは以下３つです。
- `transportationMode` : `car` / `truck` / `pedestrian` / `bicycle` /  `scooter` `taxi` / `bus` / `privateBus`
- `origin`: 出発地の緯度経度
- `destination`:到着地の緯度経度

経路情報を取得するために`return`パラメータで`polyline`を設定します。
- `return`: `polyline` 

今回は上記に加えて、[Scooter Routing](https://www.here.com/learn/blog/scooter-routing-with-here-routing-api)を参考に二輪経路用に以下のパラメータを追加しました。

- `vechicle`
  - `speedCap` -> 最高速度(m/s)
  - `engineSizeCc` -> 排気量

※Google Routes APIですら二輪用ルートは日本対応してないのでHere APIの経路がどれだけ正確なのかは分からないです。

### 最終的なリクエスト
```kotlin
  suspend fun searchRoute(start: LatLng, end: LatLng): List<Route> =
    withContext(dispatcherProvider.io()) {
      client.get("https://router.hereapi.com/v8/routes") {
        parameter("apiKey", BuildConfig.HERE_API_KEY)
        parameter("transportMode", "scooter")
        parameter("origin", "${start.latitude},${start.longitude}")
        parameter("destination", "${end.latitude},${end.longitude}")
        parameter("vehicle[speedCap]", "27.78")
        parameter("vehicle[engineSizeCc]", "399")
        parameter("return", "polyline")
        parameter("lang", "ja")
      }.body<Top>().routes
    }
```

## レスポンス
```json
{
  "routes": [
    {
      "id": "7c71f586-38c3-4fb3-b626-e02cbf7be8c5",
      "sections": [
        {
          "id": "87e57035-b27c-4a76-a85a-59443bf75e63",
          "type": "vehicle",
          "departure": {
            "time": "2024-10-13T16:18:33+09:00",
            "place": {
              "type": "place",
              "location": {
                "lat": 35.6920143,
                "lng": 139.7548055
              },
              "originalLocation": {
                "lat": 35.685175,
                "lng": 139.7527999
              }
            }
          },
          "arrival": {
            "time": "2024-10-13T16:53:23+09:00",
            "place": {
              "type": "place",
              "location": {
                "lat": 35.8071864,
                "lng": 139.6794498
              },
              "originalLocation": {
                "lat": 35.8072291,
                "lng": 139.6793604
              }
            }
          },
          "polyline": "BG8-uikCqz-xqI1M4KtP8CrP-CzK8CxK6CzJ6E1P4HzJ6EmEoL4GyS4G0SoCmJH0OnB4P_B8N_CsU_CsU9CuU_CsU_B8NtBwJNgDqJ0BqJ0BwHqB0EaiJsBsU2BkMD0XSsKlCqhBhHsKnC2OhD0QvD2QvD0OhD-TvE0I5ByI7BiI9B2QpD8GvB4GjByI3ByI5BiJjCiJhCkJ9BmJ9ByH1B2O3DkPzEqLnGqLnG6KxG6KzGqD7ByK9F0D9B8JzFqNxHmL9FwJxF8HrEwN5H0I7E0I9EyJrF0JrFiKzFiKzF8L3G6L3Gqb5NkI9DkI7DiPnHiLtFwGxG4OxF2OzF0EtBgJpCyDPuL0C0BM0HsDoI0C4Fe-IHoKpBmX1GsJlDqQvFqQtFoQvFqQvFsJlD0J7C6gB9JsQ9E0J9CqJ1CyKhDwQ3EuQ3EuQ3EuQ3EuQ3E0KhDkDdkG5BqBLuOnE8gBzJwOnE0H1CmJ5CuQ9EmJ5C0P3EsQ_E2P3EqFzB-J9CgK9CsItCsIvCyN_D6M5D6M7D2OpD0Q5D2OrDkOzCoDZ6LpC6LpC4c1F-EhB2QjD2QhDoK5B4Q7C2Q7CoK3BohB7FqNrCoNtCmL5BmL7BkMjC2Q_CkMlCmK5BuhB7F4Q_CoK5BmDR-OzCgP1C6IxB2QhD4Q_C4IxBuJtDsJrDuLlGuN5H0H7HmJnJiNlNgNjNoJpJyMxM0MzMmMxNmMvNoJtJinB1nBqJtJkI5I0M3NkI5IyG3GoFtFsJjJmN9MoN9MqJjJ0KpJqO7JsO7JsK1GgKrG0JjF2JjF8JnEiH_CgU3FoOnC4Q1CoOpC2JvByO9BmLvBmLxBwL_C-N7GuGzEuFpFuIvLoLtPuIvL0J_MqLpPsLrP0J_MoW1XiR1R8G_GgNpN6G_GyXtZ6I5IwN5O2TvUyJ_JkI3HiI3HyM1L0M1LuFjG6JhLuHnJ8L1OwHnJiGnHgD3DgIjKgIlKsL9N2GnI2GpIgBpB0G1H0G3H0BjCyLnN8M3PqJxLwE_EoJhL4J7K6J7KoHzIoH1IoV5U2K9FmHrG2NnMkHrGmL_JgNnMoCnC4JhJ2J_IoDnDgKjK4MrN8E_E8G7Gmana6G7GyI1H0NnMyIzHiHtGoMjMqMjM8B1H-LpLuL5KiN9LgN9LiLrKyKhK2DtDmG5F2GrGiIzH8KnKuM5LuM3L4IrI4IpIuJtIiL1KkL1KgGhGsClCqDnD4C1C6JnJsNzMoX9V4IhI2B1B8D3DgGxFoI_HoN9MoI_HgSxR-LxLqN7MoN5MgMxLiJjIgJhI2IjI0IlI8L9KsL_LgGtIiGvIsFlI8GlK6GjK6JtP6JrP6HnMuKnQwKpQ6HlMmGnJkGnJiHlLoKtQgHlLqFlIqFlIsJtPuJvPyFjJuJzPuGxKkFnJwGrLuGpLkL5SCB8BtD4BhD8B3DkChFABuDnJABmE3OABoCjLmB1ImC7TAA6CjayDvmBiB7KiCzUiB7KkFtxBQ1E0B9SyB7SsB_LqBhMqE1tBqG9iCoB5M2B5MwC7SyC7SwCnS6CtU6CtUwCnS8D_agDzM0FvQyD9HoGzM0DzFiCpDsH1KgMpOkMtOgMpOoJ5KoMnOoMpOoMnOoJ5K-GjIuKhMwKhMoCvDiRvWyD9EqC7C6CjDuC9CwDtDyEtD2DpCkF5C4OC4OEiBFgEVmGnB8NjDiO3EiO3EgMpEiMpEgPxFgPvFuM3EuM1EwO1FiQpGkQrGiQpGwO1FyR5G-PjGwJ3DuChBmMtEmMtEiNlFiLlEiLlEiF_BqM1EqU_HiEvBwGxC-M_E2E1B8VpIuFhCmH3CqarK-IrDoGvC0GzCub7K0ItDoCZmFnC4DtB2U5HiQhGyDrByHlDgGpCidvLmX1J2UlIwJxDuJxDgelLwLlEyIrDkQnGyIrDoI9CyJtDoQ3FyJtD4Q1FqKvDqIjD8MpE8MpE4I5CoFzB2OvE2H7CsFlC0GvC-ClB4BXqCf8CrBkEhCwG5BwCvB6DzC0S1MsLtIiMzIiMzIkJzG2MnJiEnDkI_F8H7FsG5E8E5DiI1FiI3FqE1DiT7QqHzGqHzG8DtD4H_G-FjFwH3GwH5GqM9K8CxCiNrLkNrLmLzK2arZmLzKiHjHiNlNiHjHoJ9IqJ7I2J_J-MpN2J9J0L1MmG1GwG_G0L1MkVlXsL1O2L_OuL1OiHlJiHjJuL1MyM7NsLzMqH5H4MxNoH5HwL5KuNxMyL7KqClCiI_HkNjNiI_HyLtM2M1NuZpbwLtMuM_MuM9M4ItJ0M3N0C5CiK9K2ItJkLvLkLxL8P9P6GjH6L1L4L1LsDnDoH9GsN3MqH9GiK7J8MrM8MpMkL3KqN7MkL5KoJ5IqN3MoJ5IqF3J6JrJqN3MqN3M6JrJiJ7IkJ9I6L_KwNxMuNxM6L_KsH_GwGpG2LxIiEvC4CxB8CtBiLlFyf3OiLlFiHXgJjE6EnC8PnH6PnH6NpGkFlCqYrJ4NpF6NpFwLpDyLnD8II0FgBkEY2QiD2J6B0OyCyOwC6NsCwL8B4Q4C4Q6CcE6P0CwL8BwDUgQ-CgQ-CyBK4NqC6HqB-FgB6GY4NwB4NyBsMoB8LmB8Q2B6Q4B8LmBmJtD-MqB-MqBoNkBoNkB6OW2HEgKV-JV0OhCwQvDwQvDuP9D6FvBuNtDyQlEihBrIyQnEyQlEyQlEihBtIyQlEihBtIyQlEwNtD6O7DwQpEsflI0PxD6MzC4MzC-MlC0O3B0O3BqGLqMNsMLiNL8QPiNL-LX8LVoIbia3DsJ3B4QlD2QjDsJ5BuPhDsPhDqGe-M5C0QzD8M3CoGvBiQxD0Q3DiQxD8CRhFhlBnB3K5BnN7CtU5BnNzBzTHjOEzOkC9N6CvLgF1PuCvG8E1L8EzLxOpKzM_I",
          "transport": {
            "mode": "scooter"
          }
        }
      ]
    }
  ]
}
```

# Polylineのデコード
上記で返却されたPolylineは経路の緯度経度ペアの配列です。そのまま返すと要素数が多いため、エンコードされて返ってくるので[flexible-polyline](https://github.com/heremaps/flexible-polyline)を使ってデコードします。
```kotlin
fun Section.toPolyline(): List<LatLng> {
    return PolylineEncoderDecoder.decode(polyline).map { LatLng(it.lat, it.lng) }
}
```

# 経路の表示
上記でデコードした配列を`Map Compose`の[Polyline](https://googlemaps.github.io/android-maps-compose/maps-compose/com.google.maps.android.compose/-polyline.html)の`points`に渡せば単純な経路は表示されます。
```kotlin
Polyline(
  color = Color.Red,
  points = routeLineState.routeLine,
)
```

# 経路のアニメーション
どうやるか悩んだのですが、最終的にPolylineを２個重ねて、上のPolylineをアニメーションさせる形にしました。

- `animatedProgress`: 0f-1fのアニメーション進捗オフセット
- `animatedRouteLine`: `animatedProgress`に応じてアニメーションさせるpointの配列を更新
- `fadeAlpha`: 赤いラインがパッと白に切り替わらないようにアニメーション終了時にフェードさせる 

```kotlin
@Composable
fun PreviewRoute(routeLine: List<LatLng>) {
  val infiniteTransition = rememberInfiniteTransition(label = "") // 繰り返しアニメーション
  val animatedProgress by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
      animation = keyframes {
        durationMillis = 4000
        0f at 0
        0f at 1000 // 開始
        1f at 3000 // 2秒かけて線を描画
        1f at 4000 // 1秒間停止
      },
      repeatMode = RepeatMode.Restart
    ),
    label = "progress"
  ) // アニメーション進捗オフセット

  val fadeAlpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
      animation = keyframes {
        durationMillis = 4000
        1f at 0
        1f at 3800
        0f at 4000
      },
      repeatMode = RepeatMode.Restart
    ),
    label = "fade"
  ) // animationProgressが0fに戻る時にフェードで切り替わるようにする

  val animatedRouteLine by remember(routeLine, animatedProgress) {
    mutableStateOf(routeLine.take((routeLine.size * animatedProgress).toInt()))
  } // アニメーションさせるポイント

  Polyline(
    zIndex = 2f,
    color = Color.White,
    points = routeLine,
    width = 5f
  )
  Polyline(
    zIndex = 3f,
    color = Color.Red.copy(alpha = fadeAlpha),
    points = animatedRouteLine,
    width = 6f
  )
}
```
以上です！

# 補足
`Polyline`の`span`を使えばグラデーションのアニメーションとかも細かく制御できます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95489/83b251cb-dcd2-9d14-6065-891810205bb2.gif" width=320 />



```kotlin
@Composable
fun RouteLine(routeLineState: PreviewRouteLineState) {
  val startColor = Color.Red
  val endColor = Color.Blue
  val segments = 100 //適宜調整

  val infiniteTransition = rememberInfiniteTransition(label = "")
  val animationOffset by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
      animation = tween(durationMillis = 2000, easing = LinearEasing),
      repeatMode = RepeatMode.Restart
    ), label = ""
  )

  val spans by remember(animationOffset) {
    derivedStateOf {
      buildList {
        for (i in 0 until segments) {
          // 各セグメントの位置を0-1の範囲に正規化し、animationOffsetで周期的に変化させる
          val fraction = (i.toFloat() / segments + animationOffset) % 1f
          // abs(fraction * 2 - 1)で0->1->0のパターンを作り、
          // startColorとendColorの間を周期的に補間する
          val color = lerp(startColor, endColor, abs(fraction * 2 - 1))
          add(StyleSpan(color.toArgb(), routeLineState.routeLine.size.toDouble() / segments))
        }
      }
    }
  }
  Polyline(
    spans = spans,
    points = routeLineState.routeLine,
    width = 6f
  )
}

```


# 参考
- [Maps SDK for Android の高度なポリラインを発表](https://developers-jp.googleblog.com/2022/10/advanced-polylines-maps-sdks-android.html)
- [Compose でのアニメーションのクイックガイド](https://developer.android.com/develop/ui/compose/animation/quick-guide?hl=ja)
- [価値ベースのアニメーション](https://developer.android.com/develop/ui/compose/animation/value-based?hl=ja)