
### Basic Chart

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

## Installation


  
  
    ### npm

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

### yarn

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

### bun

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

### pnpm

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

```bash
npm install layerchart motion
```

### yarn

```bash
yarn add layerchart motion
```

### bun

```bash
bun add layerchart motion
```

### pnpm

```bash
pnpm add layerchart motion
```
        
      
      
        ### Copy the code below into your project.
         

Create an `evilcharts` folder with a `charts` subfolder inside your `components` directory, then copy the base layerchart-sankey-chart code into a new file there.


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

`$lib/components/evilcharts/charts/layerchart-sankey-chart/defs/link-gradient.svelte`

```svelte
<script lang="ts">
	/** Source-to-target fade gradient that fills a single gradient-variant link. */
	import type { ChartConfig } from '../../../ui/layerchart-chart/chart-config.js';

	let {
		chartId,
		index,
		config,
		sourceName,
		targetName
	}: {
		chartId: string;
		index: number;
		config: ChartConfig;
		sourceName: string;
		targetName: string;
	} = $props();

	const sourceColor = $derived(
		sourceName in config ? `var(--color-${sourceName}-0)` : 'currentColor'
	);
	const targetColor = $derived(
		targetName in config ? `var(--color-${targetName}-0)` : 'currentColor'
	);
</script>

<linearGradient id={`${chartId}-link-gradient-${index}`} x1="0%" y1="0%" x2="100%" y2="0%">
	<stop offset="0%" stop-color={sourceColor} stop-opacity={0.2} />
	<stop offset="50%" stop-color={sourceColor} stop-opacity={0.5} />
	<stop offset="100%" stop-color={targetColor} stop-opacity={0.2} />
</linearGradient>
```

`$lib/components/evilcharts/charts/layerchart-sankey-chart/defs/link-stroke-gradient.svelte`

```svelte
<script lang="ts">
	/** Primary-coloured stroke gradient highlighting a link connected to the selection. */
	let { chartId, index }: { chartId: string; index: number } = $props();
</script>

<linearGradient id={`${chartId}-link-stroke-${index}`} x1="0%" y1="0%" x2="100%" y2="0%">
	<stop offset="0%" stop-color="var(--primary)" stop-opacity={0} />
	<stop offset="15%" stop-color="var(--primary)" stop-opacity={0.8} />
	<stop offset="50%" stop-color="var(--primary)" stop-opacity={1} />
	<stop offset="85%" stop-color="var(--primary)" stop-opacity={0.8} />
	<stop offset="100%" stop-color="var(--primary)" stop-opacity={0} />
</linearGradient>
```

`$lib/components/evilcharts/charts/layerchart-sankey-chart/defs/node-color-gradients.svelte`

```svelte
<script lang="ts">
	/** Vertical colour gradient for every configured node, painted by name. */
	import { getColorsCount } from '../../../ui/layerchart-chart/colors.js';
	import type { ChartConfig } from '../../../ui/layerchart-chart/chart-config.js';

	let { config, chartId }: { config: ChartConfig; chartId: string } = $props();

	const gradients = $derived(
		Object.entries(config).map(([dataKey, nodeConfig]) => ({
			dataKey,
			colorsCount: getColorsCount(nodeConfig)
		}))
	);
</script>

{#each gradients as { dataKey, colorsCount } (dataKey)}
	<linearGradient id={`${chartId}-sankey-colors-${dataKey}`} x1="0" y1="0" x2="0" y2="1">
		{#if colorsCount === 1}
			<stop offset="0%" stop-color={`var(--color-${dataKey}-0)`} />
			<stop offset="100%" stop-color={`var(--color-${dataKey}-0)`} />
		{:else}
			{#each Array.from({ length: colorsCount }, (_, index) => index) as index (index)}
				<stop
					offset={`${(index / (colorsCount - 1)) * 100}%`}
					stop-color={`var(--color-${dataKey}-${index}, var(--color-${dataKey}-0))`}
				/>
			{/each}
		{/if}
	</linearGradient>
{/each}
```

`$lib/components/evilcharts/charts/layerchart-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 './tooltip.svelte';

type RootComponent = typeof Root;

// Compound API: every part hangs off the root as a static member, so a consumer
// writes <EvilSankeyChart.Node/>, <EvilSankeyChart.Tooltip/>, … from a single import
// — no colliding named marker exports when several charts share one file.
//
// The explicit annotation is required for `svelte-package` to emit types.
export const EvilSankeyChart: RootComponent & {
	Node: typeof Node;
	NodeLabel: typeof NodeLabel;
	Link: typeof Link;
	Tooltip: typeof Tooltip;
} = Object.assign(Root, {
	Node,
	NodeLabel,
	Link,
	Tooltip
});

export type { LinkVariant, NodeLabelPosition } from './types.js';
export type { SankeyData } from './layout.js';
export type { ChartAccessibility, ChartConfig } from '../../ui/layerchart-chart/index.js';
```

`$lib/components/evilcharts/charts/layerchart-sankey-chart/layout.ts`

