Introduction

In this tutorial, we will learn about using GeoJSON data to draw maps in D3. At the end of this tutorial, we will create this Map, which shows parts of the Australian import and export trade. GeoJSON is a Javascript data that has a standard format to represent geographical features combined with non-geographical attributes. The features include coordinate information for different types of geometries (for example, points, line strings, polygons and collections of these types). The features geometries are used to draw map's parts as we will see soon. Sometimes, the geographical data that we need comes in the format of a Shapefile .shp and therefore, we need to convert it into a .geojson format to use it. We will learn how in the next section.

Shapefile to GeoJSON

Download the Shapefile folder from the Week 11 Tutorial Files on the Blackboard. Sometimes Shapefiles are heavy because they contain a huge number of geographical points and, consequently, take a long time to load on the web. Therefore we need to reduce the number of points to an acceptable level to reduce the file size. To do that we use mapshaper. Luckily, our Shapefile is already simplified, but we will show you how to simplify it in case you need to. Open the mapshaper website and click select under the Edit a file panel, choose the .shp file inside the Shapefile folder that you downloaded earlier. You will see that a map will show. Click Simplify on the upper right corner of the page, leave the default settings and click Apply, a Settings input range will show, you can reduce the percentage to the point where the map still showing an acceptable level of details. If Repair link showed on the left upper side of the page after adjusting the percentage, click it to fixed line intersections. Finally, click Export on the upper right corner of the page, select Shapefile and press Export. A zip folder will download to your computer, (you don't need to do the following step since our original Shapefile is already simplified) unzip the folder and copy all its files and paste them in the original Shapefile folder to replace its existing contents.

Now that the Shapefile is ready, we can convert it to a .geojson file using Python and GDAL library. We need to install the library first. If you are using Mac, install Homebrew by following the instructions on this Page. Then, open the terminal and type the command to use brew to install gdal: brew install gdal.

If you are using Windows, follow the instructions on this Page to install gdal.

Now, use the terminal/command line to move to the unzipped Shapefile folder using the cd command. Then, use the following command to convert the .shp file into .geojson file:
ogr2ogr -f GeoJSON -t_srs crs:84 fileName.geojson fileName.shp
Based on the command above, we type the following in the command line:
ogr2ogr -f GeoJSON -t_srs crs:84 ne_50m_admin_0_countries.geojson ne_50m_admin_0_countries.shp
You will find a new .geojson added to the Shapefile folder. Change this file name to world.geojson . We will read this file next to draw a map using D3.

Reading GeoJSON Data Using d3

Create a project folder, and paste the world.geojson file inside it. You can find the same world.geojson file in the Week11 Tutorial Files on the Blackboard. Use Sublime Text to create index.html file inside the project folder and paste the following code template inside it:


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>World Map</title>
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <style> 
    </style>
  </head>
  <body>
    <div id="map">
    </div>
    <script>
    
    </script>
  </body>
</html>
</span>
      

To read .geojson file, we use d3.json method, which has a similar syntax to the one we used to read .csv files:
d3.json(fileName, callbackfunction(error, data){// do things with the data});
Inside the <script></script> tag of your index.html file, paste the following code:

<script>
  
  d3.json("world.geojson", function(error, data) {
    if(error) {
      console.error(error);
    }
    console.log(data);
  });
    
</script>
      

Save the file and run it on a localhost, open the console on the Browser Developer Tools and check the result of console.log(data);. You will see an Object, click on it, a number of properties will show, one property is called features. It is an array of objects; each object has properties called geometry and properties, geometry contains coordinates and the type of geometry (Polygon for example), and properties has information linked to the geometry. We can use this information to draw a map in the next step.

Draw Map elements

We will draw a world map. We use SVG path element to draw the outlines of the countries, and then, we can modify the fill, stroke and other styles of the created paths.
First, we need to create a function that provides the description d attribute for the path element. To do this, we use d3.geopath() method, wich takes the coordinates from the .geojson data and describe them to the path element through its d attribute.
Since we are dealing with geographical coordinates, we need to use a map projection, which is responsible for transforming the coordinates from the earth spherical surface into locations on a plane map. There are different types of projections; we will use Mercator:
var projection = d3.geoMercator().center([-36, 35]).scale(190);
center controls the center point of the plane map and scale define the size of the map. Now, we can chain the projection to our geoPath() using projection() method:

  
  var projection = d3.geoMercator()
                     .center([-36, 35])
                     .scale(190);
  var lineGenerator = d3.geoPath()
                        .projection(projection);
  
      

We will use the lineGenerator variable in the d attribute of the path soon.
We will draw paths based on the data from the .geojson file. First, let's create an svg container to hold the svg elements and add a group g to contain the paths that we will draw soon:

  
  var svg = d3.select("#map").append("svg")
              .attr("width", "1200px")
              .attr("height", "600px");
  var geomap = svg.append("g").attr("id", "geomap");
  
});
      

Let's append the paths and put the code together:

var svg = d3.select("#map").append("svg")
              .attr("width", "1200px")
              .attr("height", "600px");
var geomap = svg.append("g").attr("id", "geomap");
var projection = d3.geoMercator()
                   .center([-36, 35])
                   .scale(190);
var lineGenerator = d3.geoPath()
                      .projection(projection);
d3.json("world.geojson", function(error, data) {
  if(error) {
    console.error(error);
  }  

  geomap.selectAll("path")
        .data(data.features)
        .enter()
        .append("path")
        .attr("d", function(d) {
          return lineGenerator(d)
        })
        .style("fill", "green")
        .style("stroke", "black");

});
      

Save the file and refresh the localhost browser to check the result.
The concept is simple, and since we access the data associated with each path, we can do a lot. Say you want to style the colour of Australia differently, because our .geojson has a country name property under data > features > properties > geounit, we can change the above code as follow:

    console.error(error);
  }  

  geomap.selectAll("path")
        .data(data.features)
        .enter()
        .append("path")
        .attr("d", function(d) {
          return lineGenerator(d)
        })
        .style("fill", function(d) {
          if(d.properties.geounit == "Australia") {
            return "blue";
          } else {
            return "green";
          }
        })
        .style("stroke", "black");

});
      

