From c7b7ceeb8de34f77fdf01d890bc99619d68b067d Mon Sep 17 00:00:00 2001 From: OoO Date: Sat, 2 May 2026 23:52:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(ppt):=20line=20chart=20resilience=20?= =?UTF-8?q?=E2=80=94=20sparse=20data=20+=20dense=20xtick=20+=20inset=20leg?= =?UTF-8?q?end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 實戰在 188 prod 抓到的問題:N=1 時 title/legend/data marker 全擠在頂部。 修補: - 加 `sparse = n_pts <= 2` 旗標,N<=2 時: * 略過平均水平線(單點下無意義且標籤撞 title) * marker 加大(5 → 7)讓單點更顯眼 * 中央加「⚠ 期間僅 N 個資料點」提示框(圓角米底+焦糖橘邊) * legend 改 lower right inset(避開 title) * x 軸 set_xlim 加邊距避免 marker 貼牆 - 正常 N>=3: * legend 從 bbox_to_anchor 上方移到 axes 內 upper right (frameon=True 米底邊框,避免與 title 撞) * xticklabel 在 N>14 時 rotation=45 ha=right(避免 30 點擠成一團) * title pad 加大為 18 bump 版本: - daily v3.0.1 → v3.0.2 - weekly v3.0.1 → v3.0.2 - monthly v3.1.1 → v3.1.2 本機驗證: - N=1:「資料不足」提示框正確顯示,無重疊 - N=30:30 個 xticklabel 旋轉 45° 清楚分離,legend 右上 inset 不撞 title Co-Authored-By: Claude Opus 4.7 (1M context) --- services/ppt_generator.py | 56 ++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 5841b5b..ec41f58 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -41,9 +41,9 @@ REPORTS_DIR.mkdir(parents=True, exist_ok=True) # 路由層會把版本號併入快取 key,舊快取自然 miss → 重新生成。 # Bump 規則:major 設計改版 +0.1;微調文案不需 bump。 TEMPLATE_VERSIONS = { - 'daily': 'v3.0.1', # 2026-05-02 (CJK fix) matplotlib fallback 涵蓋容器 Noto CJK JP - 'weekly': 'v3.0.1', # 2026-05-02 (CJK fix) 同上 - 'monthly': 'v3.1.1', # 2026-05-02 (CJK fix) v3.1 + 容器 CJK 字型 fallback 修正 + 'daily': 'v3.0.2', # 2026-05-02 折線稀疏資料防呆:N<=2 時 inset legend、加「資料不足」提示 + 'weekly': 'v3.0.2', # 2026-05-02 同上 + 'monthly': 'v3.1.2', # 2026-05-02 同上(雖月報通常 30 點不會踩到,仍 bump 統一) 'strategy': 'v3.0', # 2026-05-02 AI 頁去黑改暖紙 + 附錄頁 'competitor': 'v3.0', # 2026-05-02 AI 頁去黑改暖紙 + 附錄頁 'promo': 'v3.0', # 2026-05-02 AI 頁去黑改暖紙 + 附錄頁 @@ -619,7 +619,7 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, total_width_cm=30.0, total_height_cm=11.0, title="", curr_label="本月", prev_label="上月對比") -> "io.BytesIO": """日業績折線:本月實線(焦糖橘)+ 上月虛線(蜂蜜金)+ 平均水平線。 - 高/低點自動標註,圖例置上。 + 高/低點自動標註,圖例置上(N>=3 時 ncol=3 上方;N<3 時 inset best)。 """ import io matplotlib, plt = _mpl_setup() @@ -628,6 +628,9 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, fig = None try: + n_pts = len(curr_vals) + sparse = n_pts <= 2 + curr_wan = [float(v) / 10000.0 for v in curr_vals] fig_w = total_width_cm / 2.54 fig_h = total_height_cm / 2.54 @@ -637,7 +640,7 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, x = list(range(len(curr_dates))) line_curr, = ax.plot(x, curr_wan, color="#C96442", linewidth=2.4, - marker="o", markersize=5, + marker="o", markersize=7 if sparse else 5, markerfacecolor="#C96442", markeredgecolor="#FAF7F0", label=curr_label, zorder=3) ax.fill_between(x, curr_wan, alpha=0.12, color="#C96442", zorder=1) @@ -651,8 +654,10 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, label=prev_label, zorder=2, alpha=0.85) avg_v = sum(curr_wan) / len(curr_wan) if curr_wan else 0 - ax.axhline(avg_v, color="#8F4530", linewidth=1.0, linestyle=":", - alpha=0.7, label=f"本月日均 {avg_v:.1f}萬") + # 平均線僅在 N>=3 時有意義;N<=2 略過避免標籤撞 title + if not sparse: + ax.axhline(avg_v, color="#8F4530", linewidth=1.0, linestyle=":", + alpha=0.7, label=f"本月日均 {avg_v:.1f}萬") if curr_wan: max_i = curr_wan.index(max(curr_wan)) @@ -668,7 +673,12 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, ha="center", fontsize=9, color="#B5342F", fontweight="bold") ax.set_xticks(x) - ax.set_xticklabels([str(d)[-5:] for d in curr_dates], rotation=0, fontsize=9) + # 資料點多時旋轉 label 避免擠成一團;少時水平 + x_rotation = 45 if len(x) > 14 else 0 + ax.set_xticklabels([str(d)[-5:] for d in curr_dates], + rotation=x_rotation, + ha=("right" if x_rotation else "center"), + fontsize=9) ax.spines["top"].set_visible(False) ax.spines["right"].set_visible(False) ax.spines["left"].set_color("#9B9081") @@ -679,12 +689,32 @@ def _mpl_line_chart_png(curr_dates, curr_vals, prev_vals=None, ax.set_axisbelow(True) ax.set_ylabel("日業績(萬元)", fontsize=10, color="#645C52", labelpad=8) - if title: - ax.set_title(title, fontsize=13, color="#2A2520", loc="left", - pad=12, fontweight="bold") + # 資料稀疏時:x 軸留邊(避免 marker 貼牆)+ 加「資料不足」提示 + if sparse: + ax.set_xlim(-0.5, max(0.5, len(x) - 0.5)) + ax.text(0.5, 0.5, + f"⚠ 期間僅 {n_pts} 個資料點\n建議擴大查詢範圍以建立趨勢線", + transform=ax.transAxes, + ha="center", va="center", + fontsize=12, color="#8F4530", alpha=0.55, + fontweight="bold", + bbox=dict(boxstyle="round,pad=0.6", + facecolor="#FAF7F0", + edgecolor="#C96442", + linewidth=1.0, alpha=0.85)) - ax.legend(loc="upper left", frameon=False, fontsize=10, ncol=3, - bbox_to_anchor=(0, 1.10)) + if title: + # title pad 加大為 18(原 12),與 legend 留更多空間 + ax.set_title(title, fontsize=13, color="#2A2520", loc="left", + pad=18, fontweight="bold") + + # legend 一律 inset axes 內(避免與 title 撞) + if sparse: + ax.legend(loc="lower right", frameon=False, fontsize=9) + else: + ax.legend(loc="upper right", frameon=True, fontsize=9, + ncol=1, framealpha=0.85, + facecolor="#FAF7F0", edgecolor="#C4BAA8") plt.tight_layout() buf = io.BytesIO()