Skip to content

Instantly share code, notes, and snippets.

@tlfrd
Last active January 1, 2020 08:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save tlfrd/e64f7c1ae37b6f87a981f48b8aa0ff3b to your computer and use it in GitHub Desktop.
Save tlfrd/e64f7c1ae37b6f87a981f48b8aa0ff3b to your computer and use it in GitHub Desktop.
Enclosing Force Circles
license: mit

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.

<!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>
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]}
];
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment