Розробка 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 днів.







