使用-Python-和-Tkinter-构建现代仪表板

使用 Python 和 Tkinter 构建现代仪表板

原文:towardsdatascience.com/building-a-modern-dashboard-with-python-and-tkinter/

在 Gradio、Streamlit 和 Taipy 出现之前,就有了 Tkinter。Tkinter 曾经是,现在仍然是原始的 Python GUI 构建器,直到几年前,它还是你使用 Python 创建任何类型仪表板或 GUI 的少数几种方式之一。

随着像上面提到的那些基于网络的框架在桌面数据中心和机器学习应用的展示中占据了主导地位,我们提出了这样的问题,“使用 Tkinter 库是否仍有潜力可挖?”

我对这个问题的回答是响亮的 Yes!我希望在这篇文章中证明 Tkinter 仍然是一个强大、轻量级且高度相关的工具,用于创建原生桌面 GUI 和数据仪表板应用程序。

对于需要创建内部工具、简单实用程序或教育软件的开发者来说,Tkinter 可以是一个理想的选择。它不需要复杂的网络服务器、JavaScript 知识或重型依赖。它是纯粹的 Python。正如我稍后所展示的,你可以用它制作一些相当复杂、看起来很现代的仪表板。

在本文的其余部分,我们将从 Tkinter 的基本原理出发,到实际构建一个动态、数据驱动的仪表板,证明这个“OG” GUI 库仍然有很多现代技巧。

什么是 Tkinter,为什么你仍然应该关心它?

Tkinter 是 Python 的标准、内置图形用户界面(GUI)工具包。这个名字是“Tk Interface”的双关语。它围绕 Tcl/Tk 构建,这是一个自 20 世纪 90 年代初以来就存在的强大且跨平台的 GUI 工具包。

它最大的优势是包含在 Python 标准库中。这意味着如果你安装了 Python,你就有了 Tkinter。无需运行 pip 安装命令,无需解决虚拟环境依赖冲突。它在 Windows、macOS 和 Linux 上开箱即用。

那么,为什么在闪亮的网络框架时代选择 Tkinter?

  • 简单与速度:对于中小型应用,Tkinter 的开发速度很快。你只需几行代码就可以拥有一个带有交互元素的实用窗口。

  • 轻量级:Tkinter 应用程序占用很小的空间。它们不需要浏览器或网络服务器,这使得它们非常适合需要高效在任何机器上运行的简单实用程序。

  • 原生外观和感觉(在一定程度上):虽然经典的 Tkinter 有一个著名的过时外观,但 ttk 主题小部件集提供了访问更多现代、原生外观的控制,更好地匹配宿主操作系统。

  • 非常适合学习:Tkinter 教授事件驱动编程的基本概念——所有 GUI 开发的核心。了解如何在 Tkinter 中管理小部件、布局和用户事件,为学习任何其他 GUI 框架提供了坚实的基础。

当然,它也有其缺点。复杂、美学要求高的应用程序可能很难构建,并且它们的设计理念可能比 Streamlit 或 Gradio 的声明式风格更冗长。然而,对于其预期目的——创建功能性的独立桌面应用程序——它非常出色。

然而,随着时间的推移,已经编写了额外的库,使 Tkinter GUI 看起来更现代。我们将使用的一个,叫做 ttkbootstrap。这是建立在 Tkinter 之上的,增加了额外的组件,可以使你的 GUI 看起来像 Bootstrap 风格。

核心概念

每个 Tkinter 应用程序都是建立在几个关键支柱之上的。在你可以创建任何有意义的东西之前,掌握这些概念是至关重要的。

1/ 根窗口

根窗口是整个应用程序的主要容器。它是具有标题栏、最小化、最大化和关闭按钮的最高级窗口。你可以用一行代码创建它,如下所示。

import tkinter as tk

root = tk.Tk()
root.title("My First Tkinter App")

root.mainloop()

这段代码会产生这个结果。看起来并不那么令人兴奋,但这是一个开始。

图片

图片由作者提供

在你的应用程序中,其他所有内容——按钮、标签、输入字段等——都将生活在这个根窗口内。

2/ 几何管理器

小部件是 GUI 的构建块。它们是用户看到并与之交互的元素。一些最常见的小部件包括:

  • 标签:显示静态文本或图像。

  • 按钮:一个可点击的按钮,可以触发一个函数。

  • 输入:一个单行文本输入字段。

  • 文本:一个多行文本输入和显示区域。

  • 框架:一个不可见的矩形容器,用于组合其他小部件。这对于组织复杂的布局至关重要。

  • 画布:一个用于绘制形状、创建图表或显示图像的多功能小部件。

  • 复选框和单选按钮:用于布尔或多项选择。

3/ 几何管理器

