Dragging and Alpha

Dragging Nodes and Tuning Alpha Decay

The temperature alpha starts at 1, moves toward alphaTarget (0) by alphaDecay each tick, scales every force, and stops the timer below alphaMin (0.001). The default decay, 1 - 0.001^(1/300), gives 300 ticks; counted in Node.js 2,131 , 0.05 gives 135 and 0.01 gives 688, settling faster or finding better layouts. Dragging reheats: raise alphaTarget and pin the node with fx/fy (copied into x/y every tick), then unpin:

Dragging network nodes with d3-dragJavaScript
const { simulation, node } = bookNetwork(d3.select('svg'), prepareSales(SALES));
node.call(d3.drag()
  .on('start', (event, n) => {
    if (!event.active) simulation.alphaTarget(0.3).restart();       // reheat
    n.fx = n.x; n.fy = n.y; })                                        // pin
  .on('drag', (event, n) => { n.fx = event.x; n.fy = event.y; })
  .on('end', (event, n) => {
    if (!event.active) simulation.alphaTarget(0);                    // cool down
    n.fx = n.fy = null; }));                                          // release

d3-drag's default subject is the datum's x, y. In headless Chrome 1 , a scripted mousedown on Small Steps to Big Summits and a mousemove pinned it at fx 72.1, fy 37.2 with alphaTarget 0.3 (alpha climbed to 0.227 in one second); a mouseup cleared fx and reset alphaTarget. event.active counts gestures, so simultaneous drags reheat once.

Drag a book node: the simulation reheats with alphaTarget and pins it with fx and fyHTMLLive
<!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; background: #fff; }
  g.node { cursor: grab; }
  canvas { display: block; }
</style>
<script src="https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"></script>
<p>alphaDecay <select id="decay"><option value="0.0228">0.0228 (default, 300 ticks)</option><option value="0.05">0.05 (135 ticks)</option>
  <option value="0.01">0.01 (688 ticks)</option></select> <button id="shake">Reheat</button> <span id="alpha"></span></p>
<svg width="600" height="220" font-size="11" text-anchor="middle"></svg>
<canvas id="spark" width="600" height="40"></canvas>
<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, links } = prepareSales(SALES), svg = d3.select('svg');
  const nodes = books.map(b => ({ ...b })), edges = links.map(l => ({ ...l }));
  const r = d3.scaleSqrt([0, d3.max(nodes, n => n.units)], [0, 22]);
  const sim = d3.forceSimulation(nodes)
    .force('link', d3.forceLink(edges).id(n => n.id).distance(l => 150 - 2 * l.value))
    .force('charge', d3.forceManyBody().strength(-400))
    .force('collide', d3.forceCollide(n => r(n.units) + 20))
    .force('center', d3.forceCenter(300, 110)).force('y', d3.forceY(110).strength(0.15));
  const line = svg.append('g').attr('stroke', '#b0bec5').selectAll('line').data(edges).join('line').attr('stroke-width', l => l.value / 6);
  const node = svg.append('g').selectAll('g').data(nodes).join('g').attr('class', 'node');
  node.append('circle').attr('r', n => r(n.units)).attr('fill', n => n.color);
  node.append('text').attr('y', n => r(n.units) + 12).text(n => n.key);

  // Plot alpha over time on a small canvas
  const spark = document.getElementById('spark').getContext('2d'), history = [];
  sim.on('tick', () => {
    line.attr('x1', l => l.source.x).attr('y1', l => l.source.y).attr('x2', l => l.target.x).attr('y2', l => l.target.y);
    node.attr('transform', n => `translate(${n.x},${n.y})`);
    history.push(sim.alpha());  if (history.length > 300) history.shift();
    spark.clearRect(0, 0, 600, 40);  spark.fillStyle = '#e09a10';
    history.forEach((a, i) => spark.fillRect(i * 2, 40 - a * 38, 2, a * 38));
    d3.select('#alpha').text(`alpha ${sim.alpha().toFixed(3)}, target ${sim.alphaTarget()}`);
  });

  node.call(d3.drag()
    .on('start', (event, n) => {
      if (!event.active) sim.alphaTarget(0.3).restart();       // reheat once per gesture
      n.fx = n.x;  n.fy = n.y;                                   // pin
    })
    .on('drag', (event, n) => { n.fx = event.x;  n.fy = event.y; })
    .on('end', (event, n) => {
      if (!event.active) sim.alphaTarget(0);                    // cool down
      n.fx = n.fy = null;                                        // release
    }));
  d3.select('#decay').on('change', e => sim.alphaDecay(+e.target.value).alpha(1).restart());
  d3.select('#shake').on('click', () => sim.alpha(1).restart());
</script>