Introduction

In this tutorial, we will learn how to use a Leaflet map as a base for D3 map's elements. Leaflet is useful to provide the base map, which already has the names of places on the map with a responsive built-in pan/zoom in/zoom out functionalities so that you don't worry about building them from scratch in D3. We will start with Leaflet fundamentals that we learned in Tutorial 3 and, then, add some code to coordinate D3 geographical locations on a Leaflet map. We will build on to of the practice example from the previous tutorial to produce this Map.

Preparing a Base File for the Tutorial

We took the code from the practice tutorial of the previous week, and adjusted it slightly by removing the svg container var svg, the lines responsible for positioning the elements (e.g. .attr("x1", ...), .attr("cx", ...), .attr("d", ...) ) and we made some other style modifications. The removed lines will be replaced by new code based on the following sections. For now, create a new folder, name it project. Download world.geojson and trade2.csv files from the Blackboard, under Week 12 Tutorial Files and paste them inside the project folder. Use Sublime Text to create a new document inside the project folder and name it index.html. Paste the following code inside the document:


<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>World Map</title>
    <!-- D3 link -->
    <script src="https://d3js.org/d3.v4.min.js"></script>
    <style> 
      #map {
        height: 600px;
        width: 1200px;
      }
      #tooltip {
        position: fixed;
        width: 200px;
        margin: 0;
        padding: 3px;
        border-radius: 3px;
        background-color: white;
        visibility: hidden;
        z-index: 10000000;
      }
      #tooltip-text {
        margin: 0;
        padding: 0;
        font-size: 10px;
      }
      .bold {
        font-weight: bold;
      }
      #buttons {
        position: absolute;
        top: 655px;
        left: 15px;
        z-index: 1000000;
      }
      button {
        width: 70px;
      }
      .highlight, .aus {
        cursor: context-menu;
      }
      #legend {
        position: absolute;
        top: 555px;
        z-index: 1000000;
        visibility: hidden;
      }
    </style>
  </head>
  <body>
    <div id="title">
      <h1>Australia Import and Export</h1>
    </div>
    <div id="map">
    </div>
    <div id="legend">
    </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>
    // 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"] * 1000);
              } 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"] * 1000);
              }
            }
          }
          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);
          }
        });
        // 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"];
        // Get the centre coordinate of Australia
        var ausgeodata = geodata.features.filter(function(obj) {
          if (obj.properties.geounit == "Australia") {
            return obj;
          }
        });
        
        // Animation function
        function animate(type) {
          var data;
          var lineColour;
          if(type == "Import") {
            data = imports;
            lineColour = "red"
          }
          if(type == "Export") {
            data = exports;
            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"]);

          // Highlight involved countries
          var feature1 = highlights.selectAll("path")
                                    .data(data)
                                    .enter()
                                    .append("path")
                                    .attr("class", "highlight")
                                    .style("fill", function(d) {
                                      if(type == "Export") {
                                        return exportColourScale(d.totalValue);
                                      } else {
                                        return importColourScale(d.totalValue);
                                      }
                                    })
                                    .style("stroke", "black")
                                    .style("stroke-width", 1)
                                    .style("pointer-events", "visible")
                                    .style("opacity", 0.5);
          var feature2 = highlights.append("path")
                                   .attr("class", "highlight")
                                   .attr("class", "aus")
                                   .style("fill", function() {
                                     if(type == "Export") {
                                       return "#006d2c"
                                     } else{
                                       return "#a63603"
                                     }
                                   })
                                   .style("pointer-events", "visible")
                                   .style("opacity", 0.5);
          // 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");
          });

          // Draw lines 
          feature3 = lines.selectAll("line")
               .data(data)
               .enter()
               .append("line")
               .attr("class", "paths")
               .style("stroke", lineColour)
               .style("opacity", 0.5)
               .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") {
              if(d.name == "United States") {
                xPos = lineGenerator.centroid(d.geometry)[0] + 55;
              } else {
                xPos = lineGenerator.centroid(d.geometry)[0];
              }
              if(d.name == "United States" || d.name == "Canada") {
                yPos = lineGenerator.centroid(d.geometry)[1] + 55;
              } else {
                yPos = lineGenerator.centroid(d.geometry)[1];
              }
              xTarg = lineGenerator.centroid(ausgeodata[0])[0];
              yTarg = lineGenerator.centroid(ausgeodata[0])[1];
            } else {
              xPos = lineGenerator.centroid(ausgeodata[0])[0];
              yPos = lineGenerator.centroid(ausgeodata[0])[1];
              if(d.name == "United States") {
                xTarg = lineGenerator.centroid(d.geometry)[0] + 55;
              } else {
                xTarg = lineGenerator.centroid(d.geometry)[0];
              }
              if(d.name == "United States" || d.name == "Canada") {
                yTarg = lineGenerator.centroid(d.geometry)[1] + 55;
              } else {
                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);
              // 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);
              }
              wave();
            }
          });

        }
        
        // 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();
          d3.select("#legend").style("visibility", "visible");
          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();
          d3.select("#legend").style("visibility", "visible");
          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();
          d3.select("#legend").style("visibility", "hidden");
        });
      });
    });
    </script>
    <br/><br/>
  </body>
