简体   繁体   中英

Why do nodes in force layout jump from origin on update

Why do my circles jump from (0,0) when I update my data each iteration?

I want to make a force layout with circles that change radius when the data updates. I can't figure out how to use d3 forces inside a loop. All I can get is circles that jumping from the origin while they change their sizes. I suppose that problem lies in the way how d3 stores and sets the coordinates to objects.

Here is my code:

 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 dx = Math.max(rad(d.value), Math.min(width - rad(d.value), dx)); }) .attr("cy", function (d) { return dy = Math.max(rad(d.value), Math.min(height - rad(d.value), dy)); }) d3.selectAll('.label') .attr("cx", function (d) { return dx = Math.max(rad(d.value), Math.min(width - rad(d.value), dx)); }) .attr("cy", function (d) { return dy = Math.max(rad(d.value), Math.min(height - rad(d.value), dy)); }); } 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>

The ultimate issue lies with d3-force given your code, but I'm going to propose an alternative method of creating this visualization (if I understand it correctly). This proposed alternative will be more consistent with the patterns D3 was designed for.

The proposed solution will address the issue with d3-force but also make the data binding easier too. I'll attempt to explain why a different approach might be preferrable on both points below:

D3 and Data Binding

D3 places a considerable focus on data binding. Items in a data array are bound to elements in the DOM.

Generally speaking the best data source for use in D3 is one where the data is an array and every item in that data array is represented by an element in the DOM. This way we can enter, update, exit easily.

Your data is an array of 30 items, of which you show only 3. The elements have new data bound to them every step/interval. This requires filtering the data array, using a key function, and rebinding new data each step/interval. This newly bound data is used to update the circles and the force layout.

A more straight forward method with D3 would be to structure your data differently. You have three circles so your data array should have three objects in it. These objects should have properties to hold the step data. Then we can update the circles based on what step we're on. No reselection, rebinding, filtering, etc neccesarry. The downside is this often requires restructuring your data prior to even getting to the visualization code - but the returns are worth it when you start making the visualization.

D3 Force Simulation

The nodes method of a D3 force simulation takes an array of objects. If these objects don't have x , y , vx , vy properties, the force simulation modifies these objects to give them these properties (it does not clone these objects to do so). This is the initialization of nodes. If you replace the original nodes with new ones, the simulation will initialize the new nodes. There is no reference to the previous nodes - the nodes are the objects themselves (you aren't replacing a data portion of the node, you're replacing the entire node).

To address this in your current approach we'd need to take the x , y , vx , vy properties of each node of the current step and assign those properties to the nodes of the next step at the beginning of each step.

Instead, let's have the force simulation keep the same nodes the whole time. As above, we have three nodes, so we have a data array of three objects, each with all the step data contained within it. Now we don't need to filter nodes, transfer properties, etc.

Alternative Approach

We'll use a data array that contains one object for each thing we want to represent:

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"
  }
];

Now when we want to advance to the nth step, we can get the current radius with someScale(d.steps[n]) . This lets us set the drawn radius and the collision radius without rebinding data to the DOM elements and without providing new nodes to the force.

Now we can set up everything that isn't dependent on step:

  • SVG
  • Scales
  • Tick function
  • Data
  • Most of simulation set up
  • Enter SVG elements

However, you don't need to set the initial radius to the first step's value when you add the circles because we'll do that each time we move a step. We just need the static properties of each SVG element (That said, I've set the initial radii to zero below so we can transition in nicely).

Then we can place all the code that is changes with each step within the interval function. This code will do only a few things:

  • Increment to the next step
  • Set a new collide force with new radii
  • Transition the radii of the circles
  • Apply the new collide force and reheat the simulation.
  • Update the text that shows what step we're on.

Notes

I've heavily modified your code to show a simplified example of the alternative. The largest change not part of the above explanation is the removal of labels for simplicity.

One thing to note about transitions and force layouts. If you transition a radius and set the radius in the tick function, the tick function and the transition will constantly over-ride one another. Transitions should modify attributes that are not modified in the tick and vice versa. In your code you modify each circles r in both tick and transition.

I've changed the transition time to be less than the duration between each interval: this way I can insure that the transition is complete prior to beginning the next step and its corresponding transition. I've exaggerated this change cause I liked the pause.

I have a function below ( radius ) that uses the current step and datum to return a radius. Because I wanted to use just this where I need a circle's radius, I start at step -1. The reason is because I need to increment step at the beginning of the nextStep function. If I increment at the end of this function then the ticked function - which runs continuously - will use a different step than the rest of the nextStep function. The radius function has been written so that if the step is -1, it'll use the zeroth step to avoid issues on initialization. There may be nicer ways of handling this, I felt this was the simplest.

I can add more comments if necessary, but I hope that the above explanation and my limited comments are sufficient:

 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 dx = Math.max(radius(d), Math.min(width - radius(d), dx)); }) .attr("cy", function (d) { return dy = Math.max(radius(d), Math.min(height - radius(d), dy)); }) } // 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>

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM