Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d232b11411 |
|
|
@ -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
|
ax.plot(
|
||||||
bg = ds.get("backgroundColor")
|
list(x)[: len(y)],
|
||||||
if isinstance(bg, list):
|
y,
|
||||||
cols = [_color_to_rgb(str(c)) for c in bg[: len(yv)]]
|
label=lab,
|
||||||
else:
|
color=col,
|
||||||
cols = [_color_to_rgb(str(bg or "#1D9E75"))] * len(yv)
|
linewidth=1.6,
|
||||||
ax.bar(list(range(len(yv))), yv, label=lab, color=cols[: len(yv)], alpha=0.88)
|
linestyle=ls,
|
||||||
else:
|
marker="o",
|
||||||
ax.plot(
|
markersize=2,
|
||||||
list(x)[: len(y)],
|
)
|
||||||
y,
|
if labels:
|
||||||
label=lab,
|
|
||||||
color=col,
|
|
||||||
linewidth=1.6,
|
|
||||||
marker="o",
|
|
||||||
markersize=2,
|
|
||||||
)
|
|
||||||
if labels and chart_type != "bar":
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user