ウマ娘のレース管理アプリを作る【２】〜GUIアプリ作成編〜

# はじめに
前回の記事でスクレイピングでレースのデータを取得しました。
そのデータを使ってGUIのアプリケーションを作ります。
オブジェクト指向を使ったtkinterの具体的な実装法のメモになればいいかなと。
様々なサイトを参考にさせていただきました。

# 手順

1. スクレイピングでレースデータを取得
1. データを使ってGUIアプリケーションを作る（検索＆TODOリスト登録）【今回やる】

# 使用開発環境
* Python 3.8.3
* pandas 1.0.5
* tkinter 8.6.10
* エディタ：VScode

# 要件
適当に要件を決めます。

* レースを名前で検索できる
* 検索したレースを選択してTODOリストに追加できる
* TODOリストのチェックボックスをチェックするとそのレースを消すことができる
* レースを期間で昇順ソートできる
* レースのデータ（期間、場所など）も表示させる

# ソースコード

順番に実装していきます。

## インポート、定数の宣言

この辺りの定数は他の実装をしつつ定義したものです。
ほとんどがtkinterのGUI設計用の定数です。

```python
import pandas as pd
from tkinter import *

PATH='race.csv'
SEP=20
CHK_X=10
CHK_Y=10

LIST_X=10
LIST_Y=80

SER_ENT_WIDTH=20
SER_ENT_X=10
SER_ENT_Y=10

MAIN_TITLE='ウマ娘 レース計画ソフト'
MAIN_WINDOW_SIZE='600x500'

SER_BTN_TEXT='検索'
SER_BTN_X=10
SER_BTN_Y=50

UPD_BTN_TEXT = '更新'
UPD_BTN_X=80
UPD_BTN_Y=50

ADD_BTN_TEXT='レースを追加'
ALL_BTN_TEXT='全選択'
ALL_BTN_X=130

DONE_RACE = 0

MONTH_ZEN = 0.1
MONTH_KOU = 0.2
```
## Raceクラスの定義

レースに必要なプロパティと、レースデータを表示させるメソッドを定義する。
要件の、「レースのデータ（期間、場所など）も表示させる」はこれで実装完了です。

```python
class Race:
    def __init__(self, year, month, name, race_class, place, length, direction, tk):
        self.year = year
        self.month = month
        self.name = name
        self.race_class = race_class
        self.place = place
        self.length = length
        self.direction = direction
        self.bln = BooleanVar()
        self.bln.set(False)
        self.chk = Checkbutton(tk, variable=self.bln, text=self.generate_view_race())


    def generate_view_race(self):
        txt = f'{self.year} {self.month} {self.race_class} {self.name} ' + \
                f'{self.length} {self.place} {self.direction}'
        return txt
```

## アプリのクラスを定義

tkinterのウェジットをインスタンスのプロパティとして実装します。

```python
class Race_plan_app():
    def __init__(self):

        # メイン画面を定義
        self.tk = Tk()
        self.tk.title(MAIN_TITLE)
        self.tk.geometry(MAIN_WINDOW_SIZE)

        # 検索窓を定義
        self.search_entry = Entry(width=SER_ENT_WIDTH)
        self.search_entry.place(x=SER_ENT_X, y=SER_ENT_Y)

        # 検索ボタンを定義
        self.search_btn = Button(self.tk, text=SER_BTN_TEXT, command=self.search_race)
        self.search_btn.place(x=SER_BTN_X, y=SER_BTN_Y)

        # 更新ボタンを定義
        self.update_btn = Button(self.tk, text=UPD_BTN_TEXT, command=self.update)
        self.update_btn.place(x=UPD_BTN_X, y=UPD_BTN_Y)

        # レースのリストを入れるプロパティを定義
        self.race_list = []

        self.read_race_data()

    # レースのデータを読み込む
    def read_race_data(self):
        self.df_race = pd.read_csv(PATH)

    def run(self):
        self.tk.mainloop()
```

基本的なウェジットは実装完了です。
あとは、runメソッドを呼び出すことでmainloopさせるように実装しました（やり方違ってたらすみません！）。

```python
race_app = Race_plan_app()
race_app.run()
```
ただ、ボタンで起動するメソッドがまだ定義されてないのでこれだけ実行してもエラーを吐かれます。

##　検索機能の実装

