387 lines
13 KiB
Markdown
Executable File
387 lines
13 KiB
Markdown
Executable File
# Chart Templates — matplotlib Template Library
|
||
|
||
## Design Philosophy
|
||
|
||
GLM uses **matplotlib as the primary chart engine**. Advantages:
|
||
- High chart quality, print-ready
|
||
- Full style control, consistent with document palette
|
||
- Supports complex chart types (heatmap, radar, box plot, etc.)
|
||
- Reliable CJK rendering (with SimHei font configured)
|
||
|
||
**When to use native Word charts?**
|
||
Only when the user explicitly requests "editable charts." Default is always matplotlib PNG embedding.
|
||
|
||
## Base Configuration
|
||
|
||
```python
|
||
import matplotlib
|
||
matplotlib.use("Agg")
|
||
import matplotlib.pyplot as plt
|
||
import numpy as np
|
||
from matplotlib.font_manager import FontProperties
|
||
|
||
# ── CJK Font ──
|
||
_FONT_PATHS = [
|
||
"/System/Library/Fonts/Supplemental/SimHei.ttf", # macOS
|
||
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", # Linux
|
||
"/usr/share/fonts/truetype/chinese/SimHei.ttf", # custom install
|
||
"./SimHei.ttf", # current dir
|
||
]
|
||
ZH_FONT = None
|
||
for _fp in _FONT_PATHS:
|
||
try:
|
||
ZH_FONT = FontProperties(fname=_fp)
|
||
break
|
||
except:
|
||
continue
|
||
|
||
plt.rcParams["axes.unicode_minus"] = False
|
||
|
||
# ── Palette Adapter ──
|
||
def make_chart_palette(accent: str, surface: str = "#F2F4F6") -> dict:
|
||
"""Generate chart palette from document palette.accent"""
|
||
return {
|
||
"primary": accent,
|
||
"series": _generate_series_colors(accent, 6),
|
||
"grid": "#E0E0E0",
|
||
"bg": "white",
|
||
"text": "#333333",
|
||
"surface": surface,
|
||
}
|
||
|
||
def _generate_series_colors(base_hex: str, count: int) -> list:
|
||
"""Generate series colors via hue rotation from base color"""
|
||
import colorsys
|
||
base = tuple(int(base_hex.lstrip("#")[i:i+2], 16) / 255.0 for i in (0, 2, 4))
|
||
h, s, v = colorsys.rgb_to_hsv(*base)
|
||
colors = []
|
||
for i in range(count):
|
||
hi = (h + i * (1.0 / count)) % 1.0
|
||
r, g, b = colorsys.hsv_to_rgb(hi, min(s * 0.9, 1.0), min(v * 1.05, 1.0))
|
||
colors.append(f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}")
|
||
return colors
|
||
|
||
# ── Universal Export ──
|
||
def save_chart(fig, path: str, dpi: int = 200):
|
||
"""Save chart with uniform DPI. Square charts (pie/radar) use fixed padding to preserve 1:1 ratio."""
|
||
w, h = fig.get_size_inches()
|
||
if abs(w - h) < 0.1:
|
||
fig.savefig(path, dpi=dpi, bbox_inches="tight", pad_inches=0.3,
|
||
facecolor="white", edgecolor="none")
|
||
else:
|
||
fig.savefig(path, dpi=dpi, bbox_inches="tight", pad_inches=0.1,
|
||
facecolor="white", edgecolor="none")
|
||
plt.close(fig)
|
||
return path
|
||
```
|
||
|
||
## Template 1: Bar Chart
|
||
|
||
```python
|
||
def bar_chart(categories: list, values: list, title: str = "",
|
||
ylabel: str = "", palette: dict = None, output: str = "bar.png"):
|
||
"""
|
||
Basic bar chart.
|
||
categories: ["Q1", "Q2", "Q3", "Q4"]
|
||
values: [120, 150, 180, 200]
|
||
"""
|
||
p = palette or make_chart_palette("#5B8DB8")
|
||
fig, ax = plt.subplots(figsize=(10, 6))
|
||
|
||
bars = ax.bar(categories, values, color=p["primary"], width=0.6, edgecolor="white")
|
||
|
||
# Data labels
|
||
for bar, val in zip(bars, values):
|
||
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + max(values) * 0.02,
|
||
str(val), ha="center", va="bottom", fontsize=10,
|
||
fontproperties=ZH_FONT, color=p["text"])
|
||
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=15, color=p["text"])
|
||
if ylabel:
|
||
ax.set_ylabel(ylabel, fontproperties=ZH_FONT, fontsize=11, color=p["text"])
|
||
|
||
ax.set_xticklabels(categories, fontproperties=ZH_FONT, fontsize=10)
|
||
ax.spines[["top", "right"]].set_visible(False)
|
||
ax.grid(axis="y", alpha=0.3, color=p["grid"])
|
||
|
||
if len(categories) > 6:
|
||
plt.xticks(rotation=45, ha="right")
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
### Grouped Bar Chart
|
||
|
||
```python
|
||
def grouped_bar(categories: list, groups: dict, title: str = "",
|
||
ylabel: str = "", palette: dict = None, output: str = "grouped_bar.png"):
|
||
"""
|
||
groups: {"Product A": [10, 20, 30], "Product B": [15, 25, 35]}
|
||
"""
|
||
p = palette or make_chart_palette("#5B8DB8")
|
||
fig, ax = plt.subplots(figsize=(10, 6))
|
||
|
||
x = np.arange(len(categories))
|
||
n = len(groups)
|
||
width = 0.8 / n
|
||
|
||
for i, (name, vals) in enumerate(groups.items()):
|
||
offset = (i - n / 2 + 0.5) * width
|
||
bars = ax.bar(x + offset, vals, width, label=name, color=p["series"][i % len(p["series"])])
|
||
|
||
ax.set_xticks(x)
|
||
ax.set_xticklabels(categories, fontproperties=ZH_FONT, fontsize=10)
|
||
ax.legend(prop=ZH_FONT, frameon=False)
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=15)
|
||
ax.spines[["top", "right"]].set_visible(False)
|
||
ax.grid(axis="y", alpha=0.3)
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
## Template 2: Line Chart
|
||
|
||
```python
|
||
def line_chart(x_data: list, series: dict, title: str = "",
|
||
xlabel: str = "", ylabel: str = "", palette: dict = None,
|
||
output: str = "line.png"):
|
||
"""
|
||
series: {"Revenue": [100, 120, 150, 180], "Cost": [80, 90, 100, 110]}
|
||
"""
|
||
p = palette or make_chart_palette("#5B8DB8")
|
||
fig, ax = plt.subplots(figsize=(10, 6))
|
||
|
||
for i, (name, values) in enumerate(series.items()):
|
||
color = p["series"][i % len(p["series"])]
|
||
ax.plot(x_data, values, marker="o", markersize=5, linewidth=2,
|
||
label=name, color=color)
|
||
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=15)
|
||
if xlabel:
|
||
ax.set_xlabel(xlabel, fontproperties=ZH_FONT, fontsize=11)
|
||
if ylabel:
|
||
ax.set_ylabel(ylabel, fontproperties=ZH_FONT, fontsize=11)
|
||
|
||
ax.legend(prop=ZH_FONT, frameon=False, loc="best")
|
||
ax.spines[["top", "right"]].set_visible(False)
|
||
ax.grid(True, alpha=0.3)
|
||
|
||
if len(x_data) > 6:
|
||
plt.xticks(rotation=45, ha="right")
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
## Template 3: Pie Chart
|
||
|
||
```python
|
||
def pie_chart(labels: list, values: list, title: str = "",
|
||
palette: dict = None, output: str = "pie.png"):
|
||
"""Pie chart — auto-merges slices below 3% into 'Other'"""
|
||
p = palette or make_chart_palette("#5B8DB8")
|
||
fig, ax = plt.subplots(figsize=(8, 8))
|
||
|
||
# Merge slices below 3% into "Other"
|
||
total = sum(values)
|
||
merged_labels, merged_values = [], []
|
||
other = 0
|
||
for lbl, val in zip(labels, values):
|
||
if val / total < 0.03:
|
||
other += val
|
||
else:
|
||
merged_labels.append(lbl)
|
||
merged_values.append(val)
|
||
if other > 0:
|
||
merged_labels.append("Other")
|
||
merged_values.append(other)
|
||
|
||
colors = p["series"][:len(merged_labels)]
|
||
wedges, texts, autotexts = ax.pie(
|
||
merged_values, labels=merged_labels, colors=colors,
|
||
autopct="%1.1f%%", startangle=90, pctdistance=0.75,
|
||
textprops={"fontproperties": ZH_FONT, "fontsize": 11}
|
||
)
|
||
|
||
for t in autotexts:
|
||
t.set_fontsize(10)
|
||
t.set_color("white")
|
||
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=20)
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
## Template 4: Box Plot
|
||
|
||
```python
|
||
def box_plot(data: dict, title: str = "", ylabel: str = "",
|
||
palette: dict = None, output: str = "box.png"):
|
||
"""
|
||
data: {"Class A": [78, 82, 91, ...], "Class B": [65, 70, 88, ...]}
|
||
"""
|
||
p = palette or make_chart_palette("#5B8DB8")
|
||
fig, ax = plt.subplots(figsize=(10, 6))
|
||
|
||
labels = list(data.keys())
|
||
values = list(data.values())
|
||
|
||
bp = ax.boxplot(values, labels=labels, patch_artist=True, notch=False,
|
||
medianprops={"color": "white", "linewidth": 2})
|
||
|
||
for i, patch in enumerate(bp["boxes"]):
|
||
patch.set_facecolor(p["series"][i % len(p["series"])])
|
||
patch.set_alpha(0.8)
|
||
|
||
ax.set_xticklabels(labels, fontproperties=ZH_FONT, fontsize=11)
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=15)
|
||
if ylabel:
|
||
ax.set_ylabel(ylabel, fontproperties=ZH_FONT, fontsize=11)
|
||
ax.spines[["top", "right"]].set_visible(False)
|
||
ax.grid(axis="y", alpha=0.3)
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
## Template 5: Radar Chart
|
||
|
||
```python
|
||
def radar_chart(categories: list, series: dict, title: str = "",
|
||
palette: dict = None, output: str = "radar.png"):
|
||
"""
|
||
categories: ["Chinese", "Math", "English", "Physics", "Chemistry"]
|
||
series: {"Student A": [85, 92, 78, 90, 88], "Student B": [75, 88, 92, 70, 85]}
|
||
"""
|
||
p = palette or make_chart_palette("#5B8DB8")
|
||
fig, ax = plt.subplots(figsize=(8, 8), subplot_kw=dict(polar=True))
|
||
|
||
n = len(categories)
|
||
angles = np.linspace(0, 2 * np.pi, n, endpoint=False).tolist()
|
||
angles += angles[:1] # close the polygon
|
||
|
||
for i, (name, values) in enumerate(series.items()):
|
||
vals = values + values[:1] # close the polygon
|
||
color = p["series"][i % len(p["series"])]
|
||
ax.plot(angles, vals, linewidth=2, label=name, color=color)
|
||
ax.fill(angles, vals, alpha=0.15, color=color)
|
||
|
||
ax.set_xticks(angles[:-1])
|
||
ax.set_xticklabels(categories, fontproperties=ZH_FONT, fontsize=11)
|
||
ax.legend(prop=ZH_FONT, loc="upper right", bbox_to_anchor=(1.2, 1.1), frameon=False)
|
||
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=25)
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
## Template 6: Heatmap
|
||
|
||
```python
|
||
def heatmap(data: list, row_labels: list, col_labels: list, title: str = "",
|
||
palette: dict = None, output: str = "heatmap.png"):
|
||
"""
|
||
data: 2D array [[1,2,3],[4,5,6]]
|
||
row_labels: ["Row 1", "Row 2"]
|
||
col_labels: ["Col 1", "Col 2", "Col 3"]
|
||
"""
|
||
fig, ax = plt.subplots(figsize=(max(8, len(col_labels) * 1.2), max(6, len(row_labels) * 0.8)))
|
||
|
||
arr = np.array(data)
|
||
im = ax.imshow(arr, cmap="YlOrRd", aspect="auto")
|
||
|
||
ax.set_xticks(range(len(col_labels)))
|
||
ax.set_yticks(range(len(row_labels)))
|
||
ax.set_xticklabels(col_labels, fontproperties=ZH_FONT, fontsize=10)
|
||
ax.set_yticklabels(row_labels, fontproperties=ZH_FONT, fontsize=10)
|
||
|
||
# Value annotations
|
||
for i in range(len(row_labels)):
|
||
for j in range(len(col_labels)):
|
||
val = arr[i, j]
|
||
color = "white" if val > arr.max() * 0.7 else "black"
|
||
ax.text(j, i, f"{val:.1f}", ha="center", va="center",
|
||
fontsize=10, color=color)
|
||
|
||
fig.colorbar(im, ax=ax, shrink=0.8)
|
||
if title:
|
||
ax.set_title(title, fontproperties=ZH_FONT, fontsize=14, pad=15)
|
||
|
||
return save_chart(fig, output)
|
||
```
|
||
|
||
## Embedding in Documents (MANDATORY — Preserve Aspect Ratio)
|
||
|
||
**⚠️ Core Rule: When embedding any chart image, you MUST read actual image dimensions to calculate displayHeight. NEVER hardcode both width and height.**
|
||
|
||
Pie and radar charts are square — mismatched width/height produces ellipses or diamonds.
|
||
|
||
```js
|
||
// ✅ Correct: read actual image dimensions
|
||
const chartBuffer = fs.readFileSync("bar.png");
|
||
const sizeOf = require("image-size");
|
||
const dims = sizeOf(chartBuffer);
|
||
const displayWidth = 500;
|
||
const displayHeight = Math.round(displayWidth * (dims.height / dims.width));
|
||
|
||
new Paragraph({
|
||
alignment: AlignmentType.CENTER,
|
||
spacing: { before: 200, after: 100 },
|
||
children: [
|
||
new ImageRun({
|
||
data: chartBuffer,
|
||
transformation: { width: displayWidth, height: displayHeight },
|
||
type: "png",
|
||
}),
|
||
],
|
||
})
|
||
```
|
||
|
||
```js
|
||
// ❌ Wrong: hardcoded width and height (pie becomes ellipse, radar becomes diamond)
|
||
new ImageRun({
|
||
data: chartBuffer,
|
||
transformation: { width: 500, height: 350 }, // wrong ratio!
|
||
type: "png",
|
||
})
|
||
```
|
||
|
||
```python
|
||
# ✅ Python (ReportLab) correct approach:
|
||
from PIL import Image as PILImage
|
||
from reportlab.platypus import Image
|
||
pil_img = PILImage.open('chart.png')
|
||
orig_w, orig_h = pil_img.size
|
||
target_width = 400 # pt
|
||
scale = target_width / orig_w
|
||
img = Image('chart.png', width=target_width, height=orig_h * scale)
|
||
```
|
||
|
||
## Chart Selection Guide
|
||
|
||
| Data Scenario | Recommended Chart | Template Function |
|
||
|---------------|-------------------|-------------------|
|
||
| Category comparison | Bar chart | `bar_chart()` |
|
||
| Multi-group comparison | Grouped bar | `grouped_bar()` |
|
||
| Trend over time | Line chart | `line_chart()` |
|
||
| Proportion/composition | Pie chart | `pie_chart()` |
|
||
| Distribution/spread | Box plot | `box_plot()` |
|
||
| Multi-dimensional assessment | Radar chart | `radar_chart()` |
|
||
| Matrix correlation | Heatmap | `heatmap()` |
|
||
|
||
## Quality Standards
|
||
|
||
1. **DPI**: Uniform 200 DPI (built into `save_chart`)
|
||
2. **Colors**: Derived from document palette.accent for style consistency
|
||
3. **CJK text**: Must configure SimHei font; otherwise renders as boxes
|
||
4. **Label overlap prevention**: Auto-rotate 45° when >6 x-axis labels
|
||
5. **Legend**: Move outside chart (`bbox_to_anchor`) when >4 series
|
||
6. **Grid**: Light gray dashed grid lines for readability
|
||
7. **Clean frames**: Remove top/right spines for modern minimalist look
|
||
8. **Aspect ratio (CRITICAL)**: Must use `image-size` (JS) or `PIL` (Python) to read actual image dimensions and calculate displayHeight proportionally. **Pie and radar charts are square — hardcoding non-1:1 ratio causes ellipse/diamond distortion.**
|
||
9. **Dimensions**: Default 10×6 inches, fits well within A4 page
|