修复D3 circle packing圆大小失真并支持层级缩放的方案咨询
我基于大规模层级数据集构建了circle packing布局,每个圆形内的数字对应其包含的叶节点数量。每个叶节点初始赋值为1,布局会自动计算每个节点的r、x、y参数,实现代码如下:
let root = d3.stratify() .id((d) => d.id) .parentId((d) => d.parent) (data) root .sum(d => d.children ? 0 : 1) .sort((a, b) => b.height - a.height || b.value - a.value) pack(root)
当前存在的问题是顶层圆形大小计算失真:数值为1271的圆形视觉上比数值为1364的圆形更大,导致可视化结果具有误导性,效果见下图:
如果我裁剪层级结构,删除根节点所有子节点的后代节点,此时各圆形大小显示正确,但无法实现节点下钻缩放查看子节点的需求,因为子节点的x、y、r参数都未计算,对应实现与效果如下:
root .sum(d => d.children ? 0 : 1) .sort((a, b) => b.height - a.height || b.value - a.value) root.children.forEach( child => { delete child.children }) pack(root)

我了解该问题是空间填充circle packing算法的固有局限性:
Circle packing只能在恒定面积比例因子下表示单一代际节点,或表示所有叶节点,无法同时满足两者需求,两种实现均可通过d3.pack完成
Circle packing可针对叶节点或单一代际节点实现直径等比例展示,两种方式都可以通过d3.pack实现。
该问题发布于多年前,我想了解目前是否有方法可以突破这一限制?
我目前尝试的解决方案如下:
- 构建层级结构
- 临时删除根节点子节点的所有后代
- 计算全局pack布局
- 恢复根节点子节点的后代节点
- 为每个根节点的子节点单独重新计算布局
对应实现代码如下:
root .sum(d => d.children ? 0 : 1) .sort((a, b) => b.height - a.height || b.value - a.value) root.children.forEach( child => { if(child.children) { child.temp = child.children delete child.children } }) pack(root) root.children.forEach(child => { if (child.temp) { child.children = child.temp delete child.temp let pack = d3.pack() .size([child.r * 2, child.r * 2]) pack(child) child.descendants().forEach(d => { //d.x = d.x - child.r // + (child.x - child.r) //d.y = d.y - child.r //(child.y - child.r) }) } })
运行效果如下图:
此时节点定位存在偏差,我修改代码先存储子节点的x、y坐标,重新计算布局后恢复该坐标,此时顶层子节点位置正确:
root.children.forEach(child => { if (child.temp) { child.children = child.temp delete child.temp let pack = d3.pack() .size([child.r * 2, child.r * 2]) let tempX = child.x let tempY = child.y pack(child) child.x = tempX child.y = tempY } })
效果如下图:
但下钻缩放查看节点时,其后代节点定位仍存在小幅偏差,我补充了后代节点坐标偏移的逻辑:
root.children.forEach(child => { if (child.temp) { child.children = child.temp delete child.temp let pack = d3.pack() .size([child.r * 2, child.r * 2]) let tempX = child.x let tempY = child.y pack(child) child.x = tempX child.y = tempY child.descendants().forEach(d => { if(d.id == child.id) { return } d.x = d.x - child.r d.y = d.y - child.r }) } })
放大查看节点时效果如下,位置仍有少量偏差:
我推测最终版本需要递归执行上述逻辑,想了解是否有人此前尝试过类似方案?这是否属于反向circle packing算法:不再根据嵌套节点总尺寸计算父节点大小,而是先确定父节点尺寸,再按比例计算嵌套节点的参数?该可视化不需要支持叶节点间的大小对比,仅用于对比层级结构即可。
解决方案
你提到的思路是目前突破d3.pack固有局限的成熟可行方案,业内将这类实现称为层级分治circle packing,核心逻辑就是优先保证上层节点的大小比例正确,再逐层向下嵌套计算子布局,和你描述的「反向circle packing」逻辑一致。
你当前出现的坐标偏差是因为子节点计算布局时的基准原点是子容器的左上角,你需要在偏移时叠加父节点的左上角坐标,而非仅减去父节点半径,修正后的坐标偏移逻辑如下:
child.descendants().forEach(d => { if(d.id === child.id) return // 子布局的[0,0]原点对应父节点的左上角坐标(child.x - child.r, child.y - child.r) d.x += (child.x - child.r) d.y += (child.y - child.r) })
如果需要支持多层级下钻,直接递归执行你当前的逻辑即可:对每个父节点,先临时移除其后代计算父层级的pack布局,再恢复后代单独计算子节点的pack布局,逐层修正坐标即可。
该方案在不需要叶节点大小对比的前提下,完全可以满足层级结构展示+下钻的需求,不会出现上层节点大小失真的问题。
内容的提问来源于stack exchange,提问作者Ivan Bacher

