首页 > 解决方案 > D3 错误地包圈

问题描述

我试图用 D3 创建一个气泡图。一切都与示例中的完全一样,但后来我注意到数据呈现不正确。

所以我进行了一个实验:我将四个具有不同子项组合的“组”创建一个总值为100: 1 x 1002 x 503 x 33.33的组4 x 25。例如,我有这样的数据:

[{
  title: "X",
  children: [
    {
      title: "100",
      weight: 100
    },
  ]
},
{
  title: "X",
  children: [
    {
      title: "50",
      weight: 50
    },
    {
      title: "50",
      weight: 50
    },
  ]
},
{
  title: "X",
  children: [
    {
      title: "33",
      weight: 33.33
    },
    {
      title: "33",
      weight: 33.33
    },
    {
      title: "33",
      weight: 33.33
    },
  ]
},
{
  title: "X",
  children: [
    {
      title: "25",
      weight: 25
    },
    {
      title: "25",
      weight: 25
    },
    {
      title: "25",
      weight: 25
    },
    {
      title: "25",
      weight: 25
    },
  ]
}]

然后我像这样渲染图表:

const rootNode = d3.hierarchy(data);

rootNode.sum(d => d.weight || 0);

const bubbleLayout = d3.pack()
    .size([chartHeight, chartHeight])
    .radius(d => d.data.weight); // toggling this line on and off makes no difference

let nodes = null;

try {
    nodes = bubbleLayout(rootNode).descendants();
} catch (e) {
    console.error(e);
    throw e;
}

但由此产生的泡沫甚至没有:

该死的3

要定义此渲染器的不正确性,请考虑屏幕截图中间的气泡:没有子级的蓝色气泡的半径为100,其实际大小为180 px。它右边的两个气泡都有半径50,所以它们应该是180 px宽的(当沿着同一轴放置时)。但是发生的是它们的总直径是256 px,这让我认为这是不正确的渲染:

在此处输入图像描述 在此处输入图像描述

问题是:为什么会发生这种情况以及如何使该图表看起来正确,以便圆圈与r = 100两个圆圈的大小相同r = 50

标签: javascriptd3.jscharts

解决方案


基于这个问题,我不一定清楚最终目标,但我可以通过每一种可能性来确保完整性。

我认为您希望代际圈子具有相同的面积比例因子或直径比例因子(面积直径与代际每个节点的某些特定值成比例)。

或者,您可能只想让面积或直径与一代中每个节点的某个特定值成比例,尽管我认为这不太可能。

除了这些组织策略之外,我们还可以拥有与叶节点的某些值成比例的面积或直径。

鉴于评论中的讨论和最近关于该主题的另一个问题,我将借此机会回顾一下上述每个组织战略。理想情况下,这涵盖了这个问题和链接的问题。

以下是基于上述的六种策略:

面积比例

  1. 包装圆圈,使叶子(无子)圆圈具有成比例的面积
  2. 打包圆圈,使一代圆圈具有成比例的面积
  3. 包装圆圈,使所有或多代具有面积成比例的圆圈。

直径/半径的比例

  1. 包装圆圈,使叶子(无子)圆圈具有成比例的直径
  2. 打包圆,使一代圆具有成比例的直径
  3. 打包圆,以便所有或多代具有具有成比例直径的圆。

结果

本质上:一、二、四、五可以用d3.pack(). 三是不可能的。六不是圆包。

1. 叶子的比例面积

这是 的预期行为d3.pack(),不需要太多讨论。只有叶子会有成比例的面积,任何父母都将由圆圈组成,这些圆圈是他们孩子的最小包围圈。它们的半径仅由包围孩子所需的东西决定。

2. 单代的比例区域

d3.pack()这在开箱即用的情况下也是可能的——但有一点不同。d3.pack()会给叶节点一个与某个大小值成比例的区域。如果不重新编写模块(这已经是所有 d3 模块中最不易于篡改的模块),就无法更改这一点。

该算法不能给任意生成的比例区域,所以我们不能完成这个策略,除非我们使用多个圆包:

例子

如果我们想缩放最高级别的父母(根的第一代后代,在本节的其余部分称为父母),那么我们可以创建一个父圆包。父圆包只会被提供一个包含根和父的层次结构。由于所有父级都是此截断层次结构中的叶子,因此它们都将根据某些分配的值在区域中按比例缩放。g然后我们为每个节点使用 a 绘制这个圆形包。

在我们让圆形包中的每个父节点为其自己的后代生成自己的圆形包之后(这也有一个截断的层次结构,删除原始根,而不是根将成为每个圆形包的父节点)。子圆包中每个叶节点的面积将与某个指定值成比例地调整大小。叶节点的缩放在每个子圆包之间会有所不同,因为这些现在单独打包的层次结构的性质和结构将决定叶缩放。

这种方法要求我们跟踪父节点的半径以设置子圆包的大小并正确定位子包中的圆(我在下面的代码片段中为后者使用了一个局部变量)。这与实现一样困难,代码与在同一页面上附加两个圆形包时的代码基本相同。

这是一个粗略的演示:

var svg = d3.select("svg"), diameter = +svg.attr("width"), g = svg.append("g").attr("transform", "translate(2,2)"), colors = ["#ffffcc","#a1dab4","#41b6c4","#225ea8"];

var pack = d3.pack().size([diameter - 4, diameter - 4]);
    
var local = d3.local();

var root = {"name": "root","children": [{"name": "Node A","size": 100},{"name": "Node B","size": 100},{"name": "Node C","size": 100}]}
var children = [{"name":"NodeA","children":[{"name":"Node1","size":34},{"name":"Node2","size":33},{"name":"Node3","size":33}]},{"name":"NodeB","children":[{"name":"Node1","size":50},{"name":"Node2","size":50}]},{"name":"NodeC","children":[{"name":"Node1","children":[{"name":"Nodea","size":15},{"name":"Nodeb","size":12},{"name":"Nodec","size":10}]},{"name":"Node2","size":10},{"name":"Node3","size":13},{"name":"Node4","size":9},{"name":"Node5","size":6},{"name":"Node6","size":10},{"name":"Node7","size":15}]}]
    
// parent pack:
root = d3.hierarchy(root)
    .sum(function(d) { return d.size; })
    .sort(function(a, b) { return b.value - a.value; });
      
// Parent Circle Pack
var node = g.selectAll(null)
    .data(pack(root).descendants())
    .enter().append("g")
    .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; })
    .attr("fill", function(d) { return colors[d.depth]; });

// Parent circle:   
node.append("circle")
    .attr("r", function(d) { return d.r; });
        
// get radii
var radii = pack(root).descendants().filter(function(d) { return d.depth == 1; }).map(function(d) { return d.r; });
      
// Create child pack data:  
var childRoots = children.map(function(child,i) {
var childPack = d3.pack().size([radii[i]*2 - 2, radii[i]*2 - 2]);
      
var childRoot =  d3.hierarchy(child)
    .sum(function(d) { return d.size; })
    .sort(function(a,b) { return b.value - a.value; });
        
    return childPack(childRoot).descendants(); 
})    
      
// Swap node data for child node data, but keep the original data handy.
var childNodes = node.each(function(d,i) {
        local.set(this, d);  // but store the data in the local variable.
    })
    .filter(function(d,i) {
        return i > 0;
    })
    .data(childRoots)
    .selectAll("g")
    .data(function(d) { return d; })
    .enter()
    .append("g")
    .attr("transform", function(d) { var offset = local.get(this).r; return "translate(" + (d.x-offset) + "," + (d.y-offset) + ")"; })
    .attr("fill", function(d) { return colors[d.depth+1]; });

// Append child elements to each node:
childNodes.filter(function(d) { return d.depth > 0 })  // skip parent - it's already drawn.
    .append("circle")
    .attr("r", function(d) { return d.r; });
        
childNodes.filter(function(d) { return !d.children })
    .append("text")
    .text(function(d) { return d.data.name; })
    .attr("fill","black")       
    .style("text-anchor","middle")
    .attr("dy", 5);
<svg width="600" height="600"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>

每个父节点的大小值为 100,巧合的是,每个父节点的所有最深子(叶)节点大小的累积总和。每个顶级父节点的大小也相同:

在此处输入图像描述

如果我们想按比例扩展该代,我们自然可以养活父圆包装根的孙子。

3. 跨代地区的比例

让我们使用一个简单的两代圆包:一个父母和一些孩子。

如果父级与其子级具有相同的面积比例因子,则子级的累积面积将等于其父级的面积。

如果我们要将这些孩子打包到他们的父母中,我们必须以一种不会产生空白空间的方式这样做。这在处理多个子圈时是不可能的。

空隙空间是为什么这是不可能的 - 一个以上孩子的父母总是有一个大于其孩子面积总和的面积。

如果代际比例至关重要,那么树形图可以实现这一点,d3 文档中描述的权衡是:

虽然圆形包装不像树状图那样有效地使用空间,但“浪费”的空间更突出地揭示了层次结构。(文档

例外

  • 如果父母的尺寸值大于其孩子的累积尺寸值,则根据这些值,圆包装可能是可能的。为了证明这一点的有限性,请考虑两个大小相同的孩子的父母。具有这两个孩子的最有效的圆形包装将要求父母的面积是孩子的组合面积的两倍(注意,如果它大于 2x,那么我们不是圆形包装,因为我们没有使用最小值封闭的圆圈孩子们不要触摸)。

  • 同样,如果代之间或父代中有足够的叶节点,则可能(取决于值和层次结构)在代之间具有相同的面积缩放因子,以便两代的累积大小值(以及面积)节点不相等。

  • 如果所有节点只有一个或零个子节点。

前两个项目符号可能需要手动更正/验证的值仍然是圆形包装,如果它们偏离圆形包装(作为父母的最小封闭圆圈 - 没有填充或边距),那么 d3.pack() 不再是正确的工具。

为了完整起见,我添加了这些例外,我认为它们非常不可能,除了由单身儿童引起的例外(但如果与他们的父母相同,无论如何都要完全覆盖父母)。

4. 叶子的比例直径

如果d3.pack()假设尺寸值应该与叶圆的面积成正比,那么我们可以使用面积和直径之间的关系来获得一个尺寸值,该值将为叶节点创建与直径成比例的面积:

size = Math.pow(size/2,2);

我们将初始大小值视为直径,并找出具有该直径的圆的面积(按比例计算,因此我们不需要 π,因为我们会将每个结果乘以 π)。这是一个快速演示:

var svg = d3.select("svg"), diameter = +svg.attr("width"), g = svg.append("g").attr("transform", "translate(2,2)"), colors = ["#ffffcc","#a1dab4","#41b6c4","#225ea8"];

var pack = d3.pack().size([diameter - 4, diameter - 4]);
    
var root = {"name": "root","children": [{"name": "Node A","size": 100},{"name": "Node B",children:[{"name": "Node 1", "size":50},{"name": "Node 2", "size":50}]}]}
    
root = d3.hierarchy(root)
    .sum(function(d) { return Math.pow(d.size/2,2); })
    .sort(function(a, b) { return b.value - a.value; });

var node = g.selectAll(null)
    .data(pack(root).descendants())
    .enter().append("g")
    .attr("transform", function(d) { return "translate(" + d.x + "," + d.y + ")"; })
    .attr("fill", function(d) { return colors[d.depth]; });

node.append("circle")
    .attr("r", function(d) { return d.r; });
<svg width="600" height="600"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>

以及片段的视觉效果:

在此处输入图像描述

左边的(叶子)圆的大小为 100,右边的(父)圆有两个孩子(叶子),每个孩子的大小为 50(累计 100)。看起来好像通过这种方式缩放,我们已经对叶子和父母进行了相同的缩放。这只是在处理两个相同大小的子圈时发生的巧合。

5. 单代比例直径

使用直径和面积之间的关系,我们可以创建缩放值以传递给 d3.pack(),它表示给定直径的面积(与上面的 #4 相同)。

一旦我们得到一个面积值,该过程与为单代缩放面积相同(与上面的#2相同)。而已。

6. 代际直径的比例

我们可以看到为什么策略 4 和 5 中的这种比例在大多数情况下不能代代相传。在包装中,孩子们必须以允许最小包围圈的方式触摸。对于两个子节点,最小的封闭圆的直径总是等于子节点的总和。但是,如果我们每个父母有两个以上的孩子,我们就会遇到问题。

我们可以看到有五个孩子的父母与有两个孩子的父母的大小不会相同,即使每个父母的孩子圆的直径总和相同:

在此处输入图像描述

在这里,叶节点在直径(或半径)方面都是成比例的。例如,左侧大的第一代叶子的大小值为 100 - 宽度为 298 像素 (1 : 2.98),右侧大圆圈中的两个叶子的大小值为 50,宽度为 149 像素(1:2.98)。下方圆圈中的五片叶子的大小值为 20,宽度为 59.6 像素 (1 : 2.98)。

尽管叶节点中的直径(或半径)成比例,但一旦向上移动,这种比例就会消失:底部的五个子圆圈和右侧的两个子圆圈具有相同的累积直径(并且数据中的累积尺寸值相同),但父母的尺寸明显不同。

但是,我们可以创建一个布局来保持跨代直径的比例,但不能使用d3.pack(). 在这种情况下,我们不是包装圆 - 我们是包装直径,直径是线。我们正在打包一维线(恰好用圆圈表示)。

让我们假设一个简单的单亲多子女示例。如果缩放因子跨代一致,则父级的直径必须等于子级的直径之和。只有一种方法可以用最小的封闭圆来实现这一点:

在此处输入图像描述

如果您将其应用于所有世代,那么所有圆圈都将锚定在一条线上 - 因为我们实际上是线包装。

d3.pack在这里不起作用,因为它在 2d 空间中打包 2d 圆,我们只需要在 1d 线上打包 1d 行即可实现此策略。

这个策略可能可以通过一些相当简单的数学来实现。

例外

在某些情况下也有例外,例如在策略 #3 中检查的情况。

还有一个例外:每个节点都有两个大小相等的子节点的层次结构。我不确定,d3 可能只是将它绘制在一条线上,但它可以与d3.pack. 但是,尚不清楚为什么某种树形布局在这里不会更好。

概括

肥皂盒

圆形包装是一种在层次结构中传达定量数据的糟糕方法。正如上面迈克的引述所指出的那样,它更适合传达层次结构。我敢说人们对圈子的实际判断力很差。如果叶子分散在不同的世代中,我还建议用相同的比例因子调整叶子节点的大小对于读者来说可能不直观。如果需要对基本值进行快速直观的定量理解,则圆形填充不是理想的解决方案。也就是说,

结论

圆形填充不能也不能用一致的面积比例因子表示所有区域:圆形填充意味着空空间,空空间意味着父圆的面积将大于其子圆面积的总和。如果您需要所有世代都具有恒定的区域比例,那么您可能需要树状图。是的,#3、#6 中提到了一些例外情况,但这些基本上是理论上的,几乎没有实际用途

圆形包装只能代表具有恒定面积比例因子的一代或所有叶节点 - 不能同时代表两者。任何一种方法都可以通过d3.pack

圆形填充可以按比例表示叶子或一代的直径。同样,任何一种方法都可以通过d3.pack.

直径与部分或所有代成比例的圆形填料是不可能的。可以进行布局 - 但它不是圆形包装。我们可以收紧上图中三个子圆的排列,但是我们没有最小的封闭圆(因此我们没有圆包装)。将它们排成一行也不是圆形包装。因为这d3.pack()是没有用的——因为我们不再打包圈子了。

可能有其他布局选项不使用最小封闭圆或对不同代使用不同的尺寸比例(这可能(在实践中,而不是理论上)总是需要放弃最小封闭圆)。这使我们很好地摆脱了圈子包装,我不确定那里有什么可以提供帮助。


推荐阅读