Thematic maps
Introduction
There are many types of maps, such as topological maps, climate maps, relief maps or of course the general purpose maps, such as atlas maps, wall maps and road maps, which typically show roads, railways, parks, towns, cities, rivers, lakes, seas, political boundaries, etc.
D3 is particularly suitable for the creation of thematic maps. Thematic maps illustrate a specific theme, such as the spread of a virus, unemployment rates or soil types, in relation to specific geographical areas or locations. The visualization typically involves the use of symbols and/or colors. The depiction of physical features such as rivers, roads and political boundaries is kept to a minimum and serves only as a reference in the visualization of the theme. The used data corresponds to the theme. Data associated with a geographical location is called geospatial data.
Mapping geographic coordinates
If the data contains the latitude and longitude coordinates (or even the polygons) of the features you want to visualize, it is just a matter of "plotting" the features and their properties on the geographical map. The properties are typically visualized by color or size of the symbols placed on the associated locations. This way we can create thematic maps such as proportional symbol maps or dot distribution map.
Next example adds the locations and names of all the province capitals to a map with all provinces of the Netherlands. The province capitals data are provided by a comma-separated values (CSV) text file. The data consists of the province names, capital names and their longitude and latitude coordinates. The coordinates are derived from the Wikipedia pages of the individual cities (or use GeoHack).
const width = 500;
const height = width;
const colorScale = d3.scaleOrdinal(d3.schemeCategory10);
const svg = d3.select('#SVGprovinces')
.attr("viewBox", `0 0 ${width} ${height}`);
const areasContainer = svg
.append("g")
.attr("stroke", "white")
.attr("stroke-width", "0.5")
const citiesContainer = svg
.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", "10px")
.attr("text-anchor", "middle")
.attr("fill", "black");
// Function that draws the areas and cities:
function drawMap(geojson, data) {
// define a projection function:
const projection = d3.geoMercator()
.fitExtent([[0, 0], [width, height]], geojson);
// define an SVG path generator:
const geoGenerator = d3.geoPath()
.projection(projection);
// draw the areas:
areasContainer
.selectAll('path')
.data(geojson.features)
.join('path')
.attr('d', d => geoGenerator(d))
.attr("fill", (d, i) => colorScale(i));
// Draw the province capitals:
const city = citiesContainer.selectAll("g")
.data(data)
.join("g")
.attr("transform", (d, i) => `translate(${projection([d.longitude, d.latitude])})`);
city
.append("circle")
.attr("r", "4")
.attr("fill", "#262626");
city
.append("text")
.attr("y", "-5")
.text(d => d.capital);
// draw the capital of the Netherlands:
const locationAmsterdam = projection([4.893333, 52.373056]);
const amsterdam = citiesContainer
.append("g")
.attr("transform", `translate(${locationAmsterdam})`);
amsterdam
.append("circle")
.attr("r", "4")
.attr("fill", "maroon");
amsterdam
.append("text")
.attr("y", "10")
.text("Amsterdam");
};
// Fetch the GeoJSON file and capitals data file,
// and then call 'drawMap' to draw the map:
(async function fetchResources() {
try {
const responses = await Promise.all([
d3.json('nl_provinces.json'),
d3.csv('nl_provinces_capitals.csv', d => { // *) see further below
d.longitude = +d.longitude;
d.latitude = +d.latitude;
return d;
}),
]);
drawMap(responses[0],responses[1]);
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
Result:
*) d3.csv
interprets numbers in the CSV file as strings.
You can pass an "accessor" function as the second argument that is called on each row of data to convert strings to numbers.
Download GeoJSON file.
Download CSV data file.
Source: The GeoJSON file of the provinces of the Netherlands is derived form a GitHub repository,
that provides GeoJSON and TopoJSON files of the Netherlands, that are simplified versions of the ones provided by
the Dutch autonomous administrative authority CBS/PDOK.
The GeoJSON file uses WGS84 coordinates,
or more specifically, WGS84-EPSG:4326 coordinates, i.e., geographic coordinates ([longitude, latitude]
) in degrees.
WGS84 is the common standard geodetic system.
Proportional symbol maps
Next example uses the same GeoJSON file and same cities data file and adds a JSON file with
induced (non-tectonic) earthquakes in the Netherlands between 2018 and 2023, and places them on the map, based on their [longitude, latitude]
positions.
We can see that the earthquakes are concentrated in the province of Groningen, due to the extraction of natural gas there.
const width = 500;
const height = width;
const svg = d3.select('#svg')
.attr("viewBox", `0 0 ${width} ${height}`);
const areasContainer = svg
.append("g")
.attr("stroke", "white")
.attr("stroke-width", "0.5");
const citiesContainer = svg
.append("g")
.attr("font-family", "sans-serif")
.attr("font-size", "8px")
.attr("text-anchor", "middle")
.attr("fill", "#4d4d4d");
const earthquakesContainer = svg
.append("g")
.attr("stroke", "red")
.attr("fill", "none");
// convert Richter scale magnitude to amplitude:
function MagToAmp(mag) {
return Math.pow(10, mag);
};
function drawMap(geojson, dataEarthquakes, dataCities) {
const projection = d3.geoMercator()
.fitExtent([[0, 0], [width, height]], geojson);
const geoGenerator = d3.geoPath()
.projection(projection);
const circleStrokeWidthScale = d3.scaleThreshold()
.domain([1.9,2.9,3.9,4.9,5.9])
.range([0.4, 0.6, 0.8, 1.0, 1.2, 1.4 ]);
const circleAreaScale = d3.scaleSqrt()
.domain(d3.extent(dataEarthquakes.events, d => MagToAmp(d.mag)))
.range([1, 6]);
areasContainer
.selectAll('path')
.data(geojson.features)
.join('path')
.attr('d', d => geoGenerator(d))
.attr("fill", "#b3b3b3");
earthquakesContainer
.selectAll('circle')
.data(dataEarthquakes.events)
.join('circle')
.attr("transform", (d, i) => `translate(${projection([d.lon, d.lat])})`)
.attr("stroke-width", d => circleStrokeWidthScale(d.mag))
.attr('r', d => circleAreaScale(MagToAmp(d.mag)));
const city = citiesContainer.selectAll("g")
.data(dataCities)
.join("g")
.attr("transform", (d, i) => `translate(${projection([d.longitude, d.latitude])})`);
city
.append("circle")
.attr("r", "2")
.attr("fill", "#999999");
city
.append("text")
.attr("y", "-5.5")
.text(d => d.capital);
};
(async function fetchResources() {
try {
const responses = await Promise.all([
d3.json('nl_provinces.json'),
d3.json('earthquakes_induced_nl_2018-2022.json'),
d3.csv('nl_provinces_capitals.csv', d => {
d.longitude = +d.longitude;
d.latitude = +d.latitude;
return d;
}),
]);
drawMap(responses[0],responses[1],responses[2]);
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
Result:
Induced earthquakes in the Netherlands between 2018 and 2023.
Download GeoJSON file.
Download CSV data file Dutch provinces.
Download JSON data file earthquakes.
PS: There is also a more extensive version of this map (in Dutch).
Source: geographical data: CBS / PDOK, data earthquakes: KNMI.
Mapping mutual identifiers
Data for visualization on a map often does not include geographic coordinates. Data are often collected by geographic region or location, such as unemployment rates per county. First, the geometry must match the data. If the data are per municipality, the geometry must consist of municipality polygons. Entities in both geometry and data must have at least one mutual, unique property with unique matching values. For instance, the counties in the GeoJSON file have unique names that match the county names in the data file. This can be used to map data to the geometry.
Choropleth maps
Choropleth maps are often used to visualize how a variable varies across a geographic area. Projections that preserve areas are recommended for choropleth maps as the projection will not distort the data. Next example creates a choropleth map showing the population density per Dutch municipality in 2014.
const width = 500;
const height = width;
const svg = d3.select('#svg')
.attr("viewBox", `0 0 ${width} ${height}`);
const areasContainer = svg
.append("g")
.attr("stroke", "#b3b3b3")
.attr("stroke-width", "0.5");
function drawMap(geojson, data) {
/*
const colorScale = d3.scaleQuantize()
.domain(d3.extent(data, d => d.popDensity))
.range(d3.schemeOranges[7]);
*/
const colorScale = d3.scaleThreshold()
.domain([500,1000,2000,3000,4000,5000])
.range(d3.schemeOranges[7]);
const valueMap = new Map(data.map(d => [d.municipality, d.popDensity]));
const projection = d3.geoMercator()
.fitExtent([[0, 0], [width, height]], geojson);
const geoGenerator = d3.geoPath()
.projection(projection);
areasContainer
.selectAll('path')
.data(geojson.features)
.join('path')
.attr('d', d => geoGenerator(d))
.attr("fill", d => colorScale(valueMap.get(d.properties.statnaam)))
.append("title")
.text(d => `${d.properties.statnaam} \n${valueMap.get(d.properties.statnaam)} inh/km²`);
// draw the legend (see remark below):
const legend = svg
.append("g")
.attr("transform", "translate(16,16)scale(0.8)")
.append(() => Legend(colorScale, {
title: "Population per km²",
width: 150,
tickFormat: ".1s",
}))
};
(async function fetchResources() {
try {
const responses = await Promise.all([
d3.json('municipalities.json'),
d3.json('population_density_NL_2014.json'),
]);
drawMap(responses[0],responses[1]);
}
catch (error) {
document.querySelector("#errorMessage").textContent = error;
}
})();
Result:
Hover over the municipalities:
Download GeoJSON file.
Download JSON data file.
PS: In the example above the function Legend()
(not displayed in the code above) is called to create a legend. This function is derived from the
Observable notebook "Color legend" by Mike Bostock.
It sets the option tickFormat: ".1s"
to format the values as one significant digit and a SI-prefix, "5k".
PS: There is also a version of this choropleth map (in Dutch) made in plain JavaScript (no D3) and with a fetched SVG map (Mercator projection) instead of a GeoJSON.