Faceting and Small Multiples

Small multiples give each series a panel on shared scales; in Plot 40,840 that is two more channels: fx and fy split the data into columns and rows of facets, here a 3 x 2 grid:

Six small multiples on one shared y scale (sample data)HTML
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.17/dist/plot.umd.min.js">
</script>
<script src="booknest-sales.js"></script><script src="booknest-prep.js"></script>
<div id="chart"></div>
<script>
  const { books, sales } = prepareSales(SALES);
  const col = b => (b.id - 1) % 3, row = b => Math.floor((b.id - 1) / 3);    // a 3 x 2 grid
  const cell = { x: 'date', y: 'units', fx: d => col(d.book), fy: d => row(d.book) };
  document.getElementById('chart').append(Plot.plot({
    width: 620, height: 210, marginTop: 10, marginBottom: 34,
    fx: { axis: null }, fy: { axis: null }, x: { ticks: 4, label: null },
    y: { domain: [0, 120], ticks: 3, grid: true, label: null },
    marks: [Plot.frame({ stroke: '#ccc' }),
      Plot.areaY(sales, { ...cell, fill: d => d.book.color, fillOpacity: 0.25 }),
      Plot.lineY(sales, { ...cell, stroke: d => d.book.color }),
      Plot.text(books, { text: 'title', fx: col, fy: row, frameAnchor: 'top-left',
        dx: 4, dy: 4 })
    ]
  }));
</script>
Browser output of Listing 6.60
Browser output of 60

Every mark repeats in every facet, filtered to the rows whose fx and fy match, so the text mark, fed the six books, labels the panels. A channel of CSS colors (d.book.color) is used as is, with no color scale.

Six small multiples on shared scales from the fx and fy facet channelsHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@observablehq/plot@0.6.17/dist/plot.umd.min.js"></script>
<p>Grid: <label><input type="radio" name="g" value="3" checked> 3 × 2</label> <label><input type="radio" name="g" value="2"> 2 × 3</label>
  <label><input type="radio" name="g" value="6"> 6 × 1</label></p>
<div id="chart"></div>
<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 { books, sales } = prepareSales(SALES);
  function draw(cols) {
    const col = b => (b.id - 1) % cols, row = b => Math.floor((b.id - 1) / cols);
    const cell = { x: 'date', y: 'units', fx: d => col(d.book), fy: d => row(d.book) };   // two more channels
    const chart = Plot.plot({
      width: 580, height: cols === 6 ? 130 : cols === 3 ? 230 : 330, marginTop: 10, marginBottom: 30,
      fx: { axis: null }, fy: { axis: null }, x: { ticks: 3, label: null },
      y: { domain: [0, 130], ticks: 3, grid: true, label: null },
      marks: [
        Plot.frame({ stroke: '#ccc' }),
        Plot.areaY(sales, { ...cell, fill: d => d.book.color, fillOpacity: 0.25 }),   // CSS colors used as is
        Plot.lineY(sales, { ...cell, stroke: d => d.book.color }),
        // The text mark gets the six books, so each facet gets its own label
        Plot.text(books, { text: b => cols === 6 ? b.key : b.title, fx: col, fy: row, frameAnchor: 'top-left', dx: 4, dy: 4 }),
      ],
    });
    document.getElementById('chart').replaceChildren(chart);
  }
  document.querySelectorAll('input[name=g]').forEach(r => r.addEventListener('change', () => draw(+r.value)));
  draw(3);
</script>