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(""" +
+

📊 進階數據可視化工具 V2

+

上傳或貼上數據,輕鬆創建和比較多種專業圖表

+
+ """) + + # --- 狀態變量 (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(""" +
使用說明
+ +

數據輸入

+ + +

圖表創建與比較

+ + +

提示

+ + """) + + # ========================================= + # == 事件處理 (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)