Zum Inhalt springen
UI Kit
Accent

Verteilungsdiagramm

Wie sich Werte verteilen, nicht nur ihr Schnitt. Als Histogramm, Boxplots, Dichtekurven, Punktstreifen, in dem kein Punkt einen anderen verdeckt, oder als Perzentilbänder mit dem eigenen Wert darin.

Eine von 4 Components in Statistik-Charts

5 Variants

Variants

Histogramm

Anzahl je Klasse auf runden Grenzen. Die mittlere Hälfte der Werte behält den Akzent, der Median steht als Linie.

Im Studio öffnen

Boxplot

Eine Box je Gruppe, mit Quartilen, Median, Antennen bis anderthalb Boxlängen und Ausreißern als Ringen.

Im Studio öffnen

Dichte

Glatte Kurven für bis zu drei Gruppen auf einer Skala, der Median gestrichelt. Der Zeiger liest ab, wie viele schneller sind als ein Punkt.

Im Studio öffnen

Punktstreifen

Jeder Wert als Punkt, so gepackt, dass keiner einen anderen verdeckt, der Median als Linie durch jede Gruppe.

Im Studio öffnen

Perzentile

Eine Verteilung als Bänder von P10 bis P90 mit Median, der eigene Wert darin platziert und ein Satz, der sagt, wo er steht.

Im Studio öffnen

Details

Was Verteilungsdiagramm mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle DistributionChartProps aus components/ui/distribution-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
    DistributionVariant

    histogrambox-plotdensitystrippercentiles

  • valuesoptional
    number[]

    histogram, percentiles: the raw values. Other variants: use groups.

  • groupsoptional
    DistributionGroup[]

    box-plot, density, strip: one entry per group

  • unitLabeloptional
    string

    Name of the measure, shown at the axis

  • formatoptional
    Intl.NumberFormatOptions
  • binsoptional
    number

    histogram: number of bins. Default: about 12, on round edges.

  • markeroptional
    { value: number; label: string }

    percentiles: a value to place in the spread, e.g. your own result

  • lowerIsBetteroptional
    boolean

    For measures where lower is better (load time): the percentile sentence counts from the top

Eingebaute Texte

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

Alle 18 Texte anzeigen
shareLabelEnglisch ShareDeutsch Anteil
outerEnglisch Outside the middle halfDeutsch Außerhalb der mittleren Hälfte
countEnglisch CountDeutsch Anzahl
rangeEnglisch {a} to {b}Deutsch {a} bis {b}
medianEnglisch MedianDeutsch Median
q1Englisch Lower quartileDeutsch Unteres Quartil
q3Englisch Upper quartileDeutsch Oberes Quartil
minEnglisch LowestDeutsch Kleinster
maxEnglisch HighestDeutsch Größter
outliersEnglisch OutliersDeutsch Ausreißer
nEnglisch {n} valuesDeutsch {n} Werte
groupEnglisch GroupDeutsch Gruppe
pEnglisch P{n}Deutsch P{n}
betterEnglisch Better than {share} of allDeutsch Besser als {share} aller
worseEnglisch Behind {share} of allDeutsch Hinter {share} aller
bandMidEnglisch Middle halfDeutsch Mittlere Hälfte
bandWideEnglisch 80 of 100Deutsch 80 von 100
valueEnglisch ValueDeutsch Wert

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Verteilungsdiagramm 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, 426 Zeilen
components/ui/distribution-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, barPath, fill, linePath, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, PlotAxes, linear, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * How values spread, not only their average.
 *   histogram    counts per bin, the median marked
 *   box-plot     quartiles, median, whiskers and outliers per group
 *   density      smooth curves per group on one scale (up to three)
 *   strip        every value as a dot, packed so none hides another
 *   percentiles  one spread as bands from P10 to P90, a marked value placed in it
 *
 * The statistics come from the raw values: quantiles by linear
 * interpolation, whiskers at 1.5 times the box, density with a Gaussian
 * kernel and Silverman's bandwidth.
 */

export type DistributionVariant = 'histogram' | 'box-plot' | 'density' | 'strip' | 'percentiles';

export interface DistributionGroup {
  label: string;
  values: number[];
}

export interface DistributionChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: DistributionVariant;
  /** histogram, percentiles: the raw values. Other variants: use groups. */
  values?: number[];
  /** box-plot, density, strip: one entry per group */
  groups?: DistributionGroup[];
  /** Name of the measure, shown at the axis */
  unitLabel?: string;
  format?: Intl.NumberFormatOptions;
  /** histogram: number of bins. Default: about 12, on round edges. */
  bins?: number;
  /** percentiles: a value to place in the spread, e.g. your own result */
  marker?: { value: number; label: string };
  /** For measures where lower is better (load time): the percentile sentence counts from the top */
  lowerIsBetter?: boolean;
  className?: string;
}

const TEXTS = {
  en: { shareLabel: 'Share', outer: 'Outside the middle half', count: 'Count', range: '{a} to {b}', median: 'Median', q1: 'Lower quartile', q3: 'Upper quartile', min: 'Lowest', max: 'Highest', outliers: 'Outliers', n: '{n} values', group: 'Group', p: 'P{n}', better: 'Better than {share} of all', worse: 'Behind {share} of all', bandMid: 'Middle half', bandWide: '80 of 100', value: 'Value' },
  de: { shareLabel: 'Anteil', outer: 'Außerhalb der mittleren Hälfte', count: 'Anzahl', range: '{a} bis {b}', median: 'Median', q1: 'Unteres Quartil', q3: 'Oberes Quartil', min: 'Kleinster', max: 'Größter', outliers: 'Ausreißer', n: '{n} Werte', group: 'Gruppe', p: 'P{n}', better: 'Besser als {share} aller', worse: 'Hinter {share} aller', bandMid: 'Mittlere Hälfte', bandWide: '80 von 100', value: 'Wert' },
};

/** Quantile by linear interpolation of a sorted array */
export function quantile(sorted: number[], q: number) {
  if (!sorted.length) return 0;
  const pos = (sorted.length - 1) * q;
  const lo = Math.floor(pos);
  const hi = Math.ceil(pos);
  return sorted[lo] + (sorted[hi] - sorted[lo]) * (pos - lo);
}

function stats(values: number[]) {
  const s = [...values].sort((a, b) => a - b);
  const q1 = quantile(s, 0.25);
  const q3 = quantile(s, 0.75);
  const iqr = q3 - q1;
  const lo = s.find((v) => v >= q1 - 1.5 * iqr) ?? s[0];
  const hi = [...s].reverse().find((v) => v <= q3 + 1.5 * iqr) ?? s[s.length - 1];
  return { s, q1, q3, median: quantile(s, 0.5), lo, hi, out: s.filter((v) => v < lo || v > hi), min: s[0], max: s[s.length - 1] };
}

/** Gaussian kernel density at the given points */
function density(values: number[], at: number[]) {
  const n = values.length || 1;
  const mean = values.reduce((a, v) => a + v, 0) / n;
  const sd = Math.sqrt(values.reduce((a, v) => a + (v - mean) ** 2, 0) / n) || 1;
  const s = [...values].sort((a, b) => a - b);
  const iqr = quantile(s, 0.75) - quantile(s, 0.25);
  const h = 0.9 * Math.min(sd, iqr / 1.34 || sd) * n ** -0.2;
  return at.map((x) => values.reduce((a, v) => a + Math.exp(-0.5 * ((x - v) / h) ** 2), 0) / (n * h * Math.sqrt(2 * Math.PI)));
}

/**
 * The spread of values: a histogram, box plots, density curves, a dot strip or percentile bands.
 *   <DistributionChart variant="box-plot" groups={[{ label: 'Mon', values: [12, 18, …] }]} unitLabel="Minutes" />
 */
