Zum Inhalt springen
UI Kit
Accent

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.

4 VariantsEine von 8 Components in Vergleichs-Charts

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

Feld aus 100Neu in 1.2

Hundert Figuren, die Teile der Reihe nach gefärbt, der Rest blass. Die Legende nennt jeden Anteil groß, der Zeiger holt einen Teil nach vorn.

Reihen im VergleichNeu in 1.2

Eine Figur je zehn Liter, die letzte auf ihren Bruchteil angeschnitten, so lesen sich 46 Liter als viereinhalb Tropfen.

Gestapelte ReihenNeu in 1.2

Fertige Wohnungen je Jahr, die Größen folgen in einer Reihe aufeinander. Wo zwei Größen in einem Haus zusammentreffen, wird das Haus zwischen ihnen geteilt.

Zwei FelderNeu in 1.2

Zwei Felder aus 100 nebeneinander, das frühere in Grau, das spätere im Akzent, der Unterschied in Punkten darunter.

Details

Was Piktogramm-Diagramm mitbringt, aus Registry und Quellcode gelesen.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    PictogramVariant

    gridcomparestackedpair

  • itemsPflicht
    PictogramItem[]
  • partsoptional
    string[]

    stacked: names of the parts (up to four)

  • iconoptional
    PictogramIcon

    personhousecupleafdropcarboxhearttree

    The figure. Default person.

  • totaloptional
    number

    grid, pair: what the field stands for. Default 100.

  • unitoptional
    number

    compare, stacked: the value of one figure. Default 1.

  • unitLabeloptional
    string

    compare, stacked: the key, e.g. "One figure is 1,000 visitors"

  • formatoptional
    Intl.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
itemEnglisch ItemDeutsch Eintrag
valueEnglisch ValueDeutsch Wert
shareEnglisch ShareDeutsch Anteil
ofEnglisch {n} of {total}Deutsch {n} von {total}
diffEnglisch {d} pointsDeutsch {d} Punkte
figuresEnglisch {n} figuresDeutsch {n} Figuren
restEnglisch OtherDeutsch Übrige
changeEnglisch ChangeDeutsch 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
components/ui/pictogram-chart.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert