Posted on Oct 28, 2018 in data science d3 geo tutorial
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.
It’s a non-compulsory step, however this will help you make the design decisions going forward.
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).
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.
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.
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.
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>
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);
Specify the base dataset in your HTML.
var worldmap = d3.json("countries.geojson");
var cities = d3.csv("cities.csv");
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.
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 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:
Follow @EveTheAnalyst