Zum Inhalt springen
UI Kit
Accent

Ringdiagramm

Anteile als Ring oder halber Tacho, die Summe in der Mitte, Legende mit Werten und Anteilen.

Eine von 7 Components in Charts

3 Variants

Variants

Ring mit Legende

Drei Anteile, die Summe in der Mitte. Zeigen oder Pfeiltasten holen den Anteil in die Mitte.

Halber Tacho

Ein halber Ring gegen eine Kapazität, der freie Rest bleibt als ruhige Spur.

Kompaktes Paar

Zwei kleine Ringe nebeneinander, Legenden darunter, nur Anteile.

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

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.

  • dataPflicht
    DonutSlice[]

    Up to four slices read best. Fold the rest into "Other".

  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }

  • variantoptional
    'donut' | 'half'

    donut: full ring. half: a gauge from left to right.

  • maxoptional
    number

    The whole ring stands for this value. The rest stays as a quiet track (gauges, budgets). Default: the sum.

  • centerLabeloptional
    string

    Small text in the middle, under the value. Default: "Total".

  • centerValueoptional
    string

    Big text in the middle. Default: the formatted sum.

  • sizeoptional
    number

    Diameter in px. It shrinks with the container.

  • thicknessoptional
    number

    Ring width in px

  • legendoptional
    'side' | 'bottom' | false

    Legend next to the ring (side), below it (bottom) or none

  • percentoptional
    boolean

    Share 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
components/ui/donut-chart.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert