GraphHopper Directions APIでランダム周回ルートを楽しむ

この記事は [フラー株式会社 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/fuller-inc) の14日目の記事です。
13日目は @maro114510さんで、「[そこはかとなく仕事を早くする環境づくり -2024-](https://zenn.dev/kimifan/articles/my-environment-settings-2024)」でした。

# はじめに

こんにちは、フラー株式会社でエンジニア組織のマネージャーをやりつつiOSのテックリードをやっております@nirazoです。
好きなお菓子はグミです。

私夜のドライブが好きでして、ドライブしながらそれまで通ったことがない、走っていて気持ちがいい道を見つけるとすごく嬉しくなってしまうんですね。

特段目的地は決めずに大体これくらいの時間で、出発地点に帰ってこれるルートでドライブしたい、ただし同じようなルートは避けたい。そんなことってよくありますよね。あるんですよ。

# GraphHopper
Google MapsやAppleのマップのAPIだと、先述の要求を満たすのがなかなかに難しい。
そこで選択肢に上がるのがGraphHopper Directions APIです。
GraphHopperはOSSとして開発されているルーティングエンジンで、Web API経由で利用できます。

経路検索API、経路の最適化API、Matrix APIなど様々なAPIが用意されていますが、今回は経路検索APIである[Routing API](https://docs.graphhopper.com/#tag/Routing-API)についてお話しします。
他のAPIについては[公式ページ](https://www.graphhopper.com/developers/)をご覧ください。

# 料金体系
料金体系は以下の図の通りです。
![スクリーンショット 2024-12-14 14.56.06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13042/f45c24be-c4cf-3545-74d8-a6d0d8672fee.png)
（2024/12/14現在の[公式のPricingページ](https://www.graphhopper.com/pricing/)より抜粋）

今回使用するRouting APIは無料プランでも使用できますが、APIによっては有料プランが必須となります。
無料プランは500Credits/dayとなっていますが、1回の経路検索について、出発地点、目標地点と経由地点の合計地点数が2~10で1クレジット、それ以上は10ロケーション毎に1クレジットずつ消費が増えるようです（詳細は[説明ページ](https://support.graphhopper.com/support/solutions/articles/44000718211-what-is-one-credit-)をご覧ください）。
本記事では出発地点からランダムなルートを通って返ってくる、`round_trip`という経路検索を行います。
`round_trip`の場合は1回の検索で2クレジットを消費します。

なお、`optimize`パラメータを`true`にすると巡回サラリーマン問題を解くことになり、10倍のクレジットが消費されるようなので用法、用量にご注意を。

# ドライブルートのランダム生成を試してみる
では実際に試してみましょう。
GraphHopperには[API Explorer](https://explorer.graphhopper.com/)が用意されており、検索クエリの指定とマップ上での結果確認が簡単にできます。便利！

フラーの柏の葉オフィスを出発して5km程度ドライブして戻って来る、という経路検索をしてみます。
Routing APIはGET, POST両方が用意されていますがAPI ExplorerではPOSTしか使えないので、JSONを組み立ててリクエストを投げてみます。
```json: リクエストJSON
{
  "points": [[139.952404199447, 35.89523482673045]],
  "locale": "jp",
  "ch.disabled": true,
  "algorithm": "round_trip",
  "points_encoded": false,
  "round_trip.distance": 5000,
  "round_trip.seed": 0
}  
```
結果がこちらです。
![スクリーンショット 2024-12-14 15.22.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13042/a284cdd2-8c05-97c5-503d-1c0cfb2b1817.png)
API Explorerの地図上にルートが表示されました！

なお、ルートは`round_trip.seed`の値によって変わります。
これを1, 2, 3と変更すると、以下のように結果が変わります。
![スクリーンショット 2024-12-14 15.22.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13042/4c76f375-ce5e-8d5d-c00d-06f379598e27.png)
![スクリーンショット 2024-12-14 15.23.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13042/c75ce4ff-d1c0-b827-7c8b-c8c9c7dda587.png)
![スクリーンショット 2024-12-14 15.23.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13042/1a805b62-804a-3c50-6397-e97c503b894d.png)

これでランダムに、出発地に戻ってくる経路検索ができました！簡単！
ちなみにレスポンスはこのような形になっています。
```json: レスポンスJSON
{
  "hints": {
    "visited_nodes.sum": 2180,
    "visited_nodes.average": 1090
  },
  "info": {
    "copyrights": [
      "GraphHopper",
      "OpenStreetMap contributors"
    ],
    "took": 7,
    "road_data_timestamp": "2024-12-10T05:00:00Z"
  },
  "paths": [
    {
      "distance": 4833.886,
      "weight": 846.545029,
      "time": 659653,
      "transfers": 0,
      "points_encoded": false,
      "bbox": [
        139.937242,
        35.887307,
        139.954871,
        35.896675
      ],
      "points": {
        "type": "LineString",
        "coordinates": [
          [
            139.952626,
            35.895398
          ],
          [
            139.952814,
            35.895464
          ],
          [
            139.952885,
            35.895465
          ],
          ...
          [
            139.952626,
            35.895398
          ]
        ]
      },
      "instructions": [
        {
          "distance": 24.898,
          "heading": 66.61,
          "sign": 0,
          "interval": [
            0,
            2
          ],
          "text": "Continue",
          "time": 8963,
          "street_name": ""
        },
        {
          "distance": 51.358,
          "sign": -2,
          "interval": [
            2,
            4
          ],
          "text": "Turn left",
          "time": 18489,
          "street_name": ""
        },
        {
          "distance": 119.544,
          "sign": -2,
          "interval": [
            4,
            5
          ],
          "text": "Turn left",
          "time": 15370,
          "street_name": ""
        },
        ...
        {
          "distance": 0,
          "sign": 4,
          "last_heading": 246.61249418020893,
          "interval": [
            70,
            70
          ],
          "text": "Arrive at destination",
          "time": 0,
          "street_name": ""
        }
      ],
      "legs": [],
      "details": {},
      "ascend": 51.22882080078125,
      "descend": 51.22882080078125,
      "snapped_waypoints": {
        "type": "LineString",
        "coordinates": [
          [
            139.952626,
            35.895398
          ],
          [
            139.937483,
            35.887948
          ],
          [
            139.952626,
            35.895398
          ]
        ]
      }
    }
  ]
}
```

ちなみに注意点として、`points_encoded`パラメータをfalseにしないと（デフォルトはtrue）ルートが表示されないのでご注意ください。
`points_encoded`がtrueだとポリラインエンコーディングされたデータが返ってくるため、それをデコードする必要があります。

# iOSアプリでルートを表示してみる
せっかくなのでアプリ上でルートを表示できるようにしてみましょう。
今回はiOS17から使えるようになった`MapPolyline`を使って、SwiftUIの`Map`上にルートを表示します。

**1. APIを叩いて経路データを取得**
Routing APIを叩く部分は特段変わった部分が無いので説明は割愛します。
クエリパラメータの指定部分だけ掲載しておきます。
```swift
var queryParameters: [String : Any]? {
  var queryParameters: [String: Any] = [:]
  queryParameters["point"] = "\(latitude),\(longitude)"
  queryParameters["locale"] = "jp"
  queryParameters["algorithm"] = "round_trip"
  queryParameters["round_trip.distance"] = roundTripDistance
  queryParameters["round_trip.seed"] = roundTripSeed
  queryParameters["points_encoded"] = true
  queryParameters["key"] = "Your API Key"
  return queryParameters
}
```

**2. ポリライン情報をデコードしGeoJSON形式に変換**
レスポンスをこのような形で定義しておきます。ポリラインエンコードされた形式で返却されるpointsをStringで定義しておきます。

```swift: Route
struct Route: Decodable {
  let paths: [Path]
  
  struct Path: Decodable {
    let points: String
  }
}
```

Polyline形式のデコードには、その名もズバリ[Polyline](https://github.com/raphaelmor/Polyline)というライブラリを使用しました。
APIレスポンスとしている`Route`型の`route`というオブジェクトを以下のように料理するとポリライン情報のデコードが可能です。

```swift: ポリライン情報のデコード
if let point = route.paths.first?.points {
  let coordinates = Polyline(encodedPolyline: point).coordinates
  ...
}
```

デコードしたら、GeoJSONの形式に変換しましょう。
```swift: 座標配列をGeoJSON形式に変換
func coordinatesToGeoJSON(coordinates: [CLLocationCoordinate2D]) -> [String: Any] {
  let geoJSON: [String: Any] = [
    "type": "FeatureCollection",
    "features": [
      [
        "type": "Feature",
        "geometry": [
          "type": "LineString",
          "coordinates": coordinates.map { [$0.longitude, $0.latitude] }
        ],
        "properties": [
          "name": "GraphHopper Route"
        ]
      ]
    ]
  ]
  return geoJSON
}
```

**3. GeoJSONの情報をMKPolylineの配列に変換**
`[String: Any]`の形にしたGeoJSONデータを、Mapで表示できるようにMKPolylineの配列に変換します。
```swift: GeoJSONをMKPolyline配列に変換
func geoJSONToPolylines(json: [String: Any]) -> [MKPolyline] {
  do {
    let data: Data = try JSONSerialization.data(withJSONObject: json, options: [])
    let decoder = MKGeoJSONDecoder()
    let features = try decoder.decode(data)      
    return features
      .compactMap { $0 as? MKGeoJSONFeature }
      .flatMap { $0.geometry }
      .compactMap { $0 as? MKPolyline }
  } catch {
      print("GeoJSONデコードエラー: \(error.localizedDescription)")
      return []
  }
}
```

**4. Map上に経路を表示**
最後に、`MKPolyline`の配列を`MapPolyline`を使ってMap上に表示します。
`fetchRoundTripRoute`メソッドは距離（m）を引数で受取り、API経由でランダムなルートを取得して返却する自作のメソッドです。

```swift: マップ上に経路を表示
struct ContentView: View {
  @State private var polylines: [MKPolyline] = []
    
  var body: some View {
    Map {
      ForEach(polylines, id: \.self) { polyline in
        MapPolyline(polyline).stroke(.blue, lineWidth: 3)
      }
    }
    .task {
      let repository = RouteRepository(apiClient: APIClient())
      do {
        let route = try await repository.fetchRoundTripRoute(distance: 5000)
        guard let point = route.paths.first?.points,
              let coordinates = Polyline(encodedPolyline: point).coordinates else { return }
        let geoJSON = coordinatesToGeoJSON(coordinates: coordinates)
        polylines = geoJSONToPolylines(json: geoJSON)
      } catch {
          print(error)
      }
    }
  }
}
```

### 実行結果
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13042/b9dda9a7-476c-e878-33db-9545c84e8603.png" width="50%">

無事マップ上に経路が表示されました！


# おわりに
こんなに簡単にランダムな周回経路を取得できるなんて素晴らしいですね。
クレジットを増やしたり商用利用したりする場合は安くない金額がかかりますが、これだけ便利であれば納得…ということにしましょう。
これで無事、私の欲求であった「特段目的地は決めずに大体これくらいの時間で、出発地点に帰ってこれるルートでドライブしたい、ただし同じようなルートは避けたい」を満たすことができそうです。うれしいです。
厳密に言うと時間の指定はできないのですが、カーナビでそうなっているように30km/hとして、時間から大体の距離を計算して指定してあげると良いでしょう。

フラーのアドベントカレンダー2024、明日は@kanterburyさんで「Next.jsに試験的に追加されたError Boundary用API（forbidden, unauthorized）」です、お楽しみに！