Topic 7.5
Heatmaps and Small Multiples
In one line
A heatmap colours a grid of cells by a number, which shows a pattern in two directions at once, like day by hour. Small multiples repeat one small chart for many groups, with the same scale so the groups can be compared.
Think of it like this
A school timetable that you colour in. Draw the days across and the hours down, and paint the busy slots dark. In one look you see that every Monday morning is dark and every Friday afternoon is pale. A heatmap is that timetable, with the paint chosen by a number.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- heatmap
- A grid of cells whose colour shows a number.
- sequential palette
- Colours that go from light to dark in one direction, for numbers that go from low to high.
- small multiples
- Many small copies of one chart, one for each group, laid out in a grid.
- shared scale
- The same axis range in every small chart, so a tall line means the same in all of them.
- free scale
- Each small chart has its own axis range, so all of them fill their box.
Step by step
01Load the hours and make two band scales
The first example reads hourly-june.csv: 720 rows, one per hour, for 30 days by 24 hours. It uses datetime.slice(8, 10) for the day. The file's datetime has no time zone, and d3.autoType would read it as a Date in the viewer's zone (Break it).
02Paint the cells and read the pattern
Each row becomes a <rect>; its colour comes from the sequential scale. The darkest cell is Thursday 26 June at 17:00 with 599 rides. Weekdays have two dark stripes (around 8 and from 17 to 18); the weekends (1, 7, 8, 14, 15, 21, 22, 28 and 29 June) have one dark blob at midday. Zero cells (42) are grey and mostly at night.
03Change the colour scale
The same data on a linear and a square-root scale: 337 cells fall in the palest tones with linear, 164 with square root. Both are honest if labelled. Pick the one that answers the question: linear for "where is the peak", square root for "is this quiet hour really empty".
04Repeat a small chart 25 times
The second example draws one line per station. Left, one scale for all: the heights compare. Right, a scale for each: every line fills its box. S25 is 12 pixels tall on the left and 46 on the right, with the same 1,833 peak.
Try it yourself
- 1
Use the square-root scale
In the first example replace
d3.scaleSequential(byd3.scaleSequentialSqrt(in thecolorline (the first one, beforeconst svg). The whole grid turns darker: a square root lifts small numbers, so moderate hours look nearly as dark as the rush hours, and only the quietest hours stay pale. Thepalest tonesline keeps printing its two counts (337 and 164) because it builds both scales itself. - 2
Squash the colour range
Change
.domain([0, max])to.domain([0, 300]). All 118 cells with more than 300 rides now have the same darkest colour, so the busiest hour (599) can no longer be told from an ordinary rush hour. A clipped scale hides the peak.
Code & diagrams
The rows are the 30 days (1 to 30 June) and the columns the 24 hours. Hover a cell in your own page to see the `<title>` text: it is the cheapest tooltip (Topic 8.2 builds a better one).
Preview
Console
Nothing logged yet.Expected console output
cells: 720 | days: 30 | hours: 24
busiest cell: Thu 26 June at 17:00 with 599 rides
cells with no rides: 42 | median cell: 78.5
cells in the palest tones: linear 337 sqrt 164Same data, same lines, two scales. Left: S25 is a low line, which is true. Right: it fills its box and looks as busy as S01, which is not.
Preview
Console
Nothing logged yet.Expected console output
charts per grid: 25 | tallest peak: 7260 | S25 peak: 1833
S25 line height: shared 12 px of 46 | free 46 px
S22 months with no rides: Jan, Feb, Mar, AprBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Let autoType read the date
Change the load to const rows = await d3.csv("/data/lumen/hourly-june.csv", d3.autoType) and print rows[0].datetime.
Break #2
Forget to convert numbers in the small multiples
In the second example remove , d3.autoType from the d3.csv call.
Myth vs fact
Myth
A heatmap needs a rainbow so that every value has its own colour.
Fact
A rainbow has no order: people cannot say whether green is more than yellow. One hue going from light to dark is read correctly by almost everyone, including people with colour blindness.
Myth
Small multiples should each fill their box.
Fact
Only if the aim is to compare shapes. To compare size, they need a shared scale, and then small values look small, which is the truth.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
d3.scaleSequentialmaps a domain to an interpolator function, not to a range. That is why it takesd3.interpolateYlGnBuand has no.range(). Used3.scaleQuantize(domain, d3.schemeBlues[5])for a few discrete classes, the best choice when the reader must look up a value in a legend.
Remember this
- 1
The problem this solves: Priya wants to know when the rush happens. June has 720 hours of rides. A line of 720 points is a tangle. A grid of 30 days by 24 hours, coloured by rides, shows the pattern in one picture: the weekday peaks at 8 and at 17 and 18 o'clock, and the weekend blob at midday.
- 2
A heatmap is two band scales and a colour scale. The x band is the hour, the y band is the day (recall Topic 6.4: a band scale gives each category one slot), and each cell is a
<rect>whosefillcomes fromd3.scaleSequential(d3.interpolateYlGnBu).domain([0, max]). A sequential palette goes from light (few) to dark (many). Never use a rainbow: it has no order and cannot be read by someone with colour blindness (Topic 2.5). - 3
The scale decides what you see. On a linear scale, 337 of the 720 cells end up in the palest tones, so most quiet hours look the same. A square-root scale (
d3.scaleSequentialSqrt) spreads them: only 164 cells stay in the palest tones (measured as a lightness above 0.85). Neither is wrong, but you must say which you used. - 4
Zero is not the same as missing. 42 cells have zero rides (night hours). A zero cell gets its own light grey in the example, and a really missing value would need a hatch or a different colour. If both are the lightest colour, the reader cannot tell a quiet hour from a broken sensor.
- 5
Small multiples repeat the same chart. One small line chart per station, in a grid, lets the eye compare shapes quickly, with no legend to look up. The big rule is the scale. With one shared vertical scale, S25 (peak 1,833 rides) is a low line, only 12 pixels of 46, because S06 has 7,260. With each chart on its own scale, S25 looks as tall as S01 and a reader thinks they are as busy. Use a shared scale to compare size, and free scales only to compare shape, and say so on the page.
- 6
Limits. A heatmap of 30 by 24 is 720
<rect>elements, which is fine. A year by hour is 8,760: use Canvas (Phase 11). Twenty-five small charts is fine. Two hundred is a long scroll: filter or group first. Cells need a colour legend (Topic 7.6) and a text alternative for a screen reader (Phase 15).
Explain it without notes
What are the three scales of a heatmap?
Why is a rainbow a poor palette for a heatmap?
What is the difference between a shared and a free scale in small multiples?
Practice
Colour the weekend rows' labels differently: in the heatmap, add a second y axis tick label colour for days 1, 7, 8, 14, 15, 21, 22, 28 and 29.
Draw the first example as weekday by hour (7 rows) using the mean rides per hour. Hint: d3.rollup(rows, (v) => d3.mean(v, (d) => d.rides), (d) => d.weekday, (d) => d.hour).
Trade-offs
- ↔
A heatmap hides exact numbers for the sake of the pattern. Give the exact value in a tooltip or a table.
Done when you can
I can draw a heatmap with two band scales and a sequential colour scale.
I can say what changes between a linear and a square-root colour scale.
I can make small multiples with a shared and with a free scale, and say what each is for.
I can show zero and missing differently.