Zum Inhalt springen
UI Kit
Accent

Anteilsdiagramm

Anteile, die zusammen 100 % ergeben, als gestapelte Zeilen, Säulen für die Mischung über die Zeit, Marimekko, in dem die Breite das Gewicht ist, oder Umfrageantworten um eine neutrale Mitte.

Eine von 5 Components in Vergleichs-Charts

4 Variants

Variants

Gestapelte Zeilen

Ein 100-%-Balken je Zeile, dieselben Teile in derselben Reihenfolge, Anteile darin, wo sie passen. Die Legende holt einen Teil nach vorn.

Im Studio öffnen

Mischung über die Zeit

Eine 100-%-Säule je Monat, so zeigt sich eine Verschiebung der Mischung, auch wenn die Summe wächst.

Im Studio öffnen

Marimekko

Die Säulenbreite ist die Größe eines Segments, die Teile darin seine Mischung, so wiegt ein großes Segment so viel, wie es soll.

Im Studio öffnen

Umfrageantworten

Fünf Antworten von Ablehnung bis Zustimmung, die neutrale Mitte auf einer Linie, Zustimmung nach rechts und daneben der Saldo je Aussage.

Im Studio öffnen

Details

Was Anteilsdiagramm mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.1.0
Datei
components/ui/proportion-chart.tsx
Bringt mit
utilskitchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 4 eigene Texte

Props

Die Schnittstelle ProportionChartProps aus components/ui/proportion-chart.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<ChartA11y, 'label'>, ChartFigureProps an.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    ProportionVariant

    stackedcolumnsmarimekkolikert

  • partsPflicht
    string[]

    Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first)

  • rowsPflicht
    ProportionRow[]
  • formatoptional
    Intl.NumberFormatOptions

    Value format of the absolute values in tooltips and table

Eingebaute Texte

Diese Texte schreibt Anteilsdiagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 4 Texte anzeigen
itemEnglisch RowDeutsch Zeile
totalEnglisch TotalDeutsch Gesamt
netEnglisch Net agreementDeutsch Zustimmung netto
sizeEnglisch Width is the size of each segmentDeutsch Die Breite ist die Größe jedes Segments

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Anteilsdiagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links und Pfeil rechts.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 256 Zeilen
components/ui/proportion-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, Swatch, numberFormatter, seriesColor, textWidth, useEntrance, useIntlLocale, useWidth, xFormatters, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, PickLegend, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Shares that add up to 100 %.
 *   stacked    one 100 % bar per row, the same parts in the same order
 *   columns    one 100 % column per period, how the mix moves over time
 *   marimekko  column width is the size of a segment, the parts inside it
 *              their share, so both the mix and the weight are honest
 *   likert     survey answers from disagree to agree around a neutral
 *              middle, agreement to the right, the net share beside
 *
 * Up to four parts in the slot colors (they touch only their neighbours,
 * the adjacent pair check holds), parted by a 2 px surface gap. A share
 * label sits inside a part only where it fits.
 */

export type ProportionVariant = 'stacked' | 'columns' | 'marimekko' | 'likert';

export interface ProportionRow {
  label: string;
  /** One value per part (or per answer for likert, disagree first) */
  values: number[];
}

export interface ProportionChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: ProportionVariant;
  /** Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first) */
  parts: string[];
  rows: ProportionRow[];
  /** Value format of the absolute values in tooltips and table */
  format?: Intl.NumberFormatOptions;
  className?: string;
}

const TEXTS = {
  en: { item: 'Row', total: 'Total', net: 'Net agreement', size: 'Width is the size of each segment' },
  de: { item: 'Zeile', total: 'Gesamt', net: 'Zustimmung netto', size: 'Die Breite ist die Größe jedes Segments' },
};

const LIKERT = ['color-mix(in oklab, var(--kitfg-down) 80%, var(--card))', 'color-mix(in oklab, var(--kitfg-down) 38%, var(--card))', 'color-mix(in oklab, var(--muted-foreground) 30%, var(--card))', 'color-mix(in oklab, var(--primary) 45%, var(--card))', 'var(--primary)'];
// Text on a part: dark ink on light tints, the surface color on the two full tones
const INK_LIKERT = ['var(--pc-dark)', 'var(--foreground)', 'var(--foreground)', 'var(--foreground)', 'var(--primary-foreground)'];

/**
 * 100 % shares: stacked rows, columns over time, a marimekko or survey answers.
 *   <ProportionChart variant="likert" parts={['Disagree strongly', …, 'Agree strongly']} rows={[{ label: 'Easy to book', values: [4, 9, 18, 41, 28] }]} />
 */
export function ProportionChart(props: ProportionChartProps) {
  const { variant = 'stacked', parts, rows, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const intl = useIntlLocale();
  const fmt = numberFormatter(intl, format);
  const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
  const signedPct = (n: number) => (n > 0 ? '+' : n < 0 ? '−' : '') + pct(Math.abs(n));
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const [focus, setFocus] = useState<number | null>(null);
  const name = label ?? title ?? '';
  const phone = width < 520;
  const P = variant === 'likert' ? parts.slice(0, 5) : parts.slice(0, 4);
  const colorOf = (k: number) => (variant === 'likert' ? LIKERT[k] : seriesColor(k));
  const inkOf = (k: number) => (variant === 'likert' ? INK_LIKERT[k] : k === 0 ? 'var(--primary-foreground)' : k === 1 ? 'var(--background)' : 'var(--foreground)');
  const sums = rows.map((r) => r.values.slice(0, P.length).reduce((a, v) => a + Math.max(0, v), 0) || 1);
  const shares = rows.map((r, i) => r.values.slice(0, P.length).map((v) => Math.max(0, v) / sums[i]));
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const xf = xFormatters(intl, rows[0]?.label);

  const onKeyDown = (e: React.KeyboardEvent) => {
    const cur = active ?? -1;
    let next: number | null = cur;
    if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(rows.length - 1, cur + 1);
    else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
    else if (e.key === 'Escape') next = null;
    else return;
    e.preventDefault();
    setActive(next);
  };
  const rowName = (r: ProportionRow) => (variant === 'columns' ? xf.tooltip(r.label) : r.label);
  const live = active !== null && rows[active] ? `${rowName(rows[active])}. ${P.map((p, k) => `${p} ${pct(shares[active][k])}`).join(', ')}` : '';
  const table = <ChartTable caption={name} head={[t.item, ...P, t.total]} rows={rows.map((r, i) => [rowName(r), ...P.map((_, k) => `${pct(shares[i][k])} (${fmt(r.values[k] ?? 0)})`), fmt(sums[i])])} />;
  const legend = <PickLegend className="mb-4" active={focus} onActive={setFocus} items={P.map((p, k) => ({ label: p, key: <Swatch color={colorOf(k)} /> }))} />;
  const dim = (k: number) => (focus !== null && focus !== k ? 0.22 : 1);
  const surfaceProps = { label: name, summary: summary ?? '', labels, live, table, onKeyDown, onFocus: (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0), onBlur: () => setActive(null), onPointerLeave: (e: React.PointerEvent) => e.pointerType === 'mouse' && setActive(null) };

  /* ---------------- stacked rows and likert ---------------- */
  if (variant === 'stacked' || variant === 'likert') {
    const labelW = phone ? 0 : Math.min(220, Math.max(...rows.map((r) => textWidth(r.label, 13))) + 20);
    const netW = variant === 'likert' && !phone ? 64 : 0;
    // Likert: neutral centred on the middle line, disagreement to the left
    const left = (i: number) => (variant === 'likert' ? 0.5 - shares[i][0] - shares[i][1] - shares[i][2] / 2 : 0);
    const minLeft = variant === 'likert' ? Math.min(...rows.map((_, i) => left(i))) : 0;
    const maxRight = variant === 'likert' ? Math.max(...rows.map((_, i) => left(i) + 1)) : 1;
    const span = maxRight - minLeft;
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef} className="[--pc-dark:var(--foreground)] dark:[--pc-dark:var(--background)]">
          {legend}
          <div ref={wrapRef}>
            <ChartSurface {...surfaceProps}>
              <ul className="relative grid gap-3">
                {variant === 'likert' && (
                  <span aria-hidden className="pointer-events-none absolute inset-y-0 w-px bg-[var(--kitch-axis)]" style={{ left: `calc(${labelW}px + (100% - ${labelW + netW}px) * ${round((0.5 - minLeft) / span, 4)})` }} />
                )}
                {rows.map((r, i) => {
                  const net = variant === 'likert' ? shares[i][3] + shares[i][4] - shares[i][0] - shares[i][1] : 0;
                  let acc = (left(i) - minLeft) / span;
                  return (
                    <li key={r.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)} className={cn('grid items-center gap-x-3 gap-y-1.5 rounded-md transition-opacity duration-200', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : '', active !== null && active !== i && 'opacity-60')} style={phone ? undefined : { gridTemplateColumns: `${labelW - 12}px minmax(0,1fr)${netW ? ` ${netW - 12}px` : ''}` }}>
                      <span className={cn('truncate text-[13px] text-foreground', active === i && 'font-medium')}>{r.label}</span>
                      {phone && variant === 'likert' && <span className="text-right text-[12px] font-medium tabular-nums text-foreground">{signedPct(net)}</span>}
                      <span className={cn('relative h-7 min-w-0', phone && 'col-span-2')}>
                        {shares[i].map((s, k) => {
                          const x = acc;
                          acc += s / span;
                          const w = s / span;
                          const text = pct(s);
                          const fits = s > 0 && textWidth(text, 11) + 10 <= w * (width - labelW - netW);
                          return (
                            <span
                              key={k}
                              className={cn('kitch-series absolute inset-y-0 flex items-center justify-center overflow-hidden text-[11px] font-medium tabular-nums', anim && 'kitch-grow-x', k === 0 && 'rounded-l-[5px]', k === P.length - 1 && 'rounded-r-[5px]')}
                              style={{ left: `calc(${round(x * 100, 3)}% + ${k ? 1 : 0}px)`, width: `calc(${round(w * 100, 3)}% - ${k && k < P.length - 1 ? 2 : 1}px)`, background: colorOf(k), color: inkOf(k), opacity: dim(k), ...d(120 + i * 70 + k * 40) }}
                            >
                              {fits ? text : ''}
                            </span>
                          );
                        })}
                      </span>
                      {!phone && variant === 'likert' && <span className="text-right text-[13px] font-semibold tabular-nums text-foreground">{signedPct(net)}</span>}
                    </li>
                  );
                })}
              </ul>
              {variant === 'likert' && <p className="mt-3 text-right text-[12px] text-muted-foreground">{t.net}</p>}
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- columns over time and marimekko ---------------- */
  const height = phone ? 260 : 300;
  const top = 6;
  const bottom = height - 40;
  const plotH = bottom - top;
  const left = 36;
  const right = width - 4;
  const totalAll = sums.reduce((a, v) => a + v, 0) || 1;
  const gap = variant === 'marimekko' ? 3 : 0;
  const colW = variant === 'columns' ? Math.min(44, ((right - left) / rows.length) * 0.62) : 0;
  const band = (right - left) / Math.max(1, rows.length);
  let xAcc = left;
  const cols = rows.map((r, i) => {
    if (variant === 'marimekko') {
      const w = (sums[i] / totalAll) * (right - left - gap * (rows.length - 1));
      const c = { x: xAcc, w };
      xAcc += w + gap;
      return c;
    }
    return { x: left + band * i + (band - colW) / 2, w: colW };
  });
  const pick = (clientX: number, el: HTMLElement) => {
    const b = el.getBoundingClientRect();
    const px = (clientX - b.left) * (width / (b.width || width));
    const i = cols.findIndex((c) => px >= c.x - (variant === 'columns' ? (band - colW) / 2 : gap / 2) && px <= c.x + c.w + (variant === 'columns' ? (band - colW) / 2 : gap / 2));
    setActive(i >= 0 ? i : null);
  };
  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef} className="[--pc-dark:var(--foreground)] dark:[--pc-dark:var(--background)]">
        {legend}
        <div ref={wrapRef}>
          <ChartSurface {...surfaceProps} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)}>
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              {[0, 0.25, 0.5, 0.75, 1].map((q) => (
                <g key={q}>
                  <line x1={left} x2={right} y1={round(bottom - q * plotH)} y2={round(bottom - q * plotH)} stroke={q === 0 ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
                  <text x={left - 8} y={round(bottom - q * plotH)} dy="0.34em" textAnchor="end" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{pct(q)}</text>
                </g>
              ))}
              {rows.map((r, i) => {
                const c = cols[i];
                let yAcc = bottom;
                const on = active === i;
                const lab = variant === 'columns' ? xf.axis(r.label) : r.label;
                const labFits = textWidth(lab, 11) <= (variant === 'columns' ? band : c.w) - 4;
                return (
                  <g key={r.label} opacity={active !== null && !on ? 0.55 : 1} className="transition-opacity duration-200">
                    {shares[i].map((s, k) => {
                      const h = s * plotH;
                      const y = yAcc - h;
                      yAcc = y;
                      const inner = Math.max(0, h - (k < P.length - 1 ? 2 : 0));
                      const text = pct(s);
                      const fits = textWidth(text, 11) + 8 <= c.w && inner >= 16;
                      return (
                        <g key={k} className="kitch-series" opacity={dim(k)}>
                          <rect x={round(c.x)} y={round(y + (k < P.length - 1 ? 2 : 0))} width={round(c.w)} height={round(inner)} rx={k === P.length - 1 ? 3 : 0} fill={colorOf(k)} className={anim ? 'kitch-grow-y' : undefined} style={{ ...d(100 + i * 70 + k * 50), transformOrigin: `${round(c.x + c.w / 2)}px ${bottom}px`, transformBox: 'view-box' }} />
                          {fits && (
                            <text x={round(c.x + c.w / 2)} y={round(y + h / 2 + 1)} dy="0.34em" textAnchor="middle" className={cn('text-[11px] font-medium tabular-nums', anim && 'kitch-fade')} fill={inkOf(k)} style={d(600 + i * 70)}>
                              {text}
                            </text>
                          )}
                        </g>
                      );
                    })}
                    {labFits && (
                      <text x={round(c.x + c.w / 2)} y={bottom + 18} textAnchor="middle" className={cn('text-[11px]', on && 'fill-foreground font-medium')} fill="var(--muted-foreground)">{lab}</text>
                    )}
                    {variant === 'marimekko' && labFits && (
                      <text x={round(c.x + c.w / 2)} y={bottom + 33} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{pct(sums[i] / totalAll)}</text>
                    )}
                  </g>
                );
              })}
            </svg>
          </ChartSurface>
          {variant === 'marimekko' && (
            <p className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
              <span>{t.size}</span>
              {/* Segments too narrow for their name under the column are named here, nothing goes missing */}
              {rows.map((r, i) =>
                textWidth(r.label, 11) > cols[i].w - 4 ? (
                  <span key={r.label} className="tabular-nums">
                    <span className="text-foreground">{r.label}</span> {pct(sums[i] / totalAll)}
                  </span>
                ) : null,
              )}
            </p>
          )}
          {active !== null && rows[active] && (
            <p aria-hidden className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px]">
              <span className="font-medium text-foreground">{rowName(rows[active])}</span>
              {P.map((p, k) => (
                <span key={p} className="inline-flex items-center gap-1.5 text-muted-foreground">
                  <Swatch color={colorOf(k)} />
                  {p}
                  <span className="font-medium tabular-nums text-foreground">{pct(shares[active][k])}</span>
                </span>
              ))}
            </p>
          )}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Anteilsdiagramm?

Mit aiw add @ai-website/proportion-chart. Der Befehl legt die Datei components/ui/proportion-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind chart und chart-figure. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Anteilsdiagramm?

4 Varianten, Gestapelte Zeilen, Mischung über die Zeit, Marimekko und Umfrageantworten. Du wählst sie mit der Prop variant, zum Beispiel variant="columns".

Kann ich die Texte von Anteilsdiagramm übersetzen?

Ja. Anteilsdiagramm bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Zeile“, „Gesamt“ und „Zustimmung netto“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Anteilsdiagramm einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Anteilsdiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/proportion-chart. Nenn im Prompt die Variante, etwa variant="stacked", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Anteilsdiagramm ohne Code anpassen?

Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ProportionChart-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert