diff --git a/backend/report_chart_plotting.py b/backend/report_chart_plotting.py index 2b709de..041a275 100644 --- a/backend/report_chart_plotting.py +++ b/backend/report_chart_plotting.py @@ -10,8 +10,24 @@ 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 @@ -43,7 +59,87 @@ def chart_payload_to_png(payload: dict[str, Any], fig_width_in: float = 6.2, fig else: 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 [] for i, ds in enumerate(datasets): 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 lab = ds.get("label") or f"Serie {i + 1}" col = _color_to_rgb(str(ds.get("borderColor") or ds.get("backgroundColor") or "#1D9E75")) - if chart_type == "bar": - 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( - list(x)[: len(y)], - y, - label=lab, - color=col, - linewidth=1.6, - marker="o", - markersize=2, - ) - if labels and chart_type != "bar": + 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) - 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.grid(True, alpha=0.25) ax.set_xmargin(0.02) diff --git a/backend/report_viz_bundle_pdf.py b/backend/report_viz_bundle_pdf.py index b020ff4..e92a58e 100644 --- a/backend/report_viz_bundle_pdf.py +++ b/backend/report_viz_bundle_pdf.py @@ -122,6 +122,15 @@ def _weight_series_payload(bundle_weight: dict[str, Any]) -> dict[str, Any] | No "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"}} @@ -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: days = int(cfg.get("chart_days") or 30) 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") if pl2: _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") 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 {} if cfg.get("show_kcal_vs_weight", False) and kw.get("points"): - pts = kw["points"] - if pts: - pl = _line_payload_from_points(pts, "date", "kcal", "kcal") - if pl: - _add_chart_to_story(story, styles, pl, "Kalorien vs. Zeit") + pl_kw = _kcal_vs_weight_dual_payload(kw) + if pl_kw: + _add_chart_to_story(story, styles, pl_kw, "Kalorien (Ø 7 Tage) vs. Gewicht") story.append(Spacer(1, 2 * mm))