Making a Map in D3.js v.5

A pretty specific title, huh? The versioning is key in this map-making how-to. D3.js version 5 has gotten serious with the Promise class which resulted in some subtle syntax changes that proven big enough to cause confusion among the D3.js old dogs and the newcomers. This post guides you through creating a simple map in this specific version of the library. If you’d rather dive deeper into the art of making maps in D3 try the classic guides produced by Mike Bostock.

Our objective is to create a map centered on Spain and its neighbouring countries, and add some reasonably big cities onto it. The visualisation will be done in D3.js and the data transformation will be a joint effort between OGR2OGR and R. Prior to launching our D3 playground we need to acquire a base map and a list of locations we want to plot. Datasets obtained from external sources are rarely “plot-ready” and require to be transformed to the right format before use. I’ve collected these under the “preparation tasks” section in this guide. Once we have the data groomed and ready, and a good idea of what we want to visualise, the script is rather straightforward.

This is what we will build

Preparation Tasks

#1 Draft the visualisation on a piece of paper

It’s a non-compulsory step, however this will help you make the design decisions going forward.

#2 Download the base map for your visualization

I used a map of world administrative boundaries in 1:10M scale obtained from Eurostat. You can check whether the map matches your expectations by uploading it to MapShaper.org. Hovering over a country with the ‘i‘ sign activated will give you information associated with each shape, including the ID you can reference that country by (useful later).

Mapshaper

Selecting countries in Mapshaper

#3 Extract the countries you need

This is done so that the JavaScript code is not overwhelmed by the GeoJSON’s size. In my scenario I have selected to visualise Portugal, Spain, Andorra, France, Switzerland, Italy, Austria, Germany, Hungary, Lichtenstein, Belgium, Luxembourg, Slovenia, Croatia, Slovakia, Bosnia and Herzegovina, Czech Republic, and on the African side Algeria, Morocco, Tunisia, and Libya. I used OGR2OGR to extract these countries from the Eurostat dataset – it was a single line of code:

ogr2ogr -where "FID in ('PT','ES','AD','FR','CH','IT','AT','DE','HU','LI','BE','LU','SI','HR','SK','BA','CZ', 'DZ','MA','TN','LY')" countries.geojson CNTR_RG_10M_2016_4326.geojson

You can look up the FID value in the geojson file or by using the information view in MapShaper. For installation tips and more on OGR2OGR check out my older post Extracting countries from GeoJSON with OGR2OGR. Alternatively you can use Python, R, or even do it by hand.

#4 Pro Tip: perform any dataset transformations or corrections beforehand

It’s often easier to do these outside the D3 realm. I’d advise to use D3 strictly to do data visualisation tasks (plotting, movement, actions). If you need to reproject your data, you might find my post Changing dataset projection with OGR2OGR useful.

#5 Get your locations ready

Put the cities that you plan to plot in a csv file and make sure you have these 3 base columns: name, latitude, and longitude. If you’re planning to populate the map with more than a handful of locations, and your points are missing their geocoordinates, you can look them up using one of the geocoding tools or APIs on the web, for example Map Developers. In case you want to generate a set of locations from scratch, you can use Geonames. Geonames is a geographical database with more than 10M locations, freely distributed under a Creative Commons licence. You’d extract from it the locations that make sense for your visualisation – e.g. by keeping only the highly populated cities. For a guide on how to extract a reasonable set of locations from Geonames take a look at my introduction to Point-in-Polygon in R. I’ll use that dataset in this analysis.

Making the map

#1 Create the HTML layout

Load the D3 v5 library, point to your CSS file, scale SVG to fit your website. All the usual. You can use the following template:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My map</title>
<script type="text/javascript" src="https://d3js.org/d3.v5.min.js"></script>
<link rel="stylesheet" type="text/css" href="map.css">
<style></style>
</head>
<body>
    <div id="container" class="svg-container"></div>
    <script type="text/javascript">
    var w = 1400;
    var h = 700;
    var svg = d3.select("div#container").append("svg").attr("preserveAspectRatio", "xMinYMin meet")
    .attr("viewBox", "0 0 " + w + " " + h).style("background","#c9e8fd")
    .classed("svg-content", true);
    </script>
</body>
</html>

#2 Define your map settings

Choose your projection, specify the center, scale, and zoom. I find D3 in Depth’s Geographic Projection Explorer incredibly useful in preparing the map definition. Check out their documentation on D3’s approach to rendering geographic information, too. Once decided, add the projection to the HTML and point the path generator to it.

