Compare commits

...

1 Commits

Author SHA1 Message Date
d232b11411 feat: enhance charting capabilities in report generation
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
- 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
2 changed files with 226 additions and 30 deletions

View File

@ -10,8 +10,24 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt 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]: def _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
s = (hex_or_rgba or "#333333").strip() s = (hex_or_rgba or "#333333").strip()
if s.startswith("#") and len(s) >= 9:
s = s[:7]
if s.startswith("#") and len(s) >= 7: if s.startswith("#") and len(s) >= 7:
try: try:
r = int(s[1:3], 16) / 255.0 r = int(s[1:3], 16) / 255.0
@ -43,7 +59,87 @@ def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig
else: else:
ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes) ax.text(0.5, 0.5, "Keine Daten", ha="center", va="center", transform=ax.transAxes)
elif chart_type in ("line", "bar", "scatter") and datasets: 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 [] x = range(len(labels)) if labels else []
for i, ds in enumerate(datasets): for i, ds in enumerate(datasets):
y = ds.get("data") or [] y = ds.get("data") or []
@ -51,31 +147,21 @@ def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig
continue continue
lab = ds.get("label") or f"Serie {i + 1}" lab = ds.get("label") or f"Serie {i + 1}"
col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75")) col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75"))
if chart_type == "bar": ls = "--" if ds.get("borderDash") else "-"
yv = y[: len(labels)] if labels else y
bg = ds.get("backgroundColor")
if isinstance(bg, list):
cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
else:
cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
else:
ax.plot( ax.plot(
list(x)[: len(y)], list(x)[: len(y)],
y, y,
label=lab, label=lab,
color=col, color=col,
linewidth=1.6, linewidth=1.6,
linestyle=ls,
marker="o", marker="o",
markersize=2, markersize=2,
) )
if labels and chart_type != "bar": if labels:
step = max(1, len(labels) // 8) step = max(1, len(labels) // 8)
ax.set_xticks(list(x)[::step]) ax.set_xticks(list(x)[::step])
ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7) ax.set_xticklabels([labels[j] for j in range(0, len(labels), step)], rotation=25, fontsize=7)
elif labels and chart_type == "bar":
ax.set_xticks(list(x))
ax.set_xticklabels(labels, rotation=30, fontsize=7)
ax.legend(loc="upper right", fontsize=7) ax.legend(loc="upper right", fontsize=7)
ax.grid(True, alpha=0.25) ax.grid(True, alpha=0.25)
ax.set_xmargin(0.02) ax.set_xmargin(0.02)

View File

