Spaces:
Running
Running
| """圖表生成函數.""" | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime | |
| from typing import Optional, Tuple | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| from .api import get_market_data | |
| from .config import WORLD_NAMES | |
| from .utils import format_relative_time | |
| # 美化的圖表配色方案 | |
| CHART_COLORS = { | |
| "nq": "#8b9dc3", # 柔和的藍灰色 | |
| "hq": "#ffc107", # 琥珀金色 | |
| "background": "#1a1a2e", | |
| "paper": "#0f0f1a", | |
| "grid": "rgba(255, 255, 255, 0.08)", | |
| "text": "#e0e0e0", | |
| "title": "#ffc107", | |
| } | |
| # 自訂圖表佈局模板(不含 title,因為每個圖表需要不同的標題) | |
| CHART_LAYOUT = { | |
| "paper_bgcolor": CHART_COLORS["paper"], | |
| "plot_bgcolor": CHART_COLORS["background"], | |
| "font": { | |
| "family": "Noto Sans TC, sans-serif", | |
| "color": CHART_COLORS["text"], | |
| "size": 12, | |
| }, | |
| "title_font": { | |
| "size": 16, | |
| "color": CHART_COLORS["title"], | |
| }, | |
| "title_x": 0.5, | |
| "title_xanchor": "center", | |
| "legend": { | |
| "bgcolor": "rgba(0, 0, 0, 0.3)", | |
| "bordercolor": "rgba(255, 255, 255, 0.1)", | |
| "borderwidth": 1, | |
| "font": {"color": CHART_COLORS["text"]}, | |
| }, | |
| "xaxis": { | |
| "gridcolor": CHART_COLORS["grid"], | |
| "linecolor": "rgba(255, 255, 255, 0.15)", | |
| "tickfont": {"color": CHART_COLORS["text"]}, | |
| "title_font": {"color": CHART_COLORS["text"]}, | |
| }, | |
| "yaxis": { | |
| "gridcolor": CHART_COLORS["grid"], | |
| "linecolor": "rgba(255, 255, 255, 0.15)", | |
| "tickfont": {"color": CHART_COLORS["text"]}, | |
| "title_font": {"color": CHART_COLORS["text"]}, | |
| }, | |
| "hoverlabel": { | |
| "bgcolor": "#2d2d3a", | |
| "bordercolor": "rgba(255, 193, 7, 0.3)", | |
| "font": {"color": CHART_COLORS["text"]}, | |
| }, | |
| } | |
| def _normalize_timestamp(timestamp: int) -> int: | |
| """正規化時間戳(毫秒轉秒).""" | |
| if timestamp > 9999999999: | |
| return timestamp // 1000 | |
| return timestamp | |
| def _detect_outliers(prices: list) -> tuple: | |
| """使用 IQR 方法檢測異常值. | |
| Returns: | |
| (lower_bound, upper_bound) 正常價格範圍 | |
| """ | |
| if len(prices) < 4: | |
| return (0, float('inf')) | |
| sorted_prices = sorted(prices) | |
| n = len(sorted_prices) | |
| q1 = sorted_prices[n // 4] | |
| q3 = sorted_prices[3 * n // 4] | |
| iqr = q3 - q1 | |
| lower_bound = q1 - 1.5 * iqr | |
| upper_bound = q3 + 1.5 * iqr | |
| return (max(0, lower_bound), upper_bound) | |
| def create_price_chart(market_data: dict, item_name: str) -> go.Figure: | |
| """建立價格歷史圖表. | |
| Args: | |
| market_data: 市場數據(包含 recentHistory) | |
| item_name: 物品名稱 | |
| Returns: | |
| Plotly 圖表物件 | |
| """ | |
| entries = market_data.get("recentHistory", []) | |
| if not entries: | |
| fig = go.Figure() | |
| fig.update_layout(**CHART_LAYOUT, height=400) | |
| fig.add_annotation( | |
| text="無歷史數據", | |
| xref="paper", | |
| yref="paper", | |
| x=0.5, | |
| y=0.5, | |
| showarrow=False, | |
| font={"color": CHART_COLORS["text"], "size": 14}, | |
| ) | |
| return fig | |
| # 分離 HQ 和 NQ 數據 | |
| hq_data = [ | |
| ( | |
| datetime.fromtimestamp(_normalize_timestamp(e["timestamp"])), | |
| e["pricePerUnit"], | |
| ) | |
| for e in entries | |
| if e.get("hq") | |
| ] | |
| nq_data = [ | |
| ( | |
| datetime.fromtimestamp(_normalize_timestamp(e["timestamp"])), | |
| e["pricePerUnit"], | |
| ) | |
| for e in entries | |
| if not e.get("hq") | |
| ] | |
| # 檢測異常值 | |
| all_prices = [e["pricePerUnit"] for e in entries] | |
| lower_bound, upper_bound = _detect_outliers(all_prices) | |
| fig = go.Figure() | |
| # 收集異常值用於標註 | |
| outliers = [] | |
| if nq_data: | |
| nq_times, nq_prices = zip(*sorted(nq_data)) | |
| fig.add_trace(go.Scatter( | |
| x=nq_times, | |
| y=nq_prices, | |
| mode="markers+lines", | |
| name="NQ (普通品質)", | |
| line={ | |
| "color": CHART_COLORS["nq"], | |
| "width": 2, | |
| }, | |
| marker={ | |
| "size": 8, | |
| "color": CHART_COLORS["nq"], | |
| "line": {"width": 1, "color": "#ffffff"}, | |
| }, | |
| hovertemplate="<b>NQ</b><br>價格: %{y:,.0f} Gil<br>時間: %{x}<extra></extra>", | |
| )) | |
| # 找出 NQ 異常值 | |
| for t, p in zip(nq_times, nq_prices): | |
| if p < lower_bound or p > upper_bound: | |
| outliers.append((t, p, "NQ")) | |
| if hq_data: | |
| hq_times, hq_prices = zip(*sorted(hq_data)) | |
| fig.add_trace(go.Scatter( | |
| x=hq_times, | |
| y=hq_prices, | |
| mode="markers+lines", | |
| name="HQ (高品質)", | |
| line={ | |
| "color": CHART_COLORS["hq"], | |
| "width": 2, | |
| }, | |
| marker={ | |
| "size": 10, | |
| "symbol": "star", | |
| "color": CHART_COLORS["hq"], | |
| "line": {"width": 1, "color": "#ffffff"}, | |
| }, | |
| hovertemplate="<b>HQ ★</b><br>價格: %{y:,.0f} Gil<br>時間: %{x}<extra></extra>", | |
| )) | |
| # 找出 HQ 異常值 | |
| for t, p in zip(hq_times, hq_prices): | |
| if p < lower_bound or p > upper_bound: | |
| outliers.append((t, p, "HQ")) | |
| # 標註異常值 | |
| for t, p, quality in outliers: | |
| if p < lower_bound: | |
| label = "異常低價" | |
| color = "#ff6b6b" | |
| else: | |
| label = "異常高價" | |
| color = "#ffd93d" | |
| fig.add_annotation( | |
| x=t, | |
| y=p, | |
| text=f"⚠️ {label}", | |
| showarrow=True, | |
| arrowhead=2, | |
| arrowsize=1, | |
| arrowwidth=2, | |
| arrowcolor=color, | |
| font={"color": color, "size": 10}, | |
| bgcolor="rgba(0,0,0,0.7)", | |
| bordercolor=color, | |
| borderwidth=1, | |
| borderpad=3, | |
| ) | |
| fig.update_layout( | |
| **CHART_LAYOUT, | |
| title=f"📈 {item_name} - 價格歷史", | |
| xaxis_title="交易時間", | |
| yaxis_title="價格 (Gil)", | |
| hovermode="x unified", | |
| height=400, | |
| margin={"l": 60, "r": 30, "t": 60, "b": 50}, | |
| ) | |
| return fig | |
| def _fetch_world_data(item_id: int, world_name: str) -> Optional[dict]: | |
| """取得單一伺服器的市場數據(用於並行處理).""" | |
| market_data = get_market_data(item_id, world_name) | |
| if not market_data: | |
| return None | |
| listings = market_data.get("listings", []) | |
| if not listings: | |
| return None | |
| nq_prices = [ | |
| listing["pricePerUnit"] | |
| for listing in listings | |
| if not listing.get("hq") | |
| ] | |
| hq_prices = [ | |
| listing["pricePerUnit"] | |
| for listing in listings | |
| if listing.get("hq") | |
| ] | |
| return { | |
| "伺服器": world_name, | |
| "NQ 最低價": min(nq_prices) if nq_prices else "無", | |
| "HQ 最低價": min(hq_prices) if hq_prices else "無", | |
| "上架數量": len(listings), | |
| "最後更新": format_relative_time( | |
| market_data.get("lastUploadTime", 0) | |
| ), | |
| } | |
| def create_cross_world_comparison( | |
| item_id: int, | |
| item_name: str, | |
| ) -> Tuple[pd.DataFrame, go.Figure]: | |
| """建立跨伺服器比價(並行請求加速). | |
| Args: | |
| item_id: 物品 ID | |
| item_name: 物品名稱 | |
| Returns: | |
| (比價表格 DataFrame, 比價圖表) | |
| """ | |
| comparison_data = [] | |
| # 使用 ThreadPoolExecutor 並行請求所有伺服器 | |
| with ThreadPoolExecutor(max_workers=8) as executor: | |
| futures = { | |
| executor.submit(_fetch_world_data, item_id, world): world | |
| for world in WORLD_NAMES | |
| } | |
| for future in as_completed(futures): | |
| result = future.result() | |
| if result: | |
| comparison_data.append(result) | |
| # 按伺服器名稱排序 | |
| comparison_data.sort(key=lambda x: WORLD_NAMES.index(x["伺服器"])) | |
| df = pd.DataFrame(comparison_data) | |
| # 建立圖表 | |
| fig = go.Figure() | |
| if comparison_data: | |
| worlds = [d["伺服器"] for d in comparison_data] | |
| nq_prices = [ | |
| d["NQ 最低價"] if isinstance(d["NQ 最低價"], int) else 0 | |
| for d in comparison_data | |
| ] | |
| hq_prices = [ | |
| d["HQ 最低價"] if isinstance(d["HQ 最低價"], int) else 0 | |
| for d in comparison_data | |
| ] | |
| fig.add_trace(go.Bar( | |
| name="NQ (普通品質)", | |
| x=worlds, | |
| y=nq_prices, | |
| marker={ | |
| "color": CHART_COLORS["nq"], | |
| "line": {"width": 1, "color": "rgba(255,255,255,0.3)"}, | |
| }, | |
| hovertemplate="<b>%{x}</b><br>NQ 最低價: %{y:,.0f} Gil<extra></extra>", | |
| )) | |
| fig.add_trace(go.Bar( | |
| name="HQ (高品質)", | |
| x=worlds, | |
| y=hq_prices, | |
| marker={ | |
| "color": CHART_COLORS["hq"], | |
| "line": {"width": 1, "color": "rgba(255,255,255,0.3)"}, | |
| }, | |
| hovertemplate="<b>%{x}</b><br>HQ 最低價: %{y:,.0f} Gil<extra></extra>", | |
| )) | |
| fig.update_layout( | |
| **CHART_LAYOUT, | |
| title=f"🌐 {item_name} - 跨伺服器比價", | |
| xaxis_title="伺服器", | |
| yaxis_title="最低價格 (Gil)", | |
| barmode="group", | |
| height=400, | |
| margin={"l": 60, "r": 30, "t": 60, "b": 50}, | |
| bargap=0.15, | |
| bargroupgap=0.1, | |
| ) | |
| return df, fig | |
| def create_upload_stats_chart(stats_df: pd.DataFrame) -> go.Figure: | |
| """建立上傳統計圖表. | |
| Args: | |
| stats_df: 統計數據 DataFrame | |
| Returns: | |
| Plotly 圖表物件 | |
| """ | |
| if stats_df.empty: | |
| fig = go.Figure() | |
| fig.update_layout(**CHART_LAYOUT, height=400) | |
| return fig | |
| # 建立漸層色彩 | |
| n_servers = len(stats_df) | |
| colors = [ | |
| f"rgba(255, {193 - i * 10}, {7 + i * 15}, 0.85)" | |
| for i in range(n_servers) | |
| ] | |
| fig = go.Figure(data=[ | |
| go.Bar( | |
| x=stats_df["伺服器"], | |
| y=stats_df["上傳次數"], | |
| marker={ | |
| "color": colors, | |
| "line": {"width": 1, "color": "rgba(255,255,255,0.3)"}, | |
| }, | |
| hovertemplate="<b>%{x}</b><br>上傳次數: %{y:,.0f}<extra></extra>", | |
| ) | |
| ]) | |
| fig.update_layout( | |
| **CHART_LAYOUT, | |
| title="📊 繁中服上傳統計", | |
| xaxis_title="伺服器", | |
| yaxis_title="上傳次數", | |
| height=400, | |
| margin={"l": 60, "r": 30, "t": 60, "b": 50}, | |
| showlegend=False, | |
| ) | |
| return fig | |
| def create_data_flow_chart(world_status: list[dict]) -> go.Figure: | |
| """建立資料流狀態圖表(甘特圖風格). | |
| Args: | |
| world_status: 各伺服器狀態列表,來自 get_world_data_status() | |
| Returns: | |
| Plotly 圖表物件 | |
| """ | |
| if not world_status: | |
| fig = go.Figure() | |
| fig.update_layout(**CHART_LAYOUT, height=350) | |
| fig.add_annotation( | |
| text="等待資料中...", | |
| xref="paper", | |
| yref="paper", | |
| x=0.5, | |
| y=0.5, | |
| showarrow=False, | |
| font={"color": CHART_COLORS["text"], "size": 14}, | |
| ) | |
| return fig | |
| # 準備數據 | |
| worlds = [] | |
| elapsed_times = [] | |
| event_counts = [] | |
| colors = [] | |
| for status in world_status: | |
| worlds.append(status["world_name"]) | |
| elapsed = status["elapsed_seconds"] | |
| count = status["event_count"] | |
| event_counts.append(count) | |
| if elapsed < 0: | |
| # 從未收到數據 | |
| elapsed_times.append(300) # 顯示為 5 分鐘 | |
| colors.append("rgba(128, 128, 128, 0.6)") # 灰色 | |
| elif elapsed < 30: | |
| # 非常新鮮 (30 秒內) | |
| elapsed_times.append(max(elapsed, 5)) | |
| colors.append("rgba(76, 175, 80, 0.85)") # 綠色 | |
| elif elapsed < 120: | |
| # 較新鮮 (2 分鐘內) | |
| elapsed_times.append(elapsed) | |
| colors.append("rgba(255, 193, 7, 0.85)") # 黃色 | |
| elif elapsed < 300: | |
| # 稍舊 (5 分鐘內) | |
| elapsed_times.append(elapsed) | |
| colors.append("rgba(255, 152, 0, 0.85)") # 橙色 | |
| else: | |
| # 過時 (超過 5 分鐘) | |
| elapsed_times.append(min(elapsed, 600)) | |
| colors.append("rgba(244, 67, 54, 0.85)") # 紅色 | |
| # 建立水平條形圖 | |
| fig = go.Figure() | |
| fig.add_trace(go.Bar( | |
| y=worlds, | |
| x=elapsed_times, | |
| orientation="h", | |
| marker={ | |
| "color": colors, | |
| "line": {"width": 1, "color": "rgba(255,255,255,0.3)"}, | |
| }, | |
| text=[ | |
| f"{int(t)}秒 ({c}筆)" if t < 300 else ("無資料" if c == 0 else f">5分 ({c}筆)") | |
| for t, c in zip(elapsed_times, event_counts) | |
| ], | |
| textposition="inside", | |
| textfont={"color": "#ffffff", "size": 11}, | |
| hovertemplate="<b>%{y}</b><br>距上次更新: %{x:.0f} 秒<extra></extra>", | |
| )) | |
| fig.update_layout( | |
| **CHART_LAYOUT, | |
| title="📡 各伺服器資料流狀態", | |
| xaxis_title="距上次收到資料 (秒)", | |
| yaxis_title="", | |
| height=350, | |
| margin={"l": 80, "r": 30, "t": 60, "b": 50}, | |
| showlegend=False, | |
| ) | |
| # 額外設定 x/y 軸 | |
| fig.update_xaxes(range=[0, 320]) | |
| fig.update_yaxes(autorange="reversed") # 讓第一個伺服器在最上面 | |
| # 添加說明文字 | |
| fig.add_annotation( | |
| text="🟢 <30秒 🟡 <2分 🟠 <5分 🔴 >5分 ⚫ 無資料", | |
| xref="paper", | |
| yref="paper", | |
| x=0.5, | |
| y=-0.12, | |
| showarrow=False, | |
| font={"color": CHART_COLORS["text"], "size": 10}, | |
| ) | |
| return fig | |