
### Basic Chart

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'Organic' },
			{ name: 'PaidAds' },
			{ name: 'Social' },
			{ name: 'Landing' },
			{ name: 'Product' },
			{ name: 'Cart' },
			{ name: 'Purchase' },
			{ name: 'Bounced' }
		],
		links: [
			{ source: 0, target: 3, value: 42000 },
			{ source: 1, target: 3, value: 28000 },
			{ source: 2, target: 3, value: 18000 },
			{ source: 3, target: 4, value: 52000 },
			{ source: 3, target: 7, value: 36000 },
			{ source: 4, target: 5, value: 31000 },
			{ source: 4, target: 7, value: 21000 },
			{ source: 5, target: 6, value: 24000 },
			{ source: 5, target: 7, value: 7000 }
		]
	};

	const chartConfig = {
		Organic: {
			label: 'Organic Search',
			colors: {
				light: ['#059669'],
				dark: ['#34d399']
			}
		},
		PaidAds: {
			label: 'Paid Ads',
			colors: {
				light: ['#dc2626'],
				dark: ['#f87171']
			}
		},
		Social: {
			label: 'Social Media',
			colors: {
				light: ['#7c3aed'],
				dark: ['#a78bfa']
			}
		},
		Landing: {
			label: 'Landing Page',
			colors: {
				light: ['#0891b2'],
				dark: ['#22d3ee']
			}
		},
		Product: {
			label: 'Product Page',
			colors: {
				light: ['#2563eb'],
				dark: ['#60a5fa']
			}
		},
		Cart: {
			label: 'Cart',
			colors: {
				light: ['#ea580c'],
				dark: ['#fb923c']
			}
		},
		Purchase: {
			label: 'Purchase',
			colors: {
				light: ['#16a34a'],
				dark: ['#4ade80']
			}
		},
		Bounced: {
			label: 'Bounced',
			colors: {
				light: ['#f43f5e'],
				dark: ['#fb7185']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart
	class="h-full w-full p-4"
	{data}
	config={chartConfig}
	accessibility={{
		label: 'Website conversion flow Sankey chart',
		description:
			'Traffic moves from acquisition channels through landing and product pages to purchases or bounces.'
	}}
>
	<EChartsSankeyChart.Node isClickable>
		<EChartsSankeyChart.NodeLabel position="outside" showValues />
	</EChartsSankeyChart.Node>
	<EChartsSankeyChart.Link variant="source" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```

## Installation


  
  
    ### npm

```bash
npx shadcn-svelte@latest add @evilcharts/echarts-sankey-chart
```

### yarn

```bash
yarn dlx shadcn-svelte@latest add @evilcharts/echarts-sankey-chart
```

### bun

```bash
bunx --bun shadcn-svelte@latest add @evilcharts/echarts-sankey-chart
```

### pnpm

```bash
pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-sankey-chart
```
  
  
    
      
        ### Install the following dependencies:
        
          ### npm

```bash
npm install echarts
```

### yarn

```bash
yarn add echarts
```

### bun

```bash
bun add echarts
```

### pnpm

```bash
pnpm add echarts
```
        
      
      
        ### Copy and paste the following code snippets into your project.
        

First create the folder `evilcharts` and a subfolder `charts` in your `components` directory, then paste the sankey-chart code into a new `echarts-sankey-chart` file there.


        
          ### $lib/components/evilcharts/charts/echarts-sankey-chart

`$lib/components/evilcharts/charts/echarts-sankey-chart/index.ts`

```ts
import Root from './sankey-chart.svelte';
import Node from './node.svelte';
import NodeLabel from './node-label.svelte';
import Link from './link.svelte';
import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';

type RootComponent = typeof Root;
export const EChartsSankeyChart: RootComponent & {
	Node: typeof Node;
	NodeLabel: typeof NodeLabel;
	Link: typeof Link;
	Tooltip: typeof Tooltip;
} = Object.assign(Root, { Node, NodeLabel, Link, Tooltip });

export type {
	ChartAccessibility,
	ChartConfig,
	EChartsRenderer
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
export type {
	TooltipPosition,
	TooltipRoundness,
	TooltipVariant
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
export type {
	LinkVariant,
	NodeLabelPosition,
	SankeyAnimationType,
	SankeyData,
	SankeyLink,
	SankeyNode,
	SankeySelection
} from './types.js';
export {
	SANKEY_VALIDATION_ERROR_CODE,
	SANKEY_VALIDATION_ERROR_MESSAGE,
	SankeyValidationError
} from './validation.js';
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/link.svelte`

```svelte
<script lang="ts">
	import { useEChartsSankeyChart } from './sankey-chart-context.svelte.js';
	import type { LinkVariant } from './types.js';

	let {
		variant = 'gradient',
		verticalPadding = 0
	}: {
		variant?: LinkVariant;
		verticalPadding?: number;
	} = $props();
	const token = $props.id();
	const chart = useEChartsSankeyChart();
	$effect(() => chart.links.register(token, () => ({ variant, verticalPadding })));
</script>
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/node-label.svelte`

```svelte
<script lang="ts">
	import { useEChartsSankeyNodeSlots } from './node-slots.svelte.js';
	import type { NodeLabelPosition } from './types.js';

	let {
		position,
		showValues = false,
		valueFormatter
	}: {
		position?: NodeLabelPosition;
		showValues?: boolean;
		valueFormatter?: (value: number) => string;
	} = $props();
	const token = $props.id();
	const slots = useEChartsSankeyNodeSlots();
	$effect(() => slots.labels.register(token, () => ({ position, showValues, valueFormatter })));
</script>
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/node-slots.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import type { NodeLabelRegistration } from './types.js';

const NODE_SLOTS = Symbol('evilcharts.echarts-sankey-node');

export class EChartsSankeyNodeSlots {
	labels = new RegistrationSet<NodeLabelRegistration>();
}

export function setEChartsSankeyNodeSlots(): EChartsSankeyNodeSlots {
	const context = new EChartsSankeyNodeSlots();
	setContext(NODE_SLOTS, context);
	return context;
}

export function useEChartsSankeyNodeSlots(): EChartsSankeyNodeSlots {
	const context = getContext<EChartsSankeyNodeSlots | undefined>(NODE_SLOTS);
	if (!context) throw new Error('[EvilCharts] ECharts NodeLabel must be nested inside Node.');
	return context;
}
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/node.svelte`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import { setEChartsSankeyNodeSlots } from './node-slots.svelte.js';
	import { useEChartsSankeyChart } from './sankey-chart-context.svelte.js';

	let {
		radius = 0,
		isClickable = false,
		children
	}: {
		radius?: number;
		isClickable?: boolean;
		children?: Snippet;
	} = $props();
	const token = $props.id();
	const chart = useEChartsSankeyChart();
	const slots = setEChartsSankeyNodeSlots();
	$effect(() =>
		chart.nodes.register(token, () => ({
			radius,
			isClickable,
			label: slots.labels.first ?? null
		}))
	);
</script>

{@render children?.()}
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/option.ts`

```ts
import type { SankeySeriesOption } from 'echarts/charts';
import type { TooltipComponentOption } from 'echarts/components';
import type { ComposeOption } from 'echarts/core';
import * as echarts from 'echarts/core';
import {
	getColorsCount,
	withAlpha,
	type ChartConfig,
	type ResolvedColors
} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import {
	resolveTooltipPosition,
	roundnessClass,
	tooltipIndicatorHtml,
	tooltipRow,
	tooltipVariantClass
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';
import {
	DEFAULT_ITERATIONS,
	DEFAULT_LINK_CURVATURE,
	DEFAULT_NODE_PADDING,
	DEFAULT_NODE_WIDTH,
	type LinkRegistration,
	type NodeLabelRegistration,
	type NodeRegistration,
	type SankeyAnimationType,
	type SankeyData,
	type TooltipRegistration
} from './types.js';
import { validateSankeyData } from './validation.js';

export type EChartsSankeyOption = ComposeOption<SankeySeriesOption | TooltipComponentOption>;
type SankeyNodeItem = NonNullable<SankeySeriesOption['data']>[number];
type SankeyEdgeItem = NonNullable<SankeySeriesOption['links']>[number];
type Paint = string | echarts.graphic.LinearGradient;

const GRAY = 'rgba(120, 120, 120, 1)';
const NODE_DIM_OPACITY = 0.3;
const LINK_FILL_OPACITY = 0.4;
const LINK_DIM_OPACITY = 0.05;
const LABEL_DIM_OPACITY = 0.3;
const INTRO_COLUMN_STAGGER = 130;
const INTRO_NODE_GROW = 340;
const INTRO_LINK_DELAY = 90;
const INTRO_LINK_DRAW = 520;
const INTRO_FEATHER = 0.05;
const INTRO_NODE_SCALE_FROM = 0.8;

const SKELETON_NODES = [
	{ name: 's0' },
	{ name: 's1' },
	{ name: 's2' },
	{ name: 'm0' },
	{ name: 'm1' },
	{ name: 'm2' },
	{ name: 'e0' },
	{ name: 'e1' }
];
const SKELETON_LINKS = [
	{ source: 's0', target: 'm0', value: 8 },
	{ source: 's0', target: 'm1', value: 5 },
	{ source: 's1', target: 'm1', value: 7 },
	{ source: 's1', target: 'm2', value: 4 },
	{ source: 's2', target: 'm1', value: 5 },
	{ source: 's2', target: 'm2', value: 6 },
	{ source: 'm0', target: 'e0', value: 7 },
	{ source: 'm1', target: 'e0', value: 9 },
	{ source: 'm1', target: 'e1', value: 6 },
	{ source: 'm2', target: 'e1', value: 8 }
];

export type IntroState = { elapsed: number; depths: Record<string, number> };
export type SankeyRevealState = {
	hasRevealed: boolean;
	isLoading: boolean;
	animation: boolean;
	animationType: SankeyAnimationType;
	reducedMotion: boolean;
};
export type SankeyOptionContext = {
	data: SankeyData;
	config: ChartConfig;
	node: NodeRegistration;
	label: NodeLabelRegistration | null;
	link: LinkRegistration;
	tooltip?: TooltipRegistration;
	selectedNode: string | null;
	nodeWidth: number;
	nodePadding: number;
	linkCurvature: number;
	iterations: number;
	align: 'left' | 'justify';
	isLoading: boolean;
	resolved: ResolvedColors;
	nodeValues: Record<string, number>;
	intro: IntroState | null;
};

export function getSankeyRevealDecision(state: SankeyRevealState): {
	hasRevealed: boolean;
	shouldReveal: boolean;
} {
	if (state.isLoading) return { hasRevealed: false, shouldReveal: false };
	if (state.hasRevealed) return { hasRevealed: true, shouldReveal: false };
	return {
		hasRevealed: true,
		shouldReveal: state.animation && state.animationType !== 'none' && !state.reducedMotion
	};
}

export function computeNodeDepths(data: SankeyData): Record<string, number> {
	const { targets, topologicalOrder } = validateSankeyData(data);
	const depths = Object.fromEntries(data.nodes.map((node) => [node.name, 0]));
	for (const sourceIndex of topologicalOrder) {
		const source = data.nodes[sourceIndex].name;
		for (const targetIndex of targets[sourceIndex]) {
			const target = data.nodes[targetIndex].name;
			depths[target] = Math.max(depths[target], depths[source] + 1);
		}
	}
	return depths;
}

export function sankeyIntroDuration(depths: Record<string, number>): number {
	const maxDepth = Math.max(0, ...Object.values(depths));
	return Math.max(
		maxDepth * INTRO_COLUMN_STAGGER + INTRO_NODE_GROW,
		Math.max(0, maxDepth - 1) * INTRO_COLUMN_STAGGER + INTRO_LINK_DELAY + INTRO_LINK_DRAW
	);
}

export function computeNodeValues(data: SankeyData): Record<string, number> {
	const { incomingValues, outgoingValues } = validateSankeyData(data);
	return Object.fromEntries(
		data.nodes.map((node, index) => [
			node.name,
			outgoingValues[index] > 0 ? outgoingValues[index] : incomingValues[index]
		])
	);
}

function nodeGradient(colors: string[]): Paint {
	if (colors.length <= 1) return colors[0] ?? GRAY;
	return new echarts.graphic.LinearGradient(
		0,
		0,
		0,
		1,
		colors.map((color, index) => ({ offset: index / (colors.length - 1), color }))
	);
}

function linkPaint(context: SankeyOptionContext, source: string, target: string): Paint {
	const sourceColors = context.resolved.series[source] ?? [GRAY];
	const targetColors = context.resolved.series[target] ?? [GRAY];
	switch (context.link.variant) {
		case 'gradient':
			return new echarts.graphic.LinearGradient(0, 0, 1, 0, [
				{ offset: 0, color: withAlpha(sourceColors[0] ?? GRAY, 0.2) },
				{ offset: 0.5, color: withAlpha(sourceColors[0] ?? GRAY, 0.5) },
				{ offset: 1, color: withAlpha(targetColors[0] ?? GRAY, 0.2) }
			]);
		case 'source':
			return nodeGradient(sourceColors);
		case 'target':
			return nodeGradient(targetColors);
		case 'solid':
			return context.resolved.tokens.foreground;
	}
}

const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
const easeOut = (value: number) => 1 - (1 - value) ** 3;
function nodePhase(intro: IntroState | null, name: string) {
	if (!intro) return 1;
	return easeOut(
		clamp01((intro.elapsed - (intro.depths[name] ?? 0) * INTRO_COLUMN_STAGGER) / INTRO_NODE_GROW)
	);
}
function linkPhase(intro: IntroState | null, source: string) {
	if (!intro) return 1;
	const start = (intro.depths[source] ?? 0) * INTRO_COLUMN_STAGGER + INTRO_LINK_DELAY;
	return easeOut(clamp01((intro.elapsed - start) / INTRO_LINK_DRAW));
}

function paintAxis(paint: Paint): 'x' | 'y' | null {
	if (typeof paint === 'string') return null;
	const horizontal = Math.abs((paint.x2 ?? 0) - (paint.x ?? 0));
	const vertical = Math.abs((paint.y2 ?? 0) - (paint.y ?? 0));
	return horizontal >= vertical ? 'x' : 'y';
}

function paintStops(paint: Paint): { offset: number; color: string }[] {
	if (typeof paint === 'string') {
		return [
			{ offset: 0, color: paint },
			{ offset: 1, color: paint }
		];
	}
	const stops = paint.colorStops ?? [];
	return stops.length > 0
		? stops.map((stop) => ({ offset: stop.offset, color: stop.color }))
		: [{ offset: 0, color: GRAY }];
}

function sampleStops(stops: { offset: number; color: string }[], at: number): string {
	const first = stops[0];
	const last = stops[stops.length - 1];
	if (!first || !last) return GRAY;
	if (at <= first.offset) return first.color;
	if (at >= last.offset) return last.color;
	for (let index = 1; index < stops.length; index += 1) {
		const from = stops[index - 1];
		const to = stops[index];
		if (!from || !to || at > to.offset) continue;
		const span = to.offset - from.offset;
		if (span <= 1e-6) return to.color;
		return echarts.color.lerp((at - from.offset) / span, [from.color, to.color]) || from.color;
	}
	return last.color;
}

function windowedPaint(
	paint: Paint,
	axis: 'x' | 'y',
	edges: [number, number, number, number]
): Paint | null {
	const ownAxis = paintAxis(paint);
	if (ownAxis !== null && ownAxis !== axis) return null;
	const stops = paintStops(paint);
	const alphaAt = (offset: number) => {
		if (offset <= edges[0] || offset >= edges[3]) return 0;
		if (offset >= edges[1] && offset <= edges[2]) return 1;
		if (offset < edges[1]) return (offset - edges[0]) / Math.max(1e-6, edges[1] - edges[0]);
		return (edges[3] - offset) / Math.max(1e-6, edges[3] - edges[2]);
	};
	const offsets = [...new Set([0, 1, ...stops.map((stop) => stop.offset), ...edges])]
		.filter((offset) => offset >= 0 && offset <= 1)
		.sort((left, right) => left - right);
	const windowed = offsets.map((offset) => ({
		offset,
		color: withAlpha(sampleStops(stops, offset), alphaAt(offset))
	}));
	return axis === 'x'
		? new echarts.graphic.LinearGradient(0, 0, 1, 0, windowed)
		: new echarts.graphic.LinearGradient(0, 0, 0, 1, windowed);
}

function growPaint(paint: Paint, phase: number): Paint | null {
	const half = (INTRO_NODE_SCALE_FROM + (1 - INTRO_NODE_SCALE_FROM) * phase) / 2;
	return windowedPaint(paint, 'y', [
		0.5 - half - INTRO_FEATHER,
		0.5 - half,
		0.5 + half,
		0.5 + half + INTRO_FEATHER
	]);
}

function drawPaint(paint: Paint, phase: number): Paint | null {
	const head = phase * (1 + INTRO_FEATHER);
	return windowedPaint(paint, 'x', [-2, -1, head - INTRO_FEATHER, head]);
}

function connectedNodes(data: SankeyData, selected: string): Set<string> {
	const result = new Set([selected]);
	const selectedIndex = data.nodes.findIndex((node) => node.name === selected);
	for (const link of data.links) {
		if (link.source === selectedIndex) result.add(data.nodes[link.target]?.name ?? '');
		if (link.target === selectedIndex) result.add(data.nodes[link.source]?.name ?? '');
	}
	result.delete('');
	return result;
}

function nodeLabel(context: SankeyOptionContext): SankeySeriesOption['label'] {
	if (!context.label?.position) return { show: false };
	const inside = context.label.position === 'inside';
	const formatter = context.label.valueFormatter ?? ((value: number) => value.toLocaleString());
	return {
		show: true,
		position: inside ? 'inside' : 'right',
		align: inside ? 'center' : 'left',
		formatter: (params) => {
			const name = String((params as { name?: unknown }).name ?? '');
			const configured = context.config[name]?.label;
			const label = typeof configured === 'string' ? configured : name;
			return context.label?.showValues
				? `{name|${label}}\n{value|${formatter(context.nodeValues[name] ?? 0)}}`
				: `{name|${label}}`;
		},
		rich: {
			name: {
				color: context.resolved.tokens.foreground,
				fontSize: inside ? 10 : 12,
				fontWeight: 500,
				lineHeight: 15
			},
			value: {
				color: withAlpha(context.resolved.tokens.foreground, inside ? 0.6 : 0.5),
				fontFamily: 'monospace',
				fontSize: inside ? 11 : 12,
				lineHeight: 15
			}
		}
	};
}

function sankeySeries(context: SankeyOptionContext): SankeySeriesOption {
	const connected = context.selectedNode
		? connectedNodes(context.data, context.selectedNode)
		: null;
	const targetNames = new Set(
		context.data.links.map((link) => context.data.nodes[link.target]?.name)
	);
	const outside = context.label?.position === 'outside';
	const inside = context.label?.position === 'inside';
	const nodes: SankeyNodeItem[] = context.data.nodes.map((node) => {
		const phase = nodePhase(context.intro, node.name);
		const dimmed = Boolean(connected && !connected.has(node.name));
		const fill = nodeGradient(context.resolved.series[node.name] ?? [GRAY]);
		const grown = phase < 1 ? growPaint(fill, phase) : fill;
		return {
			name: node.name,
			itemStyle: inside
				? {
						color: withAlpha(context.resolved.tokens.background, 0.55 * phase),
						borderColor: grown ?? fill,
						borderWidth: 1,
						borderRadius: context.node.radius,
						opacity: (dimmed ? NODE_DIM_OPACITY : 1) * phase
					}
				: {
						color: grown ?? fill,
						borderWidth: 0,
						borderRadius: context.node.radius,
						opacity: (dimmed ? NODE_DIM_OPACITY : 1) * phase
					},
			label: {
				...(context.config[node.name]?.label === '' ? { show: false } : {}),
				opacity: (dimmed ? LABEL_DIM_OPACITY : 1) * phase,
				...(outside && !targetNames.has(node.name)
					? { position: 'left' as const, align: 'right' as const }
					: {})
			}
		};
	});
	const links: SankeyEdgeItem[] = context.data.links.map((link) => {
		const source = context.data.nodes[link.source]?.name ?? String(link.source);
		const target = context.data.nodes[link.target]?.name ?? String(link.target);
		const connectedLink =
			!context.selectedNode || source === context.selectedNode || target === context.selectedNode;
		const phase = linkPhase(context.intro, source);
		const paint = linkPaint(context, source, target);
		const drawn = phase < 1 ? drawPaint(paint, phase) : paint;
		return {
			source,
			target,
			value: link.value,
			lineStyle: {
				color: drawn ?? paint,
				opacity: (connectedLink ? LINK_FILL_OPACITY : LINK_DIM_OPACITY) * (drawn ? 1 : phase)
			}
		};
	});
	return {
		id: '__sankey',
		type: 'sankey',
		z: 3,
		left: outside ? 120 : 8,
		right: outside ? 120 : 8,
		top: 12,
		bottom: 12,
		nodeWidth: context.nodeWidth,
		nodeGap: context.nodePadding,
		layoutIterations: context.iterations,
		nodeAlign: context.align === 'left' ? 'left' : 'justify',
		draggable: false,
		emphasis: { focus: 'none' },
		lineStyle: { curveness: context.linkCurvature },
		label: nodeLabel(context),
		data: nodes,
		links
	};
}

function insidePlateSeries(context: SankeyOptionContext): SankeySeriesOption | null {
	if (context.label?.position !== 'inside') return null;
	const connected = context.selectedNode
		? connectedNodes(context.data, context.selectedNode)
		: null;
	const nodes: SankeyNodeItem[] = context.data.nodes.map((node) => {
		const phase = nodePhase(context.intro, node.name);
		const dimmed = Boolean(connected && !connected.has(node.name));
		const fill = nodeGradient(context.resolved.series[node.name] ?? [GRAY]);
		const grown = phase < 1 ? growPaint(fill, phase) : fill;
		return {
			name: node.name,
			itemStyle: {
				color: grown ?? fill,
				opacity: (dimmed ? NODE_DIM_OPACITY : 1) * phase,
				borderWidth: 0,
				borderRadius: context.node.radius
			},
			label: { show: false }
		};
	});
	const links: SankeyEdgeItem[] = context.data.links.map((link) => ({
		source: context.data.nodes[link.source]?.name ?? String(link.source),
		target: context.data.nodes[link.target]?.name ?? String(link.target),
		value: link.value,
		lineStyle: { opacity: 0 }
	}));
	return {
		id: '__sankey-plate',
		type: 'sankey',
		z: 2,
		silent: true,
		left: 8,
		right: 8,
		top: 12,
		bottom: 12,
		nodeWidth: context.nodeWidth,
		nodeGap: context.nodePadding,
		layoutIterations: context.iterations,
		nodeAlign: context.align === 'left' ? 'left' : 'justify',
		draggable: false,
		emphasis: { disabled: true },
		label: { show: false },
		lineStyle: { curveness: context.linkCurvature },
		data: nodes,
		links
	};
}

function tooltip(context: SankeyOptionContext): TooltipComponentOption {
	const slot = context.tooltip;
	const labelOf = (name: string) =>
		typeof context.config[name]?.label === 'string' ? (context.config[name].label as string) : name;
	const wrap = (body: string) =>
		`<div class="grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl ${roundnessClass[slot?.roundness ?? 'lg']} ${tooltipVariantClass[slot?.variant ?? 'default']}"><div class="grid gap-1.5">${body}</div></div>`;
	return {
		show: Boolean(slot) && !context.isLoading,
		trigger: 'item',
		confine: true,
		displayTransition: false,
		backgroundColor: 'transparent',
		borderWidth: 0,
		padding: 0,
		extraCssText: 'box-shadow:none;',
		position: resolveTooltipPosition(slot?.position ?? 'variable'),
		formatter: (params) => {
			const item = params as {
				dataType?: string;
				name?: string;
				data?: { source?: unknown; target?: unknown; value?: number };
			};
			if (item.dataType === 'edge') {
				const source = String(item.data?.source ?? '');
				const target = String(item.data?.target ?? '');
				return wrap(
					tooltipRow({
						indicatorHtml: tooltipIndicatorHtml(
							source,
							getColorsCount(context.config[source] ?? {})
						),
						labelText: `${labelOf(source)} → ${labelOf(target)}`,
						valueText: (item.data?.value ?? 0).toLocaleString(),
						dimmed: ''
					})
				);
			}
			const name = String(item.name ?? '');
			return wrap(
				tooltipRow({
					indicatorHtml: tooltipIndicatorHtml(name, getColorsCount(context.config[name] ?? {})),
					labelText: labelOf(name),
					valueText: (context.nodeValues[name] ?? 0).toLocaleString(),
					dimmed: ''
				})
			);
		}
	};
}

export function buildSankeyOption(context: SankeyOptionContext): EChartsSankeyOption {
	validateSankeyData(context.data);
	if (context.isLoading) {
		const transparent = withAlpha(context.resolved.tokens.foreground, 0);
		return {
			animation: false,
			tooltip: { show: false },
			series: [
				{
					id: '__loading',
					type: 'sankey',
					left: 12,
					right: 12,
					top: 12,
					bottom: 12,
					nodeWidth: DEFAULT_NODE_WIDTH,
					nodeGap: DEFAULT_NODE_PADDING,
					layoutIterations: DEFAULT_ITERATIONS,
					draggable: false,
					silent: true,
					emphasis: { disabled: true },
					label: { show: false },
					itemStyle: { color: transparent, borderWidth: 0 },
					lineStyle: { color: transparent, curveness: DEFAULT_LINK_CURVATURE },
					data: SKELETON_NODES,
					links: SKELETON_LINKS
				}
			]
		};
	}
	const main = sankeySeries(context);
	const plate = insidePlateSeries(context);
	return {
		animation: false,
		tooltip: tooltip(context),
		series: plate ? [plate, main] : [main]
	};
}

export function mergeSankeyChartOptions(
	built: EChartsSankeyOption,
	chartOptions?: Record<string, unknown>
): EChartsSankeyOption {
	const merged = chartOptions ? { ...built, ...chartOptions } : built;
	return Object.assign(merged, {
		animation: false,
		animationDurationUpdate: 0
	}) as EChartsSankeyOption;
}

export function sankeyShimmerStops(center: number, color: string, floor: number, peak: number) {
	const half = 0.22;
	const feather = 0.22;
	const alphaAt = (offset: number) => {
		const distance = Math.abs(offset - center);
		if (distance <= half - feather) return peak;
		if (distance >= half) return floor;
		const eased = Math.sin(((1 - (distance - (half - feather)) / feather) * Math.PI) / 2);
		return floor + (peak - floor) * eased;
	};
	return [
		0,
		center - half,
		center - half + feather,
		center,
		center + half - feather,
		center + half,
		1
	]
		.filter((offset) => offset >= 0 && offset <= 1)
		.sort((left, right) => left - right)
		.filter((offset, index, values) => index === 0 || offset - values[index - 1] > 1e-4)
		.map((offset) => ({ offset, color: withAlpha(color, alphaAt(offset)) }));
}
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/sankey-chart-context.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js';
import type { LinkRegistration, NodeRegistration } from './types.js';

const SANKEY_CONTEXT = Symbol('evilcharts.echarts-sankey-chart');

export class EChartsSankeyChartContext {
	nodes = new RegistrationSet<NodeRegistration>();
	links = new RegistrationSet<LinkRegistration>();
}

export function setEChartsSankeyChartContext(): EChartsSankeyChartContext {
	const context = new EChartsSankeyChartContext();
	setContext(SANKEY_CONTEXT, context);
	return context;
}

export function useEChartsSankeyChart(): EChartsSankeyChartContext {
	const context = getContext<EChartsSankeyChartContext | undefined>(SANKEY_CONTEXT);
	if (!context)
		throw new Error('[EvilCharts] ECharts Sankey parts must be children of EChartsSankeyChart.');
	return context;
}
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/sankey-chart.svelte`

```svelte
<script lang="ts">
	import { untrack, type Snippet } from 'svelte';
	import { prefersReducedMotion } from 'svelte/motion';
	import type { EChartsCoreOption, EChartsType } from 'echarts/core';
	import { TooltipComponent } from 'echarts/components';
	import { SankeyChart } from 'echarts/charts';
	import * as echarts from 'echarts/core';
	import {
		ChartContainer,
		DEFAULT_ECHARTS_RENDERER,
		EChartsHost,
		LoadingIndicator,
		RegistrationSet,
		resolveColors,
		setEChartsSharedSlotContext,
		type ChartAccessibility,
		type ChartConfig,
		type EChartsRenderer,
		type ResolvedColors
	} from '$lib/components/evilcharts/ui/echarts-chart/index.js';
	import {
		buildSankeyOption,
		computeNodeDepths,
		computeNodeValues,
		getSankeyRevealDecision,
		mergeSankeyChartOptions,
		sankeyIntroDuration,
		sankeyShimmerStops,
		type IntroState,
		type SankeyOptionContext
	} from './option.js';
	import { setEChartsSankeyChartContext } from './sankey-chart-context.svelte.js';
	import {
		DEFAULT_ITERATIONS,
		DEFAULT_LINK_CURVATURE,
		DEFAULT_NODE_PADDING,
		DEFAULT_NODE_WIDTH,
		LOADING_ANIMATION_DURATION,
		type LinkRegistration,
		type NodeRegistration,
		type SankeyAnimationType,
		type SankeyData,
		type SankeySelection,
		type TooltipRegistration
	} from './types.js';
	import { getRenderableSankeyData } from './validation.js';

	echarts.use([SankeyChart, TooltipComponent]);

	let {
		data,
		config,
		children,
		class: className,
		renderer = DEFAULT_ECHARTS_RENDERER,
		nodeWidth = DEFAULT_NODE_WIDTH,
		nodePadding = DEFAULT_NODE_PADDING,
		linkCurvature = DEFAULT_LINK_CURVATURE,
		iterations = DEFAULT_ITERATIONS,
		sort: _sort = true,
		align = 'justify',
		verticalAlign: _verticalAlign = 'justify',
		defaultSelectedNode = null,
		onSelectionChange,
		isLoading = false,
		animation = true,
		animationType = 'default',
		chartOptions,
		accessibility,
		initialDimension = { width: 320, height: 200 }
	}: {
		data: SankeyData;
		config: ChartConfig;
		children?: Snippet;
		class?: string;
		renderer?: EChartsRenderer;
		nodeWidth?: number;
		nodePadding?: number;
		linkCurvature?: number;
		iterations?: number;
		sort?: boolean;
		align?: 'left' | 'justify';
		verticalAlign?: 'justify' | 'top';
		defaultSelectedNode?: string | null;
		onSelectionChange?: (selection: SankeySelection | null) => void;
		isLoading?: boolean;
		animation?: boolean;
		animationType?: SankeyAnimationType;
		chartOptions?: Record<string, unknown>;
		accessibility?: ChartAccessibility;
		initialDimension?: { width: number; height: number };
	} = $props();

	let container = $state<HTMLDivElement>();
	let themeRevision = $state(0);
	let instance = $state.raw<EChartsType>();
	let selectedNode = $state<string | null>(untrack(() => defaultSelectedNode));
	let resolved = $state.raw<ResolvedColors>({
		series: {},
		tokens: {
			mutedForeground: 'rgba(120, 120, 120, 1)',
			border: 'rgba(120, 120, 120, 0.35)',
			foreground: 'rgba(120, 120, 120, 1)',
			background: 'rgba(0, 0, 0, 1)'
		}
	});
	let hasRevealed = false;
	let revealInstance: EChartsType | undefined;

	const chart = setEChartsSankeyChartContext();
	const tooltipSlots = new RegistrationSet<TooltipRegistration>();
	setEChartsSharedSlotContext({
		register(slot, token, getter) {
			if (slot === 'tooltip')
				return tooltipSlots.register(token, getter as () => TooltipRegistration);
			return () => {};
		}
	});

	const node = $derived<NodeRegistration>(
		chart.nodes.first ?? { radius: 0, isClickable: false, label: null }
	);
	const label = $derived(node.label ?? null);
	const link = $derived<LinkRegistration>(
		chart.links.first ?? { variant: 'gradient', verticalPadding: 0 }
	);
	const tooltip = $derived(tooltipSlots.first);
	const renderableData = $derived(getRenderableSankeyData(data));
	const nodeValues = $derived(computeNodeValues(renderableData));
	const nodeNames = $derived(renderableData.nodes.map((item) => item.name));

	$effect(() => {
		void themeRevision;
		const host = container;
		const names = nodeNames;
		if (host) resolved = resolveColors(host, config, names);
	});

	function optionContext(intro: IntroState | null): SankeyOptionContext {
		return {
			data: renderableData,
			config,
			node,
			label,
			link,
			tooltip,
			selectedNode,
			nodeWidth,
			nodePadding,
			linkCurvature,
			iterations,
			align,
			isLoading,
			resolved,
			nodeValues,
			intro
		};
	}

	const option = $derived.by(() => {
		const built = buildSankeyOption(optionContext(null));
		return mergeSankeyChartOptions(built, chartOptions) as EChartsCoreOption;
	});

	function select(name: string) {
		if (!node.isClickable) return;
		selectedNode = selectedNode === name ? null : name;
		onSelectionChange?.(
			selectedNode === null
				? null
				: {
						dataKey: selectedNode,
						value: nodeValues[selectedNode] ?? 0
					}
		);
	}

	const events = $derived({
		click: (params: unknown) => {
			if (!node.isClickable || !params || typeof params !== 'object') return;
			const item = params as { dataType?: string; name?: string };
			if (item.dataType === 'node' && item.name) select(item.name);
		}
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance) return;
		const lifecycleInputs = [
			config,
			node,
			label,
			link,
			tooltip,
			selectedNode,
			nodeWidth,
			nodePadding,
			linkCurvature,
			iterations,
			align,
			chartOptions
		];
		void lifecycleInputs;
		if (revealInstance !== chartInstance) {
			revealInstance = chartInstance;
			hasRevealed = false;
		}
		const decision = getSankeyRevealDecision({
			hasRevealed,
			isLoading,
			animation,
			animationType,
			reducedMotion: prefersReducedMotion.current
		});
		hasRevealed = decision.hasRevealed;
		if (!decision.shouldReveal) return;
		const depths = computeNodeDepths(renderableData);
		const duration = sankeyIntroDuration(depths);
		let frame = 0;
		const firstFrame = untrack(() =>
			mergeSankeyChartOptions(
				buildSankeyOption(optionContext({ elapsed: 0, depths })),
				chartOptions
			)
		);
		chartInstance.setOption(firstFrame, { notMerge: true });
		const start = performance.now();
		const tick = (now: number) => {
			const elapsed = now - start;
			const intro = elapsed >= duration ? null : { elapsed, depths };
			const frameOption = mergeSankeyChartOptions(
				buildSankeyOption(optionContext(intro)),
				chartOptions
			);
			chartInstance.setOption(frameOption, { silent: true, lazyUpdate: true });
			if (intro) frame = requestAnimationFrame(tick);
		};
		frame = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(frame);
	});

	$effect(() => {
		const chartInstance = instance;
		if (!chartInstance || !isLoading || prefersReducedMotion.current) return;
		let frame = 0;
		const start = performance.now();
		const tick = (now: number) => {
			const phase = ((now - start) / LOADING_ANIMATION_DURATION) % 1;
			const width = chartInstance.getWidth();
			const height = chartInstance.getHeight();
			if (width > 0 && height > 0) {
				const maxT = (width + height) / (2 * width);
				const center = phase * (maxT + 0.44) - 0.22;
				const gradient = (floor: number, peak: number) =>
					new echarts.graphic.LinearGradient(
						0,
						0,
						width,
						width,
						sankeyShimmerStops(center, resolved.tokens.foreground, floor, peak),
						true
					);
				chartInstance.setOption(
					{
						series: [
							{
								id: '__loading',
								itemStyle: { color: gradient(0.1, 0.42), borderWidth: 0 },
								lineStyle: { color: gradient(0.04, 0.16), curveness: DEFAULT_LINK_CURVATURE }
							}
						]
					},
					{ silent: true, lazyUpdate: true }
				);
			}
			frame = requestAnimationFrame(tick);
		};
		frame = requestAnimationFrame(tick);
		return () => cancelAnimationFrame(frame);
	});
</script>

{#snippet overlay()}
	<LoadingIndicator {isLoading} />
{/snippet}

<ChartContainer
	{config}
	{accessibility}
	{initialDimension}
	{overlay}
	bind:element={container}
	bind:themeRevision
	aria-busy={isLoading}
	class={className}
>
	{@render children?.()}
	<EChartsHost
		{option}
		{renderer}
		{events}
		setOptionOptions={{ notMerge: false, replaceMerge: ['series'] }}
		bind:instance
	/>
	{#if node.isClickable}
		<div class="sr-only" aria-label="Chart values">
			{#each renderableData.nodes as item (item.name)}
				<button
					type="button"
					aria-pressed={selectedNode === item.name}
					onclick={() => select(item.name)}>{item.name}: {nodeValues[item.name] ?? 0}</button
				>
			{/each}
		</div>
	{/if}
</ChartContainer>
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/types.ts`

```ts
import type { Snippet } from 'svelte';
import type {
	TooltipPosition,
	TooltipRoundness,
	TooltipVariant
} from '$lib/components/evilcharts/ui/echarts-tooltip/index.js';

export type LinkVariant = 'gradient' | 'solid' | 'source' | 'target';
export type NodeLabelPosition = 'inside' | 'outside';
export type SankeyAnimationType = 'none' | 'default';
export type SankeySelection = { dataKey: string; value: number };

export type SankeyNode = { name: string; icon?: Snippet };
export type SankeyLink = { source: number; target: number; value: number };
export type SankeyData = { nodes: SankeyNode[]; links: SankeyLink[] };

export type NodeRegistration = {
	radius: number;
	isClickable: boolean;
	label?: NodeLabelRegistration | null;
};
export type NodeLabelRegistration = {
	position?: NodeLabelPosition;
	showValues: boolean;
	valueFormatter?: (value: number) => string;
};
export type LinkRegistration = { variant: LinkVariant; verticalPadding: number };
export type TooltipRegistration = {
	variant: TooltipVariant;
	roundness: TooltipRoundness;
	cursor?: boolean;
	position: TooltipPosition;
	defaultIndex?: number;
};

export const DEFAULT_NODE_WIDTH = 10;
export const DEFAULT_NODE_PADDING = 10;
export const DEFAULT_LINK_CURVATURE = 0.5;
export const DEFAULT_ITERATIONS = 32;
export const LOADING_ANIMATION_DURATION = 2000;
```

`$lib/components/evilcharts/charts/echarts-sankey-chart/validation.ts`

```ts
import type { SankeyData } from './types.js';

export const SANKEY_VALIDATION_ERROR_CODE = 'INVALID_SANKEY_DATA' as const;
export const SANKEY_VALIDATION_ERROR_MESSAGE =
	'Sankey data requires integer in-range link endpoints, finite non-negative values, representable aggregate flows and layout scale, and no directed cycles.';

export class SankeyValidationError extends RangeError {
	readonly code = SANKEY_VALIDATION_ERROR_CODE;

	constructor() {
		super(SANKEY_VALIDATION_ERROR_MESSAGE);
		this.name = 'SankeyValidationError';
	}
}

export type SankeyDataAnalysis = {
	topologicalOrder: number[];
	targets: number[][];
	incomingValues: number[];
	outgoingValues: number[];
};

function throwValidationError(): never {
	throw new SankeyValidationError();
}

/** Rejects malformed graphs before derived values, animation depths, or ECharts layout are built. */
export function validateSankeyData(data: SankeyData): SankeyDataAnalysis {
	const targets = data.nodes.map(() => [] as number[]);
	const incomingLinkCounts = data.nodes.map(() => 0);
	const incomingValues = data.nodes.map(() => 0);
	const outgoingValues = data.nodes.map(() => 0);
	let maximumValue = 0;
	let minimumPositiveValue = Number.POSITIVE_INFINITY;

	for (const link of data.links) {
		const hasValidEndpoints =
			Number.isInteger(link.source) &&
			link.source >= 0 &&
			link.source < data.nodes.length &&
			Number.isInteger(link.target) &&
			link.target >= 0 &&
			link.target < data.nodes.length;
		const hasValidValue = Number.isFinite(link.value) && link.value >= 0;

		if (!hasValidEndpoints || !hasValidValue || link.source === link.target) {
			throwValidationError();
		}

		maximumValue = Math.max(maximumValue, link.value);
		if (link.value > 0) minimumPositiveValue = Math.min(minimumPositiveValue, link.value);

		outgoingValues[link.source] += link.value;
		incomingValues[link.target] += link.value;
		if (
			!Number.isFinite(outgoingValues[link.source]) ||
			!Number.isFinite(incomingValues[link.target])
		) {
			throwValidationError();
		}

		targets[link.source].push(link.target);
		incomingLinkCounts[link.target] += 1;
	}

	if (maximumValue > 0 && minimumPositiveValue / maximumValue === 0) {
		throwValidationError();
	}

	const queue: number[] = [];
	for (let nodeIndex = 0; nodeIndex < incomingLinkCounts.length; nodeIndex += 1) {
		if (incomingLinkCounts[nodeIndex] === 0) queue.push(nodeIndex);
	}

	const topologicalOrder: number[] = [];
	for (let queueIndex = 0; queueIndex < queue.length; queueIndex += 1) {
		const nodeIndex = queue[queueIndex];
		topologicalOrder.push(nodeIndex);

		for (const target of targets[nodeIndex]) {
			incomingLinkCounts[target] -= 1;
			if (incomingLinkCounts[target] === 0) queue.push(target);
		}
	}

	if (topologicalOrder.length !== data.nodes.length) throwValidationError();

	return { topologicalOrder, targets, incomingValues, outgoingValues };
}

export function getRenderableSankeyData(data: SankeyData): SankeyData {
	try {
		validateSankeyData(data);
		return data;
	} catch (error) {
		if (error instanceof SankeyValidationError) return { nodes: [], links: [] };
		throw error;
	}
}
```
        
      
      
        ### Add the shared chart module.
        

Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it.


        
          ### $lib/components/evilcharts/ui/echarts-chart

`$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import type { HTMLAttributes } from 'svelte/elements';
	import { cn } from '$lib/utils.js';
	import ChartStyle from './chart-style.svelte';
	import { validateChartConfigColors, type ChartAccessibility, type ChartConfig } from './types.js';

	type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
		config: ChartConfig;
		children?: Snippet;
		overlay?: Snippet;
		footer?: Snippet;
		initialDimension?: { width: number; height: number };
		dimension?: { width: number; height: number };
		element?: HTMLDivElement;
		themeRevision?: number;
		accessibility?: ChartAccessibility;
	};

	let {
		id,
		config,
		children,
		overlay,
		footer,
		initialDimension = { width: 320, height: 200 },
		dimension = $bindable(),
		element = $bindable(),
		themeRevision = $bindable(0),
		accessibility,
		class: className,
		...restProps
	}: Props = $props();

	const uniqueId = $props.id();
	const chartId = $derived(`chart-${id ?? uniqueId}`);
	const descriptionId = $derived(`${chartId}-description`);
	const describedBy = $derived(
		[accessibility?.description ? descriptionId : undefined, accessibility?.describedBy]
			.filter(Boolean)
			.join(' ') || undefined
	);
	let measuredWidth = $state(0);
	let measuredHeight = $state(0);
	const resolvedDimension = $derived(
		measuredWidth > 0 && measuredHeight > 0
			? { width: measuredWidth, height: measuredHeight }
			: initialDimension
	);

	$effect(() => {
		dimension = resolvedDimension;
	});

	$effect.pre(() => validateChartConfigColors(config));

	function observeTheme(node: HTMLElement) {
		const observer = new MutationObserver(() => {
			themeRevision += 1;
		});
		const options = {
			attributes: true,
			attributeFilter: ['class', 'style']
		};
		observer.observe(document.documentElement, options);
		if (node !== document.documentElement) observer.observe(node, options);
		return () => observer.disconnect();
	}
</script>

<div
	{@attach observeTheme}
	bind:this={element}
	data-slot="chart"
	data-chart={chartId}
	role={accessibility ? 'group' : undefined}
	aria-label={accessibility?.label}
	aria-labelledby={accessibility?.labelledBy}
	aria-describedby={describedBy}
	class={cn(
		'relative flex min-h-0 w-full flex-1 flex-col justify-center text-xs',
		!footer && 'aspect-video',
		className
	)}
	{...restProps}
>
	{#if accessibility?.description}
		<span id={descriptionId} class="sr-only">{accessibility.description}</span>
	{/if}
	<ChartStyle id={chartId} {config} />
	<div
		class="relative flex min-h-0 w-full flex-1 flex-col"
		bind:clientWidth={measuredWidth}
		bind:clientHeight={measuredHeight}
	>
		{@render children?.()}
	</div>
	{@render overlay?.()}
	{@render footer?.()}
</div>
```

`$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte`

```svelte
<script lang="ts">
	import { buildChartCss } from './colors.js';
	import type { ChartConfig } from './types.js';

	let { id, config }: { id: string; config: ChartConfig } = $props();
	const css = $derived(buildChartCss(id, config));
</script>

{#if css}
	<svelte:element this={"style"}>{css}</svelte:element>
{/if}
```

`$lib/components/evilcharts/ui/echarts-chart/colors.ts`

```ts
import * as echarts from 'echarts/core';
import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js';

const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/;

export function chartColorToken(key: string): string {
	if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key;
	return `u-${Array.from(key, (character) =>
		(character.codePointAt(0) ?? 0).toString(16).padStart(6, '0')
	).join('')}`;
}

export function chartColorVariableName(key: string, index: number): string {
	return `--color-${chartColorToken(key)}-${index}`;
}

export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string {
	const name = chartColorVariableName(key, index);
	return fallbackIndex === undefined
		? `var(${name})`
		: `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`;
}

export function quoteCssString(value: string): string {
	return `"${Array.from(value, (character) => {
		const codePoint = character.codePointAt(0) ?? 0;
		if (character === '"' || character === '\\') return `\\${character}`;
		if (codePoint === 0) return '\uFFFD';
		if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `;
		return character;
	}).join('')}"`;
}

export function getColorsCount(item: ChartConfig[string]): number {
	if (!item.colors) return 1;
	return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1);
}

export function distributeColors(colors: string[], maxCount: number): string[] {
	if (colors.length === 0) return [];
	if (colors.length >= maxCount) return colors.slice(0, maxCount);

	const result: string[] = [];
	const baseSlots = Math.floor(maxCount / colors.length);
	const extraSlots = maxCount % colors.length;
	for (let index = 0; index < colors.length; index += 1) {
		const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0);
		for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]);
	}
	return result;
}

export function buildChartCss(id: string, config: ChartConfig): string {
	const colorConfig = Object.entries(config).filter(([, item]) => item.colors);
	if (colorConfig.length === 0) return '';

	const variablesFor = (theme: ThemeKey) =>
		colorConfig
			.flatMap(([key, item]) => {
				const authored = item.colors?.[theme];
				if (!authored?.length) return [];
				return distributeColors(authored, getColorsCount(item)).map(
					(color, index) => `  ${chartColorVariableName(key, index)}: ${color};`
				);
			})
			.join('\n');

	return Object.entries(THEMES)
		.map(
			([theme, prefix]) =>
				`${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}`
		)
		.join('\n');
}

let normalizerContext: CanvasRenderingContext2D | null = null;

export function normalizeColor(value: string): string {
	const raw = value.trim();
	if (!raw || typeof document === 'undefined') return raw;

	if (!normalizerContext) {
		const canvas = document.createElement('canvas');
		canvas.width = 1;
		canvas.height = 1;
		normalizerContext = canvas.getContext('2d', { willReadFrequently: true });
	}
	if (!normalizerContext) return raw;

	normalizerContext.clearRect(0, 0, 1, 1);
	normalizerContext.fillStyle = '#000';
	normalizerContext.fillStyle = raw;
	normalizerContext.fillRect(0, 0, 1, 1);
	const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data;
	return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`;
}

export function withAlpha(color: string, alpha: number): string {
	const match = color.match(/rgba?\(([^)]+)\)/);
	if (!match) return color;
	const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim());
	const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0;
	return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`;
}

export type ResolvedColors = {
	series: Record<string, string[]>;
	tokens: {
		mutedForeground: string;
		border: string;
		foreground: string;
		background: string;
	};
};

export function resolveColors(
	container: HTMLElement,
	config: ChartConfig,
	seriesKeys: string[]
): ResolvedColors {
	const computed = getComputedStyle(container);
	const series: Record<string, string[]> = {};

	for (const key of seriesKeys) {
		const count = getColorsCount(config[key] ?? {});
		series[key] = Array.from({ length: count }, (_, index) => {
			const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim();
			return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)';
		});
	}

	const probe = document.createElement('span');
	probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;';
	container.appendChild(probe);
	const readToken = (className: string) => {
		probe.className = className;
		return normalizeColor(getComputedStyle(probe).color);
	};
	const tokens = {
		mutedForeground: readToken('text-muted-foreground'),
		border: readToken('text-border'),
		foreground: readToken('text-foreground'),
		background: readToken('text-background')
	};
	probe.remove();

	return { series, tokens };
}

export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient {
	if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)';
	return new echarts.graphic.LinearGradient(
		0,
		0,
		1,
		0,
		slots.map((color, index) => ({ offset: index / (slots.length - 1), color }))
	);
}

export function indicatorBackground(key: string, colorsCount: number): string {
	if (colorsCount <= 1) return chartColorVariable(key, 0);
	const stops = Array.from({ length: colorsCount }, (_, index) => {
		const offset = (index / (colorsCount - 1)) * 100;
		return `${chartColorVariable(key, index)} ${offset}%`;
	}).join(', ');
	return `linear-gradient(to right, ${stops})`;
}

export function flattenColor(color: string, base: string): string {
	const parse = (value: string) =>
		value
			.match(/rgba?\(([^)]+)\)/)?.[1]
			.split(',')
			.map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1];
	const [red, green, blue, alpha = 1] = parse(color);
	const [baseRed, baseGreen, baseBlue] = parse(base);
	const mix = (channel: number, baseChannel: number) =>
		Math.round(channel * alpha + baseChannel * (1 - alpha));
	return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`;
}
```

`$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte`

```svelte
<script lang="ts">
	import type { HTMLAttributes } from 'svelte/elements';
	import type { EChartsCoreOption, EChartsType, SetOptionOpts } from 'echarts/core';
	import { CanvasRenderer, SVGRenderer } from 'echarts/renderers';
	import * as echarts from 'echarts/core';
	import { cn } from '$lib/utils.js';
	import type { EChartsRenderer } from './types.js';

	// Register renderers in the module that calls `echarts.init`. Keeping this beside the runtime
	// use prevents production tree-shaking from dropping a side-effect-only barrel registration.
	echarts.use([CanvasRenderer, SVGRenderer]);

	export type EChartsEventHandler = (params: unknown) => void;

	type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
		option: EChartsCoreOption;
		renderer: EChartsRenderer;
		instance?: EChartsType;
		events?: Record<string, EChartsEventHandler>;
		setOptionOptions?: SetOptionOpts;
		hideSource?: boolean;
	};

	let {
		option,
		renderer,
		instance = $bindable(),
		events = {},
		setOptionOptions = { notMerge: true, lazyUpdate: false },
		hideSource = false,
		class: className,
		...restProps
	}: Props = $props();

	function createChartAttachment(activeRenderer: EChartsRenderer) {
		return (node: HTMLDivElement) => {
			const chart = echarts.init(node, undefined, { renderer: activeRenderer });
			instance = chart;
			const resizeObserver = new ResizeObserver(() => {
				if (chart.isDisposed()) return;
				if (node.clientWidth === chart.getWidth() && node.clientHeight === chart.getHeight())
					return;
				chart.resize();
			});
			resizeObserver.observe(node);

			return () => {
				resizeObserver.disconnect();
				if (!chart.isDisposed()) chart.dispose();
				if (instance === chart) instance = undefined;
			};
		};
	}

	$effect(() => {
		const chart = instance;
		const nextOption = option;
		const options = setOptionOptions;
		if (!chart || chart.isDisposed()) return;
		chart.setOption(nextOption, options);
	});

	$effect(() => {
		const chart = instance;
		const bindings = Object.entries(events);
		if (!chart || chart.isDisposed()) return;
		for (const [event, handler] of bindings) chart.on(event, handler);
		return () => {
			if (chart.isDisposed()) return;
			for (const [event, handler] of bindings) chart.off(event, handler);
		};
	});
</script>

<div
	{@attach createChartAttachment(renderer)}
	data-slot="echarts-host"
	data-echarts-source-hidden={hideSource || undefined}
	class={cn(
		'absolute inset-0 min-h-0 min-w-0',
		hideSource && '[&_canvas]:opacity-0 [&_svg]:opacity-0',
		className
	)}
	{...restProps}
></div>
```

