- 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.
178 lines
6.6 KiB
Python
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()
|