Files
Dashboard/features/dashboard/components/charts/Donut.tsx
T
dejanandClaude Opus 5 351a26efa4 fix(layout): kollidierenden und überlaufenden Text beseitigen
Gemeldeter Fehler: In der Ringlegende klebten Bezeichnung und Wert aneinander
("Gestellt189,1 T€"). Ursache war eine eigene Regression — beim Beheben der
abgeschnittenen Legendennamen war das truncate entfallen, ohne Ersatz. Damit
schrumpft die Namensspalte durch min-w-0 + grow auf null Breite, und der Text
läuft sichtbar über seinen Kasten hinaus:

  1280 px  Kastenbreite 0 px, Text 43 px darüber hinaus
  1440 px  Kastenbreite 25 px, Text 18 px darüber hinaus
  1600 px  unauffällig

Genau deshalb war es auf den Screenshots nicht zu sehen: Der Fehler tritt nur in
bestimmten Fensterbreiten auf.

Neu: scripts/verify-layout.mjs + npm run verify:layout. Lädt alle sechs Seiten in
sieben Breiten (1024–1920) und misst jede Textzeile über Range.getClientRects.
Gemessen wird auf Textzeilen, nicht auf Elementkästen — sonst melden benachbarte
Tabellenzellen mit Innenabstand falsch positiv. Mindestabstand 8 px = Carbon
$spacing-03. Der Lauf fand 110 Fehler; alle behoben:

- Ringlegende: Container-Query statt fester Zweispaltigkeit. Legende sitzt rechts
  vom Ring, wenn Platz ist, sonst darunter — Carbon: "positioned to the right of
  the chart when space is plentiful". Namen brechen um statt überzulaufen.
- Auslastungs-Achse: Beschriftungsdichte staffelt sich über drei Container-Stufen,
  vorher überlappten die Zahlen in schmalen Karten um bis zu 16 px.
- Handlungsbedarf und Kennzahl-Labels: break-words statt Überlauf.
- Kartentitel und -untertitel: break-words, weil Dateipfade keine Leerzeichen
  zum Umbrechen haben.

Dokumentiert als DESIGN-SYSTEM.md §9 mit Begründung, warum das ein gemessener
Prüfer sein muss und keine Sichtprüfung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 19:32:55 +02:00

125 lines
4.8 KiB
TypeScript

"use client";
import React from "react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { ChartTooltip } from "./ChartTooltip";
import { ChartLeer } from "./ChartLeer";
import { DONUT, farbeFuer, gruppiereRest } from "../../utils/chartTheme";
/**
* Donut für Anteile.
*
* Regeln aus der Planung + Carbon:
* - maximal 5 Segmente, Rest als "Sonstige" (gruppiereRest)
* - größtes Segment startet oben, dann im Uhrzeigersinn absteigend
* - Gesamtwert steht in der Mitte, sonst muss man die Segmente addieren
* - Legende rechts mit Wert — Carbon: „positioned to the right of the chart
* when space is plentiful". Ist der Platz nicht reichlich, rutscht sie unter
* den Ring, statt Namen und Werte ineinanderlaufen zu lassen. Gesteuert über
* eine Container-Query, weil dieselbe Karte mal ein Drittel und mal die halbe
* Seitenbreite einnimmt — der Viewport weiß davon nichts.
* - Klick auf einen Legendeneintrag hebt das Segment hervor
*/
export function Donut({
daten,
formatWert,
hoehe = 220,
mitteLabel = "Gesamt",
maxSegmente,
/**
* Format der Ringmitte. Getrennt vom Legendenformat, weil die Mitte eine
* Schlagzahl ist: Steht dieselbe Summe als Kennzahl auf der Seite, muss sie
* gleich geschrieben sein — "285,3 T€" neben "285.318 €" liest sich wie
* zwei verschiedene Zahlen.
*/
formatMitte,
}: {
daten: { name: string; value: number }[];
formatWert: (wert: number) => string;
hoehe?: number;
mitteLabel?: string;
maxSegmente?: number;
formatMitte?: (wert: number) => string;
}) {
const [aktiv, setAktiv] = React.useState<string | null>(null);
const segmente = gruppiereRest(daten.filter((d) => d.value > 0), maxSegmente);
if (!segmente.length) return <ChartLeer hoehe={hoehe} />;
const gesamt = segmente.reduce((summe, s) => summe + s.value, 0);
return (
<div className="@container">
<div className="flex flex-col items-center gap-4 @md:grid @md:grid-cols-[168px_minmax(0,1fr)] @md:items-center @md:gap-5">
<div className="relative w-full max-w-[220px] @md:max-w-none" style={{ height: hoehe }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={segmente}
dataKey="value"
nameKey="name"
innerRadius={DONUT.innenRadius}
outerRadius={DONUT.aussenRadius}
paddingAngle={DONUT.segmentAbstand}
startAngle={90}
endAngle={-270}
stroke="#ffffff"
strokeWidth={2}
isAnimationActive={false}
>
{segmente.map((segment, index) => (
<Cell
key={segment.name}
fill={farbeFuer(segment.name, index)}
opacity={aktiv && aktiv !== segment.name ? 0.35 : 1}
/>
))}
</Pie>
<Tooltip content={(props) => <ChartTooltip {...props} formatWert={formatWert} />} />
</PieChart>
</ResponsiveContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-[11px] uppercase tracking-[0.06em] text-text-neutral-tertiary">
{mitteLabel}
</span>
<span className="text-[15px] font-semibold tabular-nums text-text-neutral-primary">
{(formatMitte ?? formatWert)(gesamt)}
</span>
</div>
</div>
<ul className="flex w-full flex-col gap-1.5">
{segmente.map((segment, index) => {
const anteil = gesamt > 0 ? (segment.value / gesamt) * 100 : 0;
return (
<li key={segment.name}>
<button
onMouseEnter={() => setAktiv(segment.name)}
onMouseLeave={() => setAktiv(null)}
onClick={() => setAktiv((wert) => (wert === segment.name ? null : segment.name))}
className="flex w-full items-start gap-2 rounded-sm px-1 py-1 text-left hover:bg-neutral-50"
>
<span
className="mt-1 h-2.5 w-2.5 flex-none rounded-sm"
style={{ background: farbeFuer(segment.name, index) }}
/>
<span className="min-w-0 grow break-words text-[12px] leading-snug text-text-neutral-secondary">
{segment.name}
</span>
<span className="flex-none whitespace-nowrap text-[12px] tabular-nums text-text-neutral-primary">
<span className="font-semibold">{formatWert(segment.value)}</span>
<span className="ml-1.5 text-text-neutral-tertiary">
{anteil.toFixed(0)} %
</span>
</span>
</button>
</li>
);
})}
</ul>
</div>
</div>
);
}