d3-array

Grouping and Aggregating with d3-array

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:

group.mjs: rollup, group and bin on the sample salesJavaScript
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): 4

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

d3.rollup totals per book and d3.bin counts monthly sales into a histogramHTMLLive
<!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>