Now that we know how to read .geojson data, we can do many things using the concepts of data binding, append svg elements and modifying their attr and style that we learned in previous tutorials. Let's add the name of each country to the map. First, append a group to the svg container to hold the text elements:

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

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

var projection = d3.geoMercator()
      

Now, let's add the text elements:

        .style("stroke", "black");
      
      names.selectAll("text")
           .data(data.features)
           .enter()
           .append("text")
           .text(function(d) {
             return d.properties.geounit;
           })
           .attr("x", function(d) {
             return lineGenerator.centroid(d)[0];
           })
           .attr("y", function(d) {
             return lineGenerator.centroid(d)[1];
           })
           .style("font-size", 8)
           .style("fill", "brown")
           .style("text-anchor", "middle");
      
});
      

centroid method returns the centre of coordinates as an array of two elements; the first element is the x position and the second is the y position.
How about if we want to draw lines, for example, a line between Australia and China. First, append a group to the svg container:

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

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

      

Now, let's get the centre point of Australia and China and use them to draw a line that we append to lines group:

    .style("text-anchor", "middle");

// Find the center of Australia and China
var centreAus;
var centreChi;
data.features.forEach(function(d) {
  if(d.properties.geounit == "Australia") {
    centreAus = lineGenerator.centroid(d);
  }
  if(d.properties.geounit == "China") {
    centreChi = lineGenerator.centroid(d);
  }
});
// Append the line
lines.append("line")
     .attr("x1", centreAus[0])
     .attr("y1", centreAus[1])
     .attr("x2", centreChi[0])
     .attr("y2", centreChi[1])
     .style("stroke", "red")
     .style("stroke-width", 5);
 
      

Let's add a circle at the centre of each country with a circle radius equal to the labelrank, which is a property in the data. First, append a group the svg container:

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

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

      

Let's append circle elements to the group circles:

    .style("stroke-width", 5);

// Append circles
circles.selectAll("circle")
       .data(data.features)
       .enter()
       .append("circle")
       .attr("cx", function(d) {
        return lineGenerator.centroid(d)[0];
       })
       .attr("cy", function(d) {
        return lineGenerator.centroid(d)[1];
       })
       .attr("r", function(d) {
        return d.properties.labelrank;
       })
       .style("fill", "orange")
       .style("stroke", "black");

      

Practice Example

Now, that we understand the concept behind using d3 to draw map elements, we are going to create a map of the world. It shows Australia import and export through animations, check the result map. We will not go through the code in details since we have already covered the involved ideas in this tutorial and previous tuturials. Instead, we will briefly explain the functionality of each chunk of code.
First, Create a new project folder, download the world.geojson and trade.csv files from the Blackboard and paste them in the new project folder. Create a new index.html file inside the new project folder and paste the following code template inside it:


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>World Map</title>
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <style> 
      svg {
        background-color: #83d2f9;
      }
      #tooltip {
        position: fixed;
        width: 200px;
        margin: 0;
        padding: 3px;
        border-radius: 3px;
        background-color: white;
        visibility: hidden;
      }
      #tooltip-text {
        margin: 0;
        padding: 0;
        font-size: 10px;
      }
      .bold {
        font-weight: bold;
      }
      #buttons {
        position: relative;
        top: -25px;
      }
      button {
        width: 70px;
      }
    </style>
  </head>
  <body>
    <div id="title">
      <h1>Australia Import and Export</h1>
    </div>
    <div id="map">
    </div>
    <div id="tooltip">
      <p id="tooltip-text"></p>
    </div>
    <div id="buttons">
      <button id="imp" type="button">Imports</button>
      <button id="exp" type="button">Exports</button>
      <button id="res" type="button">Reset</button>
    </div>
    <div>
      <h3>Australia import and export of five products, shown in the legend, between <strong>2010</strong> and <strong>2016</strong></h3>
      <p>The tone of the colour of each partner country refers to the total value of the import/export during the total period between 2010 and 2016. The darker the colour, the higher the value.<br/>Each moving circle represent a single export/import operation. Hover over a country to view its relevant details.<br/>This graph doesn't show the all the import/export data. Only a part of the data was used for the purpose of the demonstration.</p>
    </div>
    <div id="references">
      <p>Data Source:</p>
      <a href="http://www.naturalearthdata.com/downloads/50m-cultural-vectors/" target="_blank">Natural Earth: Geographical data in the form of Shapefile</a><br/>
      <a href="https://data.gov.au/dataset?tags=trade" target="_blank">data.gov.au: Australia's merchandise trade by country (TRIEC) to FY2016</a>
    </div>
    <script>
    
    </script>
  </body>
</html>

      

Inside the <script></script> tag, paste the following:

<script>
  
  // SVG, and groups
  var svg = d3.select("#map").append("svg")
              .attr("width", "1200px")
              .attr("height", "600px");
  var geomap = svg.append("g").attr("id", "geomap");
  var highlights = svg.append("g").attr("id", "highlights")
  var lines = svg.append("g").attr("id", "lines");
  var circles = svg.append("g").attr("id", "circles");
  var circles = svg.append("g").attr("id", "cirlces");
  var legendCircles = svg.append("g").attr("id", "legendCircles");
  var text = svg.append("g").attr("id", "text");
  
</script>
      

The above code creates the svg container, to which we append groups to use them later to append different elements of the map. Next, add the following code:

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

// Projection and liner generator
var projection = d3.geoMercator()
                   .center([-36, 35])
                   .scale(190);
var lineGenerator = d3.geoPath()
                      .projection(projection);

</script>
      

Above, we added a projection and a line generator to use it later to draw path elements. In this example, we are using two data files. The first is a .geojson, which contains the name of the countries and their geographical information, and the second is .csv, which contains the name of the countries and other data including the year, the type (import/export), the product type, the partner country and the value of the deal. We will read data from both files and create our own dataset that combines data from both files. We are able to link between the two sets of data because they have one property in common the name of the country and therefore, we can associate between data in the two different columns. Add the following:

                      .projection(projection);

