中級者向け No.078

CSVグラフダッシュボード

CSVをpandasで集計しmatplotlibで棒・折れ線・円・散布図を動的に切り替えて表示するツール。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), matplotlib, pandas ⏱️ 制作時間: 30〜90分

1. アプリ概要

CSVをpandasで集計しmatplotlibで棒・折れ線・円・散布図を動的に切り替えて表示するツール。

このアプリはdataカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)・pandas・matplotlib、難易度は ★★★ です。

このアプリは「データ処理」カテゴリです。データ分析・処理アプリは Python の真骨頂と言える領域で、生データから意味ある可視化・要約までを一通り体験できます。tkinter(標準ライブラリ)・pandas・matplotlib を活かして実装するこの構造は、他のアプリにも応用が効きます。

CSVの読み込みは pandas.read_csv()、集計は groupby()agg() が担当します。左パネルの「集計方法」で 合計・平均・件数・最大・最小 のいずれかを選ぶと、X軸に指定した列でグループ化してから描画します(「なし」なら読み込んだ行をそのまま描きます)。グラフ種別と集計方法はコンボボックスを選び直した時点で描き直すので、同じデータを見る角度を変えながら確認できます。

完成形を見てから細部の解説に進む流れが効果的です。実行→気になる箇所を解説で確認→自分の手で改造、というサイクルで定着が早まります。

カスタマイズ章では具体的な拡張アイデアを示しています。一つ実装するごとに動作確認することで、変更が予期せぬ副作用を起こさないかも体感できます。

CSVグラフダッシュボード 実行画面(Windows)
実行画面(Windows)
CSVグラフダッシュボード 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • 「📂 CSV読み込み」ボタン(_open_csv())・「▶ グラフ描画」ボタン(_draw())・「💾 グラフ保存」ボタン(_save_chart())で操作
  • イベント <<ComboboxSelected>> から _on_option_changed() を実行
  • tk.Listbox による一覧表示
  • ttk.Treeview による表形式の一覧表示
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • filedialog.asksaveasfilename() によるファイル選択ダイアログ
  • messagebox.showerror()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「CSVグラフダッシュボード」・サイズ 1050x660 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

インストールが必要なライブラリ

pip install matplotlib pandas

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install matplotlib pandas
app078.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import threading

try:
    import pandas as pd
    PANDAS_AVAILABLE = True
except ImportError:
    PANDAS_AVAILABLE = False

try:
    import matplotlib
    matplotlib.use("TkAgg")
    from matplotlib import font_manager
    from matplotlib.figure import Figure
    from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
    MATPLOTLIB_AVAILABLE = True
except ImportError:
    MATPLOTLIB_AVAILABLE = False

if MATPLOTLIB_AVAILABLE:
    # 日本語の列名・項目名が □(豆腐)にならないよう、入っている日本語フォントを探して使う
    installed = {f.name for f in font_manager.fontManager.ttflist}
    for font_name in ("Meiryo", "Yu Gothic", "MS Gothic", "Noto Sans CJK JP",
                      "Noto Sans JP", "IPAexGothic", "Hiragino Sans"):
        if font_name in installed:
            matplotlib.rcParams["font.family"] = font_name
            matplotlib.rcParams["axes.unicode_minus"] = False
            break