</html>

      

Basic Steps

There are three basic steps to follow when using a Leaflet map as a base for your D3 work, these are:

Embedding Leaflet Links in HTML Documents

Instructions in the Leaflet Quick Start Guide provide the CSS and Javascript Leaflet links. Copy and paste them inside the <head></head> tag as follow:

<meta charset="utf-8">
<title>World Map</title>

<!--Leaflet links-->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.0.3/dist/leaflet.css" integrity="sha512-07I2e+7D8p6he1SIM+1twR5TIrhUQn9+I6yjqD53JQjFiMf8EtC93ty0/5vJTZGF8aAocvHYNEDJajGdNx1IsQ==" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.0.3/dist/leaflet.js" integrity="sha512-A7vV8IFfih/D732iSSKi20u/ooOfj/AGehOKq0f4vLT1Zr2Y+RX7C+w8A1gaSasGtRUZpF/NZgzSAu4/Gc41Lg==" crossorigin=""></script>

<!-- D3 link -->
<script src="https://d3js.org/d3.v4.min.js"></script>
      

Now, we have access to use Leaflet methods in our HTML document.

Initiate a Leaflet Map

Create a Leaflet map using L.map and setView method:
L.map("idName").setView([longitudeOfCentre, latitudeOfCentre], zoomLevel);
The idName is the id of the <div></div> tag that you want to add the Leaflet map to, the longitudeOfCentre, latitudeOfCentre and zoomLevel can be obtained from mapbox website. You need to sign up as we recommended in Tutorial 3. Sign in and click Get started > Explore Mapbox > Home > Create a style > Basic > Create. A map will show, adjust it using the mouse until you reach the suitable level of details and position, then, copy the Longitude, Latitude and Zoom values from the window on the top right corner and paste them in your code to replace the longitudeOfCentre, latitudeOfCentre, zoomLevel. The line above creates an empty Leaflet map. We can add a tile layer using the syntax:
L.tileLayer("tileLink", {maxZoom: value, minZoom: value}).addTo(variableNameOfLeafletMap);
The tileLink value can also be obtained from mapbox website. Sign in and click Get started > Explore Mapbox > Styles > Select one Mapbox style (e.g. Mapbox Light) > Click Leaflet option in the Develop with this style window and copy the Leaflet URL and paste it in your code to replace the tileLink. Then, input a minimum and a maximum value for the limits of the map zoom as suitable. Accordingly, add the following code (on the first line after <script> tag):

</div>
<script>

// Leaflet map, SVG, and groups
var map = L.map("map").setView([35.232, 0.000], 2);
L.tileLayer("https://api.mapbox.com/styles/v1/mapbox/light-v9/tiles/256/{z}/{x}/{y}?access_token=pk.eyJ1IjoiZGhleWFhIiwiYSI6ImNqMjhhZm5wNDAwanQyd21tdWt6anphazYifQ.V7-GpY20RXuwLvMUnZQw5Q", {maxZoom: 18, minZoom: 2}).addTo(map);

// Read both data files
d3.json("world.geojson", function(error, geodata) {
      

Adding SVG Container to the Leaflet Map

Now that we created the Leaflet map, we can add an svg container as a layer using Leaflet method L.svg() and add this layer to be part of the Leaflet map using addTo() method. We will also append groups to the svg container to append svg elements to them later as we typically do in D3 graphs:

GpY20RXuwLvMUnZQw5Q", {maxZoom: 18, minZoom: 2}).addTo(map);

// Create SVG container and groups
var svgLayer = L.svg().addTo(map);
var svg = d3.select("#map").select("svg");
var highlights = svg.append("g").attr("id", "highlights")
var lines = svg.append("g").attr("id", "lines");
var circles = svg.append("g").attr("id", "circles");

// Read both data files
d3.json("world.geojson", function(error, geodata) {
      

Transferring Geographical inputs to Positions on Leaflet Map

In the previous tutorial, we talked about using d3 geoPath() and projection() methods to provide a description to the svg path element and transfer coordinates of the earth spherical surface into locations on a plane map. Now, we will employ similar concept to transfer the coordinates to locations on a Leaflet plane map using the same methods, but with extra functions. Paste the following code in your document and let's explain it from the bottom up:

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

// Path generator with projection to Leaflet map
function projectPoint(x, y) {
  var point = map.latLngToLayerPoint(new L.latLng(y, x));
  this.stream.point(point.x, point.y);
}
var transform = d3.geoTransform({point: projectPoint});
var lineGenerator = d3.geoPath().projection(transform);

// Read both data files
d3.json("world.geojson", function(error, geodata) {
      

lineGenerator variable returns a function to be used to describe points to a path as we learned in the previous tutorial.
Inside the projection() method, we passed a function called transfer, which is defined in the line above. It uses d3.geoTransform method, which takes an object as an attribute. This object has a property called point and its value projectPoint, which is a function that modifies the point. This function is defined in the lines on top.
The projectPoint function took two attributes x and y positions of points. It creates Leaflet latLng objects (which are object that describe points using their latitude and longitude coordinates, note that we shifted the position of x and y because of the Leaflet coordinate system), then, it uses Leaflet method latLngToLayerPoint() to transfer the latitude and longitude values of objects into positions on the plane Leaflet map. d3 stream.point() method use a sequence of function calls to stream the specified points to specific projection stream. It helps to put the points together into a projection on the leaflet map. We will use the linGenerator to transfer our geographical data into Leaflet map.

Update Locations on Leaflet Map

This is the final step in using D3 and Leaflet. The concept behind this stage is that whenever a user zoom in/zoom out the Leaflet map, the x and y locations of objects in relation to the map will differ from their locations in the start before the zoom action. Therefore, whenever the zoom happens, we need to updates the locations of our elements according to the new locations on the Leaflet map. To do this, we write code that listens to the events of resetting and zooming the map, and when they happen, we execute a function that update the locations of all our svg elements. This is why we removed the code lines responsible for defining the positions of these elements before so that we can add them here. After the code that prepare data for drawing the circles data.forEach(function(d) { ... paste the following:

      // 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);
      }
      wave();
    }
  });

  map.on("viewreset", update);
  map.on("zoom", update)
  update();
  function update() {
    feature1.attr("d", function(d) {
      return lineGenerator(d.geometry);
    });
    feature2.attr("d", function(d) {
      return lineGenerator(ausgeodata[0]);
    });
    feature3.attr("x1", lineGenerator.centroid(ausgeodata[0])[0])
            .attr("y1", lineGenerator.centroid(ausgeodata[0])[1])
            .attr("x2", function(d) {
             if(d.name == "United States") {
               return lineGenerator.centroid(d.geometry)[0] + 55;
             } else {
               return lineGenerator.centroid(d.geometry)[0];
             }
            })
            .attr("y2", function(d) {
             if(d.name == "United States" || d.name == "Canada") {
               return lineGenerator.centroid(d.geometry)[1] + 55;
             } else {
               return lineGenerator.centroid(d.geometry)[1];
             }
            })
  }

}
      

The first two lines call the update function when the event of reset or zoom happen. The third line calls the update function so that the function is executed when the page loads. Inside the update()function we specified the paths and locations of our svg groups of elements feature1, feature2 and feature3 using lineGenerator function that we talked about earlier.
Notice that the code to transition circles deal with locations as well, and in order for these locations to update correctly, we will cut all the relevant code and paste it inside the update function and add one line ahead to stop the animation of the circles and remove them whenever the update function is called:

               return lineGenerator.centroid(d.geometry)[1];
             }
            })

    d3.selectAll(".circles").transition().duration(0).remove();
    // Prepares data for drawing the circles
    data.forEach(function(d) {
      var xPos;
      var yPos;
      var xTarg;
      var yTarg;
      if(type == "Import") {
        if(d.name == "United States") {
          xPos = lineGenerator.centroid(d.geometry)[0] + 55;
        } else {
          xPos = lineGenerator.centroid(d.geometry)[0];
        }
        if(d.name == "United States" || d.name == "Canada") {
          yPos = lineGenerator.centroid(d.geometry)[1] + 55;
        } else {
          yPos = lineGenerator.centroid(d.geometry)[1];
        }
        xTarg = lineGenerator.centroid(ausgeodata[0])[0];
        yTarg = lineGenerator.centroid(ausgeodata[0])[1];
      } else {
        xPos = lineGenerator.centroid(ausgeodata[0])[0];
        yPos = lineGenerator.centroid(ausgeodata[0])[1];
        if(d.name == "United States") {
          xTarg = lineGenerator.centroid(d.geometry)[0] + 55;
        } else {
          xTarg = lineGenerator.centroid(d.geometry)[0];
        }
        if(d.name == "United States" || d.name == "Canada") {
          yTarg = lineGenerator.centroid(d.geometry)[1] + 55;
        } else {
          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);
        // 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);
        }
        wave();
      }
    });

  }
}
      

