为什么强制布局中的节点在更新时从原点跳转

Why do nodes in force layout jump from origin on update

为什么每次迭代更新数据时我的圈子从 (0,0) 跳转?

我想用数据更新时改变半径的圆做一个强制布局。我不知道如何在循环中使用 d3 力。我所能得到的只是在改变尺寸时从原点跳跃的圆圈。我想问题在于 d3 如何存储和设置对象坐标的方式。

这是我的代码:

var tickDuration = 1000;
var margin = {top: 80, right: 60, bottom: 60, left: 60}
const width = 960 - margin.left - margin.right,
    height = 600 - margin.top - margin.bottom;
let step = 0;

data = [
    {
        "name": "A",
        "value": 99,
        "step": 9
    },
    {
        "name": "A",
        "value": 28,
        "step": 8
    },
    {
        "name": "A",
        "value": 27,
        "step": 7
    },
    {
        "name": "A",
        "value": 26,
        "step": 6
    },
    {
        "name": "A",
        "value": 25,
        "step": 5
    },
    {
        "name": "A",
        "value": 24,
        "step": 4
    },
    {
        "name": "A",
        "value": 23,
        "step": 3
    },
    {
        "name": "A",
        "value": 22,
        "step": 2
    },
    {
        "name": "A",
        "value": 21,
        "step": 1
    },
    {
        "name": "A",
        "value": 20,
        "step": 0
    },
    {
        "name": "B",
        "value": 19,
        "step": 9
    },
    {
        "name": "B",
        "value": 18,
        "step": 8
    },
    {
        "name": "B",
        "value": 17,
        "step": 7
    },
    {
        "name": "B",
        "value": 16,
        "step": 6
    },
    {
        "name": "B",
        "value": 150,
        "step": 5
    },
    {
        "name": "B",
        "value": 14,
        "step": 4
    },
    {
        "name": "B",
        "value": 13,
        "step": 3
    },
    {
        "name": "B",
        "value": 12,
        "step": 2
    },
    {
        "name": "B",
        "value": 11,
        "step": 1
    },
    {
        "name": "B",
        "value": 10,
        "step": 0
    },
    {
        "name": "С",
        "value": 39,
        "step": 9
    },
    {
        "name": "С",
        "value": 38,
        "step": 8
    },
    {
        "name": "С",
        "value": 37,
        "step": 7
    },
    {
        "name": "С",
        "value": 36,
        "step": 6
    },
    {
        "name": "С",
        "value": 35,
        "step": 5
    },
    {
        "name": "С",
        "value": 34,
        "step": 4
    },
    {
        "name": "С",
        "value": 33,
        "step": 3
    },
    {
        "name": "С",
        "value": 32,
        "step": 2
    },
    {
        "name": "С",
        "value": 31,
        "step": 1
    },
    {
        "name": "С",
        "value": 30,
        "step": 0
    }
];


const halo = function (text, strokeWidth) {
    text.select(function () {
        return this.parentNode.insertBefore(this.cloneNode(true), this);
    })
        .style('fill', '#ffffff')
        .style('stroke', '#ffffff')
        .style('stroke-width', strokeWidth)
        .style('stroke-linejoin', 'round')
        .style('opacity', 1);

}

var svg = d3.select('body').append('svg')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.top + margin.bottom)
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')

let rad = d3.scaleSqrt()
    .domain([0, 100])
    .range([0, 200]);

var fCollide = d3.forceCollide().radius(function (d) {
    return rad(d.value) + 2
});
    fcharge = d3.forceManyBody().strength(0.05)
    fcenter = d3.forceCenter(width / 2, height / 2)

var Startsimulation = d3.forceSimulation()
    .force('charge', fcharge)
    //.force('center', fcenter)
    // .force("forceX", d3.forceX(width/2).strength(.2))
    // .force("forceY", d3.forceY(height/2).strength(.2))
    .force("collide", fCollide)


function ticked() {
    d3.selectAll('.circ')
        .attr('r', d => rad(d.value))
        .attr("cx", function (d) {
            return d.x = Math.max(rad(d.value), Math.min(width - rad(d.value), d.x));
        })

        .attr("cy", function (d) {
            return d.y = Math.max(rad(d.value), Math.min(height - rad(d.value), d.y));
        })

    d3.selectAll('.label')
        .attr("cx", function (d) {
            return d.x = Math.max(rad(d.value), Math.min(width - rad(d.value), d.x));
        })
        .attr("cy", function (d) {
            return d.y = Math.max(rad(d.value), Math.min(height - rad(d.value), d.y));
        });

}


data.forEach(d => {
    d.value = +d.value
    d.value = isNaN(d.value) ? 0 : d.value,
        d.step = +d.step,
        d.colour = d3.hsl(Math.random() * 360, 0.6, 0.6)
});

