Ringdiagramm
Anteile als Ring oder halber Tacho, die Summe in der Mitte, Legende mit Werten und Anteilen.
Eine von 7 Components in Charts
Variants
Details
Was Ringdiagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Charts
- Bereich
- Elements
- Variants
- 3
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/donut-chart.tsx
- Bringt mit
- utilskitchart
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle DonutChartProps aus components/ui/donut-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von ChartA11y an.
dataPflichtDonutSlice[]Up to four slices read best. Fold the rest into "Other".
formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }
variantoptional'donut' | 'half'donut: full ring. half: a gauge from left to right.
maxoptionalnumberThe whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum.
centerLabeloptionalstringSmall text in the middle, under the value. Default: "Total".
centerValueoptionalstringBig text in the middle. Default: the formatted sum.
sizeoptionalnumberDiameter in px. It shrinks with the container.
thicknessoptionalnumberRing width in px
legendoptional'side' | 'bottom' | falseLegend next to the ring (side), below it (bottom) or none
percentoptionalbooleanShare in percent next to each legend value
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 147 Zeilen
'use client';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartLegend, CountUp, ChartRoot, ChartSurface, ChartTable, arcPath, numberFormatter, seriesColor, useActive, useEntrance, useIntlLocale, type ChartA11y } from '@/components/ui/chart';
export interface DonutSlice {
label: string;
value: number;
/** Any CSS color. Default: the slot color in order (accent, grey, two accent tones). */
color?: string;
}
export interface DonutChartProps extends ChartA11y {
/** Up to four slices read best. Fold the rest into "Other". */
data: DonutSlice[];
/** Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 } */
format?: Intl.NumberFormatOptions;
/** donut: full ring. half: a gauge from left to right. */
variant?: 'donut' | 'half';
/** The whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum. */
max?: number;
/** Small text in the middle, under the value. Default: "Total". */
centerLabel?: string;
/** Big text in the middle. Default: the formatted sum. */
centerValue?: string;
/** Diameter in px. It shrinks with the container. */
size?: number;
/** Ring width in px */
thickness?: number;
/** Legend next to the ring (side), below it (bottom) or none */
legend?: 'side' | 'bottom' | false;
/** Share in percent next to each legend value */
percent?: boolean;
className?: string;
}
/** Time the whole ring takes to run on, in ms. The slices run one after another, each as long as its share, so the ring reads as one stroke. */
const RING_MS = 900;
/**
* Part to whole as a ring or a half gauge, with the total in the middle.
* <DonutChart label="Revenue by channel" data={[{ label: 'Online', value: 254310 }, …]} format={{ style: 'currency', currency: 'EUR' }} />
*/
export function DonutChart({ data, format, variant = 'donut', max, centerLabel, centerValue, size = 200, thickness, legend = 'side', percent = true, label, summary, labels, animate = true, className }: DonutChartProps) {
const intl = useIntlLocale();
const t = useTexts(CHART_TEXTS, labels);
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(data.length);
const half = variant === 'half';
const sum = data.reduce((a, d) => a + Math.max(0, d.value), 0);
const whole = Math.max(max ?? sum, sum) || 1;
const sweep = half ? 180 : 360;
const start = half ? -90 : 0;
const w = thickness ?? round(size * (half ? 0.12 : 0.11), 0);
const r = round(size / 2 - w / 2 - 3);
const cx = round(size / 2);
const cy = half ? round(size / 2) : cx;
const height = half ? round(size / 2 + w / 2 + 3) : size;
// The 2px surface gap at the middle of the ring, as an angle
const gapDeg = round((2 / r) * (180 / Math.PI), 3);
const showTrack = half || sum < whole;
let acc = 0;
const arcs = data.map((d, i) => {
const a = start + (acc / whole) * sweep;
acc += Math.max(0, d.value);
const b = start + (acc / whole) * sweep;
const gap = data.length > 1 || showTrack ? gapDeg : 0;
return { a: round(a + gap / 2, 3), b: round(b - gap / 2, 3), from: (a - start) / sweep, part: Math.max(0, d.value) / whole, d, i, color: seriesColor(i, d.color) };
});
// Whole percents that add up to 100 (largest remainder), so the legend never shows 101 %
const exact = data.map((d) => (Math.max(0, d.value) / whole) * 100);
const whole100 = exact.map(Math.floor);
const missing = Math.round(exact.reduce((x, y) => x + y, 0)) - whole100.reduce((x, y) => x + y, 0);
exact
.map((v, i) => [v - Math.floor(v), i] as const)
.sort((p, q) => q[0] - p[0])
.slice(0, Math.max(0, missing))
.forEach(([, i]) => (whole100[i] += 1));
const shareAt = (i: number) => pct(whole100[i] / 100);
const act = active !== null ? data[active] : null;
// The total counts up with the ring, a hovered slice shows its own value at once
const mid: React.ReactNode = act ? fmt(act.value) : centerValue ?? <CountUp value={sum} format={fmt} anim={anim} duration={900} />;
const sub = act ? act.label : centerLabel ?? t.total;
const live = act ? `${act.label}, ${fmt(act.value)}, ${shareAt(active!)}` : '';
const auto = data.map((d, i) => `${d.label} ${shareAt(i)}`).join(', ');
return (
<ChartRoot anim={anim} rootRef={rootRef} className={className}>
<div className={cn('flex flex-col items-center gap-6', legend === 'side' && 'sm:flex-row sm:gap-10')}>
<ChartSurface
label={label}
summary={summary ?? auto}
labels={labels}
live={live}
table={<ChartTable caption={label} head={['', label, '%']} rows={data.map((d, i) => [d.label, fmt(d.value), shareAt(i)])} />}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
className={cn('w-full shrink-0', half ? 'rounded-t-full' : 'rounded-full')}
style={{ maxWidth: size }}
>
<div className="relative mx-auto" style={{ maxWidth: size }}>
<svg viewBox={`0 0 ${size} ${height}`} width="100%" aria-hidden className="block overflow-visible">
{showTrack && <path d={arcPath(cx, cy, r, start, start + sweep)} fill="none" stroke="var(--kitch-empty)" strokeWidth={w} />}
{arcs.map(({ a, b, from, part, d, i, color }) =>
b > a ? (
<path
key={d.label}
d={arcPath(cx, cy, r, a, b)}
fill="none"
stroke={color}
strokeWidth={active === i ? w + 6 : w}
pathLength={1}
opacity={active !== null && active !== i ? 0.25 : 1}
onPointerEnter={() => setActive(i)}
onPointerDown={() => setActive(i)}
className={cn('transition-[stroke-width,opacity] duration-200 motion-reduce:transition-none', anim && 'kitch-draw')}
style={anim ? ({ '--kitch-d': `${round(from * RING_MS, 0)}ms`, '--kitch-t': `${round(Math.max(160, part * RING_MS), 0)}ms`, '--kitch-e': i === arcs.length - 1 ? 'var(--ease-kit)' : 'linear' } as React.CSSProperties) : undefined}
/>
) : null,
)}
</svg>
<div className={cn('pointer-events-none absolute inset-x-0 flex flex-col items-center text-center', half ? 'bottom-0' : 'inset-y-0 justify-center')}>
<span className={cn('font-semibold leading-tight tracking-tight text-foreground', size >= 180 ? 'text-[22px] sm:text-[24px]' : 'text-[18px]')}>{mid}</span>
<span className="mt-0.5 max-w-[70%] truncate text-[12px] text-muted-foreground">{sub}</span>
</div>
</div>
</ChartSurface>
{legend && (
<ChartLegend
layout="list"
className={cn('w-full', legend === 'side' ? 'sm:max-w-[18rem]' : 'max-w-[22rem]')}
active={active}
onActive={setActive}
items={data.map((d, i) => ({ label: d.label, color: seriesColor(i, d.color), shape: 'rect', value: fmt(d.value), note: percent ? shareAt(i) : undefined }))}
/>
)}
</div>
</ChartRoot>
);
}Häufige Fragen
Wie installiere ich Ringdiagramm?
Mit aiw add @ai-website/donut-chart. Der Befehl legt die Datei components/ui/donut-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Ringdiagramm?
3 Varianten, Ring mit Legende, Halber Tacho und Kompaktes Paar.
Kann ein KI-Assistent Ringdiagramm einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Ringdiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/donut-chart. Nenn im Prompt die Variante, etwa variant="half", dann schreibt der Assistent deine Inhalte direkt in die Props.