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).
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
Widen the gaps
In the first example, change
padAngle(0.05)topadAngle(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
Find another pair
In the second example, add
console.log(matrix[2][0], matrix[0][2]);. It prints434 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
Hover a ribbon for its numbers. An empty or all-zero matrix gives groups with no width and no ribbons.
Preview
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: 15Rows are where trips start, columns where they end. The darkest cell is Old Town to Harbour.
Preview
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: 36Install 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 tripsBreak 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)));.
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);.
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.chordDirectedandd3.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.groupsare in the matrix order unless you callsortGroups.sortSubgroups(d3.descending)orders the ribbons inside a group so that the biggest sits first and ribbons cross less.sortChordscontrols 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-sankeyslows down and the diagram gets unreadable. Group small nodes into "other" and show the top ones.
Remember this
- 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
The data is a matrix.
flows.jsonhasdistricts(six names) andmatrix, a 6 × 6 table of numbers.matrix[i][j]is the number of trips that start in districtiand end in districtj. 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
**
d3.chord()turns the matrix into angles.** It returnschords(an array, one per pair of districts) with agroupsproperty: one group per district, withstartAngle,endAngle,indexandvalue. Each group's arc is as wide as that district's outgoing trips. Each chord has asourceand atarget, and each end has its own width, so one ribbon shows both directions of a pair.d3.arc()draws the outer arcs andd3.ribbon()draws the ribbons.padAngleleaves a gap between groups. - 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
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
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
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
NaNangles 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
How is a flow matrix read, and what is on its diagonal?
What does a chord's source and target hold in a chord diagram?
When is a matrix a better choice than a chord diagram?
Why must a Sankey graph have no loops, and how do you handle flows that go both ways?
Practice
Sort the matrix's rows and columns by total trips, descending, and redraw it. Which district comes first and which last?
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.arcandd3.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.