diff --git a/finance/app/engine/pie.py b/finance/app/engine/pie.py new file mode 100644 index 0000000..9d23832 --- /dev/null +++ b/finance/app/engine/pie.py @@ -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 diff --git a/finance/tests/test_pie.py b/finance/tests/test_pie.py new file mode 100644 index 0000000..7657d1f --- /dev/null +++ b/finance/tests/test_pie.py @@ -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