|
<!DOCTYPE html> |
|
<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> |