This example shows bubbles with area proportional to each country’s population (2016) superimposed on a world map. Bubbles are also colored according to the continent.
Showing population and geography together, this map could probably be better redesigned as a choropleth of population density. This example is an exercise to prepare for different kinds of data, for which a geographical density would not make sense.
Population data is obtained from data.worldbank.org. The name of the source is “World Development Indicators” and was last updated on 2017, August 2nd.
Based on this example about implementing a map of the world and this one about population bubbles.
// Generated by CoffeeScript 1.10.0
(function() {
var color, contents, graticule, height, lod, path, projection, radius, svg, width, zoom, zoomable_layer;
svg = d3.select('svg');
width = svg.node().getBoundingClientRect().width;
height = svg.node().getBoundingClientRect().height;
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([0, 0]).scale((width - 3) / (2 * Math.PI)).translate([width / 2, height / 2]);
path = d3.geoPath(projection);
graticule = d3.geoGraticule();
radius = d3.scaleSqrt().range([0, 50]);
color = d3.scaleOrdinal(d3.schemeCategory10).domain(['North America', 'Africa', 'South America', 'Asia', 'Europe', 'Oceania']);
zoomable_layer.append('path').datum(graticule.outline()).attrs({
"class": 'sphere_fill',
d: path
});
contents = zoomable_layer.append('g');
zoomable_layer.append('path').datum(graticule).attrs({
"class": 'graticule',
d: path
});
zoomable_layer.append('path').datum(graticule.outline()).attrs({
"class": 'sphere_stroke',
d: path
});
d3.json('ne_50m_admin_0_countries.topo.json', function(geo_data) {
var countries, countries_data, en_countries, en_labels, labels, labels_data;
countries_data = topojson.feature(geo_data, geo_data.objects.countries).features;
labels_data = [];
countries_data.forEach(function(d) {
var subpolys;
if (d.geometry.type === 'Polygon') {
d.area = d3.geoArea(d);
d.main = d;
return labels_data.push(d);
} else if (d.geometry.type === 'MultiPolygon') {
subpolys = [];
d.geometry.coordinates.forEach(function(p) {
var sp;
sp = {
coordinates: p,
properties: d.properties,
type: 'Polygon'
};
sp.area = d3.geoArea(sp);
return subpolys.push(sp);
});
d.main = subpolys.reduce((function(a, b) {
if (a.area > b.area) {
return a;
} else {
return b;
}
}), subpolys[0]);
return labels_data = labels_data.concat(subpolys);
}
});
countries = contents.selectAll('.country').data(countries_data);
en_countries = countries.enter().append('path').attrs({
"class": 'country',
d: path
});
labels = contents.selectAll('.label').data(labels_data);
en_labels = labels.enter().append('g').attrs({
"class": 'label',
transform: function(d) {
var ref, x, y;
ref = projection(d3.geoCentroid(d)), x = ref[0], y = ref[1];
return "translate(" + x + "," + y + ")";
}
});
en_labels.classed('no_iso_code', function(d) {
return d.properties.iso_a2 === '-99';
});
en_labels.append('text').text(function(d) {
return d.properties.name_long;
}).attrs({
dy: '0.35em'
});
lod(1);
return d3.csv('population.csv', function(data) {
var bubbles, en_bubbles, index, population_data;
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({
country: d,
value: +index[d.properties.iso_a3]['2016']
});
}
});
radius.domain([
0, d3.max(population_data, function(d) {
return d.value;
})
]);
population_data.sort(function(a, b) {
return d3.descending(a.value, b.value);
});
bubbles = contents.selectAll('.bubble').data(population_data);
en_bubbles = bubbles.enter().append('circle').attrs({
"class": 'bubble',
fill: function(d) {
return color(d.country.properties.continent);
},
r: function(d) {
return radius(d.value);
},
transform: function(d) {
var ref, x, y;
ref = projection(d3.geoCentroid(d.country.main)), x = ref[0], y = ref[1];
return "translate(" + x + "," + y + ")";
}
});
return en_bubbles.append('title').text(function(d) {
return d.country.properties.name_long + "\nPopulation: " + (d3.format(',')(d.value));
});
});
});
lod = function(z) {
return zoomable_layer.selectAll('.label').classed('hidden', function(d) {
return d.area < Math.pow(0.2 / z, 2);
});
};
}).call(this);
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>World population - bubbles on map</title>
<link type="text/css" href="index.css" rel="stylesheet"/>
<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>
</head>
<body>
<svg></svg>
<script src="index.js"></script>
</body>
</html>
svg = d3.select 'svg'
width = svg.node().getBoundingClientRect().width
height = svg.node().getBoundingClientRect().height
# 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 [0, 0]
.scale (width - 3) / (2 * Math.PI)
.translate [width/2, height/2]
path = d3.geoPath projection
# GRATICULE and OUTLINE
graticule = d3.geoGraticule()
# POPULATION BUBBLES SCALE
radius = d3.scaleSqrt()
.range [0, 50]
# COLORS
color = d3.scaleOrdinal(d3.schemeCategory10)
.domain ['North America', 'Africa', 'South America', 'Asia', 'Europe', 'Oceania']
zoomable_layer.append 'path'
.datum graticule.outline()
.attrs
class: 'sphere_fill'
d: path
contents = zoomable_layer.append 'g'
zoomable_layer.append 'path'
.datum graticule
.attrs
class: 'graticule'
d: path
zoomable_layer.append 'path'
.datum graticule.outline()
.attrs
class: 'sphere_stroke'
d: path
d3.json 'ne_50m_admin_0_countries.topo.json', (geo_data) ->
countries_data = topojson.feature(geo_data, geo_data.objects.countries).features
# label each polygon instead of each multipolygon (to help with islands etc.)
labels_data = []
countries_data.forEach (d) ->
if d.geometry.type is 'Polygon'
# compute area to aid label hiding
d.area = d3.geoArea(d)
d.main = d
labels_data.push d
else if d.geometry.type is 'MultiPolygon'
subpolys = []
d.geometry.coordinates.forEach (p) ->
sp = {
coordinates: p
properties: d.properties
type: 'Polygon'
}
# compute area to aid label hiding
sp.area = d3.geoArea(sp)
subpolys.push sp
# store the biggest polygon as main
d.main = subpolys.reduce ((a, b) -> if a.area > b.area then a else b), subpolys[0]
labels_data = labels_data.concat subpolys
# countries
countries = contents.selectAll '.country'
.data countries_data
en_countries = countries.enter().append 'path'
.attrs
class: 'country'
d: path
# labels
labels = contents.selectAll '.label'
.data labels_data
en_labels = labels.enter().append 'g'
.attrs
class: 'label'
transform: (d) ->
[x,y] = projection d3.geoCentroid(d)
return "translate(#{x},#{y})"
en_labels
.classed 'no_iso_code', (d) -> d.properties.iso_a2 is '-99'
en_labels.append 'text'
.text (d) -> d.properties.name_long
.attrs
dy: '0.35em'
# lod
lod(1)
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 {
country: d
value: +index[d.properties.iso_a3]['2016']
}
radius
.domain [0, d3.max population_data, (d) -> d.value]
# sort by descending population to avoid covering a small bubble with a big one
population_data.sort (a,b) -> d3.descending(a.value, b.value)
# bubbles
bubbles = contents.selectAll '.bubble'
.data population_data
en_bubbles = bubbles.enter().append 'circle'
.attrs
class: 'bubble'
fill: (d) -> color d.country.properties.continent
r: (d) -> radius d.value
transform: (d) ->
[x,y] = projection d3.geoCentroid(d.country.main)
return "translate(#{x},#{y})"
en_bubbles.append 'title'
.text (d) -> "#{d.country.properties.name_long}\nPopulation: #{d3.format(',')(d.value)}"
lod = (z) ->
zoomable_layer.selectAll '.label'
.classed 'hidden', (d) -> d.area < Math.pow(0.2/z,2)
body, html {
padding: 0;
margin: 0;
height: 100%;
}
svg {
width: 100%;
height: 100%;
background: white;
}
.sphere_stroke {
fill: none;
stroke: black;
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
.sphere_fill {
fill: white;
}
.graticule {
fill: none;
stroke: #777;
stroke-width: 0.5px;
stroke-opacity: 0.5;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.country {
fill: #999;
fill-opacity: 0.3;
stroke: white;
stroke-width: 0.5;
vector-effect: non-scaling-stroke;
}
.label {
font-family: sans-serif;
font-size: 10px;
pointer-events: none;
text-anchor: middle;
}
.label.no_iso_code {
font-style: italic;
}
.label.hidden {
display: none;
}
.bubble {
fill-opacity: 0.2;
stroke: black;
stroke-width: 0.5;
vector-effect: non-scaling-stroke;
}
.bubble:hover {
fill-opacity: 0.4;
}