export function DistributionChart(props: DistributionChartProps) {
  const { variant = 'histogram', values = [], groups = [], unitLabel = '', format, bins: binsProp, marker, lowerIsBetter, 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 tick = tickFormatter(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 [focus, setFocus] = useState<number | null>(null);
  const name = label ?? title ?? unitLabel;
  const phone = width < 520;
  const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);

  const keys = (n: number) => (e: React.KeyboardEvent) => {
    const cur = active ?? -1;
    let next: number | null = cur;
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = Math.min(n - 1, cur + 1);
    else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') 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 focusIn = (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0);

  /* ---------------- histogram ---------------- */
  if (variant === 'histogram') {
    const st = stats(values);
    const nice = niceTicks(st.min, st.max, binsProp ?? (phone ? 8 : 12));
    const step = nice.ticks[1] - nice.ticks[0] || 1;
    const edges = nice.ticks;
    const counts = edges.slice(0, -1).map((a, i) => values.filter((v) => v >= a && (i === edges.length - 2 ? v <= edges[i + 1] : v < edges[i + 1])).length);
    const ny = niceTicks(0, Math.max(...counts, 1), 4);
    const height = phone ? 260 : 300;
    const left = Math.max(...ny.ticks.map((v) => textWidth(String(v), 11)), 14) + 14;
    const right = width - 8;
    const top = 30;
    const bottom = height - 42;
    const X = linear(nice.min, nice.max, left, right);
    const Y = linear(0, ny.max, bottom, top);
    const n = counts.length;
    const bw = X(nice.min + step) - X(nice.min);
    const pick = (clientX: number, el: HTMLElement) => {
      const r = el.getBoundingClientRect();
      const px = (clientX - r.left) * (width / (r.width || width));
      setActive(Math.max(0, Math.min(n - 1, Math.floor((px - left) / bw))));
    };
    const rows = (i: number): TooltipRow[] => [
      { key: 'c', label: t.count, value: String(counts[i]), color: 'var(--primary)', shape: 'rect' },
      { key: 's', label: t.shareLabel, value: pct(counts[i] / Math.max(1, values.length)) },
    ];
    const binTitle = (i: number) => fill(t.range, { a: fmt(edges[i]), b: fmt(edges[i + 1]) });
    const every = Math.max(1, Math.ceil((textWidth(tick(nice.max), 11) + 12) / bw));
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={wrapRef}>
            <ChartSurface label={name} summary={summary ?? `${fill(t.n, { n: String(values.length) })}. ${t.median} ${fmt(st.median)}.`} labels={labels} live={active !== null ? `${binTitle(active)}. ${t.count} ${counts[active]}` : ''} table={<ChartTable caption={name} head={[unitLabel, t.count]} rows={counts.map((c, i) => [binTitle(i), String(c)])} />} onKeyDown={keys(n)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
              <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
                <PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={ny.ticks.map((v) => ({ at: Y(v), label: String(v), strong: v === 0 }))} xTicks={edges.map((v, i) => ({ at: X(v), label: i % every ? '' : tick(v) }))} yTitle={t.count} xTitle={unitLabel} />
                {counts.map((c, i) => {
                  const x0 = X(edges[i]) + 1;
                  const h = Y(0) - Y(c);
                  const inMid = edges[i + 1] > st.q1 && edges[i] < st.q3;
                  return c > 0 ? (
                    <path key={i} d={barPath(x0, Y(c), Math.max(1, bw - 2), h, 3, 'up')} fill={inMid ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 50%, var(--card))'} opacity={active !== null && active !== i ? 0.5 : 1} className={cn('transition-opacity duration-200', anim && 'kitch-grow-y')} style={d(80 + i * 45)} />
                  ) : null;
                })}
                {/* Median */}
                <g className={anim ? 'kitch-fade' : undefined} style={d(800)}>
                  <line x1={X(st.median)} x2={X(st.median)} y1={top - 10} y2={bottom} stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="4 3" />
                  <text x={X(st.median) + 6} y={top - 12} className="text-[11px] font-medium tabular-nums" fill="var(--foreground)" style={HALO}>
                    {`${t.median} ${fmt(st.median)}`}
                  </text>
                </g>
              </svg>
              <ChartTooltip open={open} jump={jump} x={shown !== null ? X(edges[shown]) + bw / 2 : 0} y={top} width={width} offset={bw / 2 + 6} title={shown !== null ? binTitle(shown) : ''} rows={shown !== null ? rows(shown) : []} />
            </ChartSurface>
            <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
              <li className="flex items-center gap-1.5"><Swatch color="var(--primary)" />{t.bandMid}</li>
              <li className="flex items-center gap-1.5"><Swatch color={'color-mix(in oklab, var(--primary) 50%, var(--card))'} />{t.outer}</li>
            </ul>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- percentiles ---------------- */
  if (variant === 'percentiles') {
    const s = [...values].sort((a, b) => a - b);
    const P = [10, 25, 50, 75, 90].map((q) => quantile(s, q / 100));
    const nice = niceTicks(s[0], s[s.length - 1], phone ? 4 : 6);
    const X = (v: number) => `${round(((v - nice.min) / (nice.max - nice.min || 1)) * 100, 2)}%`;
    const rank = marker ? s.filter((v) => (lowerIsBetter ? v > marker.value : v < marker.value)).length / Math.max(1, s.length) : 0;
    const items = [
      { label: t.p.replace('{n}', '10'), v: P[0] },
      { label: t.p.replace('{n}', '25'), v: P[1] },
      { label: t.median, v: P[2] },
      { label: t.p.replace('{n}', '75'), v: P[3] },
      { label: t.p.replace('{n}', '90'), v: P[4] },
    ];
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <div ref={wrapRef}>
            <ChartSurface label={name} summary={summary ?? items.map((i) => `${i.label} ${fmt(i.v)}`).join(', ')} labels={labels} live={active !== null ? `${items[active].label} ${fmt(items[active].v)}` : ''} table={<ChartTable caption={name} head={[t.value, unitLabel]} rows={[...items.map((i) => [i.label, fmt(i.v)]), ...(marker ? [[marker.label, fmt(marker.value)]] : [])]} />} onKeyDown={keys(items.length)} onFocus={focusIn} onBlur={() => setActive(null)}>
              {marker && (
                <div className="relative mb-2 h-14">
                  <div className={cn('absolute bottom-0 flex -translate-x-1/2 flex-col items-center', anim && 'kitch-rise')} style={{ left: X(marker.value), ...d(900) }}>
                    <span className="whitespace-nowrap rounded-md bg-foreground px-2 py-1 text-[12px] font-semibold tabular-nums text-background shadow-[var(--shadow-sm)]">{`${marker.label} · ${fmt(marker.value)}`}</span>
                    <span aria-hidden className="h-3 w-px bg-foreground" />
                  </div>
                </div>
              )}
              <div className="relative h-14 overflow-hidden rounded-lg bg-[color-mix(in_oklab,var(--foreground)_5%,var(--card))]">
                <div className={cn('absolute inset-y-0', anim && 'kitch-grow-x')} style={{ left: X(P[0]), width: `calc(${X(P[4])} - ${X(P[0])})`, background: 'color-mix(in oklab, var(--primary) 22%, var(--card))', ...d(100) }} />
                <div className={cn('absolute inset-y-0', anim && 'kitch-grow-x')} style={{ left: X(P[1]), width: `calc(${X(P[3])} - ${X(P[1])})`, background: 'color-mix(in oklab, var(--primary) 48%, var(--card))', ...d(300) }} />
                <div className="absolute inset-y-0 w-[3px] -translate-x-1/2 bg-primary" style={{ left: X(P[2]) }} />
                {marker && <div aria-hidden className="absolute inset-y-0 w-[2px] -translate-x-1/2 bg-foreground" style={{ left: X(marker.value) }} />}
                {items.map((it, i) => (
                  <button
                    key={it.label}
                    type="button"
                    tabIndex={-1}
                    aria-hidden
                    onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)}
                    onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}
                    onClick={() => setActive(active === i ? null : i)}
                    className="absolute inset-y-0 w-6 -translate-x-1/2"
                    style={{ left: X(it.v) }}
                  />
                ))}
              </div>
              <div aria-hidden className="relative mt-2 h-10 text-[11px] tabular-nums">
                {items.map((it, i) => (
                  <span key={it.label} className={cn('absolute top-0 flex -translate-x-1/2 flex-col items-center whitespace-nowrap transition-colors', active === i ? 'text-foreground' : 'text-muted-foreground', phone && (i === 1 || i === 3) && 'hidden')} style={{ left: X(it.v) }}>
                    <span className="font-medium">{it.label}</span>
                    <span className={cn(active === i && 'font-semibold')}>{fmt(it.v)}</span>
                  </span>
                ))}
              </div>
            </ChartSurface>
            <div className="mt-4 flex flex-wrap items-center justify-between gap-x-6 gap-y-3 border-t border-border pt-4">
              <ul className="flex flex-wrap gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
                <li className="flex items-center gap-1.5"><span aria-hidden className="size-2.5 rounded-[3px]" style={{ background: 'color-mix(in oklab, var(--primary) 48%, var(--card))' }} />{`${t.bandMid}, ${fill(t.range, { a: 'P25', b: 'P75' })}`}</li>
                <li className="flex items-center gap-1.5"><span aria-hidden className="size-2.5 rounded-[3px]" style={{ background: 'color-mix(in oklab, var(--primary) 22%, var(--card))' }} />{`${t.bandWide}, ${fill(t.range, { a: 'P10', b: 'P90' })}`}</li>
              </ul>
              {marker && <p className="text-[13px] font-medium text-foreground">{fill(rank >= 0.5 ? t.better : t.worse, { share: pct(rank >= 0.5 ? rank : 1 - rank) })}</p>}
            </div>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  /* ---------------- groups: box-plot, density, strip ---------------- */
  const G = variant === 'density' ? groups.slice(0, 3) : groups;
  const all = G.flatMap((g) => g.values);
  const nice = niceTicks(Math.min(...all), Math.max(...all), phone ? 4 : 6);
  const STS = G.map((g) => stats(g.values));

  if (variant === 'density') {
    const height = phone ? 260 : 300;
    const left = 14;
    const right = width - 10;
    const top = 16;
    const bottom = height - 40;
    const X = linear(nice.min, nice.max, left, right);
    const steps = 80;
    const xs = Array.from({ length: steps + 1 }, (_, i) => nice.min + ((nice.max - nice.min) * i) / steps);
    const dens = G.map((g) => density(g.values, xs));
    const dMax = Math.max(...dens.flat(), 1e-9);
    const Y = linear(0, dMax * 1.08, bottom, top);
    const [hover, setHover] = [active, setActive];
    const pick = (clientX: number, el: HTMLElement) => {
      const r = el.getBoundingClientRect();
      const px = (clientX - r.left) * (width / (r.width || width));
      setHover(Math.max(0, Math.min(steps, Math.round(((px - left) / (right - left)) * steps))));
    };
    return (
      <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
        <ChartRoot anim={anim} rootRef={rootRef}>
          <PickLegend className="mb-3" active={focus} onActive={setFocus} items={G.map((g, i) => ({ label: `${g.label} · ${t.median} ${fmt(STS[i].median)}`, key: <Swatch color={pairColor(i)} /> }))} />
          <div ref={wrapRef}>
            <ChartSurface label={name} summary={summary ?? G.map((g, i) => `${g.label}, ${t.median} ${fmt(STS[i].median)}`).join('. ')} labels={labels} live={hover !== null ? `${fmt(xs[hover])}` : ''} table={<ChartTable caption={name} head={[t.group, t.q1, t.median, t.q3]} rows={G.map((g, i) => [g.label, fmt(STS[i].q1), fmt(STS[i].median), fmt(STS[i].q3)])} />} onKeyDown={keys(steps + 1)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientX, e.currentTarget)} onPointerDown={(e) => pick(e.clientX, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
              <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
                <PlotAxes left={left} right={right} top={top} bottom={bottom} height={height} yTicks={[{ at: bottom, label: '', strong: true }]} xTicks={nice.ticks.map((v) => ({ at: X(v), label: tick(v) }))} xTitle={unitLabel} xGrid />
                {[...G.keys()].reverse().map((i) => {
                  const pts = xs.map((x, k) => [X(x), Y(dens[i][k])] as [number, number]);
                  const line = linePath(pts);
                  return (
                    <g key={G[i].label} className="kitch-series" opacity={focus !== null && focus !== i ? 0.15 : 1}>
                      <path d={`${line}L${X(nice.max)} ${bottom}L${X(nice.min)} ${bottom}Z`} fill={pairColor(i)} fillOpacity="0.12" className={anim ? 'kitch-fade' : undefined} style={d(400 + i * 200)} />
                      <path d={line} pathLength={1} fill="none" stroke={pairColor(i)} strokeWidth="2" className={anim ? 'kitch-draw' : undefined} style={d(100 + i * 200)} />
                      <line x1={X(STS[i].median)} x2={X(STS[i].median)} y1={Y(density(G[i].values, [STS[i].median])[0])} y2={bottom} stroke={pairColor(i)} strokeWidth="1.5" strokeDasharray="3 3" />
                    </g>
                  );
                })}
                {hover !== null && (
                  <g>
                    <line x1={X(xs[hover])} x2={X(xs[hover])} y1={top} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
                    {G.map((g, i) => (
                      <circle key={g.label} cx={X(xs[hover])} cy={Y(dens[i][hover])} r={4} fill={pairColor(i)} stroke="var(--kitch-surface)" strokeWidth="2" />
                    ))}
                  </g>
                )}
              </svg>
              <ChartTooltip open={open} jump={jump} x={shown !== null ? X(xs[shown]) : 0} y={top} width={width} title={shown !== null ? fmt(xs[shown]) : ''} rows={shown !== null ? G.map((g, i) => ({ key: g.label, label: g.label, value: pct(g.values.filter((v) => v <= xs[shown]).length / Math.max(1, g.values.length)), color: pairColor(i), shape: 'line' as const })) : []} footer={undefined} />
            </ChartSurface>
          </div>
        </ChartRoot>
      </ChartFigure>
    );
  }

  // box-plot and strip: one row per group
  const rowH = variant === 'strip' ? (phone ? 64 : 72) : 52;
  const labelW = phone ? 0 : Math.min(150, Math.max(...G.map((g) => textWidth(g.label, 12))) + 16);
  const left = labelW + 10;
  const right = width - 10;
  const top = phone ? 6 : 4;
  const groupTop = (i: number) => top + i * (rowH + (phone ? 22 : 0)) + (phone ? 20 : 0);
  const height = groupTop(G.length) + 28;
  const X = linear(nice.min, nice.max, left, right);
  const rows = (i: number): TooltipRow[] => {
    const s = STS[i];
    return [
      { key: 'm', label: t.median, value: fmt(s.median) },
      { key: 'q', label: `${t.q1} / ${t.q3}`, value: `${fmt(s.q1)} / ${fmt(s.q3)}` },
      { key: 'r', label: `${t.min} / ${t.max}`, value: `${fmt(s.min)} / ${fmt(s.max)}` },
      { key: 'n', label: t.count, value: String(G[i].values.length) },
    ];
  };
  // Strip: pack dots in rows so none covers another (simple beeswarm)
  const swarm = (vals: number[], cy: number, r: number) => {
    const placed: { x: number; y: number; v: number }[] = [];
    [...vals].sort((a, b) => a - b).forEach((v) => {
      const x = X(v);
      for (let k = 0; k < 40; k++) {
        const off = (k % 2 ? 1 : -1) * Math.ceil(k / 2) * (r * 2 + 1);
        const y = cy + off;
        if (Math.abs(off) > rowH / 2 - r) continue;
        if (placed.every((p) => (p.x - x) ** 2 + (p.y - y) ** 2 >= (2 * r + 0.8) ** 2)) {
          placed.push({ x, y, v });
          return;
        }
      }
      placed.push({ x, y: cy, v });
    });
    return placed;
  };
  const pick = (clientY: number, el: HTMLElement) => {
    const r = el.getBoundingClientRect();
    const y = clientY - r.top;
    const i = G.findIndex((_, k) => y >= groupTop(k) - (phone ? 20 : 0) && y < groupTop(k) + rowH);
    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}>
        <div ref={wrapRef}>
          <ChartSurface label={name} summary={summary ?? G.map((g, i) => `${g.label}, ${t.median} ${fmt(STS[i].median)}`).join('. ')} labels={labels} live={active !== null && G[active] ? `${G[active].label}. ${rows(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : ''} table={<ChartTable caption={name} head={[t.group, t.min, t.q1, t.median, t.q3, t.max]} rows={G.map((g, i) => [g.label, fmt(STS[i].min), fmt(STS[i].q1), fmt(STS[i].median), fmt(STS[i].q3), fmt(STS[i].max)])} />} onKeyDown={keys(G.length)} onFocus={focusIn} onBlur={() => setActive(null)} onPointerMove={(e) => pick(e.clientY, e.currentTarget)} onPointerDown={(e) => pick(e.clientY, e.currentTarget)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              {nice.ticks.map((v) => (
                <g key={v}>
                  <line x1={X(v)} x2={X(v)} y1={top} y2={height - 22} stroke="var(--kitch-grid)" strokeWidth="1" shapeRendering="crispEdges" />
                  <text x={X(v)} y={height - 6} textAnchor="middle" className="text-[11px] tabular-nums" fill="var(--muted-foreground)">{tick(v)}</text>
                </g>
              ))}
              {G.map((g, i) => {
                const s = STS[i];
                const y0 = groupTop(i);
                const cy = y0 + rowH / 2;
                const on = active === i;
                const dim = active !== null && !on;
                const bh = 22;
                return (
                  <g key={g.label} className="transition-opacity duration-200" opacity={dim ? 0.45 : 1}>
                    {on && <rect x={0} y={y0 - (phone ? 20 : 0)} width={width} height={rowH + (phone ? 20 : 0)} rx="8" fill="var(--foreground)" opacity="0.045" />}
                    {phone ? (
                      <text x={left} y={y0 - 6} className="text-[12px] font-medium" fill="var(--foreground)">{g.label}</text>
                    ) : (
                      <text x={0} y={cy} dy="0.34em" className="text-[12px] font-medium" fill="var(--foreground)">{g.label}</text>
                    )}
                    {variant === 'box-plot' ? (
                      <g className={anim ? 'kitch-grow-x' : undefined} style={{ ...d(100 + i * 90), transformOrigin: `${X(s.median)}px ${cy}px`, transformBox: 'view-box' }}>
                        <line x1={X(s.lo)} x2={X(s.q1)} y1={cy} y2={cy} stroke="var(--muted-foreground)" strokeWidth="1.5" />
                        <line x1={X(s.q3)} x2={X(s.hi)} y1={cy} y2={cy} stroke="var(--muted-foreground)" strokeWidth="1.5" />
                        <line x1={X(s.lo)} x2={X(s.lo)} y1={cy - 7} y2={cy + 7} stroke="var(--muted-foreground)" strokeWidth="1.5" />
                        <line x1={X(s.hi)} x2={X(s.hi)} y1={cy - 7} y2={cy + 7} stroke="var(--muted-foreground)" strokeWidth="1.5" />
                        <rect x={X(s.q1)} y={cy - bh / 2} width={Math.max(2, X(s.q3) - X(s.q1))} height={bh} rx="4" fill="color-mix(in oklab, var(--primary) 26%, var(--card))" stroke="var(--primary)" strokeWidth="1.5" />
                        <line x1={X(s.median)} x2={X(s.median)} y1={cy - bh / 2} y2={cy + bh / 2} stroke="var(--foreground)" strokeWidth="2.5" />
                        {s.out.map((v, k) => (
                          <circle key={k} cx={X(v)} cy={cy} r={3.5} fill="var(--kitch-surface)" stroke="var(--muted-foreground)" strokeWidth="1.5" />
                        ))}
                      </g>
                    ) : (
                      <g>
                        {swarm(g.values, cy, phone ? 3 : 3.5).map((p, k) => (
                          <circle key={k} cx={p.x} cy={round(p.y)} r={phone ? 3 : 3.5} fill={p.v >= s.q1 && p.v <= s.q3 ? 'var(--primary)' : 'color-mix(in oklab, var(--primary) 50%, var(--card))'} className={anim ? 'kitch-pop' : undefined} style={d(80 + i * 120 + k * 6)} />
                        ))}
                        <line x1={X(s.median)} x2={X(s.median)} y1={y0 + 2} y2={y0 + rowH - 2} stroke="var(--foreground)" strokeWidth="2" strokeLinecap="round" />
                      </g>
                    )}
                    {!phone && on && (
                      <text x={X(s.median)} y={y0 - 2} textAnchor="middle" className="text-[11px] font-semibold tabular-nums" fill="var(--foreground)" style={HALO}>{fmt(s.median)}</text>
                    )}
                  </g>
                );
              })}
            </svg>
            <ChartTooltip open={open} jump={jump} x={shown !== null && STS[shown] ? X(STS[shown].q3) : 0} y={shown !== null ? groupTop(shown) : 0} width={width} offset={16} title={shown !== null && G[shown] ? G[shown].label : ''} rows={shown !== null && STS[shown] ? rows(shown) : []} />
          </ChartSurface>
          <ul className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[12px] text-muted-foreground">
            {variant === 'box-plot' ? (
              <>
                <li className="flex items-center gap-1.5"><span aria-hidden className="h-2.5 w-4 rounded-[3px] border-[1.5px] border-primary" style={{ background: 'color-mix(in oklab, var(--primary) 26%, var(--card))' }} />{t.bandMid}</li>
                <li className="flex items-center gap-1.5"><span aria-hidden className="h-3 w-[2.5px] bg-foreground" />{t.median}</li>
                <li className="flex items-center gap-1.5"><span aria-hidden className="size-2.5 rounded-full border-[1.5px] border-muted-foreground" />{t.outliers}</li>
              </>
            ) : (
              <>
                <li className="flex items-center gap-1.5"><Swatch shape="dot" color="var(--primary)" />{t.bandMid}</li>
                <li className="flex items-center gap-1.5"><Swatch shape="dot" color={'color-mix(in oklab, var(--primary) 50%, var(--card))'} />{t.outer}</li>
                <li className="flex items-center gap-1.5"><span aria-hidden className="h-3 w-[2px] bg-foreground" />{t.median}</li>
              </>
            )}
            {unitLabel && <li className="ms-auto">{unitLabel}</li>}
          </ul>
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Verteilungsdiagramm?

Mit aiw add @ai-website/distribution-chart. Der Befehl legt die Datei components/ui/distribution-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 Verteilungsdiagramm?

5 Varianten, Histogramm, Boxplot, Dichte, Punktstreifen und Perzentile. Du wählst sie mit der Prop variant, zum Beispiel variant="box-plot".

Kann ich die Texte von Verteilungsdiagramm übersetzen?

Ja. Verteilungsdiagramm bringt 18 eigene Texte auf Deutsch und Englisch mit, etwa „Anteil“, „Außerhalb der mittleren Hälfte“ und „Anzahl“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Verteilungsdiagramm einbauen?

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

Kann ich Verteilungsdiagramm ohne Code anpassen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert