block by nitaku b6b22e56511064d837859b76319c4619

World population - geo-informed circle packing

Full Screen

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.

index.js

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

index.html

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

index.coffee

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

index.css

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