Перейти к содержимому

Графики

Диаграмма Санки

SankeyChart — потоки между узлами: варианты окраски связей, подписи внутри и снаружи узлов, значения и подсветка связанных узлов.


Диаграмма Санки

SankeyChart показывает потоки между узлами. Данные — { nodes, links }: узлы по имени, связи по индексам узлов со значением. Цвет и подпись узла берутся из config по его имени. Клик по узлу подсвечивает его прямые связи.

Интерактивное демо

Пропсы

Связи
Подписи
Выравнивание
Ширина узла
10
Отступ узлов
10
Кривизна
0.500
Значения
Загрузка
<script lang="ts" module>
	export type SankeyLinkVariant = 'gradient' | 'solid' | 'source' | 'target';

	export type SankeyNodeOptions = {
		/** Corner radius of node rectangles in px. */
		radius?: number;
		/** Clicking a node highlights it and its direct connections. */
		isClickable?: boolean;
	};

	export type SankeyNodeLabelOptions = {
		/** Label inside the node or beside it. */
		position?: 'inside' | 'outside';
		/** Appends each node's total flow. */
		showValues?: boolean;
		valueFormatter?: (value: number) => string;
	};

	export type SankeyLinkOptions = {
		variant?: SankeyLinkVariant;
		/** Shrinks the link band where it meets a node. */
		verticalPadding?: number;
	};
</script>

<script lang="ts">
	import type { BackgroundVariant, TooltipOptions } from './types';
	import ChartContainer from './ChartContainer.svelte';
	import ChartTooltip from './ChartTooltip.svelte';
	import ChartBackground from './ChartBackground.svelte';
	import SeriesColorGradient from './SeriesColorGradient.svelte';
	import { resolveOptions } from './cartesian';
	import { DEFAULT_MARGIN, estimateTextWidth, type ChartConfig } from './chart-utils';
	import {
		computeSankeyLayout,
		getNodeValue,
		isNodeConnected,
		type SankeyData
	} from './sankey';

	type Props = {
		/** Nodes + links (link `source`/`target` are node indices). */
		data: SankeyData;
		/** Node colors + labels, keyed by node name. */
		config: ChartConfig;
		class?: string;
		nodeWidth?: number;
		nodePadding?: number;
		/** Link curve amount, 0 (straight) to 1. */
		linkCurvature?: number;
		/** Layout relaxation passes — higher is more accurate. */
		iterations?: number;
		sort?: boolean;
		align?: 'left' | 'justify';
		verticalAlign?: 'justify' | 'top';
		node?: SankeyNodeOptions;
		/** Node labels, `false` hides them. */
		nodeLabel?: boolean | SankeyNodeLabelOptions;
		link?: SankeyLinkOptions;
		tooltip?: boolean | TooltipOptions;
		backgroundVariant?: BackgroundVariant;
		defaultSelectedNode?: string | null;
		onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;
		isLoading?: boolean;
		ariaLabel?: string;
	};

	let {
		data,
		config,
		class: className = '',
		nodeWidth = 10,
		nodePadding = 10,
		linkCurvature = 0.5,
		iterations = 32,
		sort = true,
		align = 'justify',
		verticalAlign = 'justify',
		node = {},
		nodeLabel = false,
		link = {},
		tooltip = true,
		backgroundVariant,
		defaultSelectedNode = null,
		onSelectionChange,
		isLoading = false,
		ariaLabel = 'Sankey chart'
	}: Props = $props();

	const chartId = $props.id();

	// svelte-ignore state_referenced_locally
	let selectedNode = $state<string | null>(defaultSelectedNode);
	let hovered = $state<
		{ kind: 'node' | 'link'; index: number; x: number; y: number } | null
	>(null);

	const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
	const labelOpts = $derived(resolveOptions<SankeyNodeLabelOptions>(nodeLabel, false));
	const radius = $derived(node.radius ?? 0);
	const isClickable = $derived(node.isClickable ?? false);
	const linkVariant = $derived(link.variant ?? 'gradient');
	const formatNodeValue = $derived(
		labelOpts?.valueFormatter ?? ((value: number) => value.toLocaleString())
	);

	function labelOf(name: string) {
		return config[name]?.label ?? name;
	}

	function selectNode(name: string | null) {
		selectedNode = name;
		if (!onSelectionChange) return;
		onSelectionChange(name === null ? null : { dataKey: name, value: getNodeValue(data, name) });
	}

	function pointerOf(event: PointerEvent) {
		const svg = (event.currentTarget as SVGElement).ownerSVGElement;
		const rect = (svg ?? (event.currentTarget as Element)).getBoundingClientRect();
		return { x: event.clientX - rect.left, y: event.clientY - rect.top };
	}

	function linkFill(index: number, source: string, target: string) {
		switch (linkVariant) {
			case 'gradient':
				return `url(#${chartId}-link-gradient-${String(index)})`;
			case 'source':
				return source in config ? `url(#${chartId}-sankey-colors-${source})` : 'currentColor';
			case 'target':
				return target in config ? `url(#${chartId}-sankey-colors-${target})` : 'currentColor';
			default:
				return 'currentColor';
		}
	}

	const LOADING_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 LOADING_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 }
	];

	function loadingLinkPath(fromIdx: number, toIdx: number) {
		const from = LOADING_NODES[fromIdx];
		const to = LOADING_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 c1 = startX + (endX - startX) * 0.4;
		const c2 = startX + (endX - startX) * 0.6;
		return `M${String(startX)},${String(startY)} C${String(c1)},${String(startY)} ${String(c2)},${String(endY)} ${String(endX)},${String(endY)}`;
	}
</script>

<ChartContainer id={chartId} {config} class={className} {isLoading}>
	{#snippet children({ width, height }: { width: number; height: number })}
		{#if isLoading}
			<svg
				viewBox="0 0 500 250"
				preserveAspectRatio="xMidYMid meet"
				width="100%"
				height="100%"
				class="absolute inset-0 text-foreground"
				aria-hidden="true"
			>
				{#each LOADING_LINKS as l, i (i)}
					<path
						class="chart-pulse-soft"
						style="animation-delay: {l.delay}s; --chart-pulse-duration: {2 * (0.8 + (i % 3) * 0.2)}s"
						d={loadingLinkPath(l.from, l.to)}
						fill="none"
						stroke="currentColor"
						stroke-width={l.width}
					/>
				{/each}
				{#each LOADING_NODES as n, i (i)}
					<rect
						class="chart-pulse"
						style="animation-delay: {n.delay}s; --chart-pulse-duration: {2 * (0.9 + (i % 4) * 0.1)}s"
						x={n.x}
						y={n.y}
						width={n.width}
						height={n.height}
						rx="2"
						fill="currentColor"
					/>
				{/each}
			</svg>
		{:else}
			{@const labelSpace =
				labelOpts?.position === 'outside'
					? Math.max(
							0,
							...data.nodes.map((n) => estimateTextWidth(labelOf(n.name)))
						) + 12
					: 0}
			{@const plotWidth = Math.max(
				nodeWidth + 1,
				width - DEFAULT_MARGIN.left - DEFAULT_MARGIN.right - labelSpace
			)}
			{@const plotHeight = Math.max(1, height - DEFAULT_MARGIN.top - DEFAULT_MARGIN.bottom)}
			{@const sankey = computeSankeyLayout(data, {
				width: plotWidth,
				height: plotHeight,
				nodeWidth,
				nodePadding,
				iterations,
				sort,
				align,
				verticalAlign
			})}
			<svg
				{width}
				{height}
				viewBox="0 0 {width} {height}"
				class="absolute inset-0 block"
				role="img"
				aria-label={ariaLabel}
				onpointerleave={() => (hovered = null)}
			>
				<defs>
					{#each Object.entries(config) as [key, item] (key)}
						<SeriesColorGradient
							id={chartId}
							dataKey={key}
							{item}
							direction="vertical"
							gradientId="{chartId}-sankey-colors-{key}"
						/>
					{/each}
					{#each sankey.links as l (l.index)}
						{@const source = sankey.nodes[l.source].name}
						{@const target = sankey.nodes[l.target].name}
						{#if linkVariant === 'gradient'}
							<linearGradient id="{chartId}-link-gradient-{l.index}" x1="0%" y1="0%" x2="100%" y2="0%">
								<stop
									offset="0%"
									stop-color={source in config ? `var(--color-${source}-0)` : 'currentColor'}
									stop-opacity="0.2"
								/>
								<stop
									offset="50%"
									stop-color={source in config ? `var(--color-${source}-0)` : 'currentColor'}
									stop-opacity="0.5"
								/>
								<stop
									offset="100%"
									stop-color={target in config ? `var(--color-${target}-0)` : 'currentColor'}
									stop-opacity="0.2"
								/>
							</linearGradient>
						{/if}
						<linearGradient id="{chartId}-link-stroke-{l.index}" x1="0%" y1="0%" x2="100%" y2="0%">
							<stop offset="0%" stop-color="var(--color-foreground)" stop-opacity="0" />
							<stop offset="15%" stop-color="var(--color-foreground)" stop-opacity="0.8" />
							<stop offset="50%" stop-color="var(--color-foreground)" stop-opacity="1" />
							<stop offset="85%" stop-color="var(--color-foreground)" stop-opacity="0.8" />
							<stop offset="100%" stop-color="var(--color-foreground)" stop-opacity="0" />
						</linearGradient>
					{/each}
				</defs>

				{#if backgroundVariant}
					<ChartBackground variant={backgroundVariant} />
				{/if}

				<g transform="translate({DEFAULT_MARGIN.left}, {DEFAULT_MARGIN.top})">
					<g class="text-foreground">
						{#each sankey.links as l (l.index)}
							{@const sourceNode = sankey.nodes[l.source]}
							{@const targetNode = sankey.nodes[l.target]}
							{@const sourceX = sourceNode.x + sourceNode.dx}
							{@const targetX = targetNode.x}
							{@const sourceY = sourceNode.y + l.sy + l.dy / 2}
							{@const targetY = targetNode.y + l.ty + l.dy / 2}
							{@const c1 = sourceX + (targetX - sourceX) * linkCurvature}
							{@const c2 = sourceX + (targetX - sourceX) * (1 - linkCurvature)}
							{@const half = Math.max(1, l.dy - (link.verticalPadding ?? 0)) / 2}
							{@const isConnected =
								selectedNode === null ||
								selectedNode === sourceNode.name ||
								selectedNode === targetNode.name}
							<path
								role="presentation"
								d="M{sourceX},{sourceY - half} C{c1},{sourceY - half} {c2},{targetY - half} {targetX},{targetY - half} L{targetX},{targetY + half} C{c2},{targetY + half} {c1},{sourceY + half} {sourceX},{sourceY + half} Z"
								fill={linkFill(l.index, sourceNode.name, targetNode.name)}
								fill-opacity={isConnected ? 0.4 : 0.1}
								stroke={selectedNode !== null && isConnected
									? `url(#${chartId}-link-stroke-${String(l.index)})`
									: 'none'}
								stroke-width="1"
								class="transition-opacity duration-200"
								onpointermove={(event) => (hovered = { kind: 'link', index: l.index, ...pointerOf(event) })}
							/>
						{/each}
					</g>

					{#each sankey.nodes as n (n.index)}
						{@const dimmed = isClickable && !isNodeConnected(data, selectedNode, n.name)}
						{@const showValues = labelOpts?.showValues ?? false}
						{@const labelX = n.x + n.dx / 2}
						{@const labelY = showValues ? n.y + n.dy / 2 - 8 : n.y + n.dy / 2}
						<g>
							<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
							<rect
								x={n.x}
								y={n.y}
								width={n.dx}
								height={Math.max(0, n.dy)}
								rx={radius}
								ry={radius}
								fill={n.name in config ? `url(#${chartId}-sankey-colors-${n.name})` : 'currentColor'}
								fill-opacity={dimmed ? 0.15 : 0.9}
								class="text-foreground transition-opacity duration-200"
								style:cursor={isClickable ? 'pointer' : undefined}
								onpointermove={(event) => (hovered = { kind: 'node', index: n.index, ...pointerOf(event) })}
								onclick={() => {
									if (isClickable) selectNode(selectedNode === n.name ? null : n.name);
								}}
							/>
							{#if labelOpts?.position === 'inside'}
								<rect
									x={n.x + 1}
									y={n.y + 1}
									width={Math.max(0, n.dx - 2)}
									height={Math.max(0, n.dy - 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"
								/>
								<text
									x={labelX}
									y={labelY}
									text-anchor="middle"
									dominant-baseline="middle"
									font-size="10"
									font-weight="500"
									class="pointer-events-none fill-foreground transition-opacity duration-200 dark:fill-white"
									opacity={dimmed ? 0.3 : 1}
								>
									{labelOf(n.name)}
								</text>
								{#if showValues}
									<text
										x={labelX}
										y={n.y + n.dy / 2 + 8}
										text-anchor="middle"
										dominant-baseline="middle"
										font-size="12"
										font-weight="500"
										class="pointer-events-none fill-foreground font-mono tabular-nums transition-opacity duration-200 dark:fill-white"
										opacity={dimmed ? 0.3 : 0.6}
									>
										{formatNodeValue(n.value)}
									</text>
								{/if}
							{:else if labelOpts?.position === 'outside'}
								<text
									x={n.x + n.dx + 8}
									y={n.y + n.dy / 2 - (showValues ? 8 : 0)}
									text-anchor="start"
									dominant-baseline="middle"
									font-size="12"
									class="pointer-events-none fill-foreground"
								>
									{labelOf(n.name)}
								</text>
								{#if showValues}
									<text
										x={n.x + n.dx + 8}
										y={n.y + n.dy / 2 + 8}
										text-anchor="start"
										dominant-baseline="middle"
										font-size="12"
										opacity="0.5"
										class="pointer-events-none fill-foreground font-mono tabular-nums"
									>
										{formatNodeValue(n.value)}
									</text>
								{/if}
							{/if}
						</g>
					{/each}
				</g>
			</svg>

			{#if tooltipOpts && hovered}
				{@const shown = hovered}
				{#if shown.kind === 'node'}
					{@const n = sankey.nodes[shown.index]}
					<ChartTooltip
						x={shown.x}
						y={shown.y}
						boundsWidth={width}
						boundsHeight={height}
						hideLabel
						options={tooltipOpts}
						items={[{ key: n.name, name: labelOf(n.name), value: n.value, item: config[n.name] }]}
					/>
				{:else}
					{@const l = sankey.links[shown.index]}
					{@const source = sankey.nodes[l.source].name}
					{@const target = sankey.nodes[l.target].name}
					<ChartTooltip
						x={shown.x}
						y={shown.y}
						boundsWidth={width}
						boundsHeight={height}
						hideLabel
						options={tooltipOpts}
						items={[
							{
								key: source,
								name: `${labelOf(source)} → ${labelOf(target)}`,
								value: l.value,
								item: config[source]
							}
						]}
					/>
				{/if}
			{/if}
		{/if}
	{/snippet}
</ChartContainer>
<script lang="ts" module>
	export type SankeyLinkVariant = 'gradient' | 'solid' | 'source' | 'target';

	export type SankeyNodeOptions = {
		/** Corner radius of node rectangles in px. */
		radius?: number;
		/** Clicking a node highlights it and its direct connections. */
		isClickable?: boolean;
	};

	export type SankeyNodeLabelOptions = {
		/** Label inside the node or beside it. */
		position?: 'inside' | 'outside';
		/** Appends each node's total flow. */
		showValues?: boolean;
		valueFormatter?: (value: number) => string;
	};

	export type SankeyLinkOptions = {
		variant?: SankeyLinkVariant;
		/** Shrinks the link band where it meets a node. */
		verticalPadding?: number;
	};
</script>

<script lang="ts">
	import type { BackgroundVariant, TooltipOptions } from './types';
	import ChartContainer from './ChartContainer.svelte';
	import ChartTooltip from './ChartTooltip.svelte';
	import ChartBackground from './ChartBackground.svelte';
	import SeriesColorGradient from './SeriesColorGradient.svelte';
	import { resolveOptions } from './cartesian';
	import { DEFAULT_MARGIN, estimateTextWidth, type ChartConfig } from './chart-utils';
	import {
		computeSankeyLayout,
		getNodeValue,
		isNodeConnected,
		type SankeyData
	} from './sankey';

	type Props = {
		/** Nodes + links (link `source`/`target` are node indices). */
		data: SankeyData;
		/** Node colors + labels, keyed by node name. */
		config: ChartConfig;
		class?: string;
		nodeWidth?: number;
		nodePadding?: number;
		/** Link curve amount, 0 (straight) to 1. */
		linkCurvature?: number;
		/** Layout relaxation passes — higher is more accurate. */
		iterations?: number;
		sort?: boolean;
		align?: 'left' | 'justify';
		verticalAlign?: 'justify' | 'top';
		node?: SankeyNodeOptions;
		/** Node labels, `false` hides them. */
		nodeLabel?: boolean | SankeyNodeLabelOptions;
		link?: SankeyLinkOptions;
		tooltip?: boolean | TooltipOptions;
		backgroundVariant?: BackgroundVariant;
		defaultSelectedNode?: string | null;
		onSelectionChange?: (selection: { dataKey: string; value: number } | null) => void;
		isLoading?: boolean;
		ariaLabel?: string;
	};

	let {
		data,
		config,
		class: className = '',
		nodeWidth = 10,
		nodePadding = 10,
		linkCurvature = 0.5,
		iterations = 32,
		sort = true,
		align = 'justify',
		verticalAlign = 'justify',
		node = {},
		nodeLabel = false,
		link = {},
		tooltip = true,
		backgroundVariant,
		defaultSelectedNode = null,
		onSelectionChange,
		isLoading = false,
		ariaLabel = 'Sankey chart'
	}: Props = $props();

	const chartId = $props.id();

	// svelte-ignore state_referenced_locally
	let selectedNode = $state<string | null>(defaultSelectedNode);
	let hovered = $state<
		{ kind: 'node' | 'link'; index: number; x: number; y: number } | null
	>(null);

	const tooltipOpts = $derived(resolveOptions<TooltipOptions>(tooltip, true));
	const labelOpts = $derived(resolveOptions<SankeyNodeLabelOptions>(nodeLabel, false));
	const radius = $derived(node.radius ?? 0);
	const isClickable = $derived(node.isClickable ?? false);
	const linkVariant = $derived(link.variant ?? 'gradient');
	const formatNodeValue = $derived(
		labelOpts?.valueFormatter ?? ((value: number) => value.toLocaleString())
	);

	function labelOf(name: string) {
		return config[name]?.label ?? name;
	}

	function selectNode(name: string | null) {
		selectedNode = name;
		if (!onSelectionChange) return;
		onSelectionChange(name === null ? null : { dataKey: name, value: getNodeValue(data, name) });
	}

	function pointerOf(event: PointerEvent) {
		const svg = (event.currentTarget as SVGElement).ownerSVGElement;
		const rect = (svg ?? (event.currentTarget as Element)).getBoundingClientRect();
		return { x: event.clientX - rect.left, y: event.clientY - rect.top };
	}

	function linkFill(index: number, source: string, target: string) {
		switch (linkVariant) {
			case 'gradient':
				return `url(#${chartId}-link-gradient-${String(index)})`;
			case 'source':
				return source in config ? `url(#${chartId}-sankey-colors-${source})` : 'currentColor';
			case 'target':
				return target in config ? `url(#${chartId}-sankey-colors-${target})` : 'currentColor';
			default:
				return 'currentColor';
		}
	}

	const LOADING_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 LOADING_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 }
	];

	function loadingLinkPath(fromIdx: number, toIdx: number) {
		const from = LOADING_NODES[fromIdx];
		const to = LOADING_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 c1 = startX + (endX - startX) * 0.4;
		const c2 = startX + (endX - startX) * 0.6;
		return `M${String(startX)},${String(startY)} C${String(c1)},${String(startY)} ${String(c2)},${String(endY)} ${String(endX)},${String(endY)}`;
	}
</script>

<ChartContainer id={chartId} {config} class={className} {isLoading}>
	{#snippet children({ width, height }: { width: number; height: number })}
		{#if isLoading}
			<svg
				viewBox="0 0 500 250"
				preserveAspectRatio="xMidYMid meet"
				width="100%"
				height="100%"
				class="absolute inset-0 text-foreground"
				aria-hidden="true"
			>
				{#each LOADING_LINKS as l, i (i)}
					<path
						class="chart-pulse-soft"
						style="animation-delay: {l.delay}s; --chart-pulse-duration: {2 * (0.8 + (i % 3) * 0.2)}s"
						d={loadingLinkPath(l.from, l.to)}
						fill="none"
						stroke="currentColor"
						stroke-width={l.width}
					/>
				{/each}
				{#each LOADING_NODES as n, i (i)}
					<rect
						class="chart-pulse"
						style="animation-delay: {n.delay}s; --chart-pulse-duration: {2 * (0.9 + (i % 4) * 0.1)}s"
						x={n.x}
						y={n.y}
						width={n.width}
						height={n.height}
						rx="2"
						fill="currentColor"
					/>
				{/each}
			</svg>
		{:else}
			{@const labelSpace =
				labelOpts?.position === 'outside'
					? Math.max(
							0,
							...data.nodes.map((n) => estimateTextWidth(labelOf(n.name)))
						) + 12
					: 0}
			{@const plotWidth = Math.max(
				nodeWidth + 1,
				width - DEFAULT_MARGIN.left - DEFAULT_MARGIN.right - labelSpace
			)}
			{@const plotHeight = Math.max(1, height - DEFAULT_MARGIN.top - DEFAULT_MARGIN.bottom)}
			{@const sankey = computeSankeyLayout(data, {
				width: plotWidth,
				height: plotHeight,
				nodeWidth,
				nodePadding,
				iterations,
				sort,
				align,
				verticalAlign
			})}
			<svg
				{width}
				{height}
				viewBox="0 0 {width} {height}"
				class="absolute inset-0 block"
				role="img"
				aria-label={ariaLabel}
				onpointerleave={() => (hovered = null)}
			>
				<defs>
					{#each Object.entries(config) as [key, item] (key)}
						<SeriesColorGradient
							id={chartId}
							dataKey={key}
							{item}
							direction="vertical"
							gradientId="{chartId}-sankey-colors-{key}"
						/>
					{/each}
					{#each sankey.links as l (l.index)}
						{@const source = sankey.nodes[l.source].name}
						{@const target = sankey.nodes[l.target].name}
						{#if linkVariant === 'gradient'}
							<linearGradient id="{chartId}-link-gradient-{l.index}" x1="0%" y1="0%" x2="100%" y2="0%">
								<stop
									offset="0%"
									stop-color={source in config ? `var(--color-${source}-0)` : 'currentColor'}
									stop-opacity="0.2"
								/>
								<stop
									offset="50%"
									stop-color={source in config ? `var(--color-${source}-0)` : 'currentColor'}
									stop-opacity="0.5"
								/>
								<stop
									offset="100%"
									stop-color={target in config ? `var(--color-${target}-0)` : 'currentColor'}
									stop-opacity="0.2"
								/>
							</linearGradient>
						{/if}
						<linearGradient id="{chartId}-link-stroke-{l.index}" x1="0%" y1="0%" x2="100%" y2="0%">
							<stop offset="0%" stop-color="var(--color-foreground)" stop-opacity="0" />
							<stop offset="15%" stop-color="var(--color-foreground)" stop-opacity="0.8" />
							<stop offset="50%" stop-color="var(--color-foreground)" stop-opacity="1" />
							<stop offset="85%" stop-color="var(--color-foreground)" stop-opacity="0.8" />
							<stop offset="100%" stop-color="var(--color-foreground)" stop-opacity="0" />
						</linearGradient>
					{/each}
				</defs>

				{#if backgroundVariant}
					<ChartBackground variant={backgroundVariant} />
				{/if}

				<g transform="translate({DEFAULT_MARGIN.left}, {DEFAULT_MARGIN.top})">
					<g class="text-foreground">
						{#each sankey.links as l (l.index)}
							{@const sourceNode = sankey.nodes[l.source]}
							{@const targetNode = sankey.nodes[l.target]}
							{@const sourceX = sourceNode.x + sourceNode.dx}
							{@const targetX = targetNode.x}
							{@const sourceY = sourceNode.y + l.sy + l.dy / 2}
							{@const targetY = targetNode.y + l.ty + l.dy / 2}
							{@const c1 = sourceX + (targetX - sourceX) * linkCurvature}
							{@const c2 = sourceX + (targetX - sourceX) * (1 - linkCurvature)}
							{@const half = Math.max(1, l.dy - (link.verticalPadding ?? 0)) / 2}
							{@const isConnected =
								selectedNode === null ||
								selectedNode === sourceNode.name ||
								selectedNode === targetNode.name}
							<path
								role="presentation"
								d="M{sourceX},{sourceY - half} C{c1},{sourceY - half} {c2},{targetY - half} {targetX},{targetY - half} L{targetX},{targetY + half} C{c2},{targetY + half} {c1},{sourceY + half} {sourceX},{sourceY + half} Z"
								fill={linkFill(l.index, sourceNode.name, targetNode.name)}
								fill-opacity={isConnected ? 0.4 : 0.1}
								stroke={selectedNode !== null && isConnected
									? `url(#${chartId}-link-stroke-${String(l.index)})`
									: 'none'}
								stroke-width="1"
								class="transition-opacity duration-200"
								onpointermove={(event) => (hovered = { kind: 'link', index: l.index, ...pointerOf(event) })}
							/>
						{/each}
					</g>

					{#each sankey.nodes as n (n.index)}
						{@const dimmed = isClickable && !isNodeConnected(data, selectedNode, n.name)}
						{@const showValues = labelOpts?.showValues ?? false}
						{@const labelX = n.x + n.dx / 2}
						{@const labelY = showValues ? n.y + n.dy / 2 - 8 : n.y + n.dy / 2}
						<g>
							<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
							<rect
								x={n.x}
								y={n.y}
								width={n.dx}
								height={Math.max(0, n.dy)}
								rx={radius}
								ry={radius}
								fill={n.name in config ? `url(#${chartId}-sankey-colors-${n.name})` : 'currentColor'}
								fill-opacity={dimmed ? 0.15 : 0.9}
								class="text-foreground transition-opacity duration-200"
								style:cursor={isClickable ? 'pointer' : undefined}
								onpointermove={(event) => (hovered = { kind: 'node', index: n.index, ...pointerOf(event) })}
								onclick={() => {
									if (isClickable) selectNode(selectedNode === n.name ? null : n.name);
								}}
							/>
							{#if labelOpts?.position === 'inside'}
								<rect
									x={n.x + 1}
									y={n.y + 1}
									width={Math.max(0, n.dx - 2)}
									height={Math.max(0, n.dy - 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"
								/>
								<text
									x={labelX}
									y={labelY}
									text-anchor="middle"
									dominant-baseline="middle"
									font-size="10"
									font-weight="500"
									class="pointer-events-none fill-foreground transition-opacity duration-200 dark:fill-white"
									opacity={dimmed ? 0.3 : 1}
								>
									{labelOf(n.name)}
								</text>
								{#if showValues}
									<text
										x={labelX}
										y={n.y + n.dy / 2 + 8}
										text-anchor="middle"
										dominant-baseline="middle"
										font-size="12"
										font-weight="500"
										class="pointer-events-none fill-foreground font-mono tabular-nums transition-opacity duration-200 dark:fill-white"
										opacity={dimmed ? 0.3 : 0.6}
									>
										{formatNodeValue(n.value)}
									</text>
								{/if}
							{:else if labelOpts?.position === 'outside'}
								<text
									x={n.x + n.dx + 8}
									y={n.y + n.dy / 2 - (showValues ? 8 : 0)}
									text-anchor="start"
									dominant-baseline="middle"
									font-size="12"
									class="pointer-events-none fill-foreground"
								>
									{labelOf(n.name)}
								</text>
								{#if showValues}
									<text
										x={n.x + n.dx + 8}
										y={n.y + n.dy / 2 + 8}
										text-anchor="start"
										dominant-baseline="middle"
										font-size="12"
										opacity="0.5"
										class="pointer-events-none fill-foreground font-mono tabular-nums"
									>
										{formatNodeValue(n.value)}
									</text>
								{/if}
							{/if}
						</g>
					{/each}
				</g>
			</svg>

			{#if tooltipOpts && hovered}
				{@const shown = hovered}
				{#if shown.kind === 'node'}
					{@const n = sankey.nodes[shown.index]}
					<ChartTooltip
						x={shown.x}
						y={shown.y}
						boundsWidth={width}
						boundsHeight={height}
						hideLabel
						options={tooltipOpts}
						items={[{ key: n.name, name: labelOf(n.name), value: n.value, item: config[n.name] }]}
					/>
				{:else}
					{@const l = sankey.links[shown.index]}
					{@const source = sankey.nodes[l.source].name}
					{@const target = sankey.nodes[l.target].name}
					<ChartTooltip
						x={shown.x}
						y={shown.y}
						boundsWidth={width}
						boundsHeight={height}
						hideLabel
						options={tooltipOpts}
						items={[
							{
								key: source,
								name: `${labelOf(source)} → ${labelOf(target)}`,
								value: l.value,
								item: config[source]
							}
						]}
					/>
				{/if}
			{/if}
		{/if}
	{/snippet}
</ChartContainer>

Использование

<script lang="ts">
	import SankeyChart from '$lib/components/charts/SankeyChart.svelte';
	import type { SankeyData } from '$lib/components/charts/sankey';

	const data: SankeyData = {
		nodes: [{ name: 'organic' }, { name: 'landing' }, { name: 'purchase' }, { name: 'bounced' }],
		links: [
			{ source: 0, target: 1, value: 42000 },
			{ source: 1, target: 2, value: 24000 },
			{ source: 1, target: 3, value: 18000 }
		]
	};
</script>

<SankeyChart
	{data}
	{config}
	node={{ isClickable: true }}
	nodeLabel={{ position: 'outside', showValues: true }}
	link={{ variant: 'source' }}
/>
<script lang="ts">
	import SankeyChart from '$lib/components/charts/SankeyChart.svelte';
	import type { SankeyData } from '$lib/components/charts/sankey';

	const data: SankeyData = {
		nodes: [{ name: 'organic' }, { name: 'landing' }, { name: 'purchase' }, { name: 'bounced' }],
		links: [
			{ source: 0, target: 1, value: 42000 },
			{ source: 1, target: 2, value: 24000 },
			{ source: 1, target: 3, value: 18000 }
		]
	};
</script>

<SankeyChart
	{data}
	{config}
	node={{ isClickable: true }}
	nodeLabel={{ position: 'outside', showValues: true }}
	link={{ variant: 'source' }}
/>

Примеры

Подписи внутри широких узлов

<SankeyChart {data} {config} nodeWidth={90} nodeLabel={{ position: 'inside', showValues: true }}
	node={{ radius: 4, isClickable: true }} link={{ variant: 'solid' }} />
<SankeyChart {data} {config} nodeWidth={90} nodeLabel={{ position: 'inside', showValues: true }}
	node={{ radius: 4, isClickable: true }} link={{ variant: 'solid' }} />

Окраска связей по цели

<SankeyChart {data} {config} link={{ variant: 'target', verticalPadding: 2 }} nodeLabel={{ position: 'outside' }} />
<SankeyChart {data} {config} link={{ variant: 'target', verticalPadding: 2 }} nodeLabel={{ position: 'outside' }} />

В markdown

Таблица из трёх колонок: источник, цель, значение. Узлы создаются автоматически.

:::chart
[type]sankey
[title]Воронка продаж
[values]true

| Откуда  | Куда    | Пользователи |
|---------|---------|--------------|
| Поиск   | Лендинг | 42000        |
| Реклама | Лендинг | 28000        |
| Лендинг | Товар   | 52000        |
| Лендинг | Отказ   | 18000        |
| Товар   | Покупка | 24000        |
| Товар   | Отказ   | 28000        |
:::
:::chart
[type]sankey
[title]Воронка продаж
[values]true

| Откуда  | Куда    | Пользователи |
|---------|---------|--------------|
| Поиск   | Лендинг | 42000        |
| Реклама | Лендинг | 28000        |
| Лендинг | Товар   | 52000        |
| Лендинг | Отказ   | 18000        |
| Товар   | Покупка | 24000        |
| Товар   | Отказ   | 28000        |
:::
Воронка продаж

Пропсы

Пропс Тип По умолчанию Описание
data SankeyData — Узлы и связи
config ChartConfig — Цвета и подписи по имени узла
nodeWidth number 10 Ширина узла
nodePadding number 10 Вертикальный отступ между узлами
linkCurvature number 0.5 Кривизна связей, 0–1
iterations number 32 Проходы раскладки
sort boolean true Сортировать узлы в колонках
align 'left' 'justify' 'justify' Конечные узлы — в последнюю колонку
verticalAlign 'justify' 'top' 'justify' Вертикальная раскладка
node SankeyNodeOptions — radius, isClickable
nodeLabel boolean SankeyNodeLabelOptions false position (inside/outside), showValues, valueFormatter
link SankeyLinkOptions — variant (gradient/solid/source/target), verticalPadding
tooltip boolean TooltipOptions true Тултип узлов и связей
backgroundVariant BackgroundVariant — Декоративный фон
onSelectionChange (selection) => void — Смена выбранного узла
isLoading boolean false Скелетон загрузки
class string — Классы контейнера