d9705996
d9705996

Reputation: 412

D3 Force Directed Graph ajax update

I am using d3.js and jquery with a PHP back-end (based on yii framework) to create a dynamic force directed graph to represent the current state of hosts and services on the network that we are monitoring using Nagios.

The graph shows root -> hostgroups -> hosts -> services. I have created a server side function to return a JSON object in the following format

{
    "nodes": [
        {
            "name": "MaaS",
            "object_id": 0
        },
        {
            "name": "Convergence",
            "object_id": "531",
            "colour": "#999900"
        },
        {
            "name": "maas-servers",
            "object_id": "719",
            "colour": "#999900"
        },
        {
            "name": "hrg-cube",
            "object_id": "400",
            "colour": "#660033"
        }
    ],
    "links": [
        {
            "source": 0,
            "target": "531"
        },
        {
            "source": 0,
            "target": "719"
        },
        {
            "source": "719",
            "target": "400"
        }
    ]
}

The nodes contain an object id which is used in the links and colour for displaying the state of the node (OK = green, WARNING = yellow, etc) The links has the source object ids and target object ids for the nodes. The nodes and links may change as new hosts are added or removed from the monitoring system

I have the following code which setups the initial SVG and then every 10 seconds

  1. Retrieves the current JSON object
  2. Creates map of the links
  3. Selects the current nodes and links and binds them to the JSON data
  4. Entering links are added and exiting links are removed
  5. updated and added nodes will change their fill colour and have a tooltip with their name added
  6. Force is started

    $.ajaxSetup({ cache: false }); width = 960, height = 500; node = []; link = []; force = d3.layout.force() .charge(-1000) .linkDistance(1) .size([width, height]);

    svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height)
      .append("g");
    
    setInterval(function(){
        $.ajax({
            url: "<?php echo $url;?>",
            type: "post",
            async: false,
            datatype: "json",
            success: function(json, textStatus, XMLHttpRequest) 
            {
                json = $.parseJSON(json);
    
                var nodeMap = {};
                json.nodes.forEach(function(x) { nodeMap[x.object_id] = x; });
                json.links = json.links.map(function(x) {
                    return {
                        source: nodeMap[x.source],
                        target: nodeMap[x.target],
                    };
                });
    
                link = svg.selectAll("line")
                    .data(json.links);
    
                node = svg.selectAll("circle")
                    .data(json.nodes,function(d){return d.object_id})
    
                link.enter().append("line").attr("stroke-width",1).attr('stroke','#999');
                link.exit().remove();
    
                node.enter().append("circle").attr("r",5);
                node.exit().remove();
    
                node.attr("fill",function(d){return d.colour});
    
                node.append("title")
                  .text(function(d) { return d.name; });
    
                node.call(force.drag);
    
                force
                    .nodes(node.data())
                    .links(link.data()) 
                    .start()
    
                force.on("tick", function() {
    
                    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; });
    
                    node.attr("cx", function(d) { return d.x = Math.max(5, Math.min(width - 5, d.x));  })
                        .attr("cy", function(d) { return d.y = Math.max(5, Math.min(height - 5, d.y)); });
    
                });
            }
        });
    },10000);
    

An example of the output can be seen at Network Visualization

All of the above works correctly with the exception that every time the code loops it causes the visualization to restart and the nodes all bounce about until they settle. What I need is for any current items to stay as they are but any new nodes and links are added to the visualisation and are clickable and draggable, etc.

If anyone can help I would be eternally grateful.

Upvotes: 12

Views: 8028

Answers (4)

d9705996
d9705996

Reputation: 412

