Building a Stable, Animated Treemap Grid with D3 for H5 Voting UI

This article explains how to use D3's treemap layouts—including binary, dice, slice, slice‑dice, squarify, and resquarify—to create a dynamic, stable grid animation for a voting H5 activity, covering algorithm stability, data offset handling, and visual transition techniques.

NetEase Cloud Music Tech Team
NetEase Cloud Music Tech Team
NetEase Cloud Music Tech Team
Building a Stable, Animated Treemap Grid with D3 for H5 Voting UI

Introduction

This summary describes how to create a stable, dynamic treemap‑based grid animation similar to the NetEase Cloud Music H5 voting activity. The grid visualizes weighted values as rectangular blocks that continuously “squeeze” using canvas rendering and requestAnimationFrame.

Treemap Exploration

Given a set of numeric values (e.g., 18 random numbers), the goal is to map each weight onto a fixed‑size canvas and render rectangles whose areas reflect the values. The approach mirrors the webpack‑bundle‑analyzer visualization.

Algorithm Stability

Treemap layouts were introduced by Ben Shneiderman (1992) for hierarchical data. Different layout strategies trade off aspect‑ratio quality and node ordering stability.

Common Treemap Layouts

Binary Split (treemapBinary)

function partition(i, j, value, x0, y0, x1, y1) {
  while (k < hi) {
    var mid = k + hi >>> 1;
    if (sums[mid] < valueTarget) k = mid + 1;
    else hi = mid;
  }
  if ((valueTarget - sums[k - 1]) < (sums[k] - valueTarget) && i + 1 < k) --k;
  var valueLeft = sums[k] - valueOffset,
      valueRight = value - valueLeft;
  if ((x1 - x0) > (y1 - y0)) { // wide rectangle
    var xk = value ? (x0 * valueRight + x1 * valueLeft) / value : x1;
    partition(i, k, valueLeft, x0, y0, xk, y1);
    partition(k, j, valueRight, xk, y0, x1, y1);
  } else { // tall rectangle
    var yk = value ? (y0 * valueRight + y1 * valueLeft) / value : y1;
    partition(i, k, valueLeft, x0, y0, x1, yk);
    partition(k, j, valueRight, x0, yk, x1, y1);
  }
}

Dice (treemapDice)

export default function(parent, x0, y0, x1, y1) {
  var nodes = parent.children,
      node,
      i = -1,
      n = nodes.length,
      k = parent.value && (x1 - x0) / parent.value;
  while (++i < n) {
    (node = nodes[i]), (node.y0 = y0), (node.y1 = y1);
    (node.x0 = x0), (node.x1 = x0 += node.value * k);
  }
}

Slice (treemapSlice)

export default function(parent, x0, y0, x1, y1) {
  var nodes = parent.children,
      node,
      i = -1,
      n = nodes.length,
      k = parent.value && (x1 - x0) / parent.value;
  while (++i < n) {
    (node = nodes[i]), (node.y0 = y0), (node.y1 = y1);
    (node.x0 = x0), (node.x1 = x0 += node.value * k);
  }
}

Slice‑Dice (treemapSliceDice)

export default function(parent, x0, y0, x1, y1) {
  (parent.depth & 1 ? slice : dice)(parent, x0, y0, x1, y1);
}

Squarify (treemapSquarify)

export function squarifyRatio(ratio, parent, x0, y0, x1, y1) {
  while (i0 < n) {
    do sumValue = nodes[i1++].value;
    minValue = maxValue = sumValue;
    alpha = Math.max(dy / dx, dx / dy) / (value * ratio);
    beta = sumValue * sumValue * alpha;
    minRatio = Math.max(maxValue / beta, beta / minValue);
    for (; i1 < n; ++i1) {
      sumValue += nodeValue = nodes[i1].value;
      if (nodeValue < minValue) minValue = nodeValue;
      if (nodeValue > maxValue) maxValue = nodeValue;
      beta = sumValue * sumValue * alpha;
      newRatio = Math.max(maxValue / beta, beta / minValue);
      if (newRatio > minRatio) { sumValue -= nodeValue; break; }
      minRatio = newRatio;
    }
  }
}

Resquarify (treemapResquarify)

function resquarify(parent, x0, y0, x1, y1) {
  if ((rows = parent._squarify) && rows.ratio === ratio) {
    var rows, row, nodes, i, j = -1, n, m = rows.length, value = parent.value;
    while (++j < m) {
      (row = rows[j]), (nodes = row.children);
      for (i = row.value = 0, n = nodes.length; i < n; ++i) row.value += nodes[i].value;
      if (row.dice)
        treemapDice(row, x0, y0, x1, value ? (y0 += ((y1 - y0) * row.value) / value) : y1);
      else
        treemapSlice(row, x0, y0, value ? (x0 += ((x1 - x0) * row.value) / value) : x1, y1);
      value -= row.value;
    }
  } else {
    parent._squarify = rows = squarifyRatio(ratio, parent, x0, y0, x1, y1);
    rows.ratio = ratio;
  }
}

Demo and Animation

A demo uses treemapSquarify on a random data set, then applies a time‑based offset (via Math.sin and Math.cos) to modify the values each frame. The updated data is fed back into the treemap algorithm, and the resulting rectangles are drawn on a canvas with requestAnimationFrame.

const builtGraphCanvas = () => {
  treeMapAniLoop();
  requestAnimationFrame(builtGraphCanvas);
};

function treeMapAniLoop() {
  time += 0.02;
  for (let i = 0; i < dataInput.length; i++) {
    const inc = i % 2 === 0 ? Math.sin(time + i) : Math.cos(time + i);
    dataInput[i].value = vote[i] + 0.2 * vote[vote.length - 1] * inc * inc;
  }
  const result = getTreemap({
    data: dataInput,
    width: canvasWidth,
    height: canvasHeight,
  });
  // draw rectangles using result.x, result.y, result.width, result.height
}

Transition animations (opening, selection, deselection) interpolate rectangle properties with a Bezier easing function.

function e2(t) {
  return BezierEasing(0.25, 0.1, 0.25, 1.0)(t);
}

if (this.time0 > 1 + timeOffset * result.length) {
  this.time4 += 1.5 * aniSpeed;
  const easing = this.e2(this.time4);
  const start = this.cloneDeep(this.result);
  this.setTagByResult(start, this.initialZeroResult, easing);
}

function setTagByResult(start, end, easing) {
  for (let i = 0; i < this.result.length; i++) {
    this.result[i].x = start[i].x + easing * (end[i].x - start[i].x);
    this.result[i].y = start[i].y + easing * (end[i].y - start[i].y);
    this.result[i].width = start[i].width + easing * (end[i].width - start[i].width);
    this.result[i].height = start[i].height + easing * (end[i].height - start[i].height);
  }
}

Reordering Issues

When input values change dramatically, treemapSquarify may assign different positions to the same logical node, causing visible “jumps”. This is due to the greedy nature of the algorithm, which selects the current best aspect‑ratio split without preserving node order.

Solution with Resquarify

Switching to treemapResquarify keeps node order after the initial layout, providing stable positions even when values change slightly. The D3 configuration sets the tile function to d3.treemapResquarify.ratio(1).

this.treeMap = d3.treemap()
  .size([size.canvasWidth - size.arrowSize * 2, size.canvasHeight - size.arrowSize * 2])
  .padding(0)
  .round(true)
  .tile(d3.treemapResquarify.ratio(1));

Extreme Cases and Value Scaling

When vote counts differ by large multiples (e.g., 20 vs 0 or 140 vs 0), the layout becomes chaotic. A three‑stage scaling method compresses extreme values using a hyperbolic tangent function and applies different quotas per stage.

const reviseMethod = i => Math.tanh(i / 20) * 26;
const computeVote = (vote, quota) => base + (vote / minVote - 1) * quota;

const stage1 = 10, stage2 = 30;
const ceilStage1 = 600, ceilStage2 = 1000, ceilStage3 = 1300;
let quota;
const curMultiple = maxVote / minVote;
const data = voteList.map(i => {
  let finalVote;
  if (curMultiple <= stage1 + 1) {
    quota = (ceilStage1 - base) / stage1;
    finalVote = computeVote(reviseMethod(i), quota);
  } else if (curMultiple <= stage2 + 1) {
    quota = (ceilStage2 - base) / stage2;
    finalVote = computeVote(i, quota);
  } else {
    quota = ceilStage3 / curMultiple;
    finalVote = computeVote(i, quota);
  }
  return finalVote;
});

Other Practical Issues

Label overlap handling and reliable canvas screenshots are addressed by adding crossOrigin="anonymous" to image elements or converting images to base64 before drawing with html2canvas.

Conclusion

For a treemap‑based grid animation, prefer treemapResquarify to maintain node ordering stability, and apply a staged value‑scaling function to keep extreme data ranges visually manageable. These techniques yield smooth, continuous “squeezing” animations without disruptive jumps.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

frontendanimationCanvasstabilityvisualizationd3TreeMap
NetEase Cloud Music Tech Team
Written by

NetEase Cloud Music Tech Team

Official account of NetEase Cloud Music Tech Team

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.