javascriptd3.jsforce-layout

D3.js nodes jump when restarting simulation to add or remove nodes


I'm using d3.js v6 with a force layout to represent a network graph. I'm adding and removing nodes but when I restart the simulation all the nodes jump to an upper left position and then come back to the original position.

I have this following code snippet that shows exactly what I mean, I've seen other examples online that work fine but haven't been able to find what I am doing wrong, any help is really appreciated.

var dataset = {
  nodes: [
    {
      id: 1
    }, 
    {
      id: 2
    }
  ],
  links: [{
    id: 1,
    source: 1,
    target: 2
  }]
};

let switchBool = false;

let svg = d3.select('svg')
           .attr('width', '100%')
           .attr('height', '100%');

const width = svg.node()
  .getBoundingClientRect().width;
const height = svg.node()
  .getBoundingClientRect().height;

console.log(`${width}, ${height}`);

svg = svg.append('g');

svg.append('g')
  .attr('class', 'links');

svg.append('g')
  .attr('class', 'nodes');

const simulation = d3.forceSimulation();
initSimulation();

let link = svg.select('.links')
    .selectAll('line');
  
loadLinks();

let node = svg.select('.nodes')
    .selectAll('.node');
  
loadNodes();
restartSimulation();

function initSimulation() {
    simulation
  .force('link', d3.forceLink())
  .force('charge', d3.forceManyBody())
  .force('collide', d3.forceCollide())
  .force('center', d3.forceCenter())
  .force('forceX', d3.forceX())
  .force('forceY', d3.forceY());

  simulation.force('center')
    .x(width * 0.5)
    .y(height * 0.5);

  simulation.force('link')
    .id((d) => d.id)
    .distance(100)
    .iterations(1);

  simulation.force('collide')
    .radius(10);

  simulation.force('charge')
    .strength(-100);
}

function loadLinks() {
    link = svg.select('.links')
    .selectAll('line')
    .data(dataset.links, (d) => d.id)
    .join(
      enter => enter.append('line').attr('stroke', '#000000'),
    );
}

function loadNodes() {
    node = svg.select('.nodes')
    .selectAll('.node')
    .data(dataset.nodes, (d) => d.id)
    .join(
      enter => {
        const nodes = enter.append('g')
          .attr('class', 'node')
        nodes.append('circle').attr('r', 10);
        return nodes;
      },
    );
}

function restartSimulation() {
  simulation.nodes(dataset.nodes);
  simulation.force('link').links(dataset.links);
  simulation.alpha(1).restart();
  simulation.on('tick', ticked);
}

function ticked() {
  link
    .attr('x1', (d) => d.source.x)
    .attr('y1', (d) => d.source.y)
    .attr('x2', (d) => d.target.x)
    .attr('y2', (d) => d.target.y);

  node.attr('transform', (d) => `translate(${d.x},${d.y})`);
}

function updateData() {
    switchBool = !switchBool;
  if (switchBool) {
    dataset.nodes.push({id: 3});
    dataset.links.push({id: 2, source: 1, target: 3});
  } else {
    dataset.nodes.pop();
    dataset.links.pop();
  }
  
  loadLinks();
  loadNodes();
    restartSimulation();
}
<script src="https://d3js.org/d3.v6.min.js"></script>
<div>
  <button onclick="updateData()">Add/Remove</button>
  <svg></svg>
</div>


Solution

  • It's because you use d3.forceCenter() which does not coerce nodes to a center point:

    The centering force translates nodes uniformly so that the mean position of all nodes (the center of mass if all nodes have equal weight) is at the given position ⟨x,y⟩. (docs)

    So if your two nodes are located at directly and equally below/above the centering point for d3.forceCenter, the mass is balanced. Introduce a new node and the entire force has to be transalted so that the center of mass is the center. This translation is the jump you are seeing.

    Remove forceCenter and specify the center values with d3.forceX and d3.forceY, which do nudge nodes towards the specified x and y values:

    var dataset = {
      nodes: [
        {
          id: 1
        }, 
        {
          id: 2
        }
      ],
      links: [{
        id: 1,
        source: 1,
        target: 2
      }]
    };
    
    let switchBool = false;
    
    let svg = d3.select('svg')
               .attr('width', '100%')
               .attr('height', '100%');
    
    const width = svg.node()
      .getBoundingClientRect().width;
    const height = svg.node()
      .getBoundingClientRect().height;
    
    console.log(`${width}, ${height}`);
    
    svg = svg.append('g');
    
    svg.append('g')
      .attr('class', 'links');
    
    svg.append('g')
      .attr('class', 'nodes');
    
    const simulation = d3.forceSimulation();
    initSimulation();
    
    let link = svg.select('.links')
        .selectAll('line');
      
    loadLinks();
    
    let node = svg.select('.nodes')
        .selectAll('.node');
      
    loadNodes();
    restartSimulation();
    
    function initSimulation() {
        simulation
      .force('link', d3.forceLink())
      .force('charge', d3.forceManyBody())
      .force('collide', d3.forceCollide())
      .force('forceX', d3.forceX().x(width/2))
      .force('forceY', d3.forceY().y(height/2));
    
    
    
      simulation.force('link')
        .id((d) => d.id)
        .distance(100)
        .iterations(1);
    
      simulation.force('collide')
        .radius(10);
    
      simulation.force('charge')
        .strength(-100);
    }
    
    function loadLinks() {
        link = svg.select('.links')
        .selectAll('line')
        .data(dataset.links, (d) => d.id)
        .join(
          enter => enter.append('line').attr('stroke', '#000000'),
        );
    }
    
    function loadNodes() {
        node = svg.select('.nodes')
        .selectAll('.node')
        .data(dataset.nodes, (d) => d.id)
        .join(
          enter => {
            const nodes = enter.append('g')
              .attr('class', 'node')
            nodes.append('circle').attr('r', 10);
            return nodes;
          },
        );
    }
    
    function restartSimulation() {
      simulation.nodes(dataset.nodes);
      simulation.force('link').links(dataset.links);
      simulation.alpha(1).restart();
      simulation.on('tick', ticked);
    }
    
    function ticked() {
      link
        .attr('x1', (d) => d.source.x)
        .attr('y1', (d) => d.source.y)
        .attr('x2', (d) => d.target.x)
        .attr('y2', (d) => d.target.y);
    
      node.attr('transform', (d) => `translate(${d.x},${d.y})`);
    }
    
    function updateData() {
        switchBool = !switchBool;
      if (switchBool) {
        dataset.nodes.push({id: 3});
        dataset.links.push({id: 2, source: 1, target: 3});
      } else {
        dataset.nodes.pop();
        dataset.links.pop();
      }
      
      loadLinks();
      loadNodes();
        restartSimulation();
    }
    <script src="https://d3js.org/d3.v6.min.js"></script>
    <div>
      <button onclick="updateData()">Add/Remove</button>
      <svg></svg>
    </div>