618 lines
20 KiB
Markdown
Executable File
618 lines
20 KiB
Markdown
Executable File
# matplotlib Template Library
|
||
|
||
> **⚠️ Before writing any code, read [`_rules.md`](references/_rules.md) — three non-negotiable rules on overlap, hierarchy, and color.**
|
||
|
||
|
||
## Environment Initialization (Must Execute Before Each Plot)
|
||
|
||
```python
|
||
import matplotlib
|
||
import matplotlib.pyplot as plt
|
||
import matplotlib.ticker as mticker
|
||
import numpy as np
|
||
|
||
# ═══ Chinese Font Setup ═══
|
||
# SimHei is the default; other fonts available:
|
||
# SimSun.ttf (Songti, formal docs), SimKai.ttf (Kaiti, artistic),
|
||
# SarasaMonoSC-*.ttf (monospace CJK, code scenes)
|
||
# Run `fc-list :lang=zh` for system fonts (PingFang SC, Heiti TC, etc.)
|
||
# Font path: adjust for your system. Common locations:
|
||
# macOS: '/System/Library/Fonts/Supplemental/SimHei.ttf'
|
||
# Linux: '/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc'
|
||
# Custom: './fonts/SimHei.ttf'
|
||
import os
|
||
SIMHEI_PATH = os.environ.get('SIMHEI_FONT', '/System/Library/Fonts/Supplemental/SimHei.ttf')
|
||
matplotlib.font_manager.fontManager.addfont(SIMHEI_PATH)
|
||
|
||
# ═══ Global Style ═══
|
||
plt.rcParams.update({
|
||
# Font
|
||
'font.sans-serif': ['SimHei'],
|
||
'axes.unicode_minus': False,
|
||
# Background
|
||
'figure.facecolor': '#FFFFFF',
|
||
'axes.facecolor': '#FFFFFF',
|
||
# Border: only keep left and bottom
|
||
'axes.edgecolor': '#E5E7EB',
|
||
'axes.linewidth': 0.8,
|
||
'axes.spines.top': False,
|
||
'axes.spines.right': False,
|
||
# Grid: off by default
|
||
'axes.grid': False,
|
||
# Ticks: hide tick marks
|
||
'xtick.major.size': 0,
|
||
'ytick.major.size': 0,
|
||
'xtick.labelsize': 9,
|
||
'ytick.labelsize': 9,
|
||
# Title
|
||
'axes.labelsize': 10,
|
||
'axes.titlesize': 16,
|
||
'axes.titleweight': 'bold',
|
||
'axes.titlepad': 16,
|
||
# Legend: no frame
|
||
'legend.frameon': False,
|
||
'legend.fontsize': 9,
|
||
# Export
|
||
'figure.dpi': 200,
|
||
'savefig.dpi': 200,
|
||
'savefig.bbox': 'tight',
|
||
'savefig.facecolor': '#FFFFFF',
|
||
'savefig.pad_inches': 0.3,
|
||
})
|
||
```
|
||
|
||
## Color Constants
|
||
|
||
```python
|
||
# ─── Cool Colors (Business/Tech) ───
|
||
C_BLUE = '#3B82F6'
|
||
C_CYAN = '#06B6D4'
|
||
C_PURPLE = '#8B5CF6'
|
||
C_AMBER = '#F59E0B'
|
||
C_RED = '#EF4444'
|
||
C_GREEN = '#10B981'
|
||
COOL = [C_BLUE, C_CYAN, C_PURPLE, C_AMBER, C_RED, C_GREEN]
|
||
|
||
# ─── Warm Colors (Warmth/Creative) ───
|
||
WARM = ['#F59E0B', '#EF4444', '#8B5CF6', '#3B82F6', '#10B981', '#EC4899']
|
||
|
||
# ─── Academic Grayscale ───
|
||
ACADEMIC = ['#111827', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB', '#F3F4F6']
|
||
|
||
# ─── Colorblind-Safe (Paper Preferred) ───
|
||
CB_SAFE = ['#0077BB', '#33BBEE', '#009988', '#EE7733', '#CC3311', '#EE3377']
|
||
|
||
# ─── Grayscale ───
|
||
G900, G700, G500, G400, G300, G200, G100, G50 = \
|
||
'#111827', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#E5E7EB', '#F3F4F6', '#F9FAFB'
|
||
|
||
# ─── Gain/Loss ───
|
||
POS = '#22C55E'
|
||
NEG = '#EF4444'
|
||
```
|
||
|
||
## Helper Functions
|
||
|
||
```python
|
||
def clean_axis(ax, grid=True):
|
||
"""Clean axis: remove top and right borders, add faint grid"""
|
||
ax.spines['top'].set_visible(False)
|
||
ax.spines['right'].set_visible(False)
|
||
if grid:
|
||
ax.yaxis.grid(True, alpha=0.08, color=G300)
|
||
ax.set_axisbelow(True)
|
||
|
||
def add_value_labels(ax, bars, values, highlight_idx=None, fmt='{:,.0f}',
|
||
offset_ratio=0.02):
|
||
"""Add value labels on top of bars, highlight bar in bold.
|
||
⚠️ Automatically extends Y-axis upper limit to ensure labels don't overflow chart area."""
|
||
max_val = max(values)
|
||
for i, (bar, val) in enumerate(zip(bars, values)):
|
||
is_hl = (i == highlight_idx) if highlight_idx is not None else False
|
||
ax.text(bar.get_x() + bar.get_width()/2,
|
||
bar.get_height() + max_val * offset_ratio,
|
||
fmt.format(val), ha='center', va='bottom',
|
||
fontsize=10 if is_hl else 9,
|
||
color=G900 if is_hl else G400,
|
||
fontweight='bold' if is_hl else 'normal')
|
||
|
||
# ⚠️ Critical: extend Y-axis upper limit to leave enough space for labels (at least 15%)
|
||
ax.set_ylim(0, max_val * 1.18)
|
||
|
||
def save(fig, path, dpi=200):
|
||
"""Unified save — prefer constrained_layout, fallback to tight_layout"""
|
||
if not fig.get_constrained_layout():
|
||
try:
|
||
fig.tight_layout()
|
||
except Exception:
|
||
pass
|
||
fig.savefig(path, dpi=dpi, facecolor='white', bbox_inches='tight')
|
||
plt.close(fig)
|
||
import os
|
||
size_kb = os.path.getsize(path) / 1024
|
||
print(f'✅ {path} ({size_kb:.0f}KB)')
|
||
```
|
||
|
||
### Label Text Avoidance (adjustText)
|
||
|
||
When charts have multiple label annotations (e.g., scatter plot labels, box plot annotations), **must use adjustText library** to prevent text overlap:
|
||
|
||
```python
|
||
# pip install adjustText
|
||
from adjustText import adjust_text
|
||
|
||
# Call after adding all annotations
|
||
texts = []
|
||
for i, (x, y, label) in enumerate(zip(x_data, y_data, labels)):
|
||
texts.append(ax.text(x, y, label, fontsize=9, color='#374151'))
|
||
|
||
# Auto-avoidance — pass all text objects, adjustText will move them to avoid overlap
|
||
adjust_text(texts, ax=ax,
|
||
arrowprops=dict(arrowstyle='->', color='#9CA3AF', lw=0.8),
|
||
force_text=(0.5, 0.8), # Force to push text away
|
||
force_points=(0.3, 0.5), # Repulsion from data points
|
||
expand=(1.2, 1.4)) # Expansion factor for text bbox
|
||
```
|
||
|
||
---
|
||
|
||
## Template 1: Insight Bar Chart
|
||
|
||
**Scenario**: Emphasize outstanding performance of one data item. Gray out others, highlight the focus.
|
||
|
||
```python
|
||
def insight_bar(labels, values, highlight_idx, title,
|
||
highlight_color=C_BLUE, save_path='insight_bar.png'):
|
||
fig, ax = plt.subplots(figsize=(10, 6))
|
||
|
||
colors = [G200] * len(labels)
|
||
colors[highlight_idx] = highlight_color
|
||
|
||
bars = ax.bar(labels, values, color=colors, width=0.6,
|
||
zorder=3, edgecolor='white', linewidth=0.5)
|
||
add_value_labels(ax, bars, values, highlight_idx)
|
||
|
||
ax.set_title(title, loc='left')
|
||
# ⚠️ add_value_labels already sets ylim automatically, no need to repeat here
|
||
clean_axis(ax)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 2: Trend Comparison Line Chart
|
||
|
||
**Scenario**: This year vs last year, actual vs target. Main line in colored solid, comparison line in gray dashed.
|
||
|
||
```python
|
||
def trend_compare(x, y_main, y_ref, label_main, label_ref, title,
|
||
color=C_BLUE, save_path='trend_compare.png'):
|
||
fig, ax = plt.subplots(figsize=(12, 6))
|
||
|
||
# Comparison line (gray dashed, at bottom layer)
|
||
ax.plot(x, y_ref, color=G300, linewidth=1.5, linestyle='--', zorder=2)
|
||
ax.text(len(x)-0.5, y_ref[-1], label_ref, color=G400, fontsize=9, va='center')
|
||
|
||
# Main line (colored solid + white-center dots)
|
||
ax.plot(x, y_main, color=color, linewidth=2.5, marker='o', markersize=5,
|
||
markerfacecolor='white', markeredgewidth=2, markeredgecolor=color, zorder=3)
|
||
ax.text(len(x)-0.5, y_main[-1], f'{label_main} {y_main[-1]:,.0f}',
|
||
color=color, fontsize=10, fontweight='bold', va='center')
|
||
|
||
# Difference area
|
||
ax.fill_between(range(len(x)), y_ref, y_main, alpha=0.06, color=color)
|
||
|
||
ax.set_title(title, loc='left')
|
||
ax.set_xticks(range(len(x)))
|
||
ax.set_xticklabels(x)
|
||
clean_axis(ax)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 3: Grouped Bar Chart
|
||
|
||
**Scenario**: Comparison of multiple categories across multiple dimensions.
|
||
|
||
```python
|
||
def grouped_bar(labels, datasets, series_names, title,
|
||
colors=None, save_path='grouped_bar.png'):
|
||
if colors is None:
|
||
colors = COOL[:len(datasets)]
|
||
|
||
fig, ax = plt.subplots(figsize=(12, 6))
|
||
n = len(datasets)
|
||
width = 0.7 / n
|
||
x = np.arange(len(labels))
|
||
|
||
for i, (data, name, color) in enumerate(zip(datasets, series_names, colors)):
|
||
offset = (i - n/2 + 0.5) * width
|
||
ax.bar(x + offset, data, width=width*0.85, color=color,
|
||
label=name, zorder=3, edgecolor='white', linewidth=0.3)
|
||
|
||
ax.set_title(title, loc='left')
|
||
ax.set_xticks(x)
|
||
ax.set_xticklabels(labels)
|
||
ax.set_ylim(0, max(max(d) for d in datasets) * 1.20) # Leave 20% space for labels
|
||
ax.legend(loc='upper right', ncol=n)
|
||
clean_axis(ax)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 4: Horizontal Ranking Chart
|
||
|
||
**Scenario**: Rankings / Top N. Progressive highlight for top items.
|
||
|
||
```python
|
||
def ranking_bar(labels, values, title, top_n=3,
|
||
color=C_BLUE, save_path='ranking.png'):
|
||
from matplotlib.colors import to_rgba
|
||
|
||
sorted_pairs = sorted(zip(labels, values), key=lambda x: x[1])
|
||
labels_s, values_s = zip(*sorted_pairs)
|
||
|
||
fig, ax = plt.subplots(figsize=(10, max(6, len(labels)*0.45)))
|
||
|
||
bar_colors = [G200] * len(labels_s)
|
||
for i in range(len(labels_s) - top_n, len(labels_s)):
|
||
progress = (i - (len(labels_s) - top_n)) / max(top_n - 1, 1)
|
||
bar_colors[i] = to_rgba(color, 0.35 + 0.65 * progress)
|
||
|
||
bars = ax.barh(range(len(labels_s)), values_s, color=bar_colors,
|
||
height=0.6, zorder=3, edgecolor='white', linewidth=0.3)
|
||
|
||
for i, (bar, val) in enumerate(zip(bars, values_s)):
|
||
is_top = i >= len(labels_s) - top_n
|
||
ax.text(bar.get_width() + max(values_s)*0.01,
|
||
bar.get_y() + bar.get_height()/2,
|
||
f'{val:,.0f}', va='center', fontsize=9,
|
||
color=G900 if is_top else G400)
|
||
|
||
ax.set_yticks(range(len(labels_s)))
|
||
ax.set_yticklabels(labels_s)
|
||
ax.set_title(title, loc='left')
|
||
ax.spines['bottom'].set_visible(False)
|
||
ax.xaxis.set_visible(False)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 5: Donut Chart
|
||
|
||
**Scenario**: Proportion distribution (max 5 slices, avoid if possible — bar charts are usually better).
|
||
|
||
```python
|
||
def donut(labels, values, title, center_text=None,
|
||
colors=None, save_path='donut.png'):
|
||
if colors is None:
|
||
colors = COOL[:len(labels)]
|
||
|
||
fig, ax = plt.subplots(figsize=(8, 8))
|
||
wedges, _, autotexts = ax.pie(
|
||
values, labels=None, colors=colors, autopct='%1.0f%%',
|
||
startangle=90, pctdistance=0.78,
|
||
wedgeprops=dict(width=0.35, edgecolor='white', linewidth=2))
|
||
|
||
for t in autotexts:
|
||
t.set_fontsize(10)
|
||
t.set_fontweight('bold')
|
||
|
||
if center_text:
|
||
ax.text(0, 0.06, str(center_text), ha='center', va='center',
|
||
fontsize=28, fontweight='bold', color=G900)
|
||
ax.text(0, -0.1, '总计', ha='center', va='center', fontsize=11, color=G500)
|
||
|
||
ax.legend(wedges, labels, loc='center left',
|
||
bbox_to_anchor=(1, 0.5), fontsize=10)
|
||
ax.set_title(title, loc='center', pad=20)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 6: Scatter Plot + Trend Line
|
||
|
||
**Scenario**: Two-variable correlation analysis.
|
||
|
||
```python
|
||
def scatter_trend(x, y, title, xlabel, ylabel,
|
||
color=C_BLUE, save_path='scatter.png'):
|
||
fig, ax = plt.subplots(figsize=(10, 7))
|
||
|
||
ax.scatter(x, y, c=color, s=50, alpha=0.6,
|
||
edgecolors='white', linewidth=1, zorder=3)
|
||
|
||
# Trend line
|
||
z = np.polyfit(x, y, 1)
|
||
p = np.poly1d(z)
|
||
x_line = np.linspace(min(x), max(x), 100)
|
||
ax.plot(x_line, p(x_line), color=G400, linewidth=1.5,
|
||
linestyle='--', zorder=2, alpha=0.7)
|
||
|
||
ax.set_title(title, loc='left')
|
||
ax.set_xlabel(xlabel)
|
||
ax.set_ylabel(ylabel)
|
||
clean_axis(ax, grid=True)
|
||
ax.xaxis.grid(True, alpha=0.08, color=G300)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 7: Heatmap
|
||
|
||
**Scenario**: Matrix data, correlations, time × category.
|
||
|
||
```python
|
||
def heatmap(data, row_labels, col_labels, title,
|
||
cmap_color=C_BLUE, save_path='heatmap.png'):
|
||
from matplotlib.colors import LinearSegmentedColormap
|
||
|
||
cmap = LinearSegmentedColormap.from_list('bc', ['#FFFFFF', cmap_color])
|
||
fig, ax = plt.subplots(
|
||
figsize=(max(8, len(col_labels)*1.2), max(6, len(row_labels)*0.6)))
|
||
|
||
arr = np.array(data)
|
||
im = ax.imshow(arr, cmap=cmap, aspect='auto')
|
||
|
||
vmax = arr.max()
|
||
for i in range(len(row_labels)):
|
||
for j in range(len(col_labels)):
|
||
val = arr[i][j]
|
||
color = 'white' if val > vmax * 0.6 else G700
|
||
ax.text(j, i, f'{val:.1f}', ha='center', va='center',
|
||
fontsize=9, color=color)
|
||
|
||
ax.set_xticks(range(len(col_labels)))
|
||
ax.set_yticks(range(len(row_labels)))
|
||
ax.set_xticklabels(col_labels)
|
||
ax.set_yticklabels(row_labels)
|
||
ax.set_title(title, loc='left', pad=16)
|
||
|
||
cbar = fig.colorbar(im, ax=ax, fraction=0.03, pad=0.08, shrink=0.8)
|
||
cbar.outline.set_visible(False)
|
||
cbar.ax.tick_params(labelsize=8) # colorbar ticks should not be too large
|
||
|
||
for spine in ax.spines.values():
|
||
spine.set_visible(True)
|
||
spine.set_color(G200)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 8: KPI Metric Cards
|
||
|
||
**Scenario**: Dashboard top key number display.
|
||
|
||
```python
|
||
def kpi_cards(metrics, save_path='kpi.png'):
|
||
"""
|
||
metrics: [{'label': '总收入', 'value': '12.8M',
|
||
'change': '+23%', 'positive': True}, ...]
|
||
"""
|
||
from matplotlib.patches import FancyBboxPatch
|
||
|
||
n = len(metrics)
|
||
fig, axes = plt.subplots(1, n, figsize=(3.8*n, 2.8))
|
||
if n == 1: axes = [axes]
|
||
|
||
for ax, m in zip(axes, metrics):
|
||
ax.set_xlim(0, 1); ax.set_ylim(0, 1); ax.axis('off')
|
||
|
||
bg = FancyBboxPatch((0.05, 0.05), 0.9, 0.9,
|
||
boxstyle='round,pad=0.05', facecolor=G50, edgecolor=G200, linewidth=0.8)
|
||
ax.add_patch(bg)
|
||
|
||
ax.text(0.5, 0.75, m['label'], ha='center', va='center',
|
||
fontsize=10, color=G500)
|
||
ax.text(0.5, 0.45, m['value'], ha='center', va='center',
|
||
fontsize=24, fontweight='bold', color=G900)
|
||
|
||
if 'change' in m:
|
||
is_pos = m.get('positive', True)
|
||
ax.text(0.5, 0.18,
|
||
f'{"↑" if is_pos else "↓"} {m["change"]}',
|
||
ha='center', va='center', fontsize=11,
|
||
color=POS if is_pos else NEG, fontweight='bold')
|
||
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 9: Radar Chart
|
||
|
||
**Scenario**: Multi-dimensional capability comparison (max 8 dimensions, max 3 groups).
|
||
|
||
```python
|
||
def radar(categories, datasets, series_names, title,
|
||
colors=None, save_path='radar.png'):
|
||
if colors is None:
|
||
colors = COOL[:len(datasets)]
|
||
|
||
N = len(categories)
|
||
angles = np.linspace(0, 2*np.pi, N, endpoint=False).tolist()
|
||
angles += angles[:1] # Close the polygon
|
||
|
||
fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
|
||
ax.set_theta_offset(np.pi / 2)
|
||
ax.set_theta_direction(-1)
|
||
|
||
ax.set_xticks(angles[:-1])
|
||
ax.set_xticklabels(categories, fontsize=10)
|
||
ax.yaxis.set_visible(False)
|
||
|
||
# Grid beautification
|
||
ax.spines['polar'].set_color(G200)
|
||
ax.grid(color=G200, linewidth=0.5, alpha=0.5)
|
||
|
||
for data, name, color in zip(datasets, series_names, colors):
|
||
vals = data + data[:1] # Close the polygon
|
||
ax.plot(angles, vals, color=color, linewidth=2, label=name)
|
||
ax.fill(angles, vals, color=color, alpha=0.08)
|
||
|
||
ax.legend(loc='upper right', bbox_to_anchor=(1.3, 1.1))
|
||
ax.set_title(title, pad=30, fontsize=16, fontweight='bold')
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 10: Waterfall Chart
|
||
|
||
**Scenario**: Show incremental changes from start to end value.
|
||
|
||
```python
|
||
def waterfall(labels, values, title, save_path='waterfall.png'):
|
||
"""labels and values correspond, positive=increase negative=decrease, last item auto-treated as total"""
|
||
fig, ax = plt.subplots(figsize=(12, 6))
|
||
|
||
cumulative = [0]
|
||
for v in values[:-1]:
|
||
cumulative.append(cumulative[-1] + v)
|
||
|
||
bar_colors = []
|
||
for i, v in enumerate(values):
|
||
if i == len(values) - 1:
|
||
bar_colors.append(C_BLUE) # Total
|
||
elif v >= 0:
|
||
bar_colors.append(POS) # Increase
|
||
else:
|
||
bar_colors.append(NEG) # Decrease
|
||
|
||
bottoms = []
|
||
for i, v in enumerate(values):
|
||
if i == len(values) - 1:
|
||
bottoms.append(0) # Total starts from 0
|
||
elif v >= 0:
|
||
bottoms.append(cumulative[i])
|
||
else:
|
||
bottoms.append(cumulative[i] + v)
|
||
|
||
bars = ax.bar(labels, [abs(v) for v in values], bottom=bottoms,
|
||
color=bar_colors, width=0.6, edgecolor='white', linewidth=0.5, zorder=3)
|
||
|
||
# Connecting lines
|
||
for i in range(len(values) - 2):
|
||
y = cumulative[i+1]
|
||
ax.plot([i+0.3, i+0.7], [y, y], color=G300, linewidth=0.8, zorder=2)
|
||
|
||
# Value labels
|
||
for i, (bar, val) in enumerate(zip(bars, values)):
|
||
y_pos = bar.get_y() + bar.get_height() + max(abs(v) for v in values)*0.01
|
||
prefix = '+' if val > 0 and i < len(values)-1 else ''
|
||
ax.text(bar.get_x()+bar.get_width()/2, y_pos,
|
||
f'{prefix}{val:,.0f}', ha='center', va='bottom',
|
||
fontsize=9, color=G700)
|
||
|
||
ax.set_title(title, loc='left')
|
||
clean_axis(ax)
|
||
save(fig, save_path)
|
||
```
|
||
|
||
---
|
||
|
||
## Template 11: Multi-Subplot Dashboard (GridSpec Precision Layout)
|
||
|
||
**Scenario**: Combine multiple subplots into a dashboard. ⚠️ Max 4 subplots per canvas, split if exceeded.
|
||
|
||
**Core Principle**: Use `GridSpec` for precise control of each subplot's position and spacing, don't rely on `tight_layout()`.
|
||
|
||
```python
|
||
import matplotlib.gridspec as gridspec
|
||
|
||
def dashboard(data_dict, title, save_path='dashboard.png'):
|
||
"""
|
||
2x2 dashboard layout example.
|
||
data_dict contains data needed for each subplot.
|
||
"""
|
||
# ⚠️ Use constrained_layout instead of tight_layout
|
||
fig = plt.figure(figsize=(16, 12), constrained_layout=True)
|
||
fig.suptitle(title, fontsize=20, fontweight='bold', y=0.98)
|
||
|
||
# GridSpec: precise spacing control
|
||
gs = gridspec.GridSpec(2, 2, figure=fig,
|
||
wspace=0.35, # Column spacing (at least 0.3)
|
||
hspace=0.35, # Row spacing (at least 0.3)
|
||
left=0.08, right=0.92,
|
||
top=0.92, bottom=0.08)
|
||
|
||
# ─── Top-left: bar chart ───
|
||
ax1 = fig.add_subplot(gs[0, 0])
|
||
ax1.set_title('Quarterly Revenue', loc='left', fontsize=13, fontweight='bold')
|
||
# ... binddata ...
|
||
clean_axis(ax1)
|
||
|
||
# ─── Top-right: line chart ───
|
||
ax2 = fig.add_subplot(gs[0, 1])
|
||
ax2.set_title('Monthly Trend', loc='left', fontsize=13, fontweight='bold')
|
||
# ... bind data ...
|
||
clean_axis(ax2)
|
||
|
||
# ─── Bottom-left: pie chart ───
|
||
ax3 = fig.add_subplot(gs[1, 0])
|
||
ax3.set_title('Category Share', loc='left', fontsize=13, fontweight='bold')
|
||
# ... bind data ...
|
||
|
||
# ─── Bottom-right: scatter plot ───
|
||
ax4 = fig.add_subplot(gs[1, 1])
|
||
ax4.set_title('Conversion Analysis', loc='left', fontsize=13, fontweight='bold')
|
||
# ... bind data ...
|
||
clean_axis(ax4)
|
||
|
||
fig.savefig(save_path, dpi=200, facecolor='white', bbox_inches='tight')
|
||
plt.close(fig)
|
||
```
|
||
|
||
### Dashboard Layout Golden Rules
|
||
|
||
| Rule | Description |
|
||
|------|-------------|
|
||
| **Max 4 subplots** | Split into multiple charts if exceeded |
|
||
| **Use `constrained_layout=True`** | Smarter than `tight_layout()`, auto-avoids labels |
|
||
| **`wspace/hspace ≥ 0.3`** | Subplot spacing too small causes overlap |
|
||
| **Independent title per subplot** | Use `ax.set_title()` instead of `fig.suptitle()` |
|
||
| **Consistent font sizes** | Subtitle 13px, axis labels 10px, data labels 9px |
|
||
| **Colorbar in separate column** | If a subplot needs colorbar, allocate `gs[0, 2]` separately |
|
||
| **Don't mix legend and direct labels** | Use either all legends or all direct labels in a dashboard |
|
||
|
||
### Safe Layout with Colorbar
|
||
|
||
```python
|
||
# When a subplot needs a colorbar, use 3-column layout, rightmost column for colorbar
|
||
gs = gridspec.GridSpec(2, 3, figure=fig,
|
||
width_ratios=[1, 1, 0.05], # Third column very narrow, dedicated to colorbar
|
||
wspace=0.4, hspace=0.35)
|
||
|
||
ax_heat = fig.add_subplot(gs[0, 1])
|
||
im = ax_heat.imshow(data, cmap='Blues')
|
||
|
||
# Colorbar placed in its own subplot position, won't obscure any content
|
||
cbar_ax = fig.add_subplot(gs[0, 2])
|
||
fig.colorbar(im, cax=cbar_ax)
|
||
cbar_ax.set_ylabel('Value', fontsize=10)
|
||
```
|
||
|
||
### Split Strategy for More Than 4 Subplots
|
||
|
||
```python
|
||
# ❌ Wrong: 8 subplots crammed into one canvas
|
||
fig, axes = plt.subplots(2, 4, figsize=(20, 10)) # Everything becomes unreadable
|
||
|
||
# ✅ Correct: split into 2 figures, 4 subplots each
|
||
# Figure 1: overview metrics
|
||
fig1 = plt.figure(figsize=(16, 12), constrained_layout=True)
|
||
# ... 4 subplots ...
|
||
fig1.savefig('dashboard_overview.png', dpi=200)
|
||
|
||
# Figure 2: detailed analysis
|
||
fig2 = plt.figure(figsize=(16, 12), constrained_layout=True)
|
||
# ... 4 subplots ...
|
||
fig2.savefig('dashboard_detail.png', dpi=200)
|
||
```
|