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:
@@ -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
|
||||
Reference in New Issue
Block a user