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.
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
Details
Was Sankey-Diagramm mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Vergleichs-Charts
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
labeloptionalstringAccessible name of the chart. Default: the title.
variantoptionalSankeyVariantbudgetjourneyenergymigration
nodesPflichtSankeyNode[]linksPflichtSankeyLink[]formatoptionalIntl.NumberFormatOptionsValue format, e.g. { style: 'currency', currency: 'EUR' }
suffixoptionalstringUnit written after every value, for units Intl does not know, e.g. "kWh"
stagesoptionalstring[]Names of the stages, shown above the columns
heightoptionalnumberPlot 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| from | Englisch From | Deutsch Von |
| to | Englisch To | Deutsch Nach |
| value | Englisch Value | Deutsch Wert |
| share | Englisch Share | Deutsch Anteil |
| ofSource | Englisch of {node} | Deutsch von {node} |
| ofAll | Englisch of all | Deutsch von allem |
| inflow | Englisch In | Deutsch Zufluss |
| outflow | Englisch Out | Deutsch Abfluss |
| summary | Englisch {n} flows between {s} stages, {total} in the first stage. | Deutsch {n} Flüsse über {s} Stufen, {total} in der ersten Stufe. |
| node | Englisch {label}, {value}, {share} of all | Deutsch {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
'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.