Plot's Grammar

The Grammar of Marks, Scales and Transforms

A Plot 40,840 chart is a list of marks (dots, lines, bars, areas, rules, text: about thirty types), each drawing one dataset. Mark options are channels that map a field name or a function of the row to x, y, r, fill and so on. Plot gathers the channels bound to each scale, infers its type and domain, and draws the axes and legends. Plot.plot({ marks: [Plot.dot(books, { x: 'price', y: 'units', r: 'revenue' })] }) is Axes and Legends's scatter plot without an append; chart.scale('r') reports {type: "pow", exponent: 0.5, domain: [0, 32350.5]}, a square root from zero, so circle area tracks revenue as Linear, Power and Log Scales advised.

Transforms derive data inside a mark's options, replacing d3.rollup steps: groupX and groupY reduce rows per category, binX makes histograms, stackY stacks (implicitly, in bars and areas), windowY computes moving averages and normalizeY rescales each series to a basis such as its first value.

Observable Plot's grammar: a dot mark with x, y, r and fill channels, and the scales Plot inferredHTMLLive
<!doctype html>
<style>
  body { margin: 0; padding: 8px; background: #fafaf7; font: 12px system-ui, sans-serif; color: #263238; }
  pre { margin: 4px 0; font-size: 11px; white-space: pre-wrap; }
</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>Add marks: <label><input type="checkbox" id="text" checked> Plot.text</label>
  <label><input type="checkbox" id="rule" checked> Plot.ruleY (mean)</label>
  <label><input type="checkbox" id="lin"> Plot.linearRegressionY</label></p>
<div id="chart"></div>
<pre id="scales"></pre>
<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 } = prepareSales(SALES);
  function draw() {
    const on = id => document.getElementById(id).checked;
    // A chart is a list of marks; each option is a channel mapping a field (or function) to a scale
    const chart = Plot.plot({
      width: 580, height: 230, grid: true, inset: 20,
      x: { label: 'price ($)' }, y: { label: 'units sold' }, r: { range: [0, 22] },
      marks: [
        Plot.dot(books, { x: 'price', y: 'units', r: 'revenue', fill: 'color', fillOpacity: 0.8, tip: true, title: 'title' }),
        on('text') ? Plot.text(books, { x: 'price', y: 'units', text: 'key', dy: -24 }) : null,
        on('rule') ? Plot.ruleY([d3.mean(books, b => b.units)], { stroke: '#e09a10', strokeDasharray: '4 3' }) : null,
        on('lin') ? Plot.linearRegressionY(books, { x: 'price', y: 'units', stroke: '#b5452f' }) : null,
      ],
    });
    document.getElementById('chart').replaceChildren(chart);
    // Plot gathered the channels bound to each scale and inferred type and domain
    const r = chart.scale('r'), x = chart.scale('x');
    document.getElementById('scales').textContent =
      `scale('r'): ${JSON.stringify({ type: r.type, exponent: r.exponent, domain: r.domain.map(Math.round) })}   ` +
      `scale('x'): ${JSON.stringify({ type: x.type, domain: x.domain })}`;
  }
  document.querySelectorAll('input').forEach(i => i.addEventListener('change', draw));
  draw();
</script>