Command Palette

Search for a command to run...

Hectal
PHASE 13Advanced ~27 min· topic 6 of 7

Topic 13.6

Flows: Chord Diagrams and Sankey

In one line

A flow is an amount that moves from one place to another: trips from the district where they start to the one where they end. A chord diagram draws it as ribbons round a circle and a Sankey as bands between columns. A plain matrix often reads better than both.

Think of it like this

The roads between six towns on a map, drawn so that a road's width shows how many cars use it, with one band for each direction. You see at a glance which towns exchange the most traffic. Now imagine drawing all 36 pairs, including cars that start and end in the same town.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

flow
An amount that goes from a source to a target: here, trips from a start district to an end district.
matrix
A table of numbers in rows and columns. In a flow matrix, rows are sources and columns are targets.
chord
One ribbon between two groups in a chord diagram. Its ends show the flow in each direction.
ribbon
The curved band that D3 draws for a chord, with a width at each end.
Sankey diagram
A flow chart in which bands between columns of nodes have a width that shows the amount.
acyclic
With no loops: you can never follow the links and come back to where you started. A Sankey needs this.

Step by step

01Look at the matrix first

The data is six names and a 6 × 6 table that adds up to 10,000 trips. The first example prints the sizes, then the angles. Harbour and Old Town each take 25% of the trips that start there, so their arcs are the widest (86 and 87 degrees).

02Draw the circle

d3.chord() gives 6 groups and 21 ribbons. The groups become arcs with d3.arc(), the chords ribbons with d3.ribbon(). The ribbon between Harbour and Old Town is thicker at the Old Town end (713) than at the Harbour end (647).

Draw the circlediagram
Rendering diagram…

03Draw the same data as a matrix

The second example draws 36 squares, each with its number, and reads three facts from the data: the three biggest flows, the share of trips that stay in a district (17%) and the most lopsided pair.

04Know what a Sankey needs

The code block after the examples builds a Sankey from the same matrix. It does not run on this page, because d3-sankey is a separate package. Notice how it splits each district into a "start" node and an "end" node, to make the graph loop-free.

Try it yourself

  1. 1

    Widen the gaps

    In the first example, change padAngle(0.05) to padAngle(0.2). The gaps take more of the circle, so the arcs shrink: Harbour from 86 to 73 degrees, Westpark from 22 to 18. The shares stay the same.

  2. 2

    Find another pair

    In the second example, add console.log(matrix[2][0], matrix[0][2]);. It prints 434 407: University to Harbour is 434 trips and Harbour to University 407, a gap of 27. The Old Town and Harbour pair, with a gap of 66, is the most lopsided.

Code & diagrams

A chord diagram of trips between districts, and its edge cases New tab

Hover a ribbon for its numbers. An empty or all-zero matrix gives groups with no width and no ribbons.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

trips: 10000 | districts: 6 | matrix is 6x6
groups: 6 | ribbons: 21 (6 loops inside a district + 15 pairs)
angle of each group (degrees): Harbour 86, Old Town 87, University 55, Riverside 49, Hillcrest 45, Westpark 22
share of trips that start there: 25% 25% 16% 14% 13% 6%
Old Town -> Harbour 713 | back: 647 | the ribbon is thicker at the end of the bigger flow
all zeros: groups 6 | ribbons drawn 0 | angle of group 0: 0
Westpark with no trips: its arc is 0 degrees, ribbons: 15
The same flows as a matrix, with the numbers New tab

Rows are where trips start, columns where they end. The darkest cell is Old Town to Harbour.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

three biggest flows: Old Town -> Harbour 713 | Harbour -> Old Town 647 | Old Town -> Old Town 554
trips that stay inside a district: 1713 of 10000 (17%)
most lopsided pair: Old Town -> Harbour has 66 more trips than the way back
cells drawn: 36 | labels: 36
A Sankey from the same matrix (not live: d3-sankey is a separate package)js

Install with `npm install d3-sankey`. This page cannot run it, so there is no output here. The two layout calls are the whole API you need.

import * as d3 from "d3";
import { sankey, sankeyLinkHorizontal } from "d3-sankey";

const { districts, matrix } = await d3.json("/data/lumen/flows.json");

// A Sankey needs a graph with no loops. Harbour -> Harbour and Harbour <-> Old Town are loops,
// so every district becomes TWO nodes: where trips start (left column) and where they end (right column).
const nodes = [
  ...districts.map((name) => ({ name: name + " (start)" })),
  ...districts.map((name) => ({ name: name + " (end)" })),
];
const links = matrix.flatMap((row, i) =>
  row.map((value, j) => ({ source: i, target: districts.length + j, value })), // numbers are indexes into nodes
);

const width = 560, height = 320;
const layout = sankey().nodeWidth(14).nodePadding(10).extent([[1, 1], [width - 1, height - 6]]);
const graph = layout({ nodes, links }); // adds x0, x1, y0, y1 to nodes and y0, y1, width to links; source and target become node objects

d3.select("svg").append("g").attr("fill", "none").attr("stroke-opacity", 0.4).selectAll("path").data(graph.links).join("path")
  .attr("d", sankeyLinkHorizontal()) // a curved band from the source node to the target node
  .attr("stroke", "#4e79a7")
  .attr("stroke-width", (d) => Math.max(1, d.width)); // the width IS the number of trips

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Give it a matrix that is not square

In the first example, change const chords = layout(matrix); to const chords = layout(matrix.map((row) => row.slice(0, 5)));.

terminal
$ Console
── what you'll see ──
trips: 10000 | districts: 6 | matrix is 6x6
groups: 6 | ribbons: 20 (6 loops inside a district + 15 pairs)
angle of each group (degrees): Harbour NaN, Old Town NaN, University NaN, Riverside NaN, Hillcrest NaN, Westpark NaN
Error: <path> attribute d: Expected number, "MNaN,NaNQ0,0,NaN,…".

