How is entering new nodes in a force directed graph working?

This is related to my earlier post:

I am still missing a basic understanding of how D3 is operating.

When new nodes are add, I would like to have them start in the middle rather then in the top left corner. To accomplish this, I tried doing the following:

          .join(
            (enter) => {
              let e = enter
                .append('circle')
                .attr('cx', (d) => {
                  console.log(`πŸš€ ~ cx .attr ~ d:`, d)
                  return width / 2
                })
                .attr('cy', (d) => {
                  console.log(`πŸš€ ~ cy .attr ~ d:`, d)
                  return height / 2
                })
                .attr('r', 16)
                .attr('fill', '#318631')
                .attr('stroke', '#7CC07C')
                .attr('stroke-width', '3')
              e.append('title').text((d) => `hello ${d.id}`)

              return e
            },

The idea was to provide initial cx & cy values for the new circles. It was my understanding that D3 figures out which nodes are new and that only the new nodes would be passing through enter.

However, I was seeing two strange things:

  1. The nodes are still starting in the upper left corner before being pulled to the center. I tried changing cx and cy to x and y, but the behavior remained the same.

  2. I thought that enter would apply only the new nodes. However, logging the information, I am seeing:

For the first add, It is operating as expected. Nodes 1 and 2 are new and they are the ones passing through the two .attr functions.

For the second add, only node 3 is being added. However, both node 2 and node 3 are being passed through the two .attr functions.

For the third add, only node 4 is being added. However, nodes 2, 3, & 4 are passing through the two .attr functions.

What I am also finding strange is that node 1 does not show up again in the second and third adds. Why not? Why does 2 show up in the second and third adds? Why does 3 show up in the third add?

Can anyone explain what is happening?

Here is a complete example.

<!DOCTYPE html>

<html>
  <head>
    <style>
      .graph {
        width: 750px;
        height: 400px;
      }
    </style>
  </head>

  <body>
    <script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>
    <svg ref="chart" id="chart" class="graph" style="background-color: #141621"></svg>
    <script>
      const nodeData = [
        {
          id: 'node 1',
          added: false,
        },
        {
          id: 'node 2',
          added: false,
        },
        {
          id: 'node 3',
          added: false,
        },
        {
          id: 'node 4',
          added: false,
        },
      ]

      const linkData = [
        {
          linkID: 'link 1',
          added: false,
          source: 'node 1',
          target: 'node 2',
        },
        {
          linkID: 'link 2',
          added: false,
          source: 'node 1',
          target: 'node 3',
        },
        {
          linkID: 'link 3',
          added: false,
          source: 'node 3',
          target: 'node 4',
        },
      ]

      //
      //
      //

      let svg = null

      const width = 750
      const height = 400

      function setupGraph() {
        svg = d3.select('#chart').call(d3.zoom().on('zoom', zoomed)).append('g')
        nodeGroup = svg.append('g').attr('stroke', '#fff').attr('stroke-width', 1.5)
        linkGroup = svg.append('g').attr('stroke', '#999').attr('stroke-opacity', 0.6)
      }

      // nodes and links are the d3 selections
      let nodes = null
      let links = null
      // currentNodes and currentLinks is the current data
      const currentNodes = []
      const currentLinks = []

      // the link simulation
      const linkSim = d3
        .forceLink()
        .id((d) => d.id)
        .distance((d) => 50)

      // overall simulation
      const simulation = d3
        .forceSimulation()
        .force('link', linkSim)
        .force('charge', d3.forceManyBody().strength(-400))
        .force('x', d3.forceX())
        .force('y', d3.forceY())
        .force('center', d3.forceCenter(width / 2, height / 2))
        .on('tick', ticked)

      function addData(addNodes, addLinks) {
        console.log(`πŸš€ ~ addData ~ addNodes:`, addNodes)
        console.log(`πŸš€ ~ addData ~ addLinks:`, addLinks)

        addNodes = addNodes.map((d, index) => ({ ...d }))
        addLinks = addLinks.map((d) => ({ ...d }))

        currentNodes.push(...addNodes)
        currentLinks.push(...addLinks)

        nodes = nodeGroup
          .selectAll('circle')
          .data(currentNodes, (d) => d)
          .join(
            (enter) => {
              let e = enter
                .append('circle')
                .attr('cx', (d) => {
                  console.log(`πŸš€ ~ cx .attr ~ d:`, d)
                  return width / 2
                })
                .attr('cy', (d) => {
                  console.log(`πŸš€ ~ cy .attr ~ d:`, d)
                  return height / 2
                })
                .attr('r', 16)
                .attr('fill', '#318631')
                .attr('stroke', '#7CC07C')
                .attr('stroke-width', '3')
              e.append('title').text((d) => `hello ${d.id}`)

              return e
            },
            (update) => update,
            (exit) => exit.remove()
          )

        nodes.call(d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended))

        links = linkGroup
          .selectAll('line')
          .data(currentLinks)
          .join(
            (enter) => enter.append('line').attr('stroke-width', 1),
            (update) => update,
            (exit) => exit.remove()
          )

        // stop and start the simulation
        simulation.stop()
        simulation.nodes(currentNodes)
        linkSim.links(currentLinks)
        simulation.alpha(0.3).restart()
      }

      setupGraph()

      async function addDataInChunks(allNodes, allLinks) {
        const timeout = 7000

        let nodeChunk = allNodes.slice(0, 2)
        let linkChunk = allLinks.slice(0, 1)

        addData(nodeChunk, linkChunk)
        await new Promise((r) => setTimeout(r, timeout))
        //
        nodeChunk = allNodes.slice(2, 3)
        linkChunk = allLinks.slice(1, 2)

        addData(nodeChunk, linkChunk)
        await new Promise((r) => setTimeout(r, timeout))
        //
        nodeChunk = allNodes.slice(3, 4)
        linkChunk = allLinks.slice(2, 3)

        addData(nodeChunk, linkChunk)
        await new Promise((r) => setTimeout(r, timeout))

        console.log('addDataInChunks finished')
      }

      addDataInChunks(nodeData, linkData)

      //
      // Misc Functions
      //

      function zoomed(transform) {
        const t = transform.transform

        const container = this.getElementsByTagNameNS(svgNS, 'g')[0]
        const transformString = 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')'

        container.setAttribute('transform', transformString)
      }

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

        nodes.each(function (d) {
          this.setAttribute('cx', d.x)
          this.setAttribute('cy', d.y)
        })
      }

      function dragstarted(event) {
        if (!event.active) simulation.alphaTarget(0.3).restart()
        event.subject.fx = event.subject.x
        event.subject.fy = event.subject.y
      }

      function dragged(event) {
        event.subject.fx = event.x
        event.subject.fy = event.y
      }

      function dragended(event) {
        if (!event.active) simulation.alphaTarget(0)
        event.subject.fx = null
        event.subject.fy = null
      }
    </script>
  </body>
</html>

That’s a bit too much code for me to look at in detail, but my general advice would be to shrink your dataset to the smallest number of items that are still representative of your problem, then keep an eye on the Elements panel in your browser’s devtools while you run your code.

You may also want to add asserts that verify your assumptions about a certain state.

I have shrunk everything as much as I can. It is both simple and straightforward. There are only 4 nodes and three links.

I am beginning to wonder if this represents a bug in D3…

I am wondering, without looking at the code, if you could confirm my general understanding that the nodes passing through enter should only be the new nodes being added…?

Sorry, I didn’t read your question properly the first time.

Your node objects are the only source of truth for force calculations. If you want new nodes to appear at a specific location you need to initialize their position:

currentNodes.push(...addNodes.map((d) => ({
  ...d,
  x: width / 2,
  y: height / 2
})))

Got it. Thank you.

If one more question could be answered so I can better understand how D3 operates, I would appreciate it.

It was general understanding that the nodes passing through enter should only be the new nodes being added. But, this is clearly not the case. I am having difficulty understanding why Node 1 only shows up on the first add and Node 2 shows up in all of them, for example. Can you provide some insight into this behavior?

Thanks again.

Key values have to be strings. What you do in your .data() call is equivalent to

.data(currentNodes, (d) => String(d)) // "[object Object]"

Either return the node ID

.data(currentNodes, (d) => d.id)

or let D3 use the element order:

.data(currentNodes)

Got it. Works perfectly now. I think I am finally starting to understand the design and philosophy of D3.

For anyone who is interested, here is the final version:

<!DOCTYPE html>

<html>
  <head>
    <style>
      .graph {
        width: 750px;
        height: 400px;
      }
    </style>
  </head>

  <body>
    <script src="https://d3js.org/d3.v7.min.js" charset="utf-8"></script>
    <svg ref="chart" id="chart" class="graph" style="background-color: #141621"></svg>
    <script>
      const width = 750
      const height = 400

      const nodeData = [
        {
          id: 'node 1',
          added: false,
          x: width / 2,
          y: height / 2,
        },
        {
          id: 'node 2',
          added: false,
          x: width / 2,
          y: height / 2,
        },
        {
          id: 'node 3',
          added: false,
          x: width / 2,
          y: height / 2,
        },
        {
          id: 'node 4',
          added: false,
          x: width / 2,
          y: height / 2,
        },
      ]

      const linkData = [
        {
          linkID: 'link 1',
          added: false,
          source: 'node 1',
          target: 'node 2',
        },
        {
          linkID: 'link 2',
          added: false,
          source: 'node 1',
          target: 'node 3',
        },
        {
          linkID: 'link 3',
          added: false,
          source: 'node 3',
          target: 'node 4',
        },
      ]

      //
      //
      //

      let svg = null

      function setupGraph() {
        svg = d3.select('#chart').call(d3.zoom().on('zoom', zoomed)).append('g')
        nodeGroup = svg.append('g').attr('stroke', '#fff').attr('stroke-width', 1.5)
        linkGroup = svg.append('g').attr('stroke', '#999').attr('stroke-opacity', 0.6)
      }

      // nodes and links are the d3 selections
      let nodes = null
      let links = null
      // currentNodes and currentLinks is the current data
      const currentNodes = []
      const currentLinks = []

      // the link simulation
      const linkSim = d3
        .forceLink()
        .id((d) => d.id)
        .distance((d) => 50)

      // overall simulation
      const simulation = d3
        .forceSimulation()
        .force('link', linkSim)
        .force('charge', d3.forceManyBody().strength(-400))
        .force('x', d3.forceX())
        .force('y', d3.forceY())
        .force('center', d3.forceCenter(width / 2, height / 2))
        .on('tick', ticked)

      function addData(addNodes, addLinks) {
        console.log(`πŸš€ ~ addData ~ addNodes:`, addNodes)
        console.log(`πŸš€ ~ addData ~ addLinks:`, addLinks)

        addNodes = addNodes.map((d, index) => ({ ...d }))
        addLinks = addLinks.map((d) => ({ ...d }))

        currentNodes.push(...addNodes)
        currentLinks.push(...addLinks)

        nodes = nodeGroup
          .selectAll('circle')
          .data(currentNodes, (d) => d.id)
          .join(
            (enter) => {
              let e = enter
                .append('circle')
                .attr('r', 16)
                .attr('fill', '#318631')
                .attr('stroke', '#7CC07C')
                .attr('stroke-width', '3')
              e.append('title').text((d) => `hello ${d.id}`)

              return e
            },
            (update) => update,
            (exit) => exit.remove()
          )

        nodes.call(d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended))

        links = linkGroup
          .selectAll('line')
          .data(currentLinks)
          .join(
            (enter) => enter.append('line').attr('stroke-width', 1),
            (update) => update,
            (exit) => exit.remove()
          )

        // stop and start the simulation
        simulation.stop()
        simulation.nodes(currentNodes)
        linkSim.links(currentLinks)
        simulation.alpha(0.3).restart()
      }

      setupGraph()

      async function addDataInChunks(allNodes, allLinks) {
        const timeout = 7000

        let nodeChunk = allNodes.slice(0, 2)
        let linkChunk = allLinks.slice(0, 1)

        addData(nodeChunk, linkChunk)
        await new Promise((r) => setTimeout(r, timeout))
        //
        nodeChunk = allNodes.slice(2, 3)
        linkChunk = allLinks.slice(1, 2)

        addData(nodeChunk, linkChunk)
        await new Promise((r) => setTimeout(r, timeout))
        //
        nodeChunk = allNodes.slice(3, 4)
        linkChunk = allLinks.slice(2, 3)

        addData(nodeChunk, linkChunk)
        await new Promise((r) => setTimeout(r, timeout))

        console.log('addDataInChunks finished')
      }

      addDataInChunks(nodeData, linkData)

      //
      // Misc Functions
      //

      function zoomed(transform) {
        const t = transform.transform

        const container = this.getElementsByTagNameNS(svgNS, 'g')[0]
        const transformString = 'translate(' + t.x + ',' + t.y + ') scale(' + t.k + ')'

        container.setAttribute('transform', transformString)
      }

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

        nodes.each(function (d) {
          this.setAttribute('cx', d.x)
          this.setAttribute('cy', d.y)
        })
      }

      function dragstarted(event) {
        if (!event.active) simulation.alphaTarget(0.3).restart()
        event.subject.fx = event.subject.x
        event.subject.fy = event.subject.y
      }

      function dragged(event) {
        event.subject.fx = event.x
        event.subject.fy = event.y
      }

      function dragended(event) {
        if (!event.active) simulation.alphaTarget(0)
        event.subject.fx = null
        event.subject.fy = null
      }
    </script>
  </body>
</html>