Zum Inhalt springen
UI Kit
Accent

Kombidiagramm

Säulen, eine Fläche und Linien auf einer Zeitachse. Eine Quote auf zweiter Skala mit eigenem beschrifteten Schlüssel, Ist gegen Plan, Säulen gegen ein Ziel und eine Prognose, die gestrichelt mit Spanne weiterläuft.

4 VariantsEine von 10 Components in Charts

Zwei Kennzahlen, eine Zeitachse

Ein Kombidiagramm setzt Säulen, Fläche und Linien auf eine Zeitachse, zwei verwandte Kennzahlen lesen sich so zusammen, etwa verkaufte Nächte und Auslastung. Eine zweite Skala trägt ihren eigenen Titel und teilt das Raster, beides bleibt lesbar, ohne zu raten, welche Linie zu welcher Achse gehört.

„Säulen und Quote“ zeigt verkaufte Nächte als Säulen auf der linken Skala und die Auslastung als Linie auf der rechten. „Ist gegen Plan“ zeichnet wöchentlich aktive Nutzer als Fläche und den Plan als gestrichelte Linie auf derselben Skala, wo die Fläche über der Linie liegt, ist der Plan übertroffen. „Gegen ein Ziel“ stellt Säulen gegen eine Zielstufe je Monat, Säulen, die sie erreichen, behalten den Akzent, die Zahl der Monate im Ziel steht obenauf. „Prognose“ zeigt Umsatz als Säulen mit Dreimonatsschnitt als Linie, ab Oktober werden beide schraffiert und gestrichelt, mit der erwarteten Spanne drumherum.

Nimm eine zweite Skala nur, wenn die beiden Kennzahlen verschiedene Einheiten haben. Markiere die Prognose deutlich, Schraffur und Striche sagen, was noch nicht wirklich ist. Nenn das Ziel im Titel, dann beantwortet das Diagramm seine Frage auf einen Blick.

Variants

Säulen und QuoteNeu in 1.2

Verkaufte Nächte als Säulen auf der linken Skala, Auslastung als Linie auf der rechten. Beide Skalen teilen das Raster und tragen ihren Titel.

Ist gegen PlanNeu in 1.2

Wöchentlich aktive Nutzer als Fläche, der Plan als gestrichelte Linie auf derselben Skala. Wo die Fläche über der Linie liegt, ist der Plan übertroffen.

Gegen ein ZielNeu in 1.2

Säulen gegen eine Zielstufe je Monat. Säulen, die sie erreichen, behalten den Akzent, die Zahl der Monate im Ziel steht obenauf.

PrognoseNeu in 1.2

Umsatz als Säulen und sein Dreimonatsschnitt als Linie. Ab Oktober werden beide zur Prognose, schraffiert und gestrichelt, mit der erwarteten Spanne drumherum.

Details

Was Kombidiagramm mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Charts
Bereich
Elements
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/combo-chart.tsx
Bringt mit
utilskitchartchart-figure
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 9 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle ComboChartProps aus components/ui/combo-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
    ComboVariant

    bar-linearea-linetargetforecast

  • dataPflicht
    Datum[]

    Rows, one per period, e.g. { month: '2026-03', bookings: 412, rate: 0.71 }

  • xKeyPflicht
    string

    Key of the period. "2026-03" and "2026-03-14" are read as dates.

  • seriesPflicht
    ComboSeries[]

    Up to two columns, one area and two lines

  • formatoptional
    Intl.NumberFormatOptions

    Format of the left scale, e.g. { style: 'currency', currency: 'EUR' }

  • rightFormatoptional
    Intl.NumberFormatOptions

    Format of the right scale, e.g. { style: 'percent' }

  • leftTitleoptional
    string

    Titles of the scales, shown above them with their key

  • rightTitleoptional
    string
  • targetoptional
    { value?: number; key?: string; label: string }

    target: a fixed target (value) or one per row (key), drawn on the left scale

  • forecastFromoptional
    string

    forecast: the first period that is a forecast

  • rangeoptional
    { low: string; high: string; label: string }

    forecast: keys of the low and high end of the band around the forecast line

  • xFormatoptional
    Intl.DateTimeFormatOptions
  • tooltipXFormatoptional
    Intl.DateTimeFormatOptions
  • heightoptional
    number

    Plot height in px, axis labels included. Default 320, 280 on phones.

  • xLabeloptional
    string

    Header of the first table column

