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
Variants
Details
Was Anteilsdiagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Vergleichs-Charts
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalProportionVariantstackedcolumnsmarimekkolikert
partsPflichtstring[]Names of the parts (stacked, columns, marimekko) or of the answers (likert, five, disagree first)
rowsPflichtProportionRow[]formatoptionalIntl.NumberFormatOptionsValue 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| item | Englisch Row | Deutsch Zeile |
| total | Englisch Total | Deutsch Gesamt |
| net | Englisch Net agreement | Deutsch Zustimmung netto |
| size | Englisch Width is the size of each segment | Deutsch 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
'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.