CSVからグラフを生成するアプリをつくりたい

この記事は[2024年ゆる募AdventCalendar](https://adventar.org/calendars/11153)12日目の記事です．

最近C++ばっかりでPythonに触りたかったのと，tkinterを使ってみたかったのでCSVを読み込んでグラフを作成するアプリをつくってみました．

もっと，直したいところはありますがやったところまで...

# 依存関係
以下のライブラリを使用します．
```python:使用ライブラリ
import tkinter as tk
from tkinter import filedialog
import matplotlib.pyplot as plt
import japanize_matplotlib
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
import pandas as pd
import os

```
# 完成イメージ
今回の完成イメージは以下のようになります．
画面の左側で，csvファイルの選択・データの表示・グラフの設定を行い，画面の右側にグラフを表示します．

グラフの種類は「ヒストグラム」「散布図」「折れ線グラフ」「3D散布図」を選択できるようにしました．
また，どの列のデータを使うかをドロップダウンで選択，軸のラベル・軸の範囲をキーボードで入力できるようにしました．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801723/2d0bc3b0-0cca-c25d-3795-a47b5e946ba6.png)

# コードの構成
このアプリは主に以下のクラスで構成されています：

- **CsvLoader**：CSVデータを読み込んでテキストウィンドウに表示．
- **GraphSettings**：グラフ設定を行うインターフェースを作成し入力された設定値を取得．
- **GraphViewer**：matplotlibを使用してデータをグラフとして描画．
- **Csv2GraphApp**：全体のアプリケーションのルートクラス．各コンポーネントを統合．

# CsvLoader
CsvLoaderではCSVをpandasで読み込み，そのデータフレームを左画面中央のテキストボックスに表示します．

`__init__`でこのクラスで必要なウィジェットを設置したり，変数を初期化しています．

それぞれのボタンや入力窓等を作成し，`-.grid()`で表示しています．`grid()`を使うことで特に難しいことを考えなくてもセル形式に配置できるので楽ちんです．ボタンに与えられている`command`オプションは，そのボタンが押されたときに実行される関数を与えるものです．これがないと，ボタンを押しても何も起きないままになってしまいます．
```python:CsvLoader __init__
class CsvLoader:
    def __init__(self, parent,on_columns_loaded):
        # フレームの設置
        self.frame = tk.Frame(parent)
        self.frame.grid(row=0, column=0, sticky="nsew")

        # ファイル選択部の作成
        self.file_label = tk.Label(self.frame, text="ファイル：")
        self.file_entry = tk.Entry(self.frame, width=80)
        self.file_button = tk.Button(self.frame, text="参照", command=self.load_file)
        
        self.file_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
        self.file_entry.grid(row=0, column=1, padx=5, pady=5, sticky="we")
        self.file_button.grid(row=0, column=2, padx=5, pady=5)

        # データ表示部の作成
        self.data_view_button = tk.Button(self.frame, text="データ読み込み", command=self.view_data)
        self.data_text = tk.Text(self.frame, height=30)
        
        self.data_view_button.grid(row=1, column=0, columnspan=3, padx=5, pady=5)
        self.data_text.grid(row=2, column=0, columnspan=3, padx=5, pady=5, sticky="nsew")

        # 後で使う変数の初期化
        self.file_path = None
        self.data = None
        self.on_columns_loaded = on_columns_loaded
```
このクラスでは，
- `load_file`
- `view_data`

の2つの関数を提供しています．それぞれ見ていきましょう．

## load_file
```python:CsvLoader load_file
class CsvLoader:
    def load_file(self):
        filetypes = [
            ("すべてのファイル","*"),
            ("すべてのデータファイル",["*.txt","*.csv","*.dat","*.log"]),
            ("TXTファイル","*.txt"),
            ("CSVファイル","*.csv"),
            ("DATファイル","*.dat"),
            ("LOGファイル","*.log")
        ]
        self.file_path = filedialog.askopenfilename(filetypes=filetypes, initialdir=os.getcwd())
        if self.file_path:
            self.file_entry.delete(0, "end")
            self.file_entry.insert(0, self.file_path)
```
この関数は，ファイル選択部の「参照」ボタンが押されたときに実行されます．
ファイルダイアログを開き，選択されたファイルのフルパスを取得しファイル選択部の入力窓に表示します．

`filedialog.askopenfilename`を使用することで，上記のことが実行できます．
`filetypes`のオプションではファイルダイアログに表示するファイル名や拡張子を絞り込むことが可能です．今回は，txt,csv,dat,logの拡張子で絞り込めるようにしました．
`initialdir`はファイルダイアログを開いた最初の画面で表示されるディレクトリを指定するオプションで，今回はカレントディレクトリを指定しています．

パスを取得した後は，入力窓（"self.file_entry"）に表示します．`entry.insert`で文字列を挿入することができますが，このまま挿入してしまうと予め入力されてた文字列がそのまま残ってしまいます．そのため，`self.file_entry.delete`を実行することで一度`entry`を空にしてから`self.file_entry.insert`で挿入しています．

実際に参照ボタンを押すと，以下のように表示されます．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801723/4292c48f-cfd3-c699-fa66-acccfec89953.png)

`filetypes`のオプションで複数のタイプを与えると以下のように選ぶことができるようになります．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801723/76954bbe-149b-7bea-bfb6-2a01b7d93561.png)

## view_data
```python:CsvLoader view_data
class CsvLoader:
    def view_data(self):
        file_path = self.file_entry.get()
        if file_path:
            try:
                self.data = pd.read_csv(file_path)
                self.data_text.delete("1.0", "end")
                self.data_text.insert("1.0", self.data.to_string())
                self.on_columns_loaded(self.data.columns.tolist())  # 列名を通知
            except Exception as e:
                self.data_text.delete("1.0", "end")
                self.data_text.insert("1.0", f"エラー: {e}")
```
この関数は，「データ読み込み」のボタンが押されたときに実行され，先ほどの入力窓（`file_entry`）に入力されているパスを取得し，そのファイルをおなじみの`pd.read_csv`で読み込みます．読み込めた場合は，データフレームを文字列に変換し，テキストボックスに表示します．ここでも，先ほどと同じように一度テキストボックス中を削除してから表示します．
ファイルを読めなかった場合はエラー文が表示されます．

また，グラフに使うデータ列を選択するドロップダウンで列名のリストを用いるために，列名を`on_columns_loaded`でコールバックしています．

# GraphSettings
GraphSettingsでは，グラフを描画するのに用いる様々な設定の入力欄を作成し，その情報を返すはたらきをします．

`__init__`ではCsvLoaderと同じように，このクラスで必要なウィジェットを設置したり，変数を初期化しています．
```python:GraphSettings __init__
class GraphSettings:
    def __init__(self, parent):
        self.frame = tk.Frame(parent)
        self.frame.grid(row=1, column=0, sticky="nsew")

        # グラフ設定
        self.graph_type_lists = [
            "ヒストグラム",
            "散布図",
            "折れ線グラフ",
            "3D散布図"
        ]
        self.graph_type_label = tk.Label(self.frame, text="グラフの種類")
        self.graph_type_var = tk.StringVar(value="選択")
        self.graph_type_menu = tk.OptionMenu(self.frame, self.graph_type_var, *self.graph_type_lists)
        
        self.graph_type_label.grid(row=0, column=0, padx=5, pady=5, sticky="w")
        self.graph_type_menu.grid(row=0, column=1, padx=5, pady=5, sticky="we")

        # パラメータ設定
        self.setting_parameters_str = [# 文字列のパラメータ
            "data1_index",
            "axis_label",
        ]
        self.setting_parameters_num = [# 数値のパラメータ
            "axis_min",
            "axis_max"
        ]
        self.setting_parameters = self.setting_parameters_str + self.setting_parameters_num
        self.labels = {
            **{param: {axis: tk.StringVar() for axis in ["X軸", "Y軸", "Z軸"]} for param in self.setting_parameters_str},
            **{param: {axis: tk.DoubleVar() for axis in ["X軸", "Y軸", "Z軸"]} for param in self.setting_parameters_num}
        }
        self.entries = {param: {} for param in self.setting_parameter[1:]}
        self.data1_menus = {}
        column = 1
        for i in ["データ列","軸ラベル","最小値","最大値"]:
            tk.Label(self.frame,text=i).grid(row=1, column=column, padx=5, pady=5, sticky="w")
            column += 1

        row = 2
        column = 1
        for axis in ["X軸", "Y軸", "Z軸"]:
            tk.Label(self.frame, text=f"{axis}：").grid(row=row, column=0, padx=5, pady=5, sticky="w")
            menu = tk.OptionMenu(self.frame, self.labels["data1_index"][axis], "選択")
            menu.grid(row=row, column=1, padx=5, pady=5, sticky="w")
            self.data1_menus[axis] = menu
            column = 2
            for param in self.setting_parameters[1:]:
                entry = tk.Entry(self.frame, textvariable=self.labels[param][axis])
                entry.grid(row=row, column=column, padx=5, pady=5, sticky="we")
                self.entries[param][axis] = entry
                column += 1
            row += 1
```
`tk.OptionMenu`はドロップダウンのウィジェットです．`tk.StringVar`と`tk.DoubleVar()`はtkinterで使われる変数オブジェクトで，ウィジェットに与えられた文字列や数値をPythonに橋渡しするために用いています．`tk.OptionMenu(self.frame, self.graph_type_var, *self.graph_type_lists)`では`self.graph_type_lists`の要素がドロップダウンで表示され，選ばれた値が`self.graph_type_var`（`tk.StringVar`）に代入されます．ボタン上に表示されるのは`self.graph_type_var`の値です．

`self.labels`はグラフに使う列，軸のラベル名，軸の最大値・最小値を保持するための`tk.StringVar`と`tk.DoubleVar`を辞書型で管理しています．それぞれのパラメータと軸に対してfor文で展開しています．これにより，以下のような辞書型リストが作成されます．
```python
{
    'data1_index': {
        'X軸': <tk.StringVar instance>,
        'Y軸': <tk.StringVar instance>,
        'Z軸': <tk.StringVar instance>
    },
    'axis_label': {
        'X軸': <tk.StringVar instance>,
        'Y軸': <tk.StringVar instance>,
        'Z軸': <tk.StringVar instance>
    },
    'axis_min': {
        'X軸': <tk.DoubleVar instance>,
        'Y軸': <tk.DoubleVar instance>,
        'Z軸': <tk.DoubleVar instance>
    },
    'axis_max': {
        'X軸': <tk.DoubleVar instance>,
        'Y軸': <tk.DoubleVar instance>,
        'Z軸': <tk.DoubleVar instance>
    }
}

```

`self.data1_menus = {}`以降のfor文で実際に以下の画面を作成しています．
1つ目のfor文で，表の1行目を作成し，2つ目のfor文で2行目以降を作成しています．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3801723/e766c291-30b0-06ce-4285-fa14a9d09fa2.png)

このクラスでは，
- update_columns
- get_settings

を提供しています．

## update_columns
```python:GraphSettings update_columns
class GraphSettings:
    def update_columns(self, columns):
            for axis, menu in self.data1_menus.items():
                menu['menu'].delete(0, 'end')  # 既存の項目を削除
                for column in columns:
                    menu['menu'].add_command(label=column, command=lambda axis=axis,value=column: self.labels["data1_index"][axis].set(value))
```
この関数はCsvLoaderに渡しています．csvを読み込み，列名のリストが通知されると，この関数によりデータ列のドロップダウンリストが更新されます．

` menu['menu'].delete`でそれまでに持っていたドロップダウンリストを削除し，`menu['menu'].add_command`で列名（column）をドロップダウンリストに追加していきます．`command`オプションで，列名が選択されたときに対応する`tk.StringVar`に代入するようにlambda式で記述しています．

## get_settings
```python:GraphSettings get_settings
class GraphSettings:
    def get_settings(self):
        return {
            "graph_type": self.graph_type_var.get(),
            **{param : {axis: self.labels[param][axis].get() for axis in ["X軸", "Y軸", "Z軸"]} for param in self.setting_parameters}
        }
```
この関数はグラフを作成する際に呼び出され，入力されているパラメータを返します．

# GraphViewer
GraphViewerではグラフを作成し，表示するためのウィジェットと関数を提供しています．

`__init__`ではグラフを表示するためのcanvas（`FigureCanvasTkAgg`）とmatplotlibのツールバー（`NavigationToolbar2Tk`）を表示しています．

`plot_graph`関数では，既あるグラフを削除（`self.fig.clear()`）し，先ほどの`get_settings`で得た`graph_type`に合わせてグラフを作成します．

実際のグラフを記述する関数は`_plot_hoogehoge`で，軸を記述する関数は`_set_common_axes`です．これらの関数内に，`get_settings`から得た情報をもとにグラフを記述する処理が書かれています．

```python:GraphViewer
class GraphViewer:
    def __init__(self, parent):
        self.frame = tk.Frame(parent, bg="yellow")
        self.frame.grid(row=0, column=1, rowspan=2, sticky="nsew")
        
        self.fig = plt.Figure()
        self.canvas = FigureCanvasTkAgg(self.fig, self.frame)
        self.toolbar = NavigationToolbar2Tk(self.canvas, self.frame)
        self.canvas_widget = self.canvas.get_tk_widget()
        
        self.toolbar.pack(side=tk.TOP, fill=tk.X)
        self.canvas_widget.pack(fill=tk.BOTH, expand=True)

    def plot_graph(self, data, settings):
        self.fig.clear()
        graph_type = settings.get("graph_type", "")
        try:
            if graph_type == "ヒストグラム":
                self._plot_histogram(data, settings)
            elif graph_type == "散布図":
                self._plot_scatter(data, settings)
            elif graph_type == "折れ線グラフ":
                self._plot_line(data, settings)
            elif graph_type == "3D散布図":
                self._plot_3d_scatter(data, settings)
            else:
                raise ValueError(f"Unsupported graph type: {graph_type}")
        except Exception as e:
            print(f"Error plotting graph: {e}")

        self.canvas.draw()

    def _set_common_axes(self, ax, settings, is_3d=False):
        if is_3d:
            ax.set_xlabel(settings["axis_label"]["X軸"])
            ax.set_ylabel(settings["axis_label"]["Y軸"])
            ax.set_zlabel(settings["axis_label"]["Z軸"])
        else:
            ax.set_xlabel(settings["axis_label"]["X軸"])
            ax.set_ylabel(settings["axis_label"]["Y軸"])
        
        if is_3d:
            ax.set_xlim(
                settings["axis_min"]["X軸"],
                settings["axis_max"]["X軸"]
            )
            ax.set_ylim(
                settings["axis_min"]["Y軸"],
                settings["axis_max"]["Y軸"]
            )
            ax.set_xlim(
                settings["axis_min"]["Z軸"],
                settings["axis_max"]["Z軸"]
            )
        else:
            ax.set_xlim(
                settings["axis_min"]["X軸"],
                settings["axis_max"]["X軸"]
            )
            ax.set_ylim(
                settings["axis_min"]["Y軸"],
                settings["axis_max"]["Y軸"]
            )

    def _plot_histogram(self, data, settings):
        ax = self.fig.add_subplot(111)
        x = data[settings["data1_index"]["X軸"]]
        ax.hist(x)
        self._set_common_axes(ax, settings)

    def _plot_scatter(self, data, settings):
        ax = self.fig.add_subplot(111)
        x = data[settings["data1_index"]["X軸"]]
        y = data[settings["data1_index"]["Y軸"]]
        ax.scatter(x, y)
        self._set_common_axes(ax, settings)

    def _plot_line(self, data, settings):
        ax = self.fig.add_subplot(111)
        x = data[settings["data1_index"]["X軸"]]
        y = data[settings["data1_index"]["Y軸"]]
        ax.plot(x, y)
        self._set_common_axes(ax, settings)

    def _plot_3d_scatter(self, data, settings):
        ax = self.fig.add_subplot(111, projection='3d')
        x = data[settings["data1_index"]["X軸"]]
        y = data[settings["data1_index"]["Y軸"]]
        z = data[settings["data1_index"]["Z軸"]]
        ax.scatter(x, y, z)
        self._set_common_axes(ax, settings, is_3d=True)
```

# Csv2GraphApp
Csv2GraphAppでは，ここまでに作成したGraphSettings,GraphViewer,CsVLoaderを統合しています．

各コンポーネントのインスタンスを作成し，その中でon_columns_loadedにself.settings.update_columnsを渡すことで，データが読み込まれたときに列名のプルダウンを更新する関数を呼び出すようにしています．

また，プロット用のボタンを設置し，押されたときにplot_graph（GraphViewerにも同名の関数がってわかりにくい...）を実行しグラフを表示しています．
```python
class Csv2GraphApp:
    def __init__(self, root):
        root.title("CSV to Graph")

        # 各コンポーネントのインスタンス作成
        self.settings = GraphSettings(root)
        self.viewer = GraphViewer(root)
        self.loader = CsvLoader(root, on_columns_loaded=self.settings.update_columns)

        # プロットボタン
        self.plot_button = tk.Button(root, text="グラフを表示", command=self.plot_graph)
        self.plot_button.grid(row=2, column=0, columnspan=2, pady=10)

    def plot_graph(self):
        if self.loader.data is None:
            print("データが読み込まれていません")
            return
        
        settings = self.settings.get_settings()
        self.viewer.plot_graph(self.loader.data, settings)
```

# 今後の改善点
また，暇があれば以下のようなところを改善したいなと思いながら，今回の記事はこれで終わります．

- 複数のデータの同時プロット
- 複数データの読み込み
- 第2軸の設定
- エラーバーの実装
- エラー処理の実装
- 本記事の添削