Vergleichsdiagramm
Zwei Zustände nebeneinander, als Steigungsdiagramm, Schmetterling, Balken in zwei Richtungen, A/B-Test mit Intervallen und Sicherheit oder als Reihen, die auf einer Achse bei 100 beginnen.
Eine von 5 Components in Vergleichs-Charts
Variants
Details
Was Vergleichsdiagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Vergleichs-Charts
- Bereich
- Elements
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/comparison-chart.tsx
- Bringt mit
- utilskiticonchartchart-figure
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 11 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ComparisonChartProps aus components/ui/comparison-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.
variantoptionalComparisonVariantslopebutterflydivergingab-testindexed
itemsoptionalComparisonItem[]slope, butterfly, diverging
sidesoptional[string, string]Names of the two states or groups, e.g. ['2025', '2026'] or ['Women', 'Men']
versionsoptionalAbVersion[]ab-test: the control first
xoptionalstring[]indexed: the x values ("2026-03" is read as a month)
seriesoptionalIndexedSeries[]indexed: up to three series
formatoptionalIntl.NumberFormatOptionshighlightoptionalstring[]slope: items named in the accent. Default: the two that moved most.
Eingebaute Texte
Diese Texte schreibt Vergleichsdiagramm selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 11 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| item | Englisch Item | Deutsch Eintrag |
| change | Englisch Change | Deutsch Veränderung |
| lift | Englisch Lift | Deutsch Plus |
| rate | Englisch Conversion | Deutsch Abschlussrate |
| interval | Englisch 95% interval {lo} to {hi} | Deutsch 95-%-Intervall {lo} bis {hi} |
| sure | Englisch {p} sure that {b} beats {a} | Deutsch Zu {p} sicher, dass {b} vor {a} liegt |
| notSure | Englisch Not decided yet, {p} for {b} | Deutsch Noch offen, {p} für {b} |
| visitors | Englisch {n} visitors | Deutsch {n} Besuche |
| index | Englisch Index, start = 100 | Deutsch Index, Start = 100 |
| point | Englisch Point | Deutsch Zeitpunkt |
| winner | Englisch Ahead | Deutsch Vorn |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Vergleichsdiagramm 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, 405 Zeilen
'use client';
import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { ChartRoot, ChartSurface, ChartTable, Swatch, barPath, fill, linePath, niceTicks, numberFormatter, textWidth, tickFormatter, useEntrance, useIntlLocale, useWidth, xFormatters, type ChartA11y } from '@/components/ui/chart';
import { ChartFigure, HALO, PickLegend, PlotAxes, linear, pairColor, type ChartFigureProps } from '@/components/ui/chart-figure';
/*
* Two states side by side.
* slope before and after as two columns, a line per item, the big movers in the accent
* butterfly two groups as bars to the left and right of a shared label column
* diverging the change per item, gains to the right, losses to the left of zero
* ab-test two versions with their conversion rate and its 95 % interval,
* the lift and how sure the result is
* indexed several series set to 100 at the start, so different sizes share one axis
*
* One scale per chart, never two y axes. Gains and losses carry a sign and
* an arrow besides their tone.
*/
export type ComparisonVariant = 'slope' | 'butterfly' | 'diverging' | 'ab-test' | 'indexed';
export interface ComparisonItem {
label: string;
/** Value of the first state or group (before, left) */
a: number;
/** Value of the second state or group (after, right) */
b: number;
}
export interface AbVersion {
label: string;
visitors: number;
conversions: number;
}
export interface IndexedSeries {
label: string;
/** Raw values, one per point of `x`. The chart sets the first to 100. */
values: number[];
}
export interface ComparisonChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
/** Accessible name of the chart. Default: the title. */
label?: string;
variant?: ComparisonVariant;
/** slope, butterfly, diverging */
items?: ComparisonItem[];
/** Names of the two states or groups, e.g. ['2025', '2026'] or ['Women', 'Men'] */
sides?: [string, string];
/** ab-test: the control first */
versions?: AbVersion[];
/** indexed: the x values ("2026-03" is read as a month) */
x?: string[];
/** indexed: up to three series */
series?: IndexedSeries[];
format?: Intl.NumberFormatOptions;
/** slope: items named in the accent. Default: the two that moved most. */
highlight?: string[];
className?: string;
}
const TEXTS = {
en: { item: 'Item', change: 'Change', lift: 'Lift', rate: 'Conversion', interval: '95% interval {lo} to {hi}', sure: '{p} sure that {b} beats {a}', notSure: 'Not decided yet, {p} for {b}', visitors: '{n} visitors', index: 'Index, start = 100', point: 'Point', winner: 'Ahead' },
de: { item: 'Eintrag', change: 'Veränderung', lift: 'Plus', rate: 'Abschlussrate', interval: '95-%-Intervall {lo} bis {hi}', sure: 'Zu {p} sicher, dass {b} vor {a} liegt', notSure: 'Noch offen, {p} für {b}', visitors: '{n} Besuche', index: 'Index, Start = 100', point: 'Zeitpunkt', winner: 'Vorn' },
};
/** Standard normal CDF (Abramowitz and Stegun 7.1.26) */
function phi(z: number) {
const t = 1 / (1 + 0.3275911 * Math.abs(z) / Math.SQRT2);
const y = 1 - ((((1.061405429 * t - 1.453152027) * t + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-(z * z) / 2);
return z >= 0 ? (1 + y) / 2 : (1 - y) / 2;
}
/**
* Two states compared: a slope chart, a butterfly, diverging bars, an A/B test with intervals or series indexed to 100.
* <ComparisonChart variant="slope" sides={['2025', '2026']} items={[{ label: 'Search', a: 38, b: 46 }]} />
*/
export function ComparisonChart(props: ComparisonChartProps) {
const { variant = 'slope', items = [], sides = ['A', 'B'], versions = [], x = [], series = [], format, highlight, 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', minimumFractionDigits: 1, maximumFractionDigits: 1 });
const pct0 = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 0 });
const signed = (n: number, f = fmt) => (n > 0 ? '+' : n < 0 ? '−' : '') + f(Math.abs(n));
const [wrapRef, width] = useWidth<HTMLDivElement>(720);
const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
const [active, setActive] = useState<number | null>(null);
const [focus, setFocus] = useState<number | null>(null);
const name = label ?? title ?? '';
const phone = width < 520;
const n = variant === 'ab-test' ? versions.length : variant === 'indexed' ? x.length : items.length;
const d = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
const onKeyDown = (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 focusIn = (e: React.FocusEvent) => e.target === e.currentTarget && active === null && (e.currentTarget as HTMLElement).matches(':focus-visible') && setActive(0);
/* ---------------- slope ---------------- */
if (variant === 'slope') {
const movers = highlight ?? [...items].sort((p, q) => Math.abs(q.b - q.a) - Math.abs(p.b - p.a)).slice(0, 2).map((i) => i.label);
const all = items.flatMap((i) => [i.a, i.b]);
const nice = niceTicks(Math.min(...all), Math.max(...all), 5);
const height = phone ? 340 : 380;
const lw = Math.min(phone ? 104 : 170, Math.max(...items.map((i) => textWidth(`${i.label} ${fmt(i.a)}`, 12))) + 12);
const left = lw + 4;
const right = width - lw - 4;
const top = 34;
const bottom = height - 14;
const Y = linear(nice.min, nice.max, bottom, top);
// Labels apart: push labels on each side so they never overlap (12 px apart at least)
const spread = (ys: number[]) => {
const order = ys.map((y, i) => ({ y, i })).sort((p, q) => p.y - q.y);
for (let k = 1; k < order.length; k++) if (order[k].y - order[k - 1].y < 15) order[k].y = order[k - 1].y + 15;
const out: number[] = [];
order.forEach((o) => (out[o.i] = o.y));
return out;
};
const la = spread(items.map((i) => Y(i.a)));
const lb = spread(items.map((i) => Y(i.b)));
const live = active !== null && items[active] ? `${items[active].label}. ${sides[0]} ${fmt(items[active].a)}, ${sides[1]} ${fmt(items[active].b)}, ${t.change} ${signed(items[active].b - items[active].a)}` : '';
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.a)} → ${fmt(i.b)}`).join(', ')} labels={labels} live={live} table={<ChartTable caption={name} head={[t.item, sides[0], sides[1], t.change]} rows={items.map((i) => [i.label, fmt(i.a), fmt(i.b), signed(i.b - i.a)])} />} onKeyDown={onKeyDown} onFocus={focusIn} onBlur={() => setActive(null)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)}>
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
<text x={left} y={14} textAnchor="middle" className="text-[12px] font-semibold" fill="var(--foreground)">{sides[0]}</text>
<text x={right} y={14} textAnchor="middle" className="text-[12px] font-semibold" fill="var(--foreground)">{sides[1]}</text>
<line x1={left} x2={left} y1={top - 8} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
<line x1={right} x2={right} y1={top - 8} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />
{items.map((it, i) => {
const hot = movers.includes(it.label);
const on = active === i;
const dim = active !== null && !on;
const color = hot || on ? 'var(--primary)' : 'var(--muted-foreground)';
return (
<g key={it.label} className="cursor-default transition-opacity duration-200" opacity={dim ? 0.3 : 1} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerDown={() => setActive(i)}>
<line x1={left} y1={Y(it.a)} x2={right} y2={Y(it.b)} stroke="transparent" strokeWidth="14" />
<path d={`M${left} ${Y(it.a)}L${right} ${Y(it.b)}`} pathLength={1} stroke={color} strokeWidth={hot || on ? 2.5 : 1.5} fill="none" className={anim ? 'kitch-draw' : undefined} style={d(150 + i * 70)} />
<circle cx={left} cy={Y(it.a)} r={4} fill={color} stroke="var(--kitch-surface)" strokeWidth="2" />
<circle cx={right} cy={Y(it.b)} r={4} fill={color} stroke="var(--kitch-surface)" strokeWidth="2" className={anim ? 'kitch-pop' : undefined} style={d(700 + i * 70)} />
<text x={left - 10} y={la[i]} dy="0.34em" textAnchor="end" className="text-[12px] tabular-nums" fill={hot || on ? 'var(--foreground)' : 'var(--muted-foreground)'}>
{phone ? fmt(it.a) : `${it.label} ${fmt(it.a)}`}
</text>
<text x={right + 10} y={lb[i]} dy="0.34em" textAnchor="start" className={cn('text-[12px] tabular-nums', (hot || on) && 'font-medium')} fill={hot || on ? 'var(--foreground)' : 'var(--muted-foreground)'}>
{phone ? `${fmt(it.b)} ${it.label}` : `${fmt(it.b)} ${it.label}`}
</text>
</g>
);
})}
</svg>
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- butterfly and diverging: rows ---------------- */
if (variant === 'butterfly' || variant === 'diverging') {
const rowH = 34;
const height = items.length * rowH + 8;
const isB = variant === 'butterfly';
const max = isB ? Math.max(...items.flatMap((i) => [i.a, i.b]), 1) : Math.max(...items.map((i) => Math.abs(i.b - i.a)), 1);
const nice = niceTicks(0, max, 3);
const labelW = isB ? Math.min(phone ? 92 : 150, Math.max(...items.map((i) => textWidth(i.label, 12))) + 20) : 0;
const valW = Math.max(...items.map((i) => textWidth(isB ? fmt(Math.max(i.a, i.b)) : signed(i.b - i.a), 12))) + 10;
const mid = width / 2;
const half = (isB ? mid - labelW / 2 : mid) - valW - 4;
const L = (v: number) => round((v / nice.max) * half);
const thick = 16;
const rowsOf = items.map((it, i) => ({ it, i }));
const shownRows = isB ? rowsOf : [...rowsOf].sort((p, q) => q.it.b - q.it.a - (p.it.b - p.it.a));
const live = active !== null && items[active] ? (isB ? `${items[active].label}. ${sides[0]} ${fmt(items[active].a)}, ${sides[1]} ${fmt(items[active].b)}` : `${items[active].label}. ${t.change} ${signed(items[active].b - items[active].a)}`) : '';
const pick = (clientY: number, el: HTMLElement) => {
const r = el.getBoundingClientRect();
const k = Math.max(0, Math.min(items.length - 1, Math.floor((clientY - r.top - 4) / rowH)));
setActive(shownRows[k].i);
};
return (
<ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
<ChartRoot anim={anim} rootRef={rootRef}>
<div ref={wrapRef}>
<div aria-hidden className="mb-3 grid grid-cols-2 text-[12px] font-medium">
<span className="flex items-center gap-1.5 justify-self-start text-foreground">
{isB ? <Swatch color={pairColor(1)} /> : <Icon name="arrow-down-right" size={13} strokeWidth={2} className="text-destructive" />}
{sides[0]}
</span>
<span className="flex items-center gap-1.5 justify-self-end text-foreground">
{sides[1]}
{isB ? <Swatch color={pairColor(0)} /> : <Icon name="arrow-up-right" size={13} strokeWidth={2} className="text-success-foreground" />}
</span>
</div>
<ChartSurface label={name} summary={summary ?? ''} labels={labels} live={live} table={<ChartTable caption={name} head={isB ? [t.item, sides[0], sides[1]] : [t.item, t.change]} rows={items.map((i) => (isB ? [i.label, fmt(i.a), fmt(i.b)] : [i.label, signed(i.b - i.a)]))} />} onKeyDown={onKeyDown} 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">
{!isB && <line x1={mid} x2={mid} y1={0} y2={height} stroke="var(--kitch-axis)" strokeWidth="1" />}
{shownRows.map(({ it, i }, k) => {
const cy = 4 + k * rowH + rowH / 2;
const on = active === i;
const dim = active !== null && !on;
if (isB) {
const la = L(it.a);
const lb = L(it.b);
return (
<g key={it.label} className="transition-opacity duration-200" opacity={dim ? 0.45 : 1}>
{on && <rect x={0} y={cy - rowH / 2 + 1} width={width} height={rowH - 2} rx="6" fill="var(--foreground)" opacity="0.05" />}
<text x={mid} y={cy} dy="0.34em" textAnchor="middle" className={cn('text-[12px]', on && 'font-medium')} fill="var(--foreground)">{it.label}</text>
<path d={barPath(mid - labelW / 2 - la, cy - thick / 2, la, thick, 4, 'left')} fill={pairColor(1)} className={cn('kitch-series', anim && 'kitch-grow-x')} style={{ ...d(100 + k * 60), transformOrigin: `${mid - labelW / 2}px ${cy}px`, transformBox: 'view-box' }} />
<path d={barPath(mid + labelW / 2, cy - thick / 2, lb, thick, 4, 'right')} fill={pairColor(0)} className={cn('kitch-series', anim && 'kitch-grow-x')} style={{ ...d(100 + k * 60), transformOrigin: `${mid + labelW / 2}px ${cy}px`, transformBox: 'view-box' }} />
<text x={mid - labelW / 2 - la - 6} y={cy} dy="0.34em" textAnchor="end" className="text-[12px] tabular-nums" fill="var(--muted-foreground)">{fmt(it.a)}</text>
<text x={mid + labelW / 2 + lb + 6} y={cy} dy="0.34em" textAnchor="start" className="text-[12px] tabular-nums" fill="var(--muted-foreground)">{fmt(it.b)}</text>
</g>
);
}
const ch = it.b - it.a;
const len = L(Math.abs(ch));
const up = ch >= 0;
return (
<g key={it.label} className="transition-opacity duration-200" opacity={dim ? 0.45 : 1}>
{on && <rect x={0} y={cy - rowH / 2 + 1} width={width} height={rowH - 2} rx="6" fill="var(--foreground)" opacity="0.05" />}
<path d={barPath(up ? mid : mid - len, cy - thick / 2, Math.max(1, len), thick, 4, up ? 'right' : 'left')} fill={up ? 'var(--kitfg-up)' : 'var(--kitfg-down)'} className={anim ? 'kitch-grow-x' : undefined} style={{ ...d(100 + k * 60), transformOrigin: `${mid}px ${cy}px`, transformBox: 'view-box' }} />
{/* Label on the far side of the bar, value at its tip */}
<text x={up ? mid - 10 : mid + 10} y={cy} dy="0.34em" textAnchor={up ? 'end' : 'start'} className={cn('text-[12px]', on && 'font-medium')} fill="var(--foreground)">{it.label}</text>
<text x={up ? mid + len + 6 : mid - len - 6} y={cy} dy="0.34em" textAnchor={up ? 'start' : 'end'} className="text-[12px] font-medium tabular-nums" fill="var(--foreground)">{signed(ch)}</text>
</g>
);
})}
</svg>
</ChartSurface>
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- A/B test ---------------- */
if (variant === 'ab-test') {
const rates = versions.map((v) => v.conversions / Math.max(1, v.visitors));
const se = versions.map((v, i) => Math.sqrt((rates[i] * (1 - rates[i])) / Math.max(1, v.visitors)));
const ci = rates.map((r, i) => [Math.max(0, r - 1.96 * se[i]), r + 1.96 * se[i]] as const);
const base = rates[0] ?? 0;
const lo = Math.min(...ci.map((c) => c[0]));
const hi = Math.max(...ci.map((c) => c[1]));
const nice = niceTicks(Math.max(0, lo - (hi - lo) * 0.3), hi + (hi - lo) * 0.3, phone ? 3 : 5);
const best = rates.indexOf(Math.max(...rates));
const z = versions.length > 1 ? (rates[1] - rates[0]) / Math.sqrt(se[0] ** 2 + se[1] ** 2 || 1) : 0;
const p = phi(z);
const sure = p >= 0.95;
const nf = numberFormatter(intl, {});
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 ?? versions.map((v, i) => `${v.label} ${pct(rates[i])}`).join(', ')} labels={labels} live={active !== null && versions[active] ? `${versions[active].label}. ${t.rate} ${pct(rates[active])}, ${fill(t.interval, { lo: pct(ci[active][0]), hi: pct(ci[active][1]) })}` : ''} table={<ChartTable caption={name} head={[t.item, t.rate, '95 %', t.lift]} rows={versions.map((v, i) => [v.label, pct(rates[i]), fill(t.interval, { lo: pct(ci[i][0]), hi: pct(ci[i][1]) }), i ? signed((rates[i] - base) / base, pct) : ''])} />} onKeyDown={onKeyDown} onFocus={focusIn} onBlur={() => setActive(null)}>
<ul className="grid gap-3">
{versions.map((v, i) => {
const X = (r: number) => `${round(((r - nice.min) / (nice.max - nice.min || 1)) * 100, 2)}%`;
const on = active === i;
const color = i === 0 ? 'var(--muted-foreground)' : 'var(--primary)';
return (
<li key={v.label} onPointerEnter={(e) => e.pointerType === 'mouse' && setActive(i)} onPointerLeave={(e) => e.pointerType === 'mouse' && setActive(null)} onPointerDown={() => setActive(i)} className={cn('grid items-center gap-x-5 gap-y-3 rounded-lg border px-4 py-4 transition-colors duration-200', on ? 'border-border-strong bg-accent' : 'border-border', phone ? 'grid-cols-[minmax(0,1fr)_auto]' : 'grid-cols-[minmax(0,11rem)_minmax(0,1fr)_7rem]')}>
<div className="min-w-0">
<p className="flex items-center gap-2 truncate text-[13px] font-medium text-foreground">
{v.label}
{i === best && sure && (
<span className="inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--success)_14%,var(--card))] px-1.5 py-0.5 text-[11px] font-medium text-success-foreground">
<Icon name="check-circle" size={12} strokeWidth={2} />
{t.winner}
</span>
)}
</p>
<p className="mt-0.5 text-[12px] tabular-nums text-muted-foreground">{fill(t.visitors, { n: nf(v.visitors) })}</p>
</div>
{phone && (
<p className="text-right text-[20px] font-semibold tabular-nums leading-none text-foreground">{pct(rates[i])}</p>
)}
<div className={cn('relative h-9', phone && 'col-span-2')}>
{/* scale */}
<div aria-hidden className="absolute inset-x-0 top-1/2 h-px bg-border" />
<div aria-hidden className={cn('absolute top-1/2 h-2 -translate-y-1/2 rounded-full', anim && 'kitch-grow-x')} style={{ left: X(ci[i][0]), width: `calc(${X(ci[i][1])} - ${X(ci[i][0])})`, background: `color-mix(in oklab, ${color} 30%, transparent)`, ...d(150 + i * 200) }} />
<div aria-hidden className={cn('absolute top-1/2 size-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-card', anim && 'kitch-pop')} style={{ left: X(rates[i]), background: color, ...d(500 + i * 200) }} />
{i > 0 && <div aria-hidden className="absolute top-0 h-full w-px bg-[var(--muted-foreground)] opacity-60" style={{ left: X(base) }} />}
</div>
{!phone && (
<div className="text-right">
<p className="text-[20px] font-semibold tabular-nums leading-none text-foreground">{pct(rates[i])}</p>
{i > 0 && <p className={cn('mt-1 inline-flex items-center gap-0.5 text-[12px] font-medium tabular-nums', rates[i] >= base ? 'text-success-foreground' : 'text-destructive')}>
<Icon name={rates[i] >= base ? 'arrow-up-right' : 'arrow-down-right'} size={13} strokeWidth={2} />
{signed((rates[i] - base) / base, pct)}
</p>}
</div>
)}
</li>
);
})}
</ul>
{/* The scale, on the same grid as the rows so every tick sits under its value */}
<div aria-hidden className={cn('mt-2 grid gap-x-5 border border-transparent px-4 text-[11px] tabular-nums text-muted-foreground', phone ? 'grid-cols-[minmax(0,1fr)]' : 'grid-cols-[minmax(0,11rem)_minmax(0,1fr)_7rem]')}>
{!phone && <span />}
<span className="relative h-4">
{nice.ticks.map((v) => (
<span key={v} className="absolute top-0 -translate-x-1/2 whitespace-nowrap" style={{ left: `${round(((v - nice.min) / (nice.max - nice.min || 1)) * 100, 2)}%` }}>
{pct0(v)}
</span>
))}
</span>
{!phone && <span />}
</div>
</ChartSurface>
{versions.length > 1 && (
<p className={cn('mt-5 flex items-start gap-2 rounded-lg px-3.5 py-3 text-[13px]', sure ? 'bg-[color-mix(in_oklab,var(--success)_10%,var(--card))] text-foreground' : 'bg-muted text-foreground')}>
<Icon name={sure ? 'check-circle' : 'help-circle'} size={17} strokeWidth={1.8} className={cn('mt-px shrink-0', sure ? 'text-success-foreground' : 'text-muted-foreground')} />
<span>{fill(sure ? t.sure : t.notSure, { p: pct0(p), a: versions[0].label, b: versions[1].label })}. {fill(t.interval, { lo: pct(ci[1][0]), hi: pct(ci[1][1]) })}.</span>
</p>
)}
</div>
</ChartRoot>
</ChartFigure>
);
}
/* ---------------- indexed ---------------- */
const S = series.slice(0, 3);
const idx = S.map((s) => s.values.map((v) => (s.values[0] ? (v / s.values[0]) * 100 : 100)));
const all = idx.flat();
const nice = niceTicks(Math.min(100, ...all), Math.max(100, ...all), 5);
const height = phone ? 280 : 320;
const yLabels = nice.ticks.map((v) => numberFormatter(intl, { maximumFractionDigits: 0 })(v));
const endW = Math.max(...S.map((s, i) => textWidth(`${s.label} ${numberFormatter(intl, { maximumFractionDigits: 0 })(idx[i][idx[i].length - 1])}`, 12))) + 14;
const left = Math.max(...yLabels.map((s) => textWidth(s, 11))) + 14;
const right = width - (phone ? 10 : endW);
const top = 26;
const bottom = height - 28;
const X = (i: number) => round(left + ((right - left) * i) / Math.max(1, x.length - 1));
const Y = linear(nice.min, nice.max, bottom, top);
const xf = xFormatters(intl, x[0]);
const every = Math.max(1, Math.ceil((textWidth(xf.axis(x[0]), 11) + 14) / ((right - left) / Math.max(1, x.length - 1))));
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(x.length - 1, Math.round(((px - left) / (right - left || 1)) * (x.length - 1)))));
};
const nf0 = numberFormatter(intl, { maximumFractionDigits: 0 });
// End labels apart
const ends = (() => {
const ys = idx.map((v) => Y(v[v.length - 1]));
const order = ys.map((y, i) => ({ y, i })).sort((p, q) => p.y - q.y);
for (let k = 1; k < order.length; k++) if (order[k].y - order[k - 1].y < 16) order[k].y = order[k - 1].y + 16;
const out: number[] = [];
order.forEach((o) => (out[o.i] = o.y));
return out;
})();
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={S.map((s, i) => ({ label: s.label, key: <Swatch shape="line" color={pairColor(i)} /> }))} />
<div ref={wrapRef}>
<ChartSurface label={name} summary={summary ?? S.map((s, i) => `${s.label} ${nf0(idx[i][idx[i].length - 1])}`).join(', ')} labels={labels} live={active !== null ? `${xf.tooltip(x[active])}. ${S.map((s, i) => `${s.label} ${nf0(idx[i][active])}`).join(', ')}` : ''} table={<ChartTable caption={name} head={[t.point, ...S.map((s) => s.label)]} rows={x.map((xv, k) => [xf.tooltip(xv), ...S.map((s, i) => `${nf0(idx[i][k])} (${fmt(s.values[k])})`)])} />} onKeyDown={onKeyDown} 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={nice.ticks.map((v, i) => ({ at: Y(v), label: yLabels[i], strong: v === 100 }))} xTicks={x.map((xv, i) => ({ at: X(i), label: i % every ? '' : xf.axis(xv) }))} yTitle={t.index} active={active} />
{active !== null && <line x1={X(active)} x2={X(active)} y1={top} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" />}
{[...S.keys()].reverse().map((i) => (
<g key={S[i].label} className="kitch-series" opacity={focus !== null && focus !== i ? 0.18 : 1}>
<path d={linePath(idx[i].map((v, k) => [X(k), Y(v)]))} pathLength={1} fill="none" stroke={pairColor(i)} strokeWidth="2" strokeLinecap="round" className={anim ? 'kitch-draw' : undefined} style={d(150 + i * 200)} />
{active !== null && <circle cx={X(active)} cy={Y(idx[i][active])} r={4.5} fill={pairColor(i)} stroke="var(--kitch-surface)" strokeWidth="2" />}
{!phone && (
<text x={right + 8} y={ends[i]} dy="0.34em" className={cn('text-[12px] tabular-nums', anim && 'kitch-rise')} fill="var(--foreground)" style={{ ...HALO, ...d(1100 + i * 120) }}>
{`${S[i].label} `}
<tspan fontWeight="600">{nf0(active !== null ? idx[i][active] : idx[i][idx[i].length - 1])}</tspan>
</text>
)}
</g>
))}
</svg>
</ChartSurface>
{phone && (
<ul className="mt-3 grid gap-1 text-[12px]">
{S.map((s, i) => (
<li key={s.label} className="flex items-center gap-2 text-muted-foreground">
<Swatch shape="line" color={pairColor(i)} />
<span className="flex-1">{s.label}</span>
<span className="font-semibold tabular-nums text-foreground">{nf0(active !== null ? idx[i][active] : idx[i][idx[i].length - 1])}</span>
</li>
))}
</ul>
)}
</div>
</ChartRoot>
</ChartFigure>
);
}Häufige Fragen
Wie installiere ich Vergleichsdiagramm?
Mit aiw add @ai-website/comparison-chart. Der Befehl legt die Datei components/ui/comparison-chart.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, chart und chart-figure. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Vergleichsdiagramm?
5 Varianten, Steigung, Schmetterling, In zwei Richtungen, A/B-Test und Auf 100 gesetzt. Du wählst sie mit der Prop variant, zum Beispiel variant="butterfly".
Kann ich die Texte von Vergleichsdiagramm übersetzen?
Ja. Vergleichsdiagramm bringt 11 eigene Texte auf Deutsch und Englisch mit, etwa „Eintrag“, „Veränderung“ und „Plus“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Vergleichsdiagramm einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Vergleichsdiagramm mit aiw_components und installieren sie mit aiw_add @ai-website/comparison-chart. Nenn im Prompt die Variante, etwa variant="slope", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Vergleichsdiagramm ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden ComparisonChart-Code zum Kopieren.