Sankey and Chord
Flow series: edges fromField → toField weighted by sizeField.
Sankey
Nodes are laid out in columns by topological depth; link thickness is proportional to the flow.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'User journey' },
series: [{ type: 'sankey', fromField: 'from', toField: 'to', sizeField: 'value' }],
legend: { enabled: false },
};
}export function getData() {
return [
{ from: 'Traffic', to: 'Organic', value: 620 },
{ from: 'Traffic', to: 'Ads', value: 380 },
{ from: 'Organic', to: 'Sign-up', value: 240 },
{ from: 'Ads', to: 'Sign-up', value: 190 },
{ from: 'Organic', to: 'Bounce', value: 380 },
{ from: 'Ads', to: 'Bounce', value: 190 },
{ from: 'Sign-up', to: 'Subscription', value: 160 },
{ from: 'Sign-up', to: 'Freemium', value: 270 },
];
}Labels and node configuration
A node label is the name of the node and what flows through it, drawn as one block: label.category is the name, label.value the number, each with its own font, colour and format — the same shape a pie sector label takes. The name is printed on its own until value.enabled asks for the number too, layout puts the two on one line instead of two, and label itself carries the font both halves fall back to. Alongside them, node.width / node.spacing and linkOpacity:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Budget flow' },
subtitle: { text: 'the name and the total of a node, each with its own font' },
series: [
{
type: 'sankey',
fromField: 'from',
toField: 'to',
sizeField: 'amount',
node: { width: 14, spacing: 24 },
linkOpacity: 0.5,
label: {
fontSize: 12,
// the name and the number are one label, styled apart
category: { fontWeight: 'bold' },
value: { enabled: true, format: ',.0f', fontSize: 11, color: '#8892a4' },
},
},
],
legend: { enabled: false },
};
}export function getData() {
return [
{ from: 'Salary', to: 'Budget', amount: 220 },
{ from: 'Freelance', to: 'Budget', amount: 60 },
{ from: 'Budget', to: 'Rent', amount: 90 },
{ from: 'Budget', to: 'Food', amount: 70 },
{ from: 'Budget', to: 'Transport', amount: 30 },
{ from: 'Budget', to: 'Savings', amount: 90 },
{ from: 'Savings', to: 'Investments', amount: 60 },
{ from: 'Savings', to: 'Emergency fund', amount: 30 },
];
}Many nodes in a column
The value → px scale is set by the column that runs out of room first, not by the heaviest one: the gaps between nodes are a fixed cost, so a column of twelve nodes has eleven gaps to pay for before its values get any height. Where the gaps alone would outgrow the plot, they shrink below node.spacing — every node keeps at least a hairline, and the column stays inside the chart.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Revenue by year and month' },
subtitle: { text: 'a column of twelve nodes still fits the height' },
series: [{ type: 'sankey', fromField: 'year', toField: 'month', sizeField: 'revenue', node: { spacing: 8 } }],
};
}const YEARS = ['2019', '2020', '2021'];
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** Revenue per year split across the months it landed in. */
export function getData() {
return YEARS.flatMap((year, yearIndex) =>
MONTHS.map((month, monthIndex) => ({
year,
month,
revenue: 40 + ((yearIndex * 7 + monthIndex * 5) % 55),
})),
);
}Chord
Nodes around a circle, ribbons are the mutual flows.
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'Calls between services' },
series: [{ type: 'chord', fromField: 'from', toField: 'to', sizeField: 'calls' }],
legend: { enabled: false },
};
}export function getData() {
return [
{ from: 'API', to: 'Auth', calls: 320 },
{ from: 'API', to: 'Billing', calls: 180 },
{ from: 'Web', to: 'API', calls: 540 },
{ from: 'Mobile', to: 'API', calls: 410 },
{ from: 'Billing', to: 'Auth', calls: 90 },
{ from: 'Web', to: 'Auth', calls: 130 },
];
}Spacing and labels
nodeSpacing — the gap between arcs (px along the inner radius), linkOpacity — ribbon density. The label block is the sankey's: here the value half reads as a share of the ring (value.type: 'percent') rather than as the flow itself:
import { getData } from './data';
import type { ChartOptions } from 'grafit-charts';
export function createOptions(): ChartOptions {
return {
data: getData(),
title: { text: 'User migration between platforms' },
subtitle: { text: 'nodeSpacing: 28, dense ribbons, a share under every name' },
series: [
{
type: 'chord',
fromField: 'from',
toField: 'to',
sizeField: 'users',
nodeSpacing: 28,
linkOpacity: 0.55,
label: {
fontSize: 12,
// the value half reads as a share of the ring, on a line of its own
value: { enabled: true, type: 'percent', fontSize: 10, color: '#8892a4' },
},
},
],
legend: { enabled: false },
};
}export function getData() {
return [
{ from: 'Web', to: 'iOS', users: 18 },
{ from: 'Web', to: 'Android', users: 22 },
{ from: 'iOS', to: 'Web', users: 9 },
{ from: 'Android', to: 'Web', users: 12 },
{ from: 'iOS', to: 'Android', users: 6 },
{ from: 'Android', to: 'iOS', users: 7 },
{ from: 'Web', to: 'Desktop', users: 10 },
{ from: 'Desktop', to: 'Web', users: 5 },
];
}Options
Options common to all series (name, showInLegend, tooltip.renderer, …) are covered in Common series options.
| Option | Series | Description |
|---|---|---|
fromField | both | flow graph edges |
toField | both | flow graph edges |
sizeField | both | flow graph edges |
fills | both | node colors cycling through the palette |
Full option list
| Option | Type | Default | Description |
|---|---|---|---|
linkOpacity | both | 0.35 | flow ribbon opacity |
nodeSpacing | chord | 12 | gap between node arcs, px |
label.enabled | boolean | true | node labels |
label.formatter | ({ name, total, share }) => string | — | the whole label at once; wins over category/value |
label.fontSize | Pixels | 11 | font both halves fall back to |
label.fontWeight | string | number | normal | font weight |
label.fontFamily | string | theme font | font family |
label.color | ColorValue | foreground | color |
label.layout | 'stacked' | 'inline' | 'stacked' | the two halves on two lines or one |
label.separator | string | ' · ' | between the halves of an inline label |
label.category | enabled, font, format, formatter | on | the node name |
label.value | enabled, type, font, format, formatter | off | what flows through the node |
label.value.type | 'value' | 'percent' | 'value' | the flow itself, or its share of the whole |
label.minShare | Fraction | 0 | share a node needs before it is worth a label |
label.avoidOverlap | boolean | false | drop a label there is no room for |
node.width | Pixels | 14 | sankey node width |
node.spacing | Pixels | 14 | sankey node vertical gap |
The whole a share is taken against is what the node stands among: its own column for a sankey node, the whole ring for a chord one. minShare reads the same whole, so 0.02 leaves the slivers of a crowded column unlabelled.
Tooltip and the name of a value
A node is not a row of the data — it is a name and what it adds up to — so tooltip.renderer receives NodeTooltipRendererParams: { datum?, label, value, share, color }. datum is the row the node was read from; a flow node is summed from several rows and has none.
tooltip: { renderer: ({ label, value, share }) => `${label}: ${value} (${Math.round(share * 100)}%)` },Without a renderer the row of the tooltip is named after the data key the value came from — a column name, not the name of a measure. name on the series says what it should be called instead:
series: [{ type: 'treemap', sizeField: 'revenue', name: 'Revenue' }],