"""Einfache Diagramme für Abfrageergebnisse als Inline-SVG, serverseitig erzeugt.
Kein JavaScript und keine Inline-Styles (strenge CSP): Farben kommen über CSS-Klassen
aus app.css, Tooltips über
. Die Tabelle steht immer daneben, das Diagramm ist
eine zusätzliche Sicht. Gezeigt wird es nur, wenn das Ergebnis passt:
eine Beschriftungsspalte und eine bis drei Zahlenspalten, höchstens MAX_ROWS Zeilen.
- Zeitreihe (Beschriftung sieht aus wie ein Datum, mindestens 3 Punkte): Linien
- sonst: liegende Balken, eine Zeile je Kategorie (lange Beschriftungen bleiben lesbar)
"""
import re
from dataclasses import dataclass
from typing import Any
from markupsafe import Markup, escape
MAX_ROWS = 40
MAX_SERIES = 3 # die ersten drei Farben sind für alle Paare unterscheidbar
DATE = re.compile(r"^\d{4}-\d{2}(-\d{2})?([T ]\d{2}:\d{2}.*)?$")
@dataclass(frozen=True)
class Plan:
kind: str # bar | line
label: int # Index der Beschriftungsspalte
values: list[int] # Indizes der Zahlenspalten
def is_number(value: Any) -> bool:
return isinstance(value, int | float) and not isinstance(value, bool)
def plan(columns: list[str], rows: list[list[Any]]) -> Plan | None:
if not rows or len(rows) > MAX_ROWS or len(columns) < 2:
return None
numeric = [i for i in range(len(columns)) if all(is_number(r[i]) or r[i] is None for r in rows)
and any(is_number(r[i]) for r in rows)]
text = [i for i in range(len(columns)) if i not in numeric]
if text:
label = text[0]
elif len(numeric) >= 2:
label = numeric[0] # z. B. Jahr, Umsatz
else:
return None
values = [i for i in numeric if i != label][:MAX_SERIES]
if not values:
return None
dates = all(isinstance(r[label], str) and DATE.match(r[label]) for r in rows)
years = all(isinstance(r[label], int) and 1900 <= r[label] <= 2200 for r in rows)
kind = "line" if (dates or years) and len(rows) >= 3 else "bar"
return Plan(kind, label, values)
def fmt(value: Any) -> str:
if value is None:
return "–"
if isinstance(value, float) and not value.is_integer():
text = f"{value:,.2f}"
else:
text = f"{int(value):,}"
return text.replace(",", "X").replace(".", ",").replace("X", ".")
def short(text: str, chars: int) -> str:
return text if len(text) <= chars else text[:chars - 1] + "…"
def render(columns: list[str], rows: list[list[Any]]) -> Markup | None:
found = plan(columns, rows)
if found is None:
return None
if found.kind == "line":
svg = line_chart(columns, rows, found)
else:
svg = bar_chart(columns, rows, found)
legend = ""
if len(found.values) > 1:
legend = '
' + "".join(
f'
{escape(columns[i])}
'
for n, i in enumerate(found.values)) + "
"
else:
legend = f'
{escape(columns[found.values[0]])} je {escape(columns[found.label])}
'
return Markup(f'{svg}{legend}')
def scale(rows: list[list[Any]], values: list[int]) -> tuple[float, float]:
numbers = [r[i] for r in rows for i in values if is_number(r[i])]
low, high = min(0.0, min(numbers)), max(0.0, max(numbers))
return low, (high if high != low else low + 1)
def bar_chart(columns: list[str], rows: list[list[Any]], found: Plan) -> str:
series = len(found.values)
bar, gap = (18 if series == 1 else 10), 2
band = series * (bar + gap) + 10
label_w, value_w, width = 170, 90, 640
plot_w = width - label_w - value_w
height = band * len(rows) + 8
low, high = scale(rows, found.values)
x = lambda v: label_w + (v - low) / (high - low) * plot_w
zero = x(0)
parts = [f'")
return "".join(parts)
def line_chart(columns: list[str], rows: list[list[Any]], found: Plan) -> str:
width, height = 640, 260
left, right, top, bottom = 64, 16, 12, 28
plot_w, plot_h = width - left - right, height - top - bottom
low, high = scale(rows, found.values)
n = len(rows)
x = lambda k: left + (k / (n - 1)) * plot_w
y = lambda v: top + (1 - (v - low) / (high - low)) * plot_h
parts = [f'")
return "".join(parts)