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:

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:

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:

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));
	});
}