// Read both data files
d3.json("world.geojson", function(error, geodata) {
  if(error) {
    console.error(error);
  }
  d3.csv("trade2.csv", function(error, csvdata) {
    if(error) {
      return console.error(error);
    }
    // Create a new database that merge both
    var imports = [];
    var exports = [];
    geodata.features.forEach(function(d) {
      var objI = {};
      var objE = {};
      objI.year = [];
      objI.producType = [];
      objI.productValue = [];
      objE.year = [];
      objE.producType = [];
      objE.productValue = [];
      for(var i = 0; i < csvdata.length; i++) {
        var currentRow = csvdata[i];
        if(d.properties.geounit == currentRow["Partner Country"]) {
          if(currentRow["Trade type"] == "Import") {
            objI.name = d.properties.geounit;
            objI.geometry = d;
            objI.year.push(currentRow["Year"]);
            objI.producType.push(currentRow["TRIEC 3 digit"]);
            objI.productValue.push(currentRow["A$000"]);
          } else {
            objE.name = d.properties.geounit;
            objE.geometry = d;
            objE.year.push(currentRow["Year"]);
            objE.producType.push(currentRow["TRIEC 3 digit"]);
            objE.productValue.push(currentRow["A$000"]);
          }
        }
      }
      objI.totalValue = d3.sum(objI.productValue);
      objE.totalValue = d3.sum(objE.productValue);
      if(objI.year.length > 0) {
        imports.push(objI);
      } 
      if(objE.year.length > 0 ) {
        exports.push(objE);
      }
    });
  });
});

</script>
      

In the code above, we created two arrays of objects imports and exports as two datasets. Each object contains the properties:

So, we iterate through all the data points and use conditions to check if a trade type is export or import and then add them to either the imports or exports array accordingly. Each object will have one country name, one geometry, one total value, but a number of years, product types and product values because the .csv file contains many entries for each country.

        exports.push(objE);
      }
    });
    
    // Products' types and correspondig colour
    var types = ["Unprocessed food & live animals", "Unprocessed minerals", "Unprocessed fuels", "Processed food", "Engineering products (excl Household equip)"];
    var colours = ["#66a61e", "#e7298a", "#727272", "#7570b3", "#d95f02"];
    
  });
});
      

Above, we added two arrays to represent all the five product types used in the data and the colours that correspond to each product type. We will use these two arrays later.

    var colours = ["#66a61e", "#e7298a", "#727272", "#7570b3", "#d95f02"];
    
    // Get the centre coordinate of Australia
    var ausgeodata = geodata.features.filter(function(obj) {
      if (obj.properties.geounit == "Australia") {
        return obj;
      }
    });
    var ausCentroid = lineGenerator.centroid(ausgeodata[0]);
    
  });
});
      

Above code filters the data and get the centre point of Australia. Notice that we used ausgeodata[0] because filter method returns an array.

    var ausCentroid = lineGenerator.centroid(ausgeodata[0]);
    
    // Draw Countries
    geomap.selectAll("path")
        .data(geodata.features)
        .enter()
        .append("path")
        .attr("d", lineGenerator)
        .attr("id", function(d) {
          return d.properties.geounit;
        })
        .style("stroke", "black")
        .style("fill", "#fdcd01");
    
  });
});
      

Above, we draw countries using path elements.

        .style("fill", "#fdcd01");
    
    // Animation function
    function animate(type) {
      var data;
      var lineColour;
      if(type == "Import") {
        data = imports;
        lineColour = "red"
      }
      if(type == "Export") {
        data = exports;
        lineColour = "green";
      }
    }
    
  });
});
      

Above, we created a function animate to include the code related to the animation inside it, so that whenever we want to perform the animation, we call this function. Note that we used an attribute type in the function. We can use this attribute to control the type of animation. If we passed "Import" value, the condition statement that follows would make sure to use imports as a dataset; otherwise, exports dataset will be used.

        lineColour = "green";
      }
    
    var importColourScale = d3.scaleLinear()
                              .domain(d3.extent(data, function(d) {return d.totalValue}))
                              .range(["#bae4b3", "#006d2c"]);
    var exportColourScale = d3.scaleLinear()
                              .domain(d3.extent(data, function(d) {return d.totalValue}))
                              .range(["#fdbe85", "#a63603"]);
    
    }
  });
});
      