```ts
/**
 * Sankey layout, ported from Recharts' own algorithm.
 *
 * Recharts does **not** use `d3-sankey`: `chart/Sankey.js` carries its own layout with `align`,
 * `verticalAlign`, `sort` and a relaxation loop, and d3-sankey's numbers differ. Since node
 * positions have to match the reference pixel for pixel, the algorithm is ported here rather than
 * mapped onto LayerChart's d3-sankey wrapper.
 *
 * Everything below is a direct translation of `getNodesTree` → `getDepthTree` → `updateYOfTree` →
 * `resolveCollisions` → `relaxRightToLeft`/`relaxLeftToRight` → `updateYOfLinks` → `computeData`,
 * keeping the same iteration order, the same `alpha *= 0.99` decay, and the same in-place mutation
 * of `sy`/`ty`, because each of those affects the result.
 */

export type SankeyInputNode = Record<string, unknown> & { name: string };
export type SankeyInputLink = Record<string, unknown> & {
	source: number;
	target: number;
	value: number;
};
export type SankeyData = { nodes: SankeyInputNode[]; links: SankeyInputLink[] };

export type LaidOutNode = SankeyInputNode & {
	sourceNodes: number[];
	sourceLinks: number[];
	targetNodes: number[];
	targetLinks: number[];
	value: number;
	depth: number;
	x: number;
	dx: number;
	y: number;
	dy: number;
};

export type LaidOutLink = SankeyInputLink & { dy: number; sy: number; ty: number };

/** Node rectangle handed to the renderer, in chart coordinates. */
export type SankeyNodeShape = {
	x: number;
	y: number;
	width: number;
	height: number;
	index: number;
	payload: LaidOutNode;
};

/** Link band handed to the renderer, in chart coordinates. */
export type SankeyLinkShape = {
	sourceX: number;
	targetX: number;
	sourceY: number;
	targetY: number;
	sourceControlX: number;
	targetControlX: number;
	linkWidth: number;
	index: number;
	payload: Omit<LaidOutLink, 'source' | 'target'> & {
		source: LaidOutNode;
		target: LaidOutNode;
	};
};

const interpolationGenerator = (a: number, b: number) => {
	const ka = +a;
	const kb = b - ka;
	return (t: number) => ka + kb * t;
};

const centerY = (node: LaidOutNode) => node.y + node.dy / 2;

const getValue = (entry: { value?: number } | undefined) => (entry && entry.value) || 0;

const getSumOfIds = (links: LaidOutLink[], ids: number[]) =>
	ids.reduce((result, id) => result + getValue(links[id]), 0);

const getSumWithWeightedSource = (tree: LaidOutNode[], links: LaidOutLink[], ids: number[]) =>
	ids.reduce((result, id) => {
		const link = links[id];
		if (link == null) return result;

		const sourceNode = tree[link.source];
		if (sourceNode == null) return result;

		return result + centerY(sourceNode) * getValue(links[id]);
	}, 0);

const getSumWithWeightedTarget = (tree: LaidOutNode[], links: LaidOutLink[], ids: number[]) =>
	ids.reduce((result, id) => {
		const link = links[id];
		if (link == null) return result;

		const targetNode = tree[link.target];
		if (targetNode == null) return result;

		return result + centerY(targetNode) * getValue(links[id]);
	}, 0);

const ascendingY = (a: LaidOutNode, b: LaidOutNode) => a.y - b.y;

function searchTargetsAndSources(links: SankeyInputLink[], id: number) {
	const sourceNodes: number[] = [];
	const sourceLinks: number[] = [];
	const targetNodes: number[] = [];
	const targetLinks: number[] = [];

	for (let i = 0, len = links.length; i < len; i++) {
		const link = links[i];
		if (link?.source === id) {
			targetNodes.push(link.target);
			targetLinks.push(i);
		}
		if (link?.target === id) {
			sourceNodes.push(link.source);
			sourceLinks.push(i);
		}
	}

	return { sourceNodes, sourceLinks, targetLinks, targetNodes };
}

function updateDepthOfTargets(tree: LaidOutNode[], curNode: LaidOutNode) {
	for (const targetNode of curNode.targetNodes) {
		if (targetNode == null) continue;

		const target = tree[targetNode];
		if (target) {
			target.depth = Math.max(curNode.depth + 1, target.depth);
			updateDepthOfTargets(tree, target);
		}
	}
}

function getNodesTree(
	{ nodes, links }: SankeyData,
	width: number,
	nodeWidth: number,
	align: 'left' | 'justify'
) {
	const tree = nodes.map((entry, index) => {
		const result = searchTargetsAndSources(links, index);

		return {
			...entry,
			...result,
			value: Math.max(
				getSumOfIds(links as LaidOutLink[], result.sourceLinks),
				getSumOfIds(links as LaidOutLink[], result.targetLinks)
			),
			depth: 0,
			x: 0,
			dx: nodeWidth,
			y: 0,
			dy: 0
		} as LaidOutNode;
	});

	for (const node of tree) {
		if (node != null && !node.sourceNodes.length) {
			updateDepthOfTargets(tree, node);
		}
	}

	const maxDepth = tree.reduce((max, entry) => Math.max(max, entry.depth), 0);

	if (maxDepth >= 1) {
		const childWidth = (width - nodeWidth) / maxDepth;
		for (const node of tree) {
			if (node == null) continue;
			// `justify` pushes every leaf out to the last column; `left` leaves it where it landed.
			if (!node.targetNodes.length && align === 'justify') {
				node.depth = maxDepth;
			}
			node.x = node.depth * childWidth;
			node.dx = nodeWidth;
		}
	}

	return { tree, maxDepth };
}

function getDepthTree(tree: LaidOutNode[]) {
	const result: LaidOutNode[][] = [];

	for (const node of tree) {
		if (node == null) continue;
		if (!result[node.depth]) result[node.depth] = [];
		result[node.depth].push(node);
	}

	return result;
}

function updateYOfTree(
	depthTree: LaidOutNode[][],
	height: number,
	nodePadding: number,
	links: SankeyInputLink[],
	verticalAlign: 'justify' | 'top'
): LaidOutLink[] {
	const yRatio = Math.min(
		...depthTree.map(
			(nodes) =>
				(height - (nodes.length - 1) * nodePadding) /
				nodes.reduce((sum, node) => sum + getValue(node), 0)
		)
	);

	for (const nodes of depthTree) {
		if (nodes == null) continue;

		if (verticalAlign === 'top') {
			let currentY = 0;
			for (const node of nodes) {
				if (node == null) continue;
				node.dy = node.value * yRatio;
				node.y = currentY;
				currentY += node.dy + nodePadding;
			}
		} else {
			// The relaxation loop below starts from the node's index, exactly as Recharts does.
			nodes.forEach((node, index) => {
				if (node == null) return;
				node.y = index;
				node.dy = node.value * yRatio;
			});
		}
	}

	return links.map((link) => ({ ...link, dy: getValue(link) * yRatio, sy: 0, ty: 0 }));
}

function resolveCollisions(
	depthTree: LaidOutNode[][],
	height: number,
	nodePadding: number,
	sort = true
) {
	for (const nodes of depthTree) {
		if (nodes == null) continue;
		const n = nodes.length;

		// Sort by the value of y
		if (sort) nodes.sort(ascendingY);

		let y0 = 0;
		for (let j = 0; j < n; j++) {
			const node = nodes[j];
			if (node == null) continue;

			const dy = y0 - node.y;
			if (dy > 0) node.y += dy;
			y0 = node.y + node.dy + nodePadding;
		}

		y0 = height + nodePadding;
		for (let j = n - 1; j >= 0; j--) {
			const node = nodes[j];
			if (node == null) continue;

			const dy = node.y + node.dy + nodePadding - y0;
			if (dy > 0) {
				node.y -= dy;
				y0 = node.y;
			} else {
				break;
			}
		}
	}
}

function relaxLeftToRight(
	tree: LaidOutNode[],
	depthTree: LaidOutNode[][],
	links: LaidOutLink[],
	alpha: number
) {
	for (const nodes of depthTree) {
		if (nodes == null) continue;
		for (const node of nodes) {
			if (node == null || !node.sourceLinks.length) continue;

			const sourceSum = getSumOfIds(links, node.sourceLinks);
			const weightedSum = getSumWithWeightedSource(tree, links, node.sourceLinks);
			const y = weightedSum / sourceSum;
			node.y += (y - centerY(node)) * alpha;
		}
	}
}

function relaxRightToLeft(
	tree: LaidOutNode[],
	depthTree: LaidOutNode[][],
	links: LaidOutLink[],
	alpha: number
) {
	for (let i = depthTree.length - 1; i >= 0; i--) {
		const nodes = depthTree[i];
		if (nodes == null) continue;

		for (const node of nodes) {
			if (node == null || !node.targetLinks.length) continue;

			const targetSum = getSumOfIds(links, node.targetLinks);
			const weightedSum = getSumWithWeightedTarget(tree, links, node.targetLinks);
			const y = weightedSum / targetSum;
			node.y += (y - centerY(node)) * alpha;
		}
	}
}

function updateYOfLinks(tree: LaidOutNode[], links: LaidOutLink[]) {
	for (const node of tree) {
		if (node == null) continue;

		let sy = 0;
		let ty = 0;

		node.targetLinks.sort((a, b) => {
			const yA = tree[links[a]?.target]?.y;
			const yB = tree[links[b]?.target]?.y;
			if (yA == null || yB == null) return 0;
			return yA - yB;
		});
		node.sourceLinks.sort((a, b) => {
			const yA = tree[links[a]?.source]?.y;
			const yB = tree[links[b]?.source]?.y;
			if (yA == null || yB == null) return 0;
			return yA - yB;
		});

		for (const targetLink of node.targetLinks) {
			if (targetLink == null) continue;
			const link = links[targetLink];
			if (link) {
				link.sy = sy;
				sy += link.dy;
			}
		}
		for (const sourceLink of node.sourceLinks) {
			if (sourceLink == null) continue;
			const link = links[sourceLink];
			if (link) {
				link.ty = ty;
				ty += link.dy;
			}
		}
	}
}

export type SankeyLayoutOptions = {
	data: SankeyData;
	/** Plot width, already inside the chart margin. */
	width: number;
	/** Plot height, already inside the chart margin. */
	height: number;
	iterations: number;
	nodeWidth: number;
	nodePadding: number;
	linkCurvature: number;
	sort: boolean;
	align: 'left' | 'justify';
	verticalAlign: 'justify' | 'top';
	/** Chart margin, added to every coordinate exactly as Recharts' `left`/`top` do. */
	left: number;
	top: number;
};

/** Runs the layout and returns the node rectangles and link bands ready to draw. */
export function computeSankey(options: SankeyLayoutOptions): {
	nodes: SankeyNodeShape[];
	links: SankeyLinkShape[];
} {
	const {
		data,
		width,
		height,
		iterations,
		nodeWidth,
		nodePadding,
		linkCurvature,
		sort,
		align,
		verticalAlign,
		left,
		top
	} = options;

	if (data.nodes.length === 0 || !(width > 0) || !(height > 0)) {
		return { nodes: [], links: [] };
	}

	const { tree } = getNodesTree(data, width, nodeWidth, align);
	const depthTree = getDepthTree(tree);
	const links = updateYOfTree(depthTree, height, nodePadding, data.links, verticalAlign);

	resolveCollisions(depthTree, height, nodePadding, sort);

	if (verticalAlign === 'justify') {
		let alpha = 1;
		for (let i = 1; i <= iterations; i++) {
			relaxRightToLeft(tree, depthTree, links, (alpha *= 0.99));
			resolveCollisions(depthTree, height, nodePadding, sort);
			relaxLeftToRight(tree, depthTree, links, alpha);
			resolveCollisions(depthTree, height, nodePadding, sort);
		}
	}

	updateYOfLinks(tree, links);

	const nodes: SankeyNodeShape[] = tree.map((node, index) => ({
		x: node.x + left,
		y: node.y + top,
		width: node.dx,
		height: node.dy,
		index,
		payload: node
	}));

	const bands: SankeyLinkShape[] = [];

	links.forEach((link, index) => {
		const sourceNode = tree[link.source];
		const targetNode = tree[link.target];
		if (sourceNode == null || targetNode == null) return;

		const sourceX = sourceNode.x + sourceNode.dx + left;
		const targetX = targetNode.x + left;
		const interpolate = interpolationGenerator(sourceX, targetX);

		// Destructured so the node objects replace the numeric indices rather than intersecting
		// with them, which is what the reference's spread does at runtime.
		const { source: _sourceIndex, target: _targetIndex, ...linkRest } = link;

		bands.push({
			sourceX,
			targetX,
			sourceY: sourceNode.y + link.sy + link.dy / 2 + top,
			targetY: targetNode.y + link.ty + link.dy / 2 + top,
			sourceControlX: interpolate(linkCurvature),
			targetControlX: interpolate(1 - linkCurvature),
			linkWidth: link.dy,
			index,
			payload: { ...linkRest, source: sourceNode, target: targetNode }
		});
	});

	return { nodes, links: bands };
}

/** Sums a node's outgoing flow, falling back to incoming flow for leaf nodes. */
export function getNodeValue(data: SankeyData, nodeName: string): number {
	const nodeIndex = data.nodes.findIndex((node) => node.name === nodeName);
	if (nodeIndex === -1) return 0;

	const outgoing = data.links
		.filter((link) => link.source === nodeIndex)
		.reduce((sum, link) => sum + link.value, 0);
	const incoming = data.links
		.filter((link) => link.target === nodeIndex)
		.reduce((sum, link) => sum + link.value, 0);

	return outgoing > 0 ? outgoing : incoming;
}

/** Whether a node is the selected one or directly linked to it. */
export function isNodeConnected(
	data: SankeyData,
	selectedNode: string | null,
	nodeName: string
): boolean {
	if (selectedNode === null || selectedNode === nodeName) return true;

	const selectedIdx = data.nodes.findIndex((node) => node.name === selectedNode);
	const nodeIdx = data.nodes.findIndex((node) => node.name === nodeName);

	return data.links.some(
		(link) =>
			(link.source === selectedIdx && link.target === nodeIdx) ||
			(link.source === nodeIdx && link.target === selectedIdx)
	);
}
```

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

```svelte
<script lang="ts">
	/**
	 * Configures how the sankey links render. Like <Node />, it is a configuration slot read by the
	 * root and renders nothing itself. The `variant` controls how each link band is coloured.
	 */
	import { useSankeySlots } from './sankey-slots.svelte.js';
	import type { LinkVariant } from './types.js';

	let {
		variant,
		verticalPadding = 0
	}: {
		variant?: LinkVariant; // colouring strategy for the link bands
		verticalPadding?: number; // shrinks link width where it meets a node
	} = $props();

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

	$effect.pre(() => {
		slots.registerLink(token, { variant, verticalPadding });
		return () => slots.unregisterLink(token);
	});
</script>
```

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

```svelte
<script lang="ts">
	/** One pulsing skeleton link. Separate component for the same reason as `loading-node`. */
	let { d, width, delay, duration }: { d: string; width: number; delay: number; duration: number } =
		$props();
</script>

<path
	class="loading-link"
	{d}
	fill="none"
	stroke="currentColor"
	stroke-width={width}
	style:--loading-duration={`${duration}s`}
	style:--loading-delay={`${delay}s`}
/>

<style>
	.loading-link {
		opacity: 0.04;
		animation: loading-link-pulse var(--loading-duration) ease-in-out var(--loading-delay) infinite;
	}

	@keyframes loading-link-pulse {
		50% {
			opacity: 0.14;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.loading-link {
			opacity: 0.09;
			animation: none;
		}
	}
</style>
```

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

```svelte
<script lang="ts">
	/**
	 * One pulsing skeleton node.
	 *
	 * A component per element so each owns its own `ref`: motion-sv writes `ref` back into the props
	 * proxy, which Svelte rejects unless it is bound, and a binding cannot be declared inside an
	 * `{#each}`.
	 */
	let {
		x,
		y,
		width,
		height,
		delay,
		duration
	}: {
		x: number;
		y: number;
		width: number;
		height: number;
		delay: number;
		duration: number;
	} = $props();
</script>

<rect
	class="loading-node"
	{x}
	{y}
	{width}
	{height}
	rx={2}
	fill="currentColor"
	style:--loading-duration={`${duration}s`}
	style:--loading-delay={`${delay}s`}
/>

<style>
	.loading-node {
		opacity: 0.15;
		animation: loading-node-pulse var(--loading-duration) ease-in-out var(--loading-delay) infinite;
	}

	@keyframes loading-node-pulse {
		50% {
			opacity: 0.4;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.loading-node {
			opacity: 0.25;
			animation: none;
		}
	}
</style>
```

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

```svelte
<script lang="ts">
	/**
	 * The skeleton sankey shown while the chart is loading. Rendered by the root in place of the real
	 * diagram — a fixed grid of pulsing nodes and links, with the reference's exact coordinates and
	 * per-element delays.
	 */
	import LoadingLink from './loading-link.svelte';
	import LoadingNode from './loading-node.svelte';
	import { LOADING_ANIMATION_DURATION } from '../types.js';

	const nodes = [
		{ x: 30, y: 25, width: 12, height: 65, delay: 0 },
		{ x: 30, y: 110, width: 12, height: 50, delay: 0.3 },
		{ x: 30, y: 180, width: 12, height: 45, delay: 0.15 },
		{ x: 244, y: 20, width: 12, height: 55, delay: 0.45 },
		{ x: 244, y: 95, width: 12, height: 75, delay: 0.6 },
		{ x: 244, y: 190, width: 12, height: 40, delay: 0.25 },
		{ x: 458, y: 35, width: 12, height: 80, delay: 0.5 },
		{ x: 458, y: 135, width: 12, height: 90, delay: 0.1 }
	];

	const links = [
		{ from: 0, to: 3, width: 26, delay: 0.2 },
		{ from: 0, to: 4, width: 18, delay: 0.7 },
		{ from: 1, to: 4, width: 24, delay: 0.4 },
		{ from: 1, to: 5, width: 12, delay: 0.9 },
		{ from: 2, to: 4, width: 16, delay: 0.1 },
		{ from: 2, to: 5, width: 14, delay: 0.55 },
		{ from: 3, to: 6, width: 22, delay: 0.35 },
		{ from: 3, to: 7, width: 18, delay: 0.8 },
		{ from: 4, to: 6, width: 28, delay: 0.05 },
		{ from: 4, to: 7, width: 32, delay: 0.65 },
		{ from: 5, to: 7, width: 16, delay: 0.45 }
	];

	/** A bezier connecting the right edge of one node to the left of another. */
	function getLinkPath(fromIdx: number, toIdx: number) {
		const from = nodes[fromIdx];
		const to = nodes[toIdx];
		const startX = from.x + from.width;
		const startY = from.y + from.height / 2;
		const endX = to.x;
		const endY = to.y + to.height / 2;
		const controlX1 = startX + (endX - startX) * 0.4;
		const controlX2 = startX + (endX - startX) * 0.6;
		return `M${startX},${startY} C${controlX1},${startY} ${controlX2},${endY} ${endX},${endY}`;
	}

	const baseDuration = LOADING_ANIMATION_DURATION / 1000;
</script>

<!--
	The reference renders this into its own `viewBox="0 0 500 250"` overlay, so the coordinates above
	are in that space rather than the chart's.
-->
<svg
	viewBox="0 0 500 250"
	preserveAspectRatio="xMidYMid meet"
	width="100%"
	height="100%"
	class="absolute inset-0"
>
	{#each links as link, index (`${link.from}-${link.to}`)}
		<LoadingLink
			d={getLinkPath(link.from, link.to)}
			width={link.width}
			delay={link.delay}
			duration={baseDuration * (0.8 + (index % 3) * 0.2)}
		/>
	{/each}
	{#each nodes as node, index (`${node.x}-${node.y}`)}
		<LoadingNode
			x={node.x}
			y={node.y}
			width={node.width}
			height={node.height}
			delay={node.delay}
			duration={baseDuration * (0.9 + (index % 4) * 0.1)}
		/>
	{/each}
</svg>
```

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

```svelte
<script lang="ts">
	/**
	 * Declares labels for the <Node /> it is composed inside. Like <Node />, it is a configuration
	 * slot and renders nothing on its own.
	 */
	import { useNodeSlots } from './sankey-slots.svelte.js';
	import type { NodeLabelPosition } from './types.js';

	let {
		position,
		showValues = false,
		valueFormatter
	}: {
		position?: NodeLabelPosition; // places labels inside or beside the nodes
		showValues?: boolean; // appends each node's total flow value
		valueFormatter?: (value: number) => string; // formats node values when shown
	} = $props();

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

	$effect.pre(() => {
		slots.registerLabel(token, { position, showValues, valueFormatter });
		return () => slots.unregisterLabel(token);
	});
</script>
```

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

```svelte
<script lang="ts">
	/**
	 * Configures how the sankey nodes render. It is a configuration slot — the root reads its props
	 * and wires them into the node renderer, so it renders nothing itself. Compose a <NodeLabel />
	 * inside it to show labels.
	 */
	import type { Snippet } from 'svelte';
	import { setNodeSlotsContext, useSankeySlots } from './sankey-slots.svelte.js';

	let {
		radius,
		isClickable = false,
		children
	}: {
		radius?: number; // corner radius of node rectangles in pixels
		isClickable?: boolean; // lets nodes be selected by clicking them
		children?: Snippet; // optional <NodeLabel /> composition
	} = $props();

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

	// The <NodeLabel /> child registers into this…
	const nodeSlots = setNodeSlotsContext();

	$effect.pre(() => {
		slots.registerNode(token, { radius, isClickable });
		return () => slots.unregisterNode(token);
	});

	// …and it is mirrored onto the chart-level slots, which is where the root reads it.
	$effect(() => {
		slots.nodeLabel = nodeSlots.label;
	});
</script>

<!-- Renders nothing: the child only needs to register itself. -->
{@render children?.()}
```

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

```ts
import { getContext, setContext } from 'svelte';
import type { ChartConfig } from '../../ui/layerchart-chart/chart-config.js';
import { ChartSlots } from '../../ui/layerchart-chart/chart-slots.svelte.js';
import type { SankeyData } from './layout.js';

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

type Options = {
	/** The nodes + links rendered by the chart. */
	data: () => SankeyData;
	/** Colors + labels keyed by node name. */
	config: () => ChartConfig;
	/** Selector-safe id scoping this chart's SVG defs. */
	chartId: () => string;
	isLoading: () => boolean;
	selectedNode: () => string | null;
	selectNode: (nodeName: string | null) => void;
};

/**
 * Shared state for every part of the chart. Lifted into <EvilSankeyChart /> so that <Node />,
 * <Link />, and <Tooltip /> can read it without prop drilling. A sankey chart's data is rigid — the
 * root lays out `nodes`/`links` itself — so the parts here configure how those render.
 */
export class SankeyChartContext {
	#options: Options;

	/** Parts that render outside `<Svg>` — see `ChartSlots`. */
	slots = new ChartSlots();

	constructor(options: Options) {
		this.#options = options;
	}

	get data() {
		return this.#options.data();
	}
	get config() {
		return this.#options.config();
	}
	get chartId() {
		return this.#options.chartId();
	}
	get isLoading() {
		return this.#options.isLoading();
	}
	get selectedNode() {
		return this.#options.selectedNode();
	}

	selectNode = (nodeName: string | null) => {
		this.#options.selectNode(nodeName);
	};
}

export function setSankeyChartContext(options: Options) {
	const context = new SankeyChartContext(options);
	setContext(SANKEY_CHART_KEY, context);
	return context;
}

/** Reads the chart context, throwing a helpful error when used outside <EvilSankeyChart /> */
export function useSankeyChart(): SankeyChartContext {
	const context = getContext<SankeyChartContext | undefined>(SANKEY_CHART_KEY);

	if (!context) {
		throw new Error(
			'Sankey chart parts (<Node />, <Link />, <Tooltip />, …) must be used within <EvilSankeyChart />'
		);
	}

	return context;
}
```

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

```svelte
<script lang="ts">
	/**
	 * Root of the composable sankey chart. Owns the flow data, the shared context, the layout
	 * configuration, and the loading skeleton. The visual parts — the nodes, links, and tooltip —
	 * are composed as children, so a consumer renders exactly the parts they need.
	 */
	import { Chart, Svg, type ChartState } from 'layerchart';
	import { untrack, type Snippet } from 'svelte';
	import {
		ChartContainer,
		LoadingIndicator,
		type ChartAccessibility,
		type ChartConfig
	} from '../../ui/layerchart-chart/index.js';
	import { ChartBackground, type BackgroundVariant } from '../../ui/layerchart-background/index.js';
	import NodeColorGradients from './defs/node-color-gradients.svelte';
	import { computeSankey, getNodeValue, type SankeyData } from './layout.js';
	import LoadingSankey from './loading/loading-sankey.svelte';
	import SankeyLink from './sankey-link.svelte';
	import SankeyNode from './sankey-node.svelte';
	import { setSankeyChartContext } from './sankey-chart-context.svelte.js';
	import { setSankeySlotsContext } from './sankey-slots.svelte.js';
	import TooltipRender from './tooltip-render.svelte';
	import {
		CHART_MARGIN,
		DEFAULT_ITERATIONS,
		DEFAULT_LINK_CURVATURE,
		DEFAULT_NODE_PADDING,
		DEFAULT_NODE_WIDTH
	} from './types.js';

	let {
		data,
		config,
		children,
		class: className,
		accessibility,
		nodeWidth = DEFAULT_NODE_WIDTH,
		nodePadding = DEFAULT_NODE_PADDING,
		linkCurvature = DEFAULT_LINK_CURVATURE,
		iterations = DEFAULT_ITERATIONS,
		sort = true,
		align = 'justify',
		verticalAlign = 'justify',
		backgroundVariant,
		defaultSelectedNode = null,
		onSelectionChange,
		isLoading = false,
		sankeyProps,
		chartProps,
		initialDimension = { width: 320, height: 200 }
	}: {
		data: SankeyData; // nodes + links rendered by the chart
		config: ChartConfig; // node colors + labels keyed by node name
		children: Snippet; // composed parts — <Node />, <Link />, <Tooltip />, …
		class?: string; // extra classes for the chart container
		accessibility?: ChartAccessibility; // accessible name and description for the chart group
		nodeWidth?: number; // width of each node in pixels
		nodePadding?: number; // vertical gap between nodes in pixels
		linkCurvature?: number; // link curve amount, 0 (straight) to 1 (maximum)
		iterations?: number; // layout iterations — higher is more accurate
		sort?: boolean; // sorts nodes automatically for an optimal layout
		align?: 'left' | 'justify'; // horizontal node alignment strategy
		verticalAlign?: 'justify' | 'top'; // vertical node alignment strategy
		backgroundVariant?: BackgroundVariant; // background pattern behind the chart
		defaultSelectedNode?: string | null; // node selected on first render
		onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void; // fires when the selected node changes
		isLoading?: boolean; // shows the animated loading skeleton
		sankeyProps?: Record<string, unknown>; // canonical escape hatch, matching the original API
		/** @deprecated Use `sankeyProps`. */
		chartProps?: Record<string, unknown>; // escape hatch for the raw LayerChart Chart
		initialDimension?: { width: number; height: number }; // zero-size/first-render fallback
	} = $props();

	const forwardedSankeyProps = $derived({ ...(chartProps ?? {}), ...(sankeyProps ?? {}) });

	const chartId = $props.id(); // selector-safe id keeps CSS/SVG references valid
	let chartDimension = $state(untrack(() => initialDimension));

	// One-time initialisation, mirroring the reference's `useState(defaultSelectedNode)`.
	let selectedNode = $state<string | null>(untrack(() => defaultSelectedNode));

	/** LayerChart's chart state, for the plot box and the tooltip. */
	let layerContext = $state<ChartState<Record<string, unknown>> | undefined>(undefined);

	const slots = setSankeySlotsContext();

	setSankeyChartContext({
		data: () => data,
		config: () => config,
		chartId: () => chartId,
		isLoading: () => isLoading,
		selectedNode: () => selectedNode,
		selectNode: (nodeName) => {
			selectedNode = nodeName;

			if (!onSelectionChange) return;
			if (nodeName === null) {
				onSelectionChange(null);
				return;
			}
			onSelectionChange({ dataKey: nodeName, value: getNodeValue(data, nodeName) });
		}
	});

	/**
	 * The laid-out diagram.
	 *
	 * Recharts runs its own sankey layout rather than d3-sankey, so it is ported in `layout.ts` and
	 * driven from here — that is what makes the node rectangles land on the reference's pixels.
	 */
	const laidOut = $derived(
		computeSankey({
			data,
			width: layerContext?.width ?? 0,
			height: layerContext?.height ?? 0,
			iterations,
			nodeWidth,
			nodePadding,
			linkCurvature,
			sort,
			align,
			verticalAlign,
			left: 0,
			top: 0
		})
	);

	const nodeLabel = $derived(slots.nodeLabel);

	/** Shows the tooltip for a hovered node or link, as the reference's payload does. */
	function showNode(shape: (typeof laidOut.nodes)[number]) {
		return (event: PointerEvent) => {
			layerContext?.tooltip.show(event, {
				name: shape.payload.name,
				value: shape.payload.value,
				payload: shape.payload
			});
		};
	}

	function showLink(shape: (typeof laidOut.links)[number]) {
		return (event: PointerEvent) => {
			layerContext?.tooltip.show(event, {
				name: `${shape.payload.source.name} - ${shape.payload.target.name}`,
				value: shape.payload.value,
				payload: shape.payload
			});
		};
	}

	const hide = () => layerContext?.tooltip.hide();
</script>

<ChartContainer
	{config}
	{initialDimension}
	{accessibility}
	bind:dimension={chartDimension}
	class={className}
>
	<LoadingIndicator {isLoading} />
	{#if isLoading}
		<LoadingSankey />
	{:else}
		<!--
			`padding` is Recharts' `<Sankey margin>` default, so `layerContext.width`/`.height` are the
			same plot box its layout measures against.
		-->
		<Chart
			width={chartDimension.width}
			height={chartDimension.height}
			bind:context={layerContext}
			data={data.nodes}
			padding={{
				top: CHART_MARGIN,
				right: CHART_MARGIN,
				bottom: CHART_MARGIN,
				left: CHART_MARGIN
			}}
			tooltipContext={{ mode: 'manual' }}
			class="h-full w-full"
			{...forwardedSankeyProps}
		>
			<Svg>
				{#if backgroundVariant}
					<ChartBackground variant={backgroundVariant} />
				{/if}
				<!-- Links first so the nodes sit on top, as in the reference's child order. -->
				{#each laidOut.links as shape (shape.index)}
					<SankeyLink {shape} linkConfig={slots.link} onhover={showLink(shape)} onleave={hide} />
				{/each}
				{#each laidOut.nodes as shape (shape.index)}
					<SankeyNode
						{shape}
						nodeConfig={slots.node}
						label={nodeLabel}
						onhover={showNode(shape)}
						onleave={hide}
					/>
				{/each}
				<defs>
					<NodeColorGradients {config} {chartId} />
				</defs>
			</Svg>
			<TooltipRender />
		</Chart>
	{/if}
	{@render children()}
</ChartContainer>
```

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

```svelte
<script lang="ts">
	/**
	 * Renders a single sankey link band, coloured by the composed <Link /> variant. Highlights the
	 * bands connected to the selected node and dims the rest.
	 */
	import LinkGradient from './defs/link-gradient.svelte';
	import LinkStrokeGradient from './defs/link-stroke-gradient.svelte';
	import type { SankeyLinkShape } from './layout.js';
	import { useSankeyChart } from './sankey-chart-context.svelte.js';
	import type { LinkSlot } from './sankey-slots.svelte.js';
	import { getLinkFill, linkAreaPath } from './types.js';

	let {
		shape,
		linkConfig,
		onhover,
		onleave
	}: {
		shape: SankeyLinkShape;
		linkConfig: LinkSlot | null;
		onhover: (event: PointerEvent) => void;
		onleave: () => void;
	} = $props();

	const chart = useSankeyChart();

	const variant = $derived(linkConfig?.variant ?? 'gradient');
	const verticalPadding = $derived(linkConfig?.verticalPadding ?? 0);

	const sourceName = $derived(shape.payload.source.name);
	const targetName = $derived(shape.payload.target.name);

	const isConnected = $derived(
		chart.selectedNode === null ||
			chart.selectedNode === sourceName ||
			chart.selectedNode === targetName
	);

	const halfWidth = $derived(Math.max(1, shape.linkWidth - verticalPadding) / 2);
	const path = $derived(
		linkAreaPath({
			sourceX: shape.sourceX,
			sourceY: shape.sourceY,
			targetX: shape.targetX,
			targetY: shape.targetY,
			sourceControlX: shape.sourceControlX,
			targetControlX: shape.targetControlX,
			halfWidth
		})
	);
</script>

<g>
	<defs>
		{#if variant === 'gradient'}
			<LinkGradient
				chartId={chart.chartId}
				index={shape.index}
				config={chart.config}
				{sourceName}
				{targetName}
			/>
		{/if}
		<LinkStrokeGradient chartId={chart.chartId} index={shape.index} />
	</defs>
	<path
		d={path}
		fill={getLinkFill(variant, chart.chartId, shape.index, chart.config, sourceName, targetName)}
		fill-opacity={isConnected ? 0.4 : 0.1}
		stroke={chart.selectedNode !== null && isConnected
			? `url(#${chart.chartId}-link-stroke-${shape.index})`
			: 'none'}
		stroke-width={1}
		stroke-opacity={1}
		class="transition-opacity duration-200"
		onpointerenter={onhover}
		onpointermove={onhover}
		onpointerleave={onleave}
		role="presentation"
	/>
</g>
```

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

```svelte
<script lang="ts">
	/**
	 * Renders a single sankey node rectangle, plus its optional label and value. The root draws one
	 * of these per node, configured from the composed <Node />.
	 */
	import type { Snippet } from 'svelte';
	import { isNodeConnected, type SankeyNodeShape } from './layout.js';
	import { useSankeyChart } from './sankey-chart-context.svelte.js';
	import type { NodeLabelSlot, NodeSlot } from './sankey-slots.svelte.js';

	let {
		shape,
		nodeConfig,
		label,
		onhover,
		onleave
	}: {
		shape: SankeyNodeShape;
		nodeConfig: NodeSlot | null;
		label: NodeLabelSlot | null;
		onhover: (event: PointerEvent) => void;
		onleave: () => void;
	} = $props();

	const chart = useSankeyChart();

	const radius = $derived(nodeConfig?.radius ?? 0);
	const isClickable = $derived(nodeConfig?.isClickable ?? false);

	const nodeName = $derived(shape.payload.name);
	const nodeValue = $derived(shape.payload.value);
	/** Optional per-node icon, as the reference reads off the data row. */
	const nodeIcon = $derived(shape.payload.icon as Snippet | undefined);

	const isHighlighted = $derived(isNodeConnected(chart.data, chart.selectedNode, nodeName));
	const hasConfigColor = $derived(nodeName in chart.config);
	const configLabel = $derived(chart.config[nodeName]?.label ?? nodeName);
	const dimmed = $derived(isClickable && !isHighlighted);

	const valueFormatter = $derived(
		label?.valueFormatter ?? ((value: number) => value.toLocaleString())
	);
	const showValues = $derived(label?.showValues ?? false);

	const labelX = $derived(shape.x + shape.width / 2);
	const labelY = $derived(showValues ? shape.y + shape.height / 2 - 8 : shape.y + shape.height / 2);
	const valueY = $derived(shape.y + shape.height / 2 + 8);
	const outsideLabelX = $derived(shape.x + shape.width + 8);
	const outsideLabelY = $derived(shape.y + shape.height / 2);

	function select() {
		if (!isClickable) return;
		// Clicking the selected node clears the selection, otherwise selects it
		chart.selectNode(chart.selectedNode === nodeName ? null : nodeName);
	}

	function selectFromKeyboard(event: KeyboardEvent) {
		if (!isClickable || (event.key !== 'Enter' && event.key !== ' ')) return;
		event.preventDefault();
		select();
	}
</script>

{#snippet nodeShape()}
	<rect
		x={shape.x}
		y={shape.y}
		width={shape.width}
		height={shape.height}
		rx={radius}
		ry={radius}
		fill={hasConfigColor ? `url(#${chart.chartId}-sankey-colors-${nodeName})` : 'currentColor'}
		fill-opacity={dimmed ? 0.15 : 0.9}
		class={['transition-opacity duration-200', isClickable && 'cursor-pointer']
			.filter(Boolean)
			.join(' ')}
		onclick={select}
		onpointerenter={onhover}
		onpointermove={onhover}
		onpointerleave={onleave}
		role="presentation"
	/>

	{#if label?.position === 'inside'}
		<!-- A translucent plate behind the label so it reads against the node's own colour. -->
		<rect
			x={shape.x + 1}
			y={shape.y + 1}
			width={shape.width - 2}
			height={shape.height - 2}
			rx={Math.max(0, radius - 1)}
			ry={Math.max(0, radius - 1)}
			opacity={dimmed ? 0.3 : 1}
			class="pointer-events-none fill-white/50 transition-opacity duration-200 dark:fill-black/60"
		/>
		{#if nodeIcon}
			<foreignObject
				x={labelX - 8}
				y={labelY - 30}
				width={16}
				height={16}
				opacity={dimmed ? 0.3 : 1}
				class="pointer-events-none transition-opacity duration-200"
			>
				<div class="flex items-center justify-center text-foreground/80 dark:text-white/80">
					{@render nodeIcon()}
				</div>
			</foreignObject>
		{/if}
		<text
			x={labelX}
			y={nodeIcon ? labelY - 4 : labelY}
			text-anchor="middle"
			dominant-baseline="middle"
			class="pointer-events-none fill-foreground text-[10px] font-medium transition-opacity duration-200 dark:fill-white"
			opacity={dimmed ? 0.3 : 1}
		>
			{#if typeof configLabel === 'string'}{configLabel}{:else}{@render configLabel()}{/if}
		</text>
		{#if showValues}
			<text
				x={labelX}
				y={valueY}
				text-anchor="middle"
				dominant-baseline="middle"
				class="pointer-events-none fill-foreground/60 font-mono text-xs font-medium tabular-nums transition-opacity duration-200 dark:fill-white"
				opacity={dimmed ? 0.3 : 0.6}
			>
				{valueFormatter(nodeValue)}
			</text>
		{/if}
	{/if}

	{#if label?.position === 'outside'}
		<text
			x={outsideLabelX}
			y={outsideLabelY - (showValues ? 8 : 0)}
			text-anchor="start"
			dominant-baseline="middle"
			class="pointer-events-none fill-foreground text-xs"
		>
			{#if typeof configLabel === 'string'}{configLabel}{:else}{@render configLabel()}{/if}
		</text>
		{#if showValues}
			<text
				x={outsideLabelX}
				y={outsideLabelY + 8}
				text-anchor="start"
				dominant-baseline="middle"
				opacity={0.5}
				class="pointer-events-none fill-foreground font-mono text-xs tabular-nums dark:fill-white"
			>
				{valueFormatter(nodeValue)}
			</text>
		{/if}
	{/if}
{/snippet}

{#if isClickable}
	<g
		role="button"
		tabindex="0"
		aria-label={`${nodeName}: ${valueFormatter(nodeValue)}`}
		aria-pressed={chart.selectedNode === nodeName}
		onkeydown={selectFromKeyboard}
	>
		{@render nodeShape()}
	</g>
{:else}
	<g>{@render nodeShape()}</g>
{/if}
```

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

```ts
import { getContext, setContext } from 'svelte';
import type { LinkVariant, NodeLabelPosition } from './types.js';

const SANKEY_SLOTS_KEY = Symbol('evilcharts.sankey-slots');
const NODE_SLOTS_KEY = Symbol('evilcharts.sankey-node-slots');

export type NodeSlot = {
	radius?: number;
	isClickable?: boolean;
};

export type NodeLabelSlot = {
	position?: NodeLabelPosition;
	showValues?: boolean;
	valueFormatter?: (value: number) => string;
};

export type LinkSlot = {
	variant?: LinkVariant;
	verticalPadding?: number;
};

/**
 * Registry for the chart's `<Node />` and `<Link />` children.
 *
 * The reference reads them with `React.Children.forEach` and hands the props to Recharts' `node` /
 * `link` render props; Svelte cannot inspect a snippet, so each slot registers itself here instead.
 * Tokens prevent a remount's stale teardown from clearing the live slot.
 */
export class SankeySlots {
	#nodeToken: string | null = null;
	#linkToken: string | null = null;

	node = $state<NodeSlot | null>(null);
	link = $state<LinkSlot | null>(null);
	/**
	 * The `<NodeLabel />` composed inside the `<Node />`, mirrored up so the root can read it.
	 *
	 * The reference reaches it with a nested `Children.forEach` over `nodeConfig.children`; Svelte
	 * cannot inspect a snippet, so `<Node />` forwards whatever registered into it.
	 */
	nodeLabel = $state<NodeLabelSlot | null>(null);

	registerNode(token: string, slot: NodeSlot) {
		this.#nodeToken = token;
		this.node = slot;
	}

	unregisterNode(token: string) {
		if (this.#nodeToken !== token) return;
		this.#nodeToken = null;
		this.node = null;
	}

	registerLink(token: string, slot: LinkSlot) {
		this.#linkToken = token;
		this.link = slot;
	}

	unregisterLink(token: string) {
		if (this.#linkToken !== token) return;
		this.#linkToken = null;
		this.link = null;
	}
}

/** Registry for the `<NodeLabel />` composed inside a `<Node />`. */
export class NodeSlots {
	#labelToken: string | null = null;

	label = $state<NodeLabelSlot | null>(null);

	registerLabel(token: string, slot: NodeLabelSlot) {
		this.#labelToken = token;
		this.label = slot;
	}

	unregisterLabel(token: string) {
		if (this.#labelToken !== token) return;
		this.#labelToken = null;
		this.label = null;
	}
}

export function setSankeySlotsContext() {
	const slots = new SankeySlots();
	setContext(SANKEY_SLOTS_KEY, slots);
	return slots;
}

export function useSankeySlots(): SankeySlots {
	const slots = getContext<SankeySlots | undefined>(SANKEY_SLOTS_KEY);

	if (!slots) {
		throw new Error('<Node /> and <Link /> must be composed inside an <EvilSankeyChart />');
	}

	return slots;
}

export function setNodeSlotsContext() {
	const slots = new NodeSlots();
	setContext(NODE_SLOTS_KEY, slots);
	return slots;
}

export function useNodeSlots(): NodeSlots {
	const slots = getContext<NodeSlots | undefined>(NODE_SLOTS_KEY);

	if (!slots) {
		throw new Error('<NodeLabel /> must be composed inside a <Node />');
	}

	return slots;
}
```

`$lib/components/evilcharts/charts/layerchart-sankey-chart/tooltip-render.svelte`

```svelte
<script lang="ts">
	/** Renders the registered `<Tooltip />` slot: the floating box, as a sibling of `<Svg>`. */
	import { getChartContext } from 'layerchart';
	import {
		ChartTooltip,
		ChartTooltipContent,
		type TooltipPayloadItem
	} from '../../ui/layerchart-tooltip/index.js';
	import { useSankeyChart } from './sankey-chart-context.svelte.js';

	const chart = useSankeyChart();
	/** LayerChart's own context, to tell a hovered node or link from the `defaultIndex` one. */
	const layer = getChartContext();

	const slot = $derived(chart.slots.tooltip);

	/**
	 * Row shown when nothing is hovered — the reference's `defaultIndex`, counted over the nodes.
	 *
	 * The hovered row takes precedence, because LayerChart resolves `dataProp ?? ctx.tooltip.data`
	 * and an unconditional `data` pins the tooltip forever.
	 */
	const defaultRow = $derived.by(() => {
		if (slot?.defaultIndex === undefined) return undefined;
		const node = chart.data.nodes[slot.defaultIndex];
		if (!node) return undefined;
		return { name: node.name, value: nodeValueOf(node.name), payload: node };
	});

	function nodeValueOf(nodeName: string) {
		const index = chart.data.nodes.findIndex((node) => node.name === nodeName);
		if (index === -1) return 0;

		const outgoing = chart.data.links
			.filter((link) => link.source === index)
			.reduce((sum, link) => sum + link.value, 0);
		const incoming = chart.data.links
			.filter((link) => link.target === index)
			.reduce((sum, link) => sum + link.value, 0);

		return outgoing > 0 ? outgoing : incoming;
	}

	/**
	 * One payload entry, matching the reference's `getPayloadOfTooltip`: a node is named by itself,
	 * a link by `"source - target"`.
	 */
	function toPayload(hovered: Record<string, unknown>): TooltipPayloadItem[] {
		return [
			{
				dataKey: 'value',
				name: String(hovered.name ?? ''),
				value: hovered.value as number | string | null,
				payload: hovered.payload ?? hovered
			}
		];
	}
</script>

{#if slot && !chart.isLoading}
	<ChartTooltip data={layer.tooltip.data ?? defaultRow}>
		{#snippet children({ data })}
			<!-- Read inline so changes to the hovered item re-derive the tooltip content. -->
			<ChartTooltipContent
				active
				hideLabel
				nameKey="name"
				payload={toPayload(data as Record<string, unknown>)}
				roundness={slot.roundness}
				variant={slot.variant}
			/>
		{/snippet}
	</ChartTooltip>
{/if}
```

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

```svelte
<script lang="ts">
	/**
	 * The hover tooltip. Hidden automatically while the chart is loading.
	 *
	 * Config-only: the tooltip box cannot render inside `<Svg>`, so this registers its props and
	 * the root renders it in the right place.
	 */
	import type { TooltipRoundness, TooltipVariant } from '../../ui/layerchart-tooltip/index.js';
	import { useSankeyChart } from './sankey-chart-context.svelte.js';

	let {
		variant,
		roundness,
		defaultIndex
	}: {
		variant?: TooltipVariant; // visual style of the tooltip surface
		roundness?: TooltipRoundness; // border-radius of the tooltip
		defaultIndex?: number; // data index shown by default with no hover
	} = $props();

	const chart = useSankeyChart();
	const token = $props.id();

	$effect.pre(() => {
		chart.slots.registerTooltip(token, { variant, roundness, defaultIndex, cursor: false });
		return () => chart.slots.unregisterTooltip(token);
	});
</script>
```

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

```ts
import type { ChartConfig } from '../../ui/layerchart-chart/chart-config.js';

// Constants
export const LOADING_ANIMATION_DURATION = 2000; // full loading cycle duration in milliseconds
export const DEFAULT_NODE_WIDTH = 10;
export const DEFAULT_NODE_PADDING = 10;
export const DEFAULT_LINK_CURVATURE = 0.5;
export const DEFAULT_ITERATIONS = 32;

/** Recharts' `<Sankey margin>` default, which the layout measures inside. */
export const CHART_MARGIN = 5;

export type LinkVariant = 'gradient' | 'solid' | 'source' | 'target';
export type NodeLabelPosition = 'inside' | 'outside';

/** Resolves the SVG paint reference for a link band based on its variant. */
export function getLinkFill(
	variant: LinkVariant,
	chartId: string,
	index: number,
	config: ChartConfig,
	sourceName: string,
	targetName: string
): string {
	switch (variant) {
		case 'gradient':
			return `url(#${chartId}-link-gradient-${index})`;
		case 'source':
			return sourceName in config ? `url(#${chartId}-sankey-colors-${sourceName})` : 'currentColor';
		case 'target':
			return targetName in config ? `url(#${chartId}-sankey-colors-${targetName})` : 'currentColor';
		case 'solid':
		default:
			return 'currentColor';
	}
}

/**
 * The band outline for one link, copied from the reference's `linkAreaPath`.
 *
 * Two cubics — the top edge out and the bottom edge back — closed into a ribbon.
 */
export function linkAreaPath({
	sourceX,
	sourceY,
	targetX,
	targetY,
	sourceControlX,
	targetControlX,
	halfWidth
}: {
	sourceX: number;
	sourceY: number;
	targetX: number;
	targetY: number;
	sourceControlX: number;
	targetControlX: number;
	halfWidth: number;
}) {
	return `M${sourceX},${sourceY - halfWidth}
    C${sourceControlX},${sourceY - halfWidth} ${targetControlX},${targetY - halfWidth} ${targetX},${targetY - halfWidth}
    L${targetX},${targetY + halfWidth}
    C${targetControlX},${targetY + halfWidth} ${sourceControlX},${sourceY + halfWidth} ${sourceX},${sourceY + halfWidth}
    Z`;
}
```
        
      
       
        ### Add the chart component to your project.
        

The chart needs these components to render. Make a `ui` folder inside `evilcharts` and paste the main chart component below.


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

`$lib/components/evilcharts/ui/layerchart-chart/accessibility.ts`

```ts
/**
 * Accessible name and description for a chart root.
 *
 * A chart may be named directly or by visible text elsewhere on the page. Descriptions can be
 * supplied directly, linked from existing content, or both. The container exposes this as a
 * `group`, rather than an image, so interactive legends and marks remain discoverable.
 */
type ChartAccessibleName =
	{ label: string; labelledBy?: never } | { label?: never; labelledBy: string };

export type ChartAccessibility = ChartAccessibleName & {
	description?: string;
	describedBy?: string;
};
```

`$lib/components/evilcharts/ui/layerchart-chart/animated-grow.svelte`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';

	type GrowAnimation = {
		initial: { scaleX: number } | { scaleY: number };
		animate: { scaleX: number } | { scaleY: number };
		transition: {
			duration: number;
			delay: number;
			ease: number[];
		};
		style: { originX: number } | { originY: number };
	};

	let { animation, children }: { animation: GrowAnimation; children: Snippet } = $props();

	function runGrow(value: GrowAnimation) {
		return (node: SVGGElement) => {
			node.style.transformBox = 'fill-box';
			const options: KeyframeAnimationOptions = {
				duration: value.transition.duration * 1000,
				delay: value.transition.delay * 1000,
				easing: `cubic-bezier(${value.transition.ease.join(',')})`,
				fill: 'both'
			};

			if ('scaleX' in value.initial) {
				const to = 'scaleX' in value.animate ? value.animate.scaleX : 1;
				node.style.transformOrigin = '0% 50%';
				const animation = node.animate(
					[{ transform: `scaleX(${value.initial.scaleX})` }, { transform: `scaleX(${to})` }],
					options
				);
				return () => animation.cancel();
			}

			const to = 'scaleY' in value.animate ? value.animate.scaleY : 1;
			node.style.transformOrigin = '50% 100%';
			const animation = node.animate(
				[{ transform: `scaleY(${value.initial.scaleY})` }, { transform: `scaleY(${to})` }],
				options
			);
			return () => animation.cancel();
		};
	}
</script>

<g {@attach runGrow(animation)}>
	{@render children()}
</g>
```

`$lib/components/evilcharts/ui/layerchart-chart/bar-geometry.ts`

```ts
/**
 * Bar sizing and placement within a category, ported from Recharts' `getBarPositions`.
 *
 * LayerChart divides a band with a nested `scaleBand`, which cannot reproduce Recharts' numbers:
 * Recharts subtracts the category gap and the inter-bar gaps from the band, divides what is left
 * between the bars, and **floors the result to a whole pixel** — so a 49px band holding two bars
 * with `barCategoryGap="10%"` and `barGap={4}` yields bars of exactly 17px, not 17.6px. It also
 * supports a fixed `barSize`, which centres a group of that width in the band.
 *
 * The port therefore computes each bar's offset and width here and applies them as `insets` on
 * LayerChart's `<Bar>`, leaving the band itself undivided.
 */

/** Leading/trailing insets along one axis, as LayerChart's `<Bar insets>` takes them. */
export type BarInsets = { left?: number; right?: number; top?: number; bottom?: number };

export type BarSlot = {
	/** Distance from the band's leading edge to this bar's leading edge, in pixels. */
	offset: number;
	/** Width of this bar along the category axis, in pixels. */
	size: number;
};

/**
 * Recharts' `getPercentValue` for a gap: a `"10%"` string resolves against the band, a number is
 * taken as pixels, and the result is clamped into `[0, bandSize]`.
 */
function resolveGap(
	value: number | string | undefined,
	bandSize: number,
	fallback: number | string
) {
	const raw = value ?? fallback;
	const resolved =
		typeof raw === 'string' && raw.trim().endsWith('%')
			? (Number.parseFloat(raw) / 100) * bandSize
			: Number(raw);

	if (!Number.isFinite(resolved)) return 0;
	return Math.max(0, Math.min(resolved, bandSize));
}

export function getBarPositions({
	bandSize,
	count,
	barGap,
	barCategoryGap,
	barSize,
	maxBarSize
}: {
	/** The category band's full size along the category axis. */
	bandSize: number;
	/** How many bars share the category. Stacked series count as one. */
	count: number;
	/** Gap between bars sharing a category. Recharts' default is `4`. */
	barGap?: number | string;
	/** Gap on each side of the category. Recharts' default is `"10%"`. */
	barCategoryGap?: number | string;
	/** Fixed bar width. When set, the group is centred in the band at that width. */
	barSize?: number;
	/** Upper bound on the derived width. */
	maxBarSize?: number;
}): BarSlot[] {
	if (count < 1 || !(bandSize > 0)) return [];

	let realBarGap = resolveGap(barGap, bandSize, 4);

	if (barSize != null && Number.isFinite(barSize)) {
		let useFull = false;
		let fullBarSize = bandSize / count;
		let sum = count * barSize + (count - 1) * realBarGap;

		// Too wide to fit: first drop the gaps, then fall back to 90% of an even share.
		if (sum >= bandSize) {
			sum -= (count - 1) * realBarGap;
			realBarGap = 0;
		}
		if (sum >= bandSize && fullBarSize > 0) {
			useFull = true;
			fullBarSize *= 0.9;
			sum = count * fullBarSize;
		}

		// Recharts truncates the centring offset to a whole pixel (`>> 0`).
		const offset = Math.trunc((bandSize - sum) / 2);
		const size = useFull ? fullBarSize : barSize;

		return Array.from({ length: count }, (_, index) => ({
			offset: offset + (size + realBarGap) * index,
			size
		}));
	}

	const categoryOffset = resolveGap(barCategoryGap, bandSize, '10%');
	// No room left for gaps once the category inset is taken out.
	if (bandSize - 2 * categoryOffset - (count - 1) * realBarGap <= 0) realBarGap = 0;

	let originalSize = (bandSize - 2 * categoryOffset - (count - 1) * realBarGap) / count;
	// Recharts floors anything above a pixel (`>>= 0`), which is why bars land on whole pixels.
	if (originalSize > 1) originalSize = Math.trunc(originalSize);

	const size =
		maxBarSize != null && Number.isFinite(maxBarSize)
			? Math.min(originalSize, maxBarSize)
			: originalSize;

	// The stride uses the unclamped size, so `maxBarSize` narrows a bar in place rather than
	// re-packing the group — again matching Recharts.
	return Array.from({ length: count }, (_, index) => ({
		offset: categoryOffset + (originalSize + realBarGap) * index + (originalSize - size) / 2,
		size
	}));
}
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-config.ts`

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

// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;

export type ThemeKey = keyof typeof THEMES;

// All Keys are optional at first
type ThemeColorsBase = {
	[K in ThemeKey]?: string[];
};

// Require at least one theme key
type AtLeastOneThemeColor = {
	[K in ThemeKey]: Required<Pick<ThemeColorsBase, K>> & Partial<Omit<ThemeColorsBase, K>>;
}[ThemeKey];

export const VALID_THEME_KEYS = Object.keys(THEMES) as ThemeKey[];

export { THEMES };

// Validation for chart config colors at runtime
export function validateChartConfigColors(config: ChartConfig): void {
	for (const [key, value] of Object.entries(config)) {
		if (value.colors) {
			const hasValidThemeKey = VALID_THEME_KEYS.some(
				(themeKey) => value.colors?.[themeKey] !== undefined
			);

			if (!hasValidThemeKey) {
				throw new Error(
					`[EvilCharts] Invalid chart config for "${key}": colors object must have at least one theme key (${VALID_THEME_KEYS.join(', ')}). Received empty object or invalid keys.`
				);
			}
		}
	}
}

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

/** Validates that every config key also exists on the data row type. */
export type ValidateConfigKeys<TData, TConfig> = {
	[K in keyof TConfig]: K extends keyof TData ? ChartConfig[string] : never;
};
```

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

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

	type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
		config: ChartConfig;
		children?: Snippet;
		/** Size used before the container has been measured. */
		initialDimension?: { width: number; height: number };
		/** @internal Resolved fallback-or-measured size used by chart roots. */
		dimension?: { width: number; height: number };
		/** Optional content rendered below the chart (e.g. EvilBrush) */
		footer?: Snippet;
		/** Accessible name and optional description for the chart as an interactive group. */
		accessibility?: ChartAccessibility;
	};

	let {
		id,
		config,
		initialDimension = { width: 320, height: 200 },
		dimension = $bindable(),
		class: className,
		children,
		footer,
		accessibility,
		...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;
	});

	// Validate chart config at runtime
	$effect.pre(() => {
		validateChartConfigColors(config);
	});

	setChartContext({
		get config() {
			return config;
		},
		get chartId() {
			return chartId;
		},
		get initialDimension() {
			return resolvedDimension;
		}
	});
</script>

<div
	data-slot="chart"
	data-chart={chartId}
	role={accessibility ? 'group' : undefined}
	aria-label={accessibility?.label}
	aria-labelledby={accessibility?.labelledBy}
	aria-describedby={describedBy}
	class={cn(
		'min-h-0 w-full flex-1',
		// Reference equivalents, retargeted from Recharts' `.recharts-*` hooks onto
		// LayerChart's `.lc-*` hooks.
		/*
			The grid and rule overrides are gated on `:not([stroke])`, mirroring the reference's
			`[&_.recharts-cartesian-grid_line[stroke='#ccc']]` / `[&_.recharts-polar-grid_[stroke='#ccc']]`
			selectors: they restyle only marks still carrying the library's *default* stroke and leave
			an explicitly-set one alone. Without the gate they also repainted the radar's polar grid,
			which sets `stroke="currentColor"` itself, washing the web out to `border/50`.
		*/
		"relative flex flex-col justify-center text-xs [&_.lc-arc-track]:fill-muted [&_.lc-axis-label]:[stroke:none] [&_.lc-axis-label]:text-xs [&_.lc-axis-label]:font-normal [&_.lc-axis-tick-label]:fill-[#666] [&_.lc-axis-tick-label]:[stroke:none] [&_.lc-axis-tick-label]:text-xs [&_.lc-axis-tick-label]:font-normal [&_.lc-axis[data-evil-scale='point']_.lc-axis-tick-group:last-of-type_.lc-axis-tick-label]:translate-x-[5px] [&_.lc-axis[data-evil-scale='point']_.lc-axis-tick-group:last-of-type_.lc-axis-tick-label]:[text-anchor:end] [&_.lc-grid-x-line:not([stroke])]:stroke-border/50 [&_.lc-grid-x-radial-line:not([stroke])]:stroke-border [&_.lc-grid-y-line:not([stroke])]:stroke-border/50 [&_.lc-grid-y-radial-circle:not([stroke])]:stroke-border [&_.lc-highlight-bar]:fill-muted [&_.lc-highlight-line]:stroke-border [&_.lc-highlight-point[stroke='#fff']]:stroke-transparent [&_.lc-layer]:outline-hidden [&_.lc-layout-svg]:outline-hidden [&_.lc-pie-arc]:outline-hidden [&_.lc-pie-arc[stroke='#fff']]:stroke-transparent [&_.lc-rule-x-line:not([stroke])]:stroke-border [&_.lc-rule-y-line:not([stroke])]:stroke-border",
		!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 footer?.()}
</div>
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-context.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import type { ChartConfig } from './chart-config.js';

const CHART_CONTEXT_KEY = Symbol('evilcharts.chart');

export type ChartContextValue = {
	readonly config: ChartConfig;
	readonly chartId: string;
	/**
	 * Size the chart falls back to before its container has been measured — the reference
	 * passes this to Recharts' `<ResponsiveContainer initialDimension>`.
	 */
	readonly initialDimension: { width: number; height: number };
};

export function setChartContext(value: ChartContextValue) {
	setContext(CHART_CONTEXT_KEY, value);
	return value;
}

/**
 * Reads the container context, throwing a helpful error when used outside <ChartContainer />.
 */
export function useChart(): ChartContextValue {
	const context = getContext<ChartContextValue | undefined>(CHART_CONTEXT_KEY);

	if (!context) {
		throw new Error('useChart must be used within a <ChartContainer />');
	}

	return context;
}
```

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

```ts
export type TooltipSlot = {
	variant?: 'default' | 'frosted-glass';
	roundness?: 'sm' | 'md' | 'lg' | 'xl';
	defaultIndex?: number;
	cursor?: boolean;
};

export type LegendSlot = {
	variant?:
		| 'square'
		| 'circle'
		| 'circle-outline'
		| 'rounded-square'
		| 'rounded-square-outline'
		| 'vertical-bar'
		| 'horizontal-bar';
	align?: 'left' | 'center' | 'right';
	verticalAlign?: 'top' | 'middle' | 'bottom';
	isClickable?: boolean;
};

/**
 * Reactive registrations for chart parts that render outside the plot SVG.
 *
 * A chart child records its tooltip or legend props here, and the root renders the matching HTML
 * layer in the correct place. Registrations use per-instance tokens because a chart subtree can
 * remount before the previous instance finishes tearing down. A stale cleanup must not clear the
 * newer live registration.
 */
export class ChartSlots {
	#tooltipToken: string | null = null;
	#legendToken: string | null = null;

	tooltip = $state<TooltipSlot | null>(null);
	legend = $state<LegendSlot | null>(null);

	registerTooltip(token: string, slot: TooltipSlot) {
		this.#tooltipToken = token;
		this.tooltip = slot;
	}

	unregisterTooltip(token: string) {
		if (this.#tooltipToken !== token) return;
		this.#tooltipToken = null;
		this.tooltip = null;
	}

	registerLegend(token: string, slot: LegendSlot) {
		this.#legendToken = token;
		this.legend = slot;
	}

	unregisterLegend(token: string) {
		if (this.#legendToken !== token) return;
		this.#legendToken = null;
		this.legend = null;
	}
}
```

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

```svelte
<script lang="ts">
	import { THEMES, type ChartConfig, type ThemeKey } from './chart-config.js';
	import { distributeColors, getColorsCount } from './colors.js';

	let { id, config }: { id: string; config: ChartConfig } = $props();

	const colorConfig = $derived(
		Object.entries(config).filter(([, itemConfig]) => itemConfig.colors)
	);

	function generateCssVars(theme: ThemeKey) {
		return colorConfig
			.flatMap(([key, itemConfig]) => {
				const colorsArray = itemConfig.colors?.[theme];
				if (!colorsArray || !Array.isArray(colorsArray) || colorsArray.length === 0) {
					return [];
				}

				// Get max count across all themes for this key
				const maxCount = getColorsCount(itemConfig);

				// Distribute colors evenly across all required slots
				const distributedColors = distributeColors(colorsArray, maxCount);

				return distributedColors.map((color, index) => `  --color-${key}-${index}: ${color};`);
			})
			.filter(Boolean)
			.join('\n');
	}

	const css = $derived(
		Object.entries(THEMES)
			.map(
				([theme, prefix]) =>
					`${prefix} [data-chart=${id}] {\n${generateCssVars(theme as ThemeKey)}\n}`
			)
			.join('\n')
	);
</script>

{#if colorConfig.length}
	<!-- A plain <style> element in a Svelte template is scoped-compiled, so the tag is built
	     dynamically to emit global CSS — the equivalent of the reference's
	     `<style dangerouslySetInnerHTML>`. Building it as an element (rather than {@html})
	     means the CSS text can never be parsed as markup. -->
	<svelte:element this={"style"}>{css}</svelte:element>
{/if}
```

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

```ts
import { VALID_THEME_KEYS, type ChartConfig } from './chart-config.js';

// Distribute colors evenly across slots, extra slots go to last color(s)
// Example: 2 colors for 4 slots → [red, red, pink, pink]
// Example: 3 colors for 4 slots → [red, pink, blue, blue]
export function distributeColors(colorsArray: string[], maxCount: number): string[] {
	const availableCount = colorsArray.length;
	if (availableCount >= maxCount) {
		return colorsArray.slice(0, maxCount);
	}

	const result: string[] = [];
	const baseSlots = Math.floor(maxCount / availableCount);
	const extraSlots = maxCount % availableCount;

	// First (availableCount - extraSlots) colors get baseSlots each
	// Last extraSlots colors get (baseSlots + 1) each
	for (let colorIdx = 0; colorIdx < availableCount; colorIdx++) {
		const isExtraColor = colorIdx >= availableCount - extraSlots;
		const slotsForThisColor = baseSlots + (isExtraColor ? 1 : 0);
		for (let j = 0; j < slotsForThisColor; j++) {
			result.push(colorsArray[colorIdx]);
		}
	}

	return result;
}

// Get max colors count across all themes for a config entry
export function getColorsCount(config: ChartConfig[string]): number {
	if (!config.colors) return 1;
	const counts = VALID_THEME_KEYS.map((theme) => config.colors?.[theme]?.length ?? 0);
	return Math.max(...counts, 1);
}
```

`$lib/components/evilcharts/ui/layerchart-chart/curves.ts`

```ts
import {
	curveBasis,
	curveBasisClosed,
	curveBasisOpen,
	curveBumpX,
	curveBumpY,
	curveLinear,
	curveLinearClosed,
	curveMonotoneX,
	curveMonotoneY,
	curveNatural,
	curveStep,
	curveStepAfter,
	curveStepBefore,
	type CurveFactory
} from 'd3-shape';

/**
 * The curve names Recharts accepts on `<Area type>` / `<Line type>`. Kept as the public
 * `curveType` union so every chart's API reads exactly as it does in the reference.
 */
export type CurveType =
	| 'basis'
	| 'basisClosed'
	| 'basisOpen'
	| 'bumpX'
	| 'bumpY'
	| 'bump'
	| 'linear'
	| 'linearClosed'
	| 'natural'
	| 'monotoneX'
	| 'monotoneY'
	| 'monotone'
	| 'step'
	| 'stepBefore'
	| 'stepAfter';

/**
 * Recharts resolves each `type` to the identically named d3-shape curve — `bump` and
 * `monotone` being the two aliases, which it maps to the X-oriented variants for the
 * default horizontal layout. LayerChart takes the d3 curve factory directly, so the
 * mapping is all that stands between the two APIs.
 */
const CURVES: Record<CurveType, CurveFactory> = {
	basis: curveBasis,
	basisClosed: curveBasisClosed as CurveFactory,
	basisOpen: curveBasisOpen as CurveFactory,
	bumpX: curveBumpX,
	bumpY: curveBumpY,
	bump: curveBumpX,
	linear: curveLinear,
	linearClosed: curveLinearClosed as CurveFactory,
	natural: curveNatural,
	monotoneX: curveMonotoneX,
	monotoneY: curveMonotoneY,
	monotone: curveMonotoneX,
	step: curveStep,
	stepBefore: curveStepBefore,
	stepAfter: curveStepAfter
};

/**
 * Resolve a Recharts `type` string to the d3-shape curve LayerChart marks expect.
 *
 * The three closed/open variants are line-only in d3's typings; they are cast to `CurveFactory`
 * because Recharts accepts them on `<Area type>` as well and behaves the same way — the closing
 * segment simply has no area counterpart.
 */
export function resolveCurve(type: CurveType | undefined): CurveFactory {
	return CURVES[type ?? 'linear'] ?? curveLinear;
}

export const CURVE_TYPES = Object.keys(CURVES) as CurveType[];
```

`$lib/components/evilcharts/ui/layerchart-chart/format.ts`

```ts
// Format values to percent for expanded charts
export function axisValueToPercentFormatter(value: number) {
	return `${Math.round(value * 100).toFixed(0)}%`;
}
```

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

```ts
export { default as ChartContainer } from './chart-container.svelte';
export { default as ChartStyle } from './chart-style.svelte';
export { default as LoadingIndicator } from './loading-indicator.svelte';
export type { ChartAccessibility } from './accessibility.js';

export {
	THEMES,
	VALID_THEME_KEYS,
	validateChartConfigColors,
	type ChartConfig,
	type ThemeKey,
	type ValidateConfigKeys
} from './chart-config.js';
export { setChartContext, useChart, type ChartContextValue } from './chart-context.svelte.js';
export { distributeColors, getColorsCount } from './colors.js';
export { getPayloadConfigFromPayload } from './payload.js';
export { axisValueToPercentFormatter } from './format.js';
export { getLoadingData, LOADING_CATEGORY_DATA_KEY } from './loading.js';
export { resolveCurve, CURVE_TYPES, type CurveType } from './curves.js';
export { getBarPositions, type BarSlot, type BarInsets } from './bar-geometry.js';
export { dropOverflowingLeadTick, rechartsValueAxisTicks, thinAxisTicks } from './ticks.js';
```

`$lib/components/evilcharts/ui/layerchart-chart/intros.ts`

```ts
export type IntroAction = 'reset' | 'animate' | 'finish' | 'none';

/** Decides how a polar mark responds to the chart loading lifecycle. */
export function polarIntroAction(
	wasLoading: boolean | undefined,
	isLoading: boolean,
	reduceMotion: boolean
): IntroAction {
	if (isLoading) return 'reset';
	if (reduceMotion) return 'finish';
	if (wasLoading === undefined || wasLoading) return 'animate';
	return 'none';
}

/**
 * Builds a one-shot wipe animation anchored to the chart root's mount timestamp.
 * Keyed LayerChart remounts therefore resume at elapsed progress and can never jump backwards.
 */
export function getRevealAnimation(
	durationSeconds: number,
	ease: [number, number, number, number],
	startedAt: number,
	now = Date.now()
) {
	const durationMs = durationSeconds * 1000;
	const elapsed = Math.max(0, now - startedAt);
	if (elapsed >= durationMs) return null;

	const progress = durationMs > 0 ? elapsed / durationMs : 1;
	return {
		initial: { scaleX: progress },
		animate: { scaleX: 1 },
		transition: {
			duration: Math.max(0, durationSeconds - elapsed / 1000),
			ease
		}
	};
}
```

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

```svelte
<script lang="ts">
	let { isLoading }: { isLoading: boolean } = $props();
</script>

{#if isLoading}
	<div class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
		<div
			class="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary"
		>
			<div
				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/layerchart-chart/loading.ts`

```ts
// Generate random loading data for skeleton/loading state
// min/max represent percentage of the range (0-100), defaults to 20-80 for realistic look
/** Internal ordinal key that lets LayerChart spread generated loading rows across a category scale. */
export const LOADING_CATEGORY_DATA_KEY = '__loadingCategory';

export const getLoadingData = (points: number = 10, min: number = 0, max: number = 70) => {
	const range = max - min;
	return Array.from({ length: points }, (_, index) => ({
		[LOADING_CATEGORY_DATA_KEY]: index,
		loading: Math.floor(Math.random() * range) + min
	}));
};
```

`$lib/components/evilcharts/ui/layerchart-chart/payload.ts`

```ts
import type { ChartConfig } from './chart-config.js';

// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
	if (typeof payload !== 'object' || payload === null) {
		return undefined;
	}

	const payloadPayload =
		'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
			? payload.payload
			: undefined;

	let configLabelKey: string = key;

	if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
		configLabelKey = payload[key as keyof typeof payload] as string;
	} else if (
		payloadPayload &&
		key in payloadPayload &&
		typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
	) {
		configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
	}

	return configLabelKey in config ? config[configLabelKey] : config[key];
}
```

`$lib/components/evilcharts/ui/layerchart-chart/ticks.ts`

```ts
import type { AnyScale } from 'layerchart';

/** Converts LayerChart's x-axis tick origin to Recharts' `tickMargin` baseline. */
export const RECHARTS_X_AXIS_TICK_OFFSET = 5.5;

/**
 * Axis tick values with a leading tick dropped when its label cannot fit inside the plot.
 *
 * Recharts' `<XAxis>` runs `interval="preserveEnd"`: it keeps the last tick unconditionally and
 * discards any earlier one that will not fit. For a **point** scale with no outer padding that
 * always means the first tick — its label is centred on the plot's very left edge, so half of it
 * would spill outside. A **band** scale centres the label in the band instead, half a step in, so
 * nothing is dropped. LayerChart draws every tick it is given, so the same filter is applied here.
 *
 * Pass it straight to `<Axis ticks={dropOverflowingLeadTick}>`.
 */
export function dropOverflowingLeadTick(scale: AnyScale): unknown[] {
	const values = scale.domain() as unknown[];
	if (values.length < 2) return values;

	const range = scale.range() as number[];
	const start = Math.min(...range);

	// A band scale reports its band's leading edge, but the label sits at the band's centre.
	const bandOffset =
		typeof (scale as { bandwidth?: () => number }).bandwidth === 'function'
			? ((scale as { bandwidth: () => number }).bandwidth() ?? 0) / 2
			: 0;

	const first = (scale as (value: unknown) => number | undefined)(values[0]);
	if (typeof first !== 'number') return values;

	// Less than a pixel of room means the label is centred on the boundary itself.
	return first + bandOffset - start < 1 ? values.slice(1) : values;
}

/**
 * Axis tick values thinned so their labels do not collide, the way Recharts' `interval="preserveEnd"`
 * does: it keeps the last tick and walks backwards, dropping any tick whose label would come within
 * `minTickGap` of the one already kept. LayerChart's `tickSpacing` cannot do this — it only derives a
 * tick *count*, and it is disabled outright for band scales.
 *
 * Recharts measures real text; there is no rendered text to measure before the axis draws, so the
 * width is estimated from the label's length. `charWidth` defaults to a 12px monospace-ish advance,
 * which is what these axes use.
 *
 * Pass it to `<Axis ticks={…}>`; it also applies `dropOverflowingLeadTick`'s boundary rule.
 */
export function thinAxisTicks({
	format,
	minGap = 5,
	charWidth = 6.6,
	leadingInset = 0
}: {
	/** Renders a domain value the way the axis will, so its width can be estimated. */
	format: (value: unknown, index: number) => string;
	/** Recharts' `minTickGap`, which defaults to 5. */
	minGap?: number;
	/** Estimated advance per character, in pixels. */
	charWidth?: number;
	/** Space between the SVG edge and the scale range (for example a rendered Y axis). */
	leadingInset?: number;
}) {
	return (scale: AnyScale): unknown[] => {
		const domain = scale.domain() as unknown[];
		if (domain.length < 2) return domain;

		const bandOffset =
			typeof (scale as { bandwidth?: () => number }).bandwidth === 'function'
				? ((scale as { bandwidth: () => number }).bandwidth() ?? 0) / 2
				: 0;

		const centreOf = (value: unknown) =>
			Number((scale as (v: unknown) => number)(value)) + bandOffset;
		const halfWidthOf = (value: unknown) =>
			(format(
				value,
				domain.findIndex((candidate) => Object.is(candidate, value))
			).length *
				charWidth) /
			2;
		const range = scale.range() as number[];
		const endBoundary = Math.max(...range);

		// Recharts moves the final label just far enough inward for its trailing edge to stay inside
		// the axis view box. That shifted label then owns the collision boundary, which is why a
		// narrow Jan–Dec axis keeps Dec but drops Nov even though the unshifted labels would fit.
		const kept: unknown[] = [];
		let nextHeadEdge = Number.POSITIVE_INFINITY;

		for (let index = domain.length - 1; index >= 0; index -= 1) {
			const value = domain[index];
			const half = halfWidthOf(value);
			const centre = centreOf(value);

			// The first point-scale label may use space before the plot when a Y axis has reserved it.
			// Clip against the SVG's physical leading edge (0), not the scale range's first position.
			if (index === 0 && leadingInset + centre - half < 0) continue;

			const adjustedCentre =
				index === domain.length - 1 ? Math.min(centre, endBoundary - half) : centre;
			const tail = adjustedCentre + half;

			if (tail + minGap <= nextHeadEdge) {
				kept.push(value);
				nextHeadEdge = adjustedCentre - half;
			}
		}

		return kept.reverse();
	};
}

/**
 * Recharts' numeric axes default to five ticks and include both ends of the resolved domain.
 * D3's `scale.ticks(5)` instead chooses a rounded step and can omit the upper endpoint (for
 * example `[0, 500, 1000, 1500]` for a `[0, 1800]` domain), so LayerChart needs explicit values.
 */
export function rechartsValueAxisTicks(scale: AnyScale, count = 5): unknown[] {
	const domain = scale.domain() as unknown[];
	const start = Number(domain[0]);
	const end = Number(domain.at(-1));
	if (!Number.isFinite(start) || !Number.isFinite(end) || count < 2) return domain;

	const step = (end - start) / (count - 1);
	return Array.from({ length: count }, (_, index) =>
		Number((start + step * index).toPrecision(12))
	);
}

/** Recharts' hidden 6px tick length still contributes to an auto-sized Y-axis. */
export const RECHARTS_VALUE_AXIS_TICK_LENGTH = 6;

/**
 * Resolves Recharts' `width="auto"` gutter from already measured tick labels.
 *
 * Recharts rounds the widest label to the nearest pixel, then adds the configured tick margin and
 * the default 6px tick length (even when `tickLine={false}`). Keeping this as a pure helper makes
 * the browser-only canvas measurement easy to test independently.
 */
export function rechartsAutoYAxisWidth(
	labelWidths: number[],
	tickMargin = 8,
	tickLength = RECHARTS_VALUE_AXIS_TICK_LENGTH
): number {
	return Math.round(Math.max(0, ...labelWidths) + tickMargin + tickLength);
}

/** Measures value-axis labels in the same 12px inherited font used by the chart container. */
export function measureRechartsYAxisWidth(labels: string[], tickMargin = 8): number {
	if (typeof document === 'undefined') return 42;

	const canvas = document.createElement('canvas');
	const context = canvas.getContext('2d');
	if (!context) return 42;

	const family = getComputedStyle(document.body).fontFamily;
	context.font = `400 12px ${family}`;
	return rechartsAutoYAxisWidth(
		labels.map((label) => context.measureText(label).width),
		tickMargin
	);
}

/**
 * Keeps the second argument that LayerChart supplies to format functions at runtime.
 *
 * Its public `FormatType` currently describes a single-argument callback even though Axis invokes
 * it as `format(tick, index)`. Recharts exposes that index, so this adapter keeps the runtime value
 * while remaining assignable to LayerChart's narrower callback type.
 */
export function layerChartFormatter(
	formatter: (value: unknown, index: number) => string
): (value: unknown, index?: number) => string {
	return (value, index = 0) => formatter(value, index);
}
```
        
      
    
  


## Usage

A compound component: `<EvilSankeyChart />` is the container; `<EvilSankeyChart.Node />`,
`<EvilSankeyChart.Link />`, and `<EvilSankeyChart.Tooltip />` compose as children. Render only the parts you need.

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

```svelte
<script lang="ts">
	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;
</script>

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

### Interactive Selection

Set `isClickable` on `<Node />` to select nodes on click. Handle events with the
root's `onSelectionChange` callback:

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

### Loading State

### isLoading='true'

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

Pass `isLoading` to `<EvilSankeyChart />` to show a placeholder animation of nodes and links while data loads.




## Examples

Customize the `<Link />` `variant`, the root `nodeWidth`, `nodePadding`, and more.

### Gradient Colors

### gradient colors

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

### Labeled Nodes

> 
  

Display labels and values on nodes by composing a `<NodeLabel />` inside `<Node />`.




#### Inside Labels

### showNodeLabels='inside'

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

Use a larger `nodeWidth` (e.g., 80) on the root to fit the text. Add `verticalPadding` on `<Link />` for space where links meet nodes.




#### Outside Labels

### showNodeLabels='outside'

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

### Link Variants

> 
  

The `variant` prop on `<Link />` sets the link coloring strategy.




#### Solid Links

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

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

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




#### Source-colored Links

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

```svelte
<script lang="ts">
	import {
		EvilSankeyChart,
		type SankeyData
	} from '$lib/components/evilcharts/charts/layerchart-sankey-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-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>

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

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




## API Reference

A root container plus a few composable parts. Render the root, then compose the
parts you need.

### EvilSankeyChart

The root container. Owns the flow data, layout config, shared context, and the
loading skeleton.


  ### `data` (required)

type: `SankeyData`

Nodes and links for the diagram — nodes are entities, links are flows between them. `SankeyData` is `{ nodes: SankeyNode[]; links: SankeyLink[] }`, where `SankeyNode = { name: string; icon?: Snippet }` and `SankeyLink = { source: number; target: number; value: number }`.
  ### `config` (required)

type: `ChartConfig`

Defines the chart's nodes. Each key matches a node name from your data and sets its colors.
  ### `children` (required)

type: `Snippet`

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

type: `ChartAccessibility`

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

type: `string`

Extra CSS classes for the chart container.
  ### `nodeWidth`

type: `number` · default: `10`

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

type: `number` · default: `10`

The vertical padding between nodes in pixels.
  ### `linkCurvature`

type: `number` · default: `0.5`

The curvature of links between nodes. Value between 0 (straight) and 1 (maximum curve).
  ### `iterations`

type: `number` · default: `32`

Iterations for the Sankey layout algorithm. Higher values improve the layout but take more time.
  ### `sort`

type: `boolean` · default: `true`

Whether to sort nodes automatically for optimal layout.
  ### `align`

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

Horizontal alignment for nodes. `"left"` aligns left; `"justify"` spreads them across the width.
  ### `verticalAlign`

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

Vertical alignment for nodes. `"top"` aligns to top; `"justify"` distributes vertically.
  ### `backgroundVariant`

type: `BackgroundVariant`

Background pattern variant to display behind the chart.
  ### `defaultSelectedNode`

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

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

type: `(selection: { dataKey: string; value: number } | null) => void`

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

type: `boolean` · default: `false`

Shows a loading placeholder animation when data is being fetched.
  ### `sankeyProps`

type: `Omit<SankeyProps, "data">`

Extra props for the underlying LayerChart Sankey component. See the [LayerChart Sankey documentation](https://www.layerchart.com/docs/components/Sankey) for available props.


### Node

Configures how nodes render. Compose a `<NodeLabel />` inside 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 or deselect them. Selected nodes highlight while the rest, and their links, dim.
  ### `children`

type: `Snippet`

Optional `<NodeLabel />` composition.


### NodeLabel

Declares labels for the `<Node />` it is composed inside.


  ### `position`

type: `"inside" | "outside"`

Label position. `"inside"` shows labels inside nodes; `"outside"` shows them beside nodes. Without `<NodeLabel />`, no labels render.
  ### `showValues`

type: `boolean` · default: `false`

Whether to display 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 links render.


  ### `variant`

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

Coloring strategy for links. `"gradient"` fades source to target; `"solid"` uses one color; `"source"` uses the source node color; `"target"` uses the target node color.
  ### `verticalPadding`

type: `number` · default: `0`

Vertical padding where links connect to nodes in pixels. Useful when using node labels.


### Tooltip

The hover tooltip. Hidden automatically while the chart is loading.


  ### `variant`

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

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

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

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

type: `number`

When set, shows the tooltip by default at this data point index.

