用 D3 和 React 造一个更科学的 TreeMap
- 支持固定大小与相对双布局;
- 元素的移动动画要支持溯源;
- 添加动画开关并在 React 调用;
在解决问题之前,先把涉及到的 D3 API 整理一下:
-
d3.hierarchy()
– 这个 API 用于生成树形层次结构的数据。通过运行分层布局,可以返回节点数组及指定的根节点。布局的输入参数为分层的根节点,输出返回值为一个数组,表示计算过的所有节点的位置。如果你的数据还不符合层次结构(即具备父子关系的 JSON 格式),那么你可以尝试使用d3.stratify
来处理你的数据。 -
d3.stratify()
– 将平级的数据构建为具有层次结构的数据。 -
d3.treemap()
– 这个 API 用于创建 TreeMap 布局,输入为一个具有层次结构数据的根结点。 -
d3.schemeCategory10
– 用于颜色映射使用的 API,为一个数组,其中包含十个分类颜色,每个颜色元素表示为 RGB 十六进制字符串。 -
d3.select()
– 元素选择器,可以用它设置属性,样式,属性,HTML 或文本内容等。 -
selection.transition()
– 这个 API 用于构造指定元素的新转换。 -
transition.tween()
– 当你需要为每个元素的转换添加补间动画时,你会用上这个 API。
接下来便挨个解决这几个问题,本文所使用的数据集格式可参见 https://github.com/hijiangtao/d3-treemap-with-react-demo/blob/master/src/mock.js
。
在线 Demo 见 https://hijiangtao.github.io/d3-treemap-with-react-demo/
。
解决问题
1 / 双布局
Treemap 作为一种可视化形式,非常适合展现具有层级关系的数据,利用它能够直观体现同级之间的比较。利用布局 API d3.treemap 以及一个固定大小的画布,通过更新数据源便可以看到数据的变化情况。当每帧图像中数据总量不变的时候,我们用固定大小的容器是没有问题的,但若数据总量会动态变化,那么画布也应该随之改变以体现数据的增减,所以灵活的 TreeMap 应该提供有两类布局。这里我们来看看如何实现动态调整的布局。
首先是配置 TreeMap 布局函数:
const tm = d3.treemap() .tile(tileType) // 布局类型 .size([width, height]) // 长宽像素 .padding(d => d.height === 1 ? 1 : 0) // 边距 .round(true);
然后便是调用布局函数生成带有实际位置信息的节点集。为了实现不同帧画面的布局变化,首先我们需要对每帧数据进行加和计算并对大小进行排序,其次是根据 ID 对节点进行重排:
// 加和计算和大小排序使用 .sum() 和 .sort() API const root = tm(d3.hierarchy(mapdata) .sum(d => { return d.values ? d.values[index] : 0 }) .sort((a, b) => { return b.value - a.value }));
再来看看我们的布局代码,我们最后更新每个节点的大小时会传入一个系数,这个系数用于计算画布偏移及更新最后节点位置信息:
// 获取当前数据集最大值,由 maxLayout 控制是全数据集还是当前帧数据集 const getMaxs = (data, index = -1) => { const sums = data.keys.map((d, i) => d3.hierarchy(data).sum(d => d.values ? Math.round(d.values[i]) : 0).value); return index === -1 ? d3.max(sums) : sums[index]; } const maxIndex = maxLayout ? -1 : index; const k = Math.sqrt(root.sum(d => d.values ? d.values[index] : 0).value / getMaxs(mapdata, maxIndex)); // 像素系数,由 k 控制偏移 const x = (1 - k) / 2 * width; const y = (1 - k) / 2 * height; // 更新每个节点位置信息 const leaves = tm.size([width * k, height * k])(root) .each(d => (d.x0 += x, d.x1 += x, d.y0 += y, d.y1 += y)) .leaves();
由上可以看出,最终操控布局是固定大小还是动态调整大小的系数是由 x/y 控制,然后遍历节点时传入进行更新。
2 / 动画溯源
动画溯源比较好解,由于我们在调用布局函数时已经进行过排序计算,这使得我们可以获得变更后的每个节点位置信息,那么我们只需要将返回的每条数据与前一帧时每条数据对应上,由于在添加元素时我们都有生成一个 id,故这里通过遍历完成查找和位置更新:
// 生成元素时的 ID 赋值 leaf.append("rect") .attr("id", d => (d.leafUid = UID('leaf')).id) ...... // 调用组件时所用到的 sortTransition API 用于控制动画是否溯源 // 如果不开启,那么直接返回节点集 if (!mapdata.children[0].values || !sortTransition) return leaves; const newLeaves = new Array(leaves.length); // 获取 ID 集合,其在数据中的位置即为其上一帧的位置 const keyList = mapdata.children.map(({id}) => id); // 构造结果集 newLeaves leaves.map(item => { const itemIndex = keyList.indexOf(item.data.id); newLeaves[itemIndex] = item; }) ......
但这是一个溯源思路,看到代码中有这么一个判断 !mapdata.children[0].values || !sortTransition
,这个意思即根节点层内元素如果包含的不是可遍历的节点(即其子元素仍旧是一个多层结构),那么我们将不进行溯源处理,这是因为我们后续的 ID 匹配只匹配到了根节点下面的第一层,如果需要支持多层级数据,那么如上的 leaves 遍历以及 newLeaves 构造都需要进行更改。这部分的工作会更加复杂,本文暂不涉及。
3 / 动画开关与在 React 中使用
动画开关其实在以上两点中已有涉及,其中 sortTransition
用于控制动画中节点是否溯源,而是否让 TreeMap 自动启动动画我们只需要在 React 中动态更改 state 并重新调用组件即可,这里解释下最后封装的 API:
而一个简单的 React 封装即始终让组件返回 svg 元素,但通过 id 绑定元素查询以及数据更新的逻辑。详见这57行代码 https://github.com/hijiangtao/d3-treemap-with-react-demo/blob/master/src/TreeMap.js#L101-L157
.
完整代码见 https://github.com/hijiangtao/d3-treemap-with-react-demo
, 在线 Demo 见 https://hijiangtao.github.io/d3-treemap-with-react-demo/
。