Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d232b11411 |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user