172 lines
7.8 KiB
Python
172 lines
7.8 KiB
Python
"""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 <title>. 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 = '<ul class="aiw-chart-legend">' + "".join(
|
||
f'<li><span class="aiw-chart-key aiw-chart-s{n}"></span>{escape(columns[i])}</li>'
|
||
for n, i in enumerate(found.values)) + "</ul>"
|
||
else:
|
||
legend = f'<p class="text-xs text-muted">{escape(columns[found.values[0]])} je {escape(columns[found.label])}</p>'
|
||
return Markup(f'<figure class="aiw-chart">{svg}{legend}</figure>')
|
||
|
||
|
||
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'<svg class="aiw-chart-svg" viewBox="0 0 {width} {height}" role="img" '
|
||
f'aria-label="Balkendiagramm: {escape(", ".join(columns[i] for i in found.values))} je '
|
||
f'{escape(columns[found.label])}">',
|
||
f'<line class="aiw-chart-axis" x1="{zero:.1f}" x2="{zero:.1f}" y1="0" y2="{height - 4}"/>']
|
||
for r, row in enumerate(rows):
|
||
top = 4 + r * band
|
||
name = str(row[found.label]) if row[found.label] is not None else "–"
|
||
parts.append(f'<text class="aiw-chart-label" x="{label_w - 8}" y="{top + (band - 10) / 2 + 4:.1f}" '
|
||
f'text-anchor="end">{escape(short(name, 26))}<title>{escape(name)}</title></text>')
|
||
for n, i in enumerate(found.values):
|
||
value = row[i]
|
||
y = top + n * (bar + gap)
|
||
if not is_number(value):
|
||
continue
|
||
left, right = sorted((zero, x(value)))
|
||
w = max(right - left, 1)
|
||
# Abgerundetes Datenende, gerade an der Grundlinie
|
||
radius = min(4, w / 2, bar / 2)
|
||
if value >= 0:
|
||
d = (f"M{left:.1f},{y} h{w - radius:.1f} a{radius},{radius} 0 0 1 {radius},{radius} "
|
||
f"v{bar - 2 * radius} a{radius},{radius} 0 0 1 -{radius},{radius} h-{w - radius:.1f} z")
|
||
else:
|
||
d = (f"M{right:.1f},{y} h-{w - radius:.1f} a{radius},{radius} 0 0 0 -{radius},{radius} "
|
||
f"v{bar - 2 * radius} a{radius},{radius} 0 0 0 {radius},{radius} h{w - radius:.1f} z")
|
||
tip = f"{name}: {columns[i]} {fmt(value)}"
|
||
parts.append(f'<path class="aiw-chart-bar aiw-chart-s{n}" d="{d}"><title>{escape(tip)}</title></path>')
|
||
if series == 1:
|
||
tx = (right + 6) if value >= 0 else (left - 6)
|
||
anchor = "start" if value >= 0 else "end"
|
||
parts.append(f'<text class="aiw-chart-value" x="{tx:.1f}" y="{y + bar / 2 + 4:.1f}" '
|
||
f'text-anchor="{anchor}">{escape(fmt(value))}</text>')
|
||
parts.append("</svg>")
|
||
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'<svg class="aiw-chart-svg" viewBox="0 0 {width} {height}" role="img" '
|
||
f'aria-label="Liniendiagramm: {escape(", ".join(columns[i] for i in found.values))} je '
|
||
f'{escape(columns[found.label])}">']
|
||
# Haarlinien bei 0 %, 50 %, 100 % der Skala, Beschriftung links
|
||
for share in (0.0, 0.5, 1.0):
|
||
value = low + share * (high - low)
|
||
gy = y(value)
|
||
parts.append(f'<line class="aiw-chart-grid" x1="{left}" x2="{width - right}" y1="{gy:.1f}" y2="{gy:.1f}"/>')
|
||
parts.append(f'<text class="aiw-chart-label" x="{left - 8}" y="{gy + 4:.1f}" text-anchor="end">'
|
||
f'{escape(fmt(value))}</text>')
|
||
labels = [str(r[found.label]) for r in rows]
|
||
for k in sorted({0, n // 2, n - 1}):
|
||
anchor = "start" if k == 0 else "end" if k == n - 1 else "middle"
|
||
parts.append(f'<text class="aiw-chart-label" x="{x(k):.1f}" y="{height - 8}" text-anchor="{anchor}">'
|
||
f'{escape(short(labels[k], 16))}</text>')
|
||
for s, i in enumerate(found.values):
|
||
points = [(x(k), y(r[i])) for k, r in enumerate(rows) if is_number(r[i])]
|
||
if len(points) >= 2:
|
||
path = " ".join(f"{px:.1f},{py:.1f}" for px, py in points)
|
||
parts.append(f'<polyline class="aiw-chart-line aiw-chart-s{s}" points="{path}"/>')
|
||
for k, r in enumerate(rows):
|
||
if is_number(r[i]):
|
||
tip = f"{labels[k]}: {columns[i]} {fmt(r[i])}"
|
||
parts.append(f'<circle class="aiw-chart-dot aiw-chart-s{s}" cx="{x(k):.1f}" cy="{y(r[i]):.1f}" '
|
||
f'r="4"><title>{escape(tip)}</title></circle>')
|
||
parts.append("</svg>")
|
||
return "".join(parts)
|