【Google Maps API】Pythonで複数目的地訪問時の最適経路提案してみた【Tkinter】

## 目次
[0.結論](#0-結論)
[1.はじめに](#1-はじめに)
[2.スクリプトの説明](#2-スクリプトの説明)
[2-1.ライブラリのインポート](#2-1-ライブラリのインポート)
[2-2.RoutePlannerクラス](#2-2-RoutePlannerクラス)
[2-3.経路検索の処理 (search_routeメソッド)](#2-3-経路検索の処理 (search_routeメソッド))
[3.GUIの説明](#3-GUIの説明)
[4.おわりに](#4-おわりに)

### 0. 結論
Python（Google Maps API）を用い、目的の訪問先を巡回する際の最短時間となる経路の提案可能。
検索の際にはGUIにて出発地や訪問先を入力可能。
Google Maps API取得し、本内容をコピペすることで誰でも活用可能。


▼作成したスクリプト
```Python:
import googlemaps
from math import dist
import tkinter as tk
from tkinter import ttk #プログレスバー表示用
import webbrowser

class RoutePlanner(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title('経路提案アプリ')

        # Google Maps APIのキー
        self.gmaps = googlemaps.Client(key='YOUR_API_KEY')

        # 処理用のグローバル変数
        self.row_count = 0

        # 出発地入力欄
        self.start_label = tk.Label(self, text='出発&帰着地:')
        self.start_label.pack()
        self.start_entry = tk.Entry(self)
        self.start_entry.pack()

        # 訪問先追加ボタン
        self.add_button = tk.Button(self, text='訪問先を追加', command=self.add_dest_field)
        self.add_button.pack()

        # 訪問先入力欄のフレーム
        self.dest_label = tk.Label(self, text='訪問先:')
        self.dest_label.pack()
        self.dest_frame = tk.Frame(self)
        self.dest_frame.pack()

        # 初期状態の訪問先入力欄
        self.add_dest_field()

        # 検索ボタン
        self.search_button = tk.Button(self, text='検索', command=self.search_route)
        self.search_button.pack()

        # 地図表示ボタン（検索ボタンクリック後に登場する）
        self.button_frame = tk.Frame(self)
        self.button_frame.pack()

        # 結果表示エリア
        self.result_text = tk.Text(self, height=30, width=80)
        self.result_text.pack()

        # クリアボタン
        self.clear_button = tk.Button(self, text='クリア', command=self.clear_form)
        self.clear_button.pack()

    def clear_form(self):
        self.start_entry.delete(0, tk.END)
        # 訪問先入力欄を全て削除
        for child in self.dest_frame.winfo_children():
            child.destroy()
        # 初期状態の訪問先入力欄を1つ作成
        self.add_dest_field()
        self.result_text.delete(1.0, tk.END)

    def add_dest_field(self):
        self.row_count += 1
        # 新しい訪問先入力欄を作成
        new_dest_entry = tk.Entry(self.dest_frame)
        new_dest_entry.grid(row=self.row_count, column=0, sticky='w')
        # 削除ボタンを作成
        delete_button = tk.Button(self.dest_frame, text='削除', command=lambda: self.delete_dest_field(new_dest_entry,delete_button))
        delete_button.grid(row=self.row_count, column=1, sticky='e')

    def delete_dest_field(self, entry, delete_button):
        entry.destroy()  # 指定された入力欄を削除
        delete_button.destroy()  # 指定された入力欄を削除
        self.row_count -= 1

    def search_route(self):
        # 入力された出発地と訪問先を取得
        start = self.start_entry.get()
        entrys = [child for child in self.dest_frame.winfo_children() if isinstance(child, tk.Entry)]
        destinations = [entry.get() for entry in entrys]
        destinations = [a for a in destinations if a != ''] #訪問先欄に空欄入力されても動く用

        '''
        #プログレスバーの実行
        progress_bar = ttk.Progressbar(self, orient=tk.HORIZONTAL, length=100, mode='indeterminate')
        progress_bar.pack()
        progress_bar.start()
        '''

        # 緯度経度の取得
        geo_destinations = destinations.copy()
        geo_destinations.append(start)
        geo = []
        for geo_destination in geo_destinations:
            result = self.gmaps.geocode(geo_destination)
            try:
                geo.append(result[0]['geometry']['location'])
            except IndexError:
                self.result_text.insert(tk.END, '住所が見つからない訪問先があります')
            except googlemaps.exceptions.HTTPError:
                self.result_text.insert(tk.END, '空欄の訪問先欄を削除してください')
        d_geo_destinations = dict(zip(geo_destinations, geo))

        # 経路検索の実行
        directions_result = self.gmaps.directions(
            origin=start,
            destination=start,
            waypoints=destinations,
            optimize_waypoints=True
        )

        # 訪問先名称を入力した値に補正（緯度経度からユークリッド距離を計算して一番近い名称を取得）
        # 出力はlocations = [出発地、訪問先1、訪問先2、...、着着地]となる
        locations = [start]
        for leg in directions_result[0]['legs'][1:]:
            euclidean_distance = 1000
            true_destination = ''
            for destination, geo in d_geo_destinations.items():
                true_euclidean_distance = dist(list(leg['start_location'].values()), list(geo.values()))
                if true_euclidean_distance < euclidean_distance:
                    euclidean_distance = true_euclidean_distance
                    true_destination = destination
            locations.append(true_destination)
        locations.append(start)

        # 結果を表示する地図URLの取得
        URL_gmad = 'https://www.google.com/maps/dir/'
        for location in locations:
            URL_gmad += str(list(d_geo_destinations[location].values())).replace('[', '').replace(']', '') + '/'

        '''
        #プログレスバーの表示
        progress_bar.stop()
        progress_bar.destroy()
        '''

        # 既存のテキストをクリア
        self.result_text.delete(1.0, tk.END)
        # 経路検索の結果を表示（概要版）
        self.result_text.insert(tk.END, '▼訪問順序\n')
        self.result_text.insert(tk.END, f'出発地: {locations[0]}\n')
        for i, leg in enumerate(directions_result[0]['legs']):
            if i == (len(directions_result[0]['legs'])-1):self.result_text.insert(tk.END, f'着着地: {locations[-1]}\n')
            else:self.result_text.insert(tk.END, f'{i + 1}番目訪問先: {locations[i + 1]}\n')
        self.result_text.insert(tk.END, '\n\n')

        # 経路検索の結果を表示（詳細版）
        self.result_text.insert(tk.END, '▼ルート詳細\n')
        for i, leg in enumerate(directions_result[0]['legs']):
            self.result_text.insert(tk.END, f'出発地: {locations[i]}\n')
            self.result_text.insert(tk.END, f'訪問先: {locations[i + 1]}\n')
            self.result_text.insert(tk.END, f'距離: {leg['distance']['text']}\n')
            self.result_text.insert(tk.END, f'所要時間: {leg['duration']['text']}\n\n')

        # 経路検索のURLを表示
        self.result_text.insert(tk.END, f'\n▼地図URL\n{URL_gmad}')

        # 地図を表示するボタンを追加
        for child in self.button_frame.winfo_children():
            if isinstance(child, tk.Button) and child['text'] == '地図を表示':
                child.config(command=lambda: webbrowser.open(URL_gmad))
                return
        show_map_button = tk.Button(self.button_frame, text='地図を表示', command=lambda: webbrowser.open(URL_gmad))
        show_map_button.pack()

if __name__ == '__main__':
    app = RoutePlanner()
    app.mainloop()
```

### 1. はじめに
今回の記事では、Pythonを使ってGoogle Maps APIを活用した経路計画アプリを作っていきます。
このアプリでは、複数の訪問先を指定し、最適な経路を検索することができます。
注意点としてはGoogle Cloud Platformでプロジェクトを作成し、DirectionsAPI、GeocodeAPIを有効にして、APIキーを事前に取得する必要があります。

#### プログラムの全体像
このプログラムは大きく分けて以下の機能を持っています。
1.出発地と訪問先の入力: GUIで出発地と複数の訪問先を入力します。
2.経路検索: Google Maps APIを使って、入力された情報に基づいて最適な経路を検索します。
3.結果表示: 経路の順序、各区間間の距離と所要時間、そしてGoogle MapsのURLを表示します。
4.地図表示: 表示されたURLをクリックすると、Google Mapsで経路が確認できます。


### 2. スクリプトの説明
#### 2-1. ライブラリのインポート
googlemaps: Google Maps APIを使うためのライブラリです。
math: 距離計算に使うライブラリです。
tkinter: GUIを作成するためのライブラリです。
webbrowser: Webブラウザを操作するためのライブラリです。

#### 2-2. RoutePlannerクラス
RoutePlannerクラスは、この経路計画アプリの中核となるクラスです。
このクラスが、ユーザーインターフェースの管理、Google Maps APIとの連携、そして経路検索の結果の表示など、アプリの主要な機能を担っています。

主なメソッドと役割:
__init__メソッド:
アプリケーションの初期化を行います。
tkinterを使ってGUIのウィンドウを作成し、各種ウィジェット（ラベル、エントリー、ボタンなど）を配置します。
Google Maps APIのクライアントを作成し、APIキーを設定します。

clear_formメソッド:
入力フォームをクリアします。
出発地、訪問先の入力欄を空欄にし、結果表示エリアのテキストを削除します。

add_dest_fieldメソッド:
訪問先入力欄を動的に追加します。
ユーザーが「訪問先を追加」ボタンをクリックするたびに、新しい入力欄が作成されます。

delete_dest_fieldメソッド:
訪問先入力欄を削除します。
ユーザーが「削除」ボタンをクリックすると、対応する入力欄が削除されます。

search_routeメソッド:
経路検索のメイン処理を行います。
入力された出発地と訪問先を取得し、Google Maps APIを使って経路を検索します。
検索結果を解析し、分かりやすい形式で表示します。


#### 2-3. 経路検索の処理 (search_routeメソッド)
search_routeメソッドは、アプリの最も重要な部分であり、以下の処理を行います。

入力データの取得:
GUIから出発地と複数の訪問先を取得します。

ジオコーディング:
Google Maps Geocoding APIを使って、取得した住所を緯度経度に変換します。
緯度経度は、経路検索や地図表示に必要です。

経路検索:
Google Maps Directions APIを使って、出発地、訪問先、そして最適化オプションを指定して経路を検索します。
Directions APIは、複数の経路候補を返すこともできますが、このプログラムでは最適化オプションを指定することで、最短経路を検索します。

結果の解析:
Directions APIから返されたJSON形式のデータを解析し、必要な情報を抽出して表示用の文字列に変換します。
表示する情報は、各区間の距離、所要時間、そして全体の経路などです。

地図URLの作成:
Google Mapsの静止画URLを作成します。
このURLをブラウザで開くと、検索された経路が地図上に表示されます。

結果の表示:
解析した結果と地図URLを、GUIのテキストエリアに表示します。

### 3. GUIの説明
実行時のGUI説明
以下の「出発&帰着地」欄と「訪問先」欄に住所を入力します。
「訪問先」が複数ある場合は「訪問先を追加」ボタンを押すことで「訪問先」欄を追加することができます。
「出発&帰着地」と全ての「訪問先」を入力し終わったら「検索」ボタンを押すことで、最短経路を検索します。
![スクリーンショット 2024-09-15 20.38.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3764845/8e04cd07-7e5a-632e-dd79-fac933063988.png)


以下はサンプルです。
出発&帰着地：東京駅、訪問先：横浜駅、新宿駅、千葉駅、池袋駅にて検索を実行しています。
検索結果は下の枠内に表示され、東京駅→池袋駅→新宿駅→横浜駅→千葉駅→東京駅の順序で訪問すれば効率的と分かります。
訪問先の入力順序に関わらず、最短経路となる経路を示してくれます。
検索後は「地図を表示」ボタンが表示され、クリックすると、提案経路でのGoogleMapを開きます。
![スクリーンショット 2024-09-15 20.40.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3764845/32a8ed49-ae8e-34dc-d80b-aa36098cdf98.png)


以下が「地図を表示」クリックすると表示されるGoogleMapでの提案経路です。
通常のGoogleMapでの複数経路検索と同様の画面となっており、ここで経路の順序を変更することも可能です。
![スクリーンショット 2024-09-15 20.40.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3764845/7dfe758e-a6dc-66d1-dbe3-bc0372bf26e8.png)


### 4. おわりに
初めてGoogle Maps APIを活用して地図情報を取り扱うアプリを開発してみましたが、自分の思った通りの動作ができてよかったです。

このプログラムをベースに、様々な機能を追加していくことで、より高度なアプリを作成していきたいです。

長い文章を最後までお読みいただき、ありがとうございました！！