mitai-jinkendo/backend/report_chart_plotting.py
Lars d232b11411
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 20s
feat: enhance charting capabilities in report generation
- Added support for dual-axis line charts and stacked bar charts in the report visualization.
- Introduced new helper functions for safely converting raw data to float series and for handling color conversions.
- Updated existing chart rendering logic to accommodate new chart types and improve data handling.
- Enhanced payload generation for daily macros and calorie vs. weight visualizations, ensuring accurate representation in reports.
- Improved error handling and data validation for chart datasets, enhancing overall robustness.
2026-04-29 22:22:56 +02:00

178 lines
6.6 KiB
Python

"""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()