Search code examples
reactjsd3.jsuse-effectd3-force-directed

How to add only new nodes to d3 force directed graph?


I have created a force directed graph using d3, which is rendering in a react component using the useEffect hook. The graph is initially rendered correctly, but if it is re-rendered/updated by new nodes being submitted through an input form, the existing nodes are duplicated.

I thought that the existing nodes would be left alone and only new nodes would be created after .enter(), which is clearly not happening. Any help with where I'm going wrong?

Edit 1: this is a sample of the data coming in

var nodesData = [
{"id": "Do Something", "type": "activity"},
{"id": "My Document", "type": "object"}
]

This is the code for the graph:

import React, { useRef, useEffect } from 'react';
import * as d3 from 'd3';
import '../../custom_styles/bpForceDirected.css';

interface IProps {
    data?: string;
    linkData?: string;
}

/* Component */
export const BpForceDirectedGraph = (props: IProps) => {

    const d3Container = useRef(null);

    /* The useEffect Hook is for running side effects outside of React,
       for instance inserting elements into the DOM using D3 */
    useEffect(
        () => {
            if (props.data && d3Container.current) {
                var w=500;
                var h=500;
                const svg = d3.select(d3Container.current)
                            .attr("viewBox", "0 0 " + w + " " + h )
                            .attr("preserveAspectRatio", "xMidYMid meet");

                var simulation = d3.forceSimulation()
                    .nodes(props.data);
                simulation
                    .force("charge_force", d3.forceManyBody())
                    .force("center_force", d3.forceCenter(w / 2, h / 2));

                function circleColor(d){
                    if(d.type ==="activity"){
                        return "blue";
                    } else {
                        return "pink";
                    }
                }

                function linkColor(d){
                    console.log(d); 
                    if(d.type === "Activity Output"){
                        return "green";
                    } else {
                        return "red";
                    }
                }

                //Create a node that will contain an object and text label      
                var node = svg.append("g")
                  .attr("class", "nodes")
                  .selectAll("g")
                  .data(props.data)
                  .enter()
                  .append("g");

                node.append("circle")
                        .attr("r", 10)
                        .attr("fill", circleColor);

                node.append("text")
                    .attr("class", "nodelabel")
                    .attr("dx", 12)
                    .attr("dy", ".35em")
                    .text(function(d) { return d.activityname });

                // The complete tickActions() function    
                function tickActions() {
                //update circle positions each tick of the simulation 
                    node.attr('transform', d => `translate(${d.x},${d.y})`);

                //update link positions 
                //simply tells one end of the line to follow one node around
                //and the other end of the line to follow the other node around
                    link
                        .attr("x1", function(d) { return d.source.x; })
                        .attr("y1", function(d) { return d.source.y; })
                        .attr("x2", function(d) { return d.target.x; })
                        .attr("y2", function(d) { return d.target.y; });
                }  

                  simulation.on("tick", tickActions );

                //Create the link force 
                //We need the id accessor to use named sources and targets 
                var link_force =  d3.forceLink(props.linkData)
                    .id(function(d) { return d.id; })

                simulation.force("links",link_force)

                //draw lines for the links 
                var link = svg.append("g")
                    .attr("class", "links")
                    .selectAll("line")
                    .data(props.linkData)
                    .enter().append("line")
                        .attr("stroke-width", 2)
                        .style("stroke", linkColor);

                // Remove old D3 elements
                node.exit()
                    .remove();
            }
        },

        [props.data, props.linkData, /*d3Container.current*/])

    return (
        <svg
            className="d3-component"
            ref={d3Container}
        />
    );
}

export default BpForceDirectedGraph;

Edit 2: Reproducible code example


Solution

  • The problem happens because the function generating the chart is called at each update, and does not take into account the existing content.

    Here is one way to solve the problem:

    Empty the SVG at the beginning of each execution of useEffect, when (re)defining the variable svg. As shown below, .html('') empties the existing SVG nodes.

      const svg = d3
        .select(d3Container.current)
        .html("")
        .attr("viewBox", "0 0 " + w + " " + h)
        .attr("preserveAspectRatio", "xMidYMid meet");
    

    A more elegant approach would be to update the code so that a function initializes the chart, and a second one (re)generates the graph, my understanding about react is that this is done using componentDidMount and componentDidUpdate.