Skip to content

Drag flickers on force-directed graph

I got a problem with my D3v4 graph, each time I drag a node seems the DOM wants to redraw this node faster as it should be. I am initializing the svg area and the simulation. Further I put most of the enter().exit().remove() logic in an own function, to avoid redundancy.

I appreciated any comment and hint.

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Playground D3v4</title>
    <!-- favcon -->
    <link rel="icon" href="https://networkrepository.com/favicon.png">
    <!-- call external d3.js framework -->
    <script src="https://d3js.org/d3.v4.js"></script>
    <!-- import multiselection framework -->
    <script src="https://d3js.org/d3-selection-multi.v1.js"></script>
    <!-- import "font awesome" stylesheet https://fontawesome.com/ -->
    <script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>

<style>
    body {
        overflow: hidden;
        margin: 0px;
    }

    .canvas {
        background-color: rgb(220, 220, 220);
    }

    .link {
        stroke: rgb(0, 0, 0);
        stroke-width: 1px;
    }

    circle {
        fill: whitesmoke
    }

    .node {
        stroke: white;
        stroke-width: 2px
    }

    .tooltip {
        font-family: "Open Sans", sans-serif;
        position: absolute;
        text-align: left;
        background: rgb(245, 245, 245);
        border: 2px;
        border-radius: 6px;
        border-color: rgb(255, 255, 255);
        border-style: solid;
        pointer-events: none;
        line-height: 150%;
        padding: 8px 10px;
    }

    #context-menu {
        font-family: "Open Sans", sans-serif;
        position: fixed;
        z-index: 10000;
        width: 190px;
        background: whitesmoke;
        border: 2px;
        border-radius: 6px;
        border-color: white;
        border-style: solid;
        transform: scale(0);
        transform-origin: top left;
    }

    #context-menu.active {
        transform: scale(1);
        transition: transform 200ms ease-in-out;
    }

    #context-menu .item {
        padding: 8px 10px;
        font-size: 15px;
        color: black;
    }

    #context-menu .item i {
        display: inline-block;
        margin-right: 5px;
    }

    #context-menu hr {
        margin: 5px 0px;
        border-color: whitesmoke;
    }

    #context-menu .item:hover {
        background: lightblue;
    }
</style>

<body>
    <!-- right click context menu -->
    <div id="context-menu">
        <div id="addObject" class="item">
            <i class="fa fa-plus-circle"></i> Add Node
        </div>
        <div id="removeObject" class="item">
            <i class="fa fa-minus-circle"></i> Remove Node
        </div>
    </div>

    <svg id="svg"> </svg>


    <!-- call script where the main application is written -->
    <script>
        var graph = {
            "nodes": [{
                "id": 0,
                "name": "Company",
            },
            {
                "id": 1,
                "name": "1",
            },
            {
                "id": 2,
                "name": "2",
            },
            {
                "id": 3,
                "name": "3",
            },
            {
                "id": 4,
                "name": "4",
            }
            ],
            "links": [{
                "id": 0,
                "source": 1,
                "target": 0,
            },
            {
                "id": 1,
                "source": 2,
                "target": 0,
            },
            {
                "id": 2,
                "source": 3,
                "target": 0,
            },
            {
                "id": 3,
                "source": 4,
                "target": 0,
            },
            ]
        }

        // declare initial variables
        var svg = d3.select("svg")
        width = window.innerWidth
        height = window.innerHeight
        thisNode = null;

        // define cavnas area to draw everything
        svg = d3.select("svg")
            .attr("class", "canvas")
            .attr("width", width)
            .attr("height", height)
            .append("g")

        // iniital force simulation
        var simulation = d3.forceSimulation()
            .force("link", d3.forceLink().id(function (d) {
                return d.id;
            }).distance(100))
            .force("charge", d3.forceManyBody().strength(-80))
            .force("center", d3.forceCenter(width / 2, height / 2))
            .force("attraceForce", d3.forceManyBody().strength(70));

        
        var node_group = null
        var link_group = null
            
        update()
        
        /*
        console.log("Initial Nodes")
        console.log(graph.nodes)
        console.log("------------------")
        */

        function update() {
            //define group and join
            node_group = svg.selectAll(".node_group")
                .data(graph.nodes, d => d.id)
            
            //exit, remove
            node_group.exit().remove();
            
            //enter
            var enter = node_group.enter()
                .append("g").attr("class", "node_group");
                
            //append - as many items as you need
            enter.append("circle")
                .attr("class", "node_circle")
                .attr("r", 20)
                .on("contextmenu", contextMenu)
                .call(d3.drag()
                    .on("start", dragStarted)
                    .on("drag", dragged)
                    .on("end", dragEnded)
                )

            enter.append("text")
                .attr("class", "node_label")
                .text(function (d) {
                    return d.name
                })
            
            //merge
            node_group = node_group.merge(enter);

            simulation
                .nodes(graph.nodes)
                .on("tick", ticked);

            simulation.alphaTarget(0.3).restart()
        }

        function contextMenu(d) {
            thisNode = d

            event.preventDefault()

            var contextMenu = document.getElementById("context-menu")
            contextMenu.style.top = event.clientY + "px"
            contextMenu.style.left = event.clientX + "px"
            contextMenu.classList.add("active")

            window.addEventListener("click", function () {
                contextMenu.classList.remove("active")
            })

            document.getElementById("addObject").addEventListener("click", addNode)
            document.getElementById("removeObject").addEventListener("click", removeNodeClicked)

        }

        function addNodeClicked() {
            addNode(thisNode)
        }

        function removeNodeClicked() {
            removeNode(thisNode)
        }

        function addNode() {
            var newID = Math.floor(Math.random() * 100000)

            /*
            console.log("Before adding Node")
            console.log(graph.nodes)
            console.log("------------------")
            */
            
            graph.nodes.push({ id: newID, name: "Software_" + newID })

            /*
            console.log("After adding Node")
            console.log(graph.nodes)
            console.log("------------------")
            */

            update()
        }

        function removeNode(thisNode) {
            var indexOfNode = graph.nodes.indexOf(thisNode)

            /*
            console.log("Before removing Node")
            console.log(graph.nodes)
            console.log("------------------")
            */
            
            graph.nodes.splice(indexOfNode, 1)

            /*
            console.log("After removing Node")
            console.log(graph.nodes)
            console.log("------------------")
            */
            
            update()
        }

        function ticked() {
            // update link positions

            // update node positions
            node_group
                .attr("transform", function (d) { return "translate(" + d.x + ", " + d.y + ")"; });
        }

        function dragStarted(d) {
            if (!d3.event.active) simulation.alphaTarget(0.3).restart();
            d.fx = d.x;
            d.fy = d.y;
        }

        function dragged(d) {
            d.fx = d3.event.x;
            d.fy = d3.event.y;
        }

        function dragEnded(d) {
            if (!d3.event.active) simulation.alphaTarget(0);
            d.fx = undefined;
            d.fy = undefined;
        }

    </script>
</body>

</html>

Answer

In the ticked function you are translating the groups, not the circles. Therefore, you should call d3.drag on the same groups:

node_group.call(d3.drag()
    .on("start", dragStarted)
    .on("drag", dragged)
    .on("end", dragEnded)
)

Here is your code with that change:

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <title>Playground D3v4</title>
  <!-- favcon -->
  <link rel="icon" href="https://networkrepository.com/favicon.png">
  <!-- call external d3.js framework -->
  <script src="https://d3js.org/d3.v4.js"></script>
  <!-- import multiselection framework -->
  <script src="https://d3js.org/d3-selection-multi.v1.js"></script>
  <!-- import "font awesome" stylesheet https://fontawesome.com/ -->
  <script src="https://kit.fontawesome.com/39094309d6.js" crossorigin="anonymous"></script>
</head>

<style>
  body {
    overflow: hidden;
    margin: 0px;
  }
  
  .canvas {
    background-color: rgb(220, 220, 220);
  }
  
  .link {
    stroke: rgb(0, 0, 0);
    stroke-width: 1px;
  }
  
  circle {
    fill: whitesmoke
  }
  
  .node {
    stroke: white;
    stroke-width: 2px
  }
  
  .tooltip {
    font-family: "Open Sans", sans-serif;
    position: absolute;
    text-align: left;
    background: rgb(245, 245, 245);
    border: 2px;
    border-radius: 6px;
    border-color: rgb(255, 255, 255);
    border-style: solid;
    pointer-events: none;
    line-height: 150%;
    padding: 8px 10px;
  }
  
  #context-menu {
    font-family: "Open Sans", sans-serif;
    position: fixed;
    z-index: 10000;
    width: 190px;
    background: whitesmoke;
    border: 2px;
    border-radius: 6px;
    border-color: white;
    border-style: solid;
    transform: scale(0);
    transform-origin: top left;
  }
  
  #context-menu.active {
    transform: scale(1);
    transition: transform 200ms ease-in-out;
  }
  
  #context-menu .item {
    padding: 8px 10px;
    font-size: 15px;
    color: black;
  }
  
  #context-menu .item i {
    display: inline-block;
    margin-right: 5px;
  }
  
  #context-menu hr {
    margin: 5px 0px;
    border-color: whitesmoke;
  }
  
  #context-menu .item:hover {
    background: lightblue;
  }
</style>

<body>
  <!-- right click context menu -->
  <div id="context-menu">
    <div id="addObject" class="item">
      <i class="fa fa-plus-circle"></i> Add Node
    </div>
    <div id="removeObject" class="item">
      <i class="fa fa-minus-circle"></i> Remove Node
    </div>
  </div>

  <svg id="svg"> </svg>


  <!-- call script where the main application is written -->
  <script>
    var graph = {
      "nodes": [{
          "id": 0,
          "name": "Company",
        },
        {
          "id": 1,
          "name": "1",
        },
        {
          "id": 2,
          "name": "2",
        },
        {
          "id": 3,
          "name": "3",
        },
        {
          "id": 4,
          "name": "4",
        }
      ],
      "links": [{
          "id": 0,
          "source": 1,
          "target": 0,
        },
        {
          "id": 1,
          "source": 2,
          "target": 0,
        },
        {
          "id": 2,
          "source": 3,
          "target": 0,
        },
        {
          "id": 3,
          "source": 4,
          "target": 0,
        },
      ]
    }

    // declare initial variables
    var svg = d3.select("svg")
    width = window.innerWidth
    height = window.innerHeight
    thisNode = null;

    // define cavnas area to draw everything
    svg = d3.select("svg")
      .attr("class", "canvas")
      .attr("width", width)
      .attr("height", height)
      .append("g")

    // iniital force simulation
    var simulation = d3.forceSimulation()
      .force("link", d3.forceLink().id(function(d) {
        return d.id;
      }).distance(100))
      .force("charge", d3.forceManyBody().strength(-80))
      .force("center", d3.forceCenter(width / 2, height / 2))
      .force("attraceForce", d3.forceManyBody().strength(70));


    var node_group = null
    var link_group = null

    update()

    /*
    console.log("Initial Nodes")
    console.log(graph.nodes)
    console.log("------------------")
    */

    function update() {
      //define group and join
      node_group = svg.selectAll(".node_group")
        .data(graph.nodes, d => d.id)

      //exit, remove
      node_group.exit().remove();

      //enter
      var enter = node_group.enter()
        .append("g").attr("class", "node_group");

      //append - as many items as you need
      enter.append("circle")
        .attr("class", "node_circle")
        .attr("r", 20)
        .on("contextmenu", contextMenu)

      enter.append("text")
        .attr("class", "node_label")
        .text(function(d) {
          return d.name
        })

      //merge
      node_group = node_group.merge(enter);

      node_group.call(d3.drag()
        .on("start", dragStarted)
        .on("drag", dragged)
        .on("end", dragEnded)
      )

      simulation
        .nodes(graph.nodes)
        .on("tick", ticked);

      simulation.alphaTarget(0.3).restart()
    }

    function contextMenu(d) {
      thisNode = d

      event.preventDefault()

      var contextMenu = document.getElementById("context-menu")
      contextMenu.style.top = event.clientY + "px"
      contextMenu.style.left = event.clientX + "px"
      contextMenu.classList.add("active")

      window.addEventListener("click", function() {
        contextMenu.classList.remove("active")
      })

      document.getElementById("addObject").addEventListener("click", addNode)
      document.getElementById("removeObject").addEventListener("click", removeNodeClicked)

    }

    function addNodeClicked() {
      addNode(thisNode)
    }

    function removeNodeClicked() {
      removeNode(thisNode)
    }

    function addNode() {
      var newID = Math.floor(Math.random() * 100000)

      /*
      console.log("Before adding Node")
      console.log(graph.nodes)
      console.log("------------------")
      */

      graph.nodes.push({
        id: newID,
        name: "Software_" + newID
      })

      /*
      console.log("After adding Node")
      console.log(graph.nodes)
      console.log("------------------")
      */

      update()
    }

    function removeNode(thisNode) {
      var indexOfNode = graph.nodes.indexOf(thisNode)

      /*
      console.log("Before removing Node")
      console.log(graph.nodes)
      console.log("------------------")
      */

      graph.nodes.splice(indexOfNode, 1)

      /*
      console.log("After removing Node")
      console.log(graph.nodes)
      console.log("------------------")
      */

      update()
    }

    function ticked() {
      // update link positions

      // update node positions
      node_group
        .attr("transform", function(d) {
          return "translate(" + d.x + ", " + d.y + ")";
        });
    }

    function dragStarted(d) {
      if (!d3.event.active) simulation.alphaTarget(0.3).restart();
      d.fx = d.x;
      d.fy = d.y;
    }

    function dragged(d) {
      d.fx = d3.event.x;
      d.fy = d3.event.y;
    }

    function dragEnded(d) {
      if (!d3.event.active) simulation.alphaTarget(0);
      d.fx = undefined;
      d.fy = undefined;
    }
  </script>
</body>

</html>