Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

d3.js: How to remove nodes when link-data updates in a force layout

I'm using a force layout graph to show a network but I have problems when updating my data.

I already check How to update elements of D3 force layout when the underlying data changes, and of course the "Modifying a Force Layout" as well as the "General Update Pattern" by "mbostock" from D3.js (unfortunately, I can only post a maximum of two links...).

My code based on the "Mobile Patent Suits" example with some modifications and differences. You can check my full code here:

<!DOCTYPE html>
<meta charset="utf-8">
<style>

.link {
    fill: none;
    stroke: #666;
    stroke-width: 1.5px;
}

#licensing {
    fill: green;
}

.link.licensing {
    stroke: green;
}

.link.resolved {
    stroke-dasharray: 0,2 1;
}

circle {
    fill: #ccc;
    stroke: #333;
    stroke-width: 1.5px;
}

text {
    font: 10px sans-serif;
    pointer-events: none;
    text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}

</style>
<body>
    <!-- add an update button -->
    <div id="update">
        <input name="updateButton" type="button" value="Update" onclick="newData()"/>
    </div>
    <script src="http://d3js.org/d3.v3.min.js"></script>
    <script>

    var width = 960,
        height = 500;

    var force = d3.layout.force()
        .size([width, height])
        .linkDistance(60)
        .charge(-300)
        .on("tick", tick);

    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height)
        .style("border", "1px solid black");

    var dataset = [
                 {source: "Microsoft", target: "Amazon", type: "licensing"},
                 {source: "Microsoft", target: "HTC", type: "licensing"},
                 {source: "Samsung", target: "Apple", type: "suit"},
                 {source: "Motorola", target: "Apple", type: "suit"},
                 {source: "Nokia", target: "Apple", type: "resolved"},
                 {source: "HTC", target: "Apple", type: "suit"},
                 {source: "Kodak", target: "Apple", type: "suit"},
                 {source: "Microsoft", target: "Barnes & Noble", type: "suit"},
                 {source: "Microsoft", target: "Foxconn", type: "suit"},
                 {source: "Oracle", target: "Google", type: "suit"},
                 {source: "Apple", target: "HTC", type: "suit"},
                 {source: "Microsoft", target: "Inventec", type: "suit"},
                 {source: "Samsung", target: "Kodak", type: "resolved"},
                 {source: "LG", target: "Kodak", type: "resolved"},
                 {source: "RIM", target: "Kodak", type: "suit"},
                 {source: "Sony", target: "LG", type: "suit"},
                 {source: "Kodak", target: "LG", type: "resolved"},
                 {source: "Apple", target: "Nokia", type: "resolved"},
                 {source: "Qualcomm", target: "Nokia", type: "resolved"},
                 {source: "Apple", target: "Motorola", type: "suit"},
                 {source: "Microsoft", target: "Motorola", type: "suit"},
                 {source: "Motorola", target: "Microsoft", type: "suit"},
                 {source: "Huawei", target: "ZTE", type: "suit"},
                 {source: "Ericsson", target: "ZTE", type: "suit"},
                 {source: "Kodak", target: "Samsung", type: "resolved"},
                 {source: "Apple", target: "Samsung", type: "suit"},
                 {source: "Kodak", target: "RIM", type: "suit"},
                 {source: "Nokia", target: "Qualcomm", type: "suit"}
                 ];

   var path = svg.append("g").selectAll("path"),
       circle = svg.append("g").selectAll("circle"),
       text = svg.append("g").selectAll("text"),
       marker = svg.append("defs").selectAll("marker");

   var nodes = {};

   update(dataset);

   function newData()
   {
        var newDataset = [
                    {source: "Microsoft", target: "Amazon", type: "licensing"},
                    {source: "Microsoft", target: "HTC", type: "licensing"},
                    {source: "Samsung", target: "Apple", type: "suit"},
                    ];

        update(newDataset);
   }

   function update(links)
   {
        // Compute the distinct nodes from the links.
        links.forEach(function(link)
        {
            link.source = nodes[link.source] || (nodes[link.source] = {name: link.source});
            link.target = nodes[link.target] || (nodes[link.target] = {name: link.target});
        });

        force
            .nodes(d3.values(nodes))
            .links(links)
            .start();

        // -------------------------------

        // Compute the data join. This returns the update selection.
        marker = marker.data(["suit", "licensing", "resolved"]);

        // Remove any outgoing/old markers.
        marker.exit().remove();

        // Compute new attributes for entering and updating markers.
        marker.enter().append("marker")
           .attr("id", function(d) { return d; })
           .attr("viewBox", "0 -5 10 10")
           .attr("refX", 15)
           .attr("refY", -1.5)
           .attr("markerWidth", 6)
           .attr("markerHeight", 6)
           .attr("orient", "auto")
           .append("line") // use ".append("path") for 'arrows'
           .attr("d", "M0,-5L10,0L0,5");

        // -------------------------------

        // Compute the data join. This returns the update selection.
        path = path.data(force.links());

        // Remove any outgoing/old paths.
        path.exit().remove();

        // Compute new attributes for entering and updating paths.
        path.enter().append("path")
           .attr("class", function(d) { return "link " + d.type; })
           .attr("marker-end", function(d) { return "url(#" + d.type + ")"; });

        // -------------------------------

        // Compute the data join. This returns the update selection.
        circle = circle.data(force.nodes());

        // Add any incoming circles.
        circle.enter().append("circle");

        // Remove any outgoing/old circles.
        circle.exit().remove();

        // Compute new attributes for entering and updating circles.
        circle
           .attr("r", 6)
           .call(force.drag);

        // -------------------------------

        // Compute the data join. This returns the update selection.
        text = text.data(force.nodes());

        // Add any incoming texts.
        text.enter().append("text");

        // Remove any outgoing/old texts.
        text.exit().remove();

        // Compute new attributes for entering and updating texts.
        text
           .attr("x", 8)
           .attr("y", ".31em")
           .text(function(d) { return d.name; });
    }

    // Use elliptical arc path segments to doubly-encode directionality.
    function tick()
    {
       path.attr("d", linkArc);
       circle.attr("transform", transform);
       text.attr("transform", transform);
    }

    function linkArc(d)
    {
       var dx = d.target.x - d.source.x,
           dy = d.target.y - d.source.y,
           dr = Math.sqrt(dx * dx + dy * dy);
       return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
    }

    function transform(d)
    {
       return "translate(" + d.x + "," + d.y + ")";
    }

    </script>

A JSFiddle of my code can be found here: http://jsfiddle.net/5m8a9/

After pressing the "Update" button I want to update my graph dynamically. So far so good, the problem is, that ONLY the paths will be updated but not the circles or the texts (some circles and the corresponding texts still remain and will not be removed) as you can see at my JSFiddle link. I tried to figure out the problem for the last couple of days without success.

What am I missing and how can I make my code work as aspected?

If anyone can help I would be eternally grateful.

Edited to add final solution as @AmeliaBR provided:

Here is the hole code to my final solution:

<!DOCTYPE html>
<meta charset="utf-8">
<style>

.link {
    fill: none;
    stroke: #666;
    stroke-width: 1.5px;
}

#licensing {
    fill: green;
}

.link.licensing {
    stroke: green;
}

.link.resolved {
    stroke-dasharray: 0,2 1;
}

circle {
    fill: #ccc;
    stroke: #333;
    stroke-width: 1.5px;
}

text {
    font: 10px sans-serif;
    pointer-events: none;
    text-shadow: 0 1px 0 #fff, 1px 0 0 #fff, 0 -1px 0 #fff, -1px 0 0 #fff;
}

    </style>
<body>
    <!-- add an update button -->
    <div id="update">
        <input name="updateButton" type="button" value="Update" onclick="newData()"/>
    </div>
    <script src="http://d3js.org/d3.v3.min.js"></script>
    <script>

    var width = 960,
        height = 500;

    var force = d3.layout.force()
        .size([width, height])
        .linkDistance(60)
        .charge(-300)
        .on("tick", tick);

    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height)
        .style("border", "1px solid black");

    var dataset = [
                   {source: "Microsoft", target: "Amazon", type: "licensing"},
                   {source: "Microsoft", target: "HTC", type: "licensing"},
                   {source: "Samsung", target: "Apple", type: "suit"},
                   {source: "Motorola", target: "Apple", type: "suit"},
                   {source: "Nokia", target: "Apple", type: "resolved"},
                   {source: "HTC", target: "Apple", type: "suit"},
                   {source: "Kodak", target: "Apple", type: "suit"},
                   {source: "Microsoft", target: "Barnes & Noble", type: "suit"},
                   {source: "Microsoft", target: "Foxconn", type: "suit"},
                   {source: "Oracle", target: "Google", type: "suit"},
                   {source: "Apple", target: "HTC", type: "suit"},
                   {source: "Microsoft", target: "Inventec", type: "suit"},
                   {source: "Samsung", target: "Kodak", type: "resolved"},
                   {source: "LG", target: "Kodak", type: "resolved"},
                   {source: "RIM", target: "Kodak", type: "suit"},
                   {source: "Sony", target: "LG", type: "suit"},
                   {source: "Kodak", target: "LG", type: "resolved"},
                   {source: "Apple", target: "Nokia", type: "resolved"},
                   {source: "Qualcomm", target: "Nokia", type: "resolved"},
                   {source: "Apple", target: "Motorola", type: "suit"},
                   {source: "Microsoft", target: "Motorola", type: "suit"},
                   {source: "Motorola", target: "Microsoft", type: "suit"},
                   {source: "Huawei", target: "ZTE", type: "suit"},
                   {source: "Ericsson", target: "ZTE", type: "suit"},
                   {source: "Kodak", target: "Samsung", type: "resolved"},
                   {source: "Apple", target: "Samsung", type: "suit"},
                   {source: "Kodak", target: "RIM", type: "suit"},
                   {source: "Nokia", target: "Qualcomm", type: "suit"}
                   ];

    var path = svg.append("g").selectAll("path"),
    circle = svg.append("g").selectAll("circle"),
    text = svg.append("g").selectAll("text"),
    marker = svg.append("defs").selectAll("marker");

    var nodes = {};

    update(dataset);

    function newData()
    {
        var newDataset = [
                         {source: "Microsoft", target: "Amazon", type: "licensing"},
                         {source: "Microsoft", target: "HTC", type: "licensing"},
                         {source: "Samsung", target: "Apple", type: "suit"},
                         ];

        update(newDataset);
    }

    function update(links)
    {
        d3.values(nodes).forEach(function(aNode){ aNode.linkCount = 0;});
            // Reset the link count for all existing nodes by
            // creating an array out of the nodes list, and then calling a function
            // on each node to set the linkCount property to zero.

        // Compute the distinct nodes from the links.
        links.forEach(function(link)
        {
            link.source = nodes[link.source] || (nodes[link.source] = {name: link.source, linkCount:0});    // initialize new nodes with zero links
            link.source.linkCount++;
                // record this link on the source node, whether it was just initialized
                // or already in the list, by incrementing the linkCount property
                // (remember, link.source is just a reference to the node object in the
                // nodes array, when you change its properties you change the node itself.)

            link.target = nodes[link.target] || (nodes[link.target] = {name: link.target, linkCount:0});    // initialize new nodes with zero links

            link.target.linkCount++;
        });

        d3.keys(nodes).forEach(
            // create an array of all the current keys(names) in the node list,
            // and then for each one:

            function (nodeKey)
            {
                if (!nodes[nodeKey].linkCount)
                {
                    // find the node that matches that key, and check it's linkCount value
                    // if the value is zero (false in Javascript), then the ! (NOT) operator
                    // will reverse that to make the if-statement return true,
                    // and the following will execute:

                    delete(nodes[nodeKey]);
                        //this deletes the object AND its key from the nodes array
                 }
             }
         );

        force
            .nodes(d3.values(nodes))
            .links(links)
            .start();

        // -------------------------------

        // Compute the data join. This returns the update selection.
        marker = marker.data(["suit", "licensing", "resolved"]);

        // Remove any outgoing/old markers.
        marker.exit().remove();

        // Compute new attributes for entering and updating markers.
        marker.enter().append("marker")
            .attr("id", function(d) { return d; })
            .attr("viewBox", "0 -5 10 10")
            .attr("refX", 15)
            .attr("refY", -1.5)
            .attr("markerWidth", 6)
            .attr("markerHeight", 6)
            .attr("orient", "auto")
            .append("line") // use ".append("path") for 'arrows'
            .attr("d", "M0,-5L10,0L0,5");

        // -------------------------------

        // Compute the data join. This returns the update selection.
        path = path.data(force.links());

        // Remove any outgoing/old paths.
        path.exit().remove();

        // Compute new attributes for entering and updating paths.
        path.enter().append("path")
            .attr("class", function(d) { return "link " + d.type; })
            .attr("marker-end", function(d) { return "url(#" + d.type + ")"; });

        // -------------------------------

        // Compute the data join. This returns the update selection.
        circle = circle.data(force.nodes());

        // Add any incoming circles.
        circle.enter().append("circle");

        // Remove any outgoing/old circles.
        circle.exit().remove();

        // Compute new attributes for entering and updating circles.
        circle
            .attr("r", 6)
            .call(force.drag);

        // -------------------------------

        // Compute the data join. This returns the update selection.
        text = text.data(force.nodes());

        // Add any incoming texts.
        text.enter().append("text");

        // Remove any outgoing/old texts.
        text.exit().remove();

        // Compute new attributes for entering and updating texts.
        text
            .attr("x", 8)
            .attr("y", ".31em")
            .text(function(d) { return d.name; });
    }

    // Use elliptical arc path segments to doubly-encode directionality.
    function tick()
    {
        path.attr("d", linkArc);
        circle.attr("transform", transform);
        text.attr("transform", transform);
    }

    function linkArc(d)
    {
        var dx = d.target.x - d.source.x,
            dy = d.target.y - d.source.y,
            dr = Math.sqrt(dx * dx + dy * dy);
        return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
    }

    function transform(d)
    {
        return "translate(" + d.x + "," + d.y + ")";
    }

    </script>
like image 600
user3186482 Avatar asked Jan 12 '14 03:01

user3186482


1 Answers

The general node-link graph structure can have nodes without links, just like it can have nodes with one, two or hundreds of link. Your update method replaces the links' data, but does not look through nodes data to remove those that no longer have any links attached.

They way you've currently got it set up, however, there is a fairly straightforward fix. As is, you initialize links from the dataset, and initialize nodes to be empty. Then in this section of your update method:

links.forEach(function(link)
        {
            link.source = nodes[link.source] 
                          || (nodes[link.source] = {name: link.source});

            link.target = nodes[link.target] 
                          || (nodes[link.target] = {name: link.target});
        });

You add all the nodes mentioned as either a source or target of a link to the nodes list, after first checking whether or not it's already in the list.

(If it isn't in the list, nodes[link.source] will return null, so the || OR operator will kick in and the second half of the statement is evaluated, creating the object, adding it to the nodes list, and then connecting it to the link object.)

Now, the first time your run your update method, this fills up the nodes list with data. The second time, however, the nodes list is already full and you don't do anything to take any nodes away.

The simple fix is to reset your nodes list to an empty object (nodes={};) at the start of your update method. Then, only the nodes that are in the current set of links will be added back in, so when you re-compute the data join on the circles and text all the unused nodes will be put into the .exit() selection and removed.

But, I should mention that if you're updating a lot, and only changing a few objects each time, there are other ways to do this that require more code but will be faster in update. This version recreates all the node and link data objects each time. If you've got a lot (many hundreds) of complex data nodes and are only changing a couple each update, it might be worth it to add an extra property to your node objects keeping track of how many links are attached, and only reset that at the start of your update method. Then you could use a filter to figure out which of the node objects to include in your data join.

Edited to Add:

Here is the approach I'd use for a more conservative update function (versus a complete reset of the data). It's not the only option, but it doesn't have much overhead:

First step (in update method), mark all nodes to have zero links:

d3.values(nodes).forEach(function(aNode){ aNode.linkCount = 0;}); 
   //Reset the link count for all existing nodes by
   //creating an array out of the nodes list, and then calling a function
   //on each node to set the linkCount property to zero.

Second step, change the links.forEach() method to record the number of links in each node:

links.forEach(function(link)
    {
     link.source = nodes[link.source] 
                      || (nodes[link.source] = {name: link.source, linkCount:0});
                                    //initialize new nodes with zero links

     link.source.linkCount++;
        // record this link on the source node, whether it was just initialized
        // or already in the list, by incrementing the linkCount property
        // (remember, link.source is just a reference to the node object in the 
        // nodes array, when you change its properties you change the node itself.)

     link.target = /* and then do the same for the target node */
    });

Third step, option one, use an filter to only include nodes that have at least one link:

force
    .nodes( d3.values(nodes).filter(function(d){ return d.linkCount;}) )
      //Explanation: d3.values() turns the object-list of nodes into an array.
      //.filter() goes through that array and creates a new array consisting of 
      //the nodes that return TRUE when passed to the callback function.  
      //The function just returns the linkCount of that node, which Javascript 
      //interprets as false if linkCount is zero, or true otherwise.
    .links(links)
    .start();

Note that this does not delete the unused nodes from the nodes list, it only filters them from getting passed to the layout. If you don't expect to be using those nodes again, you will need to actually remove them from the nodes list.

Third step, option two, scan through the nodes list and delete any nodes that have zero links:

d3.keys(nodes).forEach(
   //create an array of all the current keys(names) in the node list, 
   //and then for each one:

   function (nodeKey) {
       if (!nodes[nodeKey].linkCount) {
         // find the node that matches that key, and check it's linkCount value
         // if the value is zero (false in Javascript), then the ! (NOT) operator
         // will reverse that to make the if-statement return true, 
         // and the following will execute:

           delete(nodes[nodeKey]); 
             //this deletes the object AND its key from the nodes array
       }

   }//end of function

); //end of forEach method

  /*then add the nodes list to the force layout object as before, 
     no filter needed since the list only includes the nodes you want*/
like image 199
AmeliaBR Avatar answered Sep 29 '22 00:09

AmeliaBR