# 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