"""Inline-SVG-Diagramme für den HTML-Report – ohne externe Bibliotheken, aus den geprüften Zahlen der Ergebnistabellen. Formen (siehe dataviz-Richtlinie): Magnitudenvergleich -> waagerechte Balken; Zerlegung eines Zielwerts in Zu-/Abschläge -> Wasserfall. Farben über CSS-Rollen (--viz-*), damit Hell/Dunkel in einer Stelle umschaltet: divergierendes Paar Blau/Rot (validiert), Gesamtwert in neutraler Textfarbe. """ import html BAR_HEIGHT = 22 # <= 24px, Balken füllt das Band nicht ganz BAR_GAP = 10 # Luft zwischen Balken (>= 2px Surface-Gap) LABEL_WIDTH = 150 VALUE_WIDTH = 92 PADDING = 12 RADIUS = 4 # abgerundetes Datenende def _fmt(value: float, unit: str = "") -> str: text = f"{value:,.6g}".replace(",", " ") return f"{text}{unit}" def _text(x, y, content, role="ink", anchor="start", size=12, weight="normal"): return (f'{html.escape(str(content))}') def _bar(x, y, width, height, color_role, negative=False): """Balken mit abgerundetem Datenende und eckigem Ende an der Nulllinie.""" width = max(width, 1.0) r = min(RADIUS, width / 2) if negative: # wächst nach links -> linkes Ende gerundet path = (f"M{x + width:.1f},{y:.1f} H{x + r:.1f} Q{x:.1f},{y:.1f} {x:.1f},{y + r:.1f} " f"V{y + height - r:.1f} Q{x:.1f},{y + height:.1f} {x + r:.1f},{y + height:.1f} " f"H{x + width:.1f} Z") else: path = (f"M{x:.1f},{y:.1f} H{x + width - r:.1f} Q{x + width:.1f},{y:.1f} {x + width:.1f},{y + r:.1f} " f"V{y + height - r:.1f} Q{x + width:.1f},{y + height:.1f} {x + width - r:.1f},{y + height:.1f} " f"H{x:.1f} Z") return f'' def _frame(width, height, title, body) -> str: return (f'' f"{body}") def bar_chart(labels, values, title, unit="") -> str: """Waagerechte Balken für Beiträge je Objekt; negative Werte links der Nulllinie (Blau/Rot).""" if not labels: return "" width = 720 plot_x = LABEL_WIDTH + PADDING plot_width = width - plot_x - VALUE_WIDTH - PADDING height = PADDING * 2 + len(labels) * (BAR_HEIGHT + BAR_GAP) low, high = min(0.0, min(values)), max(0.0, max(values)) span = (high - low) or 1.0 zero_x = plot_x + plot_width * (0 - low) / span parts = [f''] for n, (label, value) in enumerate(zip(labels, values)): y = PADDING + n * (BAR_HEIGHT + BAR_GAP) length = plot_width * abs(value) / span negative = value < 0 x = zero_x - length if negative else zero_x parts.append(f"{html.escape(f'{label}: {_fmt(value, unit)}')}") parts.append(_bar(x, y, length, BAR_HEIGHT, "neg" if negative else "pos", negative)) parts.append("") parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end")) value_x = x - 6 if negative else x + length + 6 parts.append(_text(value_x, y + BAR_HEIGHT * 0.72, _fmt(value, unit), "ink", anchor="end" if negative else "start")) return _frame(width, height, title, "".join(parts)) def waterfall_chart(labels, deltas, total_label, total_value, title, unit="") -> str: """Zielwert-Zerlegung: Zu- und Abschläge als Stufen, Gesamtwert als neutraler Balken ab Null.""" if not labels: return "" width = 720 plot_x = LABEL_WIDTH + PADDING plot_width = width - plot_x - VALUE_WIDTH - PADDING rows = len(labels) + 1 height = PADDING * 2 + rows * (BAR_HEIGHT + BAR_GAP) running, levels = 0.0, [] for delta in deltas: levels.append((running, running + delta)) running += delta bounds = [v for pair in levels for v in pair] + [0.0, total_value] low, high = min(bounds), max(bounds) span = (high - low) or 1.0 scale = lambda v: plot_x + plot_width * (v - low) / span parts = [f''] for n, (label, delta, (start, end)) in enumerate(zip(labels, deltas, levels)): y = PADDING + n * (BAR_HEIGHT + BAR_GAP) x0, x1 = sorted((scale(start), scale(end))) negative = delta < 0 parts.append(f"{html.escape(f'{label}: {_fmt(delta, unit)}')}") parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "neg" if negative else "pos", negative)) parts.append("") parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, label, "muted", anchor="end")) parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(delta, unit), "ink")) if n + 1 < len(labels): # Verbindungslinie zur nächsten Stufe next_y = y + BAR_HEIGHT + BAR_GAP parts.append(f'') y = PADDING + len(labels) * (BAR_HEIGHT + BAR_GAP) x0, x1 = sorted((scale(0), scale(total_value))) parts.append(f"{html.escape(f'{total_label}: {_fmt(total_value, unit)}')}") parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "total", total_value < 0)) parts.append("") parts.append(_text(LABEL_WIDTH, y + BAR_HEIGHT * 0.72, total_label, "ink", anchor="end", weight="600")) parts.append(_text(x1 + 6, y + BAR_HEIGHT * 0.72, _fmt(total_value, unit), "ink", weight="600")) return _frame(width, height, title, "".join(parts)) # --- Graphenzeichnung (Knoten und Bögen) --- NODE_RADIUS = 17 ARC_BOW = 34 # Wölbung je Spannweite, damit parallele Bögen sich nicht überdecken GRAPH_PADDING = 26 def _arrow_marker(name: str, role: str) -> str: return (f'') def graph_chart(nodes, arcs, path=(), title="Graph", unit="") -> str: """Knoten-Bogen-Diagramm mit hervorgehobenem Weg. nodes: Knotennamen in Reihenfolge, arcs: (von, nach, gewicht), path: Knotenfolge des Weges. Knoten liegen auf einer Waagerechten, Bögen wölben sich darüber (Rückwärtsbögen darunter); die Wölbung wächst mit der Spannweite, damit sich parallele Bögen nicht überdecken. """ if not nodes or not arcs or len(nodes) > 14: return "" index = {name: i for i, name in enumerate(nodes)} if any(a[0] not in index or a[1] not in index for a in arcs): return "" weg = {(a, b) for a, b in zip(path, path[1:])} step = max(96, min(150, 820 // max(len(nodes) - 1, 1))) breite = 2 * GRAPH_PADDING + step * (len(nodes) - 1) + 2 * NODE_RADIUS spannen_oben = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] > index[a]] spannen_unten = [abs(index[b] - index[a]) for a, b, _ in arcs if index[b] < index[a]] oben = ARC_BOW * max(spannen_oben, default=0) / 2 + 30 unten = ARC_BOW * max(spannen_unten, default=0) / 2 + 30 mitte = GRAPH_PADDING + oben hoehe = mitte + unten + GRAPH_PADDING def x(name): return GRAPH_PADDING + NODE_RADIUS + step * index[name] teile = [f"{_arrow_marker('pfeil', 'grid')}{_arrow_marker('pfeil-weg', 'pos')}"] for start, ziel, gewicht in arcs: auf_weg = (start, ziel) in weg rolle, marker = ("pos", "pfeil-weg") if auf_weg else ("grid", "pfeil") x1, x2 = x(start), x(ziel) rueckwaerts = index[ziel] < index[start] spanne = abs(index[ziel] - index[start]) bogen = ARC_BOW * spanne * (1 if rueckwaerts else -1) # Kreisrand statt Mittelpunkt als Anfang/Ende, damit die Pfeilspitze sichtbar bleibt richtung = 1 if x2 > x1 else -1 x1 += richtung * NODE_RADIUS x2 -= richtung * NODE_RADIUS steuer_y = mitte + bogen teile.append(f'') teile.append(_text((x1 + x2) / 2, mitte + bogen / 2 + (20 if rueckwaerts else -10), _fmt(gewicht, unit), role=rolle if auf_weg else "ink", anchor="middle", size=12, weight="bold" if auf_weg else "normal")) for name in nodes: auf_weg = name in set(path) teile.append(f'') teile.append(_text(x(name), mitte + 4, name, anchor="middle", size=13, weight="bold" if auf_weg else "normal")) return _frame(breite, hoehe, title, "".join(teile))