Eingebaute Texte

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

Alle 9 Texte anzeigen
hitsEnglisch {n} of {total} periods on targetDeutsch {n} von {total} Zeiträumen im Ziel
forecastEnglisch ForecastDeutsch Prognose
actualEnglisch ActualDeutsch Ist
targetEnglisch TargetDeutsch Ziel
rangeEnglisch RangeDeutsch Spanne
periodEnglisch PeriodDeutsch Zeitraum
reachedEnglisch reachedDeutsch erreicht
missedEnglisch belowDeutsch darunter
betweenEnglisch {lo} to {hi}Deutsch {lo} bis {hi}

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

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

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

/*
 * Two kinds of marks on one time axis.
 *   bar-line   columns on the left scale, a rate as a line on the right scale,
 *              both scales titled with their key so neither is read on the wrong side
 *   area-line  an area and a line on one scale, e.g. actual against plan
 *   target     columns against a target per period (a line or steps), columns
 *              that reach it keep the accent, the count of hits on top
 *   forecast   columns and a line that turn pale and dashed from a given
 *              period on, with a band from low to high around the forecast
 *
 * Colors by role: lines take the accent and the grey first, an area the
 * next of the two, columns the pale accent tones, so the line always stays
 * the loudest mark. A second scale is only drawn where a series asks for it.
 */

export type ComboVariant = 'bar-line' | 'area-line' | 'target' | 'forecast';

export interface ComboSeries extends ChartSeries {
  /** How the series is drawn */
  type: 'bar' | 'line' | 'area';
  /** Which scale it is read on. Default left. */
  axis?: 'left' | 'right';
}

export interface ComboChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: ComboVariant;
  /** Rows, one per period, e.g. { month: '2026-03', bookings: 412, rate: 0.71 } */
  data: Datum[];
  /** Key of the period. "2026-03" and "2026-03-14" are read as dates. */
  xKey: string;
  /** Up to two columns, one area and two lines */
  series: ComboSeries[];
  /** Format of the left scale, e.g. { style: 'currency', currency: 'EUR' } */
  format?: Intl.NumberFormatOptions;
  /** Format of the right scale, e.g. { style: 'percent' } */
  rightFormat?: Intl.NumberFormatOptions;
  /** Titles of the scales, shown above them with their key */
  leftTitle?: string;
  rightTitle?: string;
  /** target: a fixed target (value) or one per row (key), drawn on the left scale */
  target?: { value?: number; key?: string; label: string };
  /** forecast: the first period that is a forecast */
  forecastFrom?: string;
  /** forecast: keys of the low and high end of the band around the forecast line */
  range?: { low: string; high: string; label: string };
  xFormat?: Intl.DateTimeFormatOptions;
  tooltipXFormat?: Intl.DateTimeFormatOptions;
  /** Plot height in px, axis labels included. Default 320, 280 on phones. */
  height?: number;
  /** Header of the first table column */
  xLabel?: string;
  className?: string;
}

const TEXTS = {
  en: { hits: '{n} of {total} periods on target', forecast: 'Forecast', actual: 'Actual', target: 'Target', range: 'Range', period: 'Period', reached: 'reached', missed: 'below', between: '{lo} to {hi}' },
  de: { hits: '{n} von {total} Zeiträumen im Ziel', forecast: 'Prognose', actual: 'Ist', target: 'Ziel', range: 'Spanne', period: 'Zeitraum', reached: 'erreicht', missed: 'darunter', between: '{lo} bis {hi}' },
};

const GAP = 2;

/**
 * Columns, areas and lines on one time axis, with a second scale, a target or a forecast.
 *   <ComboChart variant="bar-line" data={rows} xKey="month" series={[{ key: 'nights', label: 'Nights', type: 'bar' }, { key: 'rate', label: 'Occupancy', type: 'line', axis: 'right' }]} rightFormat={{ style: 'percent' }} />
 */