I have managed to find a solution to the problem using a mixture of all the advice above, below is the code I have used

    var width = $(document).width();
    var height = $(document).height();

    var outer = d3.select("#chart")
        .append("svg:svg")
            .attr("width", width)
            .attr("height", height)
            .attr("pointer-events", "all");

    var vis = outer
        .append('svg:g')
            .call(d3.behavior.zoom().on("zoom", rescale))
            .on("dblclick.zoom", null)
        .append('svg:g')

        vis.append('svg:rect')
            .attr('width', width)
            .attr('height', height)
            .attr('fill', 'white');

        var force = d3.layout.force()
            .size([width, height])
            .nodes([]) // initialize with a single node
            .linkDistance(1)
            .charge(-500)
            .on("tick", tick);

        nodes = force.nodes(),
            links = force.links();

        var node = vis.selectAll(".node"),
            link = vis.selectAll(".link");

       redraw();

       setInterval(function(){
           $.ajax({
                url: "<?php echo $url;?>",
                type: "post",
                async: false,
                datatype: "json",
                success: function(json, textStatus, XMLHttpRequest) 
                {
                    var current_nodes = [];
                    var delete_nodes = [];
                    var json = $.parseJSON(json);

                    $.each(json.nodes, function (i,data){

                        result = $.grep(nodes, function(e){ return e.object_id == data.object_id; });
                        if (!result.length)
                        {
                            nodes.push(data);
                        }
                        else
                        {
                            pos = nodes.map(function(e) { return e.object_id; }).indexOf(data.object_id);
                            nodes[pos].colour = data.colour;
                        }
                        current_nodes.push(data.object_id);             
                    });

                    $.each(nodes,function(i,data){
                        if(current_nodes.indexOf(data.object_id) == -1)
                        {
                            delete_nodes.push(data.index);
                        }       
                    });
                    $.each(delete_nodes,function(i,data){
                        nodes.splice(data,1); 
                    });

                    var nodeMap = {};
                    nodes.forEach(function(x) { nodeMap[x.object_id] = x; });
                    links = json.links.map(function(x) {
                        return {
                            source: nodeMap[x.source],
                            target: nodeMap[x.target],
                            colour: x.colour,
                        };
                    });
                    redraw();
                }
            });
       },2000);


       function redraw()
       {
           node = node.data(nodes,function(d){ return d.object_id;});
           node.enter().insert("circle")
                .attr("r", 5)
           node.attr("fill", function(d){return d.colour})
           node.exit().remove();

           link = link.data(links);
           link.enter().append("line")
               .attr("stroke-width",1)
           link.attr('stroke',function(d){return d.colour});
           link.exit().remove();
           force.start();

       }

       function tick() {
          link.attr("x1", function(d) { return Math.round(d.source.x); })
              .attr("y1", function(d) { return Math.round(d.source.y); })
              .attr("x2", function(d) { return Math.round(d.target.x); })
              .attr("y2", function(d) { return Math.round(d.target.y); });

          node.attr("cx", function(d) { return Math.round(d.x); })
              .attr("cy", function(d) { return Math.round(d.y); });
        }

       function rescale() {
            trans=d3.event.translate;
            scale=d3.event.scale;

            vis.attr("transform",
                "translate(" + trans + ")"
                + " scale(" + scale + ")"); 
        }

Upvotes: 6

Barnab&#233; Monnot
Barnab&#233; Monnot

Reputation: 1163

I recently tried to do the same thing, here is the solution I came up with. What I do is load a first batch of data with links.php and then update them with newlinks.php, both return a JSON with a list of objects with attributes sender and receiver. In this example newlinks returns a new sender each time and I set the receiver to be a randomly selected old node.

$.post("links.php", function(data) {
// Functions as an "initializer", loads the first data
// Then newlinks.php will add more data to this first batch (see below)
var w = 1400,
    h = 1400;

var svg = d3.select("#networkviz")
            .append("svg")
            .attr("width", w)
            .attr("height", h);

var links = [];
var nodes = [];

var force = d3.layout.force()
                     .nodes(nodes)
                     .links(links)
                     .size([w, h])
                     .linkDistance(50)
                     .charge(-50)
                     .on("tick", tick);

svg.append("g").attr("class", "links");
svg.append("g").attr("class", "nodes");

var linkSVG = svg.select(".links").selectAll(".link"),
    nodeSVG = svg.select(".nodes").selectAll(".node");

handleData(data);
update();

// This is the server call
var interval = 5; // set the frequency of server calls (in seconds)
setInterval(function() {
    var currentDate = new Date();
    var beforeDate = new Date(currentDate.setSeconds(currentDate.getSeconds()-interval));
    $.post("newlinks.php", {begin: beforeDate, end: new Date()}, function(newlinks) {
        // newlinks.php returns a JSON file with my new transactions (the one that happened between now and 5 seconds ago)
        if (newlinks.length != 0) { // If nothing happened, then I don't need to do anything, the graph will stay as it was
            // here I decide to add any new node and never remove any of the old ones
            // so eventually my graph will grow extra large, but that's up to you to decide what you want to do with your nodes
            newlinks = JSON.parse(newlinks);
            // Adds a node to a randomly selected node (completely useless, but a good example)
            var r = getRandomInt(0, nodes.length-1);
            newlinks[0].receiver = nodes[r].id;
            handleData(newlinks);
            update();
        }
    });
}, interval*1000);

function update() {
    // enter, update and exit
    force.start();

    linkSVG = linkSVG.data(force.links(), function(d) { return d.source.id+"-"+d.target.id; });
    linkSVG.enter().append("line").attr("class", "link").attr("stroke", "#ccc").attr("stroke-width", 2);
    linkSVG.exit().remove();

    var r = d3.scale.sqrt().domain(d3.extent(force.nodes(), function(d) {return d.weight; })).range([5, 20]);
    var c = d3.scale.sqrt().domain(d3.extent(force.nodes(), function(d) {return d.weight; })).range([0, 270]);

    nodeSVG = nodeSVG.data(force.nodes(), function(d) { return d.id; });
    nodeSVG.enter()
           .append("circle")
           .attr("class", "node")
    // Color of the nodes depends on their weight
    nodeSVG.attr("r", function(d) { return r(d.weight); })
           .attr("fill", function(d) {
               return "hsl("+c(d.weight)+", 83%, 60%)";
           });
    nodeSVG.exit().remove();    
}

function handleData(data) {
    // This is where you create nodes and links from the data you receive
    // In my implementation I have a list of transactions with a sender and a receiver that I use as id
    // You'll have to customize that part depending on your data
    for (var i = 0, c = data.length; i<c; i++) {
        var sender = {id: data[i].sender};
        var receiver = {id: data[i].receiver};
        sender = addNode(sender);
        receiver = addNode(receiver);
        addLink({source: sender, target: receiver});
    }
}

// Checks whether node already exists in nodes or not
function addNode(node) {
    var i = nodes.map(function(d) { return d.id; }).indexOf(node.id);
    if (i == -1) {
        nodes.push(node);
        return node;
    } else {
        return nodes[i];
    }
}

// Checks whether link already exists in links or not
function addLink(link) {
    if (links.map(function(d) { return d.source.id+"-"+d.target.id; }).indexOf(link.source.id+"-"+link.target.id) == -1
        && links.map(function(d) { return d.target.id+"-"+d.source.id; }).indexOf(link.source.id+"-"+link.target.id) == -1)
        links.push(link);
}

function tick() {
    linkSVG.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;});
    nodeSVG.attr("cx", function(d) {return d.x})
            .attr("cy", function(d) {return d.y});
}

function getRandomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}
}, "json");

This is a very specific implementation so you should fill the holes where necessary depending on your server output. But I believe the D3 backbone is correct and what you are looking for :) Here is a JSFiddle to toy with it : http://jsfiddle.net/bTyh5/2/

This code was really useful and inspired some of the parts introduced here.

Upvotes: 2

david4096
david4096

Reputation: 204

Check out this answer. You need a unique identifier for your nodes, which it appears you have.

Updating links on a force directed graph from dynamic json data

Upvotes: 2

Joum
Joum

Reputation: 3245

You don't actually need to pass anything back to the server, as long as, server-side, you are able to tell what new nodes and links are being generated. Then, instead of reloading your whole d3 script, you load it once, and in the force.on("tick", function()), you make your 10 sec timeout AJAX call to go get from the server the new data you want to append, be it nodes or links.

For instance, imagine that you initially have this JSON in your server:

[
    {
        "nodes": [
            {
                "name": "MaaS",
                "object_id": 0
            },
            {
                "name": "Convergence",
                "object_id": "531",
                "colour": "#999900"
            }
        ]
    },
    {
        "links": [
            {
                "source": 0,
                "target": "531"
            }
        ]
    }
]

You go get it from your server with AJAX and parse it with json = $.parseJSON(json);.

Then, write your timeout so that instead of running the whole function you have in success, only runs after calculating the layout. Then, again, on success, parse the new JSON you got from the server and add the_new_ nodes and links to the already existing force.nodes and force.links respectively.

Please note that I didn't test this and I'm not sure how it will work and/or perform, but I think the general approach is feasible.

Upvotes: 1

Related Questions