Sales Trend Chart

A Multi-Series Sales Trend Line Chart

d3.groups(sales, d => d.book) splits the rows into six [book, rows] pairs, one <path> each. End labels read faster than a legend, but two books finish two units apart, so a dodge keeps labels 13 px apart:

trendChart(): monthly units per book with dodged end labels (sample data)HTML
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script src="booknest-sales.js"></script><script src="booknest-prep.js"></script>
<svg id="trend" width="720" height="170" font-family="Arial" font-size="11"></svg>
<script>
function trendChart(svg, { sales, books }) {
  const W = +svg.attr('width'), H = +svg.attr('height'), m = { t: 10, r: 160, b: 20, l: 30 };
  const x = d3.scaleUtc(d3.extent(sales, d => d.date), [m.l, W - m.r]);
  const y = d3.scaleLinear([0, d3.max(sales, d => d.units)], [H - m.b, m.t]).nice();
  svg.append('g').attr('transform', `translate(0,${H - m.b})`).call(d3.axisBottom(x)
    .ticks(d3.utcMonth.every(3)).tickFormat(d3.utcFormat('%b %Y')));
  svg.append('g').attr('transform', `translate(${m.l},0)`).call(d3.axisLeft(y).ticks(5));
  const line = d3.line(d => x(d.date), d => y(d.units)).curve(d3.curveMonotoneX);
  const series = d3.groups(sales, d => d.book);                  // [[book, rows], ...]
  svg.append('g').attr('fill', 'none').attr('stroke-width', 2).selectAll('path')
    .data(series).join('path').attr('data-keys', ([b]) => b.key)
    .attr('stroke', ([b]) => b.color).attr('d', ([, rows]) => line(rows));
  const ends = series.map(([b, rows]) => ({ b, y: y(rows.at(-1).units) }))
    .sort((a, b) => a.y - b.y);
  ends.forEach((e, i) => { if (i) e.y = Math.max(e.y, ends[i - 1].y + 13); });  // dodge
  svg.append('g').selectAll('text').data(ends).join('text').attr('data-keys', e => e.b.key)
    .attr('x', W - m.r + 6).attr('y', e => e.y).attr('dy', '0.35em')
    .attr('fill', e => e.b.color).text(e => e.b.title);
}
trendChart(d3.select('#trend'), prepareSales(SALES));
</script>
Browser output of Listing 6.32
Browser output of 32

Every book peaks in December 2025. Small Steps to Big Summits (green) has no rows before its March 2026 release, so its line simply starts there: grouping tidy rows handles a late series without special cases. Beyond about eight series, small multiples sharing one y scale read better than a tangle of lines.

Six sales lines from d3.groups, with end labels dodged so they never overlapHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 11px system-ui, sans-serif; color: #263238; }
  svg { max-width: 100%; height: auto; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<svg id="trend" width="600" height="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 };
  })();
  // prepareSales(raw): parsed dates, sales linked to book objects, revenue and totals
  function prepareSales(raw) {
    const parse = d3.utcParse('%Y-%m'), books = raw.books.map(b => ({ ...b }));
    const byId = d3.index(books, b => b.id);
    const sales = raw.sales.map(s => ({ date: parse(s.month), book: byId.get(s.book),
      units: s.units, revenue: s.units * byId.get(s.book).price }));
    for (const [book, rows] of d3.group(sales, s => s.book)) {
      book.units = d3.sum(rows, s => s.units);
      book.revenue = d3.sum(rows, s => s.revenue);
    }
    const months = d3.rollups(sales, v => ({ units: d3.sum(v, s => s.units),
      revenue: d3.sum(v, s => s.revenue) }), s => +s.date).map(([t, v]) => ({ date: new Date(t), ...v }))
      .sort((a, b) => a.date - b.date);
    const ratings = raw.ratings.map(r => ({ ...r, book: byId.get(r.book) }));
    return { ...raw, books, sales, months, ratings };
  }

  function trendChart(svg, { sales }) {
    const W = +svg.attr('width'), H = +svg.attr('height'), m = { t: 10, r: 160, b: 20, l: 30 };
    const x = d3.scaleUtc(d3.extent(sales, d => d.date), [m.l, W - m.r]);
    const y = d3.scaleLinear([0, d3.max(sales, d => d.units)], [H - m.b, m.t]).nice();
    svg.append('g').attr('transform', `translate(0,${H - m.b})`).call(d3.axisBottom(x)
      .ticks(d3.utcMonth.every(3)).tickFormat(d3.utcFormat('%b %Y')));
    svg.append('g').attr('transform', `translate(${m.l},0)`).call(d3.axisLeft(y).ticks(5));
    const line = d3.line(d => x(d.date), d => y(d.units)).curve(d3.curveMonotoneX);
    const series = d3.groups(sales, d => d.book);                 // [[book, rows], ...]
    svg.append('g').attr('fill', 'none').attr('stroke-width', 2).selectAll('path')
      .data(series).join('path').attr('data-keys', ([b]) => b.key)
      .attr('stroke', ([b]) => b.color).attr('d', ([, rows]) => line(rows));
    // End labels, pushed down until each sits at least 13 px below the one above
    const ends = series.map(([b, rows]) => ({ b, y: y(rows.at(-1).units) })).sort((a, b) => a.y - b.y);
    ends.forEach((e, i) => { if (i) e.y = Math.max(e.y, ends[i - 1].y + 13); });
    svg.append('g').selectAll('text').data(ends).join('text').attr('data-keys', e => e.b.key)
      .attr('x', W - m.r + 6).attr('y', e => e.y).attr('dy', '0.35em')
      .attr('fill', e => e.b.color).text(e => e.b.title);
  }
  trendChart(d3.select('#trend'), prepareSales(SALES));
</script>