06 June, 2015
by nitaku

WebVis contributions bubble chart

_(This gist contains server-side code. In order to see it running, you should open it on WebVis.)_

This example displays a simple bubble chart, in which each bubble is sized according to the amount of contributions a certain user has made to the WebVis laboratory. Data is read live from the underlying Neo4j graph DB through PHP.

The bubble chart is implemented as a force layout with a non-overlapping constraint (see this example), initialized with a radial displacement of the circles. A simpler, more efficient implementation using a circle packing layout can be found in this example.

<?php
header('Content-Type: application/json');

require("phar://neo4jphp.phar");
$client = new Everyman\Neo4j\Client('127.0.0.1', 7474);

$contributors = array();

$contributors_result = (new Everyman\Neo4j\Cypher\Query($client,
  "
  MATCH (u:User)-[r:OWNS|CONTRIBUTED_TO]->()
  RETURN u AS user, count(r) AS contributions
  ORDER BY contributions DESC
  "
))->getResultSet();

foreach ($contributors_result as $row) {
  $contributor = array(
    'name' => $row['user']->getProperties()['name'],
    'user_github_id' => $row['user']->getProperties()['github_id'],
    'contributions' => $row['contributions']
  );
  array_push($contributors, $contributor);
}

// return the resulting list as JSON
echo json_encode($contributors);
?>
svg {
  background: #333;
}
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <script src="http://d3js.org/d3.v3.min.js"></script>
    <link rel="stylesheet" type="text/css" href="index.css">
    <title>WebVis contributions bubble chart</title>
  </head>
  <body>
    <script src="index.js"></script>
  </body>
</html>
var width = 960,
    height = 500,
    padding = 10,
    min_padding = 0,
    max_padding = 50,
    maxRadius = 120;

var circles, nodes, force;

d3.json('get_contributors.php', function(data) {
  var radius_scale = d3.scale.sqrt()
    .domain([0, d3.max(data, function(d){ return d.contributions; })])
    .range([0, maxRadius]);

  nodes = data.map(function(d, i){
    var c = {
      id: d.name,
      radius: radius_scale(d.contributions),
      cx: Math.cos(i*2*Math.PI/data.length)*radius_scale(d.contributions),
      cy: Math.sin(i*2*Math.PI/data.length)*radius_scale(d.contributions),
      avatar_id: d.user_github_id
    };
    c.x = c.cx;
    c.y = c.cy;
    return c;
  });

  var svg = d3.select("body").append("svg")
      .attr("width", width)
      .attr("height", height);

  svg.append('defs');
  create_avatar_patterns(nodes);

  var vis = svg.append('g')
    .attr('transform', 'translate('+width/2+','+height/2+')');

  circles = vis.selectAll("circle")
      .data(nodes);

  var enter_circle = circles.enter().append("circle")
      .attr('class', 'node');

  enter_circle
    .attr("r", function(d) { return d.radius; })
    .attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; })
    .attr('fill', function(d) { return 'url(#user_pattern_'+d.avatar_id+')'; });

  force = d3.layout.force()
      .nodes(nodes)
      .size([width, height])
      .gravity(.02)
      .charge(0)
      .start();

  for(var i=0; i<1000; i++) {
    tick();
  }
  force.stop();

  circles
    .attr('transform', function(d) { return 'translate('+d.x+','+d.y+')'; });
});

function tick() {
  circles
    .each(gravity(.2 * force.alpha()))
    .each(collide(.5));
}

// Resolve collisions between nodes.
function collide(alpha) {
  var quadtree = d3.geom.quadtree(nodes);
  return function(d) {
    var r = d.radius + maxRadius + padding,
        nx1 = d.x - r,
        nx2 = d.x + r,
        ny1 = d.y - r,
        ny2 = d.y + r;
    quadtree.visit(function(quad, x1, y1, x2, y2) {
      if (quad.point && (quad.point !== d)) {
        var x = d.x - quad.point.x,
            y = d.y - quad.point.y,
            l = Math.sqrt(x * x + y * y),
            r = d.radius + quad.point.radius + padding;
        if (l < r) {
          l = (l - r) / l * alpha;
          d.x -= x *= l;
          d.y -= y *= l;
          quad.point.x += x;
          quad.point.y += y;
        }
      }
      return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1;
    });
  };
}

//  Move nodes toward cluster focus.
function gravity(alpha) {
  return function(d) {
    d.y += (d.cy - d.y) * alpha;
    d.x += (d.cx - d.x) * alpha;
  };
}

function create_avatar_patterns(nodes) {
  var user_patterns = d3.select('defs').selectAll('.user_patterns')
    .data(nodes);

  user_patterns.enter()
    .append('pattern')
      .attr('class', 'user_patterns')
      .attr('id', function(d){ return 'user_pattern_' + d.avatar_id; })
      .attr('patternUnits', 'userSpaceOnUse')
      .attr('x', function(d){ return -d.radius; })
      .attr('y', function(d){ return -d.radius; })
      .attr('width', function(d){ return 2*d.radius; })
      .attr('height', function(d){ return 2*d.radius; })
    .append('image')
      .attr('xlink:href', function(d){ return 'http://avatars3.githubusercontent.com/u/' + d.avatar_id; })
      .attr('x', 0)
      .attr('y', 0)
      .attr('width', function(d){ return 2*d.radius; })
      .attr('height', function(d){ return 2*d.radius; })
      .attr('preserveAspectRatio', 'xMidYMid slice');
}
File not shown (binary encoding).
File not shown (binary encoding).