13 KiB
Executable File
13 KiB
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
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
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
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
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
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
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
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
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.
// ✅ 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",
}),
],
})
// ❌ 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 (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
- DPI: Uniform 200 DPI (built into
save_chart) - Colors: Derived from document palette.accent for style consistency
- CJK text: Must configure SimHei font; otherwise renders as boxes
- Label overlap prevention: Auto-rotate 45° when >6 x-axis labels
- Legend: Move outside chart (
bbox_to_anchor) when >4 series - Grid: Light gray dashed grid lines for readability
- Clean frames: Remove top/right spines for modern minimalist look
- Aspect ratio (CRITICAL): Must use
image-size(JS) orPIL(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. - Dimensions: Default 10×6 inches, fits well within A4 page