javascriptd3.jsd3-force-directed

How can I add data in chunks to a d3 force directed graph?


I am trying to add nodes to a D3 force directed graph dynamically. I believe I am close, but am not understanding some critical concept related to how selections in D3 work.

If I call my addData function directly and add all of the nodes at once, everything works as expected.

However, if I use the addDataInChunks, the first two nodes and first link are added, but after that no further nodes appear in the graph. Additionally, the ability to drag nodes around is lost as soon as the second chunk is added to the graph.

What have I done wrong? How does my code need to change so this will work?

    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
    let node = null
    let link = null
    let simulation = null

    const width = 750
    const height = 400
    const nodeCount = 10
    const svgNS = d3.namespace('svg:text').space

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

    const simulationNodes = []
    const simulationLinks = []

    function addData(addNodes, addLinks) {
      const links = addLinks.map((d) => ({ ...d }))
      const nodes = addNodes.map((d, index) => ({ ...d }))

      console.log(`🚀 ~ nodes:`, nodes)
      console.log(`🚀 ~ links:`, links)

      simulationNodes.push(...nodes)
      simulationLinks.push(...links)

      simulation = d3
        .forceSimulation(simulationNodes)
        .force(
          'link',
          d3
            .forceLink(simulationLinks)
            .id((d) => d.id)
            .distance((d) => 50)
        )
        .force('charge', d3.forceManyBody().strength(-400))
        .force('x', d3.forceX())
        .force('y', d3.forceY())
        .on('tick', ticked)

      node = node
        .selectAll()
        .data(nodes)
        .join((enter) => {
          return enter.append((d) => {
            const circleElement = document.createElementNS(svgNS, 'circle')

            circleElement.setAttribute('r', 16)
            circleElement.setAttribute('fill', '#318631')
            circleElement.setAttribute('stroke', '#7CC07C')
            circleElement.setAttribute('stroke-width', '3')

            return circleElement
          })
        })

      node.append('title').text((d) => `hello ${d.id}`)
      node.call(d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended))

      link = link.selectAll().data(links).join('line').attr('stroke-width', 1)
    }

    setupGraph()

    // addData(nodeData, linkData)

    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() {
      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.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
    }
.graph {
      width: 750px;
      height: 400px;
    }
<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>


Solution

  • Here's the summary of my changes:

    First, you need to selectAll on a "parent" of the elements. You are selecting on the selection which doesn't make sense. This works the first go-around because all the data is "entering" but subsequently d3 won't find the already present bound data. You should also explicitly selectAll on an element or class.

    Second, while you are appending and keeping track of the simulationNodes and simulationLinks you need to pass that same appended data to the selection. Otherwise, d3 can't calculate the entering vs updating data.

    Third, you need to manage the life-cycle of the simulation. Meaning you need to stop it and restart it after modifying its data.

    Fourth, I cleaned up your data-joins. I found the use of native svg methods strange when mixed with d3. I also modified them to have explicit enter, update, and exit methods. You aren't using the exit but I included it for completeness sake.

    Finally, I did some general clean-up like clearer variable names, not re-creating the simulation on each data modification and moving the graph to the center of the svg.

    <!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;
          const nodeCount = 10;
    
          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) {
            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('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>