bifroest/app/core/charts.py
2026-10-27 08:34:17 +01:00

172 lines
7.8 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)