Der Tooltip legte sich ueber die Ringmitte: "Klima & Lueftung 268,0 T€" und "GESAMT 2,27 Mio. €" uebereinander, beides unlesbar. Ein Ring von 168 px hat keinen Platz fuer ein schwebendes Feld daneben, egal wohin man es schiebt. Der Tooltip faellt deshalb ganz weg. Beim Ueberfahren zeigt die Mitte das beruehrte Segment mit Name, Wert und Anteil, im Ruhezustand weiter den Gesamtwert. Die Flaeche ist ohnehin belegt und die einzige, die gross genug ist; ausserdem wiederholte der Tooltip nur, was die Legende daneben schon zeigt. Das ist auch die Carbon-Loesung. Damit das Ueberfahren des Rings selbst wirkt (bisher setzte nur die Legende den Zustand), liegen die Handler jetzt auch am Pie. Zur Breite der Beschriftung: erst am Container bemessen, dann an der Hoehe, beides falsch. Der Ring ist so gross wie die **kleinere** der beiden Seiten. Jetzt begrenzt min() beide. Der Wert ist nachgemessen statt geschaetzt: Bei 200 px Ringhoehe ist der Innenkreis 124 px breit und "KLIMA & LÜFTUNG" in einer Zeile 109 px. Das passt rechnerisch und sieht trotzdem gedraengt aus, weil der Innenkreis ein Kreis ist und die Beschriftung ueber der Mittellinie steht, wo er schmaler wird. Bei 48 % bricht der Name auf zwei Zeilen um und bekommt Luft. Das Abblenden der uebrigen Segmente bleibt. Im Trichter war dasselbe Mittel falsch, hier ist es richtig, und der Unterschied steht als Kommentar dabei: Dort kodiert die Balkenlaenge eine Menge, und Deckkraft liest sich als "weniger". Hier kodiert die Farbe nur eine Kategorie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
|
|
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
|
|
*
|
|
* **Kein Tooltip.** Der Ring beantwortet das Überfahren in seiner eigenen
|
|
* Mitte: Statt Gesamtwert steht dort das berührte Segment mit Wert und Anteil.
|
|
*
|
|
* Ein schwebender Tooltip war hier zweimal falsch. Er legte sich über die
|
|
* Mitte und machte beide Angaben unlesbar, weil ein Ring von 168 px keinen
|
|
* Platz daneben hat. Und er wiederholte nur, was die Legende daneben schon
|
|
* zeigt. Die Mitte ist im Ruhezustand ohnehin belegt und die einzige Fläche,
|
|
* die groß genug ist.
|
|
*/
|
|
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);
|
|
const anteilVon = (wert: number) => (gesamt > 0 ? (wert / gesamt) * 100 : 0).toFixed(0);
|
|
const hervorgehoben = segmente.find((s) => s.name === aktiv) ?? null;
|
|
|
|
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}
|
|
onMouseEnter={(eintrag: { name?: string }) => setAktiv(eintrag?.name ?? null)}
|
|
onMouseLeave={() => setAktiv(null)}
|
|
>
|
|
{segmente.map((segment, index) => (
|
|
<Cell
|
|
key={segment.name}
|
|
fill={farbeFuer(segment.name, index)}
|
|
/* Abblenden der übrigen Segmente ist hier richtig, im
|
|
Trichter war es falsch. Der Unterschied: Dort kodiert die
|
|
Balkenlänge eine Menge, und Deckkraft liest sich in der
|
|
Sprache dieses Diagramms als „weniger". Hier kodiert die
|
|
Farbe nur eine Kategorie, sie trägt keine Größe. Und der
|
|
Ring hat keine Zeile, die man stattdessen hinterlegen
|
|
könnte: Das Segment ist das Element. */
|
|
opacity={aktiv && aktiv !== segment.name ? 0.35 : 1}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
|
|
{/* Die Textbreite muss am Ringdurchmesser hängen, nicht am Container.
|
|
Der Ring ist so groß wie die **kleinere** der beiden Seiten, und die
|
|
Karte kann sehr viel breiter als hoch sein. Deshalb begrenzt
|
|
`min()` beides: 56 % der Breite und 56 % der bekannten Höhe.
|
|
|
|
Warum 48 % und nicht 62 % wie der Innenradius: Nachgemessen ist der
|
|
Innenkreis bei 200 px Ringhöhe 124 px breit, „KLIMA & LÜFTUNG" in
|
|
einer Zeile 109 px. Das passt rechnerisch und sieht trotzdem
|
|
gedrängt aus, weil der Innenkreis ein Kreis ist und die
|
|
Beschriftung über der Mittellinie steht, wo er schmaler wird. Bei
|
|
48 % bricht der Name auf zwei Zeilen um und bekommt Luft. */}
|
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center text-center">
|
|
<span
|
|
className="break-words text-[11px] uppercase leading-tight tracking-[0.06em] text-text-neutral-tertiary"
|
|
style={{ maxWidth: `min(48%, ${Math.round(hoehe * 0.48)}px)` }}
|
|
>
|
|
{hervorgehoben ? hervorgehoben.name : mitteLabel}
|
|
</span>
|
|
<span className="text-[15px] font-semibold tabular-nums text-text-neutral-primary">
|
|
{hervorgehoben
|
|
? formatWert(hervorgehoben.value)
|
|
: (formatMitte ?? formatWert)(gesamt)}
|
|
</span>
|
|
{hervorgehoben ? (
|
|
<span className="text-[11px] tabular-nums text-text-neutral-tertiary">
|
|
{anteilVon(hervorgehoben.value)} %
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<ul className="flex w-full flex-col gap-1.5">
|
|
{segmente.map((segment, index) => {
|
|
const anteil = anteilVon(segment.value);
|
|
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} %
|
|
</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|