Piktogramm-Diagramm
Mengen als Reihen kleiner, selbst gezeichneter Figuren. Ein Feld aus 100 für Anteile, Reihen zum Vergleichen mit angeschnittenen Figuren für Bruchteile, gestapelte Reihen, in denen eine Figur zu zwei Teilen gehört, und zwei Felder nebeneinander.
Mengen, die man sich vorstellen kann
Ein Piktogramm-Diagramm zeigt Mengen als Reihen von Figuren, in der Tradition von Isotype. Anteile und Mengen werden so anschaulich, 46 Liter lesen sich als viereinhalb Tropfen, das Diagramm passt deshalb gut zu Berichten, Kampagnen und Datengeschichten.
„Feld aus 100“ färbt hundert Figuren der Reihe nach je Teil, der Rest bleibt blass, die Legende nennt jeden Anteil groß und der Zeiger holt einen Teil nach vorn. „Reihen im Vergleich“ nutzt eine Figur je zehn Liter, die letzte ist auf ihren Bruchteil angeschnitten. „Gestapelte Reihen“ folgt fertigen Wohnungen je Jahr, die Größen folgen in einer Reihe aufeinander, wo zwei Größen in einem Haus zusammentreffen, wird das Haus geteilt. „Zwei Felder“ stellt zwei Felder aus 100 nebeneinander, das frühere in Grau, das spätere im Akzent, darunter der Unterschied in Punkten.
Wähle eine Figur, die das Gezählte meint, einen Tropfen für Wasser, ein Haus für Wohnungen. Sag in der Legende, wofür eine Figur steht. Halte die Mengen überschaubar, ein Feld aus 100 passt am besten für Anteile.
Variants
Details
Was Piktogramm-Diagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Vergleichs-Charts
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.2.0
- Datei
- components/ui/pictogram-chart.tsx
- Bringt mit
- utilskitchartchart-figure
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 8 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle PictogramChartProps aus components/ui/pictogram-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.
variantoptionalPictogramVariantgridcomparestackedpair
itemsPflichtPictogramItem[]partsoptionalstring[]stacked: names of the parts (up to four)
iconoptionalPictogramIconpersonhousecupleafdropcarboxhearttree
The figure. Default person.
totaloptionalnumbergrid, pair: what the field stands for. Default 100.
unitoptionalnumbercompare, stacked: the value of one figure. Default 1.
unitLabeloptionalstringcompare, stacked: the key, e.g. "One figure is 1,000 visitors"
formatoptionalIntl.NumberFormatOptions
Eingebaute Texte
Diese Texte schreibt Piktogramm-Diagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 8 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| item | Englisch Item | Deutsch Eintrag |
| value | Englisch Value | Deutsch Wert |
| share | Englisch Share | Deutsch Anteil |
| of | Englisch {n} of {total} | Deutsch {n} von {total} |
| diff | Englisch {d} points | Deutsch {d} Punkte |
| figures | Englisch {n} figures | Deutsch {n} Figuren |
| rest | Englisch Other | Deutsch Übrige |
| change | Englisch Change | Deutsch Veränderung |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Piktogramm-Diagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1 und Ende.
- 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, 399 Zeilen
'use client';
import { useId, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, fill, numberFormatter, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Counts drawn as rows of small figures (isotype).
* grid a field of 100 figures, the parts colored in order, the
* rest pale, each part with its share in the key
* compare one row per item, one figure per unit, the last figure cut
* to its fraction, the value at the end
* stacked one row per period, the parts follow each other in one row
* of figures, a figure that two parts share is split between them
* pair two fields of 100 side by side, before and after or one group
* against another, the difference between them
*
* The figures are drawn here (person, house, cup, leaf, drop, car, box,
* heart, tree), filled, on a 24 unit grid. A cut figure is the same figure
* seen through a narrower window, so a third of a person stays a person.
*/
export type PictogramVariant = 'grid' | 'compare' | 'stacked' | 'pair';
export type PictogramIcon = 'person' | 'house' | 'cup' | 'leaf' | 'drop' | 'car' | 'box' | 'heart' | 'tree';
export interface PictogramItem {
label: string;
/** grid, pair: the count out of `total`. compare: the value. stacked: unused, see parts. */
value?: number;
/** stacked: one value per part, in the order of `parts` */
values?: number[];
/** compare: a figure for this row only */
icon?: PictogramIcon;
}
export interface PictogramChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: PictogramVariant;
items: PictogramItem[];
/** stacked: names of the parts (up to four) */
parts?: string[];
/** The figure. Default person. */
icon?: PictogramIcon;
/** grid, pair: what the field stands for. Default 100. */
total?: number;
/** compare, stacked: the value of one figure. Default 1. */
unit?: number;
/** compare, stacked: the key, e.g. "One figure is 1,000 visitors" */
unitLabel?: string;
format?: Intl.NumberFormatOptions;
className?: string;
}
const PARTS = ['var(--kitch-1)', 'var(--kitch-2)', 'var(--kitfg-3)', 'var(--kitch-3)'];
const partColor = (k: number) => PARTS[Math.max(0, Math.min(3, k))];
const TEXTS = {
en: { item: 'Item', value: 'Value', share: 'Share', of: '{n} of {total}', diff: '{d} points', figures: '{n} figures', rest: 'Other', change: 'Change' },
de: { item: 'Eintrag', value: 'Wert', share: 'Anteil', of: '{n} von {total}', diff: '{d} Punkte', figures: '{n} Figuren', rest: 'Übrige', change: 'Veränderung' },
};
/** Figures on a 24 unit grid, filled, even-odd for the cut-outs */
const ICONS: Record<PictogramIcon, string> = {
person: 'M12 1.6a3.3 3.3 0 1 1 0 6.6a3.3 3.3 0 1 1 0-6.6Z M8.7 9.4h6.6a2.2 2.2 0 0 1 2.2 2.2V16h-2.3v6.4H8.8V16H6.5v-4.4a2.2 2.2 0 0 1 2.2-2.2Z',
house: 'M12 2.4L22.2 11H19.6v10.8H14.6V15.2h-5.2v6.6H4.4V11H1.8Z',
cup: 'M3.6 7.2h13.6v5.6a6.2 6.2 0 0 1-6.2 6.2H9.8a6.2 6.2 0 0 1-6.2-6.2Z M17.2 8.8h1.4a3.2 3.2 0 0 1 0 6.4h-1.9a7 7 0 0 0 .4-2h1.5a1.2 1.2 0 0 0 0-2.4h-1.4Z M2.8 20.4h15.6v1.9H2.8Z M8 1.8h1.8v3.6H8Z M11.4 2.6h1.8v2.8h-1.8Z',
leaf: 'M20.6 2.6C11.4 2.8 4.6 7.4 4.6 14.6c0 1.9.5 3.5 1.3 4.8L3.4 21.9l1.3 1.3 2.5-2.5c1.3.8 2.9 1.3 4.8 1.3 7.4 0 10.6-8.4 8.6-20.4Z M8.2 18.4c2.2-4.6 5.6-8 9.2-10.2c-2.8 2.8-5.4 6.2-7.6 11Z',
drop: 'M12 2.2c0 0-7.2 8.2-7.2 13.2a7.2 7.2 0 0 0 14.4 0c0-5-7.2-13.2-7.2-13.2Z',
car: 'M6.2 5.4h11.6l2.8 6.4v8.4h-2.8V22h-3v-1.8H9.2V22h-3v-1.8H3.4v-8.4Z M7.8 7.6L6 11.6h12l-1.8-4Z M5.8 14a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0Z M15.4 14a1.4 1.4 0 1 0 2.8 0a1.4 1.4 0 1 0-2.8 0Z',
box: 'M12 1.8l9.4 4.6v11.2L12 22.2l-9.4-4.6V6.4Z M5.6 7.2L12 10.4l6.4-3.2L12 4.2Z',
heart: 'M12 21.2s-9-5.4-9-11.6A5 5 0 0 1 12 6.6a5 5 0 0 1 9 3C21 15.8 12 21.2 12 21.2Z',
tree: 'M12 1.8l6.2 8.2h-3l5.2 6.8h-6.8V22h-3.2v-5.2H3.6L8.8 10h-3Z',
};
/**
* Isotype charts: a field of 100, rows to compare, stacked rows and a pair of fields.
* <PictogramChart variant="grid" icon="person" items={[{ label: 'Walk', value: 46 }, { label: 'Bike', value: 31 }]} />
*/
export function PictogramChart(props: PictogramChartProps) {
const { variant = 'grid', items, parts = [], icon = 'person', total = 100, unit = 1, unitLabel, format, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
const t = useTexts(TEXTS, undefined);
useTexts(CHART_TEXTS, labels);
const intl = useIntlLocale();
const fmt = numberFormatter(intl, format);
const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const { shown, open, jump } = useShown(active);
const uid = useId().replace(/[^a-zA-Z0-9]/g, '');
const phone = width < 520;
const name = label ?? title ?? '';
const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const P = parts.slice(0, 4);
const sym = (k: PictogramIcon) => `${uid}-${k}`;
const usedIcons = [...new Set([icon, ...items.map((it) => it.icon).filter((x): x is PictogramIcon => !!x)])];
/** One figure, or a window of it from f0 to f1 (0 to 1 of its width) */
const figure = (key: string, k: PictogramIcon, x: number, y: number, s: number, color: string, f0 = 0, f1 = 1, extra?: React.SVGProps<SVGSVGElement>) =>
f1 - f0 <= 0.001 ? null : (
<svg key={key} x={round(x + f0 * s)} y={round(y)} width={round((f1 - f0) * s)} height={round(s)} viewBox={`${round(f0 * 24, 3)} 0 ${round((f1 - f0) * 24, 3)} 24`} preserveAspectRatio="none" overflow="hidden" {...extra}>
<use href={`#${sym(k)}`} width={24} height={24} fill={color} />
</svg>
);
const defs = (
<defs>
{usedIcons.map((k) => (
<symbol key={k} id={sym(k)} viewBox="0 0 24 24">
<path d={ICONS[k]} fillRule="evenodd" />
</symbol>
))}
</defs>
);
const onKeyDown = (n: number) => (e: React.KeyboardEvent) => {
const cur = active ?? -1;
let next: number | null = cur;
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') next = Math.min(n - 1, cur + 1);
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') next = Math.max(0, cur - 1);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = n - 1;
else if (e.key === 'Escape') next = null;
else return;
e.preventDefault();
setActive(next);
};
const base = {
label: name,
labels,
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),
};
// Small figures need four colors that part at a glance in both modes: accent, grey, deep accent, pale accent
const EMPTY = 'color-mix(in oklab, var(--foreground) 9%, transparent)';
/* ---------------- grid and pair: fields of 100 ---------------- */
if (variant === 'grid' || variant === 'pair') {
const fields = variant === 'pair' ? items.slice(0, 2) : [null];
const cells = 100;
const cols = 10;
const gapR = 0.22;
const fieldsN = fields.length;
const avail = variant === 'pair' ? (phone ? width : (width - 48) / 2) : phone ? width : Math.min(width * 0.58, 460);
const s = Math.min(phone ? 30 : 34, avail / (cols + (cols - 1) * gapR));
const step = s * (1 + gapR);
const fieldW = cols * step - s * gapR;
const fieldH = (cells / cols) * step - s * gapR;
// grid: the parts in order. pair: one part per field, the item's value.
const segs = variant === 'grid' ? items.slice(0, 4).map((it) => Math.max(0, ((it.value ?? 0) / (total || 1)) * cells)) : [];
const colorAt = (fi: number, c: number): { color: string; part: number; f0: number; f1: number }[] => {
// Portions of cell c (index along reading order) covered by each part
if (variant === 'pair') {
const v = Math.max(0, ((items[fi]?.value ?? 0) / (total || 1)) * cells);
const cov = Math.max(0, Math.min(1, v - c));
return [...(cov > 0 ? [{ color: fi === fieldsN - 1 ? partColor(0) : partColor(1), part: fi, f0: 0, f1: cov }] : []), ...(cov < 1 ? [{ color: EMPTY, part: -1, f0: cov, f1: 1 }] : [])];
}
const out: { color: string; part: number; f0: number; f1: number }[] = [];
let acc = 0;
segs.forEach((v, k) => {
const a = Math.max(c, acc);
const b = Math.min(c + 1, acc + v);
if (b > a) out.push({ color: partColor(k), part: k, f0: a - c, f1: b - c });
acc += v;
});
const covered = Math.min(c + 1, Math.max(c, acc)) - c;
if (covered < 1) out.push({ color: EMPTY, part: -1, f0: Math.max(0, covered), f1: 1 });
return out;
};
const n = variant === 'grid' ? Math.min(4, items.length) : fields.length;
const live = active !== null && items[active] ? `${items[active].label}, ${fill(t.of, { n: fmt(items[active].value ?? 0), total: fmt(total) })}, ${pct((items[active].value ?? 0) / (total || 1))}` : '';
const auto = items.map((it) => `${it.label} ${pct((it.value ?? 0) / (total || 1))}`).join(', ');
const table = <ChartTable caption={name} head={[t.item, t.value, t.share]} rows={items.map((it) => [it.label, fill(t.of, { n: fmt(it.value ?? 0), total: fmt(total) }), pct((it.value ?? 0) / (total || 1))])} />;
const svgW = variant === 'pair' ? (phone ? fieldW : width) : fieldW;
const fieldX = (fi: number) => (variant === 'pair' && !phone ? fi * (width / 2) + (width / 2 - fieldW) / 2 : 0);
const fieldY = (fi: number) => (variant === 'pair' && phone ? fi * (fieldH + 64) + 34 : variant === 'pair' ? 34 : 0);
const svgH = variant === 'pair' ? (phone ? fieldsN * (fieldH + 64) : fieldH + 34) : fieldH;
const diff = variant === 'pair' && items.length >= 2 ? (items[1].value ?? 0) - (items[0].value ?? 0) : 0;
const keyList = (
<ul className={cn('grid content-start gap-1.5', variant === 'grid' && !phone ? 'min-w-0 flex-1' : 'mt-5')}>
{(variant === 'grid' ? items.slice(0, 4) : items.slice(0, 2)).map((it, k) => {
const color = variant === 'grid' ? partColor(k) : k === fieldsN - 1 ? partColor(0) : partColor(1);
return (
<li key={it.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(k)} onPointerDown={() => setActive(k)} className={cn('flex items-baseline gap-3 rounded-md px-2 py-1.5 transition-[background-color,opacity] duration-200', active === k && 'bg-accent', active !== null && active !== k && 'opacity-55')}>
<span className="translate-y-[1px] self-center">
<Swatch color={color} />
</span>
<span className="min-w-0 flex-1 text-[13px] text-foreground">{it.label}</span>
<span className={cn('text-[22px] font-semibold leading-none tracking-[-0.02em] tabular-nums text-foreground', anim && 'kitch-rise')} style={delay(400 + k * 80)}>
{pct((it.value ?? 0) / (total || 1))}
</span>
</li>
);
})}
{variant === 'grid' && segs.reduce((a, b) => a + b, 0) < cells - 0.5 && (
<li className="flex items-baseline gap-3 px-2 py-1.5 text-muted-foreground">
<span className="self-center">
<Swatch color={EMPTY} />
</span>
<span className="min-w-0 flex-1 text-[13px]">{t.rest}</span>
<span className="text-[15px] font-medium tabular-nums">{pct(1 - segs.reduce((a, b) => a + b, 0) / cells)}</span>
</li>
)}
</ul>
);
const tip = shown !== null && items[shown];
const tipRows: TooltipRow[] = tip ? [{ key: 'v', label: t.value, value: fill(t.of, { n: fmt(items[shown!].value ?? 0), total: fmt(total) }) }, { key: 's', label: t.share, value: pct((items[shown!].value ?? 0) / (total || 1)) }] : [];
const pick = (fi: number, c: number) => {
if (variant === 'pair') return setActive(fi);
const part = colorAt(0, c).find((p) => p.part >= 0)?.part;
setActive(part ?? null);
};
// The figure under the pointer, from its position (gaps between figures count to the nearest)
const pickAt = (e: React.PointerEvent<HTMLDivElement>) => {
const b = e.currentTarget.getBoundingClientRect();
const px = e.clientX - b.left;
const py = e.clientY - b.top;
for (let fi = 0; fi < fieldsN; fi++) {
const x = px - fieldX(fi);
const y = py - fieldY(fi);
if (x < -4 || y < -4 || x > fieldW + 4 || y > fieldH + 4) continue;
const col = Math.max(0, Math.min(cols - 1, Math.floor(x / step)));
const row = Math.max(0, Math.min(cells / cols - 1, Math.floor(y / step)));
return pick(fi, row * cols + col);
}
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef} className={cn(variant === 'grid' && !phone && 'flex items-start gap-8')}>
<ChartSurface {...base} summary={summary ?? auto} live={live} table={table} onKeyDown={onKeyDown(n)} onPointerMove={pickAt} onPointerDown={pickAt} className={cn(variant === 'grid' && !phone && 'shrink-0')}>
<svg width={svgW} height={svgH} viewBox={`0 0 ${round(svgW)} ${round(svgH)}`} aria-hidden className="block max-w-full overflow-visible">
{defs}
{fields.map((_, fi) => (
<g key={fi}>
{variant === 'pair' && (
<g>
<text x={fieldX(fi)} y={fieldY(fi) - 22} className="text-[13px] font-medium" fill="var(--foreground)" dy="0.2em">
{items[fi]?.label}
</text>
<text x={fieldX(fi) + fieldW} y={fieldY(fi) - 22} textAnchor="end" className="text-[20px] font-semibold tabular-nums" fill="var(--foreground)" dy="0.3em">
{pct((items[fi]?.value ?? 0) / (total || 1))}
</text>
</g>
)}
<g opacity={variant === 'pair' && active !== null && active !== fi ? 0.4 : 1} className="transition-opacity duration-200">
{Array.from({ length: cells }, (_, c) => {
const x = fieldX(fi) + (c % cols) * step;
const y = fieldY(fi) + Math.floor(c / cols) * step;
const portions = colorAt(fi, c);
const dim = variant === 'grid' && active !== null && !portions.some((p) => p.part === active);
return (
<g key={c} opacity={dim ? 0.3 : 1} onPointerEnter={(e) => e.pointerType === 'mouse' && pick(fi, c)} onPointerDown={() => pick(fi, c)} className={cn('cursor-pointer transition-opacity duration-200', anim && 'kitch-pop')} style={delay(60 + fi * 200 + (Math.floor(c / cols) + (c % cols)) * 28)}>
{portions.map((p, k) => figure(`${c}-${k}`, icon, x, y, s, p.color, p.f0, p.f1))}
</g>
);
})}
</g>
</g>
))}
</svg>
{variant === 'grid' && <ChartTooltip open={open && phone} jump={jump} x={fieldW / 2} y={0} width={fieldW} title={tip ? items[shown!].label : ''} rows={tipRows} />}
</ChartSurface>
{variant === 'grid' && keyList}
</div>
{variant === 'pair' && items.length >= 2 && (
<p className={cn('mt-5 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-[13px] text-muted-foreground', anim && 'kitch-rise')} style={delay(900)}>
<span>{t.change}</span>
<span className={cn('text-[15px] font-semibold tabular-nums', diff >= 0 ? 'text-success-foreground' : 'text-destructive')}>{`${diff > 0 ? '+' : diff < 0 ? '−' : '±'}${fill(t.diff, { d: new Intl.NumberFormat(intl, { maximumFractionDigits: 1 }).format(Math.abs((diff / (total || 1)) * 100)) })}`}</span>
<span>{`${items[0].label} → ${items[1].label}`}</span>
</p>
)}
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- compare and stacked: rows of figures ---------------- */
const rowVals = items.map((it) => (variant === 'stacked' ? P.map((_, k) => Math.max(0, it.values?.[k] ?? 0)) : [Math.max(0, it.value ?? 0)]));
const rowTotals = rowVals.map((v) => v.reduce((a, b) => a + b, 0));
const maxUnits = Math.max(1, ...rowTotals.map((v) => v / (unit || 1)));
const labelW = phone ? 0 : Math.min(200, Math.max(...items.map((it) => textWidth(it.label, 13)), 40) + 20);
const endW = Math.max(...rowTotals.map((v) => textWidth(fmt(v), 14)), 30) + 14;
const iconsW = Math.max(60, width - labelW - endW);
const gapR = 0.16;
const s = Math.max(10, Math.min(phone ? 22 : 28, iconsW / (Math.ceil(maxUnits) * (1 + gapR))));
const step = s * (1 + gapR);
const perRow = Math.max(1, Math.floor((iconsW + s * gapR) / step));
const lines = (units: number) => Math.max(1, Math.ceil(units / perRow));
const rowGap = phone ? 18 : 14;
const heads = phone ? 22 : 0;
const rowHs = rowTotals.map((v) => heads + lines(v / (unit || 1)) * step - s * gapR);
const rowYs: number[] = [];
rowHs.reduce((y, h) => (rowYs.push(y), y + h + rowGap), 0);
const svgH = rowYs[rowYs.length - 1] + rowHs[rowHs.length - 1];
const colorOf = (k: number) => (variant === 'stacked' ? partColor(k) : 'var(--kitch-1)');
const live = active !== null && items[active] ? `${items[active].label}, ${fmt(rowTotals[active])}${variant === 'stacked' ? `. ${P.map((p, k) => `${p} ${fmt(rowVals[active][k])}`).join(', ')}` : ''}` : '';
const auto = items.map((it, i) => `${it.label} ${fmt(rowTotals[i])}`).join(', ');
const table = <ChartTable caption={name} head={[t.item, ...(variant === 'stacked' ? P : [t.value]), ...(variant === 'stacked' ? [t.value] : [])]} rows={items.map((it, i) => [it.label, ...(variant === 'stacked' ? rowVals[i].map(fmt) : []), fmt(rowTotals[i])])} />;
const pickRow = (py: number) => {
const i = rowYs.findIndex((y, k) => py >= y - rowGap / 2 && py < y + rowHs[k] + rowGap / 2);
setActive(i >= 0 ? i : null);
};
const tipRows: TooltipRow[] = shown !== null && items[shown] ? (variant === 'stacked' ? [...P.map((p, k) => ({ key: p, label: p, value: fmt(rowVals[shown][k]), color: colorOf(k) })), { key: '__total', label: t.value, value: fmt(rowTotals[shown]) }] : [{ key: 'v', label: t.value, value: fmt(rowTotals[shown]) }, { key: 'f', label: unitLabel ?? '', value: fill(t.figures, { n: new Intl.NumberFormat(intl, { maximumFractionDigits: 1 }).format(rowTotals[shown] / (unit || 1)) }) }]) : [];
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
{(variant === 'stacked' || unitLabel) && (
<div className="mb-5 flex flex-wrap items-center justify-between gap-x-6 gap-y-2 text-[12px] text-muted-foreground">
{variant === 'stacked' ? (
<ul className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
{P.map((p, k) => (
<li key={p} className="flex items-center gap-1.5">
<Swatch color={colorOf(k)} />
{p}
</li>
))}
</ul>
) : (
<span />
)}
{unitLabel && (
<span className="inline-flex items-center gap-1.5">
<svg width="14" height="14" viewBox="0 0 24 24" aria-hidden>
<path d={ICONS[icon]} fillRule="evenodd" fill="var(--muted-foreground)" />
</svg>
{unitLabel}
</span>
)}
</div>
)}
<div ref={wrapRef}>
<ChartSurface
{...base}
summary={summary ?? auto}
live={live}
table={table}
onKeyDown={onKeyDown(items.length)}
onPointerMove={(e) => pickRow(e.clientY - e.currentTarget.getBoundingClientRect().top)}
onPointerDown={(e) => pickRow(e.clientY - e.currentTarget.getBoundingClientRect().top)}
>
<svg width="100%" height={round(svgH)} viewBox={`0 0 ${width} ${round(svgH)}`} aria-hidden className="block overflow-visible">
{defs}
{items.map((it, i) => {
const y0 = rowYs[i];
const units = rowTotals[i] / (unit || 1);
const nIcons = Math.ceil(units - 1e-9);
const k0 = it.icon ?? icon;
const bounds: number[] = [];
rowVals[i].reduce((a, v) => (bounds.push(a + v / (unit || 1)), a + v / (unit || 1)), 0);
const on = active === i;
return (
<g key={i} opacity={active !== null && !on ? 0.4 : 1} className="transition-opacity duration-200">
{active === i && <rect x={-6} y={y0 - rowGap / 2 + 2} width={width + 12} height={rowHs[i] + rowGap - 4} rx={8} fill="var(--foreground)" opacity={0.045} />}
<text x={0} y={phone ? y0 + 8 : y0 + (Math.min(rowHs[i], s) / 2)} dy="0.34em" className={cn('text-[13px]', on ? 'font-semibold' : 'font-medium')} fill="var(--foreground)">
{it.label}
</text>
<text x={width} y={phone ? y0 + 8 : y0 + (Math.min(rowHs[i], s) / 2)} dy="0.34em" textAnchor="end" className={cn('text-[14px] font-semibold tabular-nums', anim && 'kitch-fade')} fill="var(--foreground)" style={delay(500 + i * 90)}>
{fmt(rowTotals[i])}
</text>
{Array.from({ length: nIcons }, (_, c) => {
const x = labelW + (c % perRow) * step;
const y = y0 + heads + Math.floor(c / perRow) * step;
const portions: React.ReactNode[] = [];
let prev = 0;
bounds.forEach((b, k) => {
const a = Math.max(c, prev);
const e = Math.min(c + 1, b);
if (e > a) portions.push(figure(`${c}-${k}`, k0, x, y, s, colorOf(k), a - c, e - c));
prev = b;
});
const filled = Math.min(1, Math.max(0, units - c));
if (filled < 1) portions.push(figure(`${c}-e`, k0, x, y, s, EMPTY, filled, 1));
return (
<g key={c} className={anim ? 'kitch-pop' : undefined} style={delay(80 + i * 90 + c * 22)}>
{portions}
</g>
);
})}
</g>
);
})}
</svg>
<ChartTooltip open={open} jump={jump} x={shown !== null && items[shown] ? Math.min(width - endW, labelW + Math.min(perRow, Math.ceil(rowTotals[shown] / (unit || 1))) * step) : 0} y={shown !== null && rowYs[shown] !== undefined ? Math.max(0, rowYs[shown] - 6) : 0} width={width} offset={10} title={shown !== null && items[shown] ? items[shown].label : ''} rows={tipRows} />
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}Häufige Fragen
Wie installiere ich Piktogramm-Diagramm?
Mit aiw add @ai-website/pictogram-chart. Der Befehl legt die Datei components/ui/pictogram-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 Piktogramm-Diagramm?
4 Varianten, Feld aus 100, Reihen im Vergleich, Gestapelte Reihen und Zwei Felder. Du wählst sie mit der Prop variant, zum Beispiel variant="compare".
Kann ich die Texte von Piktogramm-Diagramm übersetzen?
Ja. Piktogramm-Diagramm bringt 8 eigene Texte auf Deutsch und Englisch mit, etwa „Eintrag“, „Wert“ und „Anteil“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Piktogramm-Diagramm einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Piktogramm-Diagramm mit aiw_components und installieren sie mit aiw_add @ai-website/pictogram-chart. Nenn im Prompt die Variante, etwa variant="grid", dann schreibt der Assistent deine Inhalte direkt in die Props.