d3.group(data, ...keys) nests rows into Maps, one level per key; d3.rollup(data, reduce, ...keys) reduces each group to a value (rollups and groups return [key, value] arrays), and d3.bin counts values into intervals for a histogram:
import { readFileSync } from 'node:fs';
import { bin, group, rollup, sum } from 'd3';
const { books, sales } = JSON.parse(readFileSync('booknest-sales.json', 'utf8'));
const key = new Map(books.map(b => [b.id, b.key])); // 1 -> 'harbor'
const units = rollup(sales, v => sum(v, d => d.units), d => key.get(d.book)); // InternMap
console.log(JSON.stringify(Object.fromEntries(units)));
const byYear = group(sales, d => d.month.slice(0, 4), d => key.get(d.book));
console.log(byYear.get('2026').get('summit').length, 'summit rows in 2026');
const bins = bin().value(d => d.units).thresholds([40, 60, 80])(sales);
console.log(bins.map(b => `[${b.x0}, ${b.x1}): ${b.length}`).join(' '));Output
{"harbor":1164,"deepweb":819,"saffron":1040,"clock":1218,"glass":730,"summit":454}
6 summit rows in 2026
[22, 40): 19 [40, 60): 62 [60, 80): 21 [80, 100): 4Results are InternMaps, which key by value, so equal Dates share a group; keys keep first-appearance order (summit is last). The bins span the data's minimum (22) to maximum, the last one including the 100-unit launch month. d3.groupSort orders keys by a summary, and d3.index (Areas) maps unique keys.
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
svg { width: 100%; max-width: 600px; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p>Year: <select id="year"><option>all</option><option>2025</option><option>2026</option></select>
Bin thresholds: <input type="range" id="bins" min="3" max="15" value="8"></p>
<svg viewBox="0 0 600 200" font-size="11"></svg>
<script>
// BookNest sample data, invented for these examples (not real sales)
const SALES = (() => {
const books = [
{ id: 1, key: 'harbor', title: 'The Quiet Harbor', author: 'Elena Marsh', genre: 'Fiction', color: '#1f5f8b', price: 18.99, rating: 4.6 },
{ id: 2, key: 'deepweb', title: 'Patterns of the Deep Web', author: 'Ravi Menon', genre: 'Technology', color: '#5b3f99', price: 39.5, rating: 4.3 },
{ id: 3, key: 'saffron', title: 'Salt and Saffron', author: 'Leila Haddad', genre: 'Cooking', color: '#e09a10', price: 24, rating: 4.8 },
{ id: 4, key: 'summit', title: 'Small Steps to Big Summits', author: 'Tom Okafor', genre: 'Self-Help', color: '#3f7d3a', price: 14.99, rating: 4.1 },
{ id: 5, key: 'clock', title: "The Clockmaker's Paradox", author: 'Iris Vale', genre: 'Science Fiction', color: '#b5452f', price: 16.2, rating: 4.5 },
{ id: 6, key: 'glass', title: 'Gardens in Glass', author: 'June Park', genre: 'Gardening', color: '#2a9d8f', price: 22.5, rating: 4.4 },
];
const units = { // units sold per month, January 2025 to August 2026
1: [40, 47, 46, 47, 57, 51, 46, 54, 60, 60, 71, 111, 52, 42, 49, 56, 59, 48, 53, 51],
2: [40, 37, 35, 41, 48, 43, 40, 42, 50, 53, 53, 84, 42, 35, 38, 41, 45, 41, 46, 46],
3: [46, 45, 41, 46, 53, 49, 46, 45, 60, 51, 63, 100, 48, 48, 49, 48, 53, 49, 48, 52],
4: [100, 80, 72, 70, 66, 66], // released March 2026
5: [48, 47, 54, 52, 65, 55, 57, 58, 64, 66, 75, 124, 58, 49, 59, 56, 63, 57, 54, 57],
6: [31, 30, 28, 35, 31, 32, 29, 30, 41, 36, 45, 64, 31, 30, 30, 38, 40, 38, 36, 38],
};
const months = d3.utcMonths(new Date('2025-01-01'), new Date('2026-09-01')).map(d3.utcFormat('%Y-%m'));
const sales = books.flatMap(b => units[b.id].map((n, i) =>
({ month: months[i + months.length - units[b.id].length], book: b.id, units: n })));
const ratings = [[4, 8, 20, 87, 293], [6, 10, 22, 95, 147], [2, 3, 8, 45, 342], [2, 3, 8, 24, 28],
[4, 8, 20, 90, 228], [4, 7, 20, 82, 147]].map((counts, i) => ({ book: i + 1, counts })); // 1 to 5 stars
const regions = [{ id: '826', name: 'United Kingdom', units: 1480 }, { id: '276', name: 'Germany', units: 1120 },
{ id: '250', name: 'France', units: 760 }, { id: '372', name: 'Ireland', units: 540 },
{ id: '528', name: 'Netherlands', units: 410 }, { id: '724', name: 'Spain', units: 320 }];
const links = [[5, 1, 38], [1, 3, 24], [2, 5, 18], [3, 6, 21], [4, 1, 12], [2, 6, 9], [4, 3, 15], [5, 6, 14]]
.map(([source, target, value]) => ({ source, target, value })); // customers who bought both
return { books, sales, ratings, regions, links };
})();
const key = new Map(SALES.books.map(b => [b.id, b]));
const svg = d3.select('svg');
const left = svg.append('g'), right = svg.append('g').attr('transform', 'translate(330,0)');
function draw() {
const year = d3.select('#year').property('value');
// group() nests rows into Maps, one level per key: year → book → rows
const byYear = d3.group(SALES.sales, d => d.month.slice(0, 4), d => d.book);
const rows = year === 'all' ? SALES.sales : [...byYear.get(year).values()].flat();
// rollup() reduces each group to one value: an InternMap of book id → units
const units = d3.rollup(rows, v => d3.sum(v, d => d.units), d => d.book);
const y = d3.scaleBand([...units.keys()], [10, 170]).padding(0.15);
const x = d3.scaleLinear([0, d3.max(units.values())], [0, 180]);
left.selectAll('rect').data(units).join('rect').attr('x', 110).attr('y', ([id]) => y(id))
.attr('height', y.bandwidth()).attr('fill', ([id]) => key.get(id).color).attr('width', ([, u]) => x(u));
left.selectAll('text').data(units).join('text').attr('x', 105).attr('y', ([id]) => y(id) + y.bandwidth() / 2)
.attr('dy', '0.35em').attr('text-anchor', 'end').text(([id, u]) => `${key.get(id).key} ${u}`);
// bin() counts values into intervals
const bins = d3.bin().value(d => d.units).thresholds(+d3.select('#bins').property('value'))(rows);
const bx = d3.scaleLinear([bins[0].x0, bins.at(-1).x1], [30, 260]);
const by = d3.scaleLinear([0, d3.max(bins, b => b.length)], [170, 14]).nice();
right.selectAll('rect').data(bins).join('rect').attr('fill', '#2a9d8f')
.attr('x', b => bx(b.x0) + 1).attr('width', b => Math.max(0, bx(b.x1) - bx(b.x0) - 2))
.attr('y', b => by(b.length)).attr('height', b => 170 - by(b.length));
right.selectAll('g.ax').data([0, 1]).join('g').attr('class', 'ax')
.attr('transform', i => i ? 'translate(30,0)' : 'translate(0,170)')
.each(function (i) { d3.select(this).call(i ? d3.axisLeft(by).ticks(4) : d3.axisBottom(bx).ticks(6)); });
right.selectAll('text.t').data(['months by units sold (d3.bin)']).join('text').attr('class', 't')
.attr('x', 30).attr('y', 196).text(d => d);
}
d3.selectAll('select, input').on('input', draw);
draw();
</script>