GUI操作でデータを手軽にグラフ表示するツールを作ったったった

データ分析の作業をしていると、データを数字で追うのとは別にグラフ等で視覚的にデータの特徴を確認することも多々あると思います。
そういった作業が多く、データの組みあわせを自由に変えながらグラフをすぐに表示できる「Glance」というツールを作ってみました。
GUI作成にはPythonの`tkinter`というライブラリを使用しました。

ファイルは[こちら](https://gitlab.com/katsuki104/glance)にあります。
**※Windows環境で使う用に作ったのでMac環境だと多分別途修正が必要です...**

# 概要
読み込んだデータから選択してグラフ表示するだけのシンプルなツールです。
実装した動作は大きく３つあります。

1. ファイル選択からデータを読み込みそのカラムを一覧で表示する
![sample1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/394ea708-9f6d-4aec-bf7e-87f5d64b03e0.gif)

2. データを選択してグラフを表示する
   ※「norm」のチェックを入れると0～1に正規化した値でプロット
![sample2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/7489e7a1-d205-93f7-06f3-2f43901ffd56.gif)
![sample3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/b1adb602-cbce-a6e1-0701-4f805ccaa58b.gif)

3. スライダーによって動的にグラフの表示範囲を変更する
![sample4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/160512/b21637d6-266b-77ca-335b-41db1c03d84d.gif)


# ハマった部分の簡単な紹介
## tkinter内での値の受け渡しについて
今回初めてtkinterを使いましたが、詰まったところのひとつとして「**ウィジェットに割り当てた関数の返り値を受け取り方がわからない**」というところです。
そのためにデータを持たせるためのクラスを定義して、オブジェクトに逐一データを持たせることで受け渡しする必要がありそうでした。

例えば今回のツールの場合、ファイルから読み取る`fileselect`という関数がありますが、読み取ったデータフレームはreturnで返すわけではなく、処理の中でオブジェクトに持たせています。

```python
def fileselect():
    root = tkinter.Tk()
    root.withdraw()
    fTyp = [("", "*")]
    iDir = os.path.abspath(os.path.dirname(__file__))
    model.path = tkinter.filedialog.askopenfilename(filetypes=fTyp,
                                                    initialdir=iDir)

    suffix = model.path.rsplit('.', 1)[1]
    if suffix == 'csv' or suffix == 'pkl':
        model.set_data(suffix) #ここでデータを持たせている。
        graph.config(state='active')
        Entry1.insert(tk.END, model.path)

        line_size = columns_list.size()
        columns_list.delete(0, line_size)
        for column in enumerate(model.data.columns):
            columns_list.insert(tk.END, column)
    else:
        tkinter.messagebox.showinfo(model.path, 'csvかpickleファイルを選択してください。')
```



## グラフを動的に更新する方法
tkinterのウィジェットを関数で操作する場合、
変数のスコープ上関数の中に操作用関数を定義することになります。

今回はグラフを別窓で開く`new_window`も定義していますので、
グラフを更新する`redraw`関数の構造は以下のようになります。

``` python 
def main():
    # 処理
    def new_window():
        def redraw():
            # 処理
        scale = tk.Scale(command=redraw) # スライドバー
```
こうすることで、scaleを操作するたびにredraw関数が実行されるようになります。

redraw関数の中は自由でよいですが、matplotlibで表現する内容を変更した後
`canvas.draw()`が最後に無いとGUI上に反映されないので気をつけて下さい。

## コードを分けられない
これは解決していない問題なのですが、自分の書き方的にはmain()の中に
他の関数を全て定義してしまっているので関数や別ファイルとして外出しすることができませんでした。
（でもこうするしか関数でウィジェットを操作する方法がわからない...)

結果的にmain()が長くなって読みづらくなってしまうのですがどうしたものか...

### コード全体
``` python
import os
import tkinter as tk
import tkinter.filedialog
import tkinter.messagebox

import japanize_matplotlib
from matplotlib.backends.backend_tkagg import (
    FigureCanvasTkAgg, NavigationToolbar2Tk)
from matplotlib.figure import Figure
import numpy as np
import pandas as pd


def main():
    class model_data:
        def __init__(self):
            self.data = pd.DataFrame()
            self.path = ''
            self.columns = []

        def set_path(self, file_path):
            self.path = file_path

        def set_data(self, suffix):
            if suffix == 'csv':
                self.data = pd.read_csv(self.path)
            elif suffix == 'pkl':
                self.data = pd.read_pickle(self.path)

    def new_window(event):
        if any(model.data):
            def _quit_sub():
                sub_win.destroy()

            def _redraw(event):
                min_lim = min_scale.get()
                max_lim = max_scale.get()
                val = []
                if min_lim >= max_lim:
                    return
                else:
                    t = target.iloc[min_lim:max_lim, :]
                    for index in selection:
                        label = target.columns[index]
                        val.append(t[label].max())
                        val.append(t[label].min())

                    max_val = max(val)
                    min_val = min(val)

                ax.set_xlim(min_lim, max_lim)
                ax.set_ylim(min_val, max_val)
                canvas.draw()

            selection = columns_list.curselection()
            sub_win = tk.Toplevel(root)
            sub_win.configure(bg='white')
            sub_win.wm_title("Embedding in Tk")
            target = model.data.copy()
            target.reset_index(drop=True, inplace=True)
            fig = Figure(figsize=(10, 4), dpi=100)
            ax = fig.add_subplot(111)

            if norm_bln.get():
                for index in selection:
                    label = target.columns[index]
                    max_num = target[label].max()
                    min_num = target[label].min()

                    target[label] = (target[label] - min_num) / (max_num - min_num)
                    ax.plot(target.index,
                            target[label],
                            label=label)
            else:
                for index in selection:
                    label = target.columns[index]
                    ax.plot(target.index,
                            target[label],
                            label=label)
            canvas_frame = tk.Frame(master=sub_win, bg='white')
            canvas_frame.pack(side=tk.LEFT)
            canvas = FigureCanvasTkAgg(fig,
                                       master=canvas_frame)
            canvas.draw()
            canvas.get_tk_widget().pack(side=tk.TOP,
                                        fill=tk.BOTH,
                                        expand=1)
            control_frame = tk.Frame(master=sub_win)
            control_frame.pack(side=tk.RIGHT)
            toolbar = NavigationToolbar2Tk(canvas,
                                           canvas_frame)
            toolbar.update()
            canvas.get_tk_widget().pack(side=tk.TOP,
                                        fill=tk.BOTH,
                                        expand=True)

            min_scale = tk.Scale(control_frame,
                                 label='Min',
                                 orient='h',
                                 from_=0,
                                 bg='light blue',
                                 resolution=1,
                                 to=model.data.shape[0]-1,
                                 command=_redraw)
            min_scale.pack(anchor=tk.NW)
            max_scale = tk.Scale(control_frame,
                                 label='Max',
                                 orient='h',
                                 from_=1,
                                 bg='tan1',
                                 resolution=1,
                                 to=model.data.shape[0],
                                 command=_redraw)
            max_scale.set(target.shape[0])
            max_scale.pack(anchor=tk.NW)

            button = tkinter.Button(master=canvas_frame,
                                    text="Quit",
                                    command=_quit_sub)
            button.pack(side=tkinter.BOTTOM)
        else:
            tkinter.messagebox.showinfo('Glance.py', '先に読み込むデータを選択してください')

    def fileselect():
        root = tkinter.Tk()
        root.withdraw()
        fTyp = [("", "*")]
        iDir = os.path.abspath(os.path.dirname(__file__))
        model.path = tkinter.filedialog.askopenfilename(filetypes=fTyp,
                                                        initialdir=iDir)

        suffix = model.path.rsplit('.', 1)[1]
        if suffix == 'csv' or suffix == 'pkl':
            model.set_data(suffix)
            graph.config(state='active')
            Entry1.insert(tk.END, model.path)

            line_size = columns_list.size()
            columns_list.delete(0, line_size)
            for column in enumerate(model.data.columns):
                columns_list.insert(tk.END, column)
        else:
            tkinter.messagebox.showinfo(model.path, 'csvかpickleファイルを選択してください。')

    def _quit(event):
        root.quit()
        root.destroy()

    root = tk.Tk()
    model = model_data()
    root.title('Glance')
    root.geometry('680x300')

    frame1 = tk.Frame(bd=3, relief=tk.SUNKEN, pady=5)
    frame1.pack(padx=5, pady=5, ipadx=10, ipady=10, side=tk.LEFT)
    Static1 = tk.Label(frame1, text='File Path')
    Static1.pack()

    Entry1 = tk.Entry(frame1, width=80)
    Entry1.insert(tk.END, model.path)
    Entry1.pack()

    Static1 = tk.Label(frame1, text='Column Names')
    Static1.pack()

    var = tk.StringVar(value=[])
    columns_list = tk.Listbox(frame1,
                              listvariable=var,
                              width=80,
                              selectmode='multiple')
    scrollbar = tk.Scrollbar(frame1,
                             command=columns_list.yview)

    columns_list.yscrollcommand = scrollbar.set
    scrollbar.pack(fill=tk.BOTH,
                   side=tk.RIGHT)
    columns_list.pack()

    Button1 = tk.Button(frame1,
                        text='SelectFile',
                        width=10,
                        command=fileselect)
    Button1.pack(pady=5)

    frame2 = tk.Frame()
    frame2.pack(padx=5, pady=5, ipadx=10, ipady=10, fill=tk.BOTH)

    norm_bln = tk.BooleanVar()
    norm_bln.set('False')
    norm = tkinter.Checkbutton(frame2,
                               variable=norm_bln,
                               text='Norm')
    norm.pack()

    graph = tk.Button(frame2,
                      text='Graph',
                      width=10,
                      state='disable')
    graph.bind("<Button-1>", new_window)
    graph.pack()

    frame3 = tk.Frame()
    frame3.pack(padx=5, pady=5, ipadx=10, ipady=10, side=tk.BOTTOM)
    Button2 = tk.Button(frame3, text='Exit', width=5)
    Button2.bind("<Button-1>", _quit)
    Button2.pack()

    root.mainloop()


if __name__ == "__main__":
    main()

```

### 参考ページ
[グラフを表示して動的に変更する方法](https://water2litter.net/rum/post/python_tkinter_matplotlib/)
[tkinterのウィジェット配置について](https://www.shido.info/py/tkinter2.html)