Topic 4.5
Summarising with d3-array
In one line
Charts show summaries: totals, averages, the busiest day, counts per month, how many days fall in each range. d3-array gives short, tested functions for these, and you must know what each returns for empty, missing and text input.
Think of it like this
A teacher with 365 exam papers does not hand the principal 365 papers. She says: the class average, the best and worst mark, how many got each grade. Each of those is a summary, and each answers one question.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- accessor
- A small function
(d) => d.ridesthat says which value of a row to use. - reduce / rollup
- Turn many values into one: a sum, a mean, a count.
- Map
- A JavaScript collection of key → value pairs that remembers insertion order.
- bin
- One range of values in a histogram, with all rows that fall in it.
- median
- The middle value when sorted: half the days are below it, half above. Less swayed by outliers than the mean.
- quantile
- The value below which a given share of the data falls: 0.25 is the lower quarter mark.
Step by step
01Summarise one column
The first example prints total, extent, mean, median, busiest and quietest day, and the middle half of days (1,702 to 3,365 rides).
02Feed it awkward input
Ten awkward calls follow: empty arrays, one value, null and NaN inside, Math.max() of nothing, a hand-written reduce, strings. Note which return undefined, which skip bad values, which break.
03Group, index and bin
The second example makes monthly totals with d3.rollup, weekday averages (Saturday and Sunday are the quietest, about 2,290), counts of rainy (108) and dry (257) days with d3.group, a lookup with d3.index, and a histogram with d3.bin, drawn as # characters in the console.
04Format for people
Last, one number three ways with d3.format and Intl.NumberFormat in three locales. Keep it in one file (format.js).
Try it yourself
- 1
Change the bin edges
In the second example use
d3.range(0, 4500, 1000)as thresholds. The bins are439 to 1000(4 days),1000 to 2000(130),2000 to 3000(99),3000 to 4000(104) and4000 to 4380(28). The first bin starts at the data's own minimum, 439, not at 0. The counts still add up to 365. - 2
Summarise a different column
In the first example print
d3.sum(days, (d) => d.ebike_rides)after loading. It prints 310498. Thend3.mean(days, (d) => d.temp_c)prints about 17.48, the average temperature.
Code & diagrams
Failures with no error: d3.mean([]) is undefined, Math.max() is -Infinity, a plain reduce gives NaN, and d3.max on strings picks '910'.
Preview
Console
Nothing logged yet.Expected console output
days: 365 | total rides: 933651
extent: [ 439, 4380 ] | mean: 2557.9 | median: 2484
busiest day: 2025-07-18 | quietest: 2025-08-14
middle half of days: 1702 to 3365
d3.sum([]) 0
d3.mean([]) undefined
d3.max([]) undefined
d3.extent([]) [, ]
d3.median([5]) 5
d3.mean([4, null, 8, NaN]) 6
d3.sum([4, null, 8, NaN]) 12
Math.max(...[]) -Infinity
[4, NaN, 8].reduce((a, b) => a + b) NaN
d3.max(['4380', '910']) 910Monthly totals match the real file: Jul 117,803 is the busiest month. The bin counts add up to all 365 days.
Console
(nothing was printed)Expected console output
Rides per month: Jan 43,439 | Feb 44,343 | Mar 60,928 | Apr 78,970 | May 99,946 | Jun 107,712 | Jul 117,803 | Aug 108,806 | Sep 92,354 | Oct 76,485 | Nov 57,489 | Dec 45,376
Average rides per weekday: Mon 2682 | Tue 2723 | Wed 2598 | Thu 2556 | Fri 2771 | Sat 2283 | Sun 2292
Rainy days: 108 | dry days: 257
Lookup 2025-08-14: 439 rides | missing key: undefined
Top 3 days: 2025-07-18 4380 | 2025-08-06 4309 | 2025-07-15 4302 | input still in date order: 2025-01-01
439 to 500: 1
500 to 1000: # 3
1000 to 1500: ############### 45
1500 to 2000: ############################ 85
2000 to 2500: ################ 49
2500 to 3000: ################# 50
3000 to 3500: ################## 55
3500 to 4000: ################ 49
4000 to 4380: ######### 28
Days counted in bins: 365
d3.format(','): 933,651 | d3.format('~s'): 933.651k | d3.format('.1%'): 28.6%
en-GB: 933,651 | en-IN: 9,33,651 | de-DE: 933.651Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Take the maximum of an empty list
In the first example add console.log("Max of nothing:", Math.max(...[])); and console.log(d3.extent([]));.
Myth vs fact
Myth
The average is the typical day.
Fact
Lumen's mean is 2,558 and the median 2,484. One huge or tiny day pulls the mean. Look at the median and the spread (the quartiles) too.
Myth
d3.sum and d3.max treat text the same.
Fact
d3.sum converts values with +, d3.max compares them as they are. Mixed text columns give a right total and a wrong maximum.
Myth
Rollup gives me an object.
Fact
It gives a Map. Use .get(key) or spread it ([...map]) to get pairs for a chart.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
d3.rollupwith several keys nests Maps (rollup(rows, reducer, keyA, keyB));d3.rollupsreturns arrays of pairs instead, which is easier to give todata()in Phase 5. - ▸
d3.binis a histogram generator withdomain()andthresholds()options;d3.thresholdSturgesand friends pick edges automatically, but fixed round edges (500, 1000, …) read better on an axis.
Remember this
- 1
The problem this solves: 365 rows are too many to read and too many to draw as labels. Before drawing, you reduce them: one number for a KPI box, one total per month for 12 bars, one count per range for a histogram. Hand-written loops for these are easy to get slightly wrong (an empty list, a
NaN). - 2
One column, one number.
d3.sum,d3.mean,d3.median,d3.min,d3.max,d3.extent(the smallest and biggest as a pair, the input of every scale in Phase 6) andd3.quantile(values, 0.25)take an array and an optional accessor(d) => d.rides. For Lumen's 365 days: total 933,651, mean 2,557.9, median 2,484, extent[439, 4380].d3.greatest(rows, (d) => d.rides)returns the whole row (the busiest day, 18 July) whered3.maxreturns only the number. - 3
Group and rollup.
d3.group(rows, key)returns aMapfrom each key to its rows, andd3.rollup(rows, reducer, key)replaces each group's rows with one summary:d3.rollup(days, (v) => d3.sum(v, (d) => d.rides), (d) => monthName(d.date))gives the 12 monthly totals (Jan 43,439 … Jul 117,803 … Dec 45,376).d3.index(rows, key)is a lookup table by a unique key:byDate.get("2025-08-14").ridesis 439, and a key that is not there givesundefined. All three return aMap, so[...map]gives[key, value]pairs in the order the keys first appeared. To order rows, used3.sort(rows, (a, b) => d3.descending(a.rides, b.rides))(biggest first;d3.ascendingfor smallest first). It returns a new array, whilearray.sortrearranges the original and, with no comparator, sorts as text (Topic 4.3). - 4
Bin for histograms.
d3.bin().value(accessor).thresholds([...])(rows)sorts rows into ranges. Each bin is an array of rows withx0(inclusive start) andx1(exclusive end, except the last bin, which includes its end). The example uses thresholds 500, 1000, … 4000, and the 365 days spread over 9 bins, with 1,500 to 2,000 the fullest (85 days). The counts always add up to the number of rows: check that, it catches values that fell outside. - 5
Edge cases are the real lesson. For an empty array
d3.sumis 0, butd3.mean,d3.maxandd3.medianreturnundefinedandd3.extentreturns[undefined, undefined]. A scale built from that domain drawsNaN.Math.max(...[])is-Infinity. A single value:d3.median([5])is 5. Arrays holdingnullorNaN:d3.mean([4, null, 8, NaN])is 6 because d3-array skips them, whiled3.sum([4, null, 8, NaN])is 12 and a hand-writtenreducegivesNaN. All of this is in the first example's table. - 6
Text is the quiet trap.
d3.maxandd3.extentcompare as they are, so the strings"4380"and"910"give"910"as the maximum (Topic 4.2), whiled3.sumconverts with+and still works. Convert first. And becaused3.meanskipsnull, a column full of missing values returnsundefined: say "no data", do not draw 0. - 7
Numbers for people.
d3.format(",")(933651)is933,651,d3.format("~s")is933.651kandd3.format(".1%")(0.2863)is28.6%.Intl.NumberFormat(locale)follows the reader's habits:en-INgroups as9,33,651andde-DEwrites933.651. Use one of them everywhere (that issrc/utils/format.js), nevertoFixedon its own for display.
Explain it without notes
What does d3.mean([]) return, and what should a dashboard show then?
What is the difference between d3.group and d3.rollup?
Why can d3.sum be right and d3.max wrong on the same array of strings?
Practice
Using daily.csv and d3.rollup, find the month with the most rides and print its name and total.
Write summary(values) that returns { count, min, median, max } and returns null for an empty array.
Trade-offs
- ↔
d3-array functions skip bad values silently, which keeps charts alive and can hide a data problem. Count what was skipped when the number matters.
Done when you can
I can name what each of sum, mean, median, extent and quantile returns for an empty array.
I can make monthly totals with rollup and a histogram with bin.
I convert text to numbers before max, min and extent.
I format numbers in one place with d3.format or Intl.NumberFormat.