"""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 = '<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)}') 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'{escape(tip)}') if series == 1: tx = (right + 6) if value >= 0 else (left - 6) anchor = "start" if value >= 0 else "end" parts.append(f'{escape(fmt(value))}') parts.append("") 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''] # 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'') parts.append(f'' f'{escape(fmt(value))}') 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'' f'{escape(short(labels[k], 16))}') 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'') for k, r in enumerate(rows): if is_number(r[i]): tip = f"{labels[k]}: {columns[i]} {fmt(r[i])}" parts.append(f'{escape(tip)}') parts.append("") return "".join(parts)