let stepSlice = data.filter(d => d.step == step && !isNaN(d.value))
    .sort((a, b) => b.value - a.value)


let stepText = svg.append('text')
    .attr('class', 'stepText')
    .attr('x', width - margin.right)
    .attr('y', height - 25)
    .style('text-anchor', 'end')
    .html(~~step)
    .call(halo, 10);

svg.selectAll('circle.circ')
    .data(stepSlice, d => d.name)
    .enter()
    .append('circle')
    .attr('class', 'circ')
    .attr('r', d => rad(d.value))
    .style('fill', d => d.colour)
    .style("fill-opacity", 0.8)
    .attr("stroke", "black")
    .style("stroke-width", 1)

Startsimulation.nodes(stepSlice).on('tick', ticked)


let ticker = d3.interval(e => {

    stepSlice = data.filter(d => d.step == step && !isNaN(d.value))
        .sort((a, b) => b.value - a.value)

    // rad.domain([0, d3.max(stepSlice, d => d.value)]);
    let circles = d3.selectAll('.circ').data(stepSlice, d => d.name)

    circles

        .transition()
        .duration(tickDuration)
        .ease(d3.easeLinear)
        .attr('r', d => rad(d.value))


    Startsimulation
        .nodes(stepSlice)
        .alpha(1)
        .alphaTarget(0.3)


    stepText.html(~~step);
    if (step == 9) ticker.stop();
    step = d3.format('.1f')((+step) + 1);
}, tickDuration);
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <style>
        text.stepText{
            font-size: 64px;
            font-weight: 700;
            opacity: 0.25;}
    </style>
</head>
<body>
<div id="chart"></div>

<script src="https://d3js.org/d3.v5.js"></script>
</body>
</html>

根据您的代码,最终问题在于 d3-force,但我将提出另一种创建此可视化的方法(如果我理解正确的话)。这个提议的替代方案将与 D3 设计的模式更加一致。

建议的解决方案将解决 d3-force 的问题,同时也使数据绑定更容易。我将尝试解释为什么在以下两点上可能更可取不同的方法:

D3 和数据绑定

D3 非常重视数据绑定。数据数组中的项目绑定到 DOM 中的元素。

一般来说,在 D3 中使用的最佳数据源是其中的数据是一个数组,并且该数据数组中的每一项都由 DOM 中的一个元素表示。这样我们就可以轻松进入,更新,退出。

您的数据是一个包含 30 个项目的数组,您只显示了其中的 3 个。这些元素每隔 step/interval 就有一个绑定到它们的新数据。这需要过滤数据数组,使用键函数,并重新绑定每个 step/interval 的新数据。这个新绑定的数据用于更新圆圈和力布局。

使用 D3 的一种更直接的方法是以不同方式构建数据。您有三个圆圈,因此您的数据数组中应该包含三个对象。这些对象应具有保存步骤数据的属性。然后我们可以根据我们正在进行的步骤更新圆圈。无需重新选择、重新绑定、过滤等。不利之处在于,这通常需要在获取可视化代码之前重组您的数据 - 但当您开始制作可视化时,return 是值得的。

D3 力模拟

D3 力模拟的节点方法采用对象数组。如果这些对象没有 xyvxvy 属性,力模拟会修改这些对象以赋予它们这些属性(它不会克隆这些对象这样做)。这是节点的初始化。如果用新节点替换原始节点,模拟将初始化新节点。没有对先前节点的引用 - 节点本身就是对象(您不是在替换节点的数据部分,而是在替换整个节点)。

为了在您当前的方法中解决这个问题,我们需要采用当前步骤的每个节点的 xyvxvy 属性并在每个步骤开始时将这些属性分配给下一步的节点。

相反,让力模拟始终保持相同的节点。如上所述,我们有三个节点,所以我们有一个包含三个对象的数据数组,每个对象都包含其中包含的所有步骤数据。现在我们不需要过滤节点,传递属性等

替代方法

我们将使用一个包含一个对象的数据数组来表示我们想要表示的每一件事:

let data = [
  {
    "name": "A",
    "steps": [20,21,22,23,24,25,26,27,28,99],
    "colour": "steelblue"   
  },
  {
    "name": "B",
    "steps": [10,11,12,13,14,150,16,17,18,19],
    "colour":"crimson"
  },
  {
    "name": "С",
    "steps": [30,31,32,33,34,35,36,37,38,39],
    "colour":"orange"
  }
];

现在当我们要前进到第n步时,我们可以用someScale(d.steps[n])得到当前的半径。这让我们可以设置绘制半径和碰撞半径,而无需将数据重新绑定到 DOM 元素,也无需为力提供新节点。

现在我们可以设置所有不依赖于步骤的东西了:

  • SVG
  • 体重秤
  • 勾选函数
  • 数据
  • 大部分模拟设置
  • 输入 SVG 元素

但是,您不需要在添加圆圈时将初始半径设置为第一步的值,因为每次移动一步时我们都会这样做。我们只需要每个 SVG 元素的静态属性(也就是说,我已经将下面的初始半径设置为零,这样我们就可以很好地过渡)。

然后我们可以把每一步变化的代码都放在区间函数里面。这段代码只会做几件事:

  • 递增到下一步
  • 使用新的半径设置新的碰撞力
  • 过渡圆的半径
  • 应用新的碰撞力并重新加热模拟。
  • 更新显示我们正在进行的步骤的文本。

备注

我对您的代码进行了大量修改以显示备选方案的简化示例。不属于上述解释的最大变化是为简单起见删除了标签。

关于转换和强制布局的注意事项。如果您过渡半径并在刻度函数中设置半径,则刻度函数和过渡将不断地相互覆盖。 Transitions 应该修改 tick 中未修改的属性,反之亦然。在您的代码中,您在刻度和转换中修改每个圆圈 r

我已经将过渡时间更改为小于每个间隔之间的持续时间:这样我可以确保过渡在开始下一步及其相应的过渡之前完成。我夸大了这个变化,因为我喜欢暂停。

我下面有一个函数 (radius),它使用当前步长和基准 return 半径。因为我只想在需要圆半径的地方使用它,所以我从第 -1 步开始。原因是因为我需要在 nextStep 函数的开头增加 step。如果我在此函数的末尾递增,那么连续运行的 ticked 函数将使用与 nextStep 函数的其余部分不同的步骤。 radius 函数已编写为如果步长为 -1,它将使用第零步以避免初始化问题。可能有更好的处理方法,我觉得这是最简单的。

如有必要,我可以添加更多评论,但我希望以上解释和我有限的评论足够了:

const tickDuration = 1000,
      margin = {top: 80, right: 60, bottom: 60, left: 60},
      width = 960 - margin.left - margin.right,
      height = 600 - margin.top - margin.bottom;

let data = [
  {
    "name": "A",
    "steps": [20,21,22,23,24,25,26,27,28,99],
    "colour": "steelblue"   
  },
  {
    "name": "B",
    "steps": [10,11,12,13,14,150,16,17,18,19],
    "colour":"crimson"
  },
  {
    "name": "С",
    "steps": [30,31,32,33,34,35,36,37,38,39],
    "colour":"orange"
  }
];

let step = -1;
let svg = d3.select('body').append('svg')
    .attr('width', width + margin.left + margin.right)
    .attr('height', height + margin.top + margin.bottom)
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' + margin.top + ')')

// No need to set the text value yet: we'll do that in the interval.
let stepText = svg.append('text')
    .attr('x',10) // repositioned for snippet view.
    .attr('y', 10)

// Scale as before.
let scale = d3.scaleSqrt()
    .domain([0, 100])
    .range([0,  100]);

// Get the right value for the scale:
let radius = function(d) {
  return scale(d.steps[Math.max(step,0)]);
}

// Only initial or static properties - no data driven properties:
let circles = svg.selectAll('circle.circ')
    .data(data, d => d.name)
    .enter()
    .append('circle')
    .attr("r", 0) // transition from zero.
    .style('fill', d => d.colour)

// Set up forcesimulation basics:
let simulation = d3.forceSimulation()
    .force('charge', d3.forceManyBody().strength(100))
    .nodes(data)
    .on('tick', ticked)
    
// Set up the ticked function for the force simulation:
function ticked() {
  circles
    .attr("cx", function (d) {
      return d.x = Math.max(radius(d), Math.min(width - radius(d), d.x));
    })
    .attr("cy", function (d) {
      return d.y = Math.max(radius(d), Math.min(height - radius(d), d.y));
    })
}

// Advance through the steps:
let ticker = d3.interval(nextStep, tickDuration);

function nextStep() {
  step++;
   
  // Update circles
  circles.transition()
    .duration(tickDuration*0.5)
    .ease(d3.easeLinear)
    .attr('r', radius)

  // Set collision force:
  var collide = d3.forceCollide()
    .radius(function (d) {  return radius(d) + 2 })
  
  // Update force
  simulation
    .force("collide", collide)
    .alpha(1)
    .restart();

  // Update text
  stepText.text(step);
  // Check to see if we stop.
  if (step == 9) ticker.stop();
};
nextStep(); // Start first step without delay.
text { 
  font-size: 64px;
  font-weight: 700;
  opacity: 0.25;
}

circle {
  fill-opacity: 0.8;
  stroke: black;
  stroke-width: 1px;
}
<div id="chart"></div>
<script src="https://d3js.org/d3.v5.js"></script>