Designing the Dashboard Layout

Start from the questions, not the chart types. BookNest's owner asks three: how are sales moving? (a line per book), which books do readers like most? (mean rating, sorted) and where does the revenue come from? (share by genre). Time series want width, so the trend chart spans the top row of a CSS grid and the other two share the second.

Each chart is one function, chart(svg, data). It reads its <svg>'s width and height and applies Bostock's margin convention (plot area = size minus {t, r, b, l}), so it draws at any size. Colors come from the book objects (b.color), and every mark carries a data-keys attribute such as "saffron" for linking.

The dashboard's layout: a CSS grid of three charts, each drawn inside the margin conventionHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 11px system-ui, sans-serif; color: #263238; }
  #dash { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-width: 600px; }
  #dash > div { background: #fff; border: 1px solid #e0d8c8; border-radius: 6px; padding: 4px; }
  #dash > div:first-child { grid-column: 1 / -1; }          /* the trend chart spans the top row */
  h4 { margin: 0 0 2px; font-size: 12px; }
  svg { width: 100%; display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<div id="dash">
  <div><h4>How are sales moving? (a line per book)</h4><svg id="trend" viewBox="0 0 580 150"></svg></div>
  <div><h4>Which books do readers like most?</h4><svg id="ratings" viewBox="0 0 285 150"></svg></div>
  <div><h4>Where does the revenue come from?</h4><svg id="genre" viewBox="0 0 285 150"></svg></div>
</div>
<p><label><input type="checkbox" id="guides" checked> show margins (plot area = size minus {t, r, b, l})</label></p>
<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 };
  }

  const data = prepareSales(SALES);
  // Every chart is a function chart(svg, data) that reads its own size and applies the margins
  function frame(svg, m) {
    const [, , W, H] = svg.attr('viewBox').split(' ').map(Number);
    const g = svg.append('g').attr('class', 'guides');
    g.append('rect').attr('width', W).attr('height', H).attr('fill', '#fff3e0');
    g.append('rect').attr('x', m.l).attr('y', m.t).attr('width', W - m.l - m.r).attr('height', H - m.t - m.b)
      .attr('fill', '#fff').attr('stroke', '#e09a10').attr('stroke-dasharray', '4 3');
    g.append('text').attr('x', 3).attr('y', H / 2).text(`l ${m.l}`);
    g.append('text').attr('x', W - m.r + 3).attr('y', H / 2).text(`r ${m.r}`);
    return { W, H, x0: m.l, x1: W - m.r, y0: H - m.b, y1: m.t };
  }
  const trend = d3.select('#trend'), f1 = frame(trend, { t: 10, r: 50, b: 20, l: 30 });
  const x = d3.scaleUtc(d3.extent(data.sales, d => d.date), [f1.x0, f1.x1]);
  const y = d3.scaleLinear([0, 130], [f1.y0, f1.y1]);
  trend.append('g').attr('transform', `translate(0,${f1.y0})`).call(d3.axisBottom(x).ticks(6));
  trend.selectAll('path.s').data(d3.groups(data.sales, d => d.book)).join('path').attr('class', 's')
    .attr('fill', 'none').attr('stroke', ([b]) => b.color).attr('stroke-width', 1.5)
    .attr('d', ([, rows]) => d3.line(d => x(d.date), d => y(d.units))(rows));

  const ratings = d3.select('#ratings'), f2 = frame(ratings, { t: 6, r: 30, b: 6, l: 60 });
  const means = data.ratings.map(r => ({ b: r.book, m: d3.sum(r.counts, (c, i) => c * (i + 1)) / d3.sum(r.counts) }))
    .sort((a, b) => b.m - a.m);
  const yb = d3.scaleBand(means.map(d => d.b.key), [f2.y1, f2.y0]).padding(0.2);
  const xb = d3.scaleLinear([0, 5], [f2.x0, f2.x1]);
  ratings.selectAll('rect.b').data(means).join('rect').attr('class', 'b').attr('x', f2.x0).attr('y', d => yb(d.b.key))
    .attr('height', yb.bandwidth()).attr('width', d => xb(d.m) - f2.x0).attr('fill', d => d.b.color);

  const genre = d3.select('#genre'), f3 = frame(genre, { t: 8, r: 150, b: 8, l: 8 });
  const r = (f3.y0 - f3.y1) / 2;
  genre.append('g').attr('transform', `translate(${f3.x0 + r + 30},${f3.y1 + r})`).selectAll('path')
    .data(d3.pie().value(b => b.revenue)(data.books)).join('path').attr('fill', d => d.data.color)
    .attr('d', d3.arc().innerRadius(r * 0.58).outerRadius(r));
  d3.select('#guides').on('change', e => d3.selectAll('.guides').attr('opacity', e.target.checked ? 1 : 0));
</script>