🗺️

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

2024/10/13に公開

個人開発アプリで二輪のルートを返してくれるHere Routing API使ってGoogle Mapに経路を表示しました。

完成品

注意事項

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

Here Routing APIのリクエスト

色々なパラメータがありますが、必須パラメータは以下3つです。

  • transportationMode : car / truck / pedestrian / bicycle / scooter taxi / bus / privateBus
  • origin: 出発地の緯度経度
  • destination:到着地の緯度経度

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

  • return: polyline

今回は上記に加えて、Scooter Routingを参考に二輪経路用に以下のパラメータを追加しました。

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

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

最終的なリクエスト(Ktor)

  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
    }

レスポンス

{
  "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を使ってデコードします。

fun Section.toPolyline(): List<LatLng> {
    return PolylineEncoderDecoder.decode(polyline).map { LatLng(it.lat, it.lng) }
}

経路の表示

上記でデコードした配列をMap ComposePolylinepointsに渡せば単純な経路は表示されます。

Polyline(
  color = Color.Red,
  points = routeLineState.routeLine,
)

経路のアニメーション

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

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

以上です!

補足

Polylinespanを使えばグラデーションのアニメーションとかも細かく制御できます。

@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
  )
}

参考

Discussion