block by jeremycflin c021387582e15d3e3782a725e55975e5

Enclosing Force Circles

Full Screen

Using d3.packEnclose to draw larger circles around circles forced towards a single point.

Visualising London university pay ratios. The number of dots in each enclosed circle indicates the ratio between the lowest and highest paid at each university. Hovering over each circle reveals the ratio.

forked from tlfrd‘s block: Enclosing Force Circles

index.html

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="paygaps.js"></script>
  <style>
    body { margin: 0; position: fixed; top: 0; right: 0; bottom: 0; left: 0; }
    
    .enclosing-circle {
      fill-opacity: 0.1;
    }
    
    .uni-label {
      font-family: monospace;
      font-size: 12px;
    }
    
    .count-label {
      pointer-events: none;
      font-family: sans-serif;
      font-weight: bold;
      fill: white;
    }
  </style>
</head>

<body>
  <script>
    var margin = { top: 50, right: 50, bottom: 50, left: 50 };
    
    var width = 960 - margin.left - margin.right,
        height = 500 - margin.top - margin.bottom;
    
    var svg = d3.select("body").append("svg")
      .attr("width", width + margin.left + margin.right)
      .attr("height", height + margin.top + margin.bottom)
    .append("g")
    	.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
    
    var colour = d3.scaleLinear()
    	.range(["cyan", "magenta"]);
    
    var radius = 5;   
    
    var initPos = { x: 50, y: 50 },
        paddingX = 110,
        paddingY = 150;
    
    var rowLength = 8;
   
    function sortBy(attribute, order) {
    	paygaps.sort(function(a, b) {
      	if(a[attribute] < b[attribute]) return -1 * order;
        if(a[attribute] > b[attribute]) return 1 * order;
        return 0;
    	});
    }
    
    // calculate ratios
    paygaps.map(function(d) {
      d.ratio = Math.round(d.max / d.min);
      return d;
    });
    
    colour.domain(d3.extent(paygaps, function(d) {
      return d.ratio;
    }))
    
    sortBy("ratio", -1);
    
    paygaps.forEach(function(p, i) {
      var dots = d3.range(p.ratio).map(function(d) {
        return {
          circleId: i,
          id: d
        }
      });  
      
      var simulation = d3.forceSimulation(dots)
        .force("x", d3.forceX(initPos.x + paddingX * (i % rowLength)).strength(1))
        .force("y", d3.forceY(initPos.y + paddingY * (Math.floor(i / rowLength))).strength(1))
        .force("collide", d3.forceCollide(radius + 1).iterations(6))
        .stop(); 
      
    	for (var x = 0; x < 120; ++x) simulation.tick();
      
    	addCircles(dots, i);     
    })

   	function addCircles(data, i) {
      
      var circles = svg.append("g")
        .attr("class", "circles")
        .selectAll("g").data(data)
      .enter().append("g")
      	.each(function(d) {
         	d.r = radius;
        })
      	.style("fill", function(d) {
          return colour(paygaps[d.circleId].ratio);
        });
    
      circles.append("circle")
        .attr("r", radius)
      	.attr("cx", function(d) {
          return d.x;
        })
        .attr("cy", function(d) {
          return d.y;
        });
      
      var enclosingCircleAttr = d3.packEnclose(circles.data());
      
      var enclosingCircle = svg.append("g")
      	.append("circle")
      	.datum(enclosingCircleAttr)
      	.attr("class", "enclosing-circle")
      	.attr("r", function(d) {
          return d.r + radius;
        })
      	.attr("cx", function(d) {
          return d.x;
        })
        .attr("cy", function(d) {
          return d.y;
        })
      	.on("mouseover", function() {
          count
      			.style("opacity", 1)
          enclosingCircle
          	.style("fill-opacity", 0.5)
        })
      	.on("mouseout", function() {
          count
      			.style("opacity", 0)
          enclosingCircle
          	.style("fill-opacity", 0.1)
        })
      
      var count = svg.append("g")
      	.append("text")
      	.attr("class", "count-label")
      	.datum(enclosingCircleAttr)
      	.attr("text-anchor", "middle")
      	.attr("x", function(d) {
          return d.x;
        })
        .attr("y", function(d) {
          return d.y;
        })
      	.attr("dy", function(d) {
   				return (d.r / 2) - (d.r / 8);
        })
      	.text(paygaps[i].ratio + ":1")
      	.style("font-size", function(d) { 
          return Math.min(1.5 * d.r, (1.5 * d.r - 8) / this.getComputedTextLength() * 24) + "px"; 
        })
      	.style("opacity", 0);
      
      var label = svg.append("text")
      	.attr("class", "uni-label")
      	.attr("text-anchor", "middle")
      	.attr("x", enclosingCircleAttr.x)
      	.attr("y", enclosingCircleAttr.y + enclosingCircleAttr.r + 25)
      	.text(paygaps[i].name);
    }

  </script>
</body>

paygaps.js

var paygaps = [
      {name: "Birkbeck", max: 350064, min: 19316, median: 41772, apprentice: 12073},
      {name: "Brunel", max: 295000, min: 8676, median: 36875, apprentice: 8676},
      {name: "City", max: 308000, min: 18780, median: 47385, apprentice: 16718},
      {name: "Goldsmiths", max: 242640, min: 17154, median: 32125},
      {name: "Imperial", max: 448000, min: 18667, median: 37333, apprentice: [15590, 18920]},
      {name: "LBS", max: 414213, min: 19724, median: 37656},
      {name: "King's", max: 419000, min: 17979, median: "N/A", apprentice: 17979},
      {name: "Royal Central", max: 176727, min: 24035, median: 41708},
      {name: "RCA", max: 263839, min: 25369, median: 53301},
  		{name: "RHUL", max: 307000, min: 15505, median: 33736},
      {name: "RVC", max: 263000, min: 17533, median: 29222},
      {name: "SOAS", max: 230240, min: 19516, median: 40302},
      {name: "St George's", max: 232620, min: 18033, median: 40107},
      {name: "St Mary's", max: 150000, min: 14151, median: 35714},
      {name: "UCL", max: 362228, min: 20124, median: 40248, apprentice: 20124},
      {name: "UEL", max: 250000, min: 6188, median: 42373, apprentice: 6188},
      {name: "Greenwich", max: 267432, min: 16289, median: 52793},
      {name: "Westminster", max: 293929, min: 19595, median: 43870, apprentice: 19595},
      {name: "LSE", max: 276545, min: 17360, median: 33643, apprentice: 13841},
      {name: "London Met", max: 254521, min: 17798, median: 45613},
      {name: "LSBU", max: 295000, min: 18553, median: 46825, apprentice: [16961, 18940]}
    ];