|
|
|
|
|
""" |
|
|
Gradio 應用程式:進階數據可視化工具 |
|
|
作者:Gemini |
|
|
版本:1.0 (分段提供 - Part 1) |
|
|
描述:此部分包含套件導入、常數定義、輔助函數和 CSS 樣式。 |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import gradio as gr |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
import plotly.express as px |
|
|
import plotly.graph_objects as go |
|
|
import io |
|
|
import base64 |
|
|
from PIL import Image |
|
|
|
|
|
|
|
|
from plotly.subplots import make_subplots |
|
|
import re |
|
|
import json |
|
|
import colorsys |
|
|
import traceback |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CHART_TYPES = [ |
|
|
|
|
|
"長條圖", "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", |
|
|
|
|
|
"折線圖", "多重折線圖", "階梯折線圖", |
|
|
|
|
|
"區域圖", "堆疊區域圖", "百分比堆疊區域圖", |
|
|
|
|
|
"圓餅圖", "環形圖", |
|
|
|
|
|
"散點圖", "氣泡圖", |
|
|
|
|
|
"直方圖", "箱型圖", "小提琴圖", |
|
|
|
|
|
"熱力圖", "樹狀圖", |
|
|
|
|
|
"雷達圖", "漏斗圖", "極座標圖", "甘特圖" |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
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_TYPES = [ |
|
|
"無", "/", "\\", "x", "-", "|", "+", "." |
|
|
] |
|
|
|
|
|
|
|
|
AGGREGATION_FUNCTIONS = [ |
|
|
"計數", "求和", "平均值", "中位數", "最大值", "最小值", "標準差", "變異數", "第一筆", "最後一筆" |
|
|
] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
COLOR_CARD_STYLE = """ |
|
|
<div style="display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px;"> |
|
|
{color_cards} |
|
|
</div> |
|
|
""" |
|
|
|
|
|
COLOR_CARD_TEMPLATE = """ |
|
|
<div title="{color_name} ({color_hex})" style=" |
|
|
width: 20px; |
|
|
height: 20px; |
|
|
background-color: {color_hex}; |
|
|
border-radius: 3px; |
|
|
cursor: pointer; |
|
|
border: 1px solid #ddd; |
|
|
transition: transform 0.2s; |
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.1); |
|
|
" onclick="copyToClipboard('{color_hex}')" onmouseover="this.style.transform='scale(1.15)'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.2)';" onmouseout="this.style.transform='scale(1)'; this.style.boxShadow='0 1px 2px rgba(0,0,0,0.1)';"></div> |
|
|
""" |
|
|
|
|
|
COPY_SCRIPT = """ |
|
|
<script> |
|
|
function copyToClipboard(text) { |
|
|
navigator.clipboard.writeText(text).then(() => { |
|
|
// 查找或創建通知容器 |
|
|
let notificationContainer = document.getElementById('clipboard-notification-container'); |
|
|
if (!notificationContainer) { |
|
|
notificationContainer = document.createElement('div'); |
|
|
notificationContainer.id = 'clipboard-notification-container'; |
|
|
notificationContainer.style.position = 'fixed'; |
|
|
notificationContainer.style.bottom = '20px'; |
|
|
notificationContainer.style.right = '20px'; |
|
|
notificationContainer.style.zIndex = '10000'; // 確保在最上層 |
|
|
notificationContainer.style.display = 'flex'; |
|
|
notificationContainer.style.flexDirection = 'column'; |
|
|
notificationContainer.style.alignItems = 'flex-end'; |
|
|
document.body.appendChild(notificationContainer); |
|
|
} |
|
|
|
|
|
// 創建通知元素 |
|
|
const notification = document.createElement('div'); |
|
|
notification.textContent = '已複製: ' + text; |
|
|
notification.style.background = 'rgba(0, 0, 0, 0.7)'; |
|
|
notification.style.color = 'white'; |
|
|
notification.style.padding = '8px 15px'; |
|
|
notification.style.borderRadius = '4px'; |
|
|
notification.style.marginTop = '5px'; |
|
|
notification.style.fontSize = '14px'; |
|
|
notification.style.opacity = '1'; |
|
|
notification.style.transition = 'opacity 0.5s ease-out'; |
|
|
notification.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; |
|
|
|
|
|
// 添加到容器並設置消失 |
|
|
notificationContainer.appendChild(notification); |
|
|
|
|
|
setTimeout(() => { |
|
|
notification.style.opacity = '0'; |
|
|
setTimeout(() => { |
|
|
notification.remove(); |
|
|
// 如果容器空了,可以考慮移除容器 |
|
|
if (notificationContainer.children.length === 0) { |
|
|
// notificationContainer.remove(); // 或者保留以便後續使用 |
|
|
} |
|
|
}, 500); // 等待淡出動畫完成 |
|
|
}, 1500); // 顯示 1.5 秒 |
|
|
}).catch(err => { |
|
|
console.error('無法複製顏色代碼: ', err); |
|
|
// 可以添加一個錯誤提示 |
|
|
}); |
|
|
} |
|
|
</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): |
|
|
|
|
|
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))) |
|
|
|
|
|
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"] |
|
|
} |
|
|
|
|
|
|
|
|
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""" |
|
|
<div style="font-weight: bold; margin-top: 10px; font-size: 14px; color: #555;">{name}</div> |
|
|
{COLOR_CARD_STYLE.format(color_cards=cards)} |
|
|
""" |
|
|
|
|
|
|
|
|
color_display = f""" |
|
|
<div style="font-weight: bold; margin-top: 10px; font-size: 14px; color: #555;">常用單色</div> |
|
|
{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", |
|
|
"求和": "sum", |
|
|
"平均值": "mean", |
|
|
"中位數": "median", |
|
|
"最大值": "max", |
|
|
"最小值": "min", |
|
|
"標準差": "std", |
|
|
"變異數": "var", |
|
|
"第一筆": "first", |
|
|
"最後一筆": "last", |
|
|
|
|
|
} |
|
|
return mapping.get(func_name, "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() |
|
|
|
|
|
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 = [p for p in patterns_input if p and p != "無"] |
|
|
return patterns |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CUSTOM_CSS = """ |
|
|
/* --- 全局和容器 --- */ |
|
|
.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; |
|
|
} |
|
|
|
|
|
/* --- 其他 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; |
|
|
} |
|
|
.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; |
|
|
} |
|
|
|
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
Gradio 應用程式:進階數據可視化工具 |
|
|
作者:Gemini |
|
|
版本:1.0 (分段提供 - Part 2) |
|
|
描述:此部分包含核心邏輯函數,如數據處理、圖表創建、導出等。 |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_upload(file): |
|
|
""" |
|
|
處理上傳的文件 (CSV 或 Excel)。 |
|
|
Args: |
|
|
file: Gradio 文件對象。 |
|
|
Returns: |
|
|
tuple: (DataFrame 或 None, 狀態消息)。 |
|
|
""" |
|
|
if file is None: |
|
|
return None, "❌ 未上傳任何文件。" |
|
|
try: |
|
|
file_path = file.name |
|
|
file_type = file_path.split('.')[-1].lower() |
|
|
|
|
|
if file_type == 'csv': |
|
|
|
|
|
try: |
|
|
df = pd.read_csv(file_path, encoding='utf-8') |
|
|
except UnicodeDecodeError: |
|
|
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 = 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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return df, f"✅ 成功載入 '{file_path.split('/')[-1]}',共 {len(df)} 行,{len(df.columns)} 列。" |
|
|
|
|
|
except Exception as e: |
|
|
print(f"處理上傳文件時發生未預期錯誤: {e}") |
|
|
traceback.print_exc() |
|
|
return None, f"❌ 處理文件時發生未預期錯誤: {e}" |
|
|
|
|
|
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: |
|
|
|
|
|
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: |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_default = columns[1] if len(columns) > 1 else (columns[0] if columns else 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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_str="是", show_legend_str="是", agg_func_name="計數", custom_colors_dict={}): |
|
|
""" |
|
|
根據用戶選擇創建 Plotly 圖表 (已加入 Null/空白 過濾)。 |
|
|
|
|
|
Args: |
|
|
df (pd.DataFrame): 輸入數據。 |
|
|
chart_type (str): 圖表類型。 |
|
|
x_column (str): X軸或類別列。 |
|
|
y_column (str): Y軸或數值列 (可能為 None)。 |
|
|
group_column (str, optional): 分組列 (可能為 "無" 或 None)。 Defaults to None. |
|
|
size_column (str, optional): 大小列 (可能為 "無" 或 None)。 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_str (str, optional): 是否顯示網格 ("是" 或 "否")。 Defaults to "是". |
|
|
show_legend_str (str, optional): 是否顯示圖例 ("是" 或 "否")。 Defaults to "是". |
|
|
agg_func_name (str, optional): 聚合函數名稱。 Defaults to "計數". |
|
|
custom_colors_dict (dict, optional): 自定義顏色映射。 Defaults to {}. |
|
|
|
|
|
Returns: |
|
|
go.Figure: Plotly 圖表對象。 |
|
|
""" |
|
|
|
|
|
print("-" * 20, file=sys.stderr) |
|
|
print(f"調用 create_plot:", file=sys.stderr) |
|
|
print(f" - df type: {type(df)}", file=sys.stderr) |
|
|
if isinstance(df, pd.DataFrame): |
|
|
print(f" - df empty: {df.empty}", file=sys.stderr) |
|
|
print(f" - df shape: {df.shape}", file=sys.stderr) |
|
|
print(f" - chart_type: {chart_type}", file=sys.stderr) |
|
|
print(f" - x_column: {x_column}", file=sys.stderr) |
|
|
print(f" - y_column: {y_column}", file=sys.stderr) |
|
|
print(f" - group_column: {group_column}", file=sys.stderr) |
|
|
print(f" - size_column: {size_column}", file=sys.stderr) |
|
|
print(f" - agg_func_name: {agg_func_name}", file=sys.stderr) |
|
|
print(f" - show_grid_str: {show_grid_str}", file=sys.stderr) |
|
|
print(f" - show_legend_str: {show_legend_str}", file=sys.stderr) |
|
|
print("-" * 20, file=sys.stderr) |
|
|
|
|
|
|
|
|
fig = go.Figure() |
|
|
try: |
|
|
|
|
|
show_grid = True if show_grid_str == "是" else False |
|
|
show_legend = True if show_legend_str == "是" else False |
|
|
|
|
|
|
|
|
if df is None or not isinstance(df, pd.DataFrame) or df.empty: |
|
|
raise ValueError("沒有有效的 DataFrame 數據可供繪圖。請先載入數據。") |
|
|
if not chart_type: raise ValueError("請選擇圖表類型。") |
|
|
if not agg_func_name: raise ValueError("請選擇聚合函數。") |
|
|
|
|
|
if not x_column or x_column == NO_DATA_STR: raise ValueError("請選擇有效的 X 軸或類別列。") |
|
|
|
|
|
|
|
|
if x_column not in df.columns: raise ValueError(f"X 軸列 '{x_column}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
|
|
|
|
|
|
y_needed = agg_func_name != "計數" and chart_type not in ["直方圖"] |
|
|
|
|
|
if y_needed: |
|
|
if not y_column or y_column == NO_DATA_STR: raise ValueError("此圖表類型和聚合函數需要選擇有效的 Y 軸或數值列。") |
|
|
if y_column not in df.columns: raise ValueError(f"Y 軸列 '{y_column}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
else: |
|
|
y_column = None |
|
|
|
|
|
|
|
|
|
|
|
group_col = None if group_column == NONE_STR or not group_column else group_column |
|
|
size_col = None if size_column == NONE_STR or not size_column else size_column |
|
|
|
|
|
if group_col and group_col not in df.columns: raise ValueError(f"分組列 '{group_col}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
if size_col and size_col not in df.columns: raise ValueError(f"大小列 '{size_col}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
if group_col == x_column: raise ValueError("分組列不能與 X 軸列相同。") |
|
|
|
|
|
df_processed = df.copy() |
|
|
print(f"原始數據行數: {len(df_processed)}", file=sys.stderr) |
|
|
|
|
|
|
|
|
columns_to_filter = [x_column] |
|
|
if y_needed and y_column: |
|
|
columns_to_filter.append(y_column) |
|
|
if group_col: |
|
|
columns_to_filter.append(group_col) |
|
|
|
|
|
|
|
|
valid_columns_to_filter = [col for col in columns_to_filter if col in df_processed.columns] |
|
|
if valid_columns_to_filter: |
|
|
original_rows = len(df_processed) |
|
|
df_processed.dropna(subset=valid_columns_to_filter, inplace=True) |
|
|
print(f"移除 Null ({', '.join(valid_columns_to_filter)}) 後行數: {len(df_processed)} (減少 {original_rows - len(df_processed)} 行)", file=sys.stderr) |
|
|
else: |
|
|
print("警告: 沒有有效的列用於 Null 值過濾。", file=sys.stderr) |
|
|
|
|
|
|
|
|
if x_column in df_processed.columns: |
|
|
try: |
|
|
original_rows = len(df_processed) |
|
|
|
|
|
df_processed = df_processed[df_processed[x_column].astype(str).str.strip() != ''] |
|
|
print(f"移除 X 軸 '{x_column}' 空白字符串後行數: {len(df_processed)} (減少 {original_rows - len(df_processed)} 行)", file=sys.stderr) |
|
|
except Exception as e: |
|
|
print(f"警告: 過濾 X 軸 '{x_column}' 空白字符串時出錯: {e}", file=sys.stderr) |
|
|
|
|
|
if group_col and group_col in df_processed.columns: |
|
|
try: |
|
|
original_rows = len(df_processed) |
|
|
df_processed = df_processed[df_processed[group_col].astype(str).str.strip() != ''] |
|
|
print(f"移除分組列 '{group_col}' 空白字符串後行數: {len(df_processed)} (減少 {original_rows - len(df_processed)} 行)", file=sys.stderr) |
|
|
except Exception as e: |
|
|
print(f"警告: 過濾分組列 '{group_col}' 空白字符串時出錯: {e}", file=sys.stderr) |
|
|
|
|
|
|
|
|
if df_processed.empty: |
|
|
raise ValueError("過濾掉 Null 或空白值後,沒有剩餘數據可供繪圖。") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
df_processed[x_column] = df_processed[x_column].astype(str) |
|
|
if group_col: |
|
|
df_processed[group_col] = df_processed[group_col].astype(str) |
|
|
|
|
|
|
|
|
if y_column: |
|
|
try: df_processed[y_column] = pd.to_numeric(df_processed[y_column], errors='coerce') |
|
|
except Exception as e: print(f"警告:轉換 Y 軸列 '{y_column}' 為數值時出錯: {e}") |
|
|
if size_col: |
|
|
try: df_processed[size_col] = pd.to_numeric(df_processed[size_col], errors='coerce') |
|
|
except Exception as e: print(f"警告:轉換大小列 '{size_col}' 為數值時出錯: {e}") |
|
|
|
|
|
|
|
|
|
|
|
needs_aggregation = chart_type not in ["散點圖", "氣泡圖", "直方圖", "箱型圖", "小提琴圖", "甘特圖"] |
|
|
agg_df = None |
|
|
y_col_agg = y_column |
|
|
if needs_aggregation: |
|
|
grouping_cols = [x_column] + ([group_col] if group_col else []) |
|
|
|
|
|
|
|
|
if agg_func_name == "計數": |
|
|
|
|
|
agg_df = df_processed.groupby(grouping_cols, observed=False, dropna=False).size() |
|
|
agg_df = agg_df.reset_index(name='__count__') |
|
|
y_col_agg = '__count__' |
|
|
else: |
|
|
agg_func_pd = agg_function_map(agg_func_name) |
|
|
if not y_column: raise ValueError(f"聚合函數 '{agg_func_name}' 需要一個有效的 Y 軸數值列。") |
|
|
|
|
|
if agg_func_pd not in ['first', 'last'] and not pd.api.types.is_numeric_dtype(df_processed[y_column]): |
|
|
|
|
|
try: df_processed[y_column] = pd.to_numeric(df_processed[y_column], errors='raise') |
|
|
except (ValueError, TypeError): raise ValueError(f"Y 軸列 '{y_column}' 必須是數值類型才能執行聚合 '{agg_func_name}'。") |
|
|
|
|
|
try: |
|
|
|
|
|
agg_df = df_processed.groupby(grouping_cols, observed=False, dropna=False)[y_column].agg(agg_func_pd) |
|
|
agg_df = agg_df.reset_index() |
|
|
y_col_agg = y_column |
|
|
except Exception as agg_e: |
|
|
raise ValueError(f"執行聚合 '{agg_func_name}' 時出錯: {agg_e}") |
|
|
else: |
|
|
|
|
|
agg_df = df_processed |
|
|
y_col_agg = y_column |
|
|
|
|
|
|
|
|
if agg_df is None or agg_df.empty: |
|
|
raise ValueError("數據聚合後沒有產生有效結果。") |
|
|
|
|
|
required_cols_for_plot = [x_column] |
|
|
|
|
|
if y_col_agg: required_cols_for_plot.append(y_col_agg) |
|
|
if group_col: required_cols_for_plot.append(group_col) |
|
|
if size_col: required_cols_for_plot.append(size_col) |
|
|
missing_cols = [col for col in required_cols_for_plot if col not in agg_df.columns] |
|
|
if missing_cols: |
|
|
raise ValueError(f"聚合後的數據缺少繪圖所需的列: {', '.join(missing_cols)}") |
|
|
|
|
|
|
|
|
|
|
|
colors = COLOR_SCHEMES.get(color_scheme_name, px.colors.qualitative.Plotly) |
|
|
|
|
|
|
|
|
fig_params = {"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 |
|
|
|
|
|
effective_y = y_col_agg |
|
|
|
|
|
|
|
|
if chart_type == "長條圖": |
|
|
if not effective_y: raise ValueError("長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, **fig_params) |
|
|
elif chart_type == "堆疊長條圖": |
|
|
if not effective_y: raise ValueError("堆疊長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, barmode='stack', **fig_params) |
|
|
elif chart_type == "百分比堆疊長條圖": |
|
|
if not effective_y: raise ValueError("百分比堆疊長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, barmode='relative', text_auto='.1%', **fig_params) |
|
|
fig.update_layout(yaxis_title="百分比 (%)") |
|
|
elif chart_type == "群組長條圖": |
|
|
if not effective_y: raise ValueError("群組長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, barmode='group', **fig_params) |
|
|
elif chart_type == "水平長條圖": |
|
|
if not effective_y: raise ValueError("水平長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, y=x_column, x=effective_y, color=group_col, orientation='h', **fig_params) |
|
|
elif chart_type == "折線圖": |
|
|
if not effective_y: raise ValueError("折線圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.line(agg_df, x=x_column, y=effective_y, color=group_col, markers=True, **fig_params) |
|
|
elif chart_type == "多重折線圖": |
|
|
if not effective_y: raise ValueError("多重折線圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.line(agg_df, x=x_column, y=effective_y, color=group_col, markers=True, **fig_params) |
|
|
elif chart_type == "階梯折線圖": |
|
|
if not effective_y: raise ValueError("階梯折線圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.line(agg_df, x=x_column, y=effective_y, color=group_col, line_shape='hv', **fig_params) |
|
|
elif chart_type == "區域圖": |
|
|
if not effective_y: raise ValueError("區域圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.area(agg_df, x=x_column, y=effective_y, color=group_col, **fig_params) |
|
|
elif chart_type == "堆疊區域圖": |
|
|
if not effective_y: raise ValueError("堆疊區域圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.area(agg_df, x=x_column, y=effective_y, color=group_col, groupnorm=None, **fig_params) |
|
|
elif chart_type == "百分比堆疊區域圖": |
|
|
if not effective_y: raise ValueError("百分比堆疊區域圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.area(agg_df, x=x_column, y=effective_y, color=group_col, groupnorm='percent', **fig_params) |
|
|
fig.update_layout(yaxis_title="百分比 (%)") |
|
|
elif chart_type == "圓餅圖": |
|
|
if not effective_y: raise ValueError("圓餅圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if group_col: print("警告:圓餅圖不支持分組列,已忽略。") |
|
|
fig = px.pie(agg_df, names=x_column, values=effective_y, **fig_params) |
|
|
if not group_col and custom_colors_dict: fig.update_traces(marker=dict(colors=[custom_colors_dict.get(str(cat), colors[i % len(colors)]) for i, cat in enumerate(agg_df[x_column])])) |
|
|
elif chart_type == "環形圖": |
|
|
if not effective_y: raise ValueError("環形圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if group_col: print("警告:環形圖不支持分組列,已忽略。") |
|
|
fig = px.pie(agg_df, names=x_column, values=effective_y, hole=0.4, **fig_params) |
|
|
if not group_col and custom_colors_dict: fig.update_traces(marker=dict(colors=[custom_colors_dict.get(str(cat), colors[i % len(colors)]) for i, cat in enumerate(agg_df[x_column])])) |
|
|
elif chart_type == "散點圖": |
|
|
|
|
|
if not y_column: raise ValueError("散點圖需要選擇 Y 軸列。") |
|
|
fig = px.scatter(agg_df, x=x_column, y=y_column, color=group_col, size=size_col, **fig_params) |
|
|
elif chart_type == "氣泡圖": |
|
|
if not y_column: raise ValueError("氣泡圖需要選擇 Y 軸列。") |
|
|
if not size_col: raise ValueError("氣泡圖需要指定 '大小列'。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[size_col]): raise ValueError(f"大小列 '{size_col}' 必須是數值類型。") |
|
|
fig = px.scatter(agg_df, x=x_column, y=y_column, color=group_col, size=size_col, size_max=60, **fig_params) |
|
|
elif chart_type == "直方圖": |
|
|
|
|
|
if not pd.api.types.is_numeric_dtype(agg_df[x_column]): raise ValueError(f"直方圖的 X 軸列 '{x_column}' 必須是數值類型。") |
|
|
fig = px.histogram(agg_df, x=x_column, color=group_col, **fig_params); fig.update_layout(yaxis_title="計數") |
|
|
elif chart_type == "箱型圖": |
|
|
|
|
|
if not y_column: raise ValueError("箱型圖需要選擇 Y 軸列。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[y_column]): raise ValueError(f"箱型圖的 Y 軸列 '{y_column}' 必須是數值類型。") |
|
|
fig = px.box(agg_df, x=group_col, y=y_column, color=group_col, **fig_params) |
|
|
if not group_col: fig = px.box(agg_df, y=y_column, **fig_params) |
|
|
elif chart_type == "小提琴圖": |
|
|
if not y_column: raise ValueError("小提琴圖需要選擇 Y 軸列。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[y_column]): raise ValueError(f"小提琴圖的 Y 軸列 '{y_column}' 必須是數值類型。") |
|
|
fig = px.violin(agg_df, x=group_col, y=y_column, color=group_col, box=True, points="all", **fig_params) |
|
|
if not group_col: fig = px.violin(agg_df, y=y_column, box=True, points="all", **fig_params) |
|
|
elif chart_type == "熱力圖": |
|
|
if not effective_y: raise ValueError("熱力圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if not group_col: raise ValueError("熱力圖需要 X 軸、Y 軸 和一個 分組列。") |
|
|
try: |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"熱力圖的值列 '{effective_y}' 必須是數值類型。") |
|
|
pivot_df = pd.pivot_table(agg_df, values=effective_y, 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", text_auto=True, **fig_params); |
|
|
fig.update_layout(coloraxis_showscale=True) |
|
|
except Exception as pivot_e: raise ValueError(f"創建熱力圖的數據透視表時出錯: {pivot_e}") |
|
|
elif chart_type == "樹狀圖": |
|
|
if not effective_y: raise ValueError("樹狀圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
path = [group_col, x_column] if group_col else [x_column] |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"樹狀圖的值列 '{effective_y}' 必須是數值類型。") |
|
|
fig = px.treemap(agg_df, path=path, values=effective_y, color=group_col if group_col else x_column, **fig_params) |
|
|
elif chart_type == "雷達圖": |
|
|
if not effective_y: raise ValueError("雷達圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = go.Figure() |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"雷達圖的徑向值列 '{effective_y}' 必須是數值類型。") |
|
|
if not group_col: |
|
|
theta = agg_df[x_column].tolist(); r = agg_df[effective_y].tolist(); theta.append(theta[0]); r.append(r[0]) |
|
|
fig.add_trace(go.Scatterpolar(r=r, theta=theta, fill='toself', name=effective_y if effective_y != '__count__' else '計數', 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[effective_y].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, title=title, width=width, height=height) |
|
|
elif chart_type == "漏斗圖": |
|
|
if not effective_y: raise ValueError("漏斗圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"漏斗圖的值列 '{effective_y}' 必須是數值類型。") |
|
|
sorted_df = agg_df.sort_values(by=effective_y, ascending=False) |
|
|
fig = px.funnel(sorted_df, x=effective_y, y=x_column, color=group_col, **fig_params) |
|
|
elif chart_type == "極座標圖": |
|
|
if not effective_y: raise ValueError("極座標圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"極座標圖的徑向值列 '{effective_y}' 必須是數值類型。") |
|
|
fig = px.bar_polar(agg_df, r=effective_y, theta=x_column, color=group_col if group_col else x_column, **fig_params) |
|
|
elif chart_type == "甘特圖": |
|
|
start_col_gantt = y_column; end_col_gantt = group_col; task_col_gantt = x_column |
|
|
if not start_col_gantt or not end_col_gantt: raise ValueError("甘特圖需要指定 開始列 (Y軸) 和 結束列 (分組列)。") |
|
|
try: |
|
|
df_gantt = df.copy() |
|
|
if start_col_gantt not in df_gantt.columns: raise ValueError(f"開始列 '{start_col_gantt}' 不在數據中。") |
|
|
if end_col_gantt not in df_gantt.columns: raise ValueError(f"結束列 '{end_col_gantt}' 不在數據中。") |
|
|
if task_col_gantt not in df_gantt.columns: raise ValueError(f"任務列 '{task_col_gantt}' 不在數據中。") |
|
|
df_gantt['_start_'] = pd.to_datetime(df_gantt[start_col_gantt], errors='coerce') |
|
|
df_gantt['_end_'] = pd.to_datetime(df_gantt[end_col_gantt], errors='coerce') |
|
|
if df_gantt['_start_'].isnull().any(): raise ValueError(f"開始列 '{start_col_gantt}' 包含無效或無法解析的日期時間格式。") |
|
|
if df_gantt['_end_'].isnull().any(): raise ValueError(f"結束列 '{end_col_gantt}' 包含無效或無法解析的日期時間格式。") |
|
|
fig = px.timeline(df_gantt, x_start='_start_', x_end='_end_', y=task_col_gantt, 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}',使用長條圖代替。") |
|
|
if not effective_y: raise ValueError("長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, **fig_params) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if patterns: |
|
|
try: |
|
|
num_traces = len(fig.data) |
|
|
if chart_type in ["長條圖", "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", "圓餅圖", "環形圖"]: |
|
|
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" |
|
|
elif chart_type in ["散點圖", "氣泡圖"]: |
|
|
symbol_map = {"/": "diamond", "\\": "square", "x": "x", "-": "line-ew", "|": "line-ns", "+": "cross", ".": "circle-dot"} |
|
|
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 |
|
|
elif chart_type in ["折線圖", "多重折線圖", "階梯折線圖"]: |
|
|
dash_map = {"/": "dash", "\\": "dot", "x": "dashdot", "-": "longdash", "|": "solid", "+": "solid", ".": "solid"} |
|
|
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 |
|
|
elif chart_type in ["區域圖", "堆疊區域圖", "百分比堆疊區域圖"]: |
|
|
print("提示:區域圖的圖案填充支持有限,將嘗試應用線型。") |
|
|
dash_map = {"/": "dash", "\\": "dot", "x": "dashdot", "-": "longdash"} |
|
|
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 hasattr(trace, 'stackgroup') and trace.stackgroup else 'tozeroy' |
|
|
except Exception as pattern_e: print(f"應用圖案時出錯: {pattern_e}") |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
final_y_label = y_col_agg if y_col_agg != '__count__' else '計數' |
|
|
if chart_type in ["圓餅圖", "環形圖", "漏斗圖", "樹狀圖"]: fig.update_layout(xaxis_title=None, yaxis_title=None) |
|
|
elif chart_type == "水平長條圖": fig.update_layout(xaxis_title=final_y_label, yaxis_title=x_column) |
|
|
elif chart_type == "直方圖": fig.update_layout(xaxis_title=x_column, yaxis_title='計數') |
|
|
elif chart_type == "甘特圖": fig.update_layout(xaxis_title="時間", yaxis_title=x_column) |
|
|
else: fig.update_layout(xaxis_title=x_column, yaxis_title=final_y_label) |
|
|
|
|
|
except ValueError as ve: |
|
|
print(f"圖表創建錯誤 (ValueError): {ve}", file=sys.stderr); traceback.print_exc(file=sys.stderr) |
|
|
fig = go.Figure(); fig.add_annotation(text=f"⚠️ 創建圖表時出錯:<br>{ve}", align='left', showarrow=False, font=dict(size=14, color="red")); fig.update_layout(xaxis_visible=False, yaxis_visible=False) |
|
|
except Exception as e: |
|
|
error_message = f"❌ 創建圖表時發生未預期錯誤:\n{traceback.format_exc()}"; print(error_message, file=sys.stderr) |
|
|
fig = go.Figure(); user_error_msg = f"⚠️ 創建圖表時發生內部錯誤。<br>請檢查數據和設置。<br>詳細錯誤: {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) |
|
|
|
|
|
print("create_plot 函數執行完畢。", file=sys.stderr) |
|
|
return fig |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
return None, "❌ 沒有數據可以導出。" |
|
|
|
|
|
try: |
|
|
if format_type == "CSV": |
|
|
filename = "exported_data.csv" |
|
|
df.to_csv(filename, index=False, encoding='utf-8-sig') |
|
|
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) |
|
|
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}" |
|
|
|
|
|
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: |
|
|
format_lower = format_type.lower() |
|
|
filename = f"chart_export.{format_lower}" |
|
|
|
|
|
|
|
|
fig.write_image(filename, format=format_lower) |
|
|
|
|
|
return filename, f"✅ 圖表已成功準備為 {format_type} 格式,點擊下方鏈接下載。" |
|
|
|
|
|
except ValueError as ve: |
|
|
|
|
|
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: |
|
|
print(f"導出圖表時發生未預期錯誤: {e}") |
|
|
traceback.print_exc() |
|
|
return None, f"❌ 導出圖表時發生未預期錯誤: {e}" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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": "無法提供推薦。" |
|
|
} |
|
|
|
|
|
if df is None or df.empty: |
|
|
recommendation["message"] = "ℹ️ 請先上傳或輸入數據。" |
|
|
return recommendation |
|
|
|
|
|
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 |
|
|
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 |
|
|
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"] = "無法根據當前數據結構提供明確的圖表推薦。" |
|
|
|
|
|
except Exception as e: |
|
|
recommendation["message"] = f"❌ 推薦時出錯: {e}" |
|
|
print(f"智能推薦時出錯: {e}") |
|
|
traceback.print_exc() |
|
|
|
|
|
|
|
|
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"] = "無" |
|
|
|
|
|
|
|
|
if recommendation["agg_function"] and recommendation["agg_function"] != "計數" and not recommendation["y_column"]: |
|
|
recommendation["agg_function"] = None |
|
|
recommendation["message"] += " (無法確定聚合的數值列)" |
|
|
|
|
|
|
|
|
if recommendation["agg_function"] == "計數": |
|
|
recommendation["y_column"] = None |
|
|
|
|
|
return recommendation |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
""" |
|
|
Gradio 應用程式:進階數據可視化工具 |
|
|
作者:Gemini |
|
|
版本:5.2 (完整修正版 - 清理格式, 確保一致性) |
|
|
描述:包含所有功能的完整程式碼,修正導入、CSS、格式問題。 |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import gradio as gr |
|
|
import pandas as pd |
|
|
import numpy as np |
|
|
import plotly.express as px |
|
|
import plotly.graph_objects as go |
|
|
import io |
|
|
import base64 |
|
|
from PIL import Image |
|
|
from plotly.subplots import make_subplots |
|
|
import re |
|
|
import json |
|
|
import colorsys |
|
|
import traceback |
|
|
import sys |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CHART_TYPES = ["長條圖", "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", "折線圖", "多重折線圖", "階梯折線圖", "區域圖", "堆疊區域圖", "百分比堆疊區域圖", "圓餅圖", "環形圖", "散點圖", "氣泡圖", "直方圖", "箱型圖", "小提琴圖", "熱力圖", "樹狀圖", "雷達圖", "漏斗圖", "極座標圖", "甘特圖"] |
|
|
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_TYPES = ["無", "/", "\\", "x", "-", "|", "+", "."] |
|
|
AGGREGATION_FUNCTIONS = ["計數", "求和", "平均值", "中位數", "最大值", "最小值", "標準差", "變異數", "第一筆", "最後一筆"] |
|
|
EXPORT_FORMATS_DATA = ["CSV", "Excel", "JSON"] |
|
|
EXPORT_FORMATS_IMG = ["PNG", "SVG", "PDF", "JPEG"] |
|
|
YES_NO_CHOICES = ["是", "否"] |
|
|
NO_DATA_STR = "-- 無數據 --" |
|
|
NONE_STR = "無" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
COLOR_CARD_STYLE = """<div style="display: flex; flex-wrap: wrap; gap: 5px; margin-top: 5px;">{color_cards}</div>""" |
|
|
COLOR_CARD_TEMPLATE = """<div title="{color_name} ({color_hex})" style="width: 20px; height: 20px; background-color: {color_hex}; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; transition: transform 0.2s; box-shadow: 0 1px 2px rgba(0,0,0,0.1);" onclick="copyToClipboard('{color_hex}')" onmouseover="this.style.transform='scale(1.15)'; this.style.boxShadow='0 2px 4px rgba(0,0,0,0.2)';" onmouseout="this.style.transform='scale(1)'; this.style.boxShadow='0 1px 2px rgba(0,0,0,0.1)';"></div>""" |
|
|
COPY_SCRIPT = """ |
|
|
<script> |
|
|
function copyToClipboard(text) { |
|
|
navigator.clipboard.writeText(text).then(() => { |
|
|
let notificationContainer = document.getElementById('clipboard-notification-container'); |
|
|
if (!notificationContainer) { |
|
|
notificationContainer = document.createElement('div'); |
|
|
notificationContainer.id = 'clipboard-notification-container'; |
|
|
notificationContainer.style.position = 'fixed'; notificationContainer.style.bottom = '20px'; notificationContainer.style.right = '20px'; notificationContainer.style.zIndex = '10000'; notificationContainer.style.display = 'flex'; notificationContainer.style.flexDirection = 'column'; notificationContainer.style.alignItems = 'flex-end'; |
|
|
document.body.appendChild(notificationContainer); |
|
|
} |
|
|
const notification = document.createElement('div'); |
|
|
notification.textContent = '已複製: ' + text; |
|
|
notification.style.background = 'rgba(0, 0, 0, 0.7)'; notification.style.color = 'white'; notification.style.padding = '8px 15px'; notification.style.borderRadius = '4px'; notification.style.marginTop = '5px'; notification.style.fontSize = '14px'; notification.style.opacity = '1'; notification.style.transition = 'opacity 0.5s ease-out'; notification.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)'; |
|
|
notificationContainer.appendChild(notification); |
|
|
setTimeout(() => { |
|
|
notification.style.opacity = '0'; |
|
|
setTimeout(() => { notification.remove(); }, 500); |
|
|
}, 1500); |
|
|
}).catch(err => { console.error('無法複製顏色代碼: ', err); }); |
|
|
} |
|
|
</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): r, g, b = [max(0, min(255, int(c))) for c in rgb]; return '#{:02x}{:02x}{:02x}'.format(r, g, b) |
|
|
try: |
|
|
start_rgb, end_rgb = hex_to_rgb(start_color), hex_to_rgb(end_color); |
|
|
if steps <= 1: return [start_color] if steps == 1 else [] |
|
|
r_step, g_step, b_step = [(end_rgb[i] - start_rgb[i]) / (steps - 1) for i in range(3)] |
|
|
return [rgb_to_hex((start_rgb[0] + r_step * i, start_rgb[1] + g_step * i, start_rgb[2] + b_step * i)) for i in range(steps)] |
|
|
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"]} |
|
|
def generate_color_cards(): |
|
|
common_cards = "".join([COLOR_CARD_TEMPLATE.format(color_name=name, color_hex=hex_code) for name, hex_code in COMMON_COLORS.items()]) |
|
|
gradient_cards_html = "" |
|
|
for name, colors in GRADIENTS.items(): |
|
|
cards = "".join([COLOR_CARD_TEMPLATE.format(color_name=f"{name} {i+1}/{len(colors)}", color_hex=color) for i, color in enumerate(colors)]) |
|
|
gradient_cards_html += f"""<div style="font-weight: bold; margin-top: 10px; font-size: 14px; color: #555;">{name}</div>{COLOR_CARD_STYLE.format(color_cards=cards)}""" |
|
|
return f"""<div style="font-weight: bold; margin-top: 10px; font-size: 14px; color: #555;">常用單色</div>{COLOR_CARD_STYLE.format(color_cards=common_cards)}{gradient_cards_html}{COPY_SCRIPT}""" |
|
|
|
|
|
|
|
|
def agg_function_map(func_name): |
|
|
mapping = {"計數": "count", "求和": "sum", "平均值": "mean", "中位數": "median", "最大值": "max", "最小值": "min", "標準差": "std", "變異數": "var", "第一筆": "first", "最後一筆": "last"} |
|
|
return mapping.get(func_name, "count") |
|
|
def parse_custom_colors(color_text): |
|
|
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, value = key.strip(), value.strip() |
|
|
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): |
|
|
return [p for p in patterns_input if p in PATTERN_TYPES and p != "無"] |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def process_upload(file): |
|
|
if file is None: return None, "❌ 未上傳任何文件。" |
|
|
try: |
|
|
file_path = file.name; file_type = file_path.split('.')[-1].lower() |
|
|
if file_type == 'csv': |
|
|
try: df = pd.read_csv(file_path, encoding='utf-8') |
|
|
except UnicodeDecodeError: |
|
|
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 = 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() |
|
|
return df, f"✅ 成功載入 '{file_path.split('/')[-1]}',共 {len(df)} 行,{len(df.columns)} 列。" |
|
|
except Exception as e: print(f"處理上傳文件時發生未預期錯誤: {e}"); traceback.print_exc(); return None, f"❌ 處理文件時發生未預期錯誤: {e}" |
|
|
|
|
|
def parse_data(text_data): |
|
|
if not text_data or not text_data.strip(): return None, "❌ 未輸入任何數據。" |
|
|
try: |
|
|
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: separator = ',' |
|
|
try: df = pd.read_csv(data_io, sep=separator, skipinitialspace=True) |
|
|
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() |
|
|
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_as_radio(df): |
|
|
"""更新列選擇為 Radio 選項,並為 Y/Group/Size 軸添加 '無'""" |
|
|
no_data_choices = [NO_DATA_STR] |
|
|
no_data_choices_with_none = [NONE_STR, NO_DATA_STR] |
|
|
|
|
|
if df is None or df.empty: |
|
|
no_data_update = gr.Radio(choices=no_data_choices, value=no_data_choices[0]) |
|
|
no_data_update_with_none = gr.Radio(choices=no_data_choices_with_none, value=NONE_STR) |
|
|
return no_data_update, no_data_update_with_none, no_data_update_with_none, no_data_update_with_none |
|
|
|
|
|
try: |
|
|
columns = df.columns.tolist() |
|
|
valid_columns = [str(col) for col in columns if col is not None and str(col) != ""] |
|
|
if not valid_columns: |
|
|
no_data_update = gr.Radio(choices=no_data_choices, value=no_data_choices[0]) |
|
|
no_data_update_with_none = gr.Radio(choices=no_data_choices_with_none, value=NONE_STR) |
|
|
return no_data_update, no_data_update_with_none, no_data_update_with_none, no_data_update_with_none |
|
|
|
|
|
x_default = valid_columns[0] |
|
|
y_default = NONE_STR if len(valid_columns) <= 1 else valid_columns[1] |
|
|
|
|
|
y_choices = [NONE_STR] + valid_columns |
|
|
group_choices = [NONE_STR] + valid_columns |
|
|
size_choices = [NONE_STR] + valid_columns |
|
|
|
|
|
return (gr.Radio(choices=valid_columns, value=x_default, label="X軸 / 類別"), |
|
|
gr.Radio(choices=y_choices, value=y_default, label="Y軸 / 數值"), |
|
|
gr.Radio(choices=group_choices, value=NONE_STR, label="分組列"), |
|
|
gr.Radio(choices=size_choices, value=NONE_STR, label="大小列")) |
|
|
except Exception as e: |
|
|
print(f"更新列選項 (Radio) 時出錯: {e}") |
|
|
no_data_update = gr.Radio(choices=no_data_choices, value=no_data_choices[0]) |
|
|
no_data_update_with_none = gr.Radio(choices=no_data_choices_with_none, value=NONE_STR) |
|
|
return no_data_update, no_data_update_with_none, no_data_update_with_none, no_data_update_with_none |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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_str="是", show_legend_str="是", agg_func_name="計數", custom_colors_dict={}): |
|
|
""" |
|
|
根據用戶選擇創建 Plotly 圖表 (已加入 Null/空白 過濾)。 V5.2 版 |
|
|
""" |
|
|
|
|
|
print("-" * 20, file=sys.stderr) |
|
|
print(f"調用 create_plot:", file=sys.stderr) |
|
|
print(f" - df type: {type(df)}", file=sys.stderr) |
|
|
if isinstance(df, pd.DataFrame): |
|
|
print(f" - df empty: {df.empty}", file=sys.stderr) |
|
|
print(f" - df shape: {df.shape}", file=sys.stderr) |
|
|
print(f" - chart_type: {chart_type}", file=sys.stderr) |
|
|
print(f" - x_column: {x_column}", file=sys.stderr) |
|
|
print(f" - y_column: {y_column}", file=sys.stderr) |
|
|
print(f" - group_column: {group_column}", file=sys.stderr) |
|
|
print(f" - size_column: {size_column}", file=sys.stderr) |
|
|
print(f" - agg_func_name: {agg_func_name}", file=sys.stderr) |
|
|
print(f" - show_grid_str: {show_grid_str}", file=sys.stderr) |
|
|
print(f" - show_legend_str: {show_legend_str}", file=sys.stderr) |
|
|
print("-" * 20, file=sys.stderr) |
|
|
|
|
|
|
|
|
fig = go.Figure() |
|
|
try: |
|
|
|
|
|
show_grid = True if show_grid_str == "是" else False |
|
|
show_legend = True if show_legend_str == "是" else False |
|
|
|
|
|
|
|
|
if df is None or not isinstance(df, pd.DataFrame) or df.empty: |
|
|
raise ValueError("沒有有效的 DataFrame 數據可供繪圖。請先載入數據。") |
|
|
if not chart_type: raise ValueError("請選擇圖表類型。") |
|
|
if not agg_func_name: raise ValueError("請選擇聚合函數。") |
|
|
if not x_column or x_column == NO_DATA_STR: raise ValueError("請選擇有效的 X 軸或類別列。") |
|
|
|
|
|
|
|
|
if x_column not in df.columns: raise ValueError(f"X 軸列 '{x_column}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
|
|
|
|
|
|
y_column_selected = None if y_column == NONE_STR or y_column == NO_DATA_STR or not y_column else y_column |
|
|
y_needed = agg_func_name != "計數" and chart_type not in ["直方圖"] |
|
|
|
|
|
if y_needed: |
|
|
if not y_column_selected: raise ValueError("此圖表類型和聚合函數需要選擇有效的 Y 軸或數值列 (不能選 '無')。") |
|
|
if y_column_selected not in df.columns: raise ValueError(f"Y 軸列 '{y_column_selected}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
group_col = None if group_column == NONE_STR or not group_column else group_column |
|
|
size_col = None if size_column == NONE_STR or not size_column else size_column |
|
|
|
|
|
if group_col and group_col not in df.columns: raise ValueError(f"分組列 '{group_col}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
if size_col and size_col not in df.columns: raise ValueError(f"大小列 '{size_col}' 不在數據中。可用列: {', '.join(df.columns)}") |
|
|
if group_col == x_column: raise ValueError("分組列不能與 X 軸列相同。") |
|
|
|
|
|
df_processed = df.copy() |
|
|
print(f"原始數據行數: {len(df_processed)}", file=sys.stderr) |
|
|
|
|
|
|
|
|
columns_to_filter = [x_column] |
|
|
if y_needed and y_column_selected: |
|
|
columns_to_filter.append(y_column_selected) |
|
|
if group_col: |
|
|
columns_to_filter.append(group_col) |
|
|
|
|
|
valid_columns_to_filter = [col for col in columns_to_filter if col in df_processed.columns] |
|
|
if valid_columns_to_filter: |
|
|
original_rows = len(df_processed) |
|
|
df_processed.dropna(subset=valid_columns_to_filter, inplace=True) |
|
|
print(f"移除 Null ({', '.join(valid_columns_to_filter)}) 後行數: {len(df_processed)} (減少 {original_rows - len(df_processed)} 行)", file=sys.stderr) |
|
|
else: |
|
|
print("警告: 沒有有效的列用於 Null 值過濾。", file=sys.stderr) |
|
|
|
|
|
if x_column in df_processed.columns: |
|
|
try: |
|
|
original_rows = len(df_processed) |
|
|
df_processed = df_processed[df_processed[x_column].astype(str).str.strip() != ''] |
|
|
print(f"移除 X 軸 '{x_column}' 空白字符串後行數: {len(df_processed)} (減少 {original_rows - len(df_processed)} 行)", file=sys.stderr) |
|
|
except Exception as e: |
|
|
print(f"警告: 過濾 X 軸 '{x_column}' 空白字符串時出錯: {e}", file=sys.stderr) |
|
|
|
|
|
if group_col and group_col in df_processed.columns: |
|
|
try: |
|
|
original_rows = len(df_processed) |
|
|
df_processed = df_processed[df_processed[group_col].astype(str).str.strip() != ''] |
|
|
print(f"移除分組列 '{group_col}' 空白字符串後行數: {len(df_processed)} (減少 {original_rows - len(df_processed)} 行)", file=sys.stderr) |
|
|
except Exception as e: |
|
|
print(f"警告: 過濾分組列 '{group_col}' 空白字符串時出錯: {e}", file=sys.stderr) |
|
|
|
|
|
if df_processed.empty: |
|
|
raise ValueError("過濾掉 Null 或空白值後,沒有剩餘數據可供繪圖。") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
df_processed[x_column] = df_processed[x_column].astype(str) |
|
|
if group_col: |
|
|
df_processed[group_col] = df_processed[group_col].astype(str) |
|
|
|
|
|
|
|
|
if y_column_selected: |
|
|
try: df_processed[y_column_selected] = pd.to_numeric(df_processed[y_column_selected], errors='coerce') |
|
|
except Exception as e: print(f"警告:轉換 Y 軸列 '{y_column_selected}' 為數值時出錯: {e}") |
|
|
if size_col: |
|
|
try: df_processed[size_col] = pd.to_numeric(df_processed[size_col], errors='coerce') |
|
|
except Exception as e: print(f"警告:轉換大小列 '{size_col}' 為數值時出錯: {e}") |
|
|
|
|
|
|
|
|
|
|
|
needs_aggregation = chart_type not in ["散點圖", "氣泡圖", "直方圖", "箱型圖", "小提琴圖", "甘特圖"] |
|
|
agg_df = None |
|
|
y_col_agg = y_column_selected |
|
|
if needs_aggregation: |
|
|
grouping_cols = [x_column] + ([group_col] if group_col else []) |
|
|
|
|
|
if agg_func_name == "計數": |
|
|
agg_df = df_processed.groupby(grouping_cols, observed=False, dropna=False).size().reset_index(name='__count__') |
|
|
y_col_agg = '__count__' |
|
|
else: |
|
|
agg_func_pd = agg_function_map(agg_func_name) |
|
|
if not y_column_selected: raise ValueError(f"聚合函數 '{agg_func_name}' 需要一個有效的 Y 軸數值列 (不能選 '無')。") |
|
|
|
|
|
if agg_func_pd not in ['first', 'last']: |
|
|
if not pd.api.types.is_numeric_dtype(df_processed[y_column_selected]): |
|
|
try: df_processed[y_column_selected] = pd.to_numeric(df_processed[y_column_selected], errors='raise') |
|
|
except (ValueError, TypeError): raise ValueError(f"Y 軸列 '{y_column_selected}' 必須是數值類型才能執行聚合 '{agg_func_name}'。") |
|
|
|
|
|
if df_processed[y_column_selected].isnull().all(): |
|
|
raise ValueError(f"Y 軸列 '{y_column_selected}' 在轉換為數值後全為無效值 (NaN),無法執行聚合 '{agg_func_name}'。") |
|
|
|
|
|
try: |
|
|
|
|
|
agg_df = df_processed.groupby(grouping_cols, observed=False, dropna=False)[y_column_selected].agg(agg_func_pd) |
|
|
agg_df = agg_df.reset_index() |
|
|
y_col_agg = y_column_selected |
|
|
except Exception as agg_e: |
|
|
raise ValueError(f"執行聚合 '{agg_func_name}' 時出錯: {agg_e}") |
|
|
else: |
|
|
agg_df = df_processed |
|
|
y_col_agg = y_column_selected |
|
|
|
|
|
if agg_df is None or agg_df.empty: |
|
|
raise ValueError("數據聚合或處理後沒有產生有效結果。") |
|
|
|
|
|
|
|
|
required_cols_for_plot = [x_column] |
|
|
|
|
|
if y_col_agg is not None: required_cols_for_plot.append(y_col_agg) |
|
|
if group_col: required_cols_for_plot.append(group_col) |
|
|
if size_col: required_cols_for_plot.append(size_col) |
|
|
missing_cols = [col for col in required_cols_for_plot if col not in agg_df.columns] |
|
|
if missing_cols: |
|
|
raise ValueError(f"處理後的數據缺少繪圖所需的列: {', '.join(missing_cols)}") |
|
|
|
|
|
|
|
|
|
|
|
colors = COLOR_SCHEMES.get(color_scheme_name, px.colors.qualitative.Plotly) |
|
|
|
|
|
|
|
|
fig_params = {"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 |
|
|
effective_y = y_col_agg |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if chart_type == "長條圖": |
|
|
if not effective_y: raise ValueError("長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, **fig_params) |
|
|
elif chart_type == "堆疊長條圖": |
|
|
if not effective_y: raise ValueError("堆疊長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, barmode='stack', **fig_params) |
|
|
elif chart_type == "百分比堆疊長條圖": |
|
|
if not effective_y: raise ValueError("百分比堆疊長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, barmode='relative', text_auto='.1%', **fig_params) |
|
|
fig.update_layout(yaxis_title="百分比 (%)") |
|
|
elif chart_type == "群組長條圖": |
|
|
if not effective_y: raise ValueError("群組長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, barmode='group', **fig_params) |
|
|
elif chart_type == "水平長條圖": |
|
|
if not effective_y: raise ValueError("水平長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, y=x_column, x=effective_y, color=group_col, orientation='h', **fig_params) |
|
|
elif chart_type == "折線圖": |
|
|
if not effective_y: raise ValueError("折線圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.line(agg_df, x=x_column, y=effective_y, color=group_col, markers=True, **fig_params) |
|
|
elif chart_type == "多重折線圖": |
|
|
if not effective_y: raise ValueError("多重折線圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.line(agg_df, x=x_column, y=effective_y, color=group_col, markers=True, **fig_params) |
|
|
elif chart_type == "階梯折線圖": |
|
|
if not effective_y: raise ValueError("階梯折線圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.line(agg_df, x=x_column, y=effective_y, color=group_col, line_shape='hv', **fig_params) |
|
|
elif chart_type == "區域圖": |
|
|
if not effective_y: raise ValueError("區域圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.area(agg_df, x=x_column, y=effective_y, color=group_col, **fig_params) |
|
|
elif chart_type == "堆疊區域圖": |
|
|
if not effective_y: raise ValueError("堆疊區域圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.area(agg_df, x=x_column, y=effective_y, color=group_col, groupnorm=None, **fig_params) |
|
|
elif chart_type == "百分比堆疊區域圖": |
|
|
if not effective_y: raise ValueError("百分比堆疊區域圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.area(agg_df, x=x_column, y=effective_y, color=group_col, groupnorm='percent', **fig_params) |
|
|
fig.update_layout(yaxis_title="百分比 (%)") |
|
|
elif chart_type == "圓餅圖": |
|
|
if not effective_y: raise ValueError("圓餅圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if group_col: print("警告:圓餅圖不支持分組列,已忽略。") |
|
|
fig = px.pie(agg_df, names=x_column, values=effective_y, **fig_params) |
|
|
if not group_col and custom_colors_dict: fig.update_traces(marker=dict(colors=[custom_colors_dict.get(str(cat), colors[i % len(colors)]) for i, cat in enumerate(agg_df[x_column])])) |
|
|
elif chart_type == "環形圖": |
|
|
if not effective_y: raise ValueError("環形圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if group_col: print("警告:環形圖不支持分組列,已忽略。") |
|
|
fig = px.pie(agg_df, names=x_column, values=effective_y, hole=0.4, **fig_params) |
|
|
if not group_col and custom_colors_dict: fig.update_traces(marker=dict(colors=[custom_colors_dict.get(str(cat), colors[i % len(colors)]) for i, cat in enumerate(agg_df[x_column])])) |
|
|
elif chart_type == "散點圖": |
|
|
if not y_column_selected: raise ValueError("散點圖需要選擇 Y 軸列。") |
|
|
fig = px.scatter(agg_df, x=x_column, y=y_column_selected, color=group_col, size=size_col, **fig_params) |
|
|
elif chart_type == "氣泡圖": |
|
|
if not y_column_selected: raise ValueError("氣泡圖需要選擇 Y 軸列。") |
|
|
if not size_col: raise ValueError("氣泡圖需要指定 '大小列'。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[size_col]): raise ValueError(f"大小列 '{size_col}' 必須是數值類型。") |
|
|
fig = px.scatter(agg_df, x=x_column, y=y_column_selected, color=group_col, size=size_col, size_max=60, **fig_params) |
|
|
elif chart_type == "直方圖": |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[x_column]): raise ValueError(f"直方圖的 X 軸列 '{x_column}' 必須是數值類型。") |
|
|
fig = px.histogram(agg_df, x=x_column, color=group_col, **fig_params); fig.update_layout(yaxis_title="計數") |
|
|
elif chart_type == "箱型圖": |
|
|
if not y_column_selected: raise ValueError("箱型圖需要選擇 Y 軸列。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[y_column_selected]): raise ValueError(f"箱型圖的 Y 軸列 '{y_column_selected}' 必須是數值類型。") |
|
|
fig = px.box(agg_df, x=group_col, y=y_column_selected, color=group_col, **fig_params) |
|
|
if not group_col: fig = px.box(agg_df, y=y_column_selected, **fig_params) |
|
|
elif chart_type == "小提琴圖": |
|
|
if not y_column_selected: raise ValueError("小提琴圖需要選擇 Y 軸列。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[y_column_selected]): raise ValueError(f"小提琴圖的 Y 軸列 '{y_column_selected}' 必須是數值類型。") |
|
|
fig = px.violin(agg_df, x=group_col, y=y_column_selected, color=group_col, box=True, points="all", **fig_params) |
|
|
if not group_col: fig = px.violin(agg_df, y=y_column_selected, box=True, points="all", **fig_params) |
|
|
elif chart_type == "熱力圖": |
|
|
if not effective_y: raise ValueError("熱力圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if not group_col: raise ValueError("熱力圖需要 X 軸、Y 軸 和一個 分組列。") |
|
|
try: |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"熱力圖的值列 '{effective_y}' 必須是數值類型。") |
|
|
pivot_df = pd.pivot_table(agg_df, values=effective_y, 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", text_auto=True, **fig_params); |
|
|
fig.update_layout(coloraxis_showscale=True) |
|
|
except Exception as pivot_e: raise ValueError(f"創建熱力圖的數據透視表時出錯: {pivot_e}") |
|
|
elif chart_type == "樹狀圖": |
|
|
if not effective_y: raise ValueError("樹狀圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
path = [group_col, x_column] if group_col else [x_column] |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"樹狀圖的值列 '{effective_y}' 必須是數值類型。") |
|
|
fig = px.treemap(agg_df, path=path, values=effective_y, color=group_col if group_col else x_column, **fig_params) |
|
|
elif chart_type == "雷達圖": |
|
|
if not effective_y: raise ValueError("雷達圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = go.Figure() |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"雷達圖的徑向值列 '{effective_y}' 必須是數值類型。") |
|
|
if not group_col: |
|
|
theta = agg_df[x_column].tolist(); r = agg_df[effective_y].tolist(); theta.append(theta[0]); r.append(r[0]) |
|
|
fig.add_trace(go.Scatterpolar(r=r, theta=theta, fill='toself', name=effective_y if effective_y != '__count__' else '計數', 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[effective_y].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, title=title, width=width, height=height) |
|
|
elif chart_type == "漏斗圖": |
|
|
if not effective_y: raise ValueError("漏斗圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"漏斗圖的值列 '{effective_y}' 必須是數值類型。") |
|
|
sorted_df = agg_df.sort_values(by=effective_y, ascending=False) |
|
|
fig = px.funnel(sorted_df, x=effective_y, y=x_column, color=group_col, **fig_params) |
|
|
elif chart_type == "極座標圖": |
|
|
if not effective_y: raise ValueError("極座標圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
if not pd.api.types.is_numeric_dtype(agg_df[effective_y]): raise ValueError(f"極座標圖的徑向值列 '{effective_y}' 必須是數值類型。") |
|
|
fig = px.bar_polar(agg_df, r=effective_y, theta=x_column, color=group_col if group_col else x_column, **fig_params) |
|
|
elif chart_type == "甘特圖": |
|
|
start_col_gantt = y_column_selected; end_col_gantt = group_col; task_col_gantt = x_column |
|
|
if not start_col_gantt or not end_col_gantt: raise ValueError("甘特圖需要指定 開始列 (Y軸) 和 結束列 (分組列)。") |
|
|
try: |
|
|
df_gantt = df.copy() |
|
|
if start_col_gantt not in df_gantt.columns: raise ValueError(f"開始列 '{start_col_gantt}' 不在數據中。") |
|
|
if end_col_gantt not in df_gantt.columns: raise ValueError(f"結束列 '{end_col_gantt}' 不在數據中。") |
|
|
if task_col_gantt not in df_gantt.columns: raise ValueError(f"任務列 '{task_col_gantt}' 不在數據中。") |
|
|
df_gantt['_start_'] = pd.to_datetime(df_gantt[start_col_gantt], errors='coerce') |
|
|
df_gantt['_end_'] = pd.to_datetime(df_gantt[end_col_gantt], errors='coerce') |
|
|
if df_gantt['_start_'].isnull().any(): raise ValueError(f"開始列 '{start_col_gantt}' 包含無效或無法解析的日期時間格式。") |
|
|
if df_gantt['_end_'].isnull().any(): raise ValueError(f"結束列 '{end_col_gantt}' 包含無效或無法解析的日期時間格式。") |
|
|
fig = px.timeline(df_gantt, x_start='_start_', x_end='_end_', y=task_col_gantt, 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}',使用長條圖代替。") |
|
|
if not effective_y: raise ValueError("長條圖需要 Y 軸數值或 '計數' 聚合。") |
|
|
fig = px.bar(agg_df, x=x_column, y=effective_y, color=group_col, **fig_params) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if patterns: |
|
|
try: |
|
|
num_traces = len(fig.data) |
|
|
if chart_type in ["長條圖", "堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", "圓餅圖", "環形圖"]: |
|
|
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" |
|
|
elif chart_type in ["散點圖", "氣泡圖"]: |
|
|
symbol_map = {"/": "diamond", "\\": "square", "x": "x", "-": "line-ew", "|": "line-ns", "+": "cross", ".": "circle-dot"} |
|
|
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 |
|
|
elif chart_type in ["折線圖", "多重折線圖", "階梯折線圖"]: |
|
|
dash_map = {"/": "dash", "\\": "dot", "x": "dashdot", "-": "longdash", "|": "solid", "+": "solid", ".": "solid"} |
|
|
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 |
|
|
elif chart_type in ["區域圖", "堆疊區域圖", "百分比堆疊區域圖"]: |
|
|
print("提示:區域圖的圖案填充支持有限,將嘗試應用線型。") |
|
|
dash_map = {"/": "dash", "\\": "dot", "x": "dashdot", "-": "longdash"} |
|
|
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 hasattr(trace, 'stackgroup') and trace.stackgroup else 'tozeroy' |
|
|
except Exception as pattern_e: print(f"應用圖案時出錯: {pattern_e}") |
|
|
|
|
|
|
|
|
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, |
|
|
) |
|
|
|
|
|
final_y_label = y_col_agg if y_col_agg != '__count__' else '計數' |
|
|
if chart_type in ["圓餅圖", "環形圖", "漏斗圖", "樹狀圖"]: fig.update_layout(xaxis_title=None, yaxis_title=None) |
|
|
elif chart_type == "水平長條圖": fig.update_layout(xaxis_title=final_y_label, yaxis_title=x_column) |
|
|
elif chart_type == "直方圖": fig.update_layout(xaxis_title=x_column, yaxis_title='計數') |
|
|
elif chart_type == "甘特圖": fig.update_layout(xaxis_title="時間", yaxis_title=x_column) |
|
|
else: fig.update_layout(xaxis_title=x_column, yaxis_title=final_y_label) |
|
|
|
|
|
except ValueError as ve: |
|
|
print(f"圖表創建錯誤 (ValueError): {ve}", file=sys.stderr); traceback.print_exc(file=sys.stderr) |
|
|
fig = go.Figure(); fig.add_annotation(text=f"⚠️ 創建圖表時出錯:<br>{ve}", align='left', showarrow=False, font=dict(size=14, color="red")); fig.update_layout(xaxis_visible=False, yaxis_visible=False) |
|
|
except Exception as e: |
|
|
error_message = f"❌ 創建圖表時發生未預期錯誤:\n{traceback.format_exc()}"; print(error_message, file=sys.stderr) |
|
|
fig = go.Figure(); user_error_msg = f"⚠️ 創建圖表時發生內部錯誤。<br>請檢查數據和設置。<br>詳細錯誤: {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) |
|
|
|
|
|
print("create_plot 函數執行完畢。", file=sys.stderr) |
|
|
return fig |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_data(df, format_type): |
|
|
if df is None or df.empty: return None, "❌ 沒有數據可以導出。" |
|
|
try: |
|
|
if format_type == "CSV": filename = "exported_data.csv"; df.to_csv(filename, index=False, encoding='utf-8-sig') |
|
|
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) |
|
|
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}" |
|
|
|
|
|
def download_figure(fig, format_type="PNG"): |
|
|
if fig is None or not fig.data: return None, "❌ 沒有圖表可以導出。" |
|
|
try: |
|
|
format_lower = format_type.lower(); filename = f"chart_export.{format_lower}" |
|
|
import kaleido |
|
|
fig.write_image(filename, format=format_lower) |
|
|
return filename, f"✅ 圖表已成功準備為 {format_type} 格式,點擊下方鏈接下載。" |
|
|
except ImportError: error_msg = "❌ 導出圖表失敗:需要 Kaleido 套件。請在環境中安裝 `pip install -U kaleido`。"; print(error_msg); return None, error_msg |
|
|
except ValueError as ve: |
|
|
if "kaleido" in str(ve).lower(): error_msg = "❌ 導出圖表失敗:Kaleido 套件無法運行。請檢查其依賴項或嘗試重新安裝。"; print(f"{error_msg}\n{ve}"); traceback.print_exc(); return None, error_msg |
|
|
else: print(f"導出圖表時出錯 (ValueError): {ve}"); traceback.print_exc(); return None, f"❌ 導出圖表時出錯: {ve}" |
|
|
except Exception as e: print(f"導出圖表時發生未預期錯誤: {e}"); traceback.print_exc(); return None, f"❌ 導出圖表時發生未預期錯誤: {e}" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def recommend_chart_settings(df): |
|
|
recommendation = {"chart_type": None, "x_column": None, "y_column": None, "group_column": "無", "agg_function": None, "message": "無法提供推薦。"} |
|
|
if df is None or df.empty: recommendation["message"] = "ℹ️ 請先上傳或輸入數據。"; return recommendation |
|
|
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.update({"chart_type": "折線圖", "x_column": date_cols[0], "y_column": num_cols[0], "agg_function": "平均值", "message": f"檢測到時間列 '{date_cols[0]}' 和數值列 '{num_cols[0]}',推薦使用折線圖顯示趨勢。"}) |
|
|
elif len(num_cols) >= 2: recommendation.update({"chart_type": "散點圖", "x_column": num_cols[0], "y_column": num_cols[1], "agg_function": None, "message": f"檢測到數值列 '{num_cols[0]}' 和 '{num_cols[1]}',推薦使用散點圖分析相關性。"}) |
|
|
elif cat_cols and num_cols: recommendation.update({"chart_type": "長條圖", "x_column": cat_cols[0], "y_column": num_cols[0], "agg_function": "平均值", "message": f"檢測到類別列 '{cat_cols[0]}' 和數值列 '{num_cols[0]}',推薦使用長條圖比較各類別的數值。"}) |
|
|
elif len(cat_cols) >= 2: recommendation.update({"chart_type": "堆疊長條圖", "x_column": cat_cols[0], "y_column": None, "group_column": cat_cols[1], "agg_function": "計數", "message": f"檢測到多個類別列 ('{cat_cols[0]}', '{cat_cols[1]}', ...),推薦使用堆疊長條圖顯示計數分佈。"}) |
|
|
elif cat_cols: recommendation.update({"chart_type": "長條圖", "x_column": cat_cols[0], "y_column": None, "agg_function": "計數", "message": f"檢測到類別列 '{cat_cols[0]}',推薦使用長條圖顯示其頻數分佈。"}) |
|
|
elif num_cols: recommendation.update({"chart_type": "直方圖", "x_column": num_cols[0], "y_column": None, "agg_function": None, "message": f"檢測到數值列 '{num_cols[0]}',推薦使用直方圖查看其分佈。"}) |
|
|
else: recommendation["message"] = "無法根據當前數據結構提供明確的圖表推薦。" |
|
|
except Exception as e: recommendation["message"] = f"❌ 推薦時出錯: {e}"; print(f"智能推薦時出錯: {e}"); traceback.print_exc() |
|
|
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"] = "無" |
|
|
if recommendation["agg_function"] and recommendation["agg_function"] != "計數" and not recommendation["y_column"]: recommendation["agg_function"] = None; recommendation["message"] += " (無法確定聚合的數值列)" |
|
|
if recommendation["agg_function"] == "計數": recommendation["y_column"] = None |
|
|
return recommendation |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
CUSTOM_CSS = """ |
|
|
/* --- 全局和容器 --- */ |
|
|
.gradio-container { font-family: 'Inter', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f8f9fa; } |
|
|
/* --- 應用程式標頭 --- */ |
|
|
.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: 20px; 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: 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; } |
|
|
.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: 8px 16px !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 預設 */ |
|
|
/* --- 其他 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; } |
|
|
.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: 15px; min-height: 400px; 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: 10px; } |
|
|
.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 .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; } |
|
|
.gradio-slider label { margin-bottom: 5px !important; } |
|
|
.gradio-slider input[type="range"] { cursor: pointer !important; } |
|
|
/* Radio Button 樣式調整 */ |
|
|
.gradio-radio fieldset { display: flex; flex-wrap: wrap; gap: 5px 15px; } /* 嘗試讓選項水平排列並換行 */ |
|
|
.gradio-radio label { margin-bottom: 0 !important; padding: 5px 0 !important; } /* 調整標籤間距 */ |
|
|
.gradio-radio input[type="radio"] { margin-right: 5px !important; } |
|
|
|
|
|
.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; } |
|
|
/* Accordion 樣式微調 (如果重新啟用) */ |
|
|
/* .gradio-accordion > .label { font-weight: 600 !important; font-size: 1.1em !important; padding: 10px 0 !important; } */ |
|
|
""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks(css=CUSTOM_CSS, title="數據視覺化工具_Eddie", theme=gr.themes.Soft()) as demo: |
|
|
|
|
|
|
|
|
gr.HTML(""" |
|
|
<div class="app-header"> |
|
|
<h1 class="app-title">📊 數據視覺化工具_Eddie</h1> |
|
|
<p class="app-subtitle">上傳或貼上數據,創建專業圖表 (極簡化測試版 - 修正)</p> |
|
|
</div> |
|
|
""") |
|
|
|
|
|
|
|
|
data_state = gr.State(None) |
|
|
custom_colors_state = gr.State({}) |
|
|
patterns_state = gr.State([]) |
|
|
recommendation_state = gr.State({}) |
|
|
|
|
|
|
|
|
with gr.Tabs() as tabs: |
|
|
|
|
|
|
|
|
with gr.TabItem("📁 數據輸入與管理", id=0): |
|
|
with gr.Row(): |
|
|
with gr.Column(scale=2): |
|
|
gr.HTML('<div class="section-title">1. 上傳或輸入數據</div>') |
|
|
with gr.Group(elem_classes=["card"]): |
|
|
gr.Markdown("您可以上傳本地的 CSV 或 Excel 文件,或直接在下方的文本框中貼上數據。") |
|
|
file_upload = gr.File(label="上傳 CSV / Excel 文件", type="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...", lines=8, elem_classes=["data-input-textbox"]) |
|
|
parse_button = gr.Button("📝 解析貼上數據", elem_classes=["primary-button"]) |
|
|
parse_status = gr.Textbox(label="解析狀態", lines=1, interactive=False) |
|
|
with gr.Column(scale=3): |
|
|
gr.HTML('<div class="section-title">2. 數據預覽與導出</div>') |
|
|
with gr.Group(elem_classes=["card"]): |
|
|
gr.Markdown("下方將顯示載入或解析後的數據預覽。") |
|
|
data_preview = gr.Dataframe(label="數據表格預覽", interactive=False) |
|
|
with gr.Row(): |
|
|
export_format = gr.Radio(EXPORT_FORMATS_DATA, label="選擇導出格式", value="CSV") |
|
|
export_button = gr.Button("⬇️ 導出預覽數據", elem_classes=["secondary-button"]) |
|
|
export_result = gr.File(label="導出文件下載", interactive=False) |
|
|
export_status = gr.Textbox(label="導出狀態", lines=1, interactive=False) |
|
|
|
|
|
|
|
|
with gr.TabItem("📈 圖表創建", id=1): |
|
|
gr.HTML('<div class="section-title">創建圖表</div>') |
|
|
gr.Markdown("在此設置並生成圖表。") |
|
|
|
|
|
with gr.Row(): |
|
|
|
|
|
with gr.Column(scale=1): |
|
|
gr.Markdown("### 📊 圖表設置") |
|
|
with gr.Group(elem_classes=["card"]): |
|
|
gr.Markdown("**基本設置**") |
|
|
chart_type = gr.Radio(CHART_TYPES, label="圖表類型", value="長條圖", interactive=True) |
|
|
recommend_button = gr.Button("🧠 智能推薦", elem_classes=["secondary-button"], size="sm") |
|
|
chart_title = gr.Textbox(label="圖表標題", placeholder="我的圖表") |
|
|
agg_function = gr.Radio(AGGREGATION_FUNCTIONS, label="聚合函數", value="計數") |
|
|
|
|
|
gr.Markdown("**數據映射 (請選擇)**") |
|
|
|
|
|
x_column = gr.Radio([NO_DATA_STR], label="X軸 / 類別", info="選擇圖表主要分類或 X 軸") |
|
|
y_column = gr.Radio([NO_DATA_STR], label="Y軸 / 數值", info="選擇圖表數值或 Y 軸 (計數時可忽略)") |
|
|
group_column = gr.Radio([NONE_STR, NO_DATA_STR], label="分組列", info="用於生成多系列或堆疊", value=NONE_STR) |
|
|
size_column = gr.Radio([NONE_STR, NO_DATA_STR], label="大小列", info="用於氣泡圖等控制點的大小", value=NONE_STR) |
|
|
|
|
|
gr.Markdown("**顯示選項**") |
|
|
chart_width = gr.Slider(300, 1600, 700, step=50, label="寬度 (px)") |
|
|
chart_height = gr.Slider(300, 1000, 450, step=50, label="高度 (px)") |
|
|
show_grid = gr.Radio(YES_NO_CHOICES, label="顯示網格", value="是") |
|
|
show_legend = gr.Radio(YES_NO_CHOICES, label="顯示圖例", value="是") |
|
|
color_scheme = gr.Dropdown(list(COLOR_SCHEMES.keys()), label="顏色方案", value="預設 (Plotly)") |
|
|
gr.HTML('<div style="margin-top: 10px;"><b>顏色參考</b> (點擊複製)</div>') |
|
|
gr.HTML(generate_color_cards(), elem_id="color_display") |
|
|
|
|
|
gr.Markdown("**圖案與自定義顏色**") |
|
|
pattern1 = gr.Radio(PATTERN_TYPES, label="圖案1", value="無") |
|
|
pattern2 = gr.Radio(PATTERN_TYPES, label="圖案2", value="無") |
|
|
pattern3 = gr.Radio(PATTERN_TYPES, label="圖案3", value="無") |
|
|
color_customization = gr.Textbox(label="自定義顏色", placeholder="類別A:#FF5733, 類別B:#33CFFF", info="格式: 類別名:十六進制顏色代碼, ...", elem_classes=["color-customization-input"]) |
|
|
|
|
|
|
|
|
with gr.Column(scale=2): |
|
|
|
|
|
gr.HTML('<div class="section-title" style="margin-top:0; margin-bottom:10px;">操作</div>') |
|
|
update_button = gr.Button("🔄 更新圖表", variant="primary", elem_classes=["primary-button"]) |
|
|
with gr.Row(): |
|
|
export_img_format = gr.Radio(EXPORT_FORMATS_IMG, label="導出格式", value="PNG", scale=1) |
|
|
download_button = gr.Button("💾 導出圖表", elem_classes=["secondary-button"], scale=1) |
|
|
export_chart = gr.File(label="圖表文件下載", interactive=False) |
|
|
export_chart_status = gr.Textbox(label="導出狀態", lines=1, interactive=False) |
|
|
|
|
|
|
|
|
gr.HTML('<div class="section-title" style="margin-top:20px; margin-bottom:10px;">圖表預覽</div>') |
|
|
with gr.Group(elem_classes=["chart-previewer"]): |
|
|
chart_output = gr.Plot(label="", elem_id="chart_preview") |
|
|
|
|
|
|
|
|
|
|
|
with gr.TabItem("❓ 使用說明", id=2): |
|
|
with gr.Group(elem_classes=["card"]): |
|
|
gr.HTML(""" |
|
|
<div class="section-title">使用說明 (V5 - 極簡測試版)</div> |
|
|
<h3>數據輸入</h3> |
|
|
<ul><li>點擊 "上傳 CSV / Excel 文件" 按鈕選擇本地文件,或在文本框中直接貼上數據。</li><li>支持逗號 (<code>,</code>)、製表符 (<code>Tab</code>) 或空格 (<code> </code>) 分隔的數據。</li><li>第一行通常被視為欄位名稱(表頭)。</li><li>數據載入或解析成功後,會在右側顯示預覽。</li><li>您可以使用 "導出預覽數據" 功能將處理後的數據保存為 CSV、Excel 或 JSON 格式。</li></ul> |
|
|
<h3>圖表創建</h3> |
|
|
<ul><li>此頁面僅提供一個圖表進行測試。</li><li><strong>智能推薦:</strong>點擊 "智能推薦" 按鈕,系統會根據數據結構嘗試推薦合適的設置。</li><li><strong>圖表類型/聚合函數/圖案等:</strong>使用點選按鈕 (Radio) 進行選擇。</li> |
|
|
<ul><li><strong style="color: #7367f0;">【重要】單欄計數:</strong>若要統計某一欄位中各個項目出現的次數,請在 <strong>X軸/類別</strong> 選擇該欄位,並將 <strong>聚合函數</strong> 設為 <strong>計數</strong>,此時 <strong>無需選擇 Y軸/數值</strong>。然後選擇「長條圖」或「圓餅圖」。</li></ul> |
|
|
</li><li><strong>數據映射 (欄位選擇):</strong>使用點選按鈕 (Radio) 選擇 X軸、Y軸、分組列、大小列。<strong>注意:如果數據欄位過多,這裡會顯示得很長。</strong></li><li><strong>顯示選項/顏色/自定義:</strong>調整圖表外觀。顏色方案仍為下拉選單。</li><li>點擊 <strong>"更新圖表"</strong> 按鈕生成或刷新圖表預覽 (已移除自動更新)。</li><li>使用 "導出圖表" 功能將生成的圖表保存為圖片文件。</li></ul> |
|
|
<h3>提示</h3> |
|
|
<ul><li>如果圖表無法顯示或出現錯誤,請檢查數據格式、列選擇以及聚合函數是否合理。</li><li>如果欄位選擇的 Radio 按鈕區域過長或無法使用,表示此方法不適用於您的數據,且 Gradio 可能存在根本的元件衝突問題。</li></ul> |
|
|
""") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_data_and_update_ui_v5(df, status_msg): |
|
|
"""輔助函數:更新數據狀態、預覽和所有列選擇 Radio""" |
|
|
print("調用 load_data_and_update_ui_v5...", file=sys.stderr) |
|
|
preview_df = df if df is not None else pd.DataFrame() |
|
|
|
|
|
col_updates = update_columns_as_radio(df) |
|
|
if col_updates is None or len(col_updates) != 4: |
|
|
print("警告: update_columns_as_radio 未返回預期的 4 個組件更新。", file=sys.stderr) |
|
|
|
|
|
return [df, status_msg, preview_df] + [gr.update()] * 4 + [gr.Plot(value=None)] |
|
|
|
|
|
|
|
|
updates = [df, status_msg, preview_df] + list(col_updates) |
|
|
|
|
|
updates.append(gr.Plot(value=None)) |
|
|
|
|
|
print(f"load_data_and_update_ui_v5 返回 {len(updates)} 個更新。", file=sys.stderr) |
|
|
return updates |
|
|
|
|
|
|
|
|
upload_button.click( |
|
|
process_upload, |
|
|
inputs=[file_upload], |
|
|
outputs=[data_state, upload_status] |
|
|
).then( |
|
|
load_data_and_update_ui_v5, |
|
|
inputs=[data_state, upload_status], |
|
|
outputs=[ |
|
|
data_state, upload_status, data_preview, |
|
|
x_column, y_column, group_column, size_column, |
|
|
chart_output |
|
|
] |
|
|
) |
|
|
parse_button.click( |
|
|
parse_data, |
|
|
inputs=[csv_input], |
|
|
outputs=[data_state, parse_status] |
|
|
).then( |
|
|
load_data_and_update_ui_v5, |
|
|
inputs=[data_state, parse_status], |
|
|
outputs=[ |
|
|
data_state, parse_status, data_preview, |
|
|
x_column, y_column, group_column, size_column, |
|
|
chart_output |
|
|
] |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
export_button.click(export_data, inputs=[data_state, export_format], outputs=[export_result, export_status]) |
|
|
|
|
|
|
|
|
color_customization.change(parse_custom_colors, inputs=[color_customization], outputs=[custom_colors_state]) |
|
|
patterns_inputs = [pattern1, pattern2, pattern3] |
|
|
for pattern_radio in patterns_inputs: |
|
|
pattern_radio.change(update_patterns, inputs=patterns_inputs, outputs=[patterns_state]) |
|
|
|
|
|
|
|
|
chart_inputs = [data_state, chart_type, x_column, y_column, group_column, size_column, color_scheme, patterns_state, chart_title, chart_width, chart_height, show_grid, show_legend, agg_function, custom_colors_state] |
|
|
|
|
|
def update_chart_action(*inputs): |
|
|
"""按鈕點擊時的處理函數,包含調試信息""" |
|
|
print("="*30, file=sys.stderr) |
|
|
print("更新圖表按鈕點擊!", file=sys.stderr) |
|
|
|
|
|
df_input = inputs[0] |
|
|
print(f" - data_state type in handler: {type(df_input)}", file=sys.stderr) |
|
|
if isinstance(df_input, pd.DataFrame): |
|
|
print(f" - data_state empty in handler: {df_input.empty}", file=sys.stderr) |
|
|
else: |
|
|
print(f" - data_state is not a DataFrame!", file=sys.stderr) |
|
|
print("="*30, file=sys.stderr) |
|
|
|
|
|
processed_inputs = list(inputs) |
|
|
if processed_inputs[3] == NONE_STR: |
|
|
processed_inputs[3] = None |
|
|
|
|
|
if processed_inputs[4] == NONE_STR: |
|
|
processed_inputs[4] = None |
|
|
|
|
|
if processed_inputs[5] == NONE_STR: |
|
|
processed_inputs[5] = None |
|
|
|
|
|
return create_plot(*processed_inputs) |
|
|
|
|
|
update_button.click(update_chart_action, inputs=chart_inputs, outputs=[chart_output]) |
|
|
|
|
|
|
|
|
download_button.click(download_figure, inputs=[chart_output, export_img_format], outputs=[export_chart, export_chart_status]) |
|
|
|
|
|
|
|
|
def apply_recommendation_v5(rec_dict): |
|
|
if not isinstance(rec_dict, dict): print("警告:apply_recommendation 收到非字典輸入。"); return [gr.update()] * 5 |
|
|
chart_type_val = rec_dict.get("chart_type"); x_col_val = rec_dict.get("x_column"); agg_func_val = rec_dict.get("agg_function") |
|
|
|
|
|
y_col_val = NONE_STR if agg_func_val == "計數" else rec_dict.get("y_column", NONE_STR) |
|
|
group_col_val = rec_dict.get("group_column", NONE_STR) |
|
|
|
|
|
return [ |
|
|
gr.Radio(value=chart_type_val), |
|
|
gr.Radio(value=x_col_val), |
|
|
gr.Radio(value=y_col_val), |
|
|
gr.Radio(value=group_col_val), |
|
|
gr.Radio(value=agg_func_val) |
|
|
] |
|
|
|
|
|
recommend_button.click(recommend_chart_settings, inputs=[data_state], outputs=[recommendation_state]).then( |
|
|
apply_recommendation_v5, inputs=[recommendation_state], outputs=[chart_type, x_column, y_column, group_column, agg_function] |
|
|
) |
|
|
|
|
|
|
|
|
def update_element_visibility_v5(chart_type): |
|
|
try: |
|
|
|
|
|
is_pie_like = chart_type in ["圓餅圖", "環形圖", "漏斗圖", "樹狀圖"]; is_histogram = chart_type == "直方圖" |
|
|
is_box_violin = chart_type in ["箱型圖", "小提琴圖"]; is_gantt = chart_type == "甘特圖" |
|
|
is_heatmap = chart_type == "熱力圖"; is_radar = chart_type == "雷達圖" |
|
|
y_label, y_needed = "Y軸 / 數值", True |
|
|
if is_histogram: y_label, y_needed = "Y軸 (自動計數)", False |
|
|
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 ["堆疊長條圖", "百分比堆疊長條圖", "群組長條圖", "水平長條圖", "折線圖", "多重折線圖", "階梯折線圖", "區域圖", "堆疊區域圖", "百分比堆疊區域圖", "散點圖", "氣泡圖", "箱型圖", "小提琴圖", "熱力圖", "雷達圖", "極座標圖"] |
|
|
if is_gantt: group_label, group_needed = "結束時間列", True |
|
|
elif is_heatmap: group_label, group_needed = "行/列 分組", True |
|
|
size_label, size_needed = "大小列", chart_type in ["氣泡圖", "散點圖"] |
|
|
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)) |
|
|
except Exception as e: print(f"Error in update_element_visibility: {e}"); return (gr.update(), gr.update(), gr.update()) |
|
|
|
|
|
|
|
|
chart_type.change(update_element_visibility_v5, inputs=[chart_type], outputs=[y_column, group_column, size_column]) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.launch(debug=True) |
|
|
|
|
|
|