Animating between various Dorling-ish cartograms based on some random data from the USDA Agricultural Census. This version computes the next layout in a web worker while the animation runs to save a bit of time. You could get a more or less contiguous layout by adjusting the strength of the link force in worker.js
.
See also: Dorling cartogram transitions, Force-directed web worker
xxxxxxxxxx
<meta charset="utf-8">
<style>
path {
stroke: #000;
stroke-width: 1px;
}
text {
font: 600 36px sans-serif;
text-anchor: end;
}
</style>
<body>
<svg width="960", height="600"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-scale-chromatic.v1.min.js"></script>
<script src="https://unpkg.com/topojson@3"></script>
<script>
var svg = d3.select("svg"),
width = +svg.attr("width"),
height = +svg.attr("height"),
radius = d3.scaleSqrt().range([0, 72]).clamp(true),
color = d3.scaleLinear(),
worker = new Worker("worker.js"),
label = svg.append("text").attr("x", width - 25).attr("y", height - 25);
var colorSchemes = [
d3.interpolateOranges,
d3.interpolatePuRd,
d3.interpolateYlGn,
d3.interpolateYlOrBr,
d3.interpolateYlGnBu
];
d3.queue()
.defer(d3.json, "us.json")
.defer(d3.csv, "usda.csv", numeric)
.await(function(err, us, data) {
var neighbors = topojson.neighbors(us.objects.states.geometries),
features = topojson.feature(us, us.objects.states).features,
columns = d3.keys(data[0]).filter(d => d !== "id"),
dataById = d3.nest().key(d => d.id).rollup(d => d[0]).object(data);
features.forEach(cleanUpGeometry);
// Get a flat list of neighbor-neighbor links
var links = d3.merge(neighbors.map(function(neighborSet, i) {
return neighborSet.map(j => ({ source: i, target: j }));
}));
var states = svg.selectAll("path")
.data(features)
.enter()
.append("path")
.attr("d", pathString)
.attr("fill", "#ccc");
dorling();
function dorling(data) {
var column = columns.pop(),
colorScheme = colorSchemes.pop();
// Update scales
color.domain(d3.extent(features, f => f.count = dataById[f.id][column]));
radius.domain([0, color.domain()[1]]);
features.forEach(function(f){
f.r = radius(f.count);
f.color = colorScheme(color(f.count));
});
links.forEach(link => link.distance = features[link.source].r + features[link.target].r + 3);
// Compute the next simulation while the current animation runs
Promise.all([
compute(features, links),
data ? animate(data, columns[0]) : Promise.resolve(true)
]).then(d => dorling(d[0]));
columns.unshift(column);
colorSchemes.unshift(colorScheme);
}
function animate(nodes, column) {
nodes.forEach(function(node){
var interpolator = d3.interpolateArray(node.rings, node.targets);
node.interpolator = function(t){
var left, right, r;
// Return a true circle at t = ~1
if (t > 0.99) {
return node.circlePath;
}
return pathString(interpolator(t));
};
});
return new Promise(function(resolve){
states
.data(nodes)
.sort((a, b) => b.r - a.r)
.transition()
.delay(500)
.duration(1500)
.attrTween("d", node => node.interpolator)
.attr("fill", node => node.color)
.transition()
.delay(1000)
.attrTween("d", node => t => node.interpolator(1 - t))
.attr("fill", "#ccc")
.on("end", resolve);
label.transition()
.delay(1000)
.duration(0)
.on("end", () => label.text(column))
.transition()
.delay(2500)
.duration(0)
.on("end", () => label.text(""));
});
}
});
// Post new set of nodes and links to the worker
function compute(nodes, links) {
return new Promise(function(resolve) {
worker.onmessage = event => resolve(event.data);
worker.postMessage({ nodes, links });
});
}
// Turn GeoJSON into a flat list of rings
// Add some extra points to smooth things out
// Compute the relative distances of points along the perimeter
function cleanUpGeometry(f) {
var centroid = d3.geoPath().centroid(f);
f.x = f.x0 = centroid[0], f.y = f.y0 = centroid[1];
f.rings = f.geometry.type === "Polygon" ? [f.geometry.coordinates] : f.geometry.coordinates;
// Remove holes
f.rings = f.rings.map(function(polygon){
polygon[0].area = d3.polygonArea(polygon[0]);
polygon[0].centroid = d3.polygonCentroid(polygon[0]);
return polygon[0];
});
// Largest ring as primary
f.rings.sort((a, b) => b.area - a.area);
f.perimeter = d3.polygonLength(f.rings[0]);
// Optional step, makes for more circular circles
bisect(f.rings[0], f.perimeter / 36);
f.rings[0].reduce(function(prev, point){
point.along = prev ? prev.along + distance(point, prev) : 0;
return point;
}, null);
f.startingAngle = Math.atan2(f.rings[0][0][1] - f.y0, f.rings[0][0][0] - f.x0);
delete f.geometry;
}
function bisect(ring, maxSegmentLength) {
for (var i = 0; i < ring.length; i++) {
var a = ring[i], b = i === ring.length - 1 ? ring[0] : ring[i + 1];
while (distance(a, b) > maxSegmentLength) {
b = midpoint(a, b);
ring.splice(i + 1, 0, b);
}
}
}
function distance(a, b) {
return Math.sqrt((a[0] - b[0]) * (a[0] - b[0]) + (a[1] - b[1]) * (a[1] - b[1]));
}
function midpoint(a, b) {
return [a[0] + (b[0] - a[0]) * 0.5, a[1] + (b[1] - a[1]) * 0.5];
}
function pathString(d) {
return (d.rings || d).map(ring => "M" + ring.join("L") + "Z").join(" ");
}
function numeric(row) {
for (var key in row) {
if (key !== "id") {
row[key] = +row[key];
}
}
delete row[""];
return row;
}
</script>
https://d3js.org/d3.v4.min.js
https://d3js.org/d3-scale-chromatic.v1.min.js
https://unpkg.com/topojson@3