Recreating a NYTimes Graphic with D3

The New York Times recently had an article about US voter turnout demographics. It featured a few graphics to illustrate the trends. I particularly liked this one:

I'm still learning D3, so I thought it'd be fun to recreate this graphic.

Get the Data

I grabbed the source data from the census.gov website. The article mentioned performing an additional tranformation on this raw data, but they paper they cited was behind a paywall. I decided to just skip this since I'm focused on recreating the grapic rather than having identical data.

The one piece of data missing was a list of states in each US Census Bureau region. I awkwardly copy-pasted the groupings from a pdf into JSON. It's helpful to use a text editor with macros!

Load/Transform the Data

Now that we have all the source data for the graphic, we need to load the data and extract the relevant values.

Looking at the original graphic, each state only shows a few pieces of data:

Let's consolidate those in one array after downloading the data.

var q = d3.queue();

q.defer(d3.csv, "turnout_data.csv")
q.defer(d3.json, "regions.json")
q.defer(d3.json, "state_abbreviations.json")

q.await(function (error, turnoutData, regions, stateAbbrevs) {
	if (error) throw error;
	
	var years = [1980, 1984, 1988, 1992, 1996, 2000, 2004, 2008, 2012];
	var stateData = turnoutData.map(function (d) {
		var minMax = d3.extent(years.map(function (y) { return parseFloat(d[y]) }))
		return {
			mostRecent: parseFloat(d["2012"]),
			minTurnout: minMax[0],
			maxTurnout: minMax[1],
			state: stateAbbrevs[d.state],
			region: regions[d.state],
		}
	})

	plotChart(stateData);
})

Set Up the Chart

var width=1000,
    height=300;

var svg = d3.select('#voter-turnout-chart')
	.attr('width', width)
	.attr('height', height);
var yDomain = [
	d3.min(stateData, function (d) { return d.minTurnout; }),
	d3.max(stateData, function (d) { return d.maxTurnout; })
];
var y = d3.scaleLinear().domain(yDomain).range([height,0]);
var x = d3.scaleLinear().domain([0, stateData.length]).range([0, width]);

Draw the States

First, we'll create a group to hold the different elements used to represent a state. This lets us move all the elements horizontally together.

We'll use the x linear scale to space each group evenly along the horizontal axis.

var stateGroups = svg.selectAll('.state')
	.data(stateData)
	.enter().append('g')
	.attr('class', 'state')
	.attr('transform', function (d, i) { return 'translate('+x(i)+',0)'; });

The circle, text, and path are all moved verically by y. For now I'll just paint everything black.

stateGroups.append('circle')
	.attr('cy', function (d) { return y(d.mostRecent); })
	.attr('r', 4)
	.style('fill', 'black');

stateGroups.append('text')
	.text(function (d) { return d.state; })
	.attr('y', function (d) { return y(d.maxTurnout); })
	.attr('dy', -10);

Even though the path only contains two points, I used D3's path builder. I generally avoid concatenating strings to form paths when there's a good alternative.

stateGroups.append('path')
	.attr('d', function (d) {
		var p = d3.path();
		p.moveTo(0, y(d.minTurnout));
		p.lineTo(0, y(d.maxTurnout));
		return p.toString();
	})
	.style('stroke-width', 1)
	.style('stroke', 'black');

Group States by Region

Let's sort out states by two criteria. First we sort by region. Within a region we sort by descending 2012 turnout value.

var regionOrder = ["Midwest", "Northeast", "West", "South"]
stateData = stateData.sort(function (a, b) {
	if (a.region !== b.region) {
		return regionOrder.indexOf(a.region) - regionOrder.indexOf(b.region);
	} else {
		return b.mostRecent - a.mostRecent;
	}
})

That looks good! Now for some color.

var color = d3.scaleOrdinal(d3.schemeCategory10);

// replace the fill attribute for the path and circle
.attr('fill', function(d) { return color(d.region) });

This color scale is built into D3, so it's very easy to use. After testing with this, I grabbed the four colors from the source of the original.

var color = d3.scaleOrdinal([
	'rgb(99, 194, 181)',
	'rgb(216, 94, 94)',
	'rgb(180, 112, 167)',
	'rgb(229, 179, 65)',
]);

Add the Axes

The original graphic uses an axis on the left for the higher values and one on the right for the lower values. The easiest way to replicate this is to create two axes and specify exactly which tick values we want on each axis.

var leftAxis = d3.axisLeft()
	.scale(y)
	.tickFormat(d3.format('.0%'))
	.tickValues([.80, .75, .70, .65, .60]);

svg.append('g')
	.attr('transform', 'translate(0,0)')
	.attr('class', 'axis')
	.call(leftAxis);

var rightAxis = d3.axisRight()
	.scale(y)
	.tickFormat(d3.format('.0%'))
	.tickValues([.55, .50, .45, .40]);

svg.append('g')
	.attr('transform', 'translate('+width+',0)')
	.attr('class', 'axis')
	.call(rightAxis);

To match the minimal style of the original, I used CSS to hide the lines.

.axis .domain, .axis .tick line {
  display: none;
}

Titles

Creating this graphic we have the luxury that the data is fixed. Now is when we really get to take adantage of it. This means we can lay out the tricky labels exactly where we want them without worrying about devising an alorithm to do it for us.


var titleOffsets = [80, 260, 460, 720]; // hardcoded x-offsets!
svg.selectAll('.title')
	.data(regionOrder)
	.enter().append('text')
	.attr('class', 'title')
	.text(function (d) { return d; })
	.attr('fill', color)
	.attr('x', function (d, i) { return titleOffsets[i]; })
	.attr('dy', -25)
	

The annotations in the original graphic certainly took this approach. If you look they are carefully nestled among the data ink. Changing the source data would require these to be laid out manually again.

Is this horribly fragile? Yes. But, until we need to flexibly accomodate changing data, let's live it up!

That's all there is to it. I hope this post was helpful to anyone learning D3.