"""Chart rendering utilities for the MCP charts server.""" from __future__ import annotations import base64 import io from typing import Iterable import matplotlib import matplotlib.pyplot as plt import numpy as np matplotlib.use("Agg") ALLOWED_CHART_TYPES = {"line", "bar", "scatter", "pie", "grouped_bar", "stacked_bar"} def _validate_lengths(x_values: list[float], y_values: list[float]) -> None: if not x_values or not y_values: raise ValueError("x_values and y_values must not be empty.") if len(x_values) != len(y_values): raise ValueError("x_values and y_values must have the same length.") def _to_float_list(values: Iterable[float | int | str], field_name: str) -> list[float]: try: return [float(v) for v in values] except (TypeError, ValueError) as exc: raise ValueError(f"{field_name} must contain only numeric values.") from exc def _to_float_matrix( values: Iterable[Iterable[float | int | str]], field_name: str, ) -> list[list[float]]: rows: list[list[float]] = [] try: for idx, row in enumerate(values): rows.append(_to_float_list(row, f"{field_name}[{idx}]")) except TypeError as exc: raise ValueError(f"{field_name} must be a list of numeric lists.") from exc if not rows: raise ValueError(f"{field_name} must not be empty.") return rows def _to_str_list(values: Iterable[float | int | str], field_name: str) -> list[str]: try: return [str(v) for v in values] except TypeError as exc: raise ValueError(f"{field_name} must be a list.") from exc def render_chart_png_base64( chart_type: str, x_values: list[float | int | str], y_values: list[float | int | str], *, y_series: list[list[float | int | str]] | None = None, series_labels: list[str] | None = None, pie_labels: list[str] | None = None, title: str = "Generated Chart", x_label: str = "X", y_label: str = "Y", color: str = "#1f77b4", ) -> str: """Render a chart and return PNG bytes as base64.""" chart_type = chart_type.lower().strip() if chart_type not in ALLOWED_CHART_TYPES: raise ValueError( f"Unsupported chart_type '{chart_type}'. Choose one of {sorted(ALLOWED_CHART_TYPES)}." ) fig, ax = plt.subplots(figsize=(9, 5), dpi=120) try: if chart_type == "line": x = _to_float_list(x_values, "x_values") y = _to_float_list(y_values, "y_values") _validate_lengths(x, y) ax.plot(x, y, color=color, linewidth=2.2, marker="o") elif chart_type == "bar": x = _to_str_list(x_values, "x_values") y = _to_float_list(y_values, "y_values") _validate_lengths(x, y) ax.bar(x, y, color=color, alpha=0.9) elif chart_type == "scatter": x = _to_float_list(x_values, "x_values") y = _to_float_list(y_values, "y_values") _validate_lengths(x, y) ax.scatter(x, y, color=color, s=50) elif chart_type == "pie": y = _to_float_list(y_values, "y_values") if not y: raise ValueError("y_values must not be empty for pie charts.") if pie_labels is not None: labels = [str(label) for label in pie_labels] elif x_values: labels = _to_str_list(x_values, "x_values") else: labels = [f"Slice {i + 1}" for i in range(len(y))] if len(labels) != len(y): raise ValueError("pie_labels/x_values length must match y_values length.") ax.pie(y, labels=labels, autopct="%1.1f%%", startangle=90) ax.axis("equal") elif chart_type in {"grouped_bar", "stacked_bar"}: categories = _to_str_list(x_values, "x_values") matrix = _to_float_matrix(y_series or [], "y_series") num_categories = len(categories) if num_categories == 0: raise ValueError("x_values must not be empty for grouped/stacked bar charts.") for idx, row in enumerate(matrix): if len(row) != num_categories: raise ValueError( f"y_series[{idx}] must have {num_categories} values to match x_values." ) labels = ( [str(label) for label in series_labels] if series_labels is not None else [f"Series {i + 1}" for i in range(len(matrix))] ) if len(labels) != len(matrix): raise ValueError("series_labels length must match y_series length.") x_pos = np.arange(num_categories) if chart_type == "grouped_bar": width = 0.8 / len(matrix) for i, row in enumerate(matrix): offset = (i - (len(matrix) - 1) / 2) * width ax.bar(x_pos + offset, row, width=width, label=labels[i]) else: bottoms = np.zeros(num_categories) for i, row in enumerate(matrix): ax.bar(x_pos, row, bottom=bottoms, label=labels[i], alpha=0.9) bottoms = bottoms + np.array(row) ax.set_xticks(x_pos) ax.set_xticklabels(categories) ax.legend() ax.set_title(title) if chart_type != "pie": ax.set_xlabel(x_label) ax.set_ylabel(y_label) ax.grid(True, linestyle="--", alpha=0.35) fig.tight_layout() buffer = io.BytesIO() fig.savefig(buffer, format="png") buffer.seek(0) image_base64 = base64.b64encode(buffer.read()).decode("ascii") return image_base64 finally: plt.close(fig)