In the end, we create another svg container to draw legend elements inside it. The reason we created a second svg, which we didn't link to the Leaflet map as we did with the first svg container is that we want it to be fixed and not to move with the map. We also adjusted the z-index value of the div associated to this svg to make it appears on top of the Leaflet map. It is the same reason we adjusted the z-index value for other elements in the CSS code:

        wave();
      }
    });
  }
}

var legendSvg = d3.select("#legend").append("svg")
                  .attr("width", 300)
                  .attr("height", 150);
var legendGCircles = legendSvg.append("g");
var legendGText = legendSvg.append("g");
// Draw the legend
for(var i = 0; i < 5; i++) {
  legendGCircles.append("circle")
      .attr("class", "legend")
      .attr("cx", 15)
      .attr("cy", 10 + (i * 20))
      .attr("r", 5)
      .style("fill", colours[i])
      .style("stroke", "black");
  legendGText.append("text")
      .attr("class", "legend")
      .attr("x", 25)
      .attr("y", 14 + (i * 20))
      .text(types[i])
      .style("font-size", "12px");
}

// Buttons event listening
d3.select("#imp").on("click", function() {
      

Save the file and check the result in the localhost. Click on different buttons and pan/zoom in/zoom out the map and check the effect.


This is the end of the tutorial; we discussed the basic steps to use d3 on top of Leaflet maps. The final tutorial7example.html file can be downloaded from the Blackboard under Week 12 Tutorial Files



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