feat: Engine pie – Tortensegmente mit Übrige-Regel und SVG-Pfaden (A13-T3)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:13:11 +02:00
co-authored by Claude Opus 5.5
parent 7555d8e6a4
commit 125d370497
2 changed files with 151 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
"""Tortendiagramm-Segmente (Ausbaustufe 13), rein.
Beträge und Anteile bleiben Decimal; nur die Geometrie (Winkel, SVG-
Koordinaten im Einheitskreis) rechnet mit float. Start 12 Uhr, im
Uhrzeigersinn (SVG-y zeigt nach unten).
"""
import math
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
UEBRIGE = "Übrige"
MIN_SHARE = Decimal("0.03")
MAX_NAMED = 8
PCT = Decimal("0.1")
@dataclass(frozen=True)
class Slice:
label: str
value: Decimal
share: Decimal # Prozent der Tortenbasis, eine Nachkommastelle
path: str | None # SVG-d; None = Vollkreis (einziges Segment)
color: str # "s1".."s8", "uebrige", "frei"
merged: bool
members: tuple[str, ...]
start: float # Anteil 0..1
end: float
def _point(fraction: float) -> tuple[float, float]:
angle = 2 * math.pi * fraction - math.pi / 2
return math.cos(angle), math.sin(angle)
def _arc(start: float, end: float) -> str:
x0, y0 = _point(start)
x1, y1 = _point(end)
large = 1 if end - start > 0.5 else 0
return f"M 0 0 L {x0:.4f} {y0:.4f} A 1 1 0 {large} 1 {x1:.4f} {y1:.4f} Z"
def pie_slices(values: list[tuple[str, Decimal]], rest: Decimal | None = None,
rest_label: str = "", min_share: Decimal = MIN_SHARE,
max_named: int = MAX_NAMED) -> list[Slice]:
items = sorted(((l, v) for l, v in values if v > 0), key=lambda lv: (-lv[1], lv[0]))
rest = rest if rest is not None and rest > 0 else None
total = sum((v for _, v in items), Decimal("0")) + (rest or Decimal("0"))
if total <= 0:
return []
named: list[tuple[str, Decimal]] = []
small: list[tuple[str, Decimal]] = []
for label, value in items:
if len(named) < max_named and value / total >= min_share:
named.append((label, value))
else:
small.append((label, value))
parts = [(l, v, f"s{i}", False, ()) for i, (l, v) in enumerate(named, 1)]
if len(small) == 1:
parts.append((small[0][0], small[0][1], "uebrige", False, ()))
elif small:
parts.append((UEBRIGE, sum((v for _, v in small), Decimal("0")), "uebrige",
True, tuple(l for l, _ in small)))
if rest is not None:
parts.append((rest_label, rest, "frei", False, ()))
slices: list[Slice] = []
acc = Decimal("0")
for label, value, color, merged, members in parts:
start = float(acc / total)
acc += value
end = float(acc / total)
slices.append(Slice(label, value,
(value * 100 / total).quantize(PCT, ROUND_HALF_UP),
None if len(parts) == 1 else _arc(start, end),
color, merged, members, start, end))
return slices
+76
View File
@@ -0,0 +1,76 @@
from decimal import Decimal
import pytest
from app.engine.pie import UEBRIGE, pie_slices
D = Decimal
def test_leer_und_nullwerte():
assert pie_slices([]) == []
assert pie_slices([("A", D("0")), ("B", D("-5"))]) == []
def test_segmente_lueckenlos_und_anteile():
sl = pie_slices([("C", D("20")), ("A", D("50")), ("B", D("30"))])
assert [s.label for s in sl] == ["A", "B", "C"]
assert [s.color for s in sl] == ["s1", "s2", "s3"]
assert [s.share for s in sl] == [D("50.0"), D("30.0"), D("20.0")]
assert sl[0].start == 0.0 and sl[-1].end == pytest.approx(1.0)
for a, b in zip(sl, sl[1:]):
assert a.end == pytest.approx(b.start)
assert sl[0].path.startswith("M 0 0 L 0.0000 -1.0000 A 1 1 0 0 1 ")
def test_large_arc_flag():
a, b = pie_slices([("A", D("70")), ("B", D("30"))])
assert " A 1 1 0 1 1 " in a.path
assert " A 1 1 0 0 1 " in b.path
def test_einzelsegment_ist_vollkreis():
(s,) = pie_slices([("A", D("12.34"))])
assert s.path is None and s.share == D("100.0") and s.color == "s1"
def test_kleine_werte_zu_uebrige():
sl = pie_slices([("A", D("95")), ("B", D("2")), ("C", D("2")), ("D", D("1"))])
assert [s.label for s in sl] == ["A", UEBRIGE]
u = sl[1]
assert (u.value, u.color, u.merged, u.members) == (D("5"), "uebrige", True, ("B", "C", "D"))
def test_einzelner_kleiner_wert_behaelt_namen():
sl = pie_slices([("A", D("98")), ("B", D("2"))])
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("B", "uebrige", False)]
def test_hoechstens_acht_benannte_segmente():
sl = pie_slices([(f"K{i}", D("10")) for i in range(10)], rest=D("5"),
rest_label="Frei verfügbar")
assert [s.color for s in sl] == ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8",
"uebrige", "frei"]
assert sl[-2].merged and sl[-2].members == ("K8", "K9")
def test_acht_kategorien_ohne_uebrige():
sl = pie_slices([(f"K{i}", D("10")) for i in range(8)])
assert [s.color for s in sl] == [f"s{i}" for i in range(1, 9)]
assert not any(s.merged for s in sl)
def test_rest_wird_nie_zusammengefasst():
sl = pie_slices([("A", D("99.5"))], rest=D("0.5"), rest_label="Frei verfügbar")
assert [(s.label, s.color, s.merged) for s in sl] == [("A", "s1", False), ("Frei verfügbar", "frei", False)]
assert sl[1].share == D("0.5")
def test_nur_rest_ist_vollkreis():
(s,) = pie_slices([], rest=D("100"), rest_label="Frei verfügbar")
assert (s.label, s.color, s.path) == ("Frei verfügbar", "frei", None)
def test_rest_none_oder_null_ignoriert():
assert len(pie_slices([("A", D("1"))], rest=None)) == 1
assert len(pie_slices([("A", D("1"))], rest=D("0"), rest_label="X")) == 1