export function ComboChart(props: ComboChartProps) {
  const { variant = 'bar-line', data, xKey, series: allSeries, format, rightFormat, leftTitle, rightTitle, target, forecastFrom, range, xFormat, tooltipXFormat, height: heightProp, xLabel, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  const ct = useTexts(CHART_TEXTS, labels);
  const intl = useIntlLocale();
  const fl = numberFormatter(intl, format);
  const fr = numberFormatter(intl, rightFormat ?? format);
  const tl = tickFormatter(intl, format);
  const tr = tickFormatter(intl, rightFormat ?? format);
  const xf = xFormatters(intl, data[0]?.[xKey], xFormat, tooltipXFormat);
  const [wrapRef, width] = useWidth<HTMLDivElement>(720);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const n = data.length;
  const { active, setActive, onKeyDown, onFocus, onBlur } = useActive(n);
  const { shown, open, jump } = useShown(active);
  const [focus, setFocus] = useState<number | null>(null);
  const phone = width < 520;
  const height = heightProp ?? (phone ? 280 : 320);
  const name = label ?? title ?? '';
  const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);
  const num = (d: Datum | undefined, k?: string) => (d && k && typeof d[k] === 'number' ? (d[k] as number) : null);

  // Series by role, colors by role
  const bars = allSeries.filter((s) => s.type === 'bar').slice(0, 2);
  const lines = allSeries.filter((s) => s.type === 'line').slice(0, 2);
  const area = allSeries.find((s) => s.type === 'area');
  const series = [...bars, ...(area ? [area] : []), ...lines];
  const lineColors = ['var(--kitch-1)', 'var(--kitch-2)'];
  const colorOf = (s: ComboSeries) => {
    if (s.color) return s.color;
    // An area is the main measure where there is one, the lines then compare against it
    if (s.type === 'area') return 'var(--kitch-1)';
    if (s.type === 'line') return lineColors[Math.min(1, lines.indexOf(s) + (area ? 1 : 0))];
    const k = bars.indexOf(s);
    return lines.length || area ? (k ? 'var(--kitch-4)' : 'var(--kitch-3)') : k ? 'var(--kitch-3)' : 'var(--kitch-1)';
  };
  const shapeOf = (s: ComboSeries): TooltipRow['shape'] => (s.type === 'bar' ? 'rect' : 'line');
  const onRight = (s: ComboSeries) => s.axis === 'right';
  const hasRight = series.some(onRight);

  // Forecast split
  const fIndex = forecastFrom !== undefined ? data.findIndex((d) => String(d[xKey]) === forecastFrom) : -1;
  const isForecast = (i: number) => fIndex >= 0 && i >= fIndex;

  // Targets
  const targetAt = (i: number) => (target ? (target.key ? num(data[i], target.key) : (target.value ?? null)) : null);
  const barVal = (i: number) => (bars[0] ? num(data[i], bars[0].key) : null);
  const hit = (i: number) => {
    const tv = targetAt(i);
    const v = barVal(i);
    return tv !== null && v !== null && v >= tv;
  };
  const hits = data.filter((_, i) => hit(i)).length;

  // Scales
  const leftVals = data.flatMap((d, i) => [...series.filter((s) => !onRight(s)).map((s) => num(d, s.key)), targetAt(i), range ? num(d, range.high) : null, range ? num(d, range.low) : null]).filter((v): v is number => v !== null);
  const rightVals = data.flatMap((d) => series.filter(onRight).map((s) => num(d, s.key))).filter((v): v is number => v !== null);
  const lMaxV = Math.max(1e-9, ...leftVals);
  const rMax = Math.max(1e-9, ...rightVals);
  /*
   * Both scales share the grid lines: the same number of steps from zero.
   * The count is picked so neither scale runs far past its data (a rate
   * should end at 100 %, not at 150 %).
   */
  const stepFor = (raw: number) => {
    const mag = 10 ** Math.floor(Math.log10(raw));
    return ([1, 2, 2.5, 3, 4, 5, 10].find((m) => m * mag >= raw * 0.999999) ?? 10) * mag;
  };
  let nl = niceTicks(Math.min(0, ...leftVals), lMaxV, 5);
  let rightTicks: number[] = nl.ticks.map(() => 0);
  if (hasRight) {
    let best = { d: 4, waste: Infinity, sL: 1, sR: 1 };
    for (let d = 3; d <= 6; d++) {
      const sL = stepFor(lMaxV / d);
      const sR = stepFor(rMax / d);
      const waste = Math.max((d * sL) / lMaxV, (d * sR) / rMax) + Math.abs(d - 5) * 0.01;
      if (waste < best.waste) best = { d, waste, sL, sR };
    }
    const ticks = Array.from({ length: best.d + 1 }, (_, i) => parseFloat((i * best.sL).toPrecision(12)));
    nl = { min: 0, max: ticks[ticks.length - 1], ticks };
    rightTicks = Array.from({ length: best.d + 1 }, (_, i) => parseFloat((i * best.sR).toPrecision(12)));
  }
  const lTickTexts = nl.ticks.map(tl);
  const rTickTexts = rightTicks.map(tr);
  const left = Math.max(...lTickTexts.map((s) => textWidth(s, 11)), 12) + 12;
  const right = width - (hasRight ? Math.max(...rTickTexts.map((s) => textWidth(s, 11)), 12) + 12 : 6);
  const top = leftTitle || rightTitle || variant === 'target' ? 34 : 14;
  const bottom = height - 26;
  const band = (right - left) / Math.max(1, n);
  const cx = (i: number) => round(left + band * (i + 0.5));
  const Y = (v: number) => round(bottom - ((v - nl.min) / (nl.max - nl.min || 1)) * (bottom - top));
  const rTop = rightTicks[rightTicks.length - 1] || 1;
  const YR = (v: number) => round(bottom - (v / rTop) * (bottom - top));
  const yScale = (s: ComboSeries) => (onRight(s) ? YR : Y);

  const thickAll = Math.min(36, band * 0.62);
  const thick = bars.length > 1 ? Math.max(3, (thickAll - GAP) / 2) : thickAll;
  const catLabels = data.map((d) => xf.axis(d[xKey]));
  const widest = Math.max(...catLabels.map((s) => textWidth(s, 11)), 1) + 10;
  const every = Math.max(1, Math.ceil(widest / Math.max(1, band)));

  const fmtOf = (s: ComboSeries) => (onRight(s) ? fr : fl);
  const rowsAt = (i: number): TooltipRow[] => {
    const d = data[i];
    const out: TooltipRow[] = series.map((s) => {
      const v = num(d, s.key);
      return { key: s.key, label: s.label, value: v === null ? ct.noData : fmtOf(s)(v), color: colorOf(s), shape: shapeOf(s), dashed: s.dashed || (isForecast(i) && s.type === 'line') };
    });
    const tv = targetAt(i);
    if (target && tv !== null) out.push({ key: '__target', label: target.label, value: `${fl(tv)} · ${hit(i) ? t.reached : t.missed}`, color: 'var(--foreground)', shape: 'line', dashed: !target.key });
    if (range && isForecast(i)) {
      const lo = num(d, range.low);
      const hi = num(d, range.high);
      if (lo !== null && hi !== null) out.push({ key: '__range', label: range.label, value: fill(t.between, { lo: fl(lo), hi: fl(hi) }) });
    }
    return out;
  };
  const titleAt = (i: number) => `${xf.tooltip(data[i][xKey])}${isForecast(i) ? ` · ${t.forecast}` : ''}`;
  const live = active !== null && active < n ? `${titleAt(active)}. ${rowsAt(active).map((r) => `${r.label} ${r.value}`).join(', ')}` : '';
  const auto = series
    .map((s) => {
      const vals = data.map((d) => num(d, s.key)).filter((v): v is number => v !== null);
      return vals.length ? `${s.label} ${fmtOf(s)(vals[0])} → ${fmtOf(s)(vals[vals.length - 1])}` : '';
    })
    .filter(Boolean)
    .join(', ');
  const table = (
    <ChartTable
      caption={name}
      head={[xLabel ?? t.period, ...series.map((s) => s.label), ...(target ? [target.label] : []), ...(range ? [range.label] : [])]}
      rows={data.map((d, i) => [
        titleAt(i),
        ...series.map((s) => {
          const v = num(d, s.key);
          return v === null ? ct.noData : fmtOf(s)(v);
        }),
        ...(target ? [targetAt(i) === null ? ct.noData : fl(targetAt(i)!)] : []),
        ...(range ? [num(d, range.low) === null ? '' : fill(t.between, { lo: fl(num(d, range.low)!), hi: fl(num(d, range.high) ?? 0) })] : []),
      ])}
    />
  );

  const pick = (clientX: number, el: HTMLElement) => {
    const b = el.getBoundingClientRect();
    const px = (clientX - b.left) * (width / (b.width || width));
    setActive(Math.max(0, Math.min(n - 1, Math.floor((px - left) / band))));
  };

  // Line and area paths, split at the forecast
  const pts = (s: ComboSeries, from: number, to: number) => {
    const out: [number, number][] = [];
    for (let i = from; i <= to && i < n; i++) {
      const v = num(data[i], s.key);
      if (v !== null) out.push([cx(i), yScale(s)(v)]);
    }
    return out;
  };
  const cut = fIndex >= 0 ? fIndex - 1 : n - 1;

  const legendItems = [
    ...series.map((s) => ({ label: s.label, color: colorOf(s), shape: shapeOf(s), dashed: s.dashed })),
    ...(target ? [{ label: target.label, color: 'var(--foreground)', shape: 'line' as const, dashed: !target.key }] : []),
    ...(range ? [{ label: range.label, color: 'color-mix(in oklab, var(--primary) 22%, transparent)', shape: 'rect' as const, dashed: false }] : []),
  ];
  const sIndex = (s: ComboSeries) => series.indexOf(s);
  const op = (s: ComboSeries) => seriesOpacity(sIndex(s), focus !== null && focus < series.length ? focus : null);

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        <div className="mb-4 flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
          <PickLegend active={focus} onActive={setFocus} items={legendItems.map((it) => ({ label: it.label, key: <Swatch color={it.color} shape={it.shape} dashed={it.dashed} /> }))} />
          {variant === 'target' && target && (
            <p className="text-[12px] text-muted-foreground">
              <span className="font-semibold tabular-nums text-foreground">{fill(t.hits, { n: String(hits), total: String(data.filter((_, i) => targetAt(i) !== null).length) })}</span>
            </p>
          )}
        </div>
        <div ref={wrapRef}>
          <ChartSurface label={name} summary={summary ?? auto} labels={labels} live={live} table={table} onKeyDown={onKeyDown} onFocus={onFocus} onBlur={onBlur} 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">
              <defs>
                <pattern id={`combo-hatch-${variant}`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
                  <rect width="6" height="6" fill="color-mix(in oklab, var(--primary) 14%, var(--card))" />
                  <line x1="0" y1="0" x2="0" y2="6" stroke="color-mix(in oklab, var(--primary) 55%, var(--card))" strokeWidth="2" />
                </pattern>
              </defs>
              {/* Forecast region */}
              {fIndex >= 0 && (
                <g>
                  <rect x={round(left + band * fIndex)} y={top} width={round(right - left - band * fIndex)} height={bottom - top} fill="color-mix(in oklab, var(--foreground) 3.5%, transparent)" />
                  <line x1={round(left + band * fIndex)} x2={round(left + band * fIndex)} y1={top - 6} y2={bottom} stroke="var(--kitch-axis)" strokeWidth="1" strokeDasharray="3 3" shapeRendering="crispEdges" />
                  <text x={round(left + band * fIndex) + 8} y={top + 14} className="text-[11px] font-medium" fill="var(--muted-foreground)" style={HALO}>
                    {t.forecast}
                  </text>
                </g>
              )}
              {/* Grid and both scales */}
              <g className="text-[11px] tabular-nums" fill="var(--muted-foreground)">
                {nl.ticks.map((v, i) => (
                  <g key={v}>
                    <line x1={left} x2={right} y1={Y(v)} y2={Y(v)} stroke={v === 0 ? 'var(--kitch-axis)' : 'var(--kitch-grid)'} strokeWidth="1" shapeRendering="crispEdges" />
                    <text x={left - 10} y={Y(v)} dy="0.34em" textAnchor="end">
                      {lTickTexts[i]}
                    </text>
                  </g>
                ))}
                {hasRight &&
                  rightTicks.map((v, i) => (
                    <text key={v} x={right + 10} y={YR(v)} dy="0.34em" textAnchor="start">
                      {rTickTexts[i]}
                    </text>
                  ))}
                {catLabels.map((s, i) =>
                  i % every === 0 ? (
                    <text key={i} x={cx(i)} y={height - 7} textAnchor="middle" className={cn(active === i && 'fill-foreground')}>
                      {s}
                    </text>
                  ) : null,
                )}
              </g>
              {/* Scale titles with their key */}
              {(leftTitle || rightTitle) && (
                <g className="text-[11px] font-medium" fill="var(--muted-foreground)">
                  {leftTitle && <text x={0} y={12}>{leftTitle}</text>}
                  {rightTitle && hasRight && (
                    <text x={width} y={12} textAnchor="end">
                      {rightTitle}
                    </text>
                  )}
                </g>
              )}
              {/* Active band */}
              {shown !== null && shown < n && <rect x={0} y={top - 6} width={round(Math.max(0, band - 4))} height={round(bottom - top + 6)} rx="6" fill="var(--foreground)" className="kitch-glide" data-jump={jump ? '' : undefined} style={{ transform: `translateX(${round(left + band * shown + 2)}px)`, opacity: open ? 0.045 : 0 }} />}
              {/* Forecast range */}
              {range && fIndex >= 0 && (() => {
                const hi: [number, number][] = [];
                const lo: [number, number][] = [];
                for (let i = Math.max(0, fIndex - 1); i < n; i++) {
                  const h = i === fIndex - 1 ? num(data[i], lines[0]?.key) : num(data[i], range.high);
                  const l = i === fIndex - 1 ? num(data[i], lines[0]?.key) : num(data[i], range.low);
                  if (h !== null && l !== null) {
                    hi.push([cx(i), Y(h)]);
                    lo.push([cx(i), Y(l)]);
                  }
                }
                if (hi.length < 2) return null;
                const d = `${linePath(hi)} L${lo[lo.length - 1][0]} ${lo[lo.length - 1][1]} ${linePath([...lo].reverse()).replace(/^M/, 'L')} Z`;
                return <path d={d} fill="color-mix(in oklab, var(--primary) 16%, transparent)" className={anim ? 'kitch-fade' : undefined} style={delay(700)} opacity={focus !== null && focus < series.length ? 0.3 : 1} />;
              })()}
              {/* Area */}
              {area && (() => {
                const p = pts(area, 0, n - 1);
                if (p.length < 2) return null;
                const base = Y(Math.max(0, nl.min));
                const line = linePath(p);
                return (
                  <g className="kitch-series" opacity={op(area)}>
                    <path d={`${line} L${p[p.length - 1][0]} ${base} L${p[0][0]} ${base} Z`} fill={`color-mix(in oklab, ${colorOf(area)} 18%, transparent)`} className={anim ? 'kitch-clip' : undefined} style={delay(100)} />
                    <path d={line} fill="none" stroke={colorOf(area)} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" pathLength={anim ? 1 : undefined} className={anim ? 'kitch-draw' : undefined} style={delay(100)} />
                  </g>
                );
              })()}
              {/* Columns */}
              {bars.map((s, k) => (
                <g key={s.key} className="kitch-series" opacity={op(s)}>
                  {data.map((d, i) => {
                    const v = num(d, s.key);
                    if (v === null) return null;
                    const x = cx(i) - (bars.length > 1 ? thick + GAP / 2 : thick / 2) + k * (thick + GAP);
                    const y0 = Y(Math.max(0, nl.min));
                    const y1 = Y(v);
                    const fc = isForecast(i);
                    const color = variant === 'target' ? (hit(i) ? 'var(--kitch-1)' : 'var(--kitch-3)') : colorOf(s);
                    return (
                      <path
                        key={i}
                        d={barPath(x, Math.min(y0, y1), thick, Math.abs(y0 - y1), 4, v >= 0 ? 'up' : 'down')}
                        fill={fc ? `url(#combo-hatch-${variant})` : color}
                        className={cn('transition-opacity duration-200', anim && 'kitch-grow-y')}
                        opacity={active !== null && active !== i ? 0.6 : 1}
                        style={delay(60 + (i / Math.max(1, n)) * 420)}
                      />
                    );
                  })}
                </g>
              ))}
              {/* Target as steps (per row) or one line */}
              {target && (() => {
                if (target.key) {
                  let d = '';
                  data.forEach((_, i) => {
                    const tv = targetAt(i);
                    if (tv === null) return;
                    const x0 = round(left + band * i + band * 0.12);
                    const x1 = round(left + band * (i + 1) - band * 0.12);
                    d += `M${x0} ${Y(tv)}H${x1}`;
                  });
                  return <path d={d} stroke="var(--foreground)" strokeWidth="2" strokeLinecap="round" fill="none" className={anim ? 'kitch-fade' : undefined} style={delay(500)} />;
                }
                const tv = target.value ?? 0;
                return (
                  <g className={anim ? 'kitch-fade' : undefined} style={delay(500)}>
                    <line x1={left} x2={right} y1={Y(tv)} y2={Y(tv)} stroke="var(--foreground)" strokeWidth="1.5" strokeDasharray="5 4" />
                    <text x={right - 4} y={Y(tv) - 7} textAnchor="end" className="text-[11px] font-medium" fill="var(--foreground)" style={HALO}>
                      {`${target.label} ${tl(tv)}`}
                    </text>
                  </g>
                );
              })()}
              {/* Lines, solid up to the forecast, dashed after */}
              {lines.map((s) => {
                const solid = pts(s, 0, cut);
                const dashed = fIndex >= 0 ? pts(s, Math.max(0, fIndex - 1), n - 1) : [];
                const c = colorOf(s);
                return (
                  <g key={s.key} className="kitch-series" opacity={op(s)}>
                    {solid.length > 1 && <path d={linePath(solid)} fill="none" stroke="var(--kitch-surface)" strokeWidth="6" strokeLinejoin="round" strokeLinecap="round" />}
                    {solid.length > 1 && <path d={linePath(solid)} fill="none" stroke={c} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed ? '5 4' : undefined} pathLength={anim && !s.dashed ? 1 : undefined} className={anim ? (s.dashed ? 'kitch-clip' : 'kitch-draw') : undefined} style={delay(380)} />}
                    {dashed.length > 1 && <path d={linePath(dashed)} fill="none" stroke={c} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" strokeDasharray="5 4" className={anim ? 'kitch-clip' : undefined} style={delay(980)} />}
                    {solid.length > 0 && fIndex >= 0 && <circle cx={solid[solid.length - 1][0]} cy={solid[solid.length - 1][1]} r="4" fill={c} stroke="var(--kitch-surface)" strokeWidth="2" className={anim ? 'kitch-pop' : undefined} style={delay(980)} />}
                  </g>
                );
              })}
              {/* Active dots on lines and area */}
              {shown !== null &&
                shown < n &&
                [...(area ? [area] : []), ...lines].map((s) => {
                  const v = num(data[shown], s.key);
                  return v === null ? null : <GlideDot key={s.key} x={cx(shown)} y={yScale(s)(v)} open={open} jump={jump} color={colorOf(s)} opacity={op(s)} />;
                })}
            </svg>
            <ChartTooltip open={open} jump={jump} x={shown !== null && shown < n ? cx(shown) : 0} y={top} width={width} offset={Math.min(band / 2, 40) + 6} title={shown !== null && shown < n ? titleAt(shown) : ''} rows={shown !== null && shown < n ? rowsAt(shown) : []} />
          </ChartSurface>
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Kombidiagramm?

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

4 Varianten, Säulen und Quote, Ist gegen Plan, Gegen ein Ziel und Prognose. Du wählst sie mit der Prop variant, zum Beispiel variant="area-line".

Kann ich die Texte von Kombidiagramm übersetzen?

Ja. Kombidiagramm bringt 9 eigene Texte auf Deutsch und Englisch mit, etwa „Prognose“, „Ist“ und „Ziel“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Kombidiagramm einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert