Relationship / Advanced
Sankey Diagram
Trace quantities between stages using proportional flow ribbons.
Interactive Chart Preview
JAVASCRIPTA custom Chart.js plugin draws flow ribbons and nodes with native tooltips.
Edit the column names and values to update your chart. Changes stay in this page until you reload.
| Row# | Source Node Name | Destination Node Name | Nonnegative Flow | Remove row |
|---|---|---|---|---|
| 1 | ||||
| 2 | ||||
| 3 | ||||
| 4 | ||||
| 5 | ||||
| 6 | ||||
| 7 | ||||
| 8 |
Names identify nodes exactly, including case. Use one row per directed pair, nonnegative amounts, no self-links or cycles, and at least one positive flow. Zero links stay in the data. Incoming and outgoing totals may differ; no balancing flow is invented.
Chart.js Code Example
JAVASCRIPTimport Chart from 'chart.js/auto';
// Illustrative data used by the live preview.
const chartData = {
"flowHeaders": [
"Source",
"Destination",
"Requests"
],
"flows": [
{
"source": "Website",
"target": "Triage",
"value": 60
},
{
"source": "Email",
"target": "Triage",
"value": 40
},
{
"source": "Triage",
"target": "Self Service",
"value": 25
},
{
"source": "Triage",
"target": "Agent",
"value": 75
},
{
"source": "Self Service",
"target": "Resolved",
"value": 20
},
{
"source": "Self Service",
"target": "Pending",
"value": 5
},
{
"source": "Agent",
"target": "Resolved",
"value": 60
},
{
"source": "Agent",
"target": "Pending",
"value": 15
}
],
"datasets": []
};
function createConfig(chartData) {
const flows = chartData.flows.filter(flow => flow.value > 0);
const names = [...new Set(flows.flatMap(flow => [flow.source, flow.target]))];
const palette = ['#2563eb', '#0f766e', '#b45309', '#7c3aed', '#be185d', '#0369a1', '#4d7c0f'];
const nodes = names.map((name, index) => ({ name, color: palette[index % palette.length], incoming: 0, outgoing: 0, pending: 0, depth: 0, x: 0, y: 0, height: 0 }));
const byName = new Map(nodes.map(node => [node.name, node]));
const links = flows.map(flow => ({ source: byName.get(flow.source), target: byName.get(flow.target), value: flow.value, sy: 0, ty: 0, thickness: 0 }));
links.forEach(link => { link.source.outgoing += link.value; link.target.incoming += link.value; link.target.pending++; });
const queue = nodes.filter(node => node.pending === 0);
for (let index = 0; index < queue.length; index++) {
const node = queue[index];
links.filter(link => link.source === node).forEach(link => {
link.target.depth = Math.max(link.target.depth, node.depth + 1);
if (--link.target.pending === 0)
queue.push(link.target);
});
}
const maxDepth = Math.max(...nodes.map(node => node.depth));
const measure = chartData.flowHeaders[2];
const columns = Array.from({ length: maxDepth + 1 }, (_, depth) => nodes.filter(node => node.depth === depth));
const gap = Math.min(0.04, 0.5 / Math.max(...columns.map(column => column.length)));
const scale = Math.min(...columns.map(column => (1 - gap * (column.length - 1)) / column.reduce((sum, node) => sum + Math.max(node.incoming, node.outgoing), 0)));
const nodeWidth = Math.min(0.025, 0.4 / (maxDepth + 1));
columns.forEach(column => {
const occupied = column.reduce((sum, node) => sum + Math.max(node.incoming, node.outgoing) * scale, 0) + gap * (column.length - 1);
let y = (1 - occupied) / 2;
column.forEach(node => {
node.x = node.depth / maxDepth * (1 - nodeWidth);
node.y = y;
node.height = Math.max(node.incoming, node.outgoing) * scale;
y += node.height + gap;
});
});
const outgoing = new Map(nodes.map(node => [node.name, node.y]));
const incoming = new Map(nodes.map(node => [node.name, node.y]));
links.forEach(link => {
link.sy = outgoing.get(link.source.name);
link.ty = incoming.get(link.target.name);
link.thickness = link.value * scale;
outgoing.set(link.source.name, link.sy + link.thickness);
incoming.set(link.target.name, link.ty + link.thickness);
});
let regions = [];
return {
type: 'scatter',
data: { datasets: [{ data: [
...nodes.map(node => ({ x: node.x + nodeWidth / 2, y: node.y + node.height / 2 })),
...links.map(link => ({ x: (link.source.x + nodeWidth + link.target.x) / 2, y: (link.sy + link.ty + link.thickness) / 2 })),
], pointRadius: 0, pointHoverRadius: 0, pointHitRadius: 0 }] },
options: {
responsive: true, maintainAspectRatio: false, animation: false, layout: { padding: 16 },
scales: { x: { min: 0, max: 1, display: false }, y: { min: 0, max: 1, reverse: true, display: false } },
plugins: { legend: { display: false }, tooltip: { callbacks: {
title: items => { const index = items[0].dataIndex; return index < nodes.length ? nodes[index].name : `${links[index - nodes.length].source.name} → ${links[index - nodes.length].target.name}`; },
label: item => item.dataIndex < nodes.length ? [`Incoming ${measure}: ${nodes[item.dataIndex].incoming}`, `Outgoing ${measure}: ${nodes[item.dataIndex].outgoing}`] : `${measure}: ${links[item.dataIndex - nodes.length].value}`,
} } },
},
plugins: [{ id: 'flow-ribbons', afterDatasetsDraw(chart) {
const { ctx, scales } = chart, px = (value) => scales.x.getPixelForValue(value), py = (value) => scales.y.getPixelForValue(value);
regions = [];
ctx.save();
links.forEach((link, index) => {
const x0 = px(link.source.x + nodeWidth), x1 = px(link.target.x), middle = (x0 + x1) / 2;
const sy = py(link.sy), ty = py(link.ty), thickness = py(link.thickness) - py(0);
const path = new Path2D();
path.moveTo(x0, sy);
path.bezierCurveTo(middle, sy, middle, ty, x1, ty);
path.lineTo(x1, ty + thickness);
path.bezierCurveTo(middle, ty + thickness, middle, sy + thickness, x0, sy + thickness);
path.closePath();
ctx.fillStyle = link.source.color;
ctx.globalAlpha = 0.4;
ctx.fill(path);
regions.push({ path, index: nodes.length + index });
});
ctx.globalAlpha = 1;
nodes.forEach((node, index) => {
const x = px(node.x), y = py(node.y), width = px(node.x + nodeWidth) - x, height = py(node.y + node.height) - y;
const path = new Path2D();
path.rect(x, y, width, height);
ctx.fillStyle = node.color;
ctx.fill(path);
regions.push({ path, index });
if (height < 14)
return;
const label = node.name.length > 12 ? node.name.slice(0, 11) + '…' : node.name;
ctx.font = '11px sans-serif';
const textWidth = ctx.measureText(label).width;
const left = node.depth === maxDepth ? x - textWidth - 5 : x + width + 5;
ctx.fillStyle = '#fff';
ctx.fillRect(left - 2, y + height / 2 - 8, textWidth + 4, 16);
ctx.fillStyle = '#1e293b';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(label, left, y + height / 2);
});
ctx.restore();
}, afterEvent(chart, args) {
const { x, y, type } = args.event;
const region = x === null || y === null || type === 'mouseout' ? undefined : [...regions].reverse().find(region => chart.ctx.isPointInPath(region.path, x, y));
const active = region ? [{ datasetIndex: 0, index: region.index }] : [];
chart.setActiveElements(active);
chart.tooltip?.setActiveElements(active, { x: x ?? 0, y: y ?? 0 });
args.changed = true;
} }],
};
}
const chart = new Chart(document.getElementById('myChart'), createConfig(chartData));Preview and code follow the same library selection.
Run This Example
Install the package below in a browser project such as Vite. Put the copied code in a client JavaScript module and add this container to the page before the module runs.
npm install chart.js@4.5.1<div style="height:320px"><canvas id="myChart"></canvas></div>The example includes its sample data. When removing the chart, destroy or dispose of its instance and disconnect any resize observer.
Reading This Example
This illustrative process starts with 100 support requests: 60 from Website and 40 from Email. Triage sends 25 to Self Service and 75 to Agent. Self Service resolves 20 and leaves 5 pending; Agent resolves 60 and leaves 15 pending. The outcomes are 80 resolved and 20 pending. All intermediate nodes balance, and the 300-request sum across links counts the cohort at three transitions.
All library tabs use these illustrative values. Rendering styles and library APIs differ, so use the table to compare exact numbers.
About this example & sample data
| Source | Destination | Requests |
|---|---|---|
| Website | Triage | 60 |
| Triage | 40 | |
| Triage | Self Service | 25 |
| Triage | Agent | 75 |
| Self Service | Resolved | 20 |
| Self Service | Pending | 5 |
| Agent | Resolved | 60 |
| Agent | Pending | 15 |
READ THE CHART
Trace Quantities Along Their Routes
A Sankey diagram connects stages with ribbons whose thickness represents a quantity. It helps you see where a flow splits, joins, or reaches an outcome. Read the source and destination of each ribbon before comparing its width with another.
Give Each Node a Consistent Name
Each table row defines a source, destination, and nonnegative value. Names identify nodes exactly after trimming spaces, including capitalization. Reuse a name to connect links through the same node. Enter only one row per directed pair. Self-links and cycles are invalid in this example; use stage-specific names for a process that returns to the same activity later.
Read Ribbon Thickness, Not Length
Ribbon thickness at its endpoints represents the supplied amount on a shared scale. Horizontal distance and curve length come from the layout, so they do not measure duration or effort. Native libraries arrange nodes differently while preserving the flow values. Color identifies a source node, not an additional measurement.
Inspect Incoming and Outgoing Totals
A node is tall enough for the larger of its incoming and outgoing totals. Tooltips state both amounts. Edited flows do not have to balance, and the chart does not invent a balancing link. If the totals differ, check the period, units, scope, and missing routes before interpreting the gap as a gain or loss.
Avoid Counting the Same Flow Twice
The same item can cross several ribbons, so their sum may count it several times. Choose a meaningful entry or exit boundary when calculating a total. Zero links remain in the table and copied examples but have no visible ribbon; at least one flow must be positive. Many nodes or stages can crowd labels, so use the table for exact values or simplify the displayed process.