```python
class Race_plan_app():

# ~~　省略 ~~

    # レース検索機能
    def search_race(self):

        # 入力されたレース名をもとにデータフレームから検索する
        self.ent_txt = self.search_entry.get()
        self.search_result = self.df_race[self.df_race['レース名'].str.contains(self.ent_txt)]
        self.search_result = list(self.search_result.itertuples())

        # 新しいウィンドウを表示
        new_window = Toplevel()
        new_window.title('レース選択')
        new_window.geometry('500x1000')
        
        # 検索したレースデータをチェックボックス付きで表示（インスタンスも作成）
        self.search_races = []
        for i in range(len(self.search_result)):
            self.search_races.append(Race(self.search_result[i][2], self.search_result[i][3], self.search_result[i][4], self.search_result[i][5], 
                                        self.search_result[i][6], self.search_result[i][7], self.search_result[i][8], new_window))
            self.search_races[-1].chk.place(x=CHK_X, y=CHK_Y + SEP*i)

        # TODOリストへのレース追加を行う
        add_btn = Button(new_window, text=ADD_BTN_TEXT, command=self.add_race)
        add_btn.place(x=CHK_X, y=CHK_Y + SEP*len(self.search_result))

        # 検索結果を全部選択する
        all_btn = Button(new_window, text=ALL_BTN_TEXT, command=self.chose_all)
        all_btn.place(x=ALL_BTN_X, y=CHK_Y + SEP*len(self.search_result))

        new_window.mainloop()
```

これで「レースを名前で検索できる」の実装完了です。
キモとしては、新しいウィンドウを表示させるにはToplevelメソッドを使うところです。
そのウィンドウの変数を使って新しいウィンドウに表示させるウィジットを定義していきます。
最後にmainloopさせているので、ウィンドウが閉じられたら新しいウィンドウの処理も停止します。
検索だけでなく、レースの結果をTODOリストに追加する機能も実装する必要があります。
各ボタンに設定しているメソッドを実装していきます。

##　TODOリストへの追加


```python
class Race_plan_app():

# ~~　省略 ~~

    # TODOリストに追加する
    def add_race(self):
        for i in range(len(self.search_races)):
            if self.search_races[i].bln.get():
                race = self.search_races[i]
                self.race_list.append(Race(race.year, race.month, race.name, race.race_class, 
                                            race.place, race.length, race.direction, self.tk))
        self.update()

    # レースの検索結果を全部選択する
    def chose_all(self):
        for i in self.search_races:
            i.bln.set(True)

```
これで「検索したレースを選択してTODOリストに追加できる」機能の実装完了です。
これらは新しいウィンドウのボタンウィジットに登録しています。
ボタンに登録するメソッドもインスタンスないのメソッドを使用できるのでわかりやすいですね。
検索結果を全選択する機能も便利かなと思ってついでにつけました。

## 更新機能と、ソート機能

ちょっと長くなりますが、以下のように実装。
更新機能は、ボタン以外にもレースを追加する際に更新を行う。
更新を行うと、レースのソート、完了済みのレースを削除という処理が行われる。
ソートは…汚いです。動けば正義という気持ちで実装しました…

```python
class Race_plan_app():

# ~~　省略 ~~

    # 更新する
    def update(self):

        # TODOリストのレースをチェックボックス付きで表示
        s = 0
        for i in range(len(self.race_list)):
            if self.race_list[i].bln.get():
                self.race_list[i].chk.destroy()
                self.race_list[i] = DONE_RACE
            else:
                self.race_list[i].chk.place(x=LIST_X, y=LIST_Y + SEP*s)
                s+=1

        # 完了済みのレースは削除
        while DONE_RACE in self.race_list:
            self.race_list.remove(DONE_RACE)
        
        # レースをソートする
        self.sort_race()

    # レースを昇順にする
    def sort_race(self):

        # 年、月毎にデータを分ける、月も前後半で昇順になるようにする
        race_yms = []
        for race in self.race_list:
            race_yms.append([int(race.year[0]), 
                            int(race.month[:-3])])
            if race.month[-2:] == '前半':
                race_yms[-1][1] += MONTH_ZEN
            elif race.month[-2:] == '後半':
                race_yms[-1][1] += MONTH_KOU

        years = [i[0] for i in race_yms]

        # 年をソートする
        for i in range(len(self.race_list)):
            for j in range(len(self.race_list)):
                if race_yms[i][0] < race_yms[j][0]:
                    tmp = race_yms[i]
                    race_yms[i] = race_yms[j]
                    race_yms[j] = tmp

                    tmp = self.race_list[i]
                    self.race_list[i] = self.race_list[j]
                    self.race_list[j] = tmp

        # 1年目を月毎にソート
        if 1 in years:
            ri = self.count_rindex([i[0] for i in race_yms], 1) # 1年目の最後尾のインデックス番号取得
            for i in range(len(self.race_list[:ri+1])):
                for j in range(len(self.race_list[:ri+1])):
                    if race_yms[i][1] < race_yms[j][1]:
                        tmp = race_yms[i]
                        race_yms[i] = race_yms[j]
                        race_yms[j] = tmp

                        tmp = self.race_list[i]
                        self.race_list[i] = self.race_list[j]
                        self.race_list[j] = tmp

        # 2年目を月毎にソート
        if 2 in years:
            ind = [i[0] for i in race_yms].index(2)
            ri = self.count_rindex([i[0] for i in race_yms], 2)
            for i in range(ind, len(self.race_list[:ri+1])):
                for j in range(ind, len(self.race_list[:ri+1])):
                    if race_yms[i][1] < race_yms[j][1]:
                        tmp = race_yms[i]
                        race_yms[i] = race_yms[j]
                        race_yms[j] = tmp

                        tmp = self.race_list[i]
                        self.race_list[i] = self.race_list[j]
                        self.race_list[j] = tmp

        # 3年目を月毎にソート
        if 3 in years:
            ind = [i[0] for i in race_yms].index(3)
            for i in range(ind, len(self.race_list)):
                for j in range(ind, len(self.race_list)):
                    if race_yms[i][1] < race_yms[j][1]:
                        tmp = race_yms[i]
                        race_yms[i] = race_yms[j]
                        race_yms[j] = tmp

                        tmp = self.race_list[i]
                        self.race_list[i] = self.race_list[j]
                        self.race_list[j] = tmp
    
    # 年ごとの最終要素のインデックス番号を取得するメソッド
    def count_rindex(self, lis, n):
        return len(lis) - (lis[::-1].index(n)+1)

```

これで、「TODOリストのチェックボックスをチェックするとそのレースを消すことができる」、「レースを期間で昇順ソートできる」という機能の実装完了です。

# 使用例
有馬記念で検索してみるとこんな感じ。2年目と3年目の違いで、2年目を追加してみます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/456646/0f58270f-1d4b-94e8-a02f-b2f47f59f083.png)
色々追加して、2年目のレースが終わったと仮定すると、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/456646/ceace8ed-9d00-bbc5-81e9-29c3cf6557bd.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/456646/4d19db2c-3709-98ef-5f2e-65f9a16aafbd.png)
このようになります。

# 今後の展望
追加するなら、セーブ機能ですね。
実装予定ではあったんですが、自分用だけなら別にいらんなと思って実装してません。

# まとめ
簡潔にはなりますが、ウマ娘のレース管理アプリ作成でした。
自分が色々とPythonで書いていく中で考えた書き方なので癖があるかもしれませんが、参考になれば幸いです。

### 参考文献
なんとか記憶を掘り出して参考サイトとか思い出しました。漏れがあれば申し訳ないです。

* [Pythonによるプログラミング](https://www.amazon.co.jp/Python%E3%81%AB%E3%82%88%E3%82%8B%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0-%E5%B0%8F%E6%9E%97%E9%83%81%E5%A4%AB-ebook/dp/B07YMXZBHY/ref=sr_1_1?__mk_ja_JP=%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A&dchild=1&keywords=Python%E3%81%AB%E3%82%88%E3%82%8B%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0&qid=1623053874&sr=8-1)
* [【Python】GUI 画面（ウィンドウ）を作る（tkinter）](https://pg-chain.com/python-gui-tkinter)
* [【Python】ボタンのクリックイベントを取得する](https://pg-chain.com/python-button-command)
* [Entryの値を取得してみる · tkinter - Nな人（N na hito）](https://nnahito.gitbooks.io/tkinter/content/helloworld/30a8-30f3-30c8-30ea-30fc-3092-shi-3063-3066-307f-308b/entry306e-zhi-3092-qu-de-3057-3066-307f-308b.html)
* [【Python】チェックボックス（チェックボタン）を作成する（Checkbutton）](https://pg-chain.com/python-checkbutton)
* [Tkinter のボタンをクリックして新しいウィンドウを作成する方法](https://www.delftstack.com/ja/howto/python-tkinter/how-to-create-a-new-window-with-a-button-in-tkinter/)
* [Python Tkinter 入門メモ](https://qiita.com/aoirint/items/ca2386b68e8fec16ff53)

### Git Hub
* [https://github.com/sugimochi97/uma_musume/tree/master/race_plan](https://github.com/sugimochi97/uma_musume/tree/master/race_plan)