Above, we created two scales to use them later to control the fill colour of each country so that the higher the value passed to the scale, the darker the fill colour.

                              .range(["#fdbe85", "#a63603"]);
    
    // Highlight involved countries
    highlights.selectAll("path")
              .data(data)
              .enter()
              .append("path")
              .attr("class", "highlight")
              .attr("d", function(d) {
                return lineGenerator(d.geometry);
              })
              .style("fill", function(d) {
                if(type == "Export") {
                  return exportColourScale(d.totalValue);
                } else {
                  return importColourScale(d.totalValue);
                }
              })
              .style("stroke", "black")
              .style("stroke-width", 1);
    highlights.append("path")
              .attr("class", "highlight")
              .attr("class", "aus")
              .attr("d", lineGenerator(ausgeodata[0]))
              .style("fill", function() {
                if(type == "Export") {
                  return "#006d2c"
                } else{
                  return "#a63603"
                }
              });
    
    }
  });
});
      

Above code, assigned fill colours to the paths of the world countries and Australia based on the type (import/export).

                  return "#a63603"
                }
              });
    
    // Tooltip highlights interactions
    var ausTotalValue = 0;
    for(var i = 0; i < data.length; i++) {
      ausTotalValue = ausTotalValue + data[i].totalValue;
    }
    // Mouseover
    d3.selectAll(".highlight, .aus").on("mouseover", function() {
      d3.select(this)
        .style("fill", "#2b83ba")
        .style("stroke", "#d7191c")
        .style("stroke-width", 2);
      var screenWidth = d3.select("html").node().getBoundingClientRect().width;
      var mouseX = d3.event.clientX;
      var mouseY = d3.event.clientY + 20;
      d3.select("#tooltip")
        .style("left", function() {
          if(200 + mouseX >= screenWidth) {
            return (mouseX - 200) + "px";
          } else {
            return mouseX + "px";
          }
        })
        .style("top", mouseY + "px")
        .style("visibility", "visible");
      if(d3.select(this).attr("class") == "aus") {
        var country = "Australia";
        var total = ausTotalValue;
        var uniqueTypes = types;
      } else {
        var country = d3.select(this).data()[0].name;
        var total = d3.select(this).data()[0].totalValue;
        var productTypes = [];
        var doublicatedTypes = d3.select(this).data()[0].producType;
        var uniqueTypes = doublicatedTypes.filter(function(item, index) {
          return doublicatedTypes.indexOf(item) == index;
        });
      }
      d3.select("#tooltip-text").html("<span class=bold>Country</span>: " + country + "<br/><span class=bold>Total " + type + " Value</span>: $" + Math.round(total) + "<br/><span class=bold>Product</span>: " + uniqueTypes);
    });
    // Mouseout
    d3.selectAll(".highlight, .aus").on("mouseout", function() {
      if(d3.select(this).attr("class") == "aus") {
        d3.select(this)
          .style("stroke", "black")
          .style("stroke-width", 1)
          .style("fill", function() {
            if(type == "Export") {
              return "#006d2c"
            } else{
              return "#a63603"
            }
          });
      } else {
        d3.select(this)
        .style("stroke", "black")
        .style("stroke-width", 1)
        .style("fill", function(d) {
          if(type == "Export") {
            return exportColourScale(d.totalValue);
          } else {
            return importColourScale(d.totalValue);
          }
        });
      }
      d3.select("#tooltip").style("visibility", "hidden");
    });
    
    }
  });
});
      

Above code adds event listening and interactive tooltip in a fashion similar to what we've done in the previous tutorial.

      d3.select("#tooltip").style("visibility", "hidden");
    });
    
    // Draw lines 
    lines.selectAll("line")
         .data(data)
         .enter()
         .append("line")
         .attr("class", "paths")
         .attr("x1", ausCentroid[0])
         .attr("y1", ausCentroid[1])
         .attr("x2", function(d) {
          return lineGenerator.centroid(d.geometry)[0];
         })
         .attr("y2", function(d) {
          return lineGenerator.centroid(d.geometry)[1];
         })
         .style("stroke", lineColour)
         .style("opacity", 0.5)
         .style("stroke-width", 0.2);
    
    }
  });
});
      