@ -122,6 +122,15 @@ def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | No
"borderColor": "#378ADD", "borderColor": "#378ADD",
} }
) )
if any(p.get("avg14") is not None for p in series):
datasets.append(
{
"label": "Ø 14T",
"data": [safe_float(p.get("avg14")) for p in series],
"borderColor": "#085041",
"borderDash": [6, 3],
}
)
return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}} return {"chart_type": "line", "data": {"labels": labels, "datasets": datasets}, "metadata": {"confidence": "high"}}
@ -145,6 +154,97 @@ def _line_payload_from_points(
} }
def _donut_avg_to_pie_payload(donut: list[dict[str, Any]] | None) -> dict[str, Any] | None:
if not donut:
return None
labels = [str(x.get("name") or "") for x in donut]
values = [safe_float(x.get("value")) for x in donut]
colors = [str(x.get("color") or "#666666") for x in donut]
if not labels or not values or len(labels) != len(values):
return None
return {
"chart_type": "pie",
"data": {
"labels": labels,
"datasets": [{"data": values, "backgroundColor": colors}],
},
"metadata": {"confidence": "high"},
}
def _daily_macros_stacked_bar_payload(daily_macros: list[dict[str, Any]]) -> dict[str, Any] | None:
if len(daily_macros) < 2:
return None
labels = [str(d.get("date") or "") for d in daily_macros]
return {
"chart_type": "bar",
"data": {
"labels": labels,
"datasets": [
{
"label": "Protein (g)",
"data": [safe_float(d.get("Protein")) for d in daily_macros],
"backgroundColor": "#4a8f72",
"stack": "daily",
},
{
"label": "Fett (g)",
"data": [safe_float(d.get("Fett")) for d in daily_macros],
"backgroundColor": "#6e8eb8",
"stack": "daily",
},
{
"label": "KH (g)",
"data": [safe_float(d.get("KH")) for d in daily_macros],
"backgroundColor": "#c17d45",
"stack": "daily",
},
],
},
"metadata": {"confidence": "high"},
}
def _kcal_vs_weight_dual_payload(kw: dict[str, Any]) -> dict[str, Any] | None:
pts = kw.get("points") or []
if len(pts) < 2:
return None
labels = [str(p.get("date") or "") for p in pts]
tdee = kw.get("tdee_reference_kcal")
datasets: list[dict[str, Any]] = [
{
"label": "Ø Kalorien (7T)",
"data": [safe_float(p.get("kcal_avg")) for p in pts],
"borderColor": "#EA580C",
"yAxisID": "kcal",
},
]
if tdee is not None and float(safe_float(tdee) or 0) > 0:
td = float(tdee)
datasets.append(
{
"label": "TDEE (geschätzt)",
"data": [td] * len(labels),
"borderColor": "#888888",
"yAxisID": "kcal",
"borderDash": [5, 5],
}
)
datasets.append(
{
"label": "Gewicht (kg)",
"data": [safe_float(p.get("weight")) for p in pts],
"borderColor": "#2563EB",
"yAxisID": "weight",
}
)
return {
"chart_type": "dual_axis_line",
"data": {"labels": labels, "datasets": datasets},
"metadata": {"confidence": "high"},
}
def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None: def _append_body_bundle(story: list, styles: dict, profile_id: str, cfg: dict[str, Any]) -> None:
days = int(cfg.get("chart_days") or 30) days = int(cfg.get("chart_days") or 30)
bundle = get_body_history_viz_bundle(profile_id, days) bundle = get_body_history_viz_bundle(profile_id, days)
@ -235,17 +335,27 @@ def _append_nutrition_bundle(story: list, styles: dict, profile_id: str, cfg: di
pl2 = charts.get("nutrition_adherence") pl2 = charts.get("nutrition_adherence")
if pl2: if pl2:
_add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence") _add_chart_to_story(story, styles, pl2, "Ernährungs-Adherence")
if cfg.get("show_macro_distribution_pair", False) or cfg.get("show_macro_daily_bars", False): if cfg.get("show_macro_daily_bars", False):
dm = bundle.get("daily_macros") or []
pl_dm = _daily_macros_stacked_bar_payload(dm)
if pl_dm:
_add_chart_to_story(story, styles, pl_dm, "Makroverteilung täglich (g) · Fokus Protein")
if cfg.get("show_macro_distribution_pair", False):
pie_pl = _donut_avg_to_pie_payload(bundle.get("donut_avg_pct"))
if pie_pl:
_add_chart_to_story(story, styles, pie_pl, "Ø Makro-Quote (% der Makro-kcal)")
wm = bundle.get("weekly_macro_chart") wm = bundle.get("weekly_macro_chart")
if isinstance(wm, dict) and wm.get("chart_type"): if isinstance(wm, dict) and wm.get("chart_type"):
_add_chart_to_story(story, styles, wm, "Makros (wöchentlich)") meta_wm = wm.get("metadata") or {}
if meta_wm.get("confidence") != "insufficient":
_add_chart_to_story(story, styles, wm, "Wöch. Makro-Verteilung (Anteil %)")
kw = bundle.get("kcal_vs_weight") or {} kw = bundle.get("kcal_vs_weight") or {}
if cfg.get("show_kcal_vs_weight", False) and kw.get("points"): if cfg.get("show_kcal_vs_weight", False) and kw.get("points"):
pts = kw["points"] pl_kw = _kcal_vs_weight_dual_payload(kw)
if pts: if pl_kw:
pl = _line_payload_from_points(pts, "date", "kcal", "kcal") _add_chart_to_story(story, styles, pl_kw, "Kalorien (Ø 7 Tage) vs. Gewicht")
if pl:
_add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit")
story.append(Spacer(1, 2 * mm)) story.append(Spacer(1, 2 * mm))