"""Chart.js-ähnliche Payloads → PNG (Matplotlib). Von PDF- und Bundle-Rendering gemeinsam genutzt.""" from __future__ import annotations import io from typing import Any import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt def _safe_float_series(raw: list, n: int) -> list[float]: out: list[float] = [] for i in range(n): if i >= len(raw): out.append(0.0) continue v = raw[i] try: out.append(float(v) if v is not None else 0.0) except (TypeError, ValueError): out.append(0.0) return out def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]: s = (hex_or_rgba or "#333333").strip() if s.startswith("#") and len(s) >= 9: s = s[:7] if s.startswith("#") and len(s) >= 7: try: r = int(s[1:3], 16) / 255.0 g = int(s[3:5], 16) / 255.0 b = int(s[5:7], 16) / 255.0 return (r, g, b) except ValueError: pass return (0.12, 0.62, 0.46) def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig_height_in: float = 3.4) -> bytes: chart_type = payload.get("chart_type") or "line" data = payload.get("data") or {} labels = data.get("labels") or [] datasets = data.get("datasets") or [] fig, ax = plt.subplots(figsize=(fig_width_in, fig_height_in), dpi=120) ax.set_facecolor("#fafaf9") fig.patch.set_facecolor("#ffffff") if chart_type == "pie" and datasets: ds0 = datasets[0] values = ds0.get("data") or [] colors = ds0.get("backgroundColor") or ["#1D9E75", "#378ADD", "#D85A30"] if labels and values and len(labels) == len(values): ax.pie(values, labels=labels, autopct="%1.0f%%", colors=colors[: len(values)], startangle=90) ax.axis("equal") else: ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes) elif chart_type == "dual_axis_line" and datasets and labels: ax2 = ax.twinx() x = list(range(len(labels))) step = max(1, len(labels) // 8) h1, l1 = [], [] h2, l2 = [], [] for i, ds in enumerate(datasets): y_raw = ds.get("data") or [] if not y_raw: continue y = _safe_float_series(y_raw, len(labels)) lab = ds.get("label") or f"Serie {i + 1}" col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#333333")) y_axis = str(ds.get("yAxisID") or "").lower() use_right = y_axis in ("right", "weight", "y1") use_left = not use_right target = ax if use_left else ax2 linestyle = "--" if ds.get("borderDash") else "-" (ln,) = target.plot( x[: len(y)], y, label=lab, color=col, linewidth=1.8, linestyle=linestyle, marker="o", markersize=2 if linestyle == "-" else 0, ) if use_left: h1.append(ln) l1.append(lab) else: h2.append(ln) l2.append(lab) ax.set_xticks(x[::step]) ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7) ax.grid(True, alpha=0.25) ax.set_xmargin(0.02) leg = h1 + h2 leg_l = l1 + l2 if leg: ax.legend(leg, leg_l, loc="upper right", fontsize=7) elif chart_type == "bar" and datasets and labels: n = len(labels) x = list(range(n)) stack_keys_nonnull = [ds.get("stack") for ds in datasets if (ds.get("data") or []) != []] use_stacked = ( len(datasets) >= 2 and len(stack_keys_nonnull) == len(datasets) and all(sk for sk in stack_keys_nonnull) and len(set(stack_keys_nonnull)) == 1 ) if use_stacked: bottom = [0.0] * n for ds in datasets: y_raw = ds.get("data") or [] yv = _safe_float_series(y_raw, n) lab = ds.get("label") or "Serie" col = _color_to_rgb(str(ds.get("backgroundColor") or ds.get("borderColor") or "#1D9E75")) ax.bar(x, yv, bottom=bottom, label=lab, color=col, alpha=0.9, width=0.72) bottom = [bottom[i] + yv[i] for i in range(n)] ax.set_xticks(x) ax.set_xticklabels(labels, rotation=30, fontsize=7) else: width = 0.8 / max(len(datasets), 1) for i, ds in enumerate(datasets): y_raw = ds.get("data") or [] yv = _safe_float_series(y_raw, n) offset = (i - len(datasets) / 2 + 0.5) * width xpos = [xj + offset for xj in x] lab = ds.get("label") or f"Serie {i + 1}" col = _color_to_rgb(str(ds.get("backgroundColor") or ds.get("borderColor") or "#1D9E75")) ax.bar(xpos, yv, width=width * 0.9, label=lab, color=col, alpha=0.88) ax.set_xticks(x) ax.set_xticklabels(labels, rotation=30, fontsize=7) ax.legend(loc="upper right", fontsize=7) ax.grid(True, axis="y", alpha=0.25) ax.set_xmargin(0.02) elif chart_type in ("line", "scatter") and datasets: x = range(len(labels)) if labels else [] for i, ds in enumerate(datasets): y = ds.get("data") or [] if not y: continue lab = ds.get("label") or f"Serie {i + 1}" col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75")) ls = "--" if ds.get("borderDash") else "-" ax.plot( list(x)[: len(y)], y, label=lab, color=col, linewidth=1.6, linestyle=ls, marker="o", markersize=2, ) if labels: step = max(1, len(labels) // 8) ax.set_xticks(list(x)[::step]) ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7) ax.legend(loc="upper right", fontsize=7) ax.grid(True, alpha=0.25) ax.set_xmargin(0.02) else: ax.text(0.5, 0.5, "Diagrammtyp nicht unterstützt oder leer", ha="center", va="center", transform=ax.transAxes) fig.tight_layout() buf = io.BytesIO() fig.savefig(buf, format="png", bbox_inches="tight", facecolor=fig.get_facecolor()) plt.close(fig) buf.seek(0) return buf.read()