Skip to content

Instantly share code, notes, and snippets.

@nitaku
Last active August 22, 2017 02:34
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nitaku/b6b22e56511064d837859b76319c4619 to your computer and use it in GitHub Desktop.
Save nitaku/b6b22e56511064d837859b76319c4619 to your computer and use it in GitHub Desktop.
World population - geo-informed circle packing

An attempt to mix a circle packing layout with a geographical map. Population data is represented by a bubble for each country, which is then colored, grouped and displaced onto the map according to the continent it belongs to (there are a few dubious attributions, you can read about them here).

This expands upon this world map and this bubble chart, using a technique shown here to manage collisions.

Data from Natural Earth & data.worldbank.org.

svg = d3.select 'body'
.append 'svg'
width = d3.select('svg').node().getBoundingClientRect().width
height = d3.select('svg').node().getBoundingClientRect().height
CONTINENTS = [
{id: 'North America', centroid: [-100.258219,42.393044]},
{id: 'Africa', centroid: [14.313831,4.467357]},
{id: 'South America', centroid: [-68.083970,-13.071758]},
{id: 'Asia', centroid: [116.019485,30.377321]},
{id: 'Europe', centroid: [13.559762,50.671550]},
{id: 'Oceania', centroid: [151.026997,-32.147138]},
{id: 'Seven seas (open ocean)', centroid: [64.587828,-26.307320]}
]
# ZOOM
zoomable_layer = svg.append 'g'
zoom = d3.zoom()
.scaleExtent([-Infinity,Infinity])
.on 'zoom', () ->
zoomable_layer
.attrs
transform: d3.event.transform
# SEMANTIC ZOOM
# scale back all objects that have to be semantically zoomed
zoomable_layer.selectAll '.label > text'
.attrs
transform: "scale(#{1/d3.event.transform.k})"
# LOD & OVERLAPPING
lod(d3.event.transform.k)
svg.call zoom
# PROJECTION
projection = d3.geoWinkel3()
.rotate [0, 0]
.center [20, 16]
.scale 1.2*width / (2 * Math.PI)
.translate [width/2, height/2]
path = d3.geoPath projection
CONTINENTS.forEach (d) ->
d.centroid = projection d.centroid
# GRATICULE and OUTLINE
graticule = d3.geoGraticule()
# PACK
pack = d3.pack()
.size([1.2*width, 1.2*height])
.padding(3)
# FORCE
CONTINENTS.forEach (d) -> d.force = {}
simulation = d3.forceSimulation()
.force 'collision', d3.forceCollide((d) -> d.r).strength(0.01)
.force 'attract', d3.forceAttract().target((d) -> [d.foc_x, d.foc_y]).strength(0.5)
# COLORS
color = d3.scaleOrdinal(d3.schemeCategory10)
.domain CONTINENTS.map (d) -> d.id
contents = zoomable_layer.append 'g'
d3.json 'ne_50m_admin_0_countries.topo.json', (geo_data) ->
countries_data = topojson.feature(geo_data, geo_data.objects.countries).features
land = topojson.merge(geo_data, geo_data.objects.countries.geometries.filter (d) -> d.properties.continent isnt 'Antarctica')
# force simulation starts with each continent bubble placed in its centroid
# the centroid is also the attraction focus
CONTINENTS.forEach (d) ->
d.force.x = d.centroid[0]
d.force.y = d.centroid[1]
d.force.foc_x = d.centroid[0]
d.force.foc_y = d.centroid[1]
# land
contents.append 'path'
.attrs
class: 'land'
d: path land
d3.csv 'population.csv', (data) ->
# use ISO a3 code as ID
# WARNING some records do not match
index = {}
data.forEach (d) ->
index[d['Country Code']] = d
population_data = []
countries_data.forEach (d) ->
if d.properties.iso_a3 of index
population_data.push {
id: d.properties.iso_a3
parent: d.properties.continent
country: d
value: +index[d.properties.iso_a3]['2016']
}
# adding dummy root since d3 stratify does not handle multiple roots
population_data.push {id: "root", parent: ""}
# also add continents
CONTINENTS.forEach (d) ->
population_data.push {id: d.id, parent: "root", d: d}
# tree construction
root = (d3.stratify()
.id((d) -> d.id)
.parentId((d) -> d.parent)
)(population_data)
root
.sum (d) -> d.value
.sort (a, b) -> b.value - a.value
pack(root)
# compute relative coordinates
root.eachBefore (d) ->
if d.parent?
d.relx = d.x - d.parent.x
d.rely = d.y - d.parent.y
else
d.relx = d.x
d.rely = d.y
# store the result radius also for the force layout to consume
root.eachBefore (d) ->
if d.parent? and d.parent.id is 'root'
d.data.d.force.r = d.r
# bubbles
bubbles = zoomable_layer.selectAll '.bubble'
.data root.leaves()
en_bubbles = bubbles.enter().append 'circle'
.attrs
class: 'bubble'
r: (d) -> d.r
fill: (d) -> color d.parent.id
en_bubbles.append 'title'
.text (d) -> "#{d.data.country.properties.name_long}\nPopulation: #{d3.format(',')(d.value)}"
# labels
labels = zoomable_layer.selectAll '.label'
.data root.leaves()
en_labels = labels.enter().append 'g'
.attrs
class: 'label'
en_labels.append 'text'
.text (d) -> d.data.country.properties.name_long
.attrs
dy: '0.35em'
# lod
lod(1)
# Simulation
simulation
.nodes CONTINENTS.map (d) -> d.force
.stop()
for i in [0...Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay()))]
simulation.tick()
en_bubbles.attrs
transform: (d) -> "translate(#{d.relx+d.parent.data.d.force.x},#{d.rely+d.parent.data.d.force.y})"
en_labels.attrs
transform: (d) -> "translate(#{d.relx+d.parent.data.d.force.x},#{d.rely+d.parent.data.d.force.y})"
lod = (z) ->
zoomable_layer.selectAll '.label'
.classed 'hidden', (d) -> d.r < 23/z
zoomable_layer.selectAll '.land'
.attrs
opacity: 1/z
body, html {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
}
body {
display: flex;
justify-content: center;
align-items: center;
}
svg {
width: 100%;
height: 100%;
}
.land {
fill: #E8E8E8;
}
.bubble {
fill-opacity: 0.3;
stroke: black;
stroke-width: 0.5;
vector-effect: non-scaling-stroke;
}
.bubble:hover {
fill-opacity: 0.5;
}
.label {
font-family: sans-serif;
font-size: 10px;
pointer-events: none;
text-anchor: middle;
}
.label.hidden {
display: none;
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>World population - geo-informed circle Packing</title>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script src="https://d3js.org/d3-selection-multi.v0.4.min.js"></script>
<script src="https://d3js.org/d3-geo-projection.v2.min.js"></script>
<script src="//d3js.org/topojson.v2.min.js"></script>
<script src="https://unpkg.com/d3-force-attract@latest"></script>
<link rel="stylesheet" href="index.css">
</head>
<body>
<script src="index.js"></script>
</body>
</html>
// Generated by CoffeeScript 1.10.0
(function() {
var CONTINENTS, color, contents, graticule, height, lod, pack, path, projection, simulation, svg, width, zoom, zoomable_layer;
svg = d3.select('body').append('svg');
width = d3.select('svg').node().getBoundingClientRect().width;
height = d3.select('svg').node().getBoundingClientRect().height;
CONTINENTS = [
{
id: 'North America',
centroid: [-100.258219, 42.393044]
}, {
id: 'Africa',
centroid: [14.313831, 4.467357]
}, {
id: 'South America',
centroid: [-68.083970, -13.071758]
}, {
id: 'Asia',
centroid: [116.019485, 30.377321]
}, {
id: 'Europe',
centroid: [13.559762, 50.671550]
}, {
id: 'Oceania',
centroid: [151.026997, -32.147138]
}, {
id: 'Seven seas (open ocean)',
centroid: [64.587828, -26.307320]
}
];
zoomable_layer = svg.append('g');
zoom = d3.zoom().scaleExtent([-Infinity, Infinity]).on('zoom', function() {
zoomable_layer.attrs({
transform: d3.event.transform
});
zoomable_layer.selectAll('.label > text').attrs({
transform: "scale(" + (1 / d3.event.transform.k) + ")"
});
return lod(d3.event.transform.k);
});
svg.call(zoom);
projection = d3.geoWinkel3().rotate([0, 0]).center([20, 16]).scale(1.2 * width / (2 * Math.PI)).translate([width / 2, height / 2]);
path = d3.geoPath(projection);
CONTINENTS.forEach(function(d) {
return d.centroid = projection(d.centroid);
});
graticule = d3.geoGraticule();
pack = d3.pack().size([1.2 * width, 1.2 * height]).padding(3);
CONTINENTS.forEach(function(d) {
return d.force = {};
});
simulation = d3.forceSimulation().force('collision', d3.forceCollide(function(d) {
return d.r;
}).strength(0.01)).force('attract', d3.forceAttract().target(function(d) {
return [d.foc_x, d.foc_y];
}).strength(0.5));
color = d3.scaleOrdinal(d3.schemeCategory10).domain(CONTINENTS.map(function(d) {
return d.id;
}));
contents = zoomable_layer.append('g');
d3.json('ne_50m_admin_0_countries.topo.json', function(geo_data) {
var countries_data, land;
countries_data = topojson.feature(geo_data, geo_data.objects.countries).features;
land = topojson.merge(geo_data, geo_data.objects.countries.geometries.filter(function(d) {
return d.properties.continent !== 'Antarctica';
}));
CONTINENTS.forEach(function(d) {
d.force.x = d.centroid[0];
d.force.y = d.centroid[1];
d.force.foc_x = d.centroid[0];
return d.force.foc_y = d.centroid[1];
});
contents.append('path').attrs({
"class": 'land',
d: path(land)
});
return d3.csv('population.csv', function(data) {
var bubbles, en_bubbles, en_labels, i, index, j, labels, population_data, ref, root;
index = {};
data.forEach(function(d) {
return index[d['Country Code']] = d;
});
population_data = [];
countries_data.forEach(function(d) {
if (d.properties.iso_a3 in index) {
return population_data.push({
id: d.properties.iso_a3,
parent: d.properties.continent,
country: d,
value: +index[d.properties.iso_a3]['2016']
});
}
});
population_data.push({
id: "root",
parent: ""
});
CONTINENTS.forEach(function(d) {
return population_data.push({
id: d.id,
parent: "root",
d: d
});
});
root = (d3.stratify().id(function(d) {
return d.id;
}).parentId(function(d) {
return d.parent;
}))(population_data);
root.sum(function(d) {
return d.value;
}).sort(function(a, b) {
return b.value - a.value;
});
pack(root);
root.eachBefore(function(d) {
if (d.parent != null) {
d.relx = d.x - d.parent.x;
return d.rely = d.y - d.parent.y;
} else {
d.relx = d.x;
return d.rely = d.y;
}
});
root.eachBefore(function(d) {
if ((d.parent != null) && d.parent.id === 'root') {
return d.data.d.force.r = d.r;
}
});
bubbles = zoomable_layer.selectAll('.bubble').data(root.leaves());
en_bubbles = bubbles.enter().append('circle').attrs({
"class": 'bubble',
r: function(d) {
return d.r;
},
fill: function(d) {
return color(d.parent.id);
}
});
en_bubbles.append('title').text(function(d) {
return d.data.country.properties.name_long + "\nPopulation: " + (d3.format(',')(d.value));
});
labels = zoomable_layer.selectAll('.label').data(root.leaves());
en_labels = labels.enter().append('g').attrs({
"class": 'label'
});
en_labels.append('text').text(function(d) {
return d.data.country.properties.name_long;
}).attrs({
dy: '0.35em'
});
lod(1);
simulation.nodes(CONTINENTS.map(function(d) {
return d.force;
})).stop();
for (i = j = 0, ref = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); 0 <= ref ? j < ref : j > ref; i = 0 <= ref ? ++j : --j) {
simulation.tick();
}
en_bubbles.attrs({
transform: function(d) {
return "translate(" + (d.relx + d.parent.data.d.force.x) + "," + (d.rely + d.parent.data.d.force.y) + ")";
}
});
return en_labels.attrs({
transform: function(d) {
return "translate(" + (d.relx + d.parent.data.d.force.x) + "," + (d.rely + d.parent.data.d.force.y) + ")";
}
});
});
});
lod = function(z) {
zoomable_layer.selectAll('.label').classed('hidden', function(d) {
return d.r < 23 / z;
});
return zoomable_layer.selectAll('.land').attrs({
opacity: 1 / z
});
};
}).call(this);
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
View raw

(Sorry about that, but we can’t show files that are this big right now.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment