fix(ppt): line chart resilience — sparse data + dense xtick + inset legend
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

實戰在 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) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-02 23:52:04 +08:00
parent 6cad59f83e
commit c7b7ceeb8d

View File

@@ -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()