D3: Maps
The first step in using D3 for drawing maps is shape data. In particular, we represent a geographic entity as a polygon. Entity, here, can mean many things:
- Country
- State
- County
So let’s consider the United States. The US Census is quite good at maintaining shape data for different geographic entities. Click here for the data.
So we have our data, and in particular, we have a .shp file. D3 cannot read this file format, rather, we need json! However, there is a way to convert shp to json:
- Install node.js (https://nodejs.org)
- In your terminal, run:
npm install -g shapefile
- Then, use the command
shp2json shp_file -o json_file
.
Now that we have json, we can parse it with D3, and use the extensive projection support provided by D3. Here is a minimal example:
d3.json('states.json')
.then(function(data) {
plot_states(data);
})
function plot_states(map_data) {
var svg0 = d3.select('#svg0');
var x_range_pad = 20, y_range_pad = 20;
var width = +svg0.attr('width'), height = +svg0.attr('height');
var path = d3.geoPath();
map_data.features = map_data.features.filter(state => (state.properties.NAME != 'Hawaii' && state.properties.NAME != 'Alaska' && state.properties.NAME != 'Puerto Rico'));
console.log(map_data);
var projection = d3.geoAlbersUsa().fitSize([width,height], map_data)
var geo_generator = d3.geoPath().projection(projection)
svg0.selectAll('path').data(map_data.features).enter().append('path')
.attr("d", d => geo_generator(d))
.attr('fill', d3.hcl(0,0,90))
.attr('stroke', d3.hcl(0,0,40))
.attr('stroke-width', '0.6')
}
Couple notes:
- The
projection
variable is a geographic projection, one of many from D3. Here I’ve used a particularly nice one (Albers projection) designed for displaying the US.projection
has a basic purpose: it takes in an array of 2 numbers, corresponding to longitude and latitude, and returns its 2D projection that we use for drawing. - The
d3.geoPath
function is akin tod3.line
ord3.shape
: it generatesd
attributes used as part of setting the geometry ofpath
elements. - Even though I specified the width and height, there is some additional space. It is easy enough to account for this via constructing scale objects, whose domain is the output of
projection
for all coordinates, and range is width and height (for the 2 separate scales).
What if we want more detailed shape files, e.g. at the level of counties? We simply access the dataset from the Census website, and plot it in the same manner.
There is one additional difficulty here, however: the data is provided as county polygons, not state polygons. So, how do we know the state that a county belongs to? This requires a bit of data wrangling. The county data indeed has state identifiers, but they are not the names of states. On the other hand, the state data has state names, as well as identifiers. Hence, we can filter out states based on name, gather their ids, then filter out counties that do not satisfy the state ids:
d3.json('states.json')
.then(function(data) {
state_data = data;
return d3.json('counties.json')
}).then(function(data) {
counties_data = data;
plot_counties();
});
function plot_counties() {
var svg1 = d3.select('#svg1');
var x_range_pad = 20, y_range_pad = 20;
var width = +svg1.attr('width'), height = +svg1.attr('height');
var path = d3.geoPath();
state_data.features = state_data.features.filter(state => (state.properties.NAME != 'Hawaii' && state.properties.NAME != 'Alaska' && state.properties.NAME != 'Puerto Rico'));
valid_state_fps = state_data.features.map(state => state.properties.STATEFP);
counties_data.features = counties_data.features.filter(county => valid_state_fps.some(state_fp => county.properties.STATEFP==state_fp));
var projection = d3.geoAlbersUsa().fitSize([width,height], counties_data)
var geo_generator = d3.geoPath().projection(projection)
svg1.selectAll('path').data(counties_data.features).enter().append('path')
.attr("d", d => geo_generator(d))
.attr('fill', d3.hcl(0,0,90))
.attr('stroke', d3.hcl(0,0,40))
.attr('stroke-width', '0.6')
}
An important aspect about D3’s projection function is that it is not just limited to shape files. If we have other geographic data - check-in locations, trajectories of people jogging, riding their bike, etc.. - then we can use the same projection function to plot this data on the map. Here is a minimal example:
d3.json('states.json')
.then(function(data) {
plot_states_with_trajectories(data);
})
function plot_states_with_trajectories(map_data) {
var svg2 = d3.select('#svg2');
var x_range_pad = 20, y_range_pad = 20;
var width = +svg2.attr('width'), height = +svg2.attr('height');
var path = d3.geoPath();
map_data.features = map_data.features.filter(state => (state.properties.NAME != 'Hawaii' && state.properties.NAME != 'Alaska' && state.properties.NAME != 'Puerto Rico'));
console.log(map_data);
var projection = d3.geoAlbersUsa().fitSize([width,height], map_data)
var geo_generator = d3.geoPath().projection(projection)
svg2.selectAll('path').data(map_data.features).enter().append('path')
.attr("d", d => geo_generator(d))
.attr('fill', d3.hcl(0,0,90))
.attr('stroke', d3.hcl(0,0,40))
.attr('stroke-width', '0.6')
var trajectory = d3.range(-90,-80,0.1).map(d => [d,36.146]);
var mapped_trajectory = trajectory.map(d => projection(d));
var trajectory_line = d3.line()
.x(d => d[0])
.y(d => d[1])
svg2.append('path').datum(mapped_trajectory)
.attr("d", d => trajectory_line(d))
.attr('fill', 'None')
.attr('stroke', d3.hcl(120,70,40))
.attr('stroke-width', '2')
}
Further, everything we have discussed regarding interactivity extends quite nicely. Each region is a polygon, and thus if we wanted to, say, mouse over/off states, we perform the appropriate selection and event handling for their path elements:
d3.json('states.json')
.then(function(data) {
plot_hovering_states(data);
})
function plot_hovering_states(map_data) {
var svg3 = d3.select('#svg3');
var x_range_pad = 20, y_range_pad = 20;
var width = +svg3.attr('width'), height = +svg3.attr('height');
var path = d3.geoPath();
map_data.features = map_data.features.filter(state => (state.properties.NAME != 'Hawaii' && state.properties.NAME != 'Alaska' && state.properties.NAME != 'Puerto Rico'));
console.log(map_data);
var projection = d3.geoAlbersUsa().fitSize([width,height], map_data)
var geo_generator = d3.geoPath().projection(projection)
svg3.selectAll('path').data(map_data.features).enter().append('path')
.attr("d", d => geo_generator(d))
.attr('fill', d3.hcl(0,0,90))
.attr('stroke', d3.hcl(0,0,40))
.attr('stroke-width', '0.6')
svg3.selectAll('path').on('mouseover', function(d) {
d3.select(this).attr('fill', d3.hcl(350, 60, 50));
});
svg3.selectAll('path').on('mouseout', function(d) {
d3.select(this).attr('fill', d3.hcl(0,0,90));
});
}