diff --git "a/app.py" "b/app.py"
--- "a/app.py"
+++ "b/app.py"
@@ -1,377 +1,1778 @@
+# -*- coding: utf-8 -*-
+"""
+Gradio 應用程式:進階數據可視化工具
+作者:Gemini
+版本:1.0 (分段提供 - Part 1)
+描述:此部分包含套件導入、常數定義、輔助函數和 CSS 樣式。
+"""
+
+# =========================================
+# == 套件導入 (Import Libraries) ==
+# =========================================
import gradio as gr
import pandas as pd
+import numpy as np
import plotly.express as px
import plotly.graph_objects as go
-from io import StringIO, BytesIO
+import io
+import base64
+from PIL import Image
+# import matplotlib.pyplot as plt # Matplotlib/Seaborn 在此版本中未使用,暫時註解
+# import seaborn as sns # Matplotlib/Seaborn 在此版本中未使用,暫時註解
+from plotly.subplots import make_subplots
+import re
+import json
+import colorsys
+import traceback # 用於更詳細的錯誤追蹤
+
+# =========================================
+# == 常數定義 (Constants) ==
+# =========================================
+
+# 圖表類型選項 (Chart Type Options)
+# 擴展並稍微調整順序以符合常見用法
+CHART_TYPES = [
+ # --- 長條圖系列 ---
+ "長條圖", "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖",
+ # --- 折線圖系列 ---
+ "折線圖", "多重折線圖", "階梯折線圖",
+ # --- 區域圖系列 ---
+ "區域圖", "堆疊區域圖", "百分比堆疊區域圖",
+ # --- 圓形圖系列 ---
+ "圓餅圖", "環形圖",
+ # --- 散佈圖系列 ---
+ "散點圖", "氣泡圖",
+ # --- 分佈圖系列 ---
+ "直方圖", "箱型圖", "小提琴圖",
+ # --- 關係圖系列 ---
+ "熱力圖", "樹狀圖",
+ # --- 其他圖表 ---
+ "雷達圖", "漏斗圖", "極座標圖", "甘特圖"
+]
+
+# 顏色方案選項 (Color Scheme Options)
+# 增加更多 Plotly 內建方案並分類
+COLOR_SCHEMES = {
+ "預設 (Plotly)": px.colors.qualitative.Plotly,
+ "分類 - D3": px.colors.qualitative.D3,
+ "分類 - G10": px.colors.qualitative.G10,
+ "分類 - T10": px.colors.qualitative.T10,
+ "分類 - Alphabet": px.colors.qualitative.Alphabet,
+ "分類 - Dark24": px.colors.qualitative.Dark24,
+ "分類 - Light24": px.colors.qualitative.Light24,
+ "分類 - Set1": px.colors.qualitative.Set1,
+ "分類 - Set2": px.colors.qualitative.Set2,
+ "分類 - Set3": px.colors.qualitative.Set3,
+ "分類 - Pastel": px.colors.qualitative.Pastel,
+ "分類 - Pastel1": px.colors.qualitative.Pastel1,
+ "分類 - Pastel2": px.colors.qualitative.Pastel2,
+ "分類 - Antique": px.colors.qualitative.Antique,
+ "分類 - Bold": px.colors.qualitative.Bold,
+ "分類 - Prism": px.colors.qualitative.Prism,
+ "分類 - Safe": px.colors.qualitative.Safe,
+ "分類 - Vivid": px.colors.qualitative.Vivid,
+ "連續 - Viridis": px.colors.sequential.Viridis,
+ "連續 - Plasma": px.colors.sequential.Plasma,
+ "連續 - Inferno": px.colors.sequential.Inferno,
+ "連續 - Magma": px.colors.sequential.Magma,
+ "連續 - Cividis": px.colors.sequential.Cividis,
+ "連續 - Blues": px.colors.sequential.Blues,
+ "連續 - Reds": px.colors.sequential.Reds,
+ "連續 - Greens": px.colors.sequential.Greens,
+ "連續 - Purples": px.colors.sequential.Purples,
+ "連續 - Oranges": px.colors.sequential.Oranges,
+ "連續 - Greys": px.colors.sequential.Greys,
+ "連續 - Rainbow": px.colors.sequential.Rainbow,
+ "連續 - Turbo": px.colors.sequential.Turbo,
+ "連續 - Electric": px.colors.sequential.Electric,
+ "連續 - Hot": px.colors.sequential.Hot,
+ "連續 - Teal": px.colors.sequential.Teal,
+ "發散 - Spectral": px.colors.diverging.Spectral,
+ "發散 - RdBu": px.colors.diverging.RdBu,
+ "發散 - PRGn": px.colors.diverging.PRGn,
+ "發散 - PiYG": px.colors.diverging.PiYG,
+ "發散 - BrBG": px.colors.diverging.BrBG,
+ "發散 - Geyser": px.colors.diverging.Geyser,
+ "循環 - Twilight": px.colors.cyclical.Twilight,
+ "循環 - IceFire": px.colors.cyclical.IceFire,
+}
+
+# 圖案填充選項 (Pattern Fill Options)
+PATTERN_TYPES = [
+ "無", "/", "\\", "x", "-", "|", "+", "."
+]
+
+# 聚合函數選項 (Aggregation Function Options)
+AGGREGATION_FUNCTIONS = [
+ "計數", "求和", "平均值", "中位數", "最大值", "最小值", "標準差", "變異數", "第一筆", "最後一筆"
+]
+
+# =========================================
+# == 輔助函數 (Helper Functions) ==
+# =========================================
+
+# --- 顏色處理相關 ---
+
+# HTML 顏色展示卡片樣式
+COLOR_CARD_STYLE = """
+
+ {color_cards}
+
+"""
+
+COLOR_CARD_TEMPLATE = """
+
+"""
+
+COPY_SCRIPT = """
+
+"""
+
+# 常見顏色名稱和十六進制代碼
+COMMON_COLORS = {
+ "紅色": "#FF0000", "亮紅": "#FF5733", "深紅": "#C70039",
+ "橙色": "#FFA500", "亮橙": "#FFC300", "深橙": "#D35400",
+ "黃色": "#FFFF00", "亮黃": "#F1C40F", "金色": "#FFD700",
+ "綠色": "#008000", "亮綠": "#2ECC71", "深綠": "#1E8449", "橄欖綠": "#808000",
+ "藍色": "#0000FF", "亮藍": "#3498DB", "深藍": "#2874A6", "天藍": "#87CEEB",
+ "紫色": "#800080", "亮紫": "#9B59B6", "深紫": "#6C3483", "薰衣草紫": "#E6E6FA",
+ "粉紅色": "#FFC0CB", "亮粉": "#FF69B4", "深粉": "#C71585",
+ "棕色": "#A52A2A", "亮棕": "#E59866", "深棕": "#6E2C00",
+ "青色": "#00FFFF", "藍綠色": "#008080", "綠松石色": "#40E0D0",
+ "洋紅": "#FF00FF", "紫紅色": "#DC143C",
+ "灰色": "#808080", "淺灰": "#D3D3D3", "深灰": "#696969", "石板灰": "#708090",
+ "黑色": "#000000", "白色": "#FFFFFF", "米色": "#F5F5DC",
+}
+
+# 生成漸變色系
+def generate_gradient_colors(start_color, end_color, steps=10):
+ """生成從開始顏色到結束顏色的漸變色列表"""
+ def hex_to_rgb(hex_color):
+ hex_color = hex_color.lstrip('#')
+ return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
+
+ def rgb_to_hex(rgb):
+ # 確保 RGB 值在 0-255 範圍內
+ r, g, b = [max(0, min(255, int(c))) for c in rgb]
+ return '#{:02x}{:02x}{:02x}'.format(r, g, b)
+
+ try:
+ start_rgb = hex_to_rgb(start_color)
+ end_rgb = hex_to_rgb(end_color)
+
+ if steps <= 1:
+ return [start_color] if steps == 1 else []
+
+ r_step = (end_rgb[0] - start_rgb[0]) / (steps - 1)
+ g_step = (end_rgb[1] - start_rgb[1]) / (steps - 1)
+ b_step = (end_rgb[2] - start_rgb[2]) / (steps - 1)
+
+ gradient_colors = []
+ for i in range(steps):
+ r = start_rgb[0] + r_step * i
+ g = start_rgb[1] + g_step * i
+ b = start_rgb[2] + b_step * i
+ gradient_colors.append(rgb_to_hex((r, g, b)))
-# --- 自訂 CSS ---
+ return gradient_colors
+ except Exception as e:
+ print(f"生成漸變色時出錯: {e}")
+ return [start_color, end_color] # 出錯時返回基礎顏色
+
+# 定義一些常用的漸變色系
+GRADIENTS = {
+ "紅→黃": generate_gradient_colors("#FF0000", "#FFFF00"),
+ "藍→綠": generate_gradient_colors("#0000FF", "#00FF00"),
+ "紫→粉": generate_gradient_colors("#800080", "#FFC0CB"),
+ "紅→藍": generate_gradient_colors("#FF0000", "#0000FF"),
+ "黑→白": generate_gradient_colors("#000000", "#FFFFFF"),
+ "藍→紅 (發散)": generate_gradient_colors("#0000FF", "#FF0000"),
+ "綠→紫 (發散)": generate_gradient_colors("#00FF00", "#800080"),
+ "彩虹 (簡易)": ["#FF0000", "#FF7F00", "#FFFF00", "#00FF00", "#0000FF", "#4B0082", "#9400D3"]
+}
+
+# 生成顏色卡片展示 HTML
+def generate_color_cards():
+ """生成包含常見顏色和漸變色的 HTML 卡片展示"""
+ # 常見顏色卡片
+ common_cards = ""
+ for name, hex_code in COMMON_COLORS.items():
+ common_cards += COLOR_CARD_TEMPLATE.format(color_name=name, color_hex=hex_code)
+
+ # 漸變色系卡片
+ gradient_cards_html = ""
+ for name, colors in GRADIENTS.items():
+ cards = ""
+ for i, color in enumerate(colors):
+ cards += COLOR_CARD_TEMPLATE.format(
+ color_name=f"{name} {i+1}/{len(colors)}",
+ color_hex=color
+ )
+ gradient_cards_html += f"""
+ {name}
+ {COLOR_CARD_STYLE.format(color_cards=cards)}
+ """
+
+ # 合成卡片展示HTML
+ color_display = f"""
+ 常用單色
+ {COLOR_CARD_STYLE.format(color_cards=common_cards)}
+ {gradient_cards_html}
+ {COPY_SCRIPT}
+ """
+ return color_display
+
+# --- 數據處理相關 ---
+
+def agg_function_map(func_name):
+ """映射中文聚合函數名稱到 Pandas/Numpy 函數或標識符"""
+ mapping = {
+ "計數": "count", # Pandas count non-NA values
+ "求和": "sum",
+ "平均值": "mean",
+ "中位數": "median",
+ "最大值": "max",
+ "最小值": "min",
+ "標準差": "std",
+ "變異數": "var",
+ "第一筆": "first",
+ "最後一筆": "last",
+ # 'size' is handled specially in create_plot for counting rows including NA
+ }
+ return mapping.get(func_name, "count") # 默認返回 count
+
+def parse_custom_colors(color_text):
+ """解析自定義顏色文本 (例如 "類別A:#FF0000, 類別B:#00FF00")"""
+ custom_colors = {}
+ if color_text and isinstance(color_text, str) and color_text.strip():
+ try:
+ # 移除多餘空格並按逗號分割
+ pairs = [p.strip() for p in color_text.split(',') if p.strip()]
+ for pair in pairs:
+ if ':' in pair:
+ # 按第一個冒號分割
+ key, value = pair.split(':', 1)
+ key = key.strip()
+ value = value.strip()
+ # 簡單驗證顏色代碼格式 (以 # 開頭,後跟 3 或 6 個十六進制字符)
+ if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", value):
+ custom_colors[key] = value
+ else:
+ print(f"警告:忽略無效的顏色代碼格式 '{value}' for key '{key}'")
+ except Exception as e:
+ print(f"解析自定義顏色時出錯: {e}")
+ # 出錯時返回空字典
+ return {}
+ return custom_colors
+
+def update_patterns(*patterns_input):
+ """從 Gradio 輸入更新圖案列表,過濾掉 '無'"""
+ # patterns_input 會是一個包含 pattern1, pattern2, pattern3... 的元組
+ patterns = [p for p in patterns_input if p and p != "無"]
+ return patterns
+
+# =========================================
+# == CSS 樣式 (CSS Styling) ==
+# =========================================
CUSTOM_CSS = """
-body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; }
-.gradio-container { max-width: 1280px!important; margin: auto; padding-top: 20px; }
-.stale-upload-button { background-color: #f0f0f0!important; color: #888!important; } /* 示例:自訂按鈕樣式 */
-
-/* 下拉選單溢出修正 */
-.gradio-container.choices__list--dropdown {
- z-index: 100!important; /* 提高層級 */
-}
-/* 限制下拉列表高度並啟用內部滾動 */
-.gradio-container.choices__list--dropdown.choices__list {
- max-height: 250px!important; /* 調整最大高度 */
- overflow-y: auto!important; /* 啟用垂直滾動 */
-}
-/* 嘗試允許 choices 容器溢出 (謹慎使用) */
-/*.gradio-container.choices[data-type*="select-one"],
-.gradio-container.choices[data-type*="select-multiple"] {
- overflow: visible!important;
-} */
-
-/* 佈局微調 */
-.gradio-container.gap { gap: 1rem; } /* 增加元件間距 */
-.gradio-container.gr-block { border: none; box-shadow: none; } /* 移除默認邊框陰影 */
-.gr-button { margin-top: 10px; }
-
-/* 標題樣式 */
-h3 {
- margin-top: 1rem;
- margin-bottom: 0.5rem;
- font-weight: 600;
+/* --- 全局和容器 --- */
+.gradio-container {
+ font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; /* 使用更現代的字體 */
+ background: #f8f9fa; /* 更柔和的背景色 */
+ /* overflow: visible !important; */ /* 移除全局 overflow,可能導致問題 */
+}
+
+/* --- 應用程式標頭 --- */
+.app-header {
+ text-align: center;
+ margin-bottom: 25px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); /* 漸變色更新 */
+ padding: 25px 20px;
+ border-radius: 12px; /* 圓角加大 */
+ color: white;
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); /* 陰影加深 */
+}
+.app-title {
+ font-size: 2.2em; /* 標題加大 */
+ font-weight: 700; /* 字重加粗 */
+ margin: 0;
+ letter-spacing: 1px; /* 增加字間距 */
+ text-shadow: 1px 1px 3px rgba(0,0,0,0.2); /* 文字陰影 */
+}
+.app-subtitle {
+ font-size: 1.1em; /* 副標題加大 */
+ color: #e0e0e0; /* 副標題顏色調整 */
+ margin-top: 8px;
+ font-weight: 300;
+}
+
+/* --- 區塊標題 --- */
+.section-title {
+ font-size: 1.4em; /* 區塊標題加大 */
+ font-weight: 600; /* 字重調整 */
+ color: #343a40; /* 標題顏色加深 */
+ border-bottom: 3px solid #7367f0; /* 邊框顏色和寬度調整 */
+ padding-bottom: 8px;
+ margin-top: 25px;
+ margin-bottom: 20px;
+}
+
+/* --- 卡片樣式 --- */
+.card {
+ background-color: white;
+ border-radius: 10px;
+ padding: 25px; /* 內邊距加大 */
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08); /* 陰影調整 */
+ margin-bottom: 20px;
+ transition: transform 0.25s ease-out, box-shadow 0.25s ease-out;
+ border: 1px solid #e0e0e0; /* 添加細邊框 */
+}
+.card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12); /* 懸停陰影加強 */
+}
+
+/* --- 按鈕樣式 --- */
+.primary-button {
+ background: linear-gradient(to right, #667eea, #764ba2) !important;
+ border: none !important;
+ color: white !important;
+ font-weight: 600 !important; /* 字重調整 */
+ padding: 12px 24px !important; /* 按鈕加大 */
+ border-radius: 8px !important; /* 圓角加大 */
+ cursor: pointer !important;
+ transition: all 0.3s ease !important;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
+}
+.primary-button:hover {
+ background: linear-gradient(to right, #764ba2, #667eea) !important; /* 懸停漸變反轉 */
+ transform: translateY(-2px) !important;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15) !important; /* 懸停陰影加強 */
+}
+
+.secondary-button {
+ background: linear-gradient(to right, #89f7fe, #66a6ff) !important; /* 不同漸變色 */
+ border: none !important;
+ color: #333 !important; /* 文字顏色調整 */
+ font-weight: 600 !important;
+ padding: 10px 20px !important;
+ border-radius: 8px !important;
+ cursor: pointer !important;
+ transition: all 0.3s ease !important;
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
+}
+.secondary-button:hover {
+ background: linear-gradient(to right, #66a6ff, #89f7fe) !important;
+ transform: translateY(-2px) !important;
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15) !important;
+}
+
+/* --- 下拉選單修正 (Dropdown Fix) --- */
+/* 確保 Gradio 組件本身允許溢出 */
+.gradio-dropdown, .gradio-checkboxgroup, .gradio-radio {
+ overflow: visible !important;
+ z-index: auto !important; /* 移除或設置為 auto,讓子元素控制 */
+}
+
+/* 定位下拉列表本身 */
+.gradio-dropdown .choices__list--dropdown {
+ position: absolute !important; /* 使用絕對定位 */
+ top: 100% !important; /* 定位在輸入框下方 */
+ left: 0 !important;
+ width: 100% !important; /* 寬度與輸入框一致 */
+ max-height: 250px !important; /* 限制最大高度並啟用滾動 */
+ overflow-y: auto !important;
+ background: white !important;
+ border: 1px solid #ced4da !important; /* 邊框顏色 */
+ border-radius: 0 0 8px 8px !important; /* 底部圓角 */
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15) !important; /* 陰影 */
+ z-index: 1050 !important; /* 確保在最上層 (高於一般元素) */
+ margin-top: 2px !important; /* 與輸入框稍微分離 */
+}
+
+/* 下拉選項樣式 */
+.gradio-dropdown .choices__list--dropdown .choices__item--choice {
+ padding: 10px 15px !important; /* 選項內邊距 */
+ font-size: 0.95em !important;
+ cursor: pointer !important;
+ transition: background-color 0.2s ease !important;
+}
+.gradio-dropdown .choices__list--dropdown .choices__item--choice:hover {
+ background-color: #f0f0f0 !important; /* 懸停背景色 */
+}
+.gradio-dropdown .choices__list--dropdown .choices__item--choice.is-selected {
+ background-color: #e0e0e0 !important; /* 選中項背景色 */
+ font-weight: 500;
+}
+
+/* --- 其他 UI 元素 --- */
+.tips-box {
+ background-color: #e7f3ff; /* 提示框背景色 */
+ border-left: 5px solid #66a6ff; /* 提示框邊框 */
+ padding: 15px 20px;
+ border-radius: 8px;
+ margin: 20px 0;
+ font-size: 0.95em;
color: #333;
- border-bottom: 1px solid #eee;
- padding-bottom: 5px;
}
+.tips-box code { /* 提示框中的代碼樣式 */
+ background-color: #d1e7fd;
+ padding: 2px 5px;
+ border-radius: 4px;
+ font-family: 'Courier New', Courier, monospace;
+}
+
+.chart-previewer {
+ border: 2px dashed #ced4da; /* 預覽區邊框 */
+ border-radius: 10px;
+ padding: 20px;
+ min-height: 450px; /* 預覽區最小高度 */
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: #ffffff; /* 白色背景 */
+ box-shadow: inset 0 0 10px rgba(0,0,0,0.05); /* 內陰影 */
+ margin-top: 15px;
+}
+
+/* 數據表格預覽 */
+.gradio-dataframe table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: 0.9em;
+}
+.gradio-dataframe th, .gradio-dataframe td {
+ border: 1px solid #dee2e6;
+ padding: 8px 10px;
+ text-align: left;
+}
+.gradio-dataframe th {
+ background-color: #f8f9fa;
+ font-weight: 600;
+}
+.gradio-dataframe tr:nth-child(even) {
+ background-color: #f8f9fa;
+}
+
+/* 顏色自定義輸入框 */
+.color-customization-input textarea {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 0.9em;
+}
+
+/* 確保 Gradio Tabs 樣式一致 */
+.gradio-tabs .tab-nav button {
+ padding: 10px 20px !important;
+ font-weight: 500 !important;
+ border-radius: 8px 8px 0 0 !important;
+ transition: background-color 0.2s ease, color 0.2s ease !important;
+}
+.gradio-tabs .tab-nav button.selected {
+ background-color: #667eea !important;
+ color: white !important;
+ border-bottom: none !important;
+}
+
+/* 調整 Slider 樣式 */
+.gradio-slider label {
+ margin-bottom: 5px !important;
+}
+.gradio-slider input[type="range"] {
+ cursor: pointer !important;
+}
+
+/* 調整 Checkbox/Radio 樣式 */
+.gradio-checkboxgroup label, .gradio-radio label {
+ padding: 8px 0 !important;
+}
+
+/* 調整 Textbox/Textarea 樣式 */
+.gradio-textbox textarea, .gradio-textbox input {
+ border-radius: 6px !important;
+ border: 1px solid #ced4da !important;
+ padding: 10px !important;
+}
+.gradio-textbox textarea:focus, .gradio-textbox input:focus {
+ border-color: #80bdff !important;
+ box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25) !important;
+}
+
+/* 檔案上傳/下載區域 */
+.gradio-file .hidden-upload, .gradio-file .download-button {
+ border-radius: 6px !important;
+}
+.gradio-file .upload-button {
+ border-radius: 6px !important;
+ background: #6c757d !important; /* 上傳按鈕顏色 */
+ color: white !important;
+ padding: 8px 15px !important;
+}
+.gradio-file .upload-button:hover {
+ background: #5a6268 !important;
+}
+
"""
-# --- 全局選項 ---
-chart_options = ['scatter', 'bar', 'line', 'histogram', 'box']
-agg_options = ['count', 'sum', 'mean', 'median', 'min', 'max']
+# =========================================
+# == (第一部分結束) ==
+# =========================================
+# -*- coding: utf-8 -*-
+"""
+Gradio 應用程式:進階數據可視化工具
+作者:Gemini
+版本:1.0 (分段提供 - Part 2)
+描述:此部分包含核心邏輯函數,如數據處理、圖表創建、導出等。
+"""
-# --- 輔助函數 ---
+# =========================================
+# == 數據處理函數 (Data Processing Functions) ==
+# =========================================
-def parse_data(file_obj):
- """解析上傳的文件為 Pandas DataFrame"""
- if file_obj is None:
- return pd.DataFrame()
+def process_upload(file):
+ """
+ 處理上傳的文件 (CSV 或 Excel)。
+ Args:
+ file: Gradio 文件對象。
+ Returns:
+ tuple: (DataFrame 或 None, 狀態消息)。
+ """
+ if file is None:
+ return None, "❌ 未上傳任何文件。"
try:
- file_content = file_obj.read()
- file_name = file_obj.name
-
- # 嘗試根據文件擴展名解析
- if file_name.endswith('.csv'):
- # 嘗試 UTF-8,如果失敗,嘗試其他常見編碼
+ file_path = file.name
+ file_type = file_path.split('.')[-1].lower()
+
+ if file_type == 'csv':
+ # 嘗試自動檢測編碼
try:
- df = pd.read_csv(BytesIO(file_content), encoding='utf-8')
+ df = pd.read_csv(file_path, encoding='utf-8')
except UnicodeDecodeError:
- df = pd.read_csv(BytesIO(file_content), encoding='gbk') # 或 'latin1' 等
- elif file_name.endswith(('.xls', '.xlsx')):
- df = pd.read_excel(BytesIO(file_content))
- else:
- # 嘗試作為 CSV 讀取(通用後備)
- try:
- df = pd.read_csv(BytesIO(file_content), encoding='utf-8')
- except Exception:
- # 如果無法解析,返回空 DataFrame
- print(f"無法識別或解析文件格式: {file_name}")
- return pd.DataFrame()
-
- # 移除空列和空行 (可選)
- df.dropna(axis=1, how='all', inplace=True)
- df.dropna(axis=0, how='all', inplace=True)
-
- # 轉換看起來像數字的對象列為數字 (盡力而為)
- for col in df.select_dtypes(include=['object']).columns:
+ try:
+ df = pd.read_csv(file_path, encoding='big5') # 嘗試台灣常用編碼
+ except Exception as e:
+ return None, f"❌ 無法使用 UTF-8 或 Big5 解碼 CSV 文件: {e}"
+ except Exception as e:
+ return None, f"❌ 讀取 CSV 文件時出錯: {e}"
+
+ elif file_type in ['xls', 'xlsx']:
try:
- df[col] = pd.to_numeric(df[col])
- except (ValueError, TypeError):
- pass # 不能轉換的保持原樣
+ df = pd.read_excel(file_path)
+ except Exception as e:
+ return None, f"❌ 讀取 Excel 文件時出錯: {e}"
+ else:
+ return None, f"❌ 不支持的文件類型: '{file_type}'。請上傳 CSV 或 Excel 文件。"
+
+ # 清理列名中的潛在空格
+ df.columns = df.columns.str.strip()
+
+ # **移除自動添加計數列**
+ # df['計數'] = 1 # 不再自動添加
+
+ return df, f"✅ 成功載入 '{file_path.split('/')[-1]}',共 {len(df)} 行,{len(df.columns)} 列。"
- return df
except Exception as e:
- print(f"解析文件時出錯: {e}")
- return pd.DataFrame()
+ print(f"處理上傳文件時發生未預期錯誤: {e}")
+ traceback.print_exc()
+ return None, f"❌ 處理文件時發生未預期錯誤: {e}"
-def get_column_names(df):
- """獲取 DataFrame 的欄位名稱列表,包括一個 None 選項"""
- if df is None or df.empty:
- return [None]
- # 區分數值和類別欄位
- numeric_cols = df.select_dtypes(include=['number']).columns.tolist()
- category_cols = df.select_dtypes(exclude=['number']).columns.tolist()
- # 將 None 放在最前面,然後是類別,然後是數值
- return [None] + sorted(category_cols) + sorted(numeric_cols)
-
-def get_numeric_column_names(df):
- """僅獲取數值型欄位名稱列表,包括 None"""
- if df is None or df.empty:
- return [None]
- numeric_cols = df.select_dtypes(include=['number']).columns.tolist()
- return [None] + sorted(numeric_cols)
-
-# --- 核心繪圖邏輯 (重構後) ---
-
-def _aggregate_data(df, x_col, y_col, color_col, agg_function):
- """根據選擇聚合數據"""
- if df is None or df.empty or not x_col:
- return pd.DataFrame(), None # 返回空 df 和 y 軸名稱
-
- group_cols = [x_col]
- if color_col:
- # 確保 color_col 不是 x_col
- if color_col!= x_col:
- group_cols.append(color_col)
+def parse_data(text_data):
+ """
+ 解析文本框中輸入的數據 (CSV 或空格分隔)。
+ Args:
+ text_data (str): 包含數據的字符串。
+ Returns:
+ tuple: (DataFrame 或 None, 狀態消息)。
+ """
+ if not text_data or not text_data.strip():
+ return None, "❌ 未輸入任何數據。"
+ try:
+ # 使用 StringIO 模擬文件讀取
+ data_io = io.StringIO(text_data.strip())
+ first_line = data_io.readline().strip() # 讀取第一行以判斷分隔符
+ data_io.seek(0) # 重置讀取位置
+
+ # 判斷分隔符
+ if ',' in first_line:
+ separator = ','
+ elif '\t' in first_line:
+ separator = '\t'
+ elif ' ' in first_line:
+ # 如果包含空格,使用正則表達式匹配多個空格作為分隔符
+ separator = r'\s+'
else:
- # 如果顏色軸與 X 軸相同,則忽略顏色軸分組
- color_col = None
- print("警告:顏色軸與 X 軸相同,已忽略顏色軸分組。")
+ # 如果只有一列或無法判斷,默認為逗號,讓 pandas 嘗試
+ separator = ','
+
+ try:
+ df = pd.read_csv(data_io, sep=separator)
+ except pd.errors.ParserError as pe:
+ return None, f"❌ 解析數據時出錯:可能是分隔符錯誤或數據格式問題。檢測到的分隔符: '{separator}'. 錯誤: {pe}"
+ except Exception as e:
+ return None, f"❌ 解析數據時出錯: {e}"
+
+ # 清理列名中的潛在空格
+ df.columns = df.columns.str.strip()
+
+ # **移除自動添加計數列**
+ # df['計數'] = 1 # 不再自動添加
+
+ return df, f"✅ 成功解析數據,共 {len(df)} 行,{len(df.columns)} 列。"
+
+ except Exception as e:
+ print(f"解析文本數據時發生未預期錯誤: {e}")
+ traceback.print_exc()
+ return None, f"❌ 解析數據時發生未預期錯誤: {e}"
+
+def update_columns(df):
+ """
+ 根據 DataFrame 更新 Gradio 下拉菜單的選項。
+ Args:
+ df (pd.DataFrame): 輸入的 DataFrame。
+ Returns:
+ tuple: 更新後的 Gradio Dropdown 組件 (x, y, group, size)。
+ """
+ default_choices = ["-- 無數據 --"]
+ if df is None or df.empty:
+ # 提供預設或空選項
+ return (
+ gr.Dropdown(choices=default_choices, value=default_choices[0], label="X軸 / 類別"),
+ gr.Dropdown(choices=default_choices, value=default_choices[0], label="Y軸 / 數值"),
+ gr.Dropdown(choices=["無"] + default_choices, value="無", label="分組列"),
+ gr.Dropdown(choices=["無"] + default_choices, value="無", label="大小列")
+ )
+
+ columns = df.columns.tolist()
+ # 嘗試猜測合適的預設值
+ x_default = columns[0] if columns else None
+ # 預設 Y 軸為第二列(如果存在),否則為第一列
+ y_default = columns[1] if len(columns) > 1 else (columns[0] if columns else None)
+
+ # 移除 '無' 選項中的 None 或空字符串
+ valid_columns = [col for col in columns if col is not None and col != ""]
+ group_choices = ["無"] + valid_columns
+ size_choices = ["無"] + valid_columns
+
+ # 更新下拉選單
+ x_dropdown = gr.Dropdown(choices=valid_columns, value=x_default, label="X軸 / 類別")
+ y_dropdown = gr.Dropdown(choices=valid_columns, value=y_default, label="Y軸 / 數值")
+ group_dropdown = gr.Dropdown(choices=group_choices, value="無", label="分組列")
+ size_dropdown = gr.Dropdown(choices=size_choices, value="無", label="大小列")
+
+ return x_dropdown, y_dropdown, group_dropdown, size_dropdown
+# =========================================
+# == 圖表創建核心函數 (Core Plotting Function) ==
+# =========================================
- y_col_agg = None # 聚合後的 Y 軸名稱
+def create_plot(df, chart_type, x_column, y_column, group_column=None, size_column=None,
+ color_scheme_name="預設 (Plotly)", patterns=[], title="", width=800, height=500,
+ show_grid=True, show_legend=True, agg_func_name="計數", custom_colors_dict={}):
+ """
+ 根據用戶選擇創建 Plotly 圖表。
+
+ Args:
+ df (pd.DataFrame): 輸入數據。
+ chart_type (str): 圖表類型。
+ x_column (str): X軸或類別列。
+ y_column (str): Y軸或數值列。
+ group_column (str, optional): 分組列。 Defaults to None.
+ size_column (str, optional): 大小列。 Defaults to None.
+ color_scheme_name (str, optional): 顏色方案名稱。 Defaults to "預設 (Plotly)".
+ patterns (list, optional): 圖案列表。 Defaults to [].
+ title (str, optional): 圖表標題。 Defaults to "".
+ width (int, optional): 圖表寬度。 Defaults to 800.
+ height (int, optional): 圖表高度。 Defaults to 500.
+ show_grid (bool, optional): 是否顯示網格。 Defaults to True.
+ show_legend (bool, optional): 是否顯示圖例。 Defaults to True.
+ agg_func_name (str, optional): 聚合函數名稱。 Defaults to "計數".
+ custom_colors_dict (dict, optional): 自定義顏色映射。 Defaults to {}.
+
+ Returns:
+ go.Figure: Plotly 圖表對象。
+ """
+ fig = go.Figure()
+ plot_data_info = "" # 用於記錄數據處理信息
try:
- if agg_function == 'count':
- agg_df = df.groupby(group_cols, observed=True).size().reset_index(name='count')
- y_col_agg = 'count'
- elif y_col:
- if pd.api.types.is_numeric_dtype(df[y_col]):
- # 執行數值聚合
- # 使用 observed=True 避免產生未使用的類別組合
- agg_df = df.groupby(group_cols, observed=True)[y_col].agg(agg_function).reset_index()
- y_col_agg = y_col
+ # --- 1. 輸入驗證 ---
+ if df is None or df.empty:
+ raise ValueError("沒有有效的數據可供繪圖。")
+ if not x_column or x_column == "-- 無數據 --":
+ raise ValueError("請選擇有效的 X 軸或類別列。")
+ # 對於大多數圖表,Y 軸也是必需的,除非是直方圖或計數聚合
+ if not y_column or y_column == "-- 無數據 --":
+ if agg_func_name != "計數" and chart_type not in ["直方圖"]:
+ raise ValueError("請選擇有效的 Y 軸或數值列。")
+ if x_column not in df.columns:
+ raise ValueError(f"X 軸列 '{x_column}' 不在數據中。")
+ # 只有在聚合函數不是計數且圖表類型不是直方圖時才強制檢查 Y 軸
+ if agg_func_name != "計數" and chart_type not in ["直方圖"] and y_column not in df.columns:
+ raise ValueError(f"Y 軸列 '{y_column}' 不在數據中。")
+ if group_column and group_column != "無" and group_column not in df.columns:
+ raise ValueError(f"分組列 '{group_column}' 不在數據中。")
+ if size_column and size_column != "無" and size_column not in df.columns:
+ raise ValueError(f"大小列 '{size_column}' 不在數據中。")
+
+ # 處理 "無" 選項
+ group_col = None if group_column == "無" else group_column
+ size_col = None if size_column == "無" else size_column
+
+ # 複製 DataFrame 以避免修改原始數據
+ df_processed = df.copy()
+
+ # --- 2. 數據類型轉換與準備 ---
+ # 嘗試將數值列轉換為數字,分類列轉為 category 或 string
+ for col in [x_column, y_column, group_col, size_col]:
+ if col:
+ try:
+ # 如果聚合函數需要數值,嘗試轉換 Y 軸和大小列
+ if agg_func_name not in ["計數"] and col in [y_column, size_col]:
+ # 嘗試轉換為數值,無法轉換的設為 NaN
+ df_processed[col] = pd.to_numeric(df_processed[col], errors='coerce')
+ # X 軸和分組列通常視為分類
+ elif col in [x_column, group_col]:
+ df_processed[col] = df_processed[col].astype(str) # 確保為字符串以便分組
+ except Exception as e:
+ print(f"警告:轉換列 '{col}' 類型時出錯: {e}")
+ # 可以選擇忽略錯誤或拋出
+
+ # --- 3. 數據聚合 (如果需要) ---
+ # 某些圖表類型直接使用原始數據(散點圖、氣泡圖、直方圖、箱型圖、小提琴圖、甘特圖)
+ # 其他圖表需要聚合
+ needs_aggregation = chart_type not in [
+ "散點圖", "氣泡圖", "直方圖", "箱型圖", "小提琴圖", "甘特圖"
+ ]
+ agg_df = None
+ if needs_aggregation:
+ grouping_cols = [x_column]
+ if group_col:
+ grouping_cols.append(group_col)
+
+ # 處理 "計數" 聚合
+ if agg_func_name == "計數":
+ # 使用 size() 來計算每個組的行數 (包含 NaN)
+ agg_df = df_processed.groupby(grouping_cols, observed=False).size()
+ agg_df = agg_df.reset_index(name='__count__') # 將結果轉換回 DataFrame
+ y_col_agg = '__count__' # 使用新生成的計數列作為 Y 值
+ plot_data_info += f"按 {grouping_cols} 分組並計數。\n"
else:
- # Y 軸非數值,無法聚合,執行計數作為後備
- print(f"警告: Y 軸 '{y_col}' 不是數值類型,無法執行 '{agg_function}' 聚合。將執行計數。")
- agg_df = df.groupby(group_cols, observed=True).size().reset_index(name='count')
- y_col_agg = 'count'
+ # 其他聚合函數
+ agg_func_pd = agg_function_map(agg_func_name)
+ if not y_column or y_column not in df_processed.columns:
+ raise ValueError(f"聚合函數 '{agg_func_name}' 需要一個有效的 Y 軸數值列。")
+ # 確保 Y 軸是數值類型
+ if not pd.api.types.is_numeric_dtype(df_processed[y_column]):
+ raise ValueError(f"Y 軸列 '{y_column}' 必須是數值類型才能執行聚合 '{agg_func_name}'。")
+
+ try:
+ # 使用 agg() 進行聚合,處理 NaN
+ agg_df = df_processed.groupby(grouping_cols, observed=False)[y_column].agg(agg_func_pd)
+ agg_df = agg_df.reset_index()
+ y_col_agg = y_column # Y 值列名保持不變
+ plot_data_info += f"按 {grouping_cols} 分組,對 '{y_column}' 進行 '{agg_func_name}' 聚合。\n"
+ except Exception as agg_e:
+ raise ValueError(f"執行聚合 '{agg_func_name}' 時出錯: {agg_e}")
else:
- # 沒有 Y 軸,只能計數
- print(f"警告: 未選擇 Y 軸,將執行計數。")
- agg_df = df.groupby(group_cols, observed=True).size().reset_index(name='count')
- y_col_agg = 'count'
-
+ # 不需要聚合的圖表,直接使用處理過的數據
+ agg_df = df_processed
+ y_col_agg = y_column # Y 值列名保持不變
+ plot_data_info += "使用原始數據(已進行類型轉換)。\n"
+
+
+ # --- 4. 獲取顏色方案 ---
+ colors = COLOR_SCHEMES.get(color_scheme_name, px.colors.qualitative.Plotly)
+
+ # --- 5. 創建圖表 (核心邏輯) ---
+ fig_params = {
+ "data_frame": agg_df,
+ "title": title,
+ "color_discrete_sequence": colors, # 預設顏色順序
+ "width": width,
+ "height": height,
+ }
+ # 應用自定義顏色 (如果提供了分組列)
+ if group_col and custom_colors_dict:
+ fig_params["color_discrete_map"] = custom_colors_dict
+ elif not group_col and custom_colors_dict and x_column in custom_colors_dict:
+ # 如果沒有分組列,但為 X 軸類別提供了自定義顏色 (適用於餅圖、單系列條形圖等)
+ # 需要在繪圖後手動修改顏色或使用 go
+ pass # Plotly Express 不直接支持這種映射,稍後處理
+
+ # 根據圖表類型選擇繪圖函數
+ if chart_type == "長條圖":
+ fig = px.bar(agg_df, x=x_column, y=y_col_agg, color=group_col, **fig_params)
+ elif chart_type == "堆疊長條圖":
+ fig = px.bar(agg_df, x=x_column, y=y_col_agg, color=group_col, barmode='stack', **fig_params)
+ elif chart_type == "百分比堆疊長條圖":
+ fig = px.bar(agg_df, x=x_column, y=y_col_agg, color=group_col, barmode='relative', text_auto='.1%', **fig_params)
+ fig.update_layout(yaxis_title="百分比 (%)")
+ elif chart_type == "群組長條圖":
+ fig = px.bar(agg_df, x=x_column, y=y_col_agg, color=group_col, barmode='group', **fig_params)
+ elif chart_type == "水平長條圖":
+ # 水平長條圖通常 Y 軸是類別,X 軸是數值
+ fig = px.bar(agg_df, y=x_column, x=y_col_agg, color=group_col, orientation='h', **fig_params)
+ elif chart_type == "折線圖":
+ fig = px.line(agg_df, x=x_column, y=y_col_agg, color=group_col, markers=True, **fig_params)
+ elif chart_type == "多重折線圖": # 與普通折線圖類似,只是強調多線條
+ fig = px.line(agg_df, x=x_column, y=y_col_agg, color=group_col, markers=True, **fig_params)
+ elif chart_type == "階梯折線圖":
+ fig = px.line(agg_df, x=x_column, y=y_col_agg, color=group_col, line_shape='hv', **fig_params)
+ elif chart_type == "區域圖":
+ fig = px.area(agg_df, x=x_column, y=y_col_agg, color=group_col, **fig_params)
+ elif chart_type == "堆疊區域圖":
+ fig = px.area(agg_df, x=x_column, y=y_col_agg, color=group_col, groupnorm=None, **fig_params) # groupnorm=None for stack
+ elif chart_type == "百分比堆疊區域圖":
+ fig = px.area(agg_df, x=x_column, y=y_col_agg, color=group_col, groupnorm='percent', **fig_params)
+ fig.update_layout(yaxis_title="百分比 (%)")
+ elif chart_type == "圓餅圖":
+ if group_col: # 圓餅圖通常不分組,如果選了分組則忽略
+ print("警告:圓餅圖不支持分組列,已忽略。")
+ fig = px.pie(agg_df, names=x_column, values=y_col_agg, **fig_params)
+ # 處理餅圖的自定義顏色
+ if not group_col and custom_colors_dict:
+ fig.update_traces(marker=dict(colors=[custom_colors_dict.get(cat, colors[i % len(colors)])
+ for i, cat in enumerate(agg_df[x_column])]))
+ elif chart_type == "環形圖":
+ if group_col:
+ print("警告:環形圖不支持分組列,已忽略。")
+ fig = px.pie(agg_df, names=x_column, values=y_col_agg, hole=0.4, **fig_params)
+ # 處理環形圖的自定義顏色
+ if not group_col and custom_colors_dict:
+ fig.update_traces(marker=dict(colors=[custom_colors_dict.get(cat, colors[i % len(colors)])
+ for i, cat in enumerate(agg_df[x_column])]))
+ elif chart_type == "散點圖":
+ fig = px.scatter(agg_df, x=x_column, y=y_col_agg, color=group_col, size=size_col, **fig_params)
+ elif chart_type == "氣泡圖":
+ if not size_col:
+ raise ValueError("氣泡圖需要指定 '大小列'。")
+ fig = px.scatter(agg_df, x=x_column, y=y_col_agg, color=group_col, size=size_col, size_max=60, **fig_params)
+ elif chart_type == "直方圖":
+ # 直方圖分析單一變量分佈,Y 軸通常是計數
+ fig = px.histogram(agg_df, x=x_column, color=group_col, **fig_params)
+ fig.update_layout(yaxis_title="計數")
+ elif chart_type == "箱型圖":
+ # 箱型圖通常 X 軸是分類 (group_col),Y 軸是數值 (y_column)
+ fig = px.box(agg_df, x=group_col, y=y_col_agg, color=group_col, **fig_params)
+ if not group_col: # 如果沒有分組,則只顯示 Y 軸的箱型圖
+ fig = px.box(agg_df, y=y_col_agg, **fig_params)
+ elif chart_type == "小提琴圖":
+ fig = px.violin(agg_df, x=group_col, y=y_col_agg, color=group_col, box=True, points="all", **fig_params)
+ if not group_col:
+ fig = px.violin(agg_df, y=y_col_agg, box=True, points="all", **fig_params)
+ elif chart_type == "熱力圖":
+ if not group_col:
+ raise ValueError("熱力圖需要 X 軸、Y 軸 和一個 分組列 (用於顏色或數值)。")
+ # 熱力圖通常需要數據透視
+ try:
+ pivot_df = pd.pivot_table(agg_df, values=y_col_agg, index=group_col, columns=x_column, aggfunc=agg_function_map(agg_func_name) if agg_func_name != "計數" else 'size')
+ fig = px.imshow(pivot_df, color_continuous_scale=px.colors.sequential.Viridis, aspect="auto", **fig_params)
+ fig.update_layout(coloraxis_showscale=True)
+ except Exception as pivot_e:
+ raise ValueError(f"創建熱力圖的數據透視表時出錯: {pivot_e}")
+ elif chart_type == "樹狀圖":
+ if not group_col:
+ path = [x_column]
+ else:
+ path = [group_col, x_column] # 假設層級關係
+ fig = px.treemap(agg_df, path=path, values=y_col_agg, color=group_col if group_col else x_column, **fig_params)
+ elif chart_type == "雷達圖":
+ if not group_col: # 單系列雷達圖
+ theta = agg_df[x_column].tolist()
+ r = agg_df[y_col_agg].tolist()
+ # 封閉圖形
+ theta.append(theta[0])
+ r.append(r[0])
+ fig.add_trace(go.Scatterpolar(r=r, theta=theta, fill='toself', name=y_col_agg, line_color=colors[0]))
+ else: # 多系列雷達圖
+ categories = agg_df[group_col].unique()
+ for i, category in enumerate(categories):
+ subset = agg_df[agg_df[group_col] == category]
+ theta = subset[x_column].tolist()
+ r = subset[y_col_agg].tolist()
+ # 封閉圖形
+ theta.append(theta[0])
+ r.append(r[0])
+ color = custom_colors_dict.get(str(category), colors[i % len(colors)])
+ fig.add_trace(go.Scatterpolar(r=r, theta=theta, fill='toself', name=str(category), line_color=color))
+ fig.update_layout(polar=dict(radialaxis=dict(visible=True)), showlegend=show_legend)
+ fig.update_layout(title=title, width=width, height=height) # px 參數可能不完全適用 go
+ elif chart_type == "漏斗圖":
+ # 漏斗圖通常按值排序
+ sorted_df = agg_df.sort_values(by=y_col_agg, ascending=False)
+ fig = px.funnel(sorted_df, x=y_col_agg, y=x_column, color=group_col, **fig_params)
+ elif chart_type == "極座標圖":
+ fig = px.bar_polar(agg_df, r=y_col_agg, theta=x_column, color=group_col if group_col else x_column, **fig_params)
+ elif chart_type == "甘特圖":
+ # 甘特圖需要特定的列名,通常是任務、開始、結束
+ # 假設 x_column 是任務,y_column 是開始,group_column 是結束
+ if not y_column or not group_col:
+ raise ValueError("甘特圖需要指定 開始列 (Y軸) 和 結束列 (分組列)。")
+ try:
+ # 確保日期/時間格式
+ df_gantt = df.copy() # 使用原始數據,不聚合
+ df_gantt['_start_'] = pd.to_datetime(df_gantt[y_column], errors='coerce')
+ df_gantt['_end_'] = pd.to_datetime(df_gantt[group_col], errors='coerce')
+ # 檢查是否有無效日期
+ if df_gantt['_start_'].isnull().any() or df_gantt['_end_'].isnull().any():
+ raise ValueError("開始列或結束列包含無效的日期時間格式。")
+ # 繪製甘特圖
+ fig = px.timeline(df_gantt, x_start='_start_', x_end='_end_', y=x_column,
+ color=size_col if size_col else None, # 可以用大小列來區分顏色
+ title=title, color_discrete_sequence=colors, width=width, height=height)
+ fig.update_layout(xaxis_type="date")
+ except Exception as gantt_e:
+ raise ValueError(f"創建甘特圖時出錯: {gantt_e}")
+ else:
+ # 默認回退到長條圖
+ print(f"警告:未知的圖表類型 '{chart_type}',使用長條圖代替。")
+ fig = px.bar(agg_df, x=x_column, y=y_col_agg, color=group_col, **fig_params)
+
+
+ # --- 6. 應用圖案 (如果支持) ---
+ if patterns:
+ try:
+ # 條形圖、餅圖、環形圖等支持 marker.pattern
+ if chart_type in ["長條圖", "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", "圓餅圖", "環形圖"]:
+ num_traces = len(fig.data)
+ for i, trace in enumerate(fig.data):
+ pattern_index = i % len(patterns)
+ if patterns[pattern_index] != "無":
+ trace.marker.pattern.shape = patterns[pattern_index]
+ trace.marker.pattern.solidity = 0.4 # 設置圖案密度
+ trace.marker.pattern.fillmode = "replace" # 替換顏色
+ # 散點圖支持 marker.symbol
+ elif chart_type in ["散點圖", "氣泡圖"]:
+ symbol_map = {"/": "diamond", "\\": "square", "x": "x", "-": "line-ew", "|": "line-ns", "+": "cross", ".": "circle-dot"}
+ num_traces = len(fig.data)
+ for i, trace in enumerate(fig.data):
+ pattern_index = i % len(patterns)
+ symbol = symbol_map.get(patterns[pattern_index])
+ if symbol:
+ trace.marker.symbol = symbol
+ # 折線圖支持 line.dash
+ elif chart_type in ["折線圖", "多重折線圖", "階梯折線圖"]:
+ dash_map = {"/": "dash", "\\": "dot", "x": "dashdot", "-": "longdash", "|": "solid", "+": "solid", ".": "solid"} # 映射到線型
+ num_traces = len(fig.data)
+ for i, trace in enumerate(fig.data):
+ pattern_index = i % len(patterns)
+ dash = dash_map.get(patterns[pattern_index])
+ if dash:
+ trace.line.dash = dash
+ # 區域圖填充比較複雜,Plotly Express 不直接支持圖案填充
+ elif chart_type in ["區域圖", "堆疊區域圖", "百分比堆疊區域圖"]:
+ print("提示:區域圖的圖案填充支持有限,將嘗試應用線型。")
+ dash_map = {"/": "dash", "\\": "dot", "x": "dashdot", "-": "longdash"}
+ num_traces = len(fig.data)
+ for i, trace in enumerate(fig.data):
+ pattern_index = i % len(patterns)
+ dash = dash_map.get(patterns[pattern_index])
+ if dash:
+ # 嘗試修改線型模擬
+ trace.line.dash = dash
+ trace.fill = 'tonexty' if 'stackgroup' in trace else 'tozeroy' # 確保有填充
+
+ except Exception as pattern_e:
+ print(f"應用圖案時出錯: {pattern_e}")
+
+
+ # --- 7. 更新佈局 ---
+ fig.update_layout(
+ showlegend=show_legend,
+ xaxis=dict(showgrid=show_grid),
+ yaxis=dict(showgrid=show_grid),
+ template="plotly_white", # 使用簡潔模板
+ margin=dict(l=60, r=40, t=80 if title else 40, b=60), # 自動調整邊距
+ font=dict(family="Inter, sans-serif", size=12), # 應用字體
+ hoverlabel=dict(
+ bgcolor="white",
+ font_size=12,
+ font_family="Inter, sans-serif"
+ ),
+ legend=dict(
+ orientation="h", # 水平圖例
+ yanchor="bottom",
+ y=1.02,
+ xanchor="right",
+ x=1
+ ) if show_legend else None,
+ # 添加註釋說明數據處理過程
+ # annotations=[
+ # go.layout.Annotation(
+ # text=plot_data_info,
+ # align='left',
+ # showarrow=False,
+ # xref='paper',
+ # yref='paper',
+ # x=0,
+ # y=1.1 # 放在標題上方
+ # )
+ # ]
+ )
+ # 根據圖表類型調整軸標籤
+ if chart_type in ["圓餅圖", "環形圖", "漏斗圖", "樹狀圖"]:
+ fig.update_layout(xaxis_title=None, yaxis_title=None)
+ elif chart_type == "水平長條圖":
+ fig.update_layout(xaxis_title=y_col_agg, yaxis_title=x_column)
+ else:
+ fig.update_layout(xaxis_title=x_column, yaxis_title=y_col_agg)
+
+
+ except ValueError as ve:
+ # 捕獲數據或參數錯誤
+ print(f"圖表創建錯誤 (ValueError): {ve}")
+ traceback.print_exc()
+ fig = go.Figure()
+ fig.add_annotation(text=f"⚠️ 創建圖表時出錯:
{ve}", align='left', showarrow=False, font=dict(size=14, color="red"))
+ fig.update_layout(xaxis_visible=False, yaxis_visible=False)
+
except Exception as e:
- print(f"數據聚合時出錯 ({agg_function}): {e}")
- return pd.DataFrame(), None # 聚合失敗返回空
+ # 捕獲其他意外錯誤
+ error_message = f"❌ 創建圖表時發生未預期錯誤:\n{traceback.format_exc()}"
+ print(error_message)
+ fig = go.Figure()
+ # 顯示簡化的錯誤信息給用戶
+ user_error_msg = f"⚠️ 創建圖表時發生內部錯誤。
請檢查數據和設置。
詳細錯誤: {str(e)[:100]}..."
+ fig.add_annotation(text=user_error_msg, align='left', showarrow=False, font=dict(size=14, color="red"))
+ fig.update_layout(xaxis_visible=False, yaxis_visible=False)
- return agg_df, y_col_agg
+ return fig
+# =========================================
+# == 導出與下載函數 (Export & Download Functions) ==
+# =========================================
-def _generate_plotly_figure(df, chart_type, x_col, y_col, color_col, title):
- """生成 Plotly 圖形物件"""
- fig = go.Figure()
+def export_data(df, format_type):
+ """
+ 將 DataFrame 導出為指定格式的文件。
+ Args:
+ df (pd.DataFrame): 要導出的 DataFrame。
+ format_type (str): 導出格式 ("CSV", "Excel", "JSON")。
+ Returns:
+ tuple: (文件路徑或 None, 狀態消息)。 返回文件路徑供 Gradio 下載。
+ """
if df is None or df.empty:
- fig.update_layout(title_text="數據為空或處理失敗")
- return fig
- if not x_col or x_col not in df.columns:
- fig.update_layout(title_text=f"錯誤:X 軸欄位 '{x_col}' 不存在")
- return fig
-
- plot_func_map = {
- 'scatter': px.scatter, 'bar': px.bar, 'line': px.line,
- 'histogram': px.histogram, 'box': px.box
- }
+ # 不能直接返回 None,需要返回一個空的 File output
+ return None, "❌ 沒有數據可以導出。"
+
+ try:
+ if format_type == "CSV":
+ filename = "exported_data.csv"
+ df.to_csv(filename, index=False, encoding='utf-8-sig') # utf-8-sig 確保 Excel 正確讀取 BOM
+ elif format_type == "Excel":
+ filename = "exported_data.xlsx"
+ df.to_excel(filename, index=False)
+ elif format_type == "JSON":
+ filename = "exported_data.json"
+ df.to_json(filename, orient="records", indent=4, force_ascii=False) # indent 美化輸出, force_ascii=False 處理中文
+ else:
+ return None, f"❌ 不支持的導出格式: {format_type}"
+
+ return filename, f"✅ 數據已成功準備為 {format_type} 格式,點擊下方鏈接下載。"
+
+ except Exception as e:
+ print(f"導出數據時出錯: {e}")
+ traceback.print_exc()
+ return None, f"❌ 導出數據時出錯: {e}"
- if chart_type not in plot_func_map:
- fig.update_layout(title_text=f"錯誤:不支持的圖表類型 '{chart_type}'")
- return fig
+def download_figure(fig, format_type="PNG"):
+ """
+ 將 Plotly 圖表導出為圖像文件。
+ Args:
+ fig (go.Figure): Plotly 圖表對象。
+ format_type (str): 導出格式 ("PNG", "SVG", "PDF", "JPEG")。 Defaults to "PNG".
+ Returns:
+ tuple: (文件路徑或 None, 狀態消息)。 返回文件路徑供 Gradio 下載。
+ """
+ if fig is None or not fig.data:
+ return None, "❌ 沒有圖表可以導出。"
try:
- kwargs = {'data_frame': df, 'x': x_col, 'title': title}
- plot_function = plot_func_map[chart_type]
-
- # 根據圖表類型處理 Y 軸和顏色軸
- if chart_type == 'histogram':
- # 直方圖只需要 X,Y 是計數,顏色可選
- if color_col and color_col in df.columns and color_col!= x_col:
- kwargs['color'] = color_col
- # 直方圖不需要傳遞 y 參數
- elif chart_type == 'box':
- # 箱線圖可以只有 X (垂直顯示),或 X 和 Y,顏色可選
- if y_col and y_col in df.columns and y_col!= x_col:
- kwargs['y'] = y_col
- if color_col and color_col in df.columns and color_col!= x_col:
- kwargs['color'] = color_col
- else: # scatter, bar, line
- if not y_col or y_col not in df.columns:
- fig.update_layout(title_text=f"錯誤:繪製 '{chart_type}' 需要有效的 Y 軸欄位 ('{y_col}' 無效)")
- return fig
- kwargs['y'] = y_col
- if color_col and color_col in df.columns and color_col!= x_col:
- kwargs['color'] = color_col
- if chart_type == 'bar':
- kwargs['barmode'] = 'stack' # 長條圖預設堆疊
-
- # 調用 Plotly 函數
- fig = plot_function(**kwargs)
- fig.update_layout(title_x=0.5) # 標題居中
+ format_lower = format_type.lower()
+ filename = f"chart_export.{format_lower}"
+ # 使用 fig.write_image 寫入文件
+ fig.write_image(filename, format=format_lower)
+
+ return filename, f"✅ 圖表已成功準備為 {format_type} 格式,點擊下方鏈接下載。"
+
+ except ValueError as ve:
+ # kaleido 可能未安裝或配置錯誤
+ if "kaleido" in str(ve):
+ error_msg = "❌ 導出圖表失敗:需要 Kaleido 套件。請在環境中安裝 `pip install -U kaleido`。"
+ print(error_msg)
+ return None, error_msg
+ else:
+ print(f"導出圖表時出錯 (ValueError): {ve}")
+ traceback.print_exc()
+ return None, f"❌ 導出圖表時出錯: {ve}"
except Exception as e:
- error_msg = f"生成圖表時出錯: {e}"
- print(error_msg)
- fig = go.Figure()
- fig.update_layout(title_text=error_msg, title_x=0.5)
+ print(f"導出圖表時發生未預期錯誤: {e}")
+ traceback.print_exc()
+ return None, f"❌ 導出圖表時發生未預期錯誤: {e}"
- return fig
+# =========================================
+# == 智能推薦函數 (Recommendation Function) ==
+# =========================================
+def recommend_chart_settings(df):
+ """
+ 根據輸入的 DataFrame 智能推薦圖表設置。
+ Args:
+ df (pd.DataFrame): 輸入數據。
+ Returns:
+ dict: 包含推薦設置和消息的字典。
+ Keys: chart_type, x_column, y_column, group_column, agg_function, message
+ """
+ recommendation = {
+ "chart_type": None, "x_column": None, "y_column": None,
+ "group_column": "無", "agg_function": None, "message": "無法提供推薦。"
+ }
-def create_plot(df, chart_type, x_axis, y_axis, color_axis, agg_function):
- """主繪圖函數,協調數據處理和圖形生成"""
- # --- 基本檢查 ---
if df is None or df.empty:
- return go.Figure().update_layout(title_text="請先上傳並解析數據文件", title_x=0.5)
- if not chart_type:
- return go.Figure().update_layout(title_text="請選擇圖表類型", title_x=0.5)
- if not x_axis:
- return go.Figure().update_layout(title_text="請選擇 X 軸", title_x=0.5)
-
- # --- 檢查欄位是否存在 ---
- required_cols = {x_axis}
- if y_axis: required_cols.add(y_axis)
- if color_axis: required_cols.add(color_axis)
-
- missing_cols = required_cols - set(df.columns)
- if missing_cols:
- return go.Figure().update_layout(title_text=f"錯誤:數據中缺少欄位: {', '.join(missing_cols)}", title_x=0.5)
-
- # --- 數據聚合 (如果需要) ---
- df_processed = df.copy()
- y_axis_processed = y_axis
- aggregation_applied = False
-
- # 定義哪些圖表類型通常需要聚合
- aggregation_charts = ['bar', 'line'] # 示例
-
- if chart_type in aggregation_charts:
- if not agg_function:
- return go.Figure().update_layout(title_text=f"繪製 '{chart_type}' 圖需要選擇聚合函數", title_x=0.5)
-
- # 執行聚合前檢查 Y 軸對聚合函數的適用性
- if agg_function!= 'count':
- if not y_axis:
- return go.Figure().update_layout(title_text=f"'{agg_function}' 聚合需要選擇 Y 軸", title_x=0.5)
- if not pd.api.types.is_numeric_dtype(df[y_axis]):
- return go.Figure().update_layout(title_text=f"'{agg_function}' 聚合需要數值型的 Y 軸 ('{y_axis}' 不是數值型)", title_x=0.5)
-
- df_processed, y_axis_processed = _aggregate_data(df, x_axis, y_axis, color_axis, agg_function)
- aggregation_applied = True
-
- if df_processed is None or df_processed.empty:
- return go.Figure().update_layout(title_text=f"數據聚合失敗或結果為空 (聚合方式: {agg_function})", title_x=0.5)
-
- # --- 對於不需要聚合的圖表,檢查 Y 軸是否必需 ---
- elif chart_type in ['scatter', 'line']: # 箱線圖 Y 軸可選
- if not y_axis:
- return go.Figure().update_layout(title_text=f"繪製 '{chart_type}' 圖需要選擇 Y 軸", title_x=0.5)
-
-
- # --- 生成圖表標題 ---
- title = f"{chart_type.capitalize()} 圖: {x_axis}"
- if y_axis_processed and chart_type not in ['histogram']:
- title += f" vs {y_axis_processed}"
- if aggregation_applied:
- title += f" ({agg_function})"
- if color_axis and color_axis!= x_axis:
- title += f" (按 {color_axis} 著色)"
- if chart_type == 'bar': title += " - 堆疊"
-
- # --- 生成 Plotly 圖形 ---
- fig = _generate_plotly_figure(df_processed, chart_type, x_axis, y_axis_processed, color_axis, title)
+ recommendation["message"] = "ℹ️ 請先上傳或輸入數據。"
+ return recommendation
- return fig
+ columns = df.columns.tolist()
+ num_cols = df.select_dtypes(include=np.number).columns.tolist()
+ cat_cols = df.select_dtypes(include=['object', 'category', 'string']).columns.tolist()
+ # 嘗試檢測日期時間列
+ date_cols = [col for col in columns if pd.api.types.is_datetime64_any_dtype(df[col]) or ('日期' in col or '時間' in col)]
+
+ # --- 推薦邏輯 ---
+ try:
+ # 優先處理時間序列
+ if date_cols and num_cols:
+ recommendation["chart_type"] = "折線圖"
+ recommendation["x_column"] = date_cols[0]
+ recommendation["y_column"] = num_cols[0]
+ recommendation["agg_function"] = "平均值" # 或求和,取決於數據性質
+ recommendation["message"] = f"檢測到時間列 '{date_cols[0]}' 和數值列 '{num_cols[0]}',推薦使用折線圖顯示趨勢。"
+ # 兩個數值列 -> 散點圖
+ elif len(num_cols) >= 2:
+ recommendation["chart_type"] = "散點圖"
+ recommendation["x_column"] = num_cols[0]
+ recommendation["y_column"] = num_cols[1]
+ recommendation["agg_function"] = None # 散點圖通常不需要聚合
+ recommendation["message"] = f"檢測到數值列 '{num_cols[0]}' 和 '{num_cols[1]}',推薦使用散點圖分析相關性。"
+ # 一個類別列和一個數值列 -> 長條圖
+ elif cat_cols and num_cols:
+ recommendation["chart_type"] = "長條圖"
+ recommendation["x_column"] = cat_cols[0]
+ recommendation["y_column"] = num_cols[0]
+ recommendation["agg_function"] = "平均值" # 或求和
+ recommendation["message"] = f"檢測到類別列 '{cat_cols[0]}' 和數值列 '{num_cols[0]}',推薦使用長條圖比較各類別的數值。"
+ # 兩個或更多類別列 -> 堆疊/群組長條圖 (計數)
+ elif len(cat_cols) >= 2:
+ recommendation["chart_type"] = "堆疊長條圖" # 或群組長條圖
+ recommendation["x_column"] = cat_cols[0]
+ recommendation["y_column"] = None # Y軸由計數聚合產生
+ recommendation["group_column"] = cat_cols[1]
+ recommendation["agg_function"] = "計數"
+ recommendation["message"] = f"檢測到多個類別列 ('{cat_cols[0]}', '{cat_cols[1]}', ...),推薦使用堆疊長條圖顯示計數分佈。"
+ # 只有一個類別列 -> 長條圖 (計數)
+ elif cat_cols:
+ recommendation["chart_type"] = "長條圖"
+ recommendation["x_column"] = cat_cols[0]
+ recommendation["y_column"] = None # Y軸由計數聚合產生
+ recommendation["agg_function"] = "計數"
+ recommendation["message"] = f"檢測到類別列 '{cat_cols[0]}',推薦使用長條圖顯示其頻數分佈。"
+ # 只有數值列 -> 直方圖
+ elif num_cols:
+ recommendation["chart_type"] = "直方圖"
+ recommendation["x_column"] = num_cols[0]
+ recommendation["y_column"] = None
+ recommendation["agg_function"] = None # 直方圖自動計數
+ recommendation["message"] = f"檢測到數值列 '{num_cols[0]}',推薦使用直方圖查看其分佈。"
+ else:
+ recommendation["message"] = "無法根據當前數據結構提供明確的圖表推薦。"
-# --- Gradio Blocks 介面 ---
+ except Exception as e:
+ recommendation["message"] = f"❌ 推薦時出錯: {e}"
+ print(f"智能推薦時出錯: {e}")
+ traceback.print_exc()
-with gr.Blocks(css=CUSTOM_CSS, theme=gr.themes.Soft()) as demo:
- data_state = gr.State(pd.DataFrame()) # 存儲解析後的 DataFrame
+ # 確保推薦的列名是有效的
+ if recommendation["x_column"] and recommendation["x_column"] not in columns: recommendation["x_column"] = None
+ if recommendation["y_column"] and recommendation["y_column"] not in columns: recommendation["y_column"] = None
+ if recommendation["group_column"] != "無" and recommendation["group_column"] not in columns: recommendation["group_column"] = "無"
- gr.Markdown("# 通用數據可視化工具")
- gr.Markdown("上傳 CSV 或 Excel 文件,然後配置下方兩個圖表進行探索與比較。")
+ # 如果推薦了聚合但 Y 軸無效,則清空 Y 軸
+ if recommendation["agg_function"] and recommendation["agg_function"] != "計數" and not recommendation["y_column"]:
+ recommendation["agg_function"] = None # 無法聚合
+ recommendation["message"] += " (無法確定聚合的數值列)"
- with gr.Row():
- with gr.Column(scale=1):
- file_upload = gr.File(label="上傳數據文件 (CSV/Excel)", file_types=['.csv', '.xls', '.xlsx'])
- with gr.Column(scale=3):
- data_preview = gr.DataFrame(label="數據預覽 (前 5 行)", interactive=False, height=200)
+ # 如果聚合是計數,Y 軸應為空
+ if recommendation["agg_function"] == "計數":
+ recommendation["y_column"] = None # 強制 Y 軸為空,因為值由計數產生
- # --- 雙圖表配置區域 ---
- with gr.Row(elem_classes=["gap"]): # 使用 CSS class 添加間距
- # --- 圖表 1 ---
- with gr.Column(scale=1):
- gr.Markdown("### 圖表 1 配置")
- with gr.Row():
- chart_type_1 = gr.Dropdown(label="圖表類型", choices=chart_options, value='scatter', interactive=True)
- agg_function_1 = gr.Dropdown(label="聚合函數", choices=agg_options, value='count', interactive=True) # 預設 count
- x_axis_1 = gr.Dropdown(label="X 軸", choices=, interactive=True)
- y_axis_1 = gr.Dropdown(label="Y 軸", choices=, interactive=True)
- color_axis_1 = gr.Dropdown(label="顏色/分組軸", choices=, interactive=True)
- plot_output_1 = gr.Plot(label="圖表 1")
-
- # --- 圖表 2 ---
- with gr.Column(scale=1):
- gr.Markdown("### 圖表 2 配置")
+ return recommendation
+
+
+# =========================================
+# == (第二部分結束) ==
+# =========================================
+# -*- coding: utf-8 -*-
+"""
+Gradio 應用程式:進階數據可視化工具
+作者:Gemini
+版本:1.0 (分段提供 - Part 3)
+描述:此部分包含 Gradio UI 介面定義、事件處理和應用程式啟動。
+"""
+
+# =========================================
+# == Gradio UI 介面定義 (Gradio UI Definition) ==
+# =========================================
+with gr.Blocks(css=CUSTOM_CSS, title="進階數據可視化工具 V2", theme=gr.themes.Soft()) as demo:
+
+ # --- 應用程式標頭 ---
+ gr.HTML("""
+
+ """)
+
+ # --- 狀態變量 (State Variables) ---
+ # data_state 用於存儲載入的 DataFrame
+ data_state = gr.State(None)
+ # 分別為兩張圖表存儲自定義顏色和圖案
+ custom_colors_state_1 = gr.State({})
+ patterns_state_1 = gr.State([])
+ custom_colors_state_2 = gr.State({})
+ patterns_state_2 = gr.State([])
+
+ # --- 主頁籤佈局 ---
+ with gr.Tabs() as tabs:
+
+ # --- 數據輸入頁籤 ---
+ with gr.TabItem("📁 數據輸入與管理", id=0):
with gr.Row():
- chart_type_2 = gr.Dropdown(label="圖表類型", choices=chart_options, value='bar', interactive=True)
- agg_function_2 = gr.Dropdown(label="聚合函數", choices=agg_options, value='count', interactive=True)
- x_axis_2 = gr.Dropdown(label="X 軸", choices=, interactive=True)
- y_axis_2 = gr.Dropdown(label="Y 軸", choices=, interactive=True)
- color_axis_2 = gr.Dropdown(label="顏色/分組軸", choices=, interactive=True)
- plot_output_2 = gr.Plot(label="圖表 2")
-
- # --- 事件處理 ---
-
- def process_upload(file_obj):
- """處理文件上傳,更新狀態和 UI"""
- df = parse_data(file_obj)
- preview = df.head() if not df.empty else pd.DataFrame()
-
- all_cols = get_column_names(df)
- num_cols = get_numeric_column_names(df) # Y 軸通常需要數值
-
- # 更新下拉選單選項和預覽
- # 使用新版 Gradio 返回字典更新多個元件
- updates = {
- data_state: df,
- data_preview: gr.DataFrame(value=preview, interactive=False),
- x_axis_1: gr.Dropdown(choices=all_cols, value=all_cols[1] if len(all_cols) > 1 else None, interactive=True),
- y_axis_1: gr.Dropdown(choices=num_cols, value=num_cols[1] if len(num_cols) > 1 else None, interactive=True),
- color_axis_1: gr.Dropdown(choices=all_cols, value=None, interactive=True),
- x_axis_2: gr.Dropdown(choices=all_cols, value=all_cols[1] if len(all_cols) > 1 else None, interactive=True),
- y_axis_2: gr.Dropdown(choices=num_cols, value=num_cols[1] if len(num_cols) > 1 else None, interactive=True),
- color_axis_2: gr.Dropdown(choices=all_cols, value=None, interactive=True),
- # 清空圖表區域
- plot_output_1: None,
- plot_output_2: None
- }
- return updates
+ # 左側:數據輸入
+ with gr.Column(scale=2):
+ gr.HTML('1. 上傳或輸入數據
')
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("您可以上傳本地的 CSV 或 Excel 文件,或直接在下方的文本框中貼上數據。")
+ file_upload = gr.File(label="上傳 CSV / Excel 文件", type="filepath") # 使用 filepath 更穩定
+ upload_button = gr.Button("⬆️ 載入文件數據", elem_classes=["primary-button"])
+ upload_status = gr.Textbox(label="載入狀態", lines=1, interactive=False)
+
+ with gr.Group(elem_classes=["card"]):
+ csv_input = gr.Textbox(
+ label="或者,在此貼上數據 (逗號、Tab 或空格分隔)",
+ placeholder="例如:\n類別,數值\nA,10\nB,20\nC,15\n\n或\n類別\t數值\nA\t10\nB\t20\nC\t15",
+ lines=8,
+ elem_classes=["data-input-textbox"]
+ )
+ parse_button = gr.Button("📝 解析貼上數據", elem_classes=["primary-button"])
+ parse_status = gr.Textbox(label="解析狀態", lines=1, interactive=False)
- # 文件上傳觸發處理
- file_upload.upload(
- fn=process_upload,
+ # 右側:數據預覽與導出
+ with gr.Column(scale=3):
+ gr.HTML('2. 數據預覽與導出
')
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("下方將顯示載入或解析後的數據預覽。")
+ data_preview = gr.Dataframe(label="數據表格預覽", interactive=False, height=350)
+
+ with gr.Row():
+ export_format = gr.Dropdown(
+ ["CSV", "Excel", "JSON"],
+ label="選擇導出格式",
+ value="CSV"
+ )
+ export_button = gr.Button("⬇️ 導出預覽數據", elem_classes=["secondary-button"])
+ # 使用 File 組件顯示導出結果,以便下載
+ export_result = gr.File(label="導出文件下載", interactive=False)
+ export_status = gr.Textbox(label="導出狀態", lines=1, interactive=False)
+
+ # --- 圖表創建頁籤 ---
+ with gr.TabItem("📈 圖表創建與比較", id=1):
+ gr.HTML('創建與比較圖表
')
+ gr.Markdown("在這裡,您可以分別設置並生成兩張圖表,方便進行對比分析。")
+
+ # --- 圖表一 ---
+ with gr.Group(): # 使用 Group 包裹每個圖表區塊
+ gr.Markdown("### 📊 圖表一設置")
+ with gr.Row():
+ # 圖表一:左側設置
+ with gr.Column(scale=2):
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("**基本設置**")
+ chart_type_1 = gr.Dropdown(CHART_TYPES, label="圖表類型", value="長條圖", interactive=True)
+ with gr.Row():
+ recommend_button_1 = gr.Button("🧠 智能推薦 (圖表一)", elem_classes=["secondary-button"])
+ # recommendation_result_1 = gr.Textbox(label="推薦結果", lines=1, interactive=False) # 暫時隱藏推薦結果文本框
+ chart_title_1 = gr.Textbox(label="圖表標題", placeholder="圖表一:我的數據分析")
+ agg_function_1 = gr.Dropdown(AGGREGATION_FUNCTIONS, label="聚合函數", value="計數", info="選擇如何彙總 Y 軸數據")
+
+ gr.Markdown("**數據映射**")
+ x_column_1 = gr.Dropdown(["-- 無數據 --"], label="X軸 / 類別", info="選擇作為圖表主要分類或 X 軸的列")
+ y_column_1 = gr.Dropdown(["-- 無數據 --"], label="Y軸 / 數值", info="選擇作為圖表數值或 Y 軸的列 (計數聚合時可忽略)")
+ group_column_1 = gr.Dropdown(["無"], label="分組列", info="選擇用於生成多系列或堆疊的列")
+ size_column_1 = gr.Dropdown(["無"], label="大小列", info="用於氣泡圖等,控制點的大小")
+
+ # 圖表一:右側樣式
+ with gr.Column(scale=1):
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("**顯示選項**")
+ with gr.Row():
+ chart_width_1 = gr.Slider(300, 1600, 700, step=50, label="寬度 (px)")
+ chart_height_1 = gr.Slider(300, 1000, 450, step=50, label="高度 (px)")
+ with gr.Row():
+ show_grid_1 = gr.Checkbox(label="顯示網格", value=True)
+ show_legend_1 = gr.Checkbox(label="顯示圖例", value=True)
+
+ color_scheme_1 = gr.Dropdown(list(COLOR_SCHEMES.keys()), label="顏色方案", value="預設 (Plotly)")
+ gr.HTML('顏色參考 (點擊複製)
')
+ gr.HTML(generate_color_cards(), elem_id="color_display_1") # 顏色參考
+
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("**圖案與自定義顏色**")
+ with gr.Row():
+ pattern1_1 = gr.Dropdown(PATTERN_TYPES, label="圖案1", value="無")
+ pattern2_1 = gr.Dropdown(PATTERN_TYPES, label="圖案2", value="無")
+ pattern3_1 = gr.Dropdown(PATTERN_TYPES, label="圖案3", value="無")
+ color_customization_1 = gr.Textbox(
+ label="自定義顏色",
+ placeholder="類別A:#FF5733, 類別B:#33CFFF",
+ info="格式: 類別名:十六進制顏色代碼, ...",
+ elem_classes=["color-customization-input"]
+ )
+
+ # 圖表一:預覽與操作按鈕
+ with gr.Row():
+ with gr.Column(scale=3):
+ gr.HTML('圖表一預覽
')
+ with gr.Group(elem_classes=["chart-previewer"]):
+ chart_output_1 = gr.Plot(label="", elem_id="chart_preview_1")
+ with gr.Column(scale=1):
+ gr.HTML('操作
')
+ update_button_1 = gr.Button("🔄 更新圖表一", variant="primary", elem_classes=["primary-button"])
+ export_img_format_1 = gr.Dropdown(["PNG", "SVG", "PDF", "JPEG"], label="導出格式", value="PNG")
+ download_button_1 = gr.Button("💾 導出圖表一", elem_classes=["secondary-button"])
+ export_chart_1 = gr.File(label="圖表一文件下載", interactive=False)
+ export_chart_status_1 = gr.Textbox(label="導出狀態", lines=1, interactive=False)
+
+
+ # --- 分隔線 ---
+ gr.Markdown("---")
+
+ # --- 圖表二 ---
+ with gr.Group(): # 使用 Group 包裹每個圖表區塊
+ gr.Markdown("### 📊 圖表二設置")
+ with gr.Row():
+ # 圖表二:左側設置
+ with gr.Column(scale=2):
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("**基本設置**")
+ chart_type_2 = gr.Dropdown(CHART_TYPES, label="圖表類型", value="折線圖", interactive=True) # 預設不同類型
+ # 智能推薦按鈕可以只影響圖表一,用戶可以手動調整圖表二
+ # recommend_button_2 = gr.Button("🧠 智能推薦 (圖表二)", elem_classes=["secondary-button"])
+ chart_title_2 = gr.Textbox(label="圖表標題", placeholder="圖表二:另一種視角")
+ agg_function_2 = gr.Dropdown(AGGREGATION_FUNCTIONS, label="聚合函數", value="平均值", info="選擇如何彙總 Y 軸數據") # 預設不同聚合
+
+ gr.Markdown("**數據映射**")
+ x_column_2 = gr.Dropdown(["-- 無數據 --"], label="X軸 / 類別", info="選擇作為圖表主要分類或 X 軸的列")
+ y_column_2 = gr.Dropdown(["-- 無數據 --"], label="Y軸 / 數值", info="選擇作為圖表數值或 Y 軸的列 (計數聚合時可忽略)")
+ group_column_2 = gr.Dropdown(["無"], label="分組列", info="選擇用於生成多系列或堆疊的列")
+ size_column_2 = gr.Dropdown(["無"], label="大小列", info="用於氣泡圖等,控制點的大小")
+
+ # 圖表二:右側樣式
+ with gr.Column(scale=1):
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("**顯示選項**")
+ with gr.Row():
+ chart_width_2 = gr.Slider(300, 1600, 700, step=50, label="寬度 (px)")
+ chart_height_2 = gr.Slider(300, 1000, 450, step=50, label="高度 (px)")
+ with gr.Row():
+ show_grid_2 = gr.Checkbox(label="顯示網格", value=True)
+ show_legend_2 = gr.Checkbox(label="顯示圖例", value=True)
+
+ color_scheme_2 = gr.Dropdown(list(COLOR_SCHEMES.keys()), label="顏色方案", value="分類 - Set2") # 預設不同顏色
+ # 顏色參考可以共用一個
+
+ with gr.Group(elem_classes=["card"]):
+ gr.Markdown("**圖案與自定義顏色**")
+ with gr.Row():
+ pattern1_2 = gr.Dropdown(PATTERN_TYPES, label="圖案1", value="無")
+ pattern2_2 = gr.Dropdown(PATTERN_TYPES, label="圖案2", value="無")
+ pattern3_2 = gr.Dropdown(PATTERN_TYPES, label="圖案3", value="無")
+ color_customization_2 = gr.Textbox(
+ label="自定義顏色",
+ placeholder="類別C:#FFC300, 類別D:#C70039",
+ info="格式: 類別名:十六進制顏色代碼, ...",
+ elem_classes=["color-customization-input"]
+ )
+
+ # 圖表二:預覽與操作按鈕
+ with gr.Row():
+ with gr.Column(scale=3):
+ gr.HTML('圖表二預覽
')
+ with gr.Group(elem_classes=["chart-previewer"]):
+ chart_output_2 = gr.Plot(label="", elem_id="chart_preview_2")
+ with gr.Column(scale=1):
+ gr.HTML('操作
')
+ update_button_2 = gr.Button("🔄 更新圖表二", variant="primary", elem_classes=["primary-button"])
+ export_img_format_2 = gr.Dropdown(["PNG", "SVG", "PDF", "JPEG"], label="導出格式", value="PNG")
+ download_button_2 = gr.Button("💾 導出圖表二", elem_classes=["secondary-button"])
+ export_chart_2 = gr.File(label="圖表二文件下載", interactive=False)
+ export_chart_status_2 = gr.Textbox(label="導出狀態", lines=1, interactive=False)
+
+
+ # --- 使用說明頁籤 ---
+ with gr.TabItem("❓ 使用說明", id=2):
+ with gr.Group(elem_classes=["card"]):
+ gr.HTML("""
+ 使用說明
+
+ 數據輸入
+
+ - 點擊 "上傳 CSV / Excel 文件" 按鈕選擇本地文件,或在文本框中直接貼上數據。
+ - 支持逗號 (
,)、製表符 (Tab) 或空格 ( ) 分隔的數據。
+ - 第一行通常被視為欄位名稱(表頭)。
+ - 數據載入或解析成功後,會在右側顯示預覽。
+ - 您可以使用 "導出預覽數據" 功能將處理後的數據保存為 CSV、Excel 或 JSON 格式。
+
+
+ 圖表創建與比較
+
+ - 此頁面提供兩個獨立的圖表設置和預覽區域(圖表一、圖表二)。
+ - 智能推薦:點擊 "智能推薦 (圖表一)" 按鈕,系統會根據數據結構嘗試為圖表一推薦合適的設置。
+ - 圖表類型:選擇您想創建的圖表樣式。
+ - 聚合函數:決定如何匯總 Y 軸數據。選擇 "計數" 時,系統會計算 X 軸(和分組列)組合的出現次數,此時無需選擇 Y 軸列。
+ - 數據映射:
+
+ - X軸/類別:圖表的主要分類軸。
+ - Y軸/數值:圖表的數值軸。若聚合函數為 "計數",此項可忽略。
+ - 分組列:用於創建堆疊、分組或多系列圖表。
+ - 大小列:主要用於氣泡圖,控制點的大小。
+
+
+ - 顯示選項:調整圖表的外觀,如寬度、高度、顏色方案、是否顯示網格和圖例。
+ - 圖案與自定義顏色:
+
+ - 為圖表系列添加不同的填充圖案(適用於部分圖表類型,如條形圖)。
+ - 通過 "類別名:顏色代碼" 的格式為特定類別指定顏色 (例如
正面:#2ca02c, 負面:#d62728)。
+
+
+ - 點擊 "更新圖表" 按鈕生成或刷新對應的圖表預覽。
+ - 使用 "導出圖表" 功能將生成的圖表保存為圖片文件。
+
+
+ 提示
+
+ - 如果圖表無法顯示或出現錯誤,請檢查數據格式、列選擇以及聚合函數是否合理。
+ - 確保數值列確實包含數字,日期列包含有效的日期格式。
+ - 部分圖表類型對數據結構有特定要求(例如,熱力圖、甘特圖)。
+
+ """)
+
+ # =========================================
+ # == 事件處理 (Event Handling) ==
+ # =========================================
+
+ # --- 數據載入與更新 ---
+ def load_data_and_update_ui(df, status_msg):
+ """輔助函數:更新數據狀態、預覽和所有下拉列表"""
+ # 更新數據預覽
+ preview_df = df if df is not None else pd.DataFrame()
+ # 更新所有列選擇下拉列表
+ col_updates = update_columns(df) # 返回 4 個 Dropdown 更新對象
+ return [df, status_msg, preview_df] + list(col_updates) * 2 # 更新兩組下拉列表
+
+ upload_button.click(
+ process_upload,
inputs=[file_upload],
+ outputs=[data_state, upload_status] # 先更新狀態和消息
+ ).then(
+ load_data_and_update_ui,
+ inputs=[data_state, upload_status],
outputs=[
- data_state, data_preview,
- x_axis_1, y_axis_1, color_axis_1,
- x_axis_2, y_axis_2, color_axis_2,
- plot_output_1, plot_output_2 # 同時更新輸出元件
+ data_state, upload_status, data_preview,
+ x_column_1, y_column_1, group_column_1, size_column_1, # 圖表一的下拉列表
+ x_column_2, y_column_2, group_column_2, size_column_2 # 圖表二的下拉列表
+ ]
+ )
+
+ parse_button.click(
+ parse_data,
+ inputs=[csv_input],
+ outputs=[data_state, parse_status] # 先更新狀態和消息
+ ).then(
+ load_data_and_update_ui,
+ inputs=[data_state, parse_status],
+ outputs=[
+ data_state, parse_status, data_preview,
+ x_column_1, y_column_1, group_column_1, size_column_1, # 圖表一的下拉列表
+ x_column_2, y_column_2, group_column_2, size_column_2 # 圖表二的下拉列表
+ ]
+ )
+
+ # --- 數據導出 ---
+ export_button.click(
+ export_data,
+ inputs=[data_state, export_format],
+ outputs=[export_result, export_status]
+ )
+
+ # --- 圖表一:處理顏色和圖案狀態 ---
+ color_customization_1.change(
+ parse_custom_colors,
+ inputs=[color_customization_1],
+ outputs=[custom_colors_state_1]
+ )
+ # 將圖案下拉列表的變化連接到 update_patterns 函數
+ patterns_inputs_1 = [pattern1_1, pattern2_1, pattern3_1]
+ for pattern_dd in patterns_inputs_1:
+ pattern_dd.change(
+ update_patterns,
+ inputs=patterns_inputs_1,
+ outputs=[patterns_state_1]
+ )
+
+ # --- 圖表一:更新圖表 ---
+ chart_inputs_1 = [
+ data_state, chart_type_1, x_column_1, y_column_1,
+ group_column_1, size_column_1, color_scheme_1, patterns_state_1,
+ chart_title_1, chart_width_1, chart_height_1, show_grid_1, show_legend_1,
+ agg_function_1, custom_colors_state_1
+ ]
+ update_button_1.click(
+ create_plot,
+ inputs=chart_inputs_1,
+ outputs=[chart_output_1]
+ )
+ # 當任何相關設置改變時,自動更新圖表一
+ for input_component in chart_inputs_1:
+ # 避免狀態變量觸發無限循環,只監聽 UI 組件的變化
+ if isinstance(input_component, (gr.Dropdown, gr.Textbox, gr.Slider, gr.Checkbox)):
+ input_component.change(
+ create_plot,
+ inputs=chart_inputs_1,
+ outputs=[chart_output_1]
+ )
+
+ # --- 圖表一:導出圖表 ---
+ download_button_1.click(
+ download_figure,
+ inputs=[chart_output_1, export_img_format_1],
+ outputs=[export_chart_1, export_chart_status_1]
+ )
+
+ # --- 圖表一:智能推薦 ---
+ def apply_recommendation(rec_dict):
+ """將推薦字典應用到圖表一的 UI 組件"""
+ if not isinstance(rec_dict, dict):
+ return [gr.update()] * 5 # 返回空更新以避免錯誤
+
+ # 如果聚合是計數,Y 軸應為空或不更新
+ y_val = None if rec_dict.get("agg_function") == "計數" else rec_dict.get("y_column")
+
+ return [
+ gr.Dropdown(value=rec_dict.get("chart_type")),
+ gr.Dropdown(value=rec_dict.get("x_column")),
+ gr.Dropdown(value=y_val), # 使用處理過的 Y 值
+ gr.Dropdown(value=rec_dict.get("group_column", "無")),
+ gr.Dropdown(value=rec_dict.get("agg_function"))
]
+
+ recommend_button_1.click(
+ recommend_chart_settings,
+ inputs=[data_state],
+ outputs=None # 先不直接輸出到 Textbox
+ ).then(
+ apply_recommendation,
+ inputs=None, # 使用 click 的輸出作為 then 的輸入
+ outputs=[chart_type_1, x_column_1, y_column_1, group_column_1, agg_function_1]
+ ).then( # 應用推薦後立即觸發一次圖表更新
+ create_plot,
+ inputs=chart_inputs_1, # 重新收集所有輸入
+ outputs=[chart_output_1]
)
- # 圖表 1 控制項變化觸發圖表 1 更新
- controls_1 = [chart_type_1, x_axis_1, y_axis_1, color_axis_1, agg_function_1]
- for control in controls_1:
- control.change(
- fn=create_plot,
- inputs=[data_state] + controls_1,
- outputs=[plot_output_1]
+
+ # --- 圖表二:處理顏色和圖案狀態 ---
+ color_customization_2.change(
+ parse_custom_colors,
+ inputs=[color_customization_2],
+ outputs=[custom_colors_state_2]
+ )
+ patterns_inputs_2 = [pattern1_2, pattern2_2, pattern3_2]
+ for pattern_dd in patterns_inputs_2:
+ pattern_dd.change(
+ update_patterns,
+ inputs=patterns_inputs_2,
+ outputs=[patterns_state_2]
)
- # 圖表 2 控制項變化觸發圖表 2 更新
- controls_2 = [chart_type_2, x_axis_2, y_axis_2, color_axis_2, agg_function_2]
- for control in controls_2:
- control.change(
- fn=create_plot,
- inputs=[data_state] + controls_2,
- outputs=[plot_output_2]
+ # --- 圖表二:更新圖表 ---
+ chart_inputs_2 = [
+ data_state, chart_type_2, x_column_2, y_column_2,
+ group_column_2, size_column_2, color_scheme_2, patterns_state_2,
+ chart_title_2, chart_width_2, chart_height_2, show_grid_2, show_legend_2,
+ agg_function_2, custom_colors_state_2
+ ]
+ update_button_2.click(
+ create_plot,
+ inputs=chart_inputs_2,
+ outputs=[chart_output_2]
+ )
+ # 當任何相關設置改變時,自動更新圖表二
+ for input_component in chart_inputs_2:
+ if isinstance(input_component, (gr.Dropdown, gr.Textbox, gr.Slider, gr.Checkbox)):
+ input_component.change(
+ create_plot,
+ inputs=chart_inputs_2,
+ outputs=[chart_output_2]
+ )
+
+ # --- 圖表二:導出圖表 ---
+ download_button_2.click(
+ download_figure,
+ inputs=[chart_output_2, export_img_format_2],
+ outputs=[export_chart_2, export_chart_status_2]
+ )
+
+ # --- 圖表類型改變時更新 UI 元素可見性 (針對兩個圖表) ---
+ def update_element_visibility(chart_type):
+ """根據圖表類型更新 Y 軸、分組列、大小列的標籤和可見性"""
+ # 圓餅圖、環形圖、漏斗圖、樹狀圖:主要關心類別 (X) 和數值 (Y)
+ is_pie_like = chart_type in ["圓餅圖", "環形圖", "漏斗圖", "樹狀圖"]
+ # 直方圖:主要關心 X 軸分佈,Y 軸是計數
+ is_histogram = chart_type == "直方圖"
+ # 箱型圖、小提琴圖:Y 軸是數值,X 軸是可選的分組
+ is_box_violin = chart_type in ["箱型圖", "小提琴圖"]
+ # 甘特圖:Y 軸是開始時間,分組列是結束時間
+ is_gantt = chart_type == "甘特圖"
+ # 熱力圖:需要 X, Y 和分組列
+ is_heatmap = chart_type == "熱力圖"
+ # 雷達圖:Theta (X), R (Y), Color (Group)
+ is_radar = chart_type == "雷達圖"
+
+ # Y 軸的標籤和需求
+ y_label = "Y軸 / 數值"
+ y_needed = True
+ if is_histogram:
+ y_label = "Y軸 (自動計數)"
+ y_needed = False # 不需要用戶選擇 Y
+ elif is_pie_like:
+ y_label = "數值列 (用於大小/值)"
+ elif is_box_violin:
+ y_label = "數值列"
+ elif is_gantt:
+ y_label = "開始時間列"
+ elif is_radar:
+ y_label = "徑向值 (R)"
+
+
+ # 分組列的標籤和需求
+ group_label = "分組列"
+ group_needed = chart_type in [
+ "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", # 可選
+ "折線圖", "多重折線圖", "階梯折線圖", # 可選
+ "區域圖", "堆疊區域圖", "百分比堆疊區域圖", # 可選
+ "散點圖", "氣泡圖", # 可選
+ "箱型圖", "小提琴圖", # 可選,用於 X 軸
+ "熱力圖", # 必需,用於 Index 或 Columns
+ "雷達圖", # 必需,用於區分線條
+ "極座標圖" # 可選
+ ]
+ if is_gantt:
+ group_label = "結束時間列"
+ group_needed = True
+ elif is_heatmap:
+ group_label = "行/列 分組"
+ group_needed = True
+
+
+ # 大小列的需求
+ size_label = "大小列"
+ size_needed = chart_type in ["氣泡圖", "散點圖"] # 甘特圖顏色也可以用 Size 列
+ if is_gantt:
+ size_label = "顏色列 (可選)"
+ size_needed = True # 允許選擇顏色列
+
+
+ # 返回更新對象
+ return (
+ gr.update(label=y_label, visible=y_needed),
+ gr.update(label=group_label, visible=group_needed),
+ gr.update(label=size_label, visible=size_needed)
)
-# --- 啟動應用 ---
+ # 將 chart_type 的變化連接到更新函數,並應用到兩個圖表的對應組件
+ chart_type_1.change(
+ update_element_visibility,
+ inputs=[chart_type_1],
+ outputs=[y_column_1, group_column_1, size_column_1]
+ )
+ chart_type_2.change(
+ update_element_visibility,
+ inputs=[chart_type_2],
+ outputs=[y_column_2, group_column_2, size_column_2]
+ )
+
+# =========================================
+# == 應用程式啟動 (Launch Application) ==
+# =========================================
if __name__ == "__main__":
- demo.launch(debug=True) # 開啟 debug 模式以便查看錯誤
\ No newline at end of file
+ # 在 Hugging Face Spaces 上,通常不需要 share=True
+ # debug=True 可以在開發時提供更詳細的錯誤信息
+ demo.launch(debug=True)