Animated Joins

Animating the Data Join on Update

Create one transition and pass it to each set with transition(t), so enter, update and exit share its timing; exits fade before remove(), which on a transition waits for the end. This page switches from January-August 2026 to the same months of 2025:

An animated keyed join halfway from 2026 to 2025 (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>
<svg width="620" height="112"></svg>
<script>
  const janToAug = year => SALES.books.map(b => ({ ...b, units: d3.sum(SALES.sales, s =>
    s.book === b.id && s.month >= `${year}-01` && s.month <= `${year}-08` ? s.units : 0) }));
  const y = d3.scaleBand(SALES.books.map(b => b.id), [0, 112]).padding(0.15);
  function update(data, ms) {
    const t = d3.transition().duration(ms);
    d3.select('svg').selectAll('rect').data(data.filter(d => d.units), d => d.id)
      .join(enter => enter.append('rect').attr('fill', d => d.color).attr('width', 0),
        update => update, exit => exit.transition(t).style('opacity', 0).remove())
      .attr('y', d => y(d.id)).attr('height', y.bandwidth())
      .transition(t).attr('width', d => d.units);
  }
  update(janToAug(2026), 0);
  d3.timeout(() => update(janToAug(2025), 1000), 100);           // start the update at 100 ms
  d3.timeout(() => { d3.selectAll('rect').interrupt(); window.__done = true; }, 600);
</script>
Browser output of Listing 6.26
Browser output of 26

In this single frame, 500 ms in, Small Steps to Big Summits (green, no 2025 sales) fades out while The Quiet Harbor (top) grows from 415 toward 488 units and the others shrink.

One shared transition animates enter, update and exit when the year changesHTMLLive
<!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>January to August of <button id="y2025">2025</button> <button id="y2026">2026</button> <label><input type="checkbox" id="auto" checked> alternate every 2 s</label></p>
<svg viewBox="0 0 600 150" font-size="12"></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 janToAug = year => SALES.books.map(b => ({ ...b, units: d3.sum(SALES.sales, s =>
    s.book === b.id && s.month >= `${year}-01` && s.month <= `${year}-08` ? s.units : 0) }));
  const y = d3.scaleBand(SALES.books.map(b => b.id), [20, 150]).padding(0.15);
  const x = d3.scaleLinear([0, 500], [150, 580]);
  const svg = d3.select('svg');
  const title = svg.append('text').attr('y', 12).attr('font-weight', 'bold');

  function update(year, ms = 900) {
    title.text(`Units sold, January to August ${year}`);
    const t = d3.transition().duration(ms);                         // one transition shared by all three sets
    svg.selectAll('g.bar').data(janToAug(year).filter(d => d.units), d => d.id)
      .join(
        enter => {
          const g = enter.append('g').attr('class', 'bar').style('opacity', 0);
          g.append('rect').attr('x', x(0)).attr('fill', d => d.color).attr('width', 0);
          g.append('text').attr('class', 'n').attr('x', x(0) - 6).attr('text-anchor', 'end').text(d => d.title);
          g.append('text').attr('class', 'v').attr('x', x(0));
          return g;
        },
        update => update,
        exit => exit.transition(t).style('opacity', 0).remove())    // remove() waits for the end
      .attr('transform', d => `translate(0,${y(d.id)})`)
      .call(g => g.transition(t).style('opacity', 1))
      .call(g => g.select('rect').attr('height', y.bandwidth()).transition(t).attr('width', d => x(d.units) - x(0)))
      .call(g => g.select('.n').attr('y', y.bandwidth() / 2).attr('dy', '0.35em'))
      .call(g => g.select('.v').attr('y', y.bandwidth() / 2).attr('dy', '0.35em').text(d => d.units)
        .transition(t).attr('x', d => x(d.units) + 4));
  }
  let year = 2026;
  d3.select('#y2025').on('click', () => update(year = 2025));
  d3.select('#y2026').on('click', () => update(year = 2026));
  update(2026, 0);
  d3.interval(() => { if (d3.select('#auto').property('checked')) update(year = year === 2026 ? 2025 : 2026); }, 2000);
</script>