Above, we used SVG line elements to draw lines between Australia and trade partner countries.

         .style("stroke-width", 0.2);
      
      // Prepare data for drawing the circles
      data.forEach(function(d) {
        var xPos;
        var yPos;
        var xTarg;
        var yTarg;
        if(type == "Import") {
          xPos = lineGenerator.centroid(d.geometry)[0];
          yPos = lineGenerator.centroid(d.geometry)[1];
          xTarg = ausCentroid[0];
          yTarg = ausCentroid[1];
        } else {
          xPos = ausCentroid[0];
          yPos = ausCentroid[1];
          xTarg = lineGenerator.centroid(d.geometry)[0];
          yTarg = lineGenerator.centroid(d.geometry)[1];
        }
        for(var i = 0; i < d.year.length; i++) {
          var currentProduct = d.producType[i];
          var colour;
          if(currentProduct == types[0]) {
            colour = colours[0];
          } else if(currentProduct == types[1]) {
            colour = colours[1];
          } else if(currentProduct == types[2]) {
            colour = colours[2];
          } else if(currentProduct == types[3]) {
            colour = colours[3];
          }else {
            colour = colours[4];
          }
          var time = 400000 / d.year.length;

          // Drawing the circles
          var dots = circles.append("circle")
                            .attr("class", "circles")
                            .attr("r", 3)
                            .style("fill", colour)
                            .style("stroke-width", 0.1)
                            .attr("cx", xPos)
                            .attr("cy", yPos);
        }
      });
      
    }
  });
});
      

Above, we got the start and end positions of the circles based on the type (export/import). Then, iterated through the data and assigned a value to the colour variable based on the product type and finally, appended and drew circles.

                          .attr("cy", yPos);
          
          // Animating the circles
          function wave() {
            dots.attr("cx", xPos)
                .attr("cy", yPos)
                .transition()
                .ease(d3.easeLinear)
                .duration(time)
                .delay(function() {
                  return time * i;
                })
                .attr("cx", xTarg)
                .attr("cy", yTarg);
                // .on("end", wave);
          }
          wave();
          
        }
      });
    }
  });
});
      

Above, we created a function wave to be called to animate the circles.

                          .attr("cy", yPos);
    }
    wave();
   }
  });
}

    // Draw the legend
    for(var i = 0; i < 5; i++) {
      legendCircles.append("circle")
          .attr("cx", 15)
          .attr("cy", 490 + (i * 20))
          .attr("r", 5)
          .style("fill", colours[i])
          .style("stroke", "black");
      text.append("text")
          .attr("x", 25)
          .attr("y", 494 + (i * 20))
          .text(types[i])
          .style("font-size", "12px");
    }

  });
});
      

Above, we draw the legend using SVG circle and text elements.

      .style("font-size", "12px");
    }

    // Buttons event listening
    d3.select("#imp").on("click", function() {
      d3.selectAll(".circles").transition().duration(0).remove();
      d3.selectAll(".highlight").remove();
      d3.selectAll(".aus").remove();
      d3.selectAll(".paths").remove();
      animate("Import");
    });
    d3.select("#exp").on("click", function() {
      d3.selectAll(".circles").transition().duration(0).remove();
      d3.selectAll(".highlight").remove();
      d3.selectAll(".aus").remove();
      d3.selectAll(".paths").remove();
      animate("Export");
    });
    d3.select("#res").on("click", function() {
      d3.selectAll(".circles").transition().duration(0).remove();
      d3.selectAll(".highlight").remove();
      d3.selectAll(".aus").remove();
      d3.selectAll(".paths").remove();
    });
    
  });
});
      

Finally, we added event listening to the HTML buttons so that when the user click on a button, the transitions will stop, and the created elements inside the animate() function will be deleted using remove() method, and then, animate function can be called with an attribute value "Import" / "Export" based on what button has been clicked.

This is the end of the tutorial. The final Practice Exmaple file is available in the Week 11 Tutorial Files on the Blackboard. We learned the basics of how to use d3 to draw interactive maps based on GeoJSON data.


Sarkar, S. and Hussein, D.A., 2017, D3 Tutorials for Information Visualisation Design Studio, University of Sydney.
Email: somwrita.sarkar@sydney.edu.au, dhus4848@uni.sydney.edu.au