Разработка Sankey-диаграмм для визуализации потоков на сайте
Sankey-диаграмма показывает потоки между узлами: ширина полосы пропорциональна объёму потока. Это один из лучших инструментов для задач типа «откуда пришли деньги и куда ушли», «как пользователи переходят между этапами воронки», «какие страницы источники трафика для каких конверсий».
Без специального инструмента построить Sankey вручную трудно — нужен layout-алгоритм, который правильно расставляет узлы и рисует кривые Безье. Библиотека d3-sankey берёт на себя эту часть.
Установка
npm install d3-sankey d3
npm install --save-dev @types/d3-sankey
Структура данных
interface SankeyNode {
id: string;
label: string;
color?: string;
}
interface SankeyLink {
source: string; // id узла-источника
target: string; // id узла-цели
value: number; // объём потока
}
interface SankeyData {
nodes: SankeyNode[];
links: SankeyLink[];
}
// Пример: воронка e-commerce
const data: SankeyData = {
nodes: [
{ id: 'organic', label: 'Органика' },
{ id: 'paid', label: 'Платная реклама' },
{ id: 'direct', label: 'Прямые' },
{ id: 'catalog', label: 'Каталог' },
{ id: 'product', label: 'Карточка товара' },
{ id: 'cart', label: 'Корзина' },
{ id: 'checkout', label: 'Оформление' },
{ id: 'purchase', label: 'Покупка' },
{ id: 'exit', label: 'Выход' },
],
links: [
{ source: 'organic', target: 'catalog', value: 4200 },
{ source: 'organic', target: 'product', value: 1800 },
{ source: 'paid', target: 'catalog', value: 2100 },
{ source: 'paid', target: 'product', value: 3400 },
{ source: 'direct', target: 'catalog', value: 900 },
{ source: 'catalog', target: 'product', value: 5600 },
{ source: 'catalog', target: 'exit', value: 3100 },
{ source: 'product', target: 'cart', value: 2900 },
{ source: 'product', target: 'exit', value: 4800 },
{ source: 'cart', target: 'checkout', value: 1600 },
{ source: 'cart', target: 'exit', value: 1300 },
{ source: 'checkout', target: 'purchase', value: 1100 },
{ source: 'checkout', target: 'exit', value: 500 },
],
};
Компонент
import { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { sankey, sankeyLinkHorizontal, sankeyLeft } from 'd3-sankey';
export function SankeyDiagram({ data, width = 800, height = 500 }: { data: SankeyData; width?: number; height?: number }) {
const svgRef = useRef<SVGSVGElement>(null);
const margin = { top: 20, right: 20, bottom: 20, left: 20 };
useEffect(() => {
if (!svgRef.current) return;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
// Подготовка данных для d3-sankey
const nodeMap = new Map(data.nodes.map((n, i) => [n.id, { ...n, index: i }]));
const sankeyData = {
nodes: data.nodes.map(n => ({ ...n })),
links: data.links.map(l => ({
source: data.nodes.findIndex(n => n.id === l.source),
target: data.nodes.findIndex(n => n.id === l.target),
value: l.value,
})),
};
const sankeyLayout = sankey()
.nodeWidth(20)
.nodePadding(12)
.nodeAlign(sankeyLeft)
.extent([[0, 0], [iw, ih]]);
const { nodes, links } = sankeyLayout(sankeyData as any);
const colorScale = d3.scaleOrdinal(d3.schemeTableau10);
const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`);
// Tooltip
const tooltip = d3.select('body').append('div')
.style('position', 'absolute')
.style('display', 'none')
.style('background', 'rgba(0,0,0,0.8)')
.style('color', '#fff')
.style('padding', '8px 12px')
.style('border-radius', '4px')
.style('font-size', '13px')
.style('pointer-events', 'none');
// Links
g.append('g')
.selectAll('.link')
.data(links)
.join('path')
.attr('class', 'link')
.attr('d', sankeyLinkHorizontal())
.attr('fill', 'none')
.attr('stroke', (d: any) => colorScale(String(d.source.index)))
.attr('stroke-width', (d: any) => Math.max(1, d.width))
.attr('stroke-opacity', 0.4)
.on('mouseover', (event, d: any) => {
d3.select(event.currentTarget).attr('stroke-opacity', 0.7);
tooltip
.style('display', 'block')
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`)
.html(`<strong>${d.source.label} → ${d.target.label}</strong><br/>${d3.format(',.0f')(d.value)} пользователей`);
})
.on('mouseout', (event) => {
d3.select(event.currentTarget).attr('stroke-opacity', 0.4);
tooltip.style('display', 'none');
});
// Nodes
const nodeG = g.append('g')
.selectAll('.node')
.data(nodes)
.join('g')
.attr('class', 'node');
nodeG.append('rect')
.attr('x', (d: any) => d.x0)
.attr('y', (d: any) => d.y0)
.attr('width', (d: any) => d.x1 - d.x0)
.attr('height', (d: any) => Math.max(1, d.y1 - d.y0))
.attr('fill', (d: any) => colorScale(String(d.index)))
.attr('rx', 3)
.on('mouseover', (event, d: any) => {
tooltip
.style('display', 'block')
.style('left', `${event.pageX + 12}px`)
.style('top', `${event.pageY - 28}px`)
.html(`<strong>${d.label}</strong><br/>Объём: ${d3.format(',.0f')(d.value)}`);
})
.on('mouseout', () => tooltip.style('display', 'none'));
// Labels
nodeG.append('text')
.attr('x', (d: any) => d.x0 < iw / 2 ? d.x1 + 6 : d.x0 - 6)
.attr('y', (d: any) => (d.y0 + d.y1) / 2)
.attr('dy', '0.35em')
.attr('text-anchor', (d: any) => d.x0 < iw / 2 ? 'start' : 'end')
.attr('font-size', 12)
.attr('fill', '#374151')
.text((d: any) => d.label);
return () => { tooltip.remove(); };
}, [data, width, height]);
return <svg ref={svgRef} width={width} height={height} />;
}
Подготовка данных на сервере
Данные для Sankey обычно агрегируются из событийного потока. Пример для воронки сайта:
-- Последовательные переходы между страницами в рамках сессии
WITH ranked_events AS (
SELECT
session_id,
page_type,
LAG(page_type) OVER (PARTITION BY session_id ORDER BY created_at) AS prev_page_type,
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at) AS step
FROM page_views
WHERE created_at > NOW() - INTERVAL '30 days'
)
SELECT
COALESCE(prev_page_type, 'entry') AS source,
page_type AS target,
COUNT(*) AS value
FROM ranked_events
WHERE prev_page_type IS NOT NULL OR step = 1
GROUP BY 1, 2
HAVING COUNT(*) > 50 -- фильтруем редкие переходы
ORDER BY value DESC;
Нюансы раскладки
d3-sankey поддерживает несколько алгоритмов выравнивания узлов:
-
sankeyLeft— узлы выравниваются по левому краю уровня. Подходит для воронок -
sankeyRight— по правому краю -
sankeyCenter— по центру графа (для ациклических графов) -
sankeyJustify(по умолчанию) — листовые узлы прижимаются к правому краю
Для циклических данных (A → B → A) стандартный d3-sankey не работает — нужна предобработка или библиотека d3-sankey-circular.
Сроки
Sankey-диаграмма с tooltip и базовыми взаимодействиями — 2–3 дня. С drill-down (клик по узлу раскрывает детали), фильтрацией по периоду и экспортом — 5–7 дней.







