Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Steigung

Vorher und nachher als zwei Spalten, eine Linie je Eintrag. Die zwei größten Veränderungen behalten den Akzent, der Zeiger holt jede Linie nach vorn.

Im Studio öffnen

Schmetterling

Zwei Gruppen als Balken links und rechts einer gemeinsamen Beschriftung, jeder Wert am Ende seines Balkens.

Im Studio öffnen

In zwei Richtungen

Die Veränderung je Eintrag von einer Nulllinie aus, sortiert vom größten Plus zum größten Minus, Vorzeichen und Pfeil neben der Tönung.

Im Studio öffnen

A/B-Test

Zwei Versionen mit Abschlussrate und 95-%-Intervall auf einer Skala, das Plus und ein Satz, der sagt, wie sicher das Ergebnis ist.

Im Studio öffnen

Auf 100 gesetzt

Reihen sehr unterschiedlicher Größe am Start auf 100 gesetzt, so wird Wachstum auf einer Achse vergleichbar, ohne zweite Skala.

Im Studio öffnen

Details

Was Vergleichsdiagramm mitbringt, aus Registry und Quellcode gelesen.

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

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.

  • labeloptional
    string

    Accessible name of the chart. Default: the title.

  • variantoptional
    ComparisonVariant

    slopebutterflydivergingab-testindexed

  • itemsoptional
    ComparisonItem[]

    slope, butterfly, diverging

  • sidesoptional
    [string, string]

    Names of the two states or groups, e.g. ['2025', '2026'] or ['Women', 'Men']

  • versionsoptional
    AbVersion[]

    ab-test: the control first

  • xoptional
    string[]

    indexed: the x values ("2026-03" is read as a month)

  • seriesoptional
    IndexedSeries[]

    indexed: up to three series

  • formatoptional
    Intl.NumberFormatOptions
  • highlightoptional
    string[]

    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
itemEnglisch ItemDeutsch Eintrag
changeEnglisch ChangeDeutsch Veränderung
liftEnglisch LiftDeutsch Plus
rateEnglisch ConversionDeutsch Abschlussrate
intervalEnglisch 95% interval {lo} to {hi}Deutsch 95-%-Intervall {lo} bis {hi}
sureEnglisch {p} sure that {b} beats {a}Deutsch Zu {p} sicher, dass {b} vor {a} liegt
notSureEnglisch Not decided yet, {p} for {b}Deutsch Noch offen, {p} für {b}
visitorsEnglisch {n} visitorsDeutsch {n} Besuche
indexEnglisch Index, start = 100Deutsch Index, Start = 100
pointEnglisch PointDeutsch Zeitpunkt
winnerEnglisch AheadDeutsch 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
components/ui/comparison-chart.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert