Home > Code, Graphics, Interactive, Tutorial > Remaking a Washington Post graph, part 2

Remaking a Washington Post graph, part 2

This is the second part of a tutorial for making a graph like this one on the Washington Post website, using RaphaëlJS.

Click here to go to the first part of the tutorial

Step 4: Determining the scales

Step 5 will be concerned with how to put the data on the screen. This step is dedicated to where to put it.

First, I’m going to make a JavaScript object to hold all of the scale information, including the “intercepts” defined in the previous step.

var scales = {
	xintercept: xintercept,
	yintercept: yintercept
}

This is a little redundant, but I like the tidiness of having everything in one place.

Step 4a: The x scale

For the x-axis, I want to fit all of the months within the inner graph width (defined in a previous step). There are numMonths = 134 months, which means there are 133 steps along the axis to get from the first month to the last (to get from the first to the second takes one step, to get from first to third takes two steps, etc). So, our scale is going to be graphInnerWidth/(numMonths - 1). (If you multiply this by the number of steps, (numMonths - 1), You get back graphInnerWidth, which is what we want to happen.)
Here, then, is the scales object.

var scales = {
	xintercept: xintercept,
	yintercept: yintercept,
	xslope: graphInnerWidth/(numMonths - 1)
}

For the y direction, there’s more than one scale since there’s more than one graph. There’s the initial graph showing all of the continents, and there’s a separate graph for each continent. All of these graphs have their own scale.

Step 4b: The y scale for the “total” graph

For the initial graph, we want a scale that makes all of the debt totals fit within the height of the graph. Thus, we need to know the largest value of total debt. We don’t have values for the total debt, so the first thing to do is compute these values. We’ll get this by adding the totals for each continent, month by month. Here’s a function for doing that.

var totalDebt = [];
var continents = ["Asia","Europe","Middle East","North America","Australia","South America","Other"];
function makeTotal() {
	var cont;
	for(var i = 0; i < numMonths; i++) {
		totalDebt[i] = 0;
	}
	for(var j = 0; j < continents.length; j++) {
		cont = debt[continents[j]];
		for(var i = 0; i < numMonths; i++) {
			totalDebt[i] += cont["total"][i];
		}
	}
}

The continents array isn’t necessary, but we’ll be using that array later anyway, so it doesn’t hurt.

Now that we have the values for total debt, we find the scale in much the same way as for xscale.

scales["total"] = -graphInnerHeight/Math.max.apply(null,totalDebt);

There’s a minus sign to account for the fact that vertical screen direction is the opposite of the graph direction (higher y values on the screen are lower, physically; higher y values on the graph are higher, physically). The Math.max.apply(null,totalDebt) finds the maximum value of the totalDebt array that we just created. Finally, we assign this value to the scales object, in an attribute called total.

Step 4c: The y scale for the other graphs

For the other scales, we just mimic the last part of the previous step. Each continent’s total is in the data already, so we can do something like this:

attrMax = Math.max.apply(null,debt[attr]["total"]);
yscale = (attr === "Australia" || attr === "Other") ? 
    (-graphInnerHeight/attrMax + scales["total"])/2 : -graphInnerHeight/attrMax;
scales[attr] = yscale;

There’s a change here in the yscale. It seems to me that using -graphInnerHeight/Math.max.apply(null,continentTotal) is too extreme when there’s just one country. It seems to stretch the shape out too far. So, when there’s just one shape involved (as with Australia or “Other”), I cut the the scale down by averaging it with the “total” scale.

To encapsulate all of this scale code, I put it into one function:

function makeScales() {
	var attrMax;
	var yscale;

	scales["total"] = -graphInnerHeight/Math.max.apply(null,totalDebt);
	
	for (attr in debt) {
		if (debt.hasOwnProperty(attr)) {
			attrMax = Math.max.apply(null,debt[attr]["total"]);
			yscale = (attr === "Australia" || attr === "Other") ? 
				(-graphInnerHeight/attrMax + scales["total"])/2 : -graphInnerHeight/attrMax;
			scales[attr] = yscale;
		}
	}
}

That takes care of all of the scales that we’ll need, so we’re done with step 4. Here’s all of the new code from this step (I added in function calls at the end):

var scales = {
	xintercept: xintercept,
	yintercept: yintercept,
	xslope: Math.round(graphInnerWidth/(numMonths - 1),3)
}
var totalDebt = [];
var continents = ["Asia","Europe","Middle East","North America","Australia","South America","Other"];
function makeTotal() {
	var cont;
	for(var i = 0; i < numMonths; i++) {
		totalDebt[i] = 0;
	}
	for(var j = 0; j < continents.length; j++) {
		cont = debt[continents[j]];
		for(var i = 0; i < numMonths; i++) {
			totalDebt[i] += cont["total"][i];
		}
	}
}
function makeScales() {
	var attrMax;
	var yscale;

	scales["total"] = -graphInnerHeight/Math.max.apply(null,totalDebt);
	
	for (attr in debt) {
		if (debt.hasOwnProperty(attr)) {
			attrMax = Math.max.apply(null,debt[attr]["total"]);
			yscale = (attr === "Australia" || attr === "Other") ? 
				(-graphInnerHeight/attrMax + scales["total"])/2 : -graphInnerHeight/attrMax;
			scales[attr] = yscale;
		}
	}
}
makeTotal();
makeScales();

and here’s how the HTML file should look now (with some re-arranging):

<html>
    <head>
        <title>Washington Post graph clone</title>
        <script src="raphael-min.js" type="text/javascript" charset="utf-8"></script>
        <script src="wapo-data.js" type="text/javascript" charset="utf-8"></script>
        <script type="text/javascript" charset="utf-8">
			window.onload = function () {
				var width = 900;
				var height = 800;
				var xintercept = 40;  // left side of graph, or, the x-position of the first month
				var yintercept = 750; // bottom of the graph, or, the y-position of zero debt
				var graphInnerHeight = 700;
				var graphInnerWidth = 800;
				var numMonths = 134;
				var scales = {
					xintercept: xintercept,
					yintercept: yintercept,
					xslope: Math.round(graphInnerWidth/(numMonths - 1),3)
				}
				var totalDebt = [];
				var continents = ["Asia","Europe","Middle East","North America","Australia","South America","Other"];

				function rescaleData() {
					for (attr in debt) {
						if (debt.hasOwnProperty(attr) 
						  && attr !== "Asia" 
						  && attr !== "Europe" 
						  && attr !== "Australia" 
						  && attr !== "Other") {
							for (subattr in debt[attr]) {
								if (debt[attr].hasOwnProperty(subattr) && subattr !== "total") {
									for(var i = 0; i < numMonths; i++) {
										debt[attr][subattr][i] = debt[attr][subattr][i]/1000;
									}
								}
							}
						}
					}
				}
				
				function makeTotal() {
					var cont;
					for(var i = 0; i < numMonths; i++) {
						totalDebt[i] = 0;
					}
					for(var j = 0; j < continents.length; j++) {
						cont = debt[continents[j]];
						for(var i = 0; i < numMonths; i++) {
							totalDebt[i] += cont["total"][i];
						}
					}
				}
				
				function makeScales() {
					var attrMax;
					var yscale;

					scales["total"] = -graphInnerHeight/Math.max.apply(null,totalDebt);
	
					for (attr in debt) {
						if (debt.hasOwnProperty(attr)) {
							attrMax = Math.max.apply(null,debt[attr]["total"]);
							yscale = (attr === "Australia" || attr === "Other") ? 
								(-graphInnerHeight/attrMax + scales["total"])/2 : -graphInnerHeight/attrMax;
							scales[attr] = yscale;
						}
					}
				}

				rescaleData(); // do the rescaling
				makeTotal();
				makeScales();
			};
        </script>
    </head>
    <body>
        <div id="graph"></div>
    </body>
</html>

Step 5: Drawing the shapes

Figuring out how to draw the shapes to the screen might seem like a daunting task. Let’s begin by noting some obvious facts. First, we have to draw the shapes one by one, and we want to draw the lowest shape first, then the next lowest, on up to the top (we can’t draw a higher one unless we know where the lower one ends).

So, let’s look at the bottom shape. Its left is at the left side of the graph, its right at the right side of the graph. Its bottom is at zero, and its top is at the data values for that continent.

Let’s look at a shape somewhere in the middle. Its left and right are at the left and right ends of the graph. Its bottom is at the total of all of the previous continents. Its top is at the current total, which is the previous total plus this continent’s values.

One thing that might not be obvious is that we want to trace out all of the points for a single shape, top and bottom, in a consistent direction, meaning either clockwise or counter-clockwise. The shape will be a series of line segments, and in order for these to be strung together correctly, we’ll need to move along the top, say from left to right, then move from the top to the bottom of the shape, then right to left along the bottom, and finally connect back up to the top.

Translating these observations into code gives us the start of a function, something like this:

function makeAreas() {
	var prevTotal;
	var zeroes = [];
	for(var i = 0; i < numMonths; i++) {
		zeroes[i] = 0;
	}
	prevTotal = zeroes;
	function makeSingleArea(data, yscale) {
		var total = [];
		var topX, topY, bottomX, bottomY;
		
		for(var i = 0; i < numMonths; i++) {
			total[i] = prevTotal[i] + data[i];

			topX = scales["xintercept"] + scales["xslope"]*i;
			topY = scales["yintercept"] + yscale*total[i];

			bottomX = scales["xintercept"] + scales["xslope"]*(numMonths - 1 - i);
			bottomY = scales["yintercept"] + yscale*prevTotal[numMonths - 1 - i];
		}
	}
}

This function starts by setting prevTotal to be all zeroes, so that the first shape’s bottom will be on the zero line. We put most of the code in its own function, makeSingleArea, to make it easier to rerun. When we pass data and a yscale to the function, it does just what we said we wanted; it converts top data values into screen values from left to right with increasing i, and simultaneously converts bottom data values into screen values from right to left.

If the form of

topX = scales["xintercept"] + scales["xslope"]*i;

looks strange to you, just think of it like the equation of a line: y = b + mx. You can think of the equation of a line as converting x into y. Here, we’re doing the same thing but converting data values into screen values.

We’re going to be specifying the shapes in terms of SVG paths. If you aren’t familiar with SVG, all you need to know for this tutorial is that a series of line segments can be specified like

“M x1 y1 L x2 y2 L x3 y3”

This creates a line segment from x1,y1 to x2,y2, then from x2,y2 to x3,y3. Think of “M” as meaning “move to”, and the “L” as meaning “line to”. You can string as many of these line segments together as you want. For instance, if you add a line segment from x3,y3 to x4,y4, the SVG path would be

“M x1 y1 L x2 y2 L x3 y3 L x4 y4”

To get a closed path, put a “Z” at the end:

“M x1 y1 L x2 y2 L x3 y3 L x4 y4 Z”

There are other, slightly different ways you could specify this path, but this is the style I’ll be using.

Here’s our makeAreas function again, a little more complete:

function makeAreas() {
	var prevTotal;
	var zeroes = [];
	for(var i = 0; i < numMonths; i++) {
		zeroes[i] = 0;
	}
	prevTotal = zeroes;
	function makeSingleArea(data, yscale) {
		var total = [];
		var top = [];
		var bottom = [];
		var topX, topY, bottomX, bottomY, letter, path, newArea;
		
		for(var i = 0; i < numMonths; i++) {
			total[i] = prevTotal[i] + data[i];
			topX = scales["xintercept"] + scales["xslope"]*i;
			topY = scales["yintercept"] + yscale*total[i];

			bottomX = scales["xintercept"] + scales["xslope"]*(numMonths - 1 - i);
			bottomY = scales["yintercept"] + yscale*prevTotal[numMonths - 1 - i];
			
			letter = (i === 0) ? "M" : "L"
			top.push(letter);
			top.push(String(topX));
			top.push(String(topY));
							
			bottom.push("L");
			bottom.push(String(bottomX));
			bottom.push(String(bottomY));
		}
		
		path = top.join(" ") + bottom.join(" ") + " Z";
		
		prevTotal = total;
		
		newArea = R.path(path);
		
		return newArea;
	}
}

This version now has arrays top and bottom that hold the SVG letter (“M” or “L”), and the string values of the points.

So, top and bottom are something like

top = [“M”,a0,b0,”L”,a1,b1,”L” …
bottom = [“L”,x133,y133,”L”,x132,y132,”L” …

where the a’s, b’s, x’s, and y’s are each a string of a number. Using the .join(" ") on them makes them look like

“M a0 b0 L a1 b1 L …
“L x133 y133 L x132 y132 L …

Adding them puts them together into one long path, and adding " Z" closes the path.

Next, we’ve added prevTotal = total; in order to update the previous total, ready for the next shape.

Now that we have our path, we can give it to RaphaëlJS to put on the screen. That’s what the newArea = R.path(path) line does.
In order for this to work, though, we need to have said that R refers to Raphaël. Add this near the top somewhere

R = Raphael("graph",width,height);

This tells Raphaël to take over the div called “graph”, and that we’ll be using the given width and height.

There’s one last thing we have to do before we can see anything. We haven’t called the makeSingleArea function yet (or even the outer
makeAreas). First, we’ll want somewhere to store references to the shapes we make. Near the top add

var areas = {
	level0: {}
};

and then we’ll add the following lines inside of makeAreas, just below makeSingleArea:

for(var j = continents.length - 1; j >= 0; j--) {
	areas["level0"][continents[j]] = makeSingleArea(debt[continents[j]]["total"], scales["total"], continents[j], continents[j], 0);
}

Now, if we add a call to the function makeAreas, the entire HTML file should look like this:

<html>
    <head>
        <title>Washington Post graph clone</title>
        <script src="raphael-min.js" type="text/javascript" charset="utf-8"></script>
        <script src="wapo-data.js" type="text/javascript" charset="utf-8"></script>
        <script type="text/javascript" charset="utf-8">
			window.onload = function () {
				var width = 900;
				var height = 800;
				var xintercept = 40;  // left side of graph, or, the x-position of the first month
				var yintercept = 750; // bottom of the graph, or, the y-position of zero debt
				var graphInnerHeight = 700;
				var graphInnerWidth = 800;
				var numMonths = 134;
				var scales = {
					xintercept: xintercept,
					yintercept: yintercept,
					xslope: graphInnerWidth/(numMonths - 1)
				}
				var totalDebt = [];
				var continents = ["Asia","Europe","Middle East","North America","Australia","South America","Other"];
				var areas = {
					level0: {}
				};
				R = Raphael("graph",width,height);

				function rescaleData() {
					for (attr in debt) {
						if (debt.hasOwnProperty(attr) 
						  && attr !== "Asia" 
						  && attr !== "Europe" 
						  && attr !== "Australia" 
						  && attr !== "Other") {
							for (subattr in debt[attr]) {
								if (debt[attr].hasOwnProperty(subattr) && subattr !== "total") {
									for(var i = 0; i < numMonths; i++) {
										debt[attr][subattr][i] = debt[attr][subattr][i]/1000;
									}
								}
							}
						}
					}
				}
				
				function makeTotal() {
					var cont;
					for(var i = 0; i < numMonths; i++) {
						totalDebt[i] = 0;
					}
					for(var j = 0; j < continents.length; j++) {
						cont = debt[continents[j]];
						for(var i = 0; i < numMonths; i++) {
							totalDebt[i] += cont["total"][i];
						}
					}
				}
				
				function makeScales() {
					var attrMax;
					var yscale;

					scales["total"] = -graphInnerHeight/Math.max.apply(null,totalDebt);
	
					for (attr in debt) {
						if (debt.hasOwnProperty(attr)) {
							attrMax = Math.max.apply(null,debt[attr]["total"]);
							yscale = (attr === "Australia" || attr === "Other") ? 
								(-graphInnerHeight/attrMax + scales["total"])/2 : -graphInnerHeight/attrMax;
							scales[attr] = yscale;
						}
					}
				}

				function makeAreas() {
					var prevTotal;
					var zeroes = [];
					for(var i = 0; i < numMonths; i++) {
						zeroes[i] = 0;
					}
					prevTotal = zeroes;
					function makeSingleArea(data, yscale) {
						var total = [];
						var top = [];
						var bottom = [];
						var topX, topY, bottomX, bottomY, letter, path, newArea;
						
						for(var i = 0; i < numMonths; i++) {
							total[i] = prevTotal[i] + data[i];
							topX = scales["xintercept"] + scales["xslope"]*i;
							topY = scales["yintercept"] + yscale*total[i];
							
							bottomX = scales["xintercept"] + scales["xslope"]*(numMonths - 1 - i);
							bottomY = scales["yintercept"] + yscale*prevTotal[numMonths - 1 - i];
							
							letter = (i === 0) ? "M" : "L"
							top.push(letter);
							top.push(String(topX));
							top.push(String(topY));
							
							bottom.push("L");
							bottom.push(String(bottomX));
							bottom.push(String(bottomY));
						}
						
						path = top.join(" ") + bottom.join(" ") + " Z";
						
						prevTotal = total;
						
						newArea = R.path(path);
						
						return newArea;
					}
					
					for(var j = continents.length - 1; j >= 0; j--) {
						areas["level0"][continents[j]] = makeSingleArea(debt[continents[j]]["total"], scales["total"]);
					}
				}
				
				rescaleData(); // do the rescaling
				makeTotal();
				makeScales();
				makeAreas();
			};
        </script>
    </head>
    <body>
        <div id="graph"></div>
    </body>
</html>

If you try this out, you should see something like the picture below. (If you don’t see this, or see nothing at all, make sure your code is exactly the same as the code here.)

Before we end this step, let’s get the colors right. Add this near the top of your JavaScript:

var colors = {
	"Asia": ["#e99d9d", "#efbaba"],
	"Europe": ["#95a0be", "#b4bcd1"],
	"Middle East": ["#ffe2a4", "#ccb483"],
	"North America": ["#b5b88a", "#cbcdad"],
	"Australia": ["#f0b780", "#f2c595"],
	"South America": ["#987bb7", "#b6a2cc"],
	"Other": ["#cccccc", "#a3a3a3"]
};

Also, change the line

function makeSingleArea(data, yscale) {

to

function makeSingleArea(data, yscale, continent) {

and change

newArea = R.path(path);

to

newArea = R.path(path).attr({stroke: colors[continent][0], fill: colors[continent][0]});

and finally change

areas["level0"][continents[j]] = makeSingleArea(debt[continents[j]]["total"], scales["total"]);

to

areas["level0"][continents[j]] = makeSingleArea(debt[continents[j]]["total"], scales["total"], continents[j]);

Now your HTML file should look something like

<html>
    <head>
        <title>Washington Post graph clone</title>
        <script src="raphael-min.js" type="text/javascript" charset="utf-8"></script>
        <script src="wapo-data.js" type="text/javascript" charset="utf-8"></script>
        <script type="text/javascript" charset="utf-8">
			window.onload = function () {
				var width = 900;
				var height = 800;
				var xintercept = 40;  // left side of graph, or, the x-position of the first month
				var yintercept = 750; // bottom of the graph, or, the y-position of zero debt
				var graphInnerHeight = 700;
				var graphInnerWidth = 800;
				var numMonths = 134;
				var scales = {
					xintercept: xintercept,
					yintercept: yintercept,
					xslope: graphInnerWidth/(numMonths - 1)
				}
				var totalDebt = [];
				var continents = ["Asia","Europe","Middle East","North America","Australia","South America","Other"];
				var areas = {
					level0: {}
				};
				var colors = {
					"Asia": ["#e99d9d", "#efbaba"],
					"Europe": ["#95a0be", "#b4bcd1"],
					"Middle East": ["#ffe2a4", "#ccb483"],
					"North America": ["#b5b88a", "#cbcdad"],
					"Australia": ["#f0b780", "#f2c595"],
					"South America": ["#987bb7", "#b6a2cc"],
					"Other": ["#cccccc", "#a3a3a3"]
				};
				R = Raphael("graph",width,height);

				function rescaleData() {
					for (attr in debt) {
						if (debt.hasOwnProperty(attr) 
						  && attr !== "Asia" 
						  && attr !== "Europe" 
						  && attr !== "Australia" 
						  && attr !== "Other") {
							for (subattr in debt[attr]) {
								if (debt[attr].hasOwnProperty(subattr) && subattr !== "total") {
									for(var i = 0; i < numMonths; i++) {
										debt[attr][subattr][i] = debt[attr][subattr][i]/1000;
									}
								}
							}
						}
					}
				}
				
				function makeTotal() {
					var cont;

					for(var i = 0; i < numMonths; i++) {
						totalDebt[i] = 0;
					}
					for(var j = 0; j < continents.length; j++) {
						cont = debt[continents[j]];
						for(var i = 0; i < numMonths; i++) {
							totalDebt[i] += cont["total"][i];
						}
					}
				}
				
				function makeScales() {
					var attrMax;
					var yscale;

					scales["total"] = -graphInnerHeight/Math.max.apply(null,totalDebt);
	
					for (attr in debt) {
						if (debt.hasOwnProperty(attr)) {
							attrMax = Math.max.apply(null,debt[attr]["total"]);
							yscale = (attr === "Australia" || attr === "Other") ? 
								(-graphInnerHeight/attrMax + scales["total"])/2 : -graphInnerHeight/attrMax;
							scales[attr] = yscale;
						}
					}
				}

				function makeAreas() {
					var prevTotal;
					var zeroes = [];
					for(var i = 0; i < numMonths; i++) {
						zeroes[i] = 0;
					}
					prevTotal = zeroes;
					function makeSingleArea(data, yscale, continent) {
						var total = [];
						var top = [];
						var bottom = [];
						var topX, topY, bottomX, bottomY, letter, path, newArea;
						
						for(var i = 0; i < numMonths; i++) {
							total[i] = prevTotal[i] + data[i];
							topX = scales["xintercept"] + scales["xslope"]*i;
							topY = scales["yintercept"] + yscale*total[i];
							
							bottomX = scales["xintercept"] + scales["xslope"]*(numMonths - 1 - i);
							bottomY = scales["yintercept"] + yscale*prevTotal[numMonths - 1 - i];
							
							letter = (i === 0) ? "M" : "L"
							top.push(letter);
							top.push(String(topX));
							top.push(String(topY));
							
							bottom.push("L");
							bottom.push(String(bottomX));
							bottom.push(String(bottomY));
						}
						
						path = top.join(" ") + bottom.join(" ") + " Z";
						
						prevTotal = total;
						
						newArea = R.path(path).attr({stroke: colors[continent][0], fill: colors[continent][0]});
						
						return newArea;
					}
					
					for(var j = continents.length - 1; j >= 0; j--) {
						areas["level0"][continents[j]] = makeSingleArea(debt[continents[j]]["total"], scales["total"], continents[j]);
					}
				}
				
				rescaleData(); // do the rescaling
				makeTotal();
				makeScales();
				makeAreas();
			};
        </script>
    </head>
    <body>
        <div id="graph"></div>
    </body>
</html>

and it should produce a picture like the one below.

Next time

In the next post we’ll put in the x-axis ticks and labels and add some interactivity. Actually, the interactivity will have to wait a couple more steps. We’ll be adding the country shapes in the next post.

Advertisements
  1. No comments yet.
  1. No trackbacks yet.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: