Skip to content

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.

ts
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 },
  };
}
ts
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:

ts
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 },
  };
}
ts
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.

ts
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 } }],
  };
}
ts
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.

ts
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 },
  };
}
ts
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:

ts
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 },
  };
}
ts
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.

OptionSeriesDescription
fromFieldbothflow graph edges
toFieldbothflow graph edges
sizeFieldbothflow graph edges
fillsbothnode colors cycling through the palette

Full option list

OptionTypeDefaultDescription
linkOpacityboth0.35flow ribbon opacity
nodeSpacingchord12gap between node arcs, px
label.enabledbooleantruenode labels
label.formatter({ name, total, share }) => stringthe whole label at once; wins over category/value
label.fontSizePixels11font both halves fall back to
label.fontWeightstring | numbernormalfont weight
label.fontFamilystringtheme fontfont family
label.colorColorValueforegroundcolor
label.layout'stacked' | 'inline''stacked'the two halves on two lines or one
label.separatorstring' · 'between the halves of an inline label
label.categoryenabled, font, format, formatteronthe node name
label.valueenabled, type, font, format, formatteroffwhat flows through the node
label.value.type'value' | 'percent''value'the flow itself, or its share of the whole
label.minShareFraction0share a node needs before it is worth a label
label.avoidOverlapbooleanfalsedrop a label there is no room for
node.widthPixels14sankey node width
node.spacingPixels14sankey 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.

js
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:

js
series: [{ type: 'treemap', sizeField: 'revenue', name: 'Revenue' }],