一旦你创建了你的小部件,你需要告诉 Tkinter 将它们放在窗口内的哪个位置。这是几何管理器的职责。请注意,你无法在同一个父容器(如根或 Frame)内混合使用不同的管理器。

  • pack():最简单的管理器。它将小部件“打包”到窗口中,可以是垂直或水平方向。对于简单的布局来说很快,但提供的精确控制很少。

  • place():最精确的管理器。它允许你指定小部件的确切像素坐标(x, y)和大小(宽度,高度)。这通常应该避免,因为它会使你的应用程序变得僵硬,无法响应窗口大小的调整。

  • grid():最强大且灵活的管理器,也是我们将用于仪表板的管理器。它以行和列的表格结构组织小部件,非常适合创建对齐的结构化布局。

4/ 主循环

root.mainloop() 是任何 Tkinter 应用程序的最终和最关键的部分。此方法启动事件循环。应用程序进入等待状态,监听用户操作,如鼠标点击、按键或窗口调整大小。当发生事件时,Tkinter 处理它(例如,调用与按钮点击相关联的函数),然后返回循环。只有当此循环终止时,通常是通过关闭窗口,应用程序才会关闭。

设置开发环境

在我们开始编码之前,让我们设置一个开发环境。我正在逐渐将 UV 命令行工具切换到我的环境设置中,以替换 conda,这就是我们将在这里使用的工具。

# initialise our project
uv init tktest
cd tktest
# create a new venv
uv venv tktest
# switch to it
tktest\Scripts\activate
# Install required external libraries
(tktest) uv pip install matplotlib ttkbootstrap pandas

示例 1:简单的“Hello, Tkinter!”应用程序

让我们将这些概念付诸实践。我们将创建一个带有标签和按钮的窗口。当按钮被点击时,标签的文本将改变。

import tkinter as tk

# 1\. Create the root window
root = tk.Tk()
root.title("Simple Interactive App")
root.geometry("300x150") # Set window size: width x height

# This function will be called when the button is clicked
def on_button_click():
    # Update the text of the label widget
    label.config(text="Hello, Tkinter!")

# 2\. Create the widgets
label = tk.Label(root, text="Click the button below.")
button = tk.Button(root, text="Click Me!", command=on_button_click)

# 3\. Use a geometry manager to place the widgets
# We use pack() for this simple layout
label.pack(pady=20) # pady adds some vertical padding
button.pack()

# 4\. Start the main event loop
root.mainloop()

它应该看起来像这样,右边的图片是您点击按钮时得到的结果。

图片

图片由作者提供

到目前为止,一切都很简单;然而,您可以使用 Tkinter 创建现代、视觉上吸引人的 GUI 和仪表板。为了说明这一点,我们将创建一个更全面和复杂的应用程序,展示 Tkinter 能做什么。

示例 2—现代数据仪表板

对于这个示例,我们将使用 Kaggle 上的 CarsForSale 数据集创建一个数据仪表板。它附带 CC0:公共领域许可,这意味着它可以免费用于大多数目的。

这是一个以美国为中心的数据集,包含大约 40 个不同制造商在 2001-2022 年期间约 9300 种不同车型的销售和性能细节。您可以通过以下链接获取:

www.kaggle.com/datasets/chancev/carsforsale/data

下载数据集并将其保存到您本地系统上的 CSV 文件中。

注意:此数据集根据 CC0: 公共领域 许可提供,因此在此上下文中使用是允许的。

图片

图片来自 Kaggle 网站

这个示例将比第一个更复杂,但我希望给您一个 Tkinter 能实现的具体想法。接下来,我将展示代码并描述其一般功能,然后再检查它产生的 GUI。

###############################################################################
#  USED-CAR MARKETPLACE DASHBOARD 
#
#
###############################################################################
import tkinter as tk
import ttkbootstrap as tb
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator
import pandas as pd, numpy as np, re, sys
from pathlib import Path
from textwrap import shorten

# ─────────────────────────  CONFIG  ──────────────────────────
CSV_PATH = r"C:\Users\thoma\temp\carsforsale.csv"       
COLUMN_ALIASES = {
    "brand": "make", "manufacturer": "make", "carname": "model",
    "rating": "consumerrating", "safety": "reliabilityrating",
}
REQUIRED = {"make", "price"}                            
# ──────────────────────────────────────────────────────────────

