block by nitaku b66b6e352187c836c1f53b091f7a1b05

World population - bubbles on map

Full Screen

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.

index.js

// 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);

index.html

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

index.coffee

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)

index.css

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;
}