`$lib/components/evilcharts/ui/echarts-chart/index.ts`

```ts
export { default as ChartContainer } from './chart-container.svelte';
export { default as ChartStyle } from './chart-style.svelte';
export { default as EChartsHost } from './echarts-host.svelte';
export { default as LoadingIndicator } from './loading-indicator.svelte';
export { default as SelectableSeriesControls } from './selectable-series-controls.svelte';
export { mergeLifecycleOptions } from './merge-options.js';
export type { EChartsEventHandler } from './echarts-host.svelte';
export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js';
export {
	getEChartsSharedSlotContext,
	setEChartsSharedSlotContext,
	type EChartsSharedSlotName
} from './shared-slots.svelte.js';
export {
	buildChartCss,
	chartColorToken,
	chartColorVariable,
	chartColorVariableName,
	distributeColors,
	flattenColor,
	getColorsCount,
	indicatorBackground,
	normalizeColor,
	resolveColors,
	seriesPaint,
	withAlpha,
	quoteCssString,
	type ResolvedColors
} from './colors.js';
export {
	DEFAULT_ECHARTS_RENDERER,
	ECHARTS_RENDERERS,
	THEMES,
	THEME_KEYS,
	validateChartConfigColors,
	type AtLeastOneThemeColor,
	type ChartAccessibility,
	type ChartConfig,
	type EChartsRenderer,
	type EChartsRenderStyle,
	type ThemeKey
} from './types.js';
```

`$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte`

```svelte
<script lang="ts">
	import { cubicOut } from 'svelte/easing';
	import { prefersReducedMotion } from 'svelte/motion';
	import { scale } from 'svelte/transition';

	let { isLoading }: { isLoading: boolean } = $props();
</script>

{#if isLoading}
	<div class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
		<div
			role="status"
			aria-live="polite"
			in:scale={{
				duration: prefersReducedMotion.current ? 0 : 250,
				start: 0.92,
				opacity: 0,
				easing: cubicOut
			}}
			class="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary"
		>
			<div
				aria-hidden="true"
				class="h-3 w-3 animate-spin rounded-full border border-border border-t-primary motion-reduce:animate-none"
			></div>
			<span>Loading</span>
		</div>
	</div>
{/if}
```

`$lib/components/evilcharts/ui/echarts-chart/merge-options.ts`

```ts
type LifecycleOptions = {
	animation?: unknown;
	animationDuration?: unknown;
	animationDurationUpdate?: unknown;
};

/** Merge consumer options without letting them break chart-owned animation lifecycles. */
export function mergeLifecycleOptions<T extends object>(built: T, overrides?: object): T {
	const lifecycle = built as T & LifecycleOptions;
	return {
		...built,
		...overrides,
		animation: lifecycle.animation,
		animationDuration: lifecycle.animationDuration,
		animationDurationUpdate: lifecycle.animationDurationUpdate
	} as T;
}
```

`$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts`

```ts
import { SvelteMap } from 'svelte/reactivity';

export type RegistrationGetter<T> = () => T;

/** Ordered, reactive storage for DOM-free compound-component registrations. */
export class RegistrationSet<T> {
	#entries = new SvelteMap<string, RegistrationGetter<T>>();

	register(token: string, getter: RegistrationGetter<T>): () => void {
		this.#entries.set(token, getter);
		return () => {
			if (this.#entries.get(token) === getter) this.#entries.delete(token);
		};
	}

	get values(): T[] {
		return Array.from(this.#entries.values(), (getter) => getter());
	}

	get first(): T | undefined {
		return this.#entries.values().next().value?.();
	}

	get size(): number {
		return this.#entries.size;
	}
}
```

`$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte`

```svelte
<script lang="ts">
	let {
		items,
		selectedKey,
		onToggle
	}: {
		items: { key: string; label: string }[];
		selectedKey: string | null;
		onToggle: (key: string) => void;
	} = $props();
</script>

{#if items.length > 0}
	<div
		class="pointer-events-none absolute inset-0 z-50"
		role="group"
		aria-label="Selectable chart series"
	>
		{#each items as item (item.key)}
			<button
				type="button"
				aria-pressed={selectedKey === item.key}
				class="sr-only focus:pointer-events-auto focus:not-sr-only focus:absolute focus:top-2 focus:left-1/2 focus:-translate-x-1/2 focus:rounded-md focus:border focus:bg-background focus:px-3 focus:py-2 focus:text-foreground focus:shadow-md focus:ring-2 focus:ring-ring focus:outline-none"
				onclick={() => onToggle(item.key)}
			>
				{item.label}
			</button>
		{/each}
	</div>
{/if}
```

`$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';

export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush';

type SharedSlotContext = {
	register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void;
};

const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots');

export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext {
	setContext(ECHARTS_SHARED_SLOT_CONTEXT, context);
	return context;
}

export function getEChartsSharedSlotContext(): SharedSlotContext {
	const context = getContext<SharedSlotContext | undefined>(ECHARTS_SHARED_SLOT_CONTEXT);
	if (!context) {
		throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.');
	}
	return context;
}
```

`$lib/components/evilcharts/ui/echarts-chart/types.ts`

```ts
import type { Component, Snippet } from 'svelte';

export const ECHARTS_RENDERERS = {
	canvas: 'canvas',
	svg: 'svg'
} as const;

export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS];
export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas;

export const THEMES = { light: '', dark: '.dark' } as const;
export type ThemeKey = keyof typeof THEMES;
export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[];

type ThemeColorsBase = {
	[K in ThemeKey]?: string[];
};

export type AtLeastOneThemeColor = {
	[K in ThemeKey]: Required<Pick<ThemeColorsBase, K>> & Partial<Omit<ThemeColorsBase, K>>;
}[ThemeKey];

export type ChartConfig = Record<
	string,
	{
		label?: string | Snippet;
		icon?: Component<Record<string, never>>;
		colors?: AtLeastOneThemeColor;
	}
>;

export function validateChartConfigColors(config: ChartConfig): void {
	for (const [key, item] of Object.entries(config)) {
		if (!item.colors) continue;
		if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue;

		throw new Error(
			`[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.`
		);
	}
}

export type ChartAccessibility =
	| {
			label: string;
			labelledBy?: never;
			description?: string;
			describedBy?: string;
	  }
	| {
			label?: never;
			labelledBy: string;
			description?: string;
			describedBy?: string;
	  };

export type EChartsRenderStyle = 'native' | 'dither';
```
        
      
      
        ### Add the sub-component.
        

Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there.


        
          ### $lib/components/evilcharts/ui/echarts-tooltip

`$lib/components/evilcharts/ui/echarts-tooltip/index.ts`

```ts
export { default as Tooltip } from './tooltip.svelte';
export type { TooltipProps } from './tooltip.svelte';
export {
	escapeTooltipHtml,
	resolveTooltipPosition,
	roundnessClass,
	tooltipBaseOption,
	tooltipIndicatorHtml,
	tooltipRow,
	tooltipShell,
	tooltipVariantClass,
	type TooltipPosition,
	type TooltipRoundness,
	type TooltipVariant
} from './tooltip.js';
```

`$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte`

```svelte
<script lang="ts">
	import { getEChartsSharedSlotContext } from '../echarts-chart/index.js';
	import type { TooltipPosition, TooltipRoundness, TooltipVariant } from './tooltip.js';

	export type TooltipProps = {
		variant?: TooltipVariant;
		roundness?: TooltipRoundness;
		cursor?: boolean;
		defaultIndex?: number;
		position?: TooltipPosition;
	};

	let {
		variant = 'default',
		roundness = 'lg',
		cursor,
		defaultIndex,
		position = 'variable'
	}: TooltipProps = $props();

	const token = $props.id();
	const slots = getEChartsSharedSlotContext();

	$effect(() =>
		slots.register('tooltip', token, () => ({
			variant,
			roundness,
			cursor,
			defaultIndex,
			position
		}))
	);
</script>
```

`$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts`

```ts
import type { TooltipComponentOption } from 'echarts/components';
import { indicatorBackground } from '../echarts-chart/index.js';

export type TooltipVariant = 'default' | 'frosted-glass';
export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl';
export type TooltipPosition = 'fixed' | 'variable';

export const roundnessClass: Record<TooltipRoundness, string> = {
	sm: 'rounded-sm',
	md: 'rounded-md',
	lg: 'rounded-lg',
	xl: 'rounded-xl'
};

export const tooltipVariantClass: Record<TooltipVariant, string> = {
	default: 'bg-background',
	'frosted-glass': 'bg-background/50 backdrop-blur-md'
};

export function escapeTooltipHtml(value: unknown): string {
	return String(value ?? '')
		.replaceAll('&', '&amp;')
		.replaceAll('<', '&lt;')
		.replaceAll('>', '&gt;')
		.replaceAll('"', '&quot;')
		.replaceAll("'", '&#039;');
}

export function tooltipIndicatorHtml(key: string, colorsCount: number): string {
	return `<div class="h-2.5 w-2.5 shrink-0 rounded-[2px]" style="background:${indicatorBackground(key, colorsCount)}"></div>`;
}

export function tooltipRow({
	indicatorHtml,
	labelText,
	valueText,
	dimmed
}: {
	indicatorHtml: string;
	labelText: string;
	valueText: string;
	dimmed: string;
}): string {
	return `<div class="flex w-full flex-wrap items-center gap-2${dimmed}">
          ${indicatorHtml}
          <div class="flex flex-1 items-center justify-between gap-4 leading-none">
            <span class="text-muted-foreground">${escapeTooltipHtml(labelText)}</span>
            <span class="text-foreground font-mono font-medium tabular-nums">${escapeTooltipHtml(valueText)}</span>
          </div>
        </div>`;
}

export function tooltipShell({
	label,
	body,
	roundness,
	variant
}: {
	label: string;
	body: string;
	roundness: TooltipRoundness;
	variant: TooltipVariant;
}): string {
	return `<div class="grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl ${roundnessClass[roundness]} ${tooltipVariantClass[variant]}">
      <div class="font-medium text-primary">${escapeTooltipHtml(label)}</div>
      <div class="grid gap-1.5">${body}</div>
    </div>`;
}

export function resolveTooltipPosition(
	position: TooltipPosition
): TooltipComponentOption['position'] {
	if (position === 'variable') return undefined;
	return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8];
}

export function tooltipBaseOption(params: {
	present: boolean;
	cursor: boolean;
	position: TooltipPosition;
	axisPointerColor: string;
	strokeWidth: number;
}): TooltipComponentOption {
	const { present, cursor, position, axisPointerColor, strokeWidth } = params;
	return {
		show: present,
		trigger: 'axis',
		confine: true,
		displayTransition: false,
		backgroundColor: 'transparent',
		borderWidth: 0,
		padding: 0,
		extraCssText: 'box-shadow:none;',
		axisPointer: cursor
			? {
					type: 'line',
					lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] }
				}
			: { type: 'none' },
		position: resolveTooltipPosition(position)
	};
}
```
        
      
    
  


## Usage

The ECharts sankey chart is composable, sharing the LayerChart sibling's API shape. `<EChartsSankeyChart>` is the container, and every part hangs off it as a compound member — `<EChartsSankeyChart.Node>`, `<EChartsSankeyChart.NodeLabel>`, `<EChartsSankeyChart.Link>`, and `<EChartsSankeyChart.Tooltip>` — so a single import gives you the whole chart. Because nodes and links are intrinsic to the flow data, `<Node>` and `<Link>` always render and just configure the diagram; `<NodeLabel>` and `<Tooltip>` follow presence semantics — omit one and it does not render.

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type ChartConfig,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
</script>
```

```svelte
const data: SankeyData = {
  nodes: [
    { name: "Visit" },
    { name: "Direct-Favourite" },
    { name: "Page-Click" },
    { name: "Detail-Favourite" },
    { name: "Lost" },
  ],
  links: [
    { source: 0, target: 1, value: 3728 },
    { source: 0, target: 2, value: 354170 },
    { source: 2, target: 3, value: 62429 },
    { source: 2, target: 4, value: 291741 },
  ],
};

const chartConfig = {
  Visit: {
    label: "Visit",
    colors: { light: ["#3b82f6"], dark: ["#60a5fa"] },
  },
  "Page-Click": {
    label: "Page Click",
    colors: { light: ["#f59e0b"], dark: ["#fbbf24"] },
  },
  // ... more node configs
} satisfies ChartConfig;

<EChartsSankeyChart data={data} config={chartConfig}>
  <EChartsSankeyChart.Node isClickable>
    <EChartsSankeyChart.NodeLabel position="outside" showValues />
  </EChartsSankeyChart.Node>
  <EChartsSankeyChart.Link variant="source" />
  <EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```

The difference is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles an ECharts option, which ECharts paints with Canvas by default or SVG when `renderer="svg"`.

`config` is the same contract as every EvilCharts chart — each key maps a node name to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from CSS variables at runtime, so dark mode just works.

> 
  

The ECharts implementation brings a few small departures from the LayerChart sibling: node
icons are not rendered, and `verticalPadding` on <code>&lt;Link&gt;</code> has no ECharts
equivalent (see the API notes). All link variants — `gradient`, `solid`,
`source`, and `target` — are supported.




### SVG Renderer

Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer.

### renderer="svg"

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'Organic' },
			{ name: 'PaidAds' },
			{ name: 'Social' },
			{ name: 'Landing' },
			{ name: 'Product' },
			{ name: 'Cart' },
			{ name: 'Purchase' },
			{ name: 'Bounced' }
		],
		links: [
			{ source: 0, target: 3, value: 42000 },
			{ source: 1, target: 3, value: 28000 },
			{ source: 2, target: 3, value: 18000 },
			{ source: 3, target: 4, value: 52000 },
			{ source: 3, target: 7, value: 36000 },
			{ source: 4, target: 5, value: 31000 },
			{ source: 4, target: 7, value: 21000 },
			{ source: 5, target: 6, value: 24000 },
			{ source: 5, target: 7, value: 7000 }
		]
	};

	const chartConfig = {
		Organic: {
			label: 'Organic Search',
			colors: {
				light: ['#059669'],
				dark: ['#34d399']
			}
		},
		PaidAds: {
			label: 'Paid Ads',
			colors: {
				light: ['#dc2626'],
				dark: ['#f87171']
			}
		},
		Social: {
			label: 'Social Media',
			colors: {
				light: ['#7c3aed'],
				dark: ['#a78bfa']
			}
		},
		Landing: {
			label: 'Landing Page',
			colors: {
				light: ['#0891b2'],
				dark: ['#22d3ee']
			}
		},
		Product: {
			label: 'Product Page',
			colors: {
				light: ['#2563eb'],
				dark: ['#60a5fa']
			}
		},
		Cart: {
			label: 'Cart',
			colors: {
				light: ['#ea580c'],
				dark: ['#fb923c']
			}
		},
		Purchase: {
			label: 'Purchase',
			colors: {
				light: ['#16a34a'],
				dark: ['#4ade80']
			}
		},
		Bounced: {
			label: 'Bounced',
			colors: {
				light: ['#f43f5e'],
				dark: ['#fb7185']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart renderer="svg" class="h-full w-full p-4" {data} config={chartConfig}>
	<EChartsSankeyChart.Node isClickable>
		<EChartsSankeyChart.NodeLabel position="outside" showValues />
	</EChartsSankeyChart.Node>
	<EChartsSankeyChart.Link variant="source" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```

### Interactive Selection

Set `isClickable` on `<Node>` to make nodes selectable. The selected node and its
direct neighbors stay highlighted while the rest dim. Handle selection events with
the root's `onSelectionChange` callback:

```svelte
<EChartsSankeyChart
	{data}
	config={chartConfig}
	onSelectionChange={(selection) => {
		if (selection) {
			console.log('Selected:', selection.dataKey, 'Value:', selection.value);
		} else {
			console.log('Deselected');
		}
	}}
>
	<EChartsSankeyChart.Node isClickable />
	<EChartsSankeyChart.Link variant="source" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```

### Loading State

### isLoading='true'

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'BlogPosts' },
			{ name: 'Videos' },
			{ name: 'Podcasts' },
			{ name: 'Twitter' },
			{ name: 'LinkedIn' },
			{ name: 'YouTube' },
			{ name: 'Newsletter' }
		],
		links: [
			{ source: 0, target: 3, value: 12000 },
			{ source: 0, target: 4, value: 8500 },
			{ source: 0, target: 6, value: 15000 },
			{ source: 1, target: 5, value: 28000 },
			{ source: 1, target: 3, value: 4200 },
			{ source: 2, target: 5, value: 9800 },
			{ source: 2, target: 4, value: 3600 }
		]
	};

	const chartConfig = {
		BlogPosts: {
			label: 'Blog Posts',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		Videos: {
			label: 'Videos',
			colors: {
				light: ['#ef4444'],
				dark: ['#f87171']
			}
		},
		Podcasts: {
			label: 'Podcasts',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		Twitter: {
			label: 'Twitter',
			colors: {
				light: ['#0ea5e9'],
				dark: ['#38bdf8']
			}
		},
		LinkedIn: {
			label: 'LinkedIn',
			colors: {
				light: ['#0077b5'],
				dark: ['#0a95d9']
			}
		},
		YouTube: {
			label: 'YouTube',
			colors: {
				light: ['#dc2626'],
				dark: ['#ef4444']
			}
		},
		Newsletter: {
			label: 'Newsletter',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart class="h-full w-full p-4" {data} config={chartConfig} isLoading>
	<EChartsSankeyChart.Node />
	<EChartsSankeyChart.Link variant="source" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```
> 
  

Pass `isLoading` to <code>&lt;EChartsSankeyChart&gt;</code> to show an animated gray skeleton
while data loads.




## Examples

Examples of the sankey chart in different configurations. Customize the `<Link>` `variant`, the root `nodeWidth`, `nodePadding`, and more.

### Gradient Colors

### gradient colors

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'ProductSales' },
			{ name: 'Subscriptions' },
			{ name: 'Services' },
			{ name: 'TotalRevenue' },
			{ name: 'Research' },
			{ name: 'Marketing' },
			{ name: 'Operations' },
			{ name: 'Salaries' },
			{ name: 'Profit' }
		],
		links: [
			{ source: 0, target: 3, value: 450000 },
			{ source: 1, target: 3, value: 320000 },
			{ source: 2, target: 3, value: 180000 },
			{ source: 3, target: 4, value: 185000 },
			{ source: 3, target: 5, value: 142000 },
			{ source: 3, target: 6, value: 198000 },
			{ source: 3, target: 7, value: 285000 },
			{ source: 3, target: 8, value: 140000 }
		]
	};

	const chartConfig = {
		ProductSales: {
			label: 'Product Sales',
			colors: {
				light: ['#86efac', '#22c55e', '#16a34a', '#15803d', '#166534'], // [!code highlight]
				dark: ['#bbf7d0', '#4ade80', '#22c55e', '#16a34a', '#15803d'] // [!code highlight]
			}
		},
		Subscriptions: {
			label: 'Subscriptions',
			colors: {
				light: ['#93c5fd', '#3b82f6', '#2563eb', '#1d4ed8', '#1e40af'], // [!code highlight]
				dark: ['#bfdbfe', '#60a5fa', '#3b82f6', '#2563eb', '#1d4ed8'] // [!code highlight]
			}
		},
		Services: {
			label: 'Services',
			colors: {
				light: ['#c4b5fd', '#8b5cf6', '#7c3aed', '#6d28d9', '#5b21b6'], // [!code highlight]
				dark: ['#ddd6fe', '#a78bfa', '#8b5cf6', '#7c3aed', '#6d28d9'] // [!code highlight]
			}
		},
		TotalRevenue: {
			label: 'Total Revenue',
			colors: {
				light: ['#fde047', '#eab308', '#ca8a04', '#a16207', '#854d0e'], // [!code highlight]
				dark: ['#fef08a', '#facc15', '#eab308', '#ca8a04', '#a16207'] // [!code highlight]
			}
		},
		Research: {
			label: 'R&D',
			colors: {
				light: ['#67e8f9', '#06b6d4', '#0891b2', '#0e7490', '#155e75'], // [!code highlight]
				dark: ['#a5f3fc', '#22d3ee', '#06b6d4', '#0891b2', '#0e7490'] // [!code highlight]
			}
		},
		Marketing: {
			label: 'Marketing',
			colors: {
				light: ['#f9a8d4', '#ec4899', '#db2777', '#be185d', '#9d174d'], // [!code highlight]
				dark: ['#fbcfe8', '#f472b6', '#ec4899', '#db2777', '#be185d'] // [!code highlight]
			}
		},
		Operations: {
			label: 'Operations',
			colors: {
				light: ['#fdba74', '#f97316', '#ea580c', '#c2410c', '#9a3412'], // [!code highlight]
				dark: ['#fed7aa', '#fb923c', '#f97316', '#ea580c', '#c2410c'] // [!code highlight]
			}
		},
		Salaries: {
			label: 'Salaries',
			colors: {
				light: ['#5eead4', '#14b8a6', '#0d9488', '#0f766e', '#115e59'], // [!code highlight]
				dark: ['#99f6e4', '#2dd4bf', '#14b8a6', '#0d9488', '#0f766e'] // [!code highlight]
			}
		},
		Profit: {
			label: 'Profit',
			colors: {
				light: ['#bef264', '#84cc16', '#65a30d', '#4d7c0f', '#3f6212'], // [!code highlight]
				dark: ['#d9f99d', '#a3e635', '#84cc16', '#65a30d', '#4d7c0f'] // [!code highlight]
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart class="h-full w-full p-4" {data} config={chartConfig}>
	<EChartsSankeyChart.Node isClickable>
		<EChartsSankeyChart.NodeLabel position="outside" showValues />
	</EChartsSankeyChart.Node>
	<EChartsSankeyChart.Link variant="gradient" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```

### Labeled Nodes

> 
  

Display labels and values on nodes by composing a <code>&lt;NodeLabel /&gt;</code> inside <code>&lt;Node /&gt;</code>.




#### Inside Labels

### showNodeLabels='inside'

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'CRT_L' }, // Left CRT
			{ name: 'PPT_L' }, // Left PPT
			{ name: 'DMG_L' }, // Left DMG
			{ name: 'PPT_M' }, // Middle PPT
			{ name: 'DMG_M' }, // Middle DMG
			{ name: 'CRT_R' }, // Right CRT
			{ name: 'PPT_R' }, // Right PPT
			{ name: 'DMG_R' } // Right DMG
		],
		links: [
			// From left CRT to middle nodes
			{ source: 0, target: 3, value: 750 },
			{ source: 0, target: 4, value: 502 },

			// From left PPT to middle nodes
			{ source: 1, target: 3, value: 1500 },
			{ source: 1, target: 4, value: 1498 },

			// From left DMG to middle nodes
			{ source: 2, target: 3, value: 3931 },
			{ source: 2, target: 4, value: 1612 },

			// From middle PPT to right nodes
			{ source: 3, target: 5, value: 2000 },
			{ source: 3, target: 6, value: 2091 },
			{ source: 3, target: 7, value: 1840 },

			// From middle DMG to right nodes
			{ source: 4, target: 5, value: 1991 },
			{ source: 4, target: 7, value: 1158 }
		]
	};

	const chartConfig = {
		CRT_L: {
			label: 'CRT',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		PPT_L: {
			label: 'PPT',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		DMG_L: {
			label: 'DMG',
			colors: {
				light: ['#06b6d4', '#8b5cf6'],
				dark: ['#22d3ee', '#a78bfa']
			}
		},
		PPT_M: {
			label: 'PPT',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		DMG_M: {
			label: 'DMG',
			colors: {
				light: ['#06b6d4', '#8b5cf6'],
				dark: ['#22d3ee', '#a78bfa']
			}
		},
		CRT_R: {
			label: 'CRT',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		PPT_R: {
			label: 'PPT',
			colors: {
				light: ['#8b5cf6', '#10b981'],
				dark: ['#a78bfa', '#34d399']
			}
		},
		DMG_R: {
			label: 'DMG',
			colors: {
				light: ['#06b6d4', '#10b981'],
				dark: ['#22d3ee', '#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart
	class="h-full w-full p-4"
	{data}
	config={chartConfig}
	nodeWidth={80}
	nodePadding={24}
>
	<EChartsSankeyChart.Node isClickable radius={4}>
		<!-- [!code highlight:3] -->
		<EChartsSankeyChart.NodeLabel
			position="inside"
			showValues
			valueFormatter={(value) => value.toLocaleString()}
		/>
	</EChartsSankeyChart.Node>
	<EChartsSankeyChart.Link variant="gradient" verticalPadding={8} />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```
### showNodeLabels='inside' - solid colors

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'CRT_L' }, // Left CRT
			{ name: 'PPT_L' }, // Left PPT
			{ name: 'DMG_L' }, // Left DMG
			{ name: 'PPT_M' }, // Middle PPT
			{ name: 'DMG_M' }, // Middle DMG
			{ name: 'CRT_R' }, // Right CRT
			{ name: 'PPT_R' }, // Right PPT
			{ name: 'DMG_R' } // Right DMG
		],
		links: [
			// From left CRT to middle nodes
			{ source: 0, target: 3, value: 800 },
			{ source: 0, target: 4, value: 502 },

			// From left PPT to middle nodes
			{ source: 1, target: 3, value: 1500 },
			{ source: 1, target: 4, value: 1498 },

			// From left DMG to middle nodes
			{ source: 2, target: 3, value: 3931 },
			{ source: 2, target: 4, value: 1612 },

			// From middle PPT to right nodes
			{ source: 3, target: 5, value: 2000 },
			{ source: 3, target: 6, value: 2091 },
			{ source: 3, target: 7, value: 1840 },

			// From middle DMG to right nodes
			{ source: 4, target: 5, value: 1991 },
			{ source: 4, target: 7, value: 1158 }
		]
	};

	const chartConfig = {
		CRT_L: {
			label: 'CRT',
			colors: {
				light: ['#a3a3a3'], // lighter than #525252
				dark: ['#525252']
			}
		},
		PPT_L: {
			label: 'PPT',
			colors: {
				light: ['#d1b3ff'], // lighter than #8b5cf6
				dark: ['#8b5cf6']
			}
		},
		DMG_L: {
			label: 'DMG',
			colors: {
				light: ['#a3a3a3'], // lighter than #404040
				dark: ['#404040']
			}
		},
		PPT_M: {
			label: 'PPT',
			colors: {
				light: ['#c4b5fd'], // lighter than #7c3aed
				dark: ['#7c3aed']
			}
		},
		DMG_M: {
			label: 'DMG',
			colors: {
				light: ['#67e8f9'], // lighter than #06b6d4
				dark: ['#06b6d4']
			}
		},
		CRT_R: {
			label: 'CRT',
			colors: {
				light: ['#6ee7b7'], // lighter than #10b981
				dark: ['#10b981']
			}
		},
		PPT_R: {
			label: 'PPT',
			colors: {
				light: ['#a3a3a3'], // lighter than #525252
				dark: ['#525252']
			}
		},
		DMG_R: {
			label: 'DMG',
			colors: {
				light: ['#a3a3a3'], // lighter than #404040
				dark: ['#404040']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart
	class="h-full w-full p-4"
	{data}
	config={chartConfig}
	nodeWidth={80}
	nodePadding={24}
>
	<EChartsSankeyChart.Node isClickable radius={4}>
		<!-- [!code highlight:2] -->
		<EChartsSankeyChart.NodeLabel
			position="inside"
			valueFormatter={(value) => value.toLocaleString()}
		/>
	</EChartsSankeyChart.Node>
	<EChartsSankeyChart.Link variant="source" verticalPadding={8} />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```
> 
  

Use a larger `nodeWidth` (e.g., 80) on the root to accommodate the text.




#### Outside Labels

### showNodeLabels='outside'

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'Organic' },
			{ name: 'PaidAds' },
			{ name: 'Social' },
			{ name: 'Landing' },
			{ name: 'Product' },
			{ name: 'Cart' },
			{ name: 'Purchase' },
			{ name: 'Bounced' }
		],
		links: [
			{ source: 0, target: 3, value: 42000 },
			{ source: 1, target: 3, value: 28000 },
			{ source: 2, target: 3, value: 18000 },
			{ source: 3, target: 4, value: 52000 },
			{ source: 3, target: 7, value: 36000 },
			{ source: 4, target: 5, value: 31000 },
			{ source: 4, target: 7, value: 21000 },
			{ source: 5, target: 6, value: 24000 },
			{ source: 5, target: 7, value: 7000 }
		]
	};

	const chartConfig = {
		Organic: {
			label: 'Organic Search',
			colors: {
				light: ['#059669'],
				dark: ['#34d399']
			}
		},
		PaidAds: {
			label: 'Paid Ads',
			colors: {
				light: ['#dc2626'],
				dark: ['#f87171']
			}
		},
		Social: {
			label: 'Social Media',
			colors: {
				light: ['#7c3aed'],
				dark: ['#a78bfa']
			}
		},
		Landing: {
			label: 'Landing Page',
			colors: {
				light: ['#0891b2'],
				dark: ['#22d3ee']
			}
		},
		Product: {
			label: 'Product Page',
			colors: {
				light: ['#2563eb'],
				dark: ['#60a5fa']
			}
		},
		Cart: {
			label: 'Cart',
			colors: {
				light: ['#ea580c'],
				dark: ['#fb923c']
			}
		},
		Purchase: {
			label: 'Purchase',
			colors: {
				light: ['#16a34a'],
				dark: ['#4ade80']
			}
		},
		Bounced: {
			label: 'Bounced',
			colors: {
				light: ['#f43f5e'],
				dark: ['#fb7185']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart
	class="h-full w-full p-4"
	{data}
	config={chartConfig}
	nodeWidth={8}
	nodePadding={20}
>
	<EChartsSankeyChart.Node isClickable radius={4}>
		<!-- [!code highlight:3] -->
		<EChartsSankeyChart.NodeLabel
			position="outside"
			showValues
			valueFormatter={(value) => value.toLocaleString()}
		/>
	</EChartsSankeyChart.Node>
	<EChartsSankeyChart.Link variant="source" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```

### Link Variants

> 
  

Set the link coloring strategy with the `variant` prop on <code>&lt;Link /&gt;</code>.




#### Solid Links

### <Link variant='solid' />

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'Direct' },
			{ name: 'Email' },
			{ name: 'Referral' },
			{ name: 'Browse' },
			{ name: 'Search' },
			{ name: 'ViewItem' },
			{ name: 'AddToCart' },
			{ name: 'Checkout' },
			{ name: 'Abandoned' }
		],
		links: [
			{ source: 0, target: 3, value: 15200 },
			{ source: 1, target: 3, value: 8400 },
			{ source: 2, target: 3, value: 6800 },
			{ source: 3, target: 4, value: 18600 },
			{ source: 3, target: 8, value: 11800 },
			{ source: 4, target: 5, value: 12400 },
			{ source: 4, target: 8, value: 6200 },
			{ source: 5, target: 6, value: 8100 },
			{ source: 5, target: 8, value: 4300 },
			{ source: 6, target: 7, value: 5400 },
			{ source: 6, target: 8, value: 2700 }
		]
	};

	const chartConfig = {
		Direct: {
			label: 'Direct Traffic',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		Email: {
			label: 'Email Campaign',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		Referral: {
			label: 'Referral',
			colors: {
				light: ['#06b6d4'],
				dark: ['#22d3ee']
			}
		},
		Browse: {
			label: 'Browse',
			colors: {
				light: ['#f59e0b'],
				dark: ['#fbbf24']
			}
		},
		Search: {
			label: 'Search',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		},
		ViewItem: {
			label: 'View Item',
			colors: {
				light: ['#ec4899'],
				dark: ['#f472b6']
			}
		},
		AddToCart: {
			label: 'Add to Cart',
			colors: {
				light: ['#f97316'],
				dark: ['#fb923c']
			}
		},
		Checkout: {
			label: 'Checkout',
			colors: {
				light: ['#22c55e'],
				dark: ['#4ade80']
			}
		},
		Abandoned: {
			label: 'Abandoned',
			colors: {
				light: ['#e11d48'],
				dark: ['#f43f5e']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart class="h-full w-full p-4" {data} config={chartConfig}>
	<EChartsSankeyChart.Node isClickable />
	<!-- [!code highlight:2] -->
	<EChartsSankeyChart.Link variant="solid" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```
> 
  

Set <code>&lt;Link /&gt;</code> `variant` to `"solid"` for a single color across all links — clean and minimal.




#### Source-colored Links

### <Link variant='source' />

```svelte
<script lang="ts">
	import {
		EChartsSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/echarts-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/echarts-chart/index.js';

	const data: SankeyData = {
		nodes: [
			{ name: 'API' },
			{ name: 'Database' },
			{ name: 'Logs' },
			{ name: 'Ingestion' },
			{ name: 'Transform' },
			{ name: 'Analytics' },
			{ name: 'MLPipeline' },
			{ name: 'Dashboard' },
			{ name: 'Archive' }
		],
		links: [
			{ source: 0, target: 3, value: 85000 },
			{ source: 1, target: 3, value: 62000 },
			{ source: 2, target: 3, value: 43000 },
			{ source: 3, target: 4, value: 190000 },
			{ source: 4, target: 5, value: 95000 },
			{ source: 4, target: 6, value: 55000 },
			{ source: 4, target: 8, value: 40000 },
			{ source: 5, target: 7, value: 72000 },
			{ source: 5, target: 8, value: 23000 },
			{ source: 6, target: 7, value: 38000 },
			{ source: 6, target: 8, value: 17000 }
		]
	};

	const chartConfig = {
		API: {
			label: 'API Events',
			colors: {
				light: ['#0ea5e9'],
				dark: ['#38bdf8']
			}
		},
		Database: {
			label: 'Database',
			colors: {
				light: ['#8b5cf6'],
				dark: ['#a78bfa']
			}
		},
		Logs: {
			label: 'Log Files',
			colors: {
				light: ['#d97706'],
				dark: ['#fbbf24']
			}
		},
		Ingestion: {
			label: 'Ingestion Layer',
			colors: {
				light: ['#f97316'],
				dark: ['#fb923c']
			}
		},
		Transform: {
			label: 'Transform',
			colors: {
				light: ['#eab308'],
				dark: ['#facc15']
			}
		},
		Analytics: {
			label: 'Analytics',
			colors: {
				light: ['#06b6d4'],
				dark: ['#22d3ee']
			}
		},
		MLPipeline: {
			label: 'ML Pipeline',
			colors: {
				light: ['#ec4899'],
				dark: ['#f472b6']
			}
		},
		Dashboard: {
			label: 'Dashboard',
			colors: {
				light: ['#22c55e'],
				dark: ['#4ade80']
			}
		},
		Archive: {
			label: 'Archive',
			colors: {
				light: ['#be185d'],
				dark: ['#ec4899']
			}
		}
	} satisfies ChartConfig;
</script>

<EChartsSankeyChart class="h-full w-full p-4" {data} config={chartConfig}>
	<EChartsSankeyChart.Node isClickable />
	<!-- [!code highlight:2] -->
	<EChartsSankeyChart.Link variant="source" />
	<EChartsSankeyChart.Tooltip />
</EChartsSankeyChart>
```
> 
  

Set <code>&lt;Link /&gt;</code> `variant` to `"source"` to color links by their source node, tracing where flows originate.




## API Reference

A root container plus a small set of composable parts. Render the root, then compose the parts you need as children. Regardless of renderer, each part is declarative config the root compiles, but the API closely mirrors the LayerChart sibling.

### EChartsSankeyChart

The root container. It owns the flow data, shared selection state, loading skeleton, and intro reveal. Everything visual is composed as children and compiled into the ECharts option.


  ### `data` (required)

type: `SankeyData`

Nodes and links for the flow. `SankeyData` is `{ nodes: SankeyNode[]; links: SankeyLink[] }`, where `SankeyNode = { name: string; icon?: Snippet }` and `SankeyLink = { source: number; target: number; value: number }`. (`icon` is accepted for parity with the LayerChart shape but is not rendered by the ECharts provider.)

Each `source` and `target` must be an integer index into `nodes`; values must be finite and non-negative. The graph must be acyclic, and its aggregate flows must fit JavaScript's representable numeric range. Invalid data produces no nodes or links instead of sending partial or non-finite geometry to ECharts.
  ### `config` (required)

type: `ChartConfig`

Defines the chart's nodes. Each key matches a node name, with a `label` and a per-theme `colors` array. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config).
  ### `children` (required)

type: `Snippet`

The composed parts — `<Node />`, `<NodeLabel />`, `<Link />`, and `<Tooltip />`.
  ### `class`

type: `string`

Additional CSS classes for the chart container.
  ### `renderer`

type: `"canvas" | "svg"` · default: `"canvas"`

Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default.
  ### `nodeWidth`

type: `number` · default: `10`

The width of each node in pixels.
  ### `nodePadding`

type: `number` · default: `10`

The vertical gap between nodes in pixels (ECharts `nodeGap`).
  ### `linkCurvature`

type: `number` · default: `0.5`

Curvature of links between nodes, 0 (straight) to 1 (maximum curve).
  ### `iterations`

type: `number` · default: `32`

Iterations for the sankey layout algorithm. Higher values improve the layout but take more time.
  ### `align`

type: `"left" | "justify"` · default: `"justify"`

Horizontal alignment for nodes (ECharts `nodeAlign`). `"left"` aligns to the left, `"justify"` spreads them across the width.
  ### `sort`

type: `boolean` · default: `true`

Accepted for parity with the LayerChart sibling. The ECharts layout always sorts nodes, so this prop has no effect.
  ### `verticalAlign`

type: `"justify" | "top"` · default: `"justify"`

Accepted for parity with the LayerChart sibling. ECharts has no vertical-alignment control for sankey, so this prop has no effect.
  ### `defaultSelectedNode`

type: `string | null` · default: `null`

The node name selected on first render.
  ### `onSelectionChange`



Called when a node is selected or deselected. Receives an object with `dataKey` (node name) and `value` (node value from links), or `null` on deselect. Fires on click while `<Node />` has `isClickable` set.
  ### `isLoading`

type: `boolean` · default: `false`

Shows the animated loading skeleton.
  ### `animation`

type: `boolean` · default: `true`

Master switch for the intro draw-in. Pass `false` to render the chart instantly.
  ### `animationType`

type: `"none" | "default"` · default: `"default"`

`"default"` reveals nodes and links column by column on first render; `"none"` disables the intro. The OS reduce-motion preference falls back to `"none"` automatically.
  ### `chartOptions`

type: `Record<string, unknown>`

Escape hatch merged over the built ECharts option object. See the [ECharts sankey option documentation](https://echarts.apache.org/en/option.html#series-sankey).
  ### `accessibility`

type: `ChartAccessibility`

Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology.


### Node

Configures how the sankey nodes render. Compose a `<NodeLabel />` inside it to show labels and values.


  ### `radius`

type: `number` · default: `0`

The corner radius of node rectangles in pixels. Set to 0 for square nodes.
  ### `isClickable`

type: `boolean` · default: `false`

Lets nodes be clicked to select/deselect them. Selected nodes and their direct neighbors stay highlighted while the rest dim.
  ### `children`

type: `Snippet`

Optional `<NodeLabel />` composition.


### NodeLabel

Declares labels for the `<Node />` it is composed inside. With no `position`, no labels are shown.


  ### `position`

type: `"inside" | "outside"`

Where node labels sit. `"inside"` centers them on the nodes (with a translucent backing plate), `"outside"` hangs them to the right. Without `<NodeLabel />`, or with no `position`, no labels show.
  ### `showValues`

type: `boolean` · default: `false`

Show the total flow value alongside each node label.
  ### `valueFormatter`

type: `(value: number) => string` · default: `(value) => value.toLocaleString()`

Function to format node values when `showValues` is enabled.


### Link

Configures how the sankey links render.


  ### `variant`

type: `"gradient" | "solid" | "source" | "target"` · default: `"gradient"`

The coloring strategy for links. `"gradient"` fades from source to target color, `"solid"` uses the foreground color, `"source"` uses the source node color, `"target"` uses the target node color.
  ### `verticalPadding`

type: `number` · default: `0`

Accepted for parity with the LayerChart sibling. ECharts sizes each link band to its value with no per-link inset, so this prop has no effect.


### Tooltip

The hover tooltip. Its presence enables it; omit it and none shows. Hovering a node shows its label and total flow; hovering a link shows the source → target flow and its value. Hidden automatically while loading.


  ### `variant`

type: `"default" | "frosted-glass"` · default: `"default"`

Controls the visual style of the tooltip surface.
  ### `roundness`

type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"`

Controls the border-radius of the tooltip.
  ### `position`

type: `"fixed" | "variable"` · default: `"variable"`

Controls how the tooltip is anchored. `"variable"` follows the pointer (ECharts' default); `"fixed"` pins the tooltip near the top and only tracks the pointer's X.
  ### `defaultIndex`

type: `number`

Accepted for parity with the LayerChart sibling. ECharts does not surface a default-visible tooltip for sankey, so this prop has no effect.

