block by nitaku fb61dc1f251fd1cbac8e71d48f95fab3

World map V

Full Screen

Continuing the series on implementing a general purpose world map (see the previous attempt), this example introduces a way to solve the placement of labels for countries with faraway territories (like France). In previous solutions, the labels were placed at the centroid of the whole MultiPolygon feature, but this time it is splitted into multiple Polygon features, each labeled independently.

A tooltip is also added to help identifying regions when zoomed out.

index.js

// Generated by CoffeeScript 1.10.0
(function() {
  var contents, graticule, height, lod, path, projection, 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();

  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) {
      if (d.geometry.type === 'Polygon') {
        return labels_data.push(d);
      } else if (d.geometry.type === 'MultiPolygon') {
        return d.geometry.coordinates.forEach(function(p) {
          return labels_data.push({
            coordinates: p,
            properties: d.properties,
            type: 'Polygon'
          });
        });
      }
    });
    labels_data.forEach(function(d) {
      return d.area = d3.geoArea(d);
    });
    countries = contents.selectAll('.country').data(countries_data);
    en_countries = countries.enter().append('path').attrs({
      "class": 'country',
      d: path
    });
    en_countries.append('title').text(function(d) {
      return d.properties.name_long;
    });
    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;
    });
    return lod(1);
  });

  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 map V</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()

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'
        labels_data.push d
      else if d.geometry.type is 'MultiPolygon'
        d.geometry.coordinates.forEach (p) ->
          labels_data.push {
            coordinates: p
            properties: d.properties
            type: 'Polygon'
          }
    
    # compute area to aid label hiding
    labels_data.forEach (d) -> d.area = d3.geoArea(d)
#     labels_data.sort (a,b) -> a.area - b.area
    
    # countries
    countries = contents.selectAll '.country'
      .data countries_data
      
    en_countries = countries.enter().append 'path'
      .attrs
        class: 'country'
        d: path
        
    en_countries.append 'title'
      .text (d) -> d.properties.name_long
      
    # 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
      
    # lod
    lod(1)
      
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: #DDD;
  stroke: white;
  stroke-width: 0.5;
  vector-effect: non-scaling-stroke;
}
.country:hover {
  fill: #CCC;
}

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