7
头图
Author of this article: curious

Preface

Some time ago, NetEase Cloud Music launched an h5 event based on acquaintances' social voting gameplay. This event divides grid blocks according to the weight of the number of votes, and greatly increases the fun by seamlessly squeezing dynamic effects between grid blocks. This article will focus on how to achieve a stable dynamic grid block extrusion effect based on treemap (rectangular tree map) and some of the problems encountered in it.

friendface4.gif

Rectangular tree diagram exploration

Enter a set of 18 random size values. How to map the weight values of this set of data on a two-dimensional plane and render 18 grids of different sizes on a fixed-size canvas? Visually, it can produce clearly distinguishable boundaries, which can be read at a glance. Which grids have the largest composition, and which grids seem to be insignificant? In the practice of front-end engineering, think of the webpack package and compilation scenario, using the webpack-bundle-analyzer plug-in will generate a visual rectangular tree diagram containing all the packaged files. Trying to use a rectangular tree diagram may be an idea, as shown in the following figure:

webpack-analyzer

Algorithm stability

The treemap (rectangular tree map) was first proposed by the American computer scientist Ben Shneiderman in 1992. In order to deal with the common hard disk full problem, Ben Shneiderman innovatively proposed the idea of generating a visualized directory tree structure. This form of display even has a place in the field of rectangular art later.

treeviz.gif

Common rectangular tree diagram

Many data sets are hierarchical in nature. A good hierarchical visualization is conducive to rapid multi-scale differentiation: micro-level observation of individual elements and macro-level observation of the overall data set. Rectangular tree diagrams are suitable for displaying data with hierarchical relationships and can intuitively reflect the comparison between the same levels.

treemap-category

Take the implementation of d3-hierarchy as an example:

treemapBinary

The idea is to recursively divide the specified node into an approximately balanced binary tree, choose horizontal partitions for wide rectangles, and choose vertical partitions for tall rectangles.

  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)) {
      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 {
      // 高矩形
      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);
    }
  }
}

sample graph:
binary.png

treemapDice

According to the value of the child node of each specified node, the rectangular area calculated by inputting x0, y0, x1, y1 coordinates is divided in the horizontal direction. Starting from the coordinates of the left edge (x0) of the given rectangle, the divided sub-elements are arranged in order.

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)
    }
}

sample graph:

dice.png

treemapSlice

According to the value of the child node of each specified node, the rectangular area calculated by inputting x0, y0, x1, y1 coordinates is divided in the vertical direction. Starting from the coordinates of the upper edge (y0) of the given rectangle, the divided sub-elements are arranged in order.

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)
    }
}

sample graph:

slice.png

treemapSliceDice

If the depth value of the specified node is odd, execute treemapSlice, otherwise execute treemapDice.

export default function (parent, x0, y0, x1, y1) {
    // 节点深度判定
    ;(parent.depth & 1 ? slice : dice)(parent, x0, y0, x1, y1)
}

sample graph:

slicedice.png

treemapSquarify

This squarified tree map layout will use the specified aspect ratio (ratio) to divide the rectangle as much as possible, so that the generated rectangle is as close to a square as possible, and has a better average aspect ratio.

export function squarifyRatio(ratio, parent, x0, y0, x1, y1) {
    while (i0 < n) {
        // Find the next non-empty node.
        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)

        // Keep adding nodes while the aspect ratio maintains or improves.
        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
        }
    }
}

squarify.png

treemapResquarify

treemapResquarify uses the squarified tree map method for the first layout to ensure a good average aspect ratio. Subsequent data changes only change the size of the node, but not the relative position of the node. This layout method will have a better effect on the animation performance of the tree graph, because it avoids the instability of the layout caused by node changes, and this instability may distract people's attention.

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 {
        // 首次布局采用 squarify 算法
        parent._squarify = rows = squarifyRatio(ratio, parent, x0, y0, x1, y1)
        rows.ratio = ratio
    }
}

sample graph:

resquarify.png

The detailed rectangular tree diagram effect can be viewed at demo

Summarize

The average aspect ratio refers to the ratio of the length and width of the generated rectangle. The better the average aspect ratio, the closer the rectangle is to the square, the better the user experience. Node order refers to the degree of change in the position of the tree graph node when the weight value of the input data changes. Ordered nodes, the stability of the tree graph is also better.

Average aspect ratio Node order Stability
treemapBinarygoodPartially orderedgenerally
treemapSliceVery badOrderlyexcellent
treemapDiceVery badOrderlyexcellent
treemapResquarifygoodOrderlyexcellent
treemapSquarifyexcellentPartially orderedgenerally

It can be found that treemapSquarify has a better average aspect ratio. In contrast, treemapResquarify also has a good average aspect ratio during the first layout. When the weight value of subsequent data changes, treemapResquarify will also have good stability due to the orderly nature of the nodes.

Make the rectangular tree diagram more vivid

Let's have a demo first

Based on the treemapSquarify treemap idea, a set of demo tests were started. Enter a set of random input parameters with value data:

[
    { value: 10, color: 'red' },
    { value: 7,  color: 'black' },
    { value: 4,  color: 'blue' },
    ...
]

Perform treemapSquarify calculations and transform the generated results to get a set of data lists with position coordinates and width and height, as shown below:

[
    {
      x: 0,
      y: 0,
      width: 330.56,
      height: 352.94,
      data: { value: 10, color: 'red' },
    },
    {
      x: 0,
      y: 352.94,
      width: 330.56,
      height: 247.06,
      data: { value: 7, color: 'black' },
    },
    {
      x: 330.56,
      y: 0,
      width: 295.56,
      height: 157.89,
      data: { value: 4, color: 'blue' },
    }
    ...
]

It can be seen that the input data is a set of random arrays. After calculation by treemapSquarify, a set of data containing x and y coordinates, width and height are obtained. Based on this set of initial input data, we add an offset to the data. By adding a time self-increment and using the characteristics of trigonometric functions to limit the offset within a certain range of the initial data, a set of result data after the initial data offset can be obtained. By continuously changing the offset range of the input data back and forth, it is possible to continuously generate multiple sets of result data after the initial data offset. As follows:

// requestAnimationFrame 循环动画
const builtGraphCanvas = () => {
  // 主逻辑
  treeMapAniLoop();
  requestAnimationFrame(builtGraphCanvas);
};
builtGraphCanvas();

// 主逻辑
treeMapAniLoop() {
  // 通过 time 自增,
  time += 0.02
  for (let i = 0; i < dataInput.length; i++) {
    // 利用三角函数限制范围,来回改变输入
    const increment = i % 2 == 0 ? Math.sin(time + i) : Math.cos(time + i)
    // 赋值偏移量,改变初始数据范围
    dataInput[i].value =
      vote[i] + 0.2 * vote[vote.length - 1] * increment * increment
  }

// treemapSquarify 算法生成结果
const result = getTreemap({
   data: dataInput,      // 带偏移量的数据输入
   width: canvasWidth,   // 画布宽
   height: canvasHeight, // 画布高
 })
}

According to the results returned by the obtained multiple sets of data offset, draw the x and y coordinates and the width and height on the canvas:

treeMapAniLoop() {
  // 绘制 canvas 矩形
  drawStrokeRoundRect() {
    cxt.translate(x, y);
    cxt.beginPath(0);
    cxt.arc(width - radius, height - radius, radius, 0, Math.PI / 2);
    cxt.lineTo(radius, height);
    ...
    cxt.fill();
    cxt.stroke();
  }
}

In the browser's requestAnimationFrame redrawing method, the initial input data continuously changes the offset under the time increment, thereby continuously generating a series of result data. The rendering animation is as follows:

friendface1.gif

Lattice rearrangement

As mentioned above, input a set of data values, use treemapSquarify to calculate the grid coordinates and width and height, and use time auto-increment to continuously "squeeze" the animation, which looks perfect. Try to replace several sets of different input data sources to verify the rendering effect in various input scenarios, one of which is as follows:

friendface2.gif

Please take a closer look. After a short period of time, the lattice blocks were perfectly "squeezed", and there was a phenomenon of beating. In the actual input data source test, it is found that the probability of grid bounce is relatively high. This is actually a rearrangement of the grid blocks. If we sort the grid blocks generated by the initial data on the canvas according to the numbers 1-18, we find that the grid block at position 3 originally, after the self-increasing offset change, the grid block position generated by recalculation has become the fifth. If the input data source differs greatly, the offset of this type of position will be more serious. So the direct appearance is that the grid animation on the canvas is constantly beating, the stability is very poor, and the user's look and feel experience will be bad.

Reasons for rearrangement

As mentioned above, treemapSquarify makes the generated rectangle as close to a square as possible and has a good average aspect ratio. In fact, it does not consider how to divide all levels at the same time at the beginning, which can avoid a large amount of calculation.
The main ideas are:

  • Arrange the child nodes in order from largest to smallest;
  • When a node starts to fill, there are two options: directly add to the current row, or fix the current row, and start a new row in the remaining rectangular space;
  • The final choice depends on which choice will bring a better average aspect ratio, the lower the aspect ratio, the better the current layout

Taking the data set [3,2,6,4,1,2,6] as an example, the sorted sequence is [6, 6, 4, 3, 2, 2, 1].
Step ①, the length of the lattice block is 4 and the width is 1.5, so the aspect ratio is 4/1.5 = 2.6..
Step ②, the length of the lattice block is 3, the width is 2, so the aspect ratio is 3/2 = 1.5
...And so on, the strategy is to choose the option with a lower average aspect ratio

progress.jpeg

Therefore, if the input data is offset to a certain extent, the calculation of treemapSquarify is greedy, it will only use the current best aspect ratio selection, so when the offset exceeds a certain boundary value, the output grid will inevitably appear Block positions are inconsistent, resulting in rearrangement.

Resolve rearrangement

In fact, as mentioned in the summary of the rectangular tree diagram above, treemapResquarify uses treemapSquarify in the first layout, so it also has a good average aspect ratio. When the data weight value changes, due to the unique node ordering feature of treemapResquarify, it will have good stability. Therefore, we use the treemapResquarify algorithm to generate the result of the input value, as shown below:

this.treeMap = d3
    .treemap()
    .size([size.canvasWidth - size.arrowSize * 2, size.canvasHeight - size.arrowSize * 2])
    .padding(0)
    .round(true)
    // 期望最佳长宽比 ratio = 1
    .tile(d3.treemapResquarify.ratio(1))

According to the input value, the leavseResult conversion output result is obtained. At this time, even if the input value is offset to a certain extent, the position of the output result is stable.

// 生成 treemap 结果
generateTreeMap(voteTree: Array<number>) {
  this.root.sum(function(d) {
    if (d.hasOwnProperty('idx')) {
      return treeData[d.idx - 1];
    }
    return d.value;
  });

  this.treeMap(this.root);
  const leavesResult = this.root.leaves();

  // 转换输出结构
  this.result = leavesResult.map(i => ({
    x: i.x0 + arrowSize,
    y: i.y0 + arrowSize,
    width: i.x1 - i.x0,
    height: i.y1 - i.y0,
    value: i.value,
  }));
}

Since the actual input offset does not deviate greatly from the initial data, the impact of the output result position offset is relatively small.

Before using treemapResquarify to solve the rearrangement problem, I also tried some other ideas. For example, to record the calculation results generated for the first time, when executing the input data auto-increment offset, try to check whether there is a grid "jumping". If the boundary of the grid "jumping" is checked, try to start from the periphery of the current grid. Borrow a bit of grid space and prevent grid jumps, thereby forcing the avoidance of rearrangement problems. However, this problem cannot be solved well. When some input data has a large contrast, there will be obvious animation freezes.

Transition animation

In addition to the above-mentioned squeeze animation of the result of the known number of votes, we have also done a lot of transition animations. The subdivision can be divided into 6 scenes. In order to ensure the smoothness of the animation, these 6 scenes are all made in a canvas. Take the opening animation as an example, the grid blocks gradually bulge up in order and then gradually shrink to the state of white grid blocks, as shown in the figure below

open.gif

The realization principle is to transition from state A to state B within a certain time range. Then use this time as an increment variable of the BezierEasing method to output a Bezier animation value. Each frame changes the x, y coordinates and width and height values of state A according to this animation value, and gradually tends to state B. The core code is as follows Shown:

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 resultA = this.cloneDeep(this.result);
  // 转换动画,A 状态转换到 0 票白色状态
  this.setTagByResult(resultA, this.initialZeroResult, easing);
}

//传入的两组 result 及补间参数 easing ,输出混合的 result。
setTagByResult(resultA, resultB, easing) {
  const result = this.result;
  for (let i = 0; i < result.length; i++) {
    result[i].x = resultA[i].x + easing * (resultB[i].x - resultA[i].x);
    result[i].y = resultA[i].y + easing * (resultB[i].y - resultA[i].y);

    result[i].width =
      resultA[i].width + easing * (resultB[i].width - resultA[i].width);
    result[i].height =
      resultA[i].height + easing * (resultB[i].height - resultA[i].height);
  }
}

As shown in the figure below, it is the selected state of a known squeeze result page transition to a white grid block. The principle of realization is similar. Within a certain time range, by changing the coordinate position and size of the grid, it gradually transitions from the extruded state to the white grid block state. There are more animation state changes, such as changing from a "unselected" grid block state to "selected" state; after the grid block is fully selected, transition from the "selected full" state to the result squeezed state, etc., here is No more details.

reselect.gif

Let the arrangement be staggered

Boundary processing in extreme scenarios is always a tricky thing.

Extreme scene display problem

It is conceivable that when the highest number of votes and the lowest number of votes differ greatly, the layout will be completely messed up.
As shown in the figure below, the highest number of votes is only 20 votes, and the lowest number of votes is 0 votes, and the space of the small number of votes grid block is squeezed to the point where it can't breathe.

luanqibazao.gif

If the distinction is more obvious, the highest number of votes is 140 votes, and the lowest number of votes is 0 votes, it will become more confusing, as shown in the following figure:

luanqibazao2.gif

Therefore, it is necessary to carry out a reasonable conversion of the input data source, and deal with the situation that exceeds the maximum multiple ratio in a case-by-case basis, and the conversion is within a reasonable interval.
The maximum multiple ratio is divided into 3 stages, which are 10 times, 30 times, and 30 times or more intervals. In fact, it is to convert an unpredictable random range into a controllable reasonable interval.

  // x=1-2区间,由双曲正切函数调整,输出增长率快到缓慢
  const reviseMethod = i => Math.tanh(i / 20) * 26;
  const computeVote = (vote: number, quota: number) => {
    // 基底100 + 倍数 * 每倍的份额
    return base + (vote / minVote - 1) * quota;
  };

  const stage1 = 10;
  const stage2 = 30;
  // 10倍区间
  const ceilStage1 = 600;
  // 30倍区间
  const ceilStage2 = 1000;
  // 30倍以上区间
  const ceilStage3 = 1300;

  let quota;
  // 最大最小倍数比
  const curMultiple = maxVote / minVote;
  const data = voteList.map(i => {
    let finalVote;

    // 不同阶段处理方案
    if (curMultiple <= stage1 + 1) {
      quota = (ceilStage1 - base) / stage1;
      const reviseValue = reviseMethod(i);
      finalVote = computeVote(reviseValue, quota);
    } else if (curMultiple <= stage2 + 1) {
      quota = (ceilStage2 - base) / stage2;
      finalVote = computeVote(i, quota);
    } else {
      // 需要隐藏部分票数,隐藏部分尖角等
      quota = ceilStage3 / curMultiple;
      finalVote = computeVote(i, quota);
    }

    return finalVote;
  });
  return data;
};

In addition to the processing between the layout of the grid block, the "emoticons", "tag words", and "number of votes" inside the grid blocks may also be squeezed or obscured each other. The following figure shows the earliest processing solution for label words. Actually, there are far more processing scenarios than this. The boundary conditions included are: automatic horizontal arrangement of label words, automatic vertical arrangement, self-adaptive font size, different processing of multiple characters, and occlusion processing between two grids. List any boundary conditions that may arise and deal with it!

39f13ae31dbdb9f961d4eefc803cf560.jpg

Typography is too regular

As shown in the figure below, the animation does not seem to be problematic. However, the highest number of votes is more than 10 times the lowest number. We believe that the layout of the animation is too "even and regular" to make it impossible to make a difference at a glance. In order to pursue a more distinct and distinguishable typesetting, the grid blocks are sufficiently messy and confusing, adding some "messy beauty" temperament. This problem can be better optimized by grouping the initial input data sources.

cuoluanyouzhi.gif

The 18 input data sources originally defined are juxtaposed in a level. We imagine that if it is possible to group the data, when the data is mapped to a two-dimensional plane, the grid is divided into multiple modules for display, will it be more confusing. As shown in the following code, we set the initial vote value to 1 vote and define multiple group levels. The actual performance effect is indeed more "messy".

// 初始带组的数据
export const dataGroupTree = {
  name: 'allGroup',
  children: [
    {
      name: 'group1',
      children: [
        {
          idx: 1,
          value: 1,
        },
                ...
      ],
    },
    {
      name: 'group2',
      children: [
           ...
      ],
    },
    {
      name: 'group3',
      children: [
        ...
      ],
    },
  ],
};

With a little cute corner

At the beginning, we designed the extruded lattice block with a little sharp corner, as shown in the figure below, there are cute sharp corners on the edge of the lattice block. However, we found that in some scenes, sharp corners may block each other or even encounter grid expressions. Due to the need for targeted boundary processing, it was finally removed due to time constraints.

arrow.gif

other problems

In fact, in addition to some of the problems listed above, there are also many large and small problems.

For example, it is very important to take screenshots of squeezing motion effects and convert them into pictures to share. When we are sharing the screenshot of the canvas, the action of the screenshot is based on the ability of the html2canvas library, but the screenshots are often blank occasionally. Try to add the crossOrigin="anonymous" attribute to the picture link when drawing the picture on the canvas and find that the solution is solved problem. Later, it was discovered that when the WeChat long press to save the picture, some Android models still had the problem of occasional screenshots and pictures blank. Finally, the problem was finally solved by converting the input picture link to the base64 address when drawing the picture on the canvas.
In short, if you want to complete a cute and "perfect" squeeze effect, you may encounter various problems, nothing else, deal with it!

summary

Finally, end with one sentence from the poster:
It is worth pondering, there is something, and the juvenile feeling never expires.

result

Interested students can search for "bubble" in the NetEase Music App to experience it.

Reference

This article was published from big front-end team of NetEase Cloud Music . Any form of reprinting of the article is prohibited without authorization. We recruit front-end, iOS, and Android all year round. If you are ready to change jobs and you happen to like cloud music, then join us at grp.music-fe(at)corp.netease.com!

云音乐技术团队
3.6k 声望3.5k 粉丝

网易云音乐技术团队