class Dashboard:
    # ═══════════════════════════════════════════════════════════
    def __init__(self, root: tb.Window):
        self.root = root
        self.style = tb.Style("darkly")
        self._make_spinbox_style()
        self.clr = self.style.colors

        self.current_analysis_plot_func = None 

        self._load_data()
        self._build_gui()
        self._apply_filters()

    # ─────────── spin-box style (white arrows) ────────────────
    def _make_spinbox_style(self):
        try:
            self.style.configure("White.TSpinbox",
                                 arrowcolor="white",
                                 arrowsize=12)
            self.style.map("White.TSpinbox",
                           arrowcolor=[("disabled", "white"),
                                       ("active",   "white"),
                                       ("pressed",  "white")])
        except tk.TclError:
            pass

    # ───────────────────── DATA LOAD ───────────────────────────
    def _load_data(self):
        csv = Path(CSV_PATH)
        if not csv.exists():
            tb.dialogs.Messagebox.show_error("CSV not found", str(csv))
            sys.exit()

        df = pd.read_csv(csv, encoding="utf-8-sig", skipinitialspace=True)
        df.columns = [
            COLUMN_ALIASES.get(
                re.sub(r"[⁰-9a-z]", "", c.lower().replace("\ufeff", "")),
                c.lower()
            )
            for c in df.columns
        ]
        if "year" not in df.columns:
            for col in df.columns:
                nums = pd.to_numeric(df[col], errors="coerce")
                if nums.dropna().between(1900, 2035).all():
                    df.rename(columns={col: "year"}, inplace=True)
                    break
        for col in ("price", "minmpg", "maxmpg",
                    "year", "mileage", "consumerrating"):
            if col in df.columns:
                df[col] = pd.to_numeric(
                    df[col].astype(str)
                          .str.replace(r"[^\d.]", "", regex=True),
                    errors="coerce"
                )
        if any(c not in df.columns for c in REQUIRED):
            tb.dialogs.Messagebox.show_error(
                "Bad CSV", "Missing required columns.")
            sys.exit()
        self.df = df.dropna(subset=["make", "price"])

    # ───────────────────── GUI BUILD ───────────────────────────
    def _build_gui(self):
        header = tb.Frame(self.root, width=600, height=60, bootstyle="dark")
        header.pack_propagate(False)
        header.pack(side="top", anchor="w", padx=8, pady=(4, 2))
        tb.Label(header, text="🚗  USED-CAR DASHBOARD",
                 font=("Segoe UI", 16, "bold"), anchor="w")\
          .pack(fill="both", padx=8, pady=4)

        self.nb = tb.Notebook(self.root); self.nb.pack(fill="both", expand=True)
        self._overview_tab()
        self._analysis_tab()
        self._data_tab()

    # ─────────────────  OVERVIEW TAB  ─────────────────────────
    def _overview_tab(self):
        tab = tb.Frame(self.nb); self.nb.add(tab, text="Overview")
        self._filters(tab)
        self._cards(tab)
        self._overview_fig(tab)

    def _spin(self, parent, **kw):
        return tb.Spinbox(parent, style="White.TSpinbox", **kw)

    def _filters(self, parent):
        f = tb.Labelframe(parent, text="Filters", padding=6)
        f.pack(fill="x", padx=8, pady=6)
        tk.Label(f, text="Make").grid(row=0, column=0, sticky="w", padx=4)
        self.make = tk.StringVar(value="All")
        tb.Combobox(f, textvariable=self.make, state="readonly", width=14,
                    values=["All"] + sorted(self.df["make"].unique()),
                    bootstyle="dark")\
          .grid(row=0, column=1)
        self.make.trace_add("write", self._apply_filters)
        if "drivetrain" in self.df.columns:
            tk.Label(f, text="Drivetrain").grid(row=0, column=2, padx=(20, 4))
            self.drive = tk.StringVar(value="All")
            tb.Combobox(f, textvariable=self.drive, state="readonly", width=14,
                        values=["All"] + sorted(self.df["drivetrain"].dropna()
                                                .unique()),
                        bootstyle="dark")\
              .grid(row=0, column=3)
            self.drive.trace_add("write", self._apply_filters)
        pr_min, pr_max = self.df["price"].min(), self.df["price"].max()
        tk.Label(f, text="Price $").grid(row=0, column=4, padx=(20, 4))
        self.pmin = tk.DoubleVar(value=float(pr_min))
        self.pmax = tk.DoubleVar(value=float(pr_max))
        for col, var in [(5, self.pmin), (6, self.pmax)]:
            self._spin(f, from_=0, to=float(pr_max), textvariable=var,
                       width=10, increment=1000, bootstyle="secondary")\
              .grid(row=0, column=col)
        if "year" in self.df.columns:
            yr_min, yr_max = int(self.df["year"].min()), int(self.df["year"].max())
            tk.Label(f, text="Year").grid(row=0, column=7, padx=(20, 4))
            self.ymin = tk.IntVar(value=yr_min)
            self.ymax = tk.IntVar(value=yr_max)
            for col, var in [(8, self.ymin), (9, self.ymax)]:
                self._spin(f, from_=1900, to=2035, textvariable=var,
                           width=6, bootstyle="secondary")\
                  .grid(row=0, column=col)
        tb.Button(f, text="Apply Year/Price Filters",
                  bootstyle="primary-outline",
                  command=self._apply_filters)\
          .grid(row=0, column=10, padx=(30, 4))

    def _cards(self, parent):
        wrap = tb.Frame(parent); wrap.pack(fill="x", padx=8)
        self.cards = {}
        for lbl in ("Total Cars", "Average Price",
                    "Average Mileage", "Avg Rating"):
            card = tb.Frame(wrap, padding=6, relief="ridge", bootstyle="dark")
            card.pack(side="left", fill="x", expand=True, padx=4, pady=4)
            val = tb.Label(card, text="-", font=("Segoe UI", 16, "bold"),
                           foreground=self.clr.info)
            val.pack()
            tb.Label(card, text=lbl, foreground="white").pack()
            self.cards[lbl] = val

    def _overview_fig(self, parent):
        fr = tb.Frame(parent); fr.pack(fill="both", expand=True, padx=8, pady=6)
        self.ov_fig = plt.Figure(figsize=(18, 10), facecolor="#1e1e1e",
                                 constrained_layout=True)
        self.ov_canvas = FigureCanvasTkAgg(self.ov_fig, master=fr)
        self.ov_canvas.get_tk_widget().pack(fill="both", expand=True)

    # ───────────────── ANALYSIS TAB ──────────────────────────
    def _analysis_tab(self):
        tab = tb.Frame(self.nb); self.nb.add(tab, text="Analysis")
        ctl = tb.Frame(tab); ctl.pack(fill="x", padx=8, pady=6)
        def set_and_run_analysis(plot_function):
            self.current_analysis_plot_func = plot_function
            plot_function()
        for txt, fn in (("Correlation", self._corr),
                        ("Price by Make", self._price_make),
                        ("MPG", self._mpg),
                        ("Ratings", self._ratings)):
            tb.Button(ctl, text=txt, command=lambda f=fn: set_and_run_analysis(f),
                      bootstyle="info-outline").pack(side="left", padx=4)
        self.an_fig = plt.Figure(figsize=(12, 7), facecolor="#1e1e1e",
                                 constrained_layout=True)
        self.an_canvas = FigureCanvasTkAgg(self.an_fig, master=tab)
        w = self.an_canvas.get_tk_widget()
        w.configure(width=1200, height=700)
        w.pack(padx=8, pady=4)

    # ───────────────── DATA TAB ────────────────────────────────
    def _data_tab(self):
        tab = tb.Frame(self.nb); self.nb.add(tab, text="Data")
        top = tb.Frame(tab); top.pack(fill="x", padx=8, pady=6)
        tk.Label(top, text="Search").pack(side="left")
        self.search = tk.StringVar()
        tk.Entry(top, textvariable=self.search, width=25)\
          .pack(side="left", padx=4)
        self.search.trace_add("write", self._search_tree)
        cols = list(self.df.columns)
        self.tree = tb.Treeview(tab, columns=cols, show="headings",
                                bootstyle="dark")
        for c in cols:
            self.tree.heading(c, text=c.title())
            self.tree.column(c, width=120, anchor="w")
        ysb = tb.Scrollbar(tab, orient="vertical", command=self.tree.yview)
        xsb = tb.Scrollbar(tab, orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscroll=ysb.set, xscroll=xsb.set)
        self.tree.pack(side="left", fill="both", expand=True)
        ysb.pack(side="right", fill="y"); xsb.pack(side="bottom", fill="x")

    # ───────────────── FILTER & STATS ──────────────────────────
    def _apply_filters(self, *_):
        df = self.df.copy()
        if self.make.get() != "All":
            df = df[df["make"] == self.make.get()]
        if hasattr(self, "drive") and self.drive.get() != "All":
            df = df[df["drivetrain"] == self.drive.get()]
        try:
            pmin, pmax = float(self.pmin.get()), float(self.pmax.get())
        except ValueError:
            pmin, pmax = df["price"].min(), df["price"].max()
        df = df[(df["price"] >= pmin) & (df["price"] <= pmax)]
        if "year" in df.columns and hasattr(self, "ymin"):
            try:
                ymin, ymax = int(self.ymin.get()), int(self.ymax.get())
            except ValueError:
                ymin, ymax = df["year"].min(), df["year"].max()
            df = df[(df["year"] >= ymin) & (df["year"] <= ymax)]
        self.filtered = df
        self._update_cards()
        self._draw_overview()
        self._fill_tree()
        if self.current_analysis_plot_func:
            self.current_analysis_plot_func()

    def _update_cards(self):
        d = self.filtered
        self.cards["Total Cars"].configure(text=f"{len(d):,}")
        self.cards["Average Price"].configure(
            text=f"${d['price'].mean():,.0f}" if not d.empty else "$0")
        m = d["mileage"].mean() if "mileage" in d.columns else np.nan
        self.cards["Average Mileage"].configure(
            text=f"{m:,.0f} mi" if not np.isnan(m) else "-")
        r = d["consumerrating"].mean() if "consumerrating" in d.columns else np.nan
        self.cards["Avg Rating"].configure(
            text=f"{r:.2f}" if not np.isnan(r) else "-")

    # ───────────────── OVERVIEW PLOTS (clickable) ──────────────
    def _draw_overview(self):
        if hasattr(self, "_ov_pick_id"):
            self.ov_fig.canvas.mpl_disconnect(self._ov_pick_id)

        self.ov_fig.clear()
        self._ov_annot = None 

        df = self.filtered
        if df.empty:
            ax = self.ov_fig.add_subplot(111)
            ax.axis("off")
            ax.text(0.5, 0.5, "No data", ha="center", va="center", color="white", fontsize=16)
            self.ov_canvas.draw(); return

        gs = self.ov_fig.add_gridspec(2, 2)

        ax_hist = self.ov_fig.add_subplot(gs[0, 0])
        ax_scatter = self.ov_fig.add_subplot(gs[0, 1])
        ax_pie = self.ov_fig.add_subplot(gs[1, 0])
        ax_bar = self.ov_fig.add_subplot(gs[1, 1])

        ax_hist.hist(df["price"], bins=30, color=self.clr.info)
        ax_hist.set_title("Price Distribution", color="w")
        ax_hist.set_xlabel("Price ($)", color="w"); ax_hist.set_ylabel("Cars", color="w")
        ax_hist.tick_params(colors="w")

        df_scatter_data = df.dropna(subset=["mileage", "price"])
        self._ov_scatter_map = {}
        if not df_scatter_data.empty:
            sc = ax_scatter.scatter(df_scatter_data["mileage"], df_scatter_data["price"],
                                    s=45, alpha=0.8, c=df_scatter_data["year"], cmap="viridis")
            sc.set_picker(True); sc.set_pickradius(10)
            self._ov_scatter_map[sc] = df_scatter_data.reset_index(drop=True)
            cb = self.ov_fig.colorbar(sc, ax=ax_scatter)
            cb.ax.yaxis.set_major_locator(MaxNLocator(integer=True))
            cb.ax.tick_params(colors="w"); cb.set_label("Year", color="w")

            def _on_pick(event):
                if len(event.ind) == 0:
                    return
                row = self._ov_scatter_map[event.artist].iloc[event.ind[0]]
                label = shorten(f"{row['make']} {row.get('model','')}", width=40, placeholder="…")
                if self._ov_annot:
                    self._ov_annot.remove()
                self._ov_annot = ax_scatter.annotate(
                    label, (row["mileage"], row["price"]),
                    xytext=(10, 10), textcoords="offset points",
                    bbox=dict(boxstyle="round", fc="white", alpha=0.9), color="black")
                self.ov_canvas.draw_idle()
            self._ov_pick_id = self.ov_fig.canvas.mpl_connect("pick_event", _on_pick)

        ax_scatter.set_title("Mileage vs Price", color="w")
        ax_scatter.set_xlabel("Mileage", color="w"); ax_scatter.set_ylabel("Price ($)", color="w")
        ax_scatter.tick_params(colors="w")

        if "drivetrain" in df.columns:
            cnt = df["drivetrain"].value_counts()
            if not cnt.empty:
                ax_pie.pie(cnt, labels=cnt.index, autopct="%1.0f%%", textprops={'color': 'w'})
            ax_pie.set_title("Cars by Drivetrain", color="w")

        if not df.empty:
            top = df.groupby("make")["price"].mean().nlargest(10).sort_values()
            if not top.empty:
                top.plot(kind="barh", ax=ax_bar, color=self.clr.primary)
        ax_bar.set_title("Top-10 Makes by Avg Price", color="w")
        ax_bar.set_xlabel("Average Price ($)", color="w"); ax_bar.set_ylabel("Make", color="w")
        ax_bar.tick_params(colors="w")

        self.ov_canvas.draw()

    # ───────────────── ANALYSIS PLOTS ──────────────────────────
    def _corr(self):
        self.an_fig.clear()
        ax = self.an_fig.add_subplot(111)

        num = self.filtered.select_dtypes(include=np.number)
        if num.shape[1] < 2:
            ax.text(0.5, 0.5, "Not Enough Numeric Data", ha="center", va="center", color="white", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        im = ax.imshow(num.corr(), cmap="RdYlBu_r", vmin=-1, vmax=1)
        ax.set_xticks(range(num.shape[1])); ax.set_yticks(range(num.shape[1]))
        ax.set_xticklabels(num.columns, rotation=45, ha="right", color="w")
        ax.set_yticklabels(num.columns, color="w")
        cb = self.an_fig.colorbar(im, ax=ax, fraction=0.046)
        cb.ax.tick_params(colors="w"); cb.set_label("Correlation", color="w")
        ax.set_title("Feature Correlation Heat-map", color="w")
        self.an_canvas.draw()

    def _price_make(self):
        self.an_fig.clear()
        ax = self.an_fig.add_subplot(111)

        df = self.filtered
        if df.empty or {"make","price"}.issubset(df.columns) is False:
            ax.text(0.5, 0.5, "No Data for this Filter", ha="center", va="center", color="white", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        makes = df["make"].value_counts().nlargest(15).index
        if makes.empty:
            ax.text(0.5, 0.5, "No Makes to Display", ha="center", va="center", color="white", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        data  = [df[df["make"] == m]["price"] for m in makes]
        # ### FIX: Use 'labels' instead of 'tick_labels' ###
        ax.boxplot(data, labels=makes, vert=False, patch_artist=True,
                   boxprops=dict(facecolor=self.clr.info),
                   medianprops=dict(color=self.clr.danger))
        ax.set_title("Price Distribution by Make", color="w")
        ax.set_xlabel("Price ($)", color="w"); ax.set_ylabel("Make", color="w")
        ax.tick_params(colors="w")
        self.an_canvas.draw()

    def _ratings(self):
        self.an_fig.clear()
        ax = self.an_fig.add_subplot(111)

        cols = [c for c in (
            "consumerrating","comfortrating","interiordesignrating",
            "performancerating","valueformoneyrating","reliabilityrating")
            if c in self.filtered.columns]

        if not cols:
            ax.text(0.5, 0.5, "No Rating Data in CSV", ha="center", va="center", color="white", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        data = self.filtered[cols].dropna()
        if data.empty:
            ax.text(0.5, 0.5, "No Rating Data for this Filter", ha="center", va="center", color="white", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        ax.boxplot(data.values,
                   labels=[c.replace("rating","") for c in cols],
                   patch_artist=True,
                   boxprops=dict(facecolor=self.clr.warning),
                   medianprops=dict(color=self.clr.danger))
        ax.set_title("Ratings Distribution", color="w")
        ax.set_ylabel("Rating (out of 5)", color="w"); ax.set_xlabel("Rating Type", color="w")
        ax.tick_params(colors="w", rotation=45)
        self.an_canvas.draw()

    def _mpg(self):
        if hasattr(self, "_mpg_pick_id"):
            self.an_fig.canvas.mpl_disconnect(self._mpg_pick_id)
        self.an_fig.clear()
        ax = self.an_fig.add_subplot(111)
        self._mpg_annot = None

        raw = self.filtered
        if {"minmpg","maxmpg","make"}.issubset(raw.columns) is False:
            ax.text(0.5,0.5,"No MPG Data in CSV",ha="center",va="center",color="w", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        df = raw.dropna(subset=["minmpg","maxmpg"])
        if df.empty:
            ax.text(0.5,0.5,"No MPG Data for this Filter",ha="center",va="center",color="w", fontsize=16)
            ax.axis('off')
            self.an_canvas.draw(); return

        top = df["make"].value_counts().nlargest(6).index
        palette = plt.cm.tab10.colors
        self._scatter_map = {}
        rest = df[~df["make"].isin(top)]
        if not rest.empty:
            sc = ax.scatter(rest["minmpg"], rest["maxmpg"],
                            s=25, c="lightgrey", alpha=.45, label="Other")
            sc.set_picker(True); sc.set_pickradius(10)
            self._scatter_map[sc] = rest.reset_index(drop=True)
        for i, mk in enumerate(top):
            sub = df[df["make"] == mk]
            sc = ax.scatter(sub["minmpg"], sub["maxmpg"],
                            s=35, color=palette[i % 10], label=mk, alpha=.8)
            sc.set_picker(True); sc.set_pickradius(10)
            self._scatter_map[sc] = sub.reset_index(drop=True)
        def _on_pick(event):
            if len(event.ind) == 0:
                return
            row = self._scatter_map[event.artist].iloc[event.ind[0]]
            label = shorten(f"{row['make']} {row.get('model','')}", width=40, placeholder="…")
            if self._mpg_annot: self._mpg_annot.remove()
            self._mpg_annot = ax.annotate(
                label, (row["minmpg"], row["maxmpg"]),
                xytext=(10, 10), textcoords="offset points",
                bbox=dict(boxstyle="round", fc="white", alpha=0.9), color="black")
            self.an_canvas.draw_idle()
        self._mpg_pick_id = self.an_fig.canvas.mpl_connect("pick_event", _on_pick)
        try:
            best_hwy  = df.loc[df["maxmpg"].idxmax()]
            best_city = df.loc[df["minmpg"].idxmax()]
            for r, t in [(best_hwy, "Best Hwy"), (best_city, "Best City")]:
                ax.annotate(
                    f"{t}: {shorten(r['make']+' '+str(r.get('model','')),28, placeholder='…')}",
                    xy=(r["minmpg"], r["maxmpg"]),
                    xytext=(5, 5), textcoords="offset points",
                    fontsize=7, color="w", backgroundcolor="#00000080")
        except (ValueError, KeyError): pass
        ax.set_title("City MPG vs Highway MPG", color="w")
        ax.set_xlabel("City MPG", color="w"); ax.set_ylabel("Highway MPG", color="w")
        ax.tick_params(colors="w")
        if len(top) > 0:
            ax.legend(facecolor="#1e1e1e", framealpha=.3, fontsize=8, labelcolor="w", loc="upper left")
        self.an_canvas.draw()

    # ───────────── TABLE / SEARCH / EXPORT ─────────────────────
    def _fill_tree(self):
        self.tree.delete(*self.tree.get_children())
        for _, row in self.filtered.head(500).iterrows():
            vals = [f"{v:,.2f}" if isinstance(v, float)
                    else f"{int(v):,}" if isinstance(v, (int, np.integer)) else v
                    for v in row]
            self.tree.insert("", "end", values=vals)

    def _search_tree(self, *_):
        term = self.search.get().lower()
        self.tree.delete(*self.tree.get_children())
        if not term: self._fill_tree(); return
        mask = self.filtered.astype(str).apply(
            lambda s: s.str.lower().str.contains(term, na=False)).any(axis=1)
        for _, row in self.filtered[mask].head(500).iterrows():
            vals = [f"{v:,.2f}" if isinstance(v, float)
                    else f"{int(v):,}" if isinstance(v, (int, np.integer)) else v
                    for v in row]
            self.tree.insert("", "end", values=vals)

    def _export(self):
        fn = tb.dialogs.filedialog.asksaveasfilename(
            defaultextension=".csv", filetypes=[("CSV", "*.csv")])
        if fn:
            self.filtered.to_csv(fn, index=False)
            tb.dialogs.Messagebox.show_info("Export complete", fn)

# ═══════════════════════════════════════════════════════════════
if __name__ == "__main__":
    root = tb.Window(themename="darkly")
    Dashboard(root)
    root.mainloop()

高级代码描述和技术栈

此 Python 脚本创建了一个全面且高度交互式的图形仪表板,专为用于汽车数据集的探索性分析而设计。它作为一个独立的桌面应用程序构建,使用了一系列强大的库。Tkinter,通过 ttkbootstrap 包装器,提供了现代、主题化的图形用户界面(GUI)组件和窗口管理。数据操作和聚合由 pandas 库在后台高效处理。所有数据可视化都由 matplotlib 生成,并通过其 FigureCanvasTkAgg 后端无缝嵌入到 Tkinter 窗口中,允许在应用程序框架内进行复杂的交互式图表。应用程序在单个 Dashboard 类中构建,封装了所有状态和方法,以实现干净、有序的结构。

数据摄取和预处理

启动时,应用程序执行一系列稳健的数据加载和清理过程。它使用 pandas 读取指定的 CSV 文件,并立即执行几个预处理步骤以确保数据质量和一致性。

  1. 标题规范化:它遍历所有列名,将它们转换为小写并删除特殊字符。这防止了由于命名不一致而引起的错误,例如“价格”与“price”。

  2. 列别名:它使用预定义的字典将常见的替代列名(例如,“品牌”或“制造商”)重命名为标准内部名称(例如,“品牌”)。这增加了灵活性,允许应用程序在不更改代码的情况下处理不同的 CSV 格式。

  3. 智能“年份”检测:如果未明确找到“年份”列,脚本会智能地扫描其他列以找到包含符合典型汽车年份范围(1900-2035)的数字的列,并将其自动指定为“年份”列。

  4. 类型强制转换:它系统地清理预期为数值型(如价格和里程)的列,通过删除非数值字符(例如,'$',',',' mi')并将结果转换为浮点数,优雅地处理任何转换错误。

  5. 数据修剪:最后,它删除任何缺少关键数据点(如品牌和价格)的行,确保用于绘图和分析的所有数据都是有效的。

用户界面和交互式过滤

用户界面组织成一个主笔记本,包含三个不同的标签页,为分析提供了一个直接的流程。

  • 一个核心特性是 动态过滤面板。此面板包含组合框(用于汽车品牌)和用于价格和年份范围的 Spinbox 控件等小部件。这些小部件直接链接到应用程序的核心逻辑。

  • 状态管理:当用户更改过滤器时,会触发一个中央方法 _apply_filters。此函数通过将用户的选定内容应用于主数据集来创建一个新的、临时的 pandas DataFrame,命名为 self.filtered。然后,这个 self.filtered DataFrame 成为所有视觉组件的唯一真相来源。

  • 自动 UI 刷新:数据筛选后,_apply_filters 方法通过调用所有必要的更新函数来协调仪表板的全面刷新。这包括重新绘制“概览”标签页上的每个图表,更新关键绩效指标(KPI)卡片,重新填充数据表,以及至关重要的,重新绘制“分析”标签页上当前活动的图表。这创造了一个高度响应和直观的用户体验。

可视化和分析标签页

应用程序的核心价值在于其可视化能力,分布在两个标签页中:

1/ 概览标签页:这作为主仪表板,包括:

  • KPI 卡片:顶部显示四个突出卡片,展示关键指标如“总车辆”和“平均价格”,这些指标会随着筛选器的实时更新而更新。

  • 2×2 图表网格:一个大型多面板图同时显示四个图表:价格分布的直方图,传动类型饼图,按平均价格排名前 10 的品牌水平条形图,以及一个可点击的散点图,显示车辆里程与价格的关系,按年份着色。点击散点图上的点会弹出一个注释,显示汽车的制造商和型号。这种交互性是通过将 Matplotlib pick_event 连接到处理函数来实现的,该函数绘制注释。

2/ 分析标签页:此标签页用于更专注的单图分析。一排按钮允许用户选择几种高级可视化之一:

  • 相关性热图:显示数据集中所有数值列之间的相关性。

  • 按品牌的价格箱线图:比较前 15 种最常见汽车品牌的价格分布,提供对价格差异和异常值的洞察。

  • 评分箱线图:显示并比较各种消费者评分类别的分布(例如,舒适性、性能、可靠性)。

  • MPG 散点图:一个完全交互式的散点图,用于分析城市与高速公路的 MPG,点根据品牌着色,并具有类似于概述标签上的点击注释功能。

    应用程序巧妙地记住最后查看的分析图,并在全局筛选器更改时自动使用新数据重新绘制它。

3/ 数据标签页:对于想要检查原始数字的用户,此标签页以可滚动树视图表的形式显示筛选后的数据。它还包括一个实时搜索框,用户在键入时立即筛选表格内容。

运行代码

代码的运行方式与常规 Python 程序相同,因此将其保存到 Python 文件中,例如 tktest.py,并确保将文件位置更改为从 Kaggle 下载文件的位置。按照这种方式运行代码:

$ python tktest.py

你的屏幕应该看起来像这样,

图片

图片由作者提供

你可以在概览、分析和数据标签页之间切换,以查看数据的不同视图。如果你从下拉选项中更改了制造商驱动方式,显示的数据将立即反映这一变化。使用应用年份/价格过滤器按钮,当你选择不同的年份或价格范围时,可以看到数据的变化。

概览屏幕是当 GUI 显示时你首先看到的屏幕。它由四个主要图表和位于过滤器字段下方的信息统计显示组成。

分析标签页提供了四种额外的数据视图。一个相关性热图、一个按制造商的价格图表、一个 MPG 图表显示了各种制造商/型号的效率,以及一个包含六个不同指标的评级图表。在概览标签页上的价格按制造商图表和里程/价格图表上,你可以点击图表上的单个“点”来查看它指的是哪个汽车制造商和型号。以下是 MPG 图表的示例,显示了各种制造商在比较城市与高速公路 MPG 数据时的效率。

图片

图片由作者提供

图片

最后,我们有一个数据标签页。这只是一个底层数据集的行和列的表格表示。像所有显示的图表一样,这个输出会随着你过滤数据而变化。

为了看到它的实际应用,我首先点击了概览标签页,并将输入参数更改为,

Make: BMW 
Drivetrain: All-wheel Drive 
Price: 2300.0 to 449996.0 
Year: 2022

然后,我点击了数据标签页,得到了以下输出。

图片

摘要

本文是一份全面指南,介绍了如何使用 Tkinter,Python 的原始内置 GUI 库,来创建现代、数据驱动的桌面应用程序。它是一个耐用、轻量级且仍然相关的工具,与 ttkbootstrap 库搭配使用,完全能够制作出现代风格的数据显示和仪表板。

我首先介绍了任何 Tkinter 应用程序的基本构建块,例如根窗口、小部件(按钮、标签)和布局的几何管理器。

接着,我转向了一个功能齐全的分析工具,具有标签页界面、动态过滤器可以实时更新所有视觉效果,以及可点击的图表,提供响应式和专业用户体验,有力地证明了 Tkinter 的能力远超简单工具。

posted @ 2026-03-28 09:53  布客飞龙I  阅读(8)  评论(0)    收藏  举报