Break #2

Put in a negative number

In the first example, change const chords = layout(matrix); to matrix[1][2] = -409; const chords = layout(matrix);.

terminal
$ Console
── what you'll see ──
angle of each group (degrees): Harbour 94, Old Town 64, University 60, Riverside 53, Hillcrest 48, Westpark 24

Myth vs fact

Myth

Chord diagrams are the best way to show flows between groups.

Fact

They look impressive but are hard to read exactly. For a few groups, a matrix is faster to read, and a bar chart of the top flows is simpler still.

Myth

A Sankey diagram can show any flow.

Fact

It needs a graph with no loops, so flows that go both ways, or back to where they started, need two nodes per place.

Myth

A ribbon's two ends must be equal.

Fact

In a chord diagram each end has its own width: the flow in that direction. Equal ends mean equal flows both ways.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    d3.chordDirected and d3.ribbonArrow (d3-chord 3) draw one ribbon per direction with an arrowhead, which avoids the "one ribbon, two widths" reading problem, at the cost of twice as many ribbons.

  • ▸

    chords.groups are in the matrix order unless you call sortGroups. sortSubgroups(d3.descending) orders the ribbons inside a group so that the biggest sits first and ribbons cross less. sortChords controls the drawing order (so a thin ribbon is not hidden under a thick one).

  • ▸

    A matrix is only as readable as its row and column order. Order rows and columns by total, or by a clustering, so that big flows sit together. D3 has no built-in seriation, but sorting by row total is a simple, honest start.

  • ▸

    For a big Sankey (more than ~50 nodes), d3-sankey slows down and the diagram gets unreadable. Group small nodes into "other" and show the top ones.

Remember this

  1. 1

    The problem this solves: Arjun wants to know where Lumen's trips go. 10,000 trips in June start in one of six districts and end in one of six, so there are 36 numbers (a 6 × 6 table). A line chart or a bar chart has no place for "from" and "to" at the same time. Flow charts do.

  2. 2

    The data is a matrix. flows.json has districts (six names) and matrix, a 6 × 6 table of numbers. matrix[i][j] is the number of trips that start in district i and end in district j. Row 0, column 1 is Harbour to Old Town: 647 trips. The diagonal (matrix[i][i]) is trips that stay inside a district: 1,713 of 10,000 trips in all (17%).

  3. 3

    **d3.chord() turns the matrix into angles.** It returns chords (an array, one per pair of districts) with a groups property: one group per district, with startAngle, endAngle, index and value. Each group's arc is as wide as that district's outgoing trips. Each chord has a source and a target, and each end has its own width, so one ribbon shows both directions of a pair. d3.arc() draws the outer arcs and d3.ribbon() draws the ribbons. padAngle leaves a gap between groups.

  4. 4

    Reading a chord diagram is harder than it looks. The eye must compare the width of a ribbon end, which is a thin curved shape, and follow a ribbon across the circle. With six districts there are 21 ribbons (6 loops and 15 pairs) and many cross. It is good for "which groups exchange the most" and bad for exact numbers. Add tooltips, sort the groups, and keep the group count small (about ten at most).

  5. 5

    A matrix says the same thing, with the numbers. Rows are where trips start, columns where they end, and each cell is coloured by its value and carries the number. You read the biggest flows directly: Old Town to Harbour 713, Harbour to Old Town 647, Old Town to itself 554. The lopsided pair is easy to see: Old Town to Harbour has 66 more trips than the way back. A matrix does not tangle, scales to more groups, and can be read by a screen reader as a table.

  6. 6

    Sankey: bands between columns. A Sankey diagram draws flows as bands whose width is the amount, between nodes arranged in columns, and it is best for stages (visitors, then signed up, then paid). It needs the separate package d3-sankey, which is not in this site's live runtime, so its code below is not run here. It requires a graph with no loops, so district-to-district trips must be split into two nodes per district, one for "starts here" and one for "ends here". That turns the 36 numbers into 12 nodes and 36 bands.

  7. 7

    Edge cases. An all-zero matrix gives six groups of angle 0 and no ribbons: show an empty state, not an empty circle. A district with no trips has an arc of 0 degrees. A negative value does not throw. It silently lowers that district's arc, so check the data before you draw. A matrix that is not square gives NaN angles and the path error. Chord diagrams show flows between a few groups; for many nodes, use a network (Topic 13.5) with a threshold.

Explain it without notes

01

How is a flow matrix read, and what is on its diagonal?

02

What does a chord's source and target hold in a chord diagram?

03

When is a matrix a better choice than a chord diagram?

04

Why must a Sankey graph have no loops, and how do you handle flows that go both ways?

Practice

01

Sort the matrix's rows and columns by total trips, descending, and redraw it. Which district comes first and which last?

02

Write validFlows(matrix) that returns an error message for a matrix that is not square, or has a negative or non-numeric value, and null when it is fine.

Trade-offs

  • ↔

    A chord diagram shows the whole system in one picture but is hard to read exactly. A matrix is exact and stable but does not show "who talks to whom" as a shape.

  • ↔

    A Sankey shows stages and drop-off well, and needs another package and a graph without loops.

Done when you can

  • I can read a flow matrix: rows are sources, columns targets, the diagonal stays inside.

  • I can draw a chord diagram with d3.chord, d3.arc and d3.ribbon.

  • I can say why a matrix is often clearer, and draw one with numbers.

  • I can explain what a Sankey needs and why district flows need two nodes per district.

  • I can validate a flow matrix before drawing it.