Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~20 min· topic 5 of 7

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.rides that 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.

Group, index and bindiagram
Rendering diagram…

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. 1

    Change the bin edges

    In the second example use d3.range(0, 4500, 1000) as thresholds. The bins are 439 to 1000 (4 days), 1000 to 2000 (130), 2000 to 3000 (99), 3000 to 4000 (104) and 4000 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. 2

    Summarise a different column

    In the first example print d3.sum(days, (d) => d.ebike_rides) after loading. It prints 310498. Then d3.mean(days, (d) => d.temp_c) prints about 17.48, the average temperature.

Code & diagrams

One column, and what happens at the edges New tab

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

Press Run to see the page.

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'])             910
Group, rollup, index, bin and format New tab

Monthly 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.651

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

Take the maximum of an empty list

In the first example add console.log("Max of nothing:", Math.max(...[])); and console.log(d3.extent([]));.

terminal
$ Console
── what you'll see ──
Max of nothing: -Infinity
[ undefined, undefined ]

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.rollup with several keys nests Maps (rollup(rows, reducer, keyA, keyB)); d3.rollups returns arrays of pairs instead, which is easier to give to data() in Phase 5.

  • ▸

    d3.bin is a histogram generator with domain() and thresholds() options; d3.thresholdSturges and friends pick edges automatically, but fixed round edges (500, 1000, …) read better on an axis.

Remember this

  1. 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. 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) and d3.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) where d3.max returns only the number.

  3. 3

    Group and rollup. d3.group(rows, key) returns a Map from each key to its rows, and d3.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").rides is 439, and a key that is not there gives undefined. All three return a Map, so [...map] gives [key, value] pairs in the order the keys first appeared. To order rows, use d3.sort(rows, (a, b) => d3.descending(a.rides, b.rides)) (biggest first; d3.ascending for smallest first). It returns a new array, while array.sort rearranges the original and, with no comparator, sorts as text (Topic 4.3).

  4. 4

    Bin for histograms. d3.bin().value(accessor).thresholds([...])(rows) sorts rows into ranges. Each bin is an array of rows with x0 (inclusive start) and x1 (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. 5

    Edge cases are the real lesson. For an empty array d3.sum is 0, but d3.mean, d3.max and d3.median return undefined and d3.extent returns [undefined, undefined]. A scale built from that domain draws NaN. Math.max(...[]) is -Infinity. A single value: d3.median([5]) is 5. Arrays holding null or NaN: d3.mean([4, null, 8, NaN]) is 6 because d3-array skips them, while d3.sum([4, null, 8, NaN]) is 12 and a hand-written reduce gives NaN. All of this is in the first example's table.

  6. 6

    Text is the quiet trap. d3.max and d3.extent compare as they are, so the strings "4380" and "910" give "910" as the maximum (Topic 4.2), while d3.sum converts with + and still works. Convert first. And because d3.mean skips null, a column full of missing values returns undefined: say "no data", do not draw 0.

  7. 7

    Numbers for people. d3.format(",")(933651) is 933,651, d3.format("~s") is 933.651k and d3.format(".1%")(0.2863) is 28.6%. Intl.NumberFormat(locale) follows the reader's habits: en-IN groups as 9,33,651 and de-DE writes 933.651. Use one of them everywhere (that is src/utils/format.js), never toFixed on its own for display.

Explain it without notes

01

What does d3.mean([]) return, and what should a dashboard show then?

02

What is the difference between d3.group and d3.rollup?

03

Why can d3.sum be right and d3.max wrong on the same array of strings?

Practice

01

Using daily.csv and d3.rollup, find the month with the most rides and print its name and total.

02

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.