Polish stats chart layout and readability
This commit is contained in:
120
SubMind.py
120
SubMind.py
@@ -425,12 +425,13 @@ def make_autopct(values, currency_code):
|
|||||||
|
|
||||||
async def stats(update: Update, context: CallbackContext):
|
async def stats(update: Update, context: CallbackContext):
|
||||||
user_id = update.effective_user.id
|
user_id = update.effective_user.id
|
||||||
await update.message.reply_text("正在为您生成更美观的统计图,请稍候...")
|
await update.message.reply_text("正在生成更清晰、更好看的统计图,请稍候...")
|
||||||
|
|
||||||
font_prop = get_chinese_font()
|
font_prop = get_chinese_font()
|
||||||
main_currency = get_user_main_currency(user_id)
|
main_currency = get_user_main_currency(user_id)
|
||||||
with get_db_connection() as conn:
|
with get_db_connection() as conn:
|
||||||
df = pd.read_sql_query("SELECT * FROM subscriptions WHERE user_id = ?", conn, params=(user_id,))
|
df = pd.read_sql_query("SELECT * FROM subscriptions WHERE user_id = ?", conn, params=(user_id,))
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
await update.message.reply_text("您还没有任何订阅数据。")
|
await update.message.reply_text("您还没有任何订阅数据。")
|
||||||
return
|
return
|
||||||
@@ -453,7 +454,7 @@ async def stats(update: Update, context: CallbackContext):
|
|||||||
await update.message.reply_text("您的订阅没有有效的费用信息。")
|
await update.message.reply_text("您的订阅没有有效的费用信息。")
|
||||||
return
|
return
|
||||||
|
|
||||||
max_categories = 8
|
max_categories = 7
|
||||||
if len(category_costs) > max_categories:
|
if len(category_costs) > max_categories:
|
||||||
top = category_costs.iloc[:max_categories]
|
top = category_costs.iloc[:max_categories]
|
||||||
others_sum = category_costs.iloc[max_categories:].sum()
|
others_sum = category_costs.iloc[max_categories:].sum()
|
||||||
@@ -463,92 +464,133 @@ async def stats(update: Update, context: CallbackContext):
|
|||||||
category_costs = top
|
category_costs = top
|
||||||
|
|
||||||
total_monthly = category_costs.sum()
|
total_monthly = category_costs.sum()
|
||||||
|
active_subs_count = int((df['monthly_cost'] > 0).sum())
|
||||||
|
top_category = category_costs.index[0]
|
||||||
|
top_ratio = (category_costs.iloc[0] / total_monthly * 100) if total_monthly else 0
|
||||||
|
|
||||||
currency_symbols = {'USD': '$', 'CNY': '¥', 'EUR': '€', 'GBP': '£', 'JPY': '¥'}
|
currency_symbols = {'USD': '$', 'CNY': '¥', 'EUR': '€', 'GBP': '£', 'JPY': '¥'}
|
||||||
symbol = currency_symbols.get(main_currency.upper(), f'{main_currency.upper()} ')
|
symbol = currency_symbols.get(main_currency.upper(), f'{main_currency.upper()} ')
|
||||||
|
|
||||||
def autopct_if_large(pct):
|
def format_money(value):
|
||||||
if pct < 4:
|
return f"{symbol}{value:,.2f}"
|
||||||
return ''
|
|
||||||
value = total_monthly * pct / 100
|
|
||||||
return f'{pct:.1f}%\n{symbol}{value:.2f}'
|
|
||||||
|
|
||||||
fig = plt.figure(figsize=(14, 8), facecolor='#f8fafc')
|
def autopct_if_large(pct):
|
||||||
gs = fig.add_gridspec(1, 2, width_ratios=[1.2, 1], wspace=0.12)
|
if pct < 5:
|
||||||
ax_pie = fig.add_subplot(gs[0, 0])
|
return ''
|
||||||
ax_bar = fig.add_subplot(gs[0, 1])
|
return f'{pct:.1f}%'
|
||||||
|
|
||||||
|
fig = plt.figure(figsize=(15, 9), facecolor='#f8fafc')
|
||||||
|
gs = fig.add_gridspec(2, 2, height_ratios=[0.22, 1], width_ratios=[1.05, 1], hspace=0.08, wspace=0.16)
|
||||||
|
|
||||||
|
ax_header = fig.add_subplot(gs[0, :])
|
||||||
|
ax_pie = fig.add_subplot(gs[1, 0])
|
||||||
|
ax_bar = fig.add_subplot(gs[1, 1])
|
||||||
image_path = None
|
image_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
colors = list(plt.get_cmap('tab20').colors)[:len(category_costs)]
|
base_colors = ['#4f46e5', '#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6', '#f97316']
|
||||||
|
colors = base_colors[:len(category_costs)]
|
||||||
|
|
||||||
_, texts, autotexts = ax_pie.pie(
|
# 顶部信息栏
|
||||||
|
ax_header.set_facecolor('#ffffff')
|
||||||
|
ax_header.set_xticks([])
|
||||||
|
ax_header.set_yticks([])
|
||||||
|
for spine in ax_header.spines.values():
|
||||||
|
spine.set_visible(False)
|
||||||
|
|
||||||
|
header_title = '每月订阅支出概览'
|
||||||
|
header_desc = (
|
||||||
|
f"总月支出:{format_money(total_monthly)} "
|
||||||
|
f"活跃订阅:{active_subs_count} 个 "
|
||||||
|
f"最大类别:{top_category}({top_ratio:.1f}%)"
|
||||||
|
)
|
||||||
|
ax_header.text(0.01, 0.66, header_title, fontproperties=font_prop, fontsize=21, color='#0f172a', weight='bold')
|
||||||
|
ax_header.text(0.01, 0.20, header_desc, fontproperties=font_prop, fontsize=12, color='#334155')
|
||||||
|
|
||||||
|
# 环形图:展示结构
|
||||||
|
wedges, _, autotexts = ax_pie.pie(
|
||||||
category_costs.values,
|
category_costs.values,
|
||||||
labels=category_costs.index,
|
labels=None,
|
||||||
autopct=autopct_if_large,
|
autopct=autopct_if_large,
|
||||||
startangle=120,
|
startangle=100,
|
||||||
counterclock=False,
|
counterclock=False,
|
||||||
pctdistance=0.78,
|
pctdistance=0.80,
|
||||||
labeldistance=1.08,
|
|
||||||
colors=colors,
|
colors=colors,
|
||||||
wedgeprops={'width': 0.42, 'edgecolor': 'white', 'linewidth': 1.2}
|
wedgeprops={'width': 0.42, 'edgecolor': 'white', 'linewidth': 1.5}
|
||||||
)
|
)
|
||||||
|
|
||||||
for t in texts:
|
|
||||||
t.set_fontproperties(font_prop)
|
|
||||||
t.set_fontsize(12)
|
|
||||||
t.set_color('#1f2937')
|
|
||||||
for t in autotexts:
|
for t in autotexts:
|
||||||
t.set_fontproperties(font_prop)
|
t.set_fontproperties(font_prop)
|
||||||
t.set_fontsize(10)
|
t.set_fontsize(10)
|
||||||
t.set_color('#111827')
|
t.set_color('#0f172a')
|
||||||
|
|
||||||
ax_pie.text(
|
ax_pie.text(
|
||||||
0, 0,
|
0, 0,
|
||||||
f"月总支出\n{symbol}{total_monthly:.2f}",
|
f"月总支出\n{format_money(total_monthly)}",
|
||||||
ha='center', va='center',
|
ha='center', va='center',
|
||||||
fontproperties=font_prop,
|
fontproperties=font_prop,
|
||||||
fontsize=16,
|
fontsize=15,
|
||||||
color='#111827',
|
color='#111827',
|
||||||
weight='bold'
|
weight='bold'
|
||||||
)
|
)
|
||||||
ax_pie.set_title('订阅支出结构(按类别)', fontproperties=font_prop, fontsize=16, pad=14, color='#111827')
|
ax_pie.set_title('支出结构(类别占比)', fontproperties=font_prop, fontsize=16, pad=14, color='#0f172a')
|
||||||
ax_pie.axis('equal')
|
ax_pie.axis('equal')
|
||||||
|
|
||||||
|
legend_labels = [f"{name} · {format_money(value)}" for name, value in category_costs.items()]
|
||||||
|
legend = ax_pie.legend(
|
||||||
|
wedges,
|
||||||
|
legend_labels,
|
||||||
|
title='类别 / 月支出',
|
||||||
|
loc='center left',
|
||||||
|
bbox_to_anchor=(0.95, 0.5),
|
||||||
|
frameon=False,
|
||||||
|
fontsize=10
|
||||||
|
)
|
||||||
|
if legend and legend.get_title():
|
||||||
|
legend.get_title().set_fontproperties(font_prop)
|
||||||
|
legend.get_title().set_fontsize(11)
|
||||||
|
for txt in legend.get_texts():
|
||||||
|
txt.set_fontproperties(font_prop)
|
||||||
|
|
||||||
|
# 横向柱状图:展示对比
|
||||||
bar_series = category_costs.sort_values(ascending=True)
|
bar_series = category_costs.sort_values(ascending=True)
|
||||||
bars = ax_bar.barh(bar_series.index, bar_series.values, color=colors[:len(bar_series)], alpha=0.9)
|
bars = ax_bar.barh(bar_series.index, bar_series.values, color=colors[:len(bar_series)], alpha=0.95)
|
||||||
ax_bar.set_title('类别月支出对比', fontproperties=font_prop, fontsize=16, pad=14, color='#111827')
|
ax_bar.set_title('类别月支出对比', fontproperties=font_prop, fontsize=16, pad=14, color='#0f172a')
|
||||||
ax_bar.set_xlabel(f'金额({main_currency.upper()})', fontproperties=font_prop, fontsize=11, color='#374151')
|
ax_bar.set_xlabel(f'金额({main_currency.upper()})', fontproperties=font_prop, fontsize=11, color='#475569')
|
||||||
ax_bar.tick_params(axis='x', colors='#6b7280')
|
ax_bar.tick_params(axis='x', colors='#64748b')
|
||||||
ax_bar.tick_params(axis='y', colors='#111827')
|
ax_bar.tick_params(axis='y', colors='#0f172a')
|
||||||
ax_bar.grid(axis='x', linestyle='--', alpha=0.25)
|
ax_bar.grid(axis='x', linestyle='--', alpha=0.22, color='#94a3b8')
|
||||||
|
ax_bar.set_axisbelow(True)
|
||||||
|
|
||||||
for label in ax_bar.get_yticklabels():
|
for label in ax_bar.get_yticklabels():
|
||||||
label.set_fontproperties(font_prop)
|
label.set_fontproperties(font_prop)
|
||||||
label.set_fontsize(11)
|
label.set_fontsize(11)
|
||||||
|
|
||||||
max_val = bar_series.max() if len(bar_series) else 0
|
max_val = bar_series.max() if len(bar_series) else 0
|
||||||
offset = max_val * 0.015 if max_val > 0 else 0.1
|
offset = max(max_val * 0.015, 0.08)
|
||||||
|
ax_bar.set_xlim(0, max_val * 1.22 if max_val > 0 else 1)
|
||||||
|
|
||||||
for rect, value in zip(bars, bar_series.values):
|
for rect, value in zip(bars, bar_series.values):
|
||||||
ax_bar.text(
|
ax_bar.text(
|
||||||
rect.get_width() + offset,
|
rect.get_width() + offset,
|
||||||
rect.get_y() + rect.get_height() / 2,
|
rect.get_y() + rect.get_height() / 2,
|
||||||
f"{symbol}{value:.2f}",
|
format_money(value),
|
||||||
va='center',
|
va='center',
|
||||||
ha='left',
|
ha='left',
|
||||||
fontproperties=font_prop,
|
fontproperties=font_prop,
|
||||||
fontsize=10,
|
fontsize=10,
|
||||||
color='#1f2937'
|
color='#1e293b'
|
||||||
)
|
)
|
||||||
|
|
||||||
fig.suptitle('每月订阅支出统计', fontproperties=font_prop, fontsize=20, color='#0f172a', y=0.98)
|
fig.tight_layout(rect=[0.01, 0.01, 0.99, 0.98])
|
||||||
fig.tight_layout(rect=[0, 0, 1, 0.96])
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp:
|
with tempfile.NamedTemporaryFile(prefix=f'stats_{user_id}_', suffix='.png', delete=False) as tmp:
|
||||||
image_path = tmp.name
|
image_path = tmp.name
|
||||||
|
|
||||||
plt.savefig(image_path, dpi=220, bbox_inches='tight', facecolor=fig.get_facecolor())
|
plt.savefig(image_path, dpi=240, bbox_inches='tight', facecolor=fig.get_facecolor())
|
||||||
|
|
||||||
with open(image_path, 'rb') as photo:
|
with open(image_path, 'rb') as photo:
|
||||||
await update.message.reply_photo(photo, caption="这是优化后的订阅月支出统计图(结构 + 对比)。")
|
await update.message.reply_photo(photo, caption="已为你优化统计图样式(概览 + 结构 + 对比)。")
|
||||||
finally:
|
finally:
|
||||||
plt.close(fig)
|
plt.close(fig)
|
||||||
if image_path and os.path.exists(image_path):
|
if image_path and os.path.exists(image_path):
|
||||||
|
|||||||
Reference in New Issue
Block a user