text_to_solver_program/charts.py

188 lines
9.3 KiB
Python
Raw Normal View History

"""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'<text x="{x:.1f}" y="{y:.1f}" text-anchor="{anchor}" font-size="{size}" '
f'font-weight="{weight}" fill="var(--viz-{role})">{html.escape(str(content))}</text>')
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'<path d="{path}" fill="var(--viz-{color_role})"/>'
def _frame(width, height, title, body) -> str:
return (f'<svg class="viz" viewBox="0 0 {width} {height:.0f}" width="100%" height="{height:.0f}" '
f'role="img" aria-label="{html.escape(title)}" xmlns="http://www.w3.org/2000/svg">'
f"{body}</svg>")
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'<line x1="{zero_x:.1f}" y1="{PADDING - 4}" x2="{zero_x:.1f}" y2="{height - PADDING + 4}" '
f'stroke="var(--viz-grid)" stroke-width="1"/>']
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"<g><title>{html.escape(f'{label}: {_fmt(value, unit)}')}</title>")
parts.append(_bar(x, y, length, BAR_HEIGHT, "neg" if negative else "pos", negative))
parts.append("</g>")
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'<line x1="{scale(0):.1f}" y1="{PADDING - 4}" x2="{scale(0):.1f}" y2="{height - PADDING + 4}" '
f'stroke="var(--viz-grid)" stroke-width="1"/>']
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"<g><title>{html.escape(f'{label}: {_fmt(delta, unit)}')}</title>")
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "neg" if negative else "pos", negative))
parts.append("</g>")
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'<line x1="{scale(end):.1f}" y1="{y + BAR_HEIGHT:.1f}" x2="{scale(end):.1f}" '
f'y2="{next_y:.1f}" stroke="var(--viz-grid)" stroke-width="1" stroke-dasharray="2 2"/>')
y = PADDING + len(labels) * (BAR_HEIGHT + BAR_GAP)
x0, x1 = sorted((scale(0), scale(total_value)))
parts.append(f"<g><title>{html.escape(f'{total_label}: {_fmt(total_value, unit)}')}</title>")
parts.append(_bar(x0, y, x1 - x0, BAR_HEIGHT, "total", total_value < 0))
parts.append("</g>")
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'<marker id="{name}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" '
f'orient="auto-start-reverse"><path d="M0,0 L10,5 L0,10 z" fill="var(--viz-{role})"/></marker>')
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"<defs>{_arrow_marker('pfeil', 'grid')}{_arrow_marker('pfeil-weg', 'pos')}</defs>"]
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'<path d="M{x1:.1f},{mitte:.1f} Q{(x1 + x2) / 2:.1f},{steuer_y:.1f} {x2:.1f},{mitte:.1f}" '
f'fill="none" stroke="var(--viz-{rolle})" stroke-width="{3 if auf_weg else 1.5}" '
f'marker-end="url(#{marker})"/>')
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'<circle cx="{x(name):.1f}" cy="{mitte:.1f}" r="{NODE_RADIUS}" '
f'fill="var(--viz-surface)" stroke="var(--viz-{"pos" if auf_weg else "grid"})" '
f'stroke-width="{3 if auf_weg else 1.5}"/>')
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))