Zum Inhalt springen
UI Kit
Accent

Sankey-Diagramm

Flüsse zwischen Stufen, jedes Band so breit wie sein Wert. Ein Haushalt von den Quellen zur Verwendung, Besucherwege mit Ausstiegen, Energie von der Quelle bis zur Nutzung mit Verlusten und ein Vorher und Nachher von Tarifen. Ein gewählter Knoten zeigt jeden Fluss, der durch ihn läuft.

4 VariantsEine von 8 Components in Vergleichs-Charts

Wohin alles fließt

Ein Sankey-Diagramm zeigt Flüsse zwischen Stufen mit Bändern, die so breit sind wie ihr Wert, große Ströme und kleine Verluste sind also zugleich sichtbar. Zeiger und Pfeiltasten heben jeden Fluss durch einen Knoten hervor, ein Weg lässt sich so durch das ganze Diagramm verfolgen.

„Haushalt“ lässt vier Quellen in einen Haushalt und weiter in fünf Verwendungen fließen, der Zeiger auf einem Knoten zeigt seinen ganzen Weg. „Besucherwege“ führt vom Einstieg über die erste Seite bis zum Ergebnis, Ausstiege bleiben grau und schraffiert, jeder Knoten trägt seinen Anteil an allen Besuchen. „Energiefluss“ folgt einem Haus mit Solardach und Speicher über ein Jahr, jedes Band behält die Farbe seiner Quelle, Verluste sind schraffiert. „Vorher und nachher“ zeigt Kunden nach Tarif in zwei Quartalen mit Beschriftung außen, jedes Band behält die Farbe seines Tarifs, Kündigungen sind schraffiert.

Halte es bei drei oder vier Stufen, mehr Spalten machen aus den Flüssen ein Knäuel. Färbe nach Herkunft, dann lässt sich eine Quelle bis zum Ende verfolgen. Schraffiere Verluste und Ausstiege, oft sind sie der Grund für das Diagramm.

Variants

HaushaltNeu in 1.2

Vier Quellen fließen in einen Haushalt und weiter in fünf Verwendungen. Der Zeiger auf einem Knoten zeigt seinen ganzen Weg.

BesucherwegeNeu in 1.2

Vom Einstieg über die erste Seite bis zum Ergebnis. Ausstiege bleiben grau und schraffiert, jeder Knoten trägt seinen Anteil an allen Besuchen.

EnergieflussNeu in 1.2

Ein Haus mit Solardach und Speicher über ein Jahr. Jedes Band behält die Farbe der Quelle, aus der es kommt, Verluste sind schraffiert.

Vorher und nachherNeu in 1.2

Kunden nach Tarif in zwei Quartalen. Die Beschriftung steht außen, jedes Band behält die Farbe des Tarifs, aus dem es kommt, Kündigungen sind schraffiert.

Details

Was Sankey-Diagramm mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle SankeyChartProps aus components/ui/sankey-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
    SankeyVariant

    budgetjourneyenergymigration

  • nodesPflicht
    SankeyNode[]
  • linksPflicht
    SankeyLink[]
  • formatoptional
    Intl.NumberFormatOptions

    Value format, e.g. { style: 'currency', currency: 'EUR' }

  • suffixoptional
    string

    Unit written after every value, for units Intl does not know, e.g. "kWh"

  • stagesoptional
    string[]

    Names of the stages, shown above the columns

  • heightoptional
    number

    Plot height in px. Default 380, 460 on phones.

Eingebaute Texte

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

Alle 10 Texte anzeigen
fromEnglisch FromDeutsch Von
toEnglisch ToDeutsch Nach
valueEnglisch ValueDeutsch Wert
shareEnglisch ShareDeutsch Anteil
ofSourceEnglisch of {node}Deutsch von {node}
ofAllEnglisch of allDeutsch von allem
inflowEnglisch InDeutsch Zufluss
outflowEnglisch OutDeutsch Abfluss
summaryEnglisch {n} flows between {s} stages, {total} in the first stage.Deutsch {n} Flüsse über {s} Stufen, {total} in der ersten Stufe.
nodeEnglisch {label}, {value}, {share} of allDeutsch {label}, {value}, {share} von allem

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Sankey-Diagramm reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende und Leertaste.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 480 Zeilen
components/ui/sankey-chart.tsx
'use client';

import { useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { CHART_TEXTS, ChartRoot, ChartSurface, ChartTable, ChartTooltip, Swatch, fill, numberFormatter, seriesColor, textWidth, useEntrance, useIntlLocale, useShown, useWidth, type ChartA11y, type TooltipRow } from '@/components/ui/chart';
import { ChartFigure, HALO, type ChartFigureProps } from '@/components/ui/chart-figure';

/*
 * Flows between stages, each band as wide as its value.
 *   budget     money from its sources into a budget and out to its uses,
 *              neutral bands, the path through a node lights up
 *   journey    visitor paths from entry to outcome, exits in grey, every
 *              node with its share of all visits
 *   energy     sources, conversion and use, each band in the color of the
 *              source it starts from, losses hatched
 *   migration  two columns, before and after (plans, tariffs, channels),
 *              labels outside, the bands in the color of where they came from
 *
 * Pointer and keyboard pick a node and light up every flow that passes
 * through it, up to the first stage and down to the last. Arrow keys move
 * up and down within a stage and left and right between the stages.
 */

export type SankeyVariant = 'budget' | 'journey' | 'energy' | 'migration';

export interface SankeyNode {
  id: string;
  label: string;
  /** The stage, 0 is the first. Default: the longest path from a first node. */
  column?: number;
  /** loss: drawn hatched and grey (waste, exits, cancellations) */
  kind?: 'loss';
}

export interface SankeyLink {
  source: string;
  target: string;
  value: number;
}

export interface SankeyChartProps extends Omit<ChartA11y, 'label'>, ChartFigureProps {
  /** Accessible name of the chart. Default: the title. */
  label?: string;
  variant?: SankeyVariant;
  nodes: SankeyNode[];
  links: SankeyLink[];
  /** Value format, e.g. { style: 'currency', currency: 'EUR' } */
  format?: Intl.NumberFormatOptions;
  /** Unit written after every value, for units Intl does not know, e.g. "kWh" */
  suffix?: string;
  /** Names of the stages, shown above the columns */
  stages?: string[];
  /** Plot height in px. Default 380, 460 on phones. */
  height?: number;
  className?: string;
}

const TEXTS = {
  en: { from: 'From', to: 'To', value: 'Value', share: 'Share', ofSource: 'of {node}', ofAll: 'of all', inflow: 'In', outflow: 'Out', summary: '{n} flows between {s} stages, {total} in the first stage.', node: '{label}, {value}, {share} of all' },
  de: { from: 'Von', to: 'Nach', value: 'Wert', share: 'Anteil', ofSource: 'von {node}', ofAll: 'von allem', inflow: 'Zufluss', outflow: 'Abfluss', summary: '{n} Flüsse über {s} Stufen, {total} in der ersten Stufe.', node: '{label}, {value}, {share} von allem' },
};

type Pos = { x: number; y: number; h: number; value: number; col: number };

/**
 * A Sankey diagram: budget, visitor journey, energy flow or a before and after.
 *   <SankeyChart variant="budget" nodes={[{ id: 'fees', label: 'Fees' }, …]} links={[{ source: 'fees', target: 'budget', value: 420000 }]} />
 */
export function SankeyChart(props: SankeyChartProps) {
  const { variant = 'budget', nodes, links: rawLinks, format, suffix, stages, height: heightProp, label, summary, labels, animate = true, title, value, delta, meta, source, framed, className } = props;
  const t = useTexts(TEXTS, undefined);
  useTexts(CHART_TEXTS, labels);
  const intl = useIntlLocale();
  const nf = numberFormatter(intl, format);
  const fmt = (n: number) => (suffix ? `${nf(n)} ${suffix}` : nf(n));
  const pct = numberFormatter(intl, { style: 'percent', maximumFractionDigits: 1 });
  const [wrapRef, width] = useWidth<HTMLDivElement>(760);
  const [rootRef, anim] = useEntrance<HTMLDivElement>(animate);
  const [active, setActive] = useState<number | null>(null);
  const [link, setLink] = useState<number | null>(null);
  const { shown, open, jump } = useShown(active ?? (link !== null ? -1 - link : null));
  const phone = width < 560;
  const height = heightProp ?? (phone ? 460 : 380);
  const name = label ?? title ?? '';
  const delay = (ms: number) => (anim ? ({ '--kitch-d': `${round(ms, 0)}ms` } as React.CSSProperties) : undefined);

  // Graph
  const index = new Map(nodes.map((nd, i) => [nd.id, i]));
  const links = rawLinks.filter((l) => index.has(l.source) && index.has(l.target) && l.value > 0);
  const src = links.map((l) => index.get(l.source)!);
  const dst = links.map((l) => index.get(l.target)!);
  const outOf = nodes.map((_, i) => links.map((_, k) => k).filter((k) => src[k] === i));
  const inTo = nodes.map((_, i) => links.map((_, k) => k).filter((k) => dst[k] === i));
  const col: number[] = nodes.map((nd) => nd.column ?? -1);
  // Longest path from a first node. `path` holds the nodes on the current path only, so a
  // cycle stops while a node reached on two routes still counts its longer one.
  const memo = new Map<number, number>();
  const depth = (i: number, path: Set<number>): number => {
    if (nodes[i].column !== undefined) return nodes[i].column!;
    if (memo.has(i)) return memo.get(i)!;
    if (path.has(i)) return 0;
    path.add(i);
    const ins = inTo[i];
    const d = ins.length ? Math.max(...ins.map((k) => depth(src[k], path) + 1)) : 0;
    path.delete(i);
    memo.set(i, d);
    return d;
  };
  nodes.forEach((_, i) => (col[i] = depth(i, new Set())));
  const C = Math.max(1, ...col.map((c) => c + 1));
  const valueOf = nodes.map((_, i) => Math.max(inTo[i].reduce((a, k) => a + links[k].value, 0), outOf[i].reduce((a, k) => a + links[k].value, 0)));
  const columns = Array.from({ length: C }, (_, c) => nodes.map((_, i) => i).filter((i) => col[i] === c));
  const stageTotal = columns.map((ids) => ids.reduce((a, i) => a + valueOf[i], 0));
  const firstTotal = stageTotal[0] || 1;

  // Roots for colors: nodes of the first stage
  const rootSlot = new Map(columns[0].filter((i) => nodes[i].kind !== 'loss').map((i, k) => [i, k]));
  const colored = variant === 'energy' || variant === 'migration';
  const rootOfLink = (k: number): number => {
    let i = src[k];
    const seen = new Set<number>();
    while (col[i] > 0 && inTo[i].length && !seen.has(i)) {
      seen.add(i);
      // follow the largest inflow back to the first stage
      i = src[[...inTo[i]].sort((a, b) => links[b].value - links[a].value)[0]];
    }
    return i;
  };
  const nodeColor = (i: number) => (nodes[i].kind === 'loss' ? 'var(--muted-foreground)' : colored ? (slotOf(i) !== undefined ? seriesColor(slotOf(i)!) : 'color-mix(in oklab, var(--foreground) 72%, var(--card))') : 'var(--kitch-1)');
  /** A later node with the same name as a first-stage node (the same plan after the move) shares its color */
  function slotOf(i: number) {
    if (rootSlot.has(i)) return rootSlot.get(i);
    const twin = [...rootSlot.keys()].find((r) => nodes[r].label === nodes[i].label);
    return twin === undefined ? undefined : rootSlot.get(twin);
  }
  const linkBase = (k: number) => {
    if (nodes[dst[k]].kind === 'loss') return 'color-mix(in oklab, var(--muted-foreground) 16%, transparent)';
    if (colored) {
      const r = variant === 'energy' ? rootOfLink(k) : src[k];
      if (rootSlot.has(r)) return `color-mix(in oklab, ${seriesColor(rootSlot.get(r)!)} 42%, transparent)`;
    }
    return 'color-mix(in oklab, var(--foreground) 13%, transparent)';
  };
  const LIT = 'color-mix(in oklab, var(--primary) 58%, transparent)';

  // Layout
  const labelOut = !phone;
  const labelText = (i: number) => nodes[i].label;
  const outerW = (c: number) => (labelOut && (c === 0 || c === C - 1) ? Math.min(Math.round(width * 0.28), Math.max(...columns[c].map((i) => Math.max(textWidth(labelText(i), 12), textWidth(fmt(valueOf[i]) + (variant === 'journey' ? ' · ' + pct(valueOf[i] / firstTotal) : ''), 11))), 0) + 14) : 0);
  const nodeW = phone ? 10 : 12;
  const left = outerW(0);
  const right = width - (C > 1 ? outerW(C - 1) : 0);
  const top = stages?.length ? 30 : 6;
  const bottom = height - 6;
  const pad = phone ? 12 : 14;
  const k = Math.min(...columns.map((ids, c) => (bottom - top - pad * Math.max(0, ids.length - 1)) / (stageTotal[c] || 1)));
  const colX = (c: number) => round(C > 1 ? left + (c / (C - 1)) * (right - left - nodeW) : left);
  const pos: Pos[] = nodes.map(() => ({ x: 0, y: 0, h: 0, value: 0, col: 0 }));
  columns.forEach((ids, c) => {
    const used = ids.reduce((a, i) => a + valueOf[i] * k, 0) + pad * Math.max(0, ids.length - 1);
    let y = top + (bottom - top - used) / 2;
    ids.forEach((i) => {
      const h = Math.max(2, valueOf[i] * k);
      pos[i] = { x: colX(c), y: round(y), h: round(h), value: valueOf[i], col: c };
      y += h + pad;
    });
  });
  // Band ends: outgoing sorted by target position, incoming by source position
  const sOff = new Array(links.length).fill(0);
  const tOff = new Array(links.length).fill(0);
  nodes.forEach((_, i) => {
    let a = 0;
    [...outOf[i]].sort((p, q) => pos[dst[p]].y - pos[dst[q]].y).forEach((kk) => {
      sOff[kk] = a;
      a += links[kk].value * k;
    });
    let b = 0;
    [...inTo[i]].sort((p, q) => pos[src[p]].y - pos[src[q]].y).forEach((kk) => {
      tOff[kk] = b;
      b += links[kk].value * k;
    });
  });
  // Label heights per column: pushed apart where small nodes sit close, kept inside the plot
  const labelY: number[] = pos.map((p) => p.y + p.h / 2);
  columns.forEach((ids) => {
    const gapL = 31;
    for (let j = 1; j < ids.length; j++) labelY[ids[j]] = Math.max(labelY[ids[j]], labelY[ids[j - 1]] + gapL);
    const over = labelY[ids[ids.length - 1]] - (bottom - 12);
    if (over > 0) {
      labelY[ids[ids.length - 1]] -= over;
      for (let j = ids.length - 2; j >= 0; j--) labelY[ids[j]] = Math.min(labelY[ids[j]], labelY[ids[j + 1]] - gapL);
    }
  });

  const band = (kk: number) => {
    const s = pos[src[kk]];
    const d = pos[dst[kk]];
    const w = Math.max(1, links[kk].value * k);
    const x0 = s.x + nodeW;
    const x1 = d.x;
    const y0 = s.y + sOff[kk];
    const y1 = d.y + tOff[kk];
    const xm = (x0 + x1) / 2;
    const r = (n: number) => round(n);
    return `M${r(x0)} ${r(y0)}C${r(xm)} ${r(y0)} ${r(xm)} ${r(y1)} ${r(x1)} ${r(y1)}L${r(x1)} ${r(y1 + w)}C${r(xm)} ${r(y1 + w)} ${r(xm)} ${r(y0 + w)} ${r(x0)} ${r(y0 + w)}Z`;
  };

  // The path through a node: every flow upstream and downstream
  const pathOf = (i: number) => {
    const lit = new Set<number>();
    const litNodes = new Set<number>([i]);
    const up = (n: number) => inTo[n].forEach((kk) => !lit.has(kk) && (lit.add(kk), litNodes.add(src[kk]), up(src[kk])));
    const down = (n: number) => outOf[n].forEach((kk) => !lit.has(kk) && (lit.add(kk), litNodes.add(dst[kk]), down(dst[kk])));
    up(i);
    down(i);
    return { lit, litNodes };
  };
  const hot = active !== null ? pathOf(active) : link !== null ? { lit: new Set([link]), litNodes: new Set([src[link], dst[link]]) } : null;

  // Keyboard: up and down within a stage, left and right between stages
  const onKeyDown = (e: React.KeyboardEvent) => {
    setLink(null);
    if (e.key === 'Escape') return setActive(null);
    const keys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'];
    if (!keys.includes(e.key)) return;
    e.preventDefault();
    if (active === null) return setActive(columns[0][0] ?? null);
    const c = col[active];
    const at = columns[c].indexOf(active);
    if (e.key === 'ArrowDown') return setActive(columns[c][Math.min(columns[c].length - 1, at + 1)]);
    if (e.key === 'ArrowUp') return setActive(columns[c][Math.max(0, at - 1)]);
    if (e.key === 'Home') return setActive(columns[0][0]);
    if (e.key === 'End') return setActive(columns[C - 1][columns[C - 1].length - 1]);
    const nc = e.key === 'ArrowRight' ? Math.min(C - 1, c + 1) : Math.max(0, c - 1);
    if (nc === c) return;
    const mid = pos[active].y + pos[active].h / 2;
    // Prefer a node connected to this one, else the nearest by height
    const linked = columns[nc].filter((j) => (nc > c ? outOf[active].some((kk) => dst[kk] === j) : inTo[active].some((kk) => src[kk] === j)));
    const pool = linked.length ? linked : columns[nc];
    setActive([...pool].sort((a, b) => Math.abs(pos[a].y + pos[a].h / 2 - mid) - Math.abs(pos[b].y + pos[b].h / 2 - mid))[0]);
  };

  // Readouts
  const nodeRows = (i: number): TooltipRow[] => {
    const out: TooltipRow[] = [{ key: 'v', label: t.value, value: fmt(valueOf[i]) }, { key: 's', label: t.share, value: `${pct(valueOf[i] / firstTotal)} ${t.ofAll}` }];
    const ins = [...inTo[i]].sort((a, b) => links[b].value - links[a].value).slice(0, 3);
    const outs = [...outOf[i]].sort((a, b) => links[b].value - links[a].value).slice(0, 3);
    ins.forEach((kk) => out.push({ key: `i${kk}`, label: `← ${nodes[src[kk]].label}`, value: fmt(links[kk].value) }));
    outs.forEach((kk) => out.push({ key: `o${kk}`, label: `→ ${nodes[dst[kk]].label}`, value: fmt(links[kk].value) }));
    return out;
  };
  const linkRows = (kk: number): TooltipRow[] => [
    { key: 'v', label: t.value, value: fmt(links[kk].value) },
    { key: 's', label: fill(t.ofSource, { node: nodes[src[kk]].label }), value: pct(links[kk].value / (valueOf[src[kk]] || 1)) },
  ];
  const shownNode = shown !== null && shown >= 0 && nodes[shown] ? shown : null;
  const shownLink = shown !== null && shown < 0 && links[-1 - shown] ? -1 - shown : null;
  const live = link !== null && links[link] ? `${nodes[src[link]].label} → ${nodes[dst[link]].label}, ${fmt(links[link].value)}` : active !== null && nodes[active] ? `${fill(t.node, { label: nodes[active].label, value: fmt(valueOf[active]), share: pct(valueOf[active] / firstTotal) })}. ${[...inTo[active].map((kk) => `${t.from} ${nodes[src[kk]].label} ${fmt(links[kk].value)}`), ...outOf[active].map((kk) => `${t.to} ${nodes[dst[kk]].label} ${fmt(links[kk].value)}`)].join(', ')}` : '';
  const auto = fill(t.summary, { n: String(links.length), s: String(C), total: fmt(stageTotal[0] ?? 0) });
  const table = <ChartTable caption={name} head={[t.from, t.to, t.value, t.share]} rows={links.map((l, kk) => [nodes[src[kk]].label, nodes[dst[kk]].label, fmt(l.value), `${pct(l.value / (valueOf[src[kk]] || 1))} ${fill(t.ofSource, { node: nodes[src[kk]].label })}`])} />;

  let tipX = 0;
  let tipY = 0;
  let tipTitle = '';
  let tipRows: TooltipRow[] = [];
  if (shownNode !== null) {
    const p = pos[shownNode];
    tipX = p.col === C - 1 ? p.x : p.x + nodeW;
    tipY = Math.max(0, Math.min(p.y + p.h / 2 - 30, height - 140));
    tipTitle = nodes[shownNode].label;
    tipRows = nodeRows(shownNode);
  } else if (shownLink !== null) {
    const s = pos[src[shownLink]];
    const d = pos[dst[shownLink]];
    tipX = round((s.x + nodeW + d.x) / 2);
    tipY = Math.max(0, round((s.y + sOff[shownLink] + d.y + tOff[shownLink]) / 2) - 30);
    tipTitle = `${nodes[src[shownLink]].label} → ${nodes[dst[shownLink]].label}`;
    tipRows = linkRows(shownLink);
  }

  const maxLabel = (c: number) => {
    if (labelOut && (c === 0 || c === C - 1)) return outerW(c) - 12;
    const gap = C > 1 ? (right - left - nodeW) / (C - 1) - nodeW : width;
    // Phones: the first and last stage share the gap only when there are two stages
    // The middle stages are listed below the plot on phones, so the outer labels share the whole width between them
    if (phone && (c === 0 || c === C - 1)) return Math.max(40, gap * (C === 2 ? 0.46 : (C - 1) / 2) - 8);
    // Middle labels may run over the bands (they carry a halo), so a word like "Wärmepumpe" stays whole on tablets
    return Math.max(40, gap * (c === C - 1 ? 0.92 : 0.8) - 10);
  };
  /** Room for a label of three lines: the neighbours in the column sit at least 45 px away */
  const roomy = (i: number) => {
    const col = columns[pos[i].col];
    const j = col.indexOf(i);
    return (j <= 0 || labelY[i] - labelY[col[j - 1]] >= 45) && (j >= col.length - 1 || labelY[col[j + 1]] - labelY[i] >= 45) && labelY[i] - 21 >= top && labelY[i] + 21 <= height;
  };
  const wrapLabel = (s: string, w: number, room: boolean): string[] => {
    if (textWidth(s, 12) <= w || !room || !s.includes(' ')) return [clip(s, w)];
    const words = s.split(' ');
    let first = words[0];
    let k = 1;
    while (k < words.length && textWidth(`${first} ${words[k]}`, 12) <= w) first += ` ${words[k++]}`;
    return k >= words.length ? [first] : [clip(first, w), clip(words.slice(k).join(' '), w)];
  };
  const clip = (s: string, w: number, size = 12) => {
    if (textWidth(s, size) <= w) return s;
    let out = s;
    while (out.length > 1 && textWidth(`${out}…`, size) > w) out = out.slice(0, -1);
    return `${out.trimEnd()}…`;
  };

  const legendItems = colored ? [...rootSlot.entries()].map(([i, slot]) => ({ label: nodes[i].label, color: seriesColor(slot) })) : [];

  return (
    <ChartFigure title={title} value={value} delta={delta} meta={meta} source={source} framed={framed} className={className}>
      <ChartRoot anim={anim} rootRef={rootRef}>
        {legendItems.length > 1 && (
          <ul className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12px] text-muted-foreground">
            {legendItems.map((it) => (
              <li key={it.label} className="flex items-center gap-1.5">
                <Swatch color={it.color} />
                {it.label}
              </li>
            ))}
          </ul>
        )}
        <div ref={wrapRef}>
          <ChartSurface
            label={name}
            summary={summary ?? auto}
            labels={labels}
            live={live}
            table={table}
            onKeyDown={onKeyDown}
            onFocus={(e) => e.target === e.currentTarget && active === null && e.currentTarget.matches(':focus-visible') && setActive(columns[0][0] ?? null)}
            onBlur={() => {
              setActive(null);
              setLink(null);
            }}
            onPointerLeave={(e) => {
              if (e.pointerType !== 'mouse') return;
              setActive(null);
              setLink(null);
            }}
          >
            <svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="block overflow-visible">
              <defs>
                <pattern id={`sankey-loss-${variant}`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
                  <rect width="6" height="6" fill="color-mix(in oklab, var(--muted-foreground) 14%, var(--card))" />
                  <line x1="0" y1="0" x2="0" y2="6" stroke="color-mix(in oklab, var(--muted-foreground) 60%, var(--card))" strokeWidth="2" />
                </pattern>
              </defs>
              {stages?.length ? (
                <g className="text-[11px] font-medium uppercase tracking-[0.06em]" fill="var(--muted-foreground)">
                  {stages.slice(0, C).map((s, c) => (
                    <text key={c} x={c === 0 ? colX(0) : c === C - 1 ? colX(c) + nodeW : colX(c) + nodeW / 2} y={12} textAnchor={c === 0 ? 'start' : c === C - 1 ? 'end' : 'middle'}>
                      {clip(s, c === 0 || c === C - 1 ? Math.max(60, (right - left) / C) : (right - left) / C - 8, 11)}
                    </text>
                  ))}
                </g>
              ) : null}
              {/* Bands */}
              <g>
                {links.map((_, kk) => {
                  const lit = hot?.lit.has(kk);
                  return (
                    <path
                      key={kk}
                      d={band(kk)}
                      fill={lit && !colored ? LIT : linkBase(kk)}
                      stroke="var(--kitch-surface)"
                      strokeWidth="0.75"
                      strokeOpacity="0.7"
                      opacity={hot ? (lit ? 1 : 0.32) : 1}
                      onPointerEnter={(e) => {
                        if (e.pointerType !== 'mouse') return;
                        setActive(null);
                        setLink(kk);
                      }}
                      onPointerDown={() => {
                        setActive(null);
                        setLink(kk);
                      }}
                      className={cn('cursor-pointer transition-[opacity,fill] duration-300 motion-reduce:transition-none', anim && 'kitch-clip')}
                      style={delay(260 + pos[src[kk]].col * 260)}
                    />
                  );
                })}
              </g>
              {/* Nodes */}
              {nodes.map((nd, i) => {
                const p = pos[i];
                const lit = hot?.litNodes.has(i);
                const loss = nd.kind === 'loss';
                return (
                  <g key={nd.id} opacity={hot && !lit ? 0.45 : 1} className="transition-opacity duration-300 motion-reduce:transition-none">
                    <rect
                      x={p.x}
                      y={p.y}
                      width={nodeW}
                      height={p.h}
                      rx={3}
                      fill={loss ? `url(#sankey-loss-${variant})` : lit && !colored ? 'var(--primary)' : nodeColor(i)}
                      stroke={loss ? 'var(--muted-foreground)' : 'none'}
                      strokeWidth={loss ? 1 : 0}
                      strokeDasharray={loss ? '3 2' : undefined}
                      onPointerEnter={(e) => {
                        if (e.pointerType !== 'mouse') return;
                        setLink(null);
                        setActive(i);
                      }}
                      onPointerDown={() => {
                        setLink(null);
                        setActive(i);
                      }}
                      className={cn('cursor-pointer', anim && 'kitch-grow-y')}
                      style={delay(80 + p.col * 260)}
                    />
                    {active === i && <rect x={p.x - 3} y={p.y - 3} width={nodeW + 6} height={p.h + 6} rx={5} fill="none" stroke="var(--foreground)" strokeWidth="1.5" />}
                  </g>
                );
              })}
              {/* Labels: outside on the first and last stage, beside the node in between */}
              <g className="pointer-events-none">
                {nodes.map((nd, i) => {
                  const p = pos[i];
                  const last = p.col === C - 1 && C > 1;
                  // Phones name the middle stages in the list below the plot
                  if (phone && C > 2 && p.col > 0 && !last) return null;
                  const outside = labelOut && (p.col === 0 || last);
                  const toLeft = outside ? p.col === 0 : last;
                  const x = toLeft ? p.x - 8 : p.x + nodeW + 8;
                  const two = p.h >= 26 || !phone;
                  const cy = labelY[i];
                  const w = maxLabel(p.col);
                  // A long name takes a second line where its neighbours leave room, instead of an ellipsis
                  const lines = wrapLabel(nd.label, w, roomy(i));
                  const share = variant === 'journey' ? ` · ${pct(valueOf[i] / firstTotal)}` : '';
                  const lit = hot?.litNodes.has(i);
                  return (
                    <g key={nd.id} opacity={hot && !lit ? 0.5 : 1} className={cn('transition-opacity duration-300', anim && 'kitch-fade')} style={delay(520 + p.col * 200)}>
                      <text x={x} y={(two ? cy - 7 : cy) - (lines.length - 1) * 7} dy="0.34em" textAnchor={toLeft ? 'end' : 'start'} className={cn('text-[12px]', active === i ? 'font-semibold' : 'font-medium')} fill={nd.kind === 'loss' ? 'var(--muted-foreground)' : 'var(--foreground)'} style={HALO}>
                        {lines.map((ln, li) => (
                          <tspan key={li} x={x} dy={li ? 14 : undefined}>
                            {ln}
                          </tspan>
                        ))}
                      </text>
                      {two && (
                        <text x={x} y={cy + 8 + (lines.length - 1) * 7} dy="0.34em" textAnchor={toLeft ? 'end' : 'start'} className="text-[11px] tabular-nums" fill="var(--muted-foreground)" style={HALO}>
                          {clip(`${fmt(valueOf[i])}${share}`, w, 11)}
                        </text>
                      )}
                    </g>
                  );
                })}
              </g>
            </svg>
            <ChartTooltip open={open && (shownNode !== null || shownLink !== null)} jump={jump} x={tipX} y={tipY} width={width} offset={12} title={tipTitle} rows={tipRows} />
          </ChartSurface>
          {phone && C > 2 && (
            <dl className="mt-4 grid gap-3 border-t border-border pt-4 text-[12px]">
              {columns.slice(1, -1).map((ids, c) => (
                <div key={c}>
                  {stages?.[c + 1] && <dt className="mb-1 font-medium uppercase tracking-[0.06em] text-muted-foreground">{stages[c + 1]}</dt>}
                  <dd className="flex flex-wrap gap-x-4 gap-y-1">
                    {ids.map((i) => (
                      <span key={i} className={cn('tabular-nums text-muted-foreground transition-opacity', hot && !hot.litNodes.has(i) && 'opacity-50')}>
                        <span className={cn('font-medium', nodes[i].kind === 'loss' ? 'text-muted-foreground' : 'text-foreground')}>{nodes[i].label}</span> {fmt(valueOf[i])}
                      </span>
                    ))}
                  </dd>
                </div>
              ))}
            </dl>
          )}
        </div>
      </ChartRoot>
    </ChartFigure>
  );
}

Häufige Fragen

Wie installiere ich Sankey-Diagramm?

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

4 Varianten, Haushalt, Besucherwege, Energiefluss und Vorher und nachher. Du wählst sie mit der Prop variant, zum Beispiel variant="journey".

Kann ich die Texte von Sankey-Diagramm übersetzen?

Ja. Sankey-Diagramm bringt 10 eigene Texte auf Deutsch und Englisch mit, etwa „Von“, „Nach“ und „Wert“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Sankey-Diagramm einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert