467 Stellen. Nicht durch einen anderen Strich ersetzt, sondern durch das
Satzzeichen, das an der jeweiligen Stelle richtig ist: Doppelpunkt, wo eine
Erlaeuterung folgt; Komma, wo der Satz weiterlaeuft; Punkt, wo zwei
Hauptsaetze aneinanderstiessen. Ein Halbgeviertstrich waere nur derselbe
Griff mit kuerzerem Strich gewesen.
Regelbasiert ersetzt und danach nachgeprueft, weil eine Regel allein nicht
reicht. Gefunden und einzeln korrigiert:
- Eingeschobene Saetze mit Strichpaar. Nur der oeffnende Strich passte ins
Muster, der schliessende blieb als "—," stehen. Drei Stellen, jetzt mit
Klammern oder als eigener Satz.
- Doppelte Doppelpunkte, wo beide Striche eines Paars zu Doppelpunkten wurden
("Globale Regeln: Schrift, Groessen, Abstaende: kommen aus HiveGrid").
Sieben Stellen in Doku und Oberflaeche, jetzt in Klammern.
- Pfade, denen ein Komma statt eines Doppelpunkts folgte.
- Ausgerichtete Kommentartabellen, in denen der Strich eine Spalte war.
Nicht angefasst: drei Geviertstriche, die keine Prosa sind, sondern
Tabellenplatzhalter fuer "kein Wert" (Trichter, Auftraege, Kunden). Dort ist
der Strich die Angabe selbst; ihn zu entfernen liesse leere Zellen zurueck.
tsc, lint, build, verify:carbon und verify:layout laufen sauber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
125 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|