mcp-charts / src /charting.py
mfallahian's picture
initial commit
8cd9715
Raw
History Blame Contribute Delete
5.81 kB
"""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)