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

Remaking a Washington Post graph, part 4

This is the fourth 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 8: Adding interactivity

Step 8a: Most of it

We’re going to add three event handlers
1. a mouseover so that when the cursor enters a shape, the color of the shape changes and a popup is created with debt information,
2. a mousemove so that whenever the cursor moves, the popup will move with it, and
3. a mouseout so that when the cursor leaves a shape, its color is changed back and the popup is removed.

Before we do any of those, though, we need to correct an oversight of mine. We need a way to refer to a place’s continent. Add this line inside of makeSingleArea

newArea.continent = continent;

For the mousemove event, we’ll need a way to get the cursor position. We’ll use this function, taken from http://www.quirksmode.org/

function getCursorPosition(e) {  // from http://www.quirksmode.org/js/events_properties.html
	var posx = 0;  
	var posy = 0;
	if (!e) var e = window.event;
	if (e.pageX || e.pageY) 	{
		posx = e.pageX;
		posy = e.pageY;
	}
	else if (e.clientX || e.clientY) 	{
		posx = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
		posy = e.clientY + document.body.scrollTop + document.documentElement.scrollTop;
	}
	return [posx,posy];
}

This function should take care of all of the different ways browsers handle cursor position.

Next, we’re going to need ways to create and destroy the popup. Now, we could create this popup using RaphaëlJS objects, but it turns out that Internet Explorer (my version, at least), doesn’t handle moving Raphaël objects very well. It tends to not completely erase the object from previous positions. So, instead, we’ll use an html div. To “create” the popup, we’ll write its contents using innerHTML and make it visible. To “erase” the popup, we’ll just make it hidden.

To establish the div, add

<div id="info"></div>

just after the graph div in the body of the html.

To style it, add

<style type="text/css" media="screen">
	#info {
		visibility: hidden;
		text-align: center;
		background: #eeeeee;
		position: absolute;
		padding-top: 5px;
		padding-bottom: 5px;
		padding-left: 10px;
		padding-right: 10px;
	}
</style>

within the head of the html. Among other things, the style attributes make the div hidden initially. We also need a way to reference this div in JavaScript, so add this line near the top of the JavaScript

var infoPopup = document.getElementById("info");

The function we’ll use to “create” the popup is this

function makePopup(place,e) {
	var dataValue, valueString, extra, placeText, holdText;
	var cursorPos = getCursorPosition(e);
	
	if (place.level === 0) {
		dataValue = debt[place.name]["total"][numMonths - 1];
		extra = "(Click for detailed view.)";
	} else {
		dataValue = (place.name === "Australia" || place.name === "Other") ? debt[place.continent]["total"][numMonths - 1] : debt[place.continent][place.name][numMonths - 1];
		extra = "(Click to go back.)";
	}
		
	if (dataValue > 1)
		valueString = "$" + String(Math.round(dataValue,1)) + " trillion";
	else
		valueString = "$" + String(Math.round(dataValue*1000)) + " billion";
	
	placeText = (place.name === "Other") ? "Other countries" : place.name;
	holdText = (place.name === "Other" || place.name === "Caribbean banks" || place.name === "Oil exporters") ? " hold " : " holds ";
	infoPopup.innerHTML = placeText + holdText + valueString + " <br>of US debt. <br><br> " + extra;
	infoPopup.style.visibility = "visible";
	infoPopup.style.left = String(cursorPos[0] + 30) + "px";
	infoPopup.style.top = String(cursorPos[1] + 30) + "px";
}

The function takes two arguments. The first, place will identify which shape is being interacted with; the second, e identifies the event object, which gets passed to getCursorPosition. Next, it assembles the text, getting the debt value from the debt object by using place.continent and/or place.name, and writes the text to the div by setting its innerHTML property. Finally, the div is made visible, and is moved to the right and below the cursor.

To hide the popup when the mouse leaves the shape, all we need is

function erasePopup() {
	infoPopup.style.visibility = "hidden";
}

We haven’t yet made any statements about when to do these things; we haven’t yet put in the event handlers. Add this line inside makeSingleArea, inside of the function makeAreas

newArea.mouseover( function(e) { areaMouseover(this,e); });

and add this function, outside of makeAreas

function areaMouseover(place,e) {
	if (place.attr("fill") !== colors[place.continent][1]) {  // for IE and Opera, to prevent re-firing this event
		place.attr({fill:colors[place.continent][1]});
		place.toFront();
		makePopup(place,e);
	}
}

Then the areaMouseover says to change the fill color (if that hasn’t been done already), bring that shape to the front, and make the popup. We could have skipped the areaMouseover function and put all of these commands inside the first line, but this way feels tidier to me.

Now for mouseout, add this line inside makeSingleArea

newArea.mouseout( function(e) { areaMouseout(this); });

and this function outside of makeAreas

function areaMouseout(place) {
	place.attr({fill:colors[place.continent][0]});
	erasePopup();
}

For mousemove add this line inside makeSingleArea

newArea.mousemove( function(e) { trackCursor(this,e); });

and this function outside of makeAreas

function trackCursor(place,e) {	
	var cursorPos = getCursorPosition(e);
	infoPopup.style.left = String(cursorPos[0] + 30) + "px";
	infoPopup.style.top = String(cursorPos[1] + 30) + "px";
}

At this point, you should have something like the page here.

Step 8b: Graph transitions

There’s one last type of interactivity to add. When we click on the initial graph, we want to transition to a graph of just those countries within the continent we clicked on. For now we’ll make the plainest possible transition; we’ll just hide the initial graph and show the other. In a later step we’ll add a more impressive transition.

Add

newArea.click( function(e) { transition(this); });

inside of makeSingleArea and then add the function transition

function transition(place) {
	if (place.node.fireEvent) place.node.fireEvent("onmouseout"); // for IE; otherwise it doesn't know it's not in the node
	if (place.level === 0) {
		for (attr in areas["level0"]) {
			if (areas["level0"].hasOwnProperty(attr)) {
				areas["level0"][attr].hide();
			}
		}
		for (subattr in areas["level1"][place.continent]) {
			if (areas["level1"][place.continent].hasOwnProperty(subattr)) {
				areas["level1"][place.continent][subattr].show();
			}
		}
	} else if (place.level === 1) {
		for (subattr in areas["level1"][place.continent]) {
			if (areas["level1"][place.continent].hasOwnProperty(subattr)) {
				areas["level1"][place.continent][subattr].hide();
			}
		}
		for (attr in areas["level0"]) {
			if (areas["level0"].hasOwnProperty(attr)) {
				areas["level0"][attr].show();
			}
		}
	}
}

The first line of the function is specifically for Internet Explorer. Up until this transition, everything should work fine in Internet Explorer. For some reason, IE can’t handle how the shapes are hidden and shown. The first line gets the function a fraction closer to working, but only a fraction (I’m working on it, though).

After that first line, the function is divided into code for when you click on the initial graph (level = 0), and a part for when you click on a continent’s graph. So, unlike the original, once you get you a single continent’s graph (level = 1), you click the graph again to go back. In the original graph, there was a button to click to go back to the original graph.

You can try this version of the graph here. If you do try it, you’ll notice that when you click on a continent, there’s no way to tell the country shapes apart until you move your mouse over one. It would help if we turn the stroke color to white within continents (but we keep the same stroke color in the intitial graph). To do this, add a variable strokeColor inside of makeSingleArea, then add the following line, right before newArea is created

strokeColor = (level === 0) ? colors[continent][0] : "#ffffff";

and finally change the line that says

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

to

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

You can try the new version here.

The html file at this point should look something like

<html>
    <head>
        <title>Washington Post graph clone</title>
		<style type="text/css" media="screen">
			#info {
				visibility: hidden;
				text-align: center;
				background: #eeeeee;
				position: absolute;
				padding-top: 5px;
				padding-bottom: 5px;
				padding-left: 10px;
				padding-right: 10px;
			}
		</style>
        <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: {},
					level1: {}
				};
				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"]
				};
				var xaxis = {ticks: [], labels: []};
				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 makeXaxis() {
					var x0 = scales.xintercept;
					var x1 = scales.xslope;
					var y0 = scales.yintercept;
					
					var path = ["M",String(x0),String(y0),"L",String(x0),String(y0 + 8)].join(" ");
					xaxis.ticks.push(R.path(path));  // first tick, March 2000
					xaxis.labels.push(R.text(x0,y0 + 25, dates[0].replace("-","\n\'")).attr({"font-size":12}));
					
					for(var i = 10; i < numMonths; i+=6) { // every January and July
						if ((i-10)%12 === 0) {              // if it's January, make a label and a longer tick
							xaxis.labels.push(R.text(x0 + x1*i, y0 + 25, dates[i].replace("-","\n\'")).attr({"font-size":12}));
							path = ["M",String(x0 + x1*i),String(y0),"L",String(x0 + x1*i),String(y0 + 8)].join(" ");
							xaxis.ticks.push(R.path(path));
						} else {
							path = ["M",String(x0 + x1*i),String(y0),"L",String(x0 + x1*i),String(y0 + 4)].join(" ");
							xaxis.ticks.push(R.path(path));
						}
					}
				}

				function makeAreas() {
					var placeData, placeScale, placeName;
					var prevTotal = [];
					var zeroes = [];
					for(var i = 0; i < numMonths; i++) {
						zeroes[i] = 0;
					}
					prevTotal[0] = zeroes;
					prevTotal[1] = zeroes;
					function makeSingleArea(data, yscale, name, continent, level) {
						var total = [];
						var top = [];
						var bottom = [];
						var topX, topY, bottomX, bottomY, letter, path, newArea;
						
						for(var i = 0; i < numMonths; i++) {
							total[i] = prevTotal[level][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[level][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[level] = total;
						
						newArea = R.path(path).attr({stroke: colors[continent][0], fill: colors[continent][0]});
						newArea.name = name;
						newArea.level = level;
						newArea.mouseover( function(e) { areaMouseover(this,e); });
						newArea.mouseout( function(e) { areaMouseout(this); });
						newArea.mousemove( function(e) { trackCursor(this,e); });
						newArea.click( function(e) { transition(this); });
						
						return newArea;
					}
					
					for(var j = continents.length - 1; j >= 0; j--) {
						placeData = debt[continents[j]]["total"];
						placeScale = scales["total"];
						placeName = continents[j];
						areas["level0"][continents[j]] = makeSingleArea(placeData, placeScale, placeName, continents[j], 0);
						
						areas["level1"][continents[j]] = {};
						prevTotal[1] = zeroes;
						for(attr in debt[continents[j]]) {
							if (debt[continents[j]].hasOwnProperty(attr) && attr !== "total") {
								placeData = debt[continents[j]][attr];
								placeScale = scales[continents[j]];
								placeName = attr;
								areas["level1"][continents[j]][attr] = makeSingleArea(placeData, placeScale, placeName, continents[j], 1);
								areas["level1"][continents[j]][attr].hide();
							} else if (attr === "total" && (continents[j] === "Australia" || continents[j] === "Other")) {
								placeData = debt[continents[j]][attr];
								placeScale = scales[continents[j]];
								placeName = continents[j];
								areas["level1"][continents[j]][attr] = makeSingleArea(placeData, placeScale, placeName, continents[j], 1);
								areas["level1"][continents[j]][attr].hide();
							}
						}
					}
				}
				
				function getCursorPosition(e) {  // from http://www.quirksmode.org/js/events_properties.html
					var posx = 0;  
					var posy = 0;
					if (!e) var e = window.event;
					if (e.pageX || e.pageY) 	{
						posx = e.pageX;
						posy = e.pageY;
					}
					else if (e.clientX || e.clientY) 	{
						posx = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
						posy = e.clientY + document.body.scrollTop + document.documentElement.scrollTop;
					}
					return [posx,posy];
				}
				
				function makePopup(place,e) {
					var dataValue, valueString, extra, placeText, holdText;
					var cursorPos = getCursorPosition(e);
					
					dataValue = debt[place.name]["total"][numMonths - 1];
					extra = "(Click for detailed view.)";
						
					if (dataValue > 1)
						valueString = "$" + String(Math.round(dataValue,1)) + " trillion";
					else
						valueString = "$" + String(Math.round(dataValue*1000)) + " billion";
					
					placeText = (place.name === "Other") ? "Other countries" : place.name;
					holdText = (place.name === "Other" || place.name === "Caribbean banks" || place.name === "Oil exporters") ? " hold " : " holds ";
					infoPopup.innerHTML = placeText + holdText + valueString + " <br>of US debt. <br><br> " + extra;
					infoPopup.style.visibility = "visible";
					infoPopup.style.left = String(cursorPos[0] + 30) + "px";
					infoPopup.style.top = String(cursorPos[1] + 30) + "px";
				}
				
				function erasePopup() {
					infoPopup.style.visibility = "hidden";
				}
				
				function areaMouseover(place,e) {
					if (place.attr("fill") !== colors[place.continent][1]) {  // for IE and Opera, to prevent re-firing this event
						place.attr({fill:colors[place.continent][1]});
						place.toFront();
						makePopup(place,e);
					}
				}
				
				function areaMouseout(place) {
					place.attr({fill:colors[place.continent][0]});
					erasePopup();
				}
				
				function trackCursor(place,e) {	
					var cursorPos = getCursorPosition(e);
					infoPopup.style.left = String(cursorPos[0] + 30) + "px";
					infoPopup.style.top = String(cursorPos[1] + 30) + "px";
				}
				
				function transition(place) {
					if (place.node.fireEvent) place.node.fireEvent("onmouseout"); // for IE; otherwise it doesn't know it's not in the node
					if (place.level === 0) {
						for (attr in areas["level0"]) {
							if (areas["level0"].hasOwnProperty(attr)) {
								areas["level0"][attr].hide();
							}
						}
						for (subattr in areas["level1"][place.continent]) {
							if (areas["level1"][place.continent].hasOwnProperty(subattr)) {
								areas["level1"][place.continent][subattr].show();
							}
						}
					} else if (place.level === 1) {
						for (subattr in areas["level1"][place.continent]) {
							if (areas["level1"][place.continent].hasOwnProperty(subattr)) {
								areas["level1"][place.continent][subattr].hide();
							}
						}
						for (attr in areas["level0"]) {
							if (areas["level0"].hasOwnProperty(attr)) {
								areas["level0"][attr].show();
							}
						}
					}
				}
				
				rescaleData(); // do the rescaling
				makeTotal();
				makeScales();
				makeAreas();
				makeXaxis();
			};
        </script>
    </head>
    <body>
        <div id="graph"></div>
		<div id="info"></div>
    </body>
</html>

Next time

In the next post we’ll make a smoother transition between graphs, and finish up by adding the title and y-axis info.

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: