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
Variants
Details
Was Verteilungsdiagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Statistik-Charts
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalDistributionVarianthistogrambox-plotdensitystrippercentiles
valuesoptionalnumber[]histogram, percentiles: the raw values. Other variants: use groups.
groupsoptionalDistributionGroup[]box-plot, density, strip: one entry per group
unitLabeloptionalstringName of the measure, shown at the axis
formatoptionalIntl.NumberFormatOptionsbinsoptionalnumberhistogram: 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
lowerIsBetteroptionalbooleanFor 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| shareLabel | Englisch Share | Deutsch Anteil |
| outer | Englisch Outside the middle half | Deutsch Außerhalb der mittleren Hälfte |
| count | Englisch Count | Deutsch Anzahl |
| range | Englisch {a} to {b} | Deutsch {a} bis {b} |
| median | Englisch Median | Deutsch Median |
| q1 | Englisch Lower quartile | Deutsch Unteres Quartil |
| q3 | Englisch Upper quartile | Deutsch Oberes Quartil |
| min | Englisch Lowest | Deutsch Kleinster |
| max | Englisch Highest | Deutsch Größter |
| outliers | Englisch Outliers | Deutsch Ausreißer |
| n | Englisch {n} values | Deutsch {n} Werte |
| group | Englisch Group | Deutsch Gruppe |
| p | Englisch P{n} | Deutsch P{n} |
| better | Englisch Better than {share} of all | Deutsch Besser als {share} aller |
| worse | Englisch Behind {share} of all | Deutsch Hinter {share} aller |
| bandMid | Englisch Middle half | Deutsch Mittlere Hälfte |
| bandWide | Englisch 80 of 100 | Deutsch 80 von 100 |
| value | Englisch Value | Deutsch 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
'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.