- Introduced the `viz_bundle` block type to the report profile schema, allowing for the inclusion of bundled visualizations in PDF reports. - Updated the `build_structured_report_pdf` function to handle `VizBundleBlock` and append its content to the report. - Enhanced the report catalog API to include details for the new `viz_bundle` block type. - Added configuration editors for various visualization bundles in the frontend settings page. - Updated tests to validate the new `viz_bundle` functionality and ensure proper handling of report profiles. - Bumped application version to reflect these enhancements.
92 lines
3.4 KiB
Python
92 lines
3.4 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 _color_to_rgb(hex_or_rgba: str) -> tuple[float, float, float]:
|
|
s = (hex_or_rgba or "#333333").strip()
|
|
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 in ("line", "bar", "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"))
|
|
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":
|
|
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)
|
|
|
|
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()
|