block by harrystevens 1b7db7831777665ba8457b530397fb8d

Contours

Full Screen

Use d3-contour to make a contour map.

This is part of a graphic published in Axios with Andrew Freedman’s story, First map of global freshwater trends shows “human fingerprint”. The data is from from M. Rodell et al., 2018, based on data from Gravity Recovery and Climate Experiment (GRACE) satellites.

index.html

<!DOCTYPE html>
<html>
<head>
  <style>
    .polygon {
      fill: none;
      stroke-width: .5px;
      stroke-dasharray: 5, 5;
    }
    .boundary {
      fill: none;
      stroke: #000;
      stroke-width: 1px;
    }
  </style>
</head>
<body>
  <script src="https://d3js.org/d3.v4.min.js"></script>
  <script src="https://d3js.org/d3-contour.v1.min.js"></script>
  <script src="https://d3js.org/d3-geo-projection.v2.min.js"></script>
  <script src="https://unpkg.com/topojson@3"></script>
  <script>
    var width = window.innerWidth, 
        height = window.innerHeight;

    var svg = d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height);

    var clipPath = svg.append("clipPath")
        .attr("id", "clipPath");

    var projection = d3.geoNaturalEarth1();
        path = d3.geoPath(projection);

    var n = 720, m = 360;

    var contourGenerator = d3.contours()
      .size([n, m])
      .thresholds(20);

    var colors = ["tomato", "pink", "white", "lightblue", "steelblue"];

    var colorScale = d3.scaleLinear()
      .interpolate(d3.interpolateLab)
      .range(colors)
      .domain([-2, -.4, 0, .4, 2]);

    d3.queue()
        .defer(d3.json, "countries.json")
        .defer(d3.json, "data.json")
        .await(ready);

    function ready(error, world, values){
      // Coerce min and max value to -2 and 2.
      values = values.map(d => d >= 2 ? 2 : d <= -2 ? -2 : d);

      var feature = topojson.feature(world, world.objects.countries),
          mesh = topojson.mesh(world, world.objects.land, (a, b) => a === b);

      draw();

      window.onresize = () => draw();

      function draw(){
        width = window.innerWidth, height = window.innerHeight;
        svg.attr("width", width).attr("height", height);
        projection.fitSize([width, height], feature);

        var clipBoundary = clipPath.selectAll(".clip-boundary")
            .data([mesh]);
        
        clipBoundary.enter().append("path")
            .attr("class", "clip-boundary")
            .style("stroke", "#4a4a4a")
            .style("fill", "none")
          .merge(clipBoundary)
            .attr("transform", "scale(1, -1) translate(0, " + (-height * .99) + ")")
            .attr("d", path);  

        var contours = svg.selectAll(".contour")
            .data(contourGenerator(values).map(invert));

        contours.enter().append("path")
            .attr("class", "contour")
            .attr("fill", d => colorScale(d.value))
            .attr("clip-path", "url(#clipPath)")
          .merge(contours)
            .attr("transform", "scale(1, -1) translate(0, " + (-height * .99) + ")") // TODO: Figure out why my data is updside down.
            .attr("d", path);
        
        var polygons = svg.selectAll(".polygon")
            .data(feature.features);

        polygons.enter().append("path")
            .attr("class", "polygon")
            .style("stroke", d => d.id === 10 ? "#fff" : "4a4a4a") // Remove Antarctica's stroke.
          .merge(polygons)
            .attr("d", path);

        var boundary = svg.selectAll(".boundary")
            .data([mesh]);

        boundary.enter().append("path")
            .attr("class", "boundary")
          .merge(boundary)
            .attr("d", path);
      }

      // See: https://bl.ocks.org/mbostock/83c0be21dba7602ee14982b020b12f51
      function invert(d) {
        var shared = {};
        var p = {
          type: "Polygon",
          coordinates: d3.merge(d.coordinates.map(function(polygon) {
            return polygon.map(function(ring) {
              return ring.map(function(point) {
                return [point[0] / n * 360 - 180, 90 - point[1] / m * 180];
              }).reverse();
            });
          }))
        };
        // Record the y-intersections with the antimeridian.
        p.coordinates.forEach(function(ring) {
          ring.forEach(function(p) {
            if (p[0] === -180) shared[p[1]] |= 1;
            else if (p[0] === 180) shared[p[1]] |= 2;
          });
        });
        // Offset any unshared antimeridian points to prevent their stitching.
        p.coordinates.forEach(function(ring) {
          ring.forEach(function(p) {
            if ((p[0] === -180 || p[0] === 180) && shared[p[1]] !== 3) {
              p[0] = p[0] === -180 ? -179.9995 : 179.9995;
            }
          });
        });
        p = d3.geoStitch(p);
        // If the MultiPolygon is empty, treat it as the Sphere.
        return p.coordinates.length
            ? {type: "Polygon", coordinates: p.coordinates, value: d.value}
            : {type: "Sphere", value: d.value};
      }
    }
  </script>
</body>
</html>