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.
// 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);
<!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>
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;
}