0

I am currently building a graph of the relationship between research papers in D3.js. Currently my code allows me to generate a force-directed graph. I can zoom and drag the graph and for the time being "ugly" tooltips display node information on "mouseover" (but that is irrelevant for this question).

I am looking for the best way to visualize the article network based on publishing year. I believe that the best way to do this is to display the nodes by year in a concentric circle pattern, like this:

Simple representation of the expected result of a concentric circle force-directed graph

In the image like in my code nodes are colored based on year.

Here is my plunk link: http://plnkr.co/edit/RCzGe0OFaQNnI32kBuSn?p=preview

And here is my code: HTML:

<!DOCTYPE html>
<html>

  <head>
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <script src="script.js"></script>
  </body>

</html>

style.CSS:

/* Styles go here */

.links line {
  stroke: #999;
  stroke-opacity: 0.6;
}

.nodes circle {
  stroke: #fff;
  stroke-width: 1.5px;
}

div.tooltip {   
  position: absolute;           
  text-align: center;               
  padding: 2px;             
  font: 12px sans-serif;        
  background: lightsteelblue;   
  border: 0px;      
  border-radius: 8px;           
  pointer-events: none;         
}

test-data.JSON:

{
  "papers":[
    {
      "id":"1",
      "title":"Title 1",
      "year":"2016",
      "authors":["A1","A2"],
      "problematic":"",
      "solution":"",
      "references":["2","3"]
      },
    {
      "id":"2",
      "title":"Title 2",
      "year":"2015",
      "authors":["A2","A3"],
      "problematic":"",
      "solution":"",
      "references":["4","5"]
      },
    {
      "id":"3",
      "title":"Title 3",
      "year":"2015",
      "authors":["A4","A5"],
      "problematic":"",
      "solution":"",
      "references":["4"]
      },
    {
      "id":"4",
      "title":"Title 4",
      "year":"2014",
      "authors":["A1","A3"],
      "problematic":"",
      "solution":"",
      "references":[]
      },
    {
      "id":"5",
      "title":"Title 5",
      "year":"2013",
      "authors":["A6","A7"],
      "problematic":"",
      "solution":"",
      "references":[]
      }
  ]
}

script.js:

/* ------ DESCRIPTION ------
  Properties of the graph:
  BASIC:
    ✓ Graph represents all papers and relationships in RTB research
    ✓ Graph is force dynamic
    ✓ Nodes are coloured by publishing year
    ✓ Graph is draggable
    ✓ Graph is zoomable
    X Graph is "tree like" where the nodes are "ordered" by publishing year, the oldest being at the bottom
    ~ Hovering over a Node will display it's info
    - Clicking a node will allow to visualize it's direct or most important connections

  ADVANCED:
    - Display papers graph
    - Display authors graph
    - Search for paper based on info: id, title, author, year, ...
    - Add new paper to graph and modify and save JSON file
    - Open PDF File in new Tab
*/


// ----- GLOBAL VARIABLES ------
var w = window.innerWidth;
var h = window.innerHeight;

var svg = d3.select("body").append("svg")
                           .attr("width",w)
                           .attr("height",h)
                           .style("cursor","move");
var g = svg.append("g");

// NODE COLORS
var color = d3.scaleOrdinal(d3.schemeCategory20);


// FORCE SIMULATION

var simulation = d3.forceSimulation()
                    .force("link", d3.forceLink().id(function(d) { return d.id; }))
                    .force("charge", d3.forceManyBody().strength(-100))
                    .force("center", d3.forceCenter(w / 2, h / 2))
                    .force("collide", d3.forceCollide(10));

// ZOOM PARAMETERS
var min_zoom = 0.1;
var max_zoom = 7;
var zoom = d3.zoom()
              .scaleExtent([min_zoom,max_zoom])
              .on("zoom", zoomed);
svg.call(zoom);
var transform = d3.zoomIdentity
                  .translate(w / 6, h / 6)
                  .scale(0.5);

svg.call(zoom.transform, transform);

// BASIC NODE SIZE
var nominal_stroke = 1.5;
var nominal_node_size = 8;

// ----- GLOBAL FUNCTIONS -----

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

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

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

function zoomed() {
  g.attr("transform", d3.event.transform);
  // Manually offsets the zoom to compensate for the initial position. Should get fixed asap or the position variables made global.
  //svg.attr("transform", "translate(" + (d3.event.transform.x + 400) + "," + (d3.event.transform.y + 325) + ")scale(" +  d3.event.transform.k + ")");
}

function isInList(el, list){
  for (var i = 0; i < list.length; i++){
    if (el == list[i]) return true;
  }
  return false;
}

// builds a graph dictionary based on paper references
function referencesGraph(file_data){
  var nodes = [];
  var links = [];

  // we use these to add nodes to references that are missing as nodes
  var node_ids = [];
  var ref_ids = [];

  // for each paper in graph create a node and append result to node list
  for (var i = 0; i < file_data.length; i++ ){
    var node = {
      "id":file_data[i].id,
      "title":file_data[i].title,
      "year":file_data[i].year,
      "authors":file_data[i].authors
    };

    node_ids.push(file_data[i].id);
    nodes.push(node);

    // for each referenced paper in graph create a link and append result to link list
    for (var j = 0; j < file_data[i].references.length; j++){
      var link = {
        "source":file_data[i].id,
        "target":file_data[i].references[j]
      };

      ref_ids.push(file_data[i].references[j]);
      links.push(link);
    }
  }

  //check if all referenced elements have a node associated
  for (var i = 0; i < ref_ids.length; i++){
    if (!isInList(ref_ids[i],node_ids)){
      var node = {
        "id":ref_ids[i],
        "title":ref_ids[i],
        "year":""
      }

      nodes.push(node);
    }  
  }

  var graph = {
    "nodes":nodes,
    "links":links
  };
  return graph;
}

// builds a graph dictionary based on author collaboration
function authorsGraph(data){

}

// DEAL WITH MISSING DATA TO BE WORKED

// ----- MANAGE JSON DATA -----
d3.json("test-data.json",function(error,graph){
  if (error) throw error;

  // Read the JSON data and create a dictionary of nodes and links based on references
  var paper_graph_data = referencesGraph(graph.papers);

  //var authors_graph_data; //function not implemented yet

  // INITIALIZE THE LINKS
  var link = g.append("g")
                .attr("class","links")
                .selectAll("line")
                .data(paper_graph_data.links)
                .enter()
                .append("line")
                .attr("stroke-width",function(d){return nominal_stroke})

  /* FUNCTION THAT CREATES DIV ELEMENT TO HOLD NODE INFORMATION 
    [              PAPER TITLE              ]
    [ PUBLISHING YEAR ][    PERSONAL RATING ]
    [           AUTHORS & LINKS             ]
    [             PROBLEMATIC               ]
    [              SOLUTION                 ]
                              [OPEN PDF FILE]
  */
  var div = d3.select("body").append("div")   
                             .attr("class", "tooltip")               
                             .style("opacity", 0);

  function createTooltip(d){
    //get node data, manage missing values
    div.transition()        
        .duration(200)      
        .style("opacity", .9);

    div.html("<table><tr><td>" + d.title + "</td></tr><tr><td>" + d.year + "</td></tr><tr><td>" + d.authors + "</td></tr><tr><td>" + d.problematic + "</td></tr><tr><td>" + d. solution + "</td></tr></table>")
       .style("left", (d3.event.pageX) + "px")     
       .style("top", (d3.event.pageY - 28) + "px");  
  }

  // INITIALIZE THE NODES
  var node = g.append("g")
                .attr("class","nodes")
                .selectAll("circles")
                .data(paper_graph_data.nodes)
                .enter()
                .append("circle")
                .attr("r",nominal_node_size)
                .attr("fill",function(d){return color(d.year);})
                .style("cursor","pointer")
                .on("mouseover",createTooltip)
                .on("mouseout",function(d){
                  div.transition()        
                     .duration(500)      
                     .style("opacity", 0);
                })
                .call(d3.drag()
                        .on("start", dragStart)
                        .on("drag", dragging)
                        .on("end", dragEnd));

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

  simulation.force("link")
            .links(paper_graph_data.links);

  // function to return link and node position when simulation is generated
  function ticked(){
    // Each year is placed on a different level to get chronological order of paper network
    /*
    switch(d.source.year){
            case "2016":
              return 40;
            case "2015":
              return 80;
            case "2014":
              return 120;
            case "2013":
              return 160;
            case "2012":
              return 200;
            case "2011":
              return 240;
            case "2010":
              return 280;
            case "2009":
              return 320;
            case "2008":
              return 360;
            case "2007":
              return 400;
            default:
              return 600;
          }
    */

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

  function ticked_advanced(){
    link
        .attr("x1", function(d) { return d.source.x; })
        .attr("y1", function(d) { 
          switch(d.source.year){
            case "2016":
              return 40;
            case "2015":
              return 80;
            case "2014":
              return 120;
            case "2013":
              return 160;
            case "2012":
              return 200;
            case "2011":
              return 240;
            case "2010":
              return 280;
            case "2009":
              return 320;
            case "2008":
              return 360;
            case "2007":
              return 400;
            default:
              return 600;
          }
        })
        .attr("x2", function(d) { return d.target.x; })
        .attr("y2", function(d) { 
          switch(d.target.year){
            case "2016":
              return 40;
            case "2015":
              return 80;
            case "2014":
              return 120;
            case "2013":
              return 160;
            case "2012":
              return 200;
            case "2011":
              return 240;
            case "2010":
              return 280;
            case "2009":
              return 320;
            case "2008":
              return 360;
            case "2007":
              return 400;
            default:
              return 600;
          }
        });

    node
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { 
          switch(d.year){
            case "2016":
              return 40;
            case "2015":
              return 80;
            case "2014":
              return 120;
            case "2013":
              return 160;
            case "2012":
              return 200;
            case "2011":
              return 240;
            case "2010":
              return 280;
            case "2009":
              return 320;
            case "2008":
              return 360;
            case "2007":
              return 400;
            default:
              return 600;
          }
        });
  }
});

I imagine that I have to modify the tick function so as to return x and y random coordinates within each "year zone" but don't know how to calculate this.

Any ideas on how to do this? Thanks a lot.

Note:

I found this answer to generating a random number in an annulus that also refers to generating a random number in a circle uniformly:

Generate a uniformly random point within an annulus (ring)

Community
  • 1
  • 1
David
  • 23
  • 5

1 Answers1

2

I think there are a few ways to do this,

One way as shown below is to constrain the possible locations that the nodes can move to. I created a constrain(d) function that takes in a node and updates its x/y to fit within a circular area defined by the number of years in the dataset. Any time the node positions are updated, just call the constrain function and they will stay within their defined areas. One drawback of this is that the edge forces will tend to pull them to the boundaries.

var graph = {
  "papers": [{
    "id": "1",
    "title": "Title 1",
    "year": "2016",
    "authors": ["A1", "A2"],
    "problematic": "",
    "solution": "",
    "references": ["2", "3"]
  }, {
    "id": "2",
    "title": "Title 2",
    "year": "2015",
    "authors": ["A2", "A3"],
    "problematic": "",
    "solution": "",
    "references": ["4", "5"]
  }, {
    "id": "3",
    "title": "Title 3",
    "year": "2015",
    "authors": ["A4", "A5"],
    "problematic": "",
    "solution": "",
    "references": ["4"]
  }, {
    "id": "4",
    "title": "Title 4",
    "year": "2014",
    "authors": ["A1", "A3"],
    "problematic": "",
    "solution": "",
    "references": []
  }, {
    "id": "5",
    "title": "Title 5",
    "year": "2013",
    "authors": ["A6", "A7"],
    "problematic": "",
    "solution": "",
    "references": []
  }]
};


var w = window.innerWidth;
var h = window.innerHeight;
var maxRadStep = 100;
var cX = w / 2,
  cY = h / 2;

var years = d3.set(graph.papers.map(function(obj) {
  return +obj.year;
})).values();
years.sort();

function constrain(d) {
  var yearIndex = years.indexOf(d.year);
  var max = (maxRadStep * (yearIndex + 1)) - 10;
  var min = (max - maxRadStep) + 20;
  var vX = d.x - cX;
  var vY = d.y - cY;
  var magV = Math.sqrt(vX * vX + vY * vY);
  if (magV > max) {
    d.vx = 0;
    d.vy = 0;
    d.x = cX + vX / magV * max;
    d.y = cY + vY / magV * max;
  } else if (magV < min) {
    d.vx = 0;
    d.vy = 0;
    d.x = cX + vX / magV * min;
    d.y = cY + vY / magV * min;
  }
}

var svg = d3.select("body").append("svg")
  .attr("width", w)
  .attr("height", h)
  .style("cursor", "move");
var g = svg.append("g");

// NODE COLORS
var color = d3.scaleOrdinal(d3.schemeCategory20);


// FORCE SIMULATION

var simulation = d3.forceSimulation()
  .force("link", d3.forceLink().id(function(d) {
    return d.id;
  }))
  .force("charge", d3.forceManyBody().strength(-100))
  //.force("center", d3.forceCenter(w / 2, h / 2))
  .force("collide", d3.forceCollide(10));

// ZOOM PARAMETERS
var min_zoom = 0.1;
var max_zoom = 7;
var zoom = d3.zoom()
  .scaleExtent([min_zoom, max_zoom])
  .on("zoom", zoomed);
svg.call(zoom);
var transform = d3.zoomIdentity
  .translate(w / 6, h / 6)
  .scale(0.5);

svg.call(zoom.transform, transform);

// BASIC NODE SIZE
var nominal_stroke = 1.5;
var nominal_node_size = 8;

// ----- GLOBAL FUNCTIONS -----

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

function dragging(d) {
  console.log(d3.event.x + ' ' + d3.event.y);
  d.fx = d3.event.x;
  d.fy = d3.event.y;
  constrain(d);
}

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

function zoomed() {
  g.attr("transform", d3.event.transform);
  // Manually offsets the zoom to compensate for the initial position. Should get fixed asap or the position variables made global.
  //svg.attr("transform", "translate(" + (d3.event.transform.x + 400) + "," + (d3.event.transform.y + 325) + ")scale(" +  d3.event.transform.k + ")");
}

function isInList(el, list) {
  for (var i = 0; i < list.length; i++) {
    if (el == list[i]) return true;
  }
  return false;
}

// builds a graph dictionary based on paper references
function referencesGraph(file_data) {
  var nodes = [];
  var links = [];

  // we use these to add nodes to references that are missing as nodes
  var node_ids = [];
  var ref_ids = [];

  // for each paper in graph create a node and append result to node list
  for (var i = 0; i < file_data.length; i++) {
    var node = {
      "id": file_data[i].id,
      "title": file_data[i].title,
      "year": file_data[i].year,
      "authors": file_data[i].authors
    };

    node_ids.push(file_data[i].id);
    nodes.push(node);

    // for each referenced paper in graph create a link and append result to link list
    for (var j = 0; j < file_data[i].references.length; j++) {
      var link = {
        "source": file_data[i].id,
        "target": file_data[i].references[j]
      };

      ref_ids.push(file_data[i].references[j]);
      links.push(link);
    }
  }

  //check if all referenced elements have a node associated
  for (var i = 0; i < ref_ids.length; i++) {
    if (!isInList(ref_ids[i], node_ids)) {
      var node = {
        "id": ref_ids[i],
        "title": ref_ids[i],
        "year": ""
      }

      nodes.push(node);
    }
  }

  var graph = {
    "nodes": nodes,
    "links": links
  };
  return graph;
}

// builds a graph dictionary based on author collaboration
function authorsGraph(data) {

}

// DEAL WITH MISSING DATA TO BE WORKED

// ----- MANAGE JSON DATA -----

// Read the JSON data and create a dictionary of nodes and links based on references
var paper_graph_data = referencesGraph(graph.papers);

//var authors_graph_data; //function not implemented yet

// INITIALIZE THE LINKS
var link = g.append("g")
  .attr("class", "links")
  .selectAll("line")
  .data(paper_graph_data.links)
  .enter()
  .append("line")
  .attr("stroke-width", function(d) {
    return nominal_stroke
  })

// INITIALIZE THE NODES
var node = g.append("g")
  .attr("class", "nodes")
  .selectAll("circles")
  .data(paper_graph_data.nodes)
  .enter()
  .append("circle")
  .attr("r", nominal_node_size)
  .attr("fill", function(d) {
    return color(d.year);
  })
  .style("cursor", "pointer")
  .call(d3.drag()
    .on("start", dragStart)
    .on("drag", dragging)
    .on("end", dragEnd));

g.append('g')
  .attr('class', 'boundry')
  .selectAll('.boundry')
  .data(years)
  .enter()
  .append('circle')
  .attr('r', function(d, index) {
    return (index + 1) * maxRadStep;
  }).attr('cx', cX).attr('cy', cY);

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

simulation.force("link")
  .links(paper_graph_data.links);

function ticked() {
  node.each(constrain);
  node
    .attr("cx", function(d) {
      return d.x;
    })
    .attr("cy", function(d) {
      return d.y;
    });
  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;
    });
}
/* Styles go here */

.links line {
  stroke: #999;
  stroke-opacity: 0.6;
}

.nodes circle {
  stroke: #fff;
  stroke-width: 1.5px;
}

.boundry circle {
  stroke: #000;
  fill: none;
}
<script src="https://d3js.org/d3.v4.min.js"></script>
Andrew
  • 13,757
  • 13
  • 66
  • 84
  • This is indeed the correct answer, I had found the same. However I would have like to add explanations on the reasoning behind the function constrain for those not familiar with D3.js or the math. And why don't people comment their code? – David Nov 06 '16 at 08:52