class App078:
    """CSVグラフダッシュボード"""

    CHART_TYPES = ["折れ線", "棒グラフ", "散布図", "面グラフ", "円グラフ", "ヒストグラム"]
    AGG_FUNCS = {"なし": None, "合計": "sum", "平均": "mean",
                 "件数": "count", "最大": "max", "最小": "min"}
    COLORS = ["#4fc3f7", "#ef5350", "#26a69a", "#ffa726", "#ab47bc",
              "#66bb6a", "#ec407a", "#29b6f6", "#ffca28", "#8d6e63"]

    def __init__(self, root):
        self.root = root
        self.root.title("CSVグラフダッシュボード")
        self.root.geometry("1050x660")
        self.root.configure(bg="#1e1e1e")
        self.df = None
        self._build_ui()

    def _build_ui(self):
        header = tk.Frame(self.root, bg="#252526", pady=6)
        header.pack(fill=tk.X)
        tk.Label(header, text="📊 CSVグラフダッシュボード",
                 font=("Noto Sans JP", 12, "bold"),
                 bg="#252526", fg="#4fc3f7").pack(side=tk.LEFT, padx=12)

        missing = []
        if not PANDAS_AVAILABLE:
            missing.append("pandas")
        if not MATPLOTLIB_AVAILABLE:
            missing.append("matplotlib")
        if missing:
            tk.Label(self.root,
                     text="⚠ " + "・".join(missing) + " が未インストールです"
                          " (pip install " + " ".join(missing) + ")",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        # ツールバー
        tb = tk.Frame(self.root, bg="#2d2d2d", pady=4)
        tb.pack(fill=tk.X)
        ttk.Button(tb, text="📂 CSV読み込み",
                   command=self._open_csv).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="▶ グラフ描画",
                   command=self._draw).pack(side=tk.LEFT, padx=4)
        ttk.Button(tb, text="💾 グラフ保存",
                   command=self._save_chart).pack(side=tk.LEFT, padx=4)

        # メインエリア
        paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)

        # 左パネル: データ設定
        left = tk.Frame(paned, bg="#1e1e1e", width=280)
        paned.add(left, weight=0)

        # グラフ設定
        cfg = tk.LabelFrame(left, text="グラフ設定", bg="#252526",
                             fg="#ccc", font=("Arial", 9), padx=6, pady=6)
        cfg.pack(fill=tk.X, padx=4, pady=4)

        tk.Label(cfg, text="グラフ種別:", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).grid(row=0, column=0, sticky="w")
        self.chart_type_var = tk.StringVar(value="折れ線")
        self.chart_type_cb = ttk.Combobox(cfg, textvariable=self.chart_type_var,
                                           values=self.CHART_TYPES,
                                           state="readonly", width=12)
        self.chart_type_cb.grid(row=0, column=1, padx=4, pady=2)

        tk.Label(cfg, text="集計方法:", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).grid(row=1, column=0, sticky="w")
        self.agg_var = tk.StringVar(value="なし")
        self.agg_cb = ttk.Combobox(cfg, textvariable=self.agg_var,
                                    values=list(self.AGG_FUNCS),
                                    state="readonly", width=12)
        self.agg_cb.grid(row=1, column=1, padx=4, pady=2)

        # 種別・集計方法を選び直したら、その場でグラフを描き直す
        for cb in (self.chart_type_cb, self.agg_cb):
            cb.bind("<<ComboboxSelected>>", self._on_option_changed)

        tk.Label(cfg, text="X軸 (列):", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).grid(row=2, column=0, sticky="w")
        self.xcol_var = tk.StringVar()
        self.xcol_cb = ttk.Combobox(cfg, textvariable=self.xcol_var,
                                     state="readonly", width=12)
        self.xcol_cb.grid(row=2, column=1, padx=4, pady=2)

        tk.Label(cfg, text="Y軸 (列):", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).grid(row=3, column=0, sticky="w")
        self.ycol_var = tk.StringVar()
        self.ycol_cb = ttk.Combobox(cfg, textvariable=self.ycol_var,
                                     state="readonly", width=12)
        self.ycol_cb.grid(row=3, column=1, padx=4, pady=2)

        tk.Label(cfg, text="タイトル:", bg="#252526", fg="#ccc",
                 font=("Arial", 9)).grid(row=4, column=0, sticky="w")
        self.title_var = tk.StringVar(value="CSVデータグラフ")
        ttk.Entry(cfg, textvariable=self.title_var,
                  width=14).grid(row=4, column=1, padx=4, pady=2)

        self.grid_var = tk.BooleanVar(value=True)
        tk.Checkbutton(cfg, text="グリッド表示", variable=self.grid_var,
                       bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
                       activebackground="#252526").grid(
            row=5, column=0, columnspan=2, sticky="w")

        self.legend_var = tk.BooleanVar(value=True)
        tk.Checkbutton(cfg, text="凡例表示", variable=self.legend_var,
                       bg="#252526", fg="#ccc", selectcolor="#3c3c3c",
                       activebackground="#252526").grid(
            row=6, column=0, columnspan=2, sticky="w")

        # 複数Y列選択
        tk.Label(left, text="Y軸 複数選択 (Ctrl+クリック):",
                 bg="#1e1e1e", fg="#888", font=("Arial", 8)).pack(
            anchor="w", padx=4, pady=(8, 0))
        self.ycols_listbox = tk.Listbox(left, selectmode=tk.MULTIPLE,
                                         bg="#0d1117", fg="#c9d1d9",
                                         font=("Arial", 9), height=6,
                                         relief=tk.FLAT,
                                         selectbackground="#1f6feb")
        self.ycols_listbox.pack(fill=tk.X, padx=4)

        # データプレビュー
        tk.Label(left, text="データプレビュー", bg="#1e1e1e", fg="#888",
                 font=("Arial", 8)).pack(anchor="w", padx=4, pady=(8, 0))
        preview_cols = ("col", "type", "sample")
        self.preview_tree = ttk.Treeview(left, columns=preview_cols,
                                          show="headings", height=6)
        self.preview_tree.heading("col",    text="列名")
        self.preview_tree.heading("type",   text="型")
        self.preview_tree.heading("sample", text="サンプル")
        self.preview_tree.column("col",    width=80)
        self.preview_tree.column("type",   width=50)
        self.preview_tree.column("sample", width=100)
        self.preview_tree.pack(fill=tk.X, padx=4)

        # 右パネル: グラフ
        right = tk.Frame(paned, bg="#0d1117")
        paned.add(right, weight=1)

        if MATPLOTLIB_AVAILABLE:
            self.fig = Figure(facecolor="#0d1117")
            self.ax  = self.fig.add_subplot(111)
            self.mpl_canvas = FigureCanvasTkAgg(self.fig, master=right)
            toolbar = NavigationToolbar2Tk(self.mpl_canvas, right)
            toolbar.update()
            toolbar.pack(side=tk.TOP, fill=tk.X)
            self.mpl_canvas.get_tk_widget().pack(fill=tk.BOTH, expand=True)
        else:
            tk.Label(right, text="matplotlib が必要です",
                     bg="#0d1117", fg="#555").pack(expand=True)

        self.status_var = tk.StringVar(value="CSVファイルを読み込んでください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#252526", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    def _on_option_changed(self, event=None):
        """グラフ種別・集計方法を選び直したら、その場で描き直す"""
        if self.df is not None and MATPLOTLIB_AVAILABLE:
            self._draw()

    def _open_csv(self):
        if not PANDAS_AVAILABLE:
            messagebox.showerror("エラー", "pandas が必要です (pip install pandas)")
            return
        path = filedialog.askopenfilename(
            filetypes=[("CSV", "*.csv"), ("TSV", "*.tsv"), ("すべて", "*.*")])
        if not path:
            return
        sep = "\t" if path.lower().endswith(".tsv") else ","
        try:
            try:
                df = pd.read_csv(path, sep=sep, encoding="utf-8-sig")
            except UnicodeDecodeError:
                # Excel が書き出した日本語CSVは cp932 のことがある
                df = pd.read_csv(path, sep=sep, encoding="cp932")
        except Exception as e:
            messagebox.showerror("エラー", str(e))
            return
        if df.empty or len(df.columns) == 0:
            messagebox.showwarning("警告", "空のファイルです")
            return
        self.df = df
        self._update_columns()
        self.status_var.set(
            f"読み込み: {path}  ({len(df)} 行 × {len(df.columns)} 列)")

    def _update_columns(self):
        headers = [str(c) for c in self.df.columns]
        self.xcol_cb["values"] = headers
        self.ycol_cb["values"] = headers
        if headers:
            self.xcol_var.set(headers[0])
            self.ycol_var.set(headers[-1] if len(headers) > 1 else headers[0])
        self.ycols_listbox.delete(0, tk.END)
        for h in headers:
            self.ycols_listbox.insert(tk.END, h)

        # プレビュー: pandas が判定した dtype と先頭行の値を並べる
        self.preview_tree.delete(*self.preview_tree.get_children())
        for col in self.df.columns:
            series = self.df[col]
            sample = "" if series.empty else str(series.iloc[0])
            self.preview_tree.insert("", tk.END,
                                      values=(str(col), str(series.dtype),
                                              sample[:20]))

    def _selected_y_cols(self):
        """リストボックスで選んだY列(未選択なら Y軸コンボボックスの1列)を返す"""
        headers = [str(c) for c in self.df.columns]
        sel = self.ycols_listbox.curselection()
        if sel:
            y_cols = [headers[i] for i in sel if i < len(headers)]
        else:
            y_cols = [self.ycol_var.get()]
        return [c for c in y_cols if c in headers]

    def _aggregate(self, x_col, y_cols, agg_name):
        """X列でグループ化して pandas で集計した DataFrame を返す。

        集計方法が「なし」のときは読み込んだデータをそのまま使う。
        Y列は to_numeric で数値化し、数値にならない値は NaN として扱う。
        """
        cols = [x_col] + [c for c in y_cols if c != x_col]
        df = self.df.loc[:, cols].copy()
        for c in cols[1:]:
            df[c] = pd.to_numeric(df[c], errors="coerce")
        func = self.AGG_FUNCS.get(agg_name)
        if func is None:
            return df
        return df.groupby(x_col, as_index=False, sort=True).agg(func)

    def _draw(self):
        if not MATPLOTLIB_AVAILABLE:
            messagebox.showerror("エラー", "matplotlib が必要です (pip install matplotlib)")
            return
        if self.df is None:
            messagebox.showwarning("警告", "先にCSVを読み込んでください")
            return
        # tkinter の変数はメインスレッドで読み、集計だけをワーカースレッドへ渡す
        opts = {"chart_type": self.chart_type_var.get(),
                "agg_name":   self.agg_var.get(),
                "x_col":      self.xcol_var.get(),
                "y_cols":     self._selected_y_cols()}
        if not opts["x_col"]:
            self.status_var.set("X軸の列を選んでください")
            return
        threading.Thread(target=self._do_draw, args=(opts,), daemon=True).start()

    def _do_draw(self, opts):
        """集計をワーカースレッドで行い、描画は after() でメインスレッドへ戻す"""
        try:
            plot_df = self._aggregate(opts["x_col"], opts["y_cols"],
                                      opts["agg_name"])
        except Exception as e:
            message = f"集計エラー: {e}"
            self.root.after(0, lambda: self.status_var.set(message))
            return
        self.root.after(0, lambda: self._render_chart(plot_df, opts))

    def _render_chart(self, plot_df, opts):
        chart_type = opts["chart_type"]
        x_col = opts["x_col"]
        y_cols = [c for c in opts["y_cols"]
                  if c in plot_df.columns and c != x_col] or [x_col]

        self.ax.clear()
        self.ax.set_facecolor("#0d1117")

        x_series = plot_df[x_col]
        x_num = pd.to_numeric(x_series, errors="coerce")
        numeric_x = len(x_num) > 0 and bool(x_num.notna().all())
        labels = [str(v) for v in x_series.tolist()]
        xn = x_num.tolist() if numeric_x else list(range(len(x_series)))

        if chart_type == "円グラフ":
            series = pd.to_numeric(plot_df[y_cols[0]], errors="coerce").dropna()
            if series.empty:
                self.status_var.set("描画エラー: 数値として読める列を選んでください")
                return
            pie_labels = [str(v) for v in plot_df.loc[series.index, x_col].tolist()]
            self.ax.pie(series.tolist(), labels=pie_labels,
                         colors=self.COLORS,
                         autopct="%1.1f%%", startangle=90,
                         textprops={"color": "#c9d1d9", "fontsize": 7})
        elif chart_type == "ヒストグラム":
            series = pd.to_numeric(plot_df[y_cols[0]], errors="coerce").dropna()
            if series.empty:
                self.status_var.set("描画エラー: 数値として読める列を選んでください")
                return
            self.ax.hist(series.tolist(), bins=20,
                         color=self.COLORS[0], edgecolor="#0d1117")
        else:
            drawn = 0
            for i, y_col in enumerate(y_cols):
                ys = pd.to_numeric(plot_df[y_col], errors="coerce")
                keep = ys.notna().tolist()
                yn = [v for v, ok in zip(ys.tolist(), keep) if ok]
                xs = [v for v, ok in zip(xn, keep) if ok]
                if not yn:
                    continue
                drawn += 1
                color = self.COLORS[i % len(self.COLORS)]

                if chart_type == "折れ線":
                    self.ax.plot(xs, yn, color=color, label=y_col, linewidth=1.5)
                elif chart_type == "棒グラフ":
                    self.ax.bar([x + i*0.15 for x in xs], yn,
                                 width=0.15, color=color, label=y_col)
                elif chart_type == "散布図":
                    self.ax.scatter(xs, yn, color=color, label=y_col, s=20)
                elif chart_type == "面グラフ":
                    self.ax.fill_between(xs, yn, alpha=0.4, color=color, label=y_col)
                    self.ax.plot(xs, yn, color=color, linewidth=1)
            if drawn == 0:
                self.status_var.set("描画エラー: 数値として読める列を選んでください")
                return
            if not numeric_x and labels:
                # X軸が文字列の列なら、目盛りにその文字列を並べる(最大20個)
                step = max(1, len(labels) // 20)
                ticks = list(range(0, len(labels), step))
                self.ax.set_xticks(ticks)
                self.ax.set_xticklabels([labels[t] for t in ticks],
                                         rotation=30, ha="right")

        # スタイリング
        self.ax.set_title(self.title_var.get(), color="#c9d1d9", fontsize=11)
        self.ax.set_xlabel(x_col, color="#8b949e", fontsize=9)
        self.ax.tick_params(colors="#8b949e", labelsize=7)
        for spine in self.ax.spines.values():
            spine.set_edgecolor("#30363d")
        if self.grid_var.get():
            self.ax.grid(True, color="#21262d", linewidth=0.5)
        if self.legend_var.get() and chart_type not in ("円グラフ", "ヒストグラム"):
            self.ax.legend(facecolor="#161b22", edgecolor="#30363d",
                            labelcolor="#c9d1d9", fontsize=8)
        self.fig.tight_layout()
        self.mpl_canvas.draw()
        agg_note = "" if opts["agg_name"] == "なし" else f"  集計={opts['agg_name']}"
        self.status_var.set(
            f"描画完了: {chart_type}{agg_note}  X={x_col}  Y={', '.join(y_cols)}")

    def _save_chart(self):
        if not MATPLOTLIB_AVAILABLE:
            return
        path = filedialog.asksaveasfilename(
            initialfile="chart.png",
            defaultextension=".png",
            filetypes=[("PNG", "*.png"), ("SVG", "*.svg"), ("PDF", "*.pdf")])
        if not path:
            return
        try:
            self.fig.savefig(path, facecolor="#0d1117", dpi=150)
            self.status_var.set(f"保存完了: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))


if __name__ == "__main__":
    root = tk.Tk()
    app = App078(root)
    root.mainloop()

5. コード解説

CSVグラフダッシュボードのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App078 クラスにアプリの全機能をまとめています(メソッド11個・全394行)。__init__ ではタイトル「CSVグラフダッシュボード」とウィンドウサイズ 1050x660 を設定します。あわせて状態を保持する変数(self.df)を初期化し、最後に _build_ui() で画面を組み立てます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×4・tk.Label×11・ttk.Button×3・ttk.PanedWindowtk.LabelFramettk.Combobox×4・ttk.Entrytk.Checkbutton×2・tk.Listboxttk.Treeview で構成しています。見出し付きの枠(LabelFrame)は「グラフ設定」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「📂 CSV読み込み」ボタン(_open_csv())・「▶ グラフ描画」ボタン(_draw())・「💾 グラフ保存」ボタン(_save_chart())を command= で接続しています。また bind("<<ComboboxSelected>>", ...) から _on_option_changed() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:CHART_TYPES = ['折れ線', '棒グラフ', '散布図', '面グラフ', '円グラフ', 'ヒストグラム']AGG_FUNCS = {'なし': None, '合計': 'sum', '平均': 'mean', '件数': 'count', '最大': 'max', '最小': 'min'}。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で ExceptionImportErrorUnicodeDecodeError を捕捉しています。あわせて messagebox.showerror()messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app078.py と保存します。外部ライブラリ matplotlibpandas を使います。先に pip install matplotlib pandas を実行してください。

  2. 2
    クラスの骨格を作る

    App078 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("CSVグラフダッシュボード")geometry("1050x660")configure(bg="#1e1e1e") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×4・tk.Label×11・ttk.Button×3・ttk.PanedWindowtk.LabelFramettk.Combobox×4・ttk.Entrytk.Checkbutton×2・tk.Listboxttk.Treeview を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_open_csv()_draw()_save_chart())につなぎます。bind("<<ComboboxSelected>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _render_chart()(81行)・_open_csv()(25行)・_draw()(16行)など を実装します。

  7. 7
    動作確認する

    python app078.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(CHART_TYPESAGG_FUNCS)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("1050x660") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。練習用に、見出し 月,売上,費用1月,100,402月,200,503月,300,60 の3行だけのCSVを新しく作ってください。手元の実データを読み込む必要はありません。実行には matplotlibpandas が必要です(pip install matplotlib pandas)。

  1. 課題1:描く前でも「💾 グラフ保存」で空の画像ができる

    「💾 グラフ保存」は matplotlib が入っているかどうかしか見ていません。CSVを読み込む前でも保存ダイアログが開き、目盛りだけの画像ができあがります。描くものが無いときは保存させないでください。

    期待結果:アプリを起動してすぐ「💾 グラフ保存」を押し、chart.png として保存する。警告は出ずにステータスが 保存完了: に変わり、できた画像は目盛りが 0.0 から 1.0 まで並んだだけの白い四角になる。直したあとは保存ダイアログが開く前に警告が出て、ファイルは作られない。

    合格条件

    • 一度でも描画したあとは、これまでどおり保存できる
    • 保存ダイアログで「キャンセル」を押したときの動きは変わらない
    • 「描画エラー」と出た直後も保存できない

    ヒント①(どこを触るか): 触るのは _save_chart() の先頭1か所です。if not MATPLOTLIB_AVAILABLE: の下、filedialog.asksaveasfilename(...) の前に足します。
    ヒント②(使うもの): self.ax.has_data() で、その軸に描かれたものがあるか分かります(plotbarscatterpiehist のどれでも True になります)。警告は _draw() にある messagebox.showwarning("警告", "先にCSVを読み込んでください") が手本です。
    つまずきやすい点: self.figself.ax は起動時に作られるので、これらの有無では判定できません。self.df is None だけで判定すると、CSVを読み込んだあと一度も描いていない場合は空の画像のままです。_render_chart() は先頭で self.ax.clear() を呼んでから失敗時に抜けるため、失敗直後の has_data()False を返します。

  2. 課題2:円グラフのとき使っていない列名まで表示される

    円グラフとヒストグラムは、選んだY列のうち先頭の1列しか使いません。それなのに下のステータスには選んだ列がすべて並びます。実際に使った列だけを出すようにしてください。

    期待結果:練習用CSVを読み込み、「Y軸 複数選択」で 売上費用 を選ぶ。グラフ種別を「円グラフ」にすると、円は 売上 の 100・200・300 だけで描かれ(16.7%33.3%50.0%)、ステータスは 描画完了: 円グラフ X=月 Y=売上, 費用 と2列とも並ぶ。直したあとは Y=売上 だけになる。

    合格条件

    • 折れ線・棒グラフ・散布図・面グラフでは、これまでどおり描いた列が並ぶ
    • ヒストグラムも円グラフと同じ扱いになる
    • グラフの絵そのものは変わらない

    ヒント①(どこを触るか): 触るのは _render_chart() の末尾1か所です。self.status_var.set(f"描画完了: ...") を組み立てているところです。
    ヒント②(使うもの): 表示用のリストを別に作ります(used = y_cols[:1] if chart_type in ("円グラフ", "ヒストグラム") else y_cols)。同じ条件は少し上の凡例の判定 if self.legend_var.get() and chart_type not in ("円グラフ", "ヒストグラム"): にもあります。
    つまずきやすい点: y_cols は関数の先頭で作り直され、X軸と同じ列は除かれます(残らなければX列そのものになります)ので、opts["y_cols"] ではなくこちらを使ってください。折れ線などでは数値に直せない列が continue で飛ばされるため、厳密に「描けた列」だけを出したいなら drawn を数えている for の中で名前を集める必要があります。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。