var projection = d3.geoMercator().translate([w/2, h/2]).scale(2200).center([0,40]);
var path = d3.geoPath().projection(projection);

#3 Point to the datasets:

Specify the base dataset in your HTML.

var worldmap = d3.json("countries.geojson");
var cities = d3.csv("cities.csv");

#4 Draw the map

I’m going to load both data sets in my promise syntax, then reference each by its index number. We’ll iterate through the data sets with the clever Promise.all method. Check out my guide to promises in D3.js v.5 if you’re unfamiliar with this new syntax.

Promise.all([worldmap, cities]).then(function(values){    
// draw map
    svg.selectAll("path")
        .data(values[0].features)
        .enter()
        .append("path")
        .attr("class","continent")
        .attr("d", path),
// draw points
    svg.selectAll("circle")
        .data(values[1])
        .enter()
        .append("circle")
        .attr("class","circles")
        .attr("cx", function(d) {return projection([d.Longitude, d.Lattitude])[0];})
        .attr("cy", function(d) {return projection([d.Longitude, d.Lattitude])[1];})
        .attr("r", "1px"),
// add labels
    svg.selectAll("text")
        .data(values[1])
        .enter()
        .append("text")
        .text(function(d) {
            return d.City;
            })
        .attr("x", function(d) {return projection([d.Longitude, d.Lattitude])[0] + 5;})
        .attr("y", function(d) {return projection([d.Longitude, d.Lattitude])[1] + 15;})
        .attr("class","labels");
});

Let’s review what’s happening in the code block by block. Promise.all allows for resolving values from multiple promises at once – in our case the countries geojson and the cities csv file. The Draw Map section plots the base map. In the data call we reference the first value returned by the promise – so the map.csv. The Draw Points section puts our cities on the map – this time by referencing the second data set. The cx and cy coordinates ask for latitude and longitude information and act on one or another (index 0 or 1). Finally, the Add Labels part prints the city names and places them so that they don’t overlap the points.

#5 Make it pretty

Adjust the CSS. This is how my map.css looks like:

.continent {
    fill: #f0e4dd;
    stroke: #e0cabc;
    stroke-width: 0.5;
}

.circles {
    fill: #3c373d;
}

.labels {
    font-family: sans-serif;
    font-size: 11px;
    fill: #3c373d;
}

So prepared, we can finally draw the map. Here’s the final visualisation:

The map in its full glory

The full code:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My map</title>
<script type="text/javascript" src="https://d3js.org/d3.v5.min.js"></script>
<link rel="stylesheet" type="text/css" href="map.css">
<style></style>
</head>
<body>
    <div id="container" class="svg-container"></div>
    <script type="text/javascript">
    var w = 1400;
    var h = 700;
    var svg = d3.select("div#container").append("svg").attr("preserveAspectRatio", "xMinYMin meet").style("background-color","#c9e8fd")
    .attr("viewBox", "0 0 " + w + " " + h)
    .classed("svg-content", true);
    var projection = d3.geoMercator().translate([w/2, h/2]).scale(2200).center([0,40]);
    var path = d3.geoPath().projection(projection);

  // load data  
var worldmap = d3.json("countries.geojson");
var cities = d3.csv("cities.csv");

Promise.all([worldmap, cities]).then(function(values){    
 // draw map
    svg.selectAll("path")
        .data(values[0].features)
        .enter()
        .append("path")
        .attr("class","continent")
        .attr("d", path),
 // draw points
    svg.selectAll("circle")
        .data(values[1])
        .enter()
        .append("circle")
        .attr("class","circles")
        .attr("cx", function(d) {return projection([d.Longitude, d.Lattitude])[0];})
        .attr("cy", function(d) {return projection([d.Longitude, d.Lattitude])[1];})
        .attr("r", "1px"),
 // add labels
    svg.selectAll("text")
        .data(values[1])
        .enter()
        .append("text")
        .text(function(d) {
                    return d.City;
               })
        .attr("x", function(d) {return projection([d.Longitude, d.Lattitude])[0] + 5;})
        .attr("y", function(d) {return projection([d.Longitude, d.Lattitude])[1] + 15;})
        .attr("class","labels");

    });

</script>
</body>
</html>

The map is also posted in my my bl.ocks.org library! Download my files (including map.html, map.css, countries.geojson, and cities.csv) if you want to try it out on your environment.

Thanks for looking! Let me know in the comments whether you’d improve something or in case you’d spotted an error. Also check out my new project for Test Data Management: RandomKey.

You can follow my adventures in the data wonderland on Twitter: