Building a lightweight, flexible D3.js dashboard (Part 3 of 3)

Part 1
Part 2

(The full sourcecode for the below example can be found here, and an interactive example can be found here. Click Open in a new Window to see all graphs in their pre-determined sizes)

The riveting conclusion! The last two posts in this series introduced D3.js and established the structure of a D3.js dashboard. This post will extend that foundation by creating a multi-dimensional (ie more than one country) dataset that can be browsed through a console.

The first thing we must do is add more data to the dataset; for this example, we'll add data for two additional countries: Finland and Estonia. The getData function adds another dimension to the data array when looping through the JSON object to accommodate multiple countries.

function getData() {
	var data = [];
	var metrics =
	{
	    "countries":
	    [
	        {
	            "country": "USA",
	            "metrics":
	            [
	                {
	                    "date" : "2012-08-19",
	                    "DAU" : 500,
	                    "DNU" : 200,
	                    "sessions" : 100,
	                    "sessions_length" : 2000,
	                    "d1_retention" : 102,
	                    "d7_retention" : 48,
	                    "d30_retention" : 16
	                },
	                {
	                    "date" : "2012-08-20",
	                    "DAU" : 800,
	                    "DNU" : 300,
	                    "sessions" : 120,
	                    "sessions_length" : 4000,
	                    "d1_retention" : 82,
	                    "d7_retention" : 58,
	                    "d30_retention" : 19
	                },
	                {
	                    "date" : "2012-08-21",
	                    "DAU" : 1000,
	                    "DNU" : 700,
	                    "sessions" : 200,
	                    "sessions_length" : 5000,
	                    "d1_retention" : 285,
	                    "d7_retention" : 126,
	                    "d30_retention" : 9
	                }
	            ]
	        },
	        {
				"country": "Estonia",
				"metrics":
				[
					{
						"date" : "2012-08-19",
						"DAU" : 1500,
						"DNU" : 1000,
						"sessions" : 430,
						"sessions_length" : 5100,
						"d1_retention" : 948,
						"d7_retention" : 698,
						"d30_retention" : 294
					},
					{
						"date" : "2012-08-20",
						"DAU" : 2094,
						"DNU" : 1294,
						"sessions" : 491,
						"sessions_length" : 6958,
						"d1_retention" : 1029,
						"d7_retention" : 918,
						"d30_retention" : 485
					},
					{
						"date" : "2012-08-21",
						"DAU" : 2594,
						"DNU" : 1592,
						"sessions" : 592,
						"sessions_length" : 8492,
						"d1_retention" : 1349,
						"d7_retention" : 1029,
						"d30_retention" : 685
					}
				]
			},
			{
				"country": "Finland",
				"metrics":
				[
					{
						"date" : "2012-08-19",
						"DAU" : 984,
						"DNU" : 596,
						"sessions" : 349,
						"sessions_length" : 49852,
						"d1_retention" : 294,
						"d7_retention" : 102,
						"d30_retention" : 55
					},
					{
						"date" : "2012-08-20",
						"DAU" : 890,
						"DNU" : 698,
						"sessions" : 589,
						"sessions_length" : 60921,
						"d1_retention" : 304,
						"d7_retention" : 198,
						"d30_retention" : 78
					},
					{
						"date" : "2012-08-21",
						"DAU" : 1201,
						"DNU" : 509,
						"sessions" : 492,
						"sessions_length" : 70982,
						"d1_retention" : 295,
						"d7_retention" : 159,
						"d30_retention" : 98
					}
				]
			}
		]
	};
	var i = 0;
	$.each(metrics.countries, function() {
		data[i] = [];
		data[i][0] = this.country;
		$.each(this.metrics, function() {
			var metric = this;
			var temp_date = new Date(this.date);
			var month = temp_date.getMonth();
			var date = temp_date.getDate();
			var year = temp_date.getFullYear();
			metric.date = month + '/' + date + '/' + year;
			data[i].push(metric);
		});
		i++;
	});
	return data;
}

As in the last post, data is an array of arrays: each element of data contains an array, which in turn contains the name of the country as the first element and objects containing a day of data each as individual elements.

We'll next have to add functionality to identify the countries in the dataset and build a selection console containing toggle-able controls for showing the data for them. We'll create two functions: buildConsole and getCountries. buildConsole will construct the menu through which countries can be added and removed from the graph objects; getCountries will identify which countries are selected and return a list of them whenever the console is changed.

function buildConsole(data) {
	var html = "";
	html += "<div id="country-selector" class="console-element">";
	for (var i = 0; i < data.length; i++) {
		html += "<div style="width: 150px; float: left;">";
		html += "<input name="countries" id="country-selector-" + data[i][0] + "" type="checkbox" ";
		if (i == 0) {
			html += "checked"; // set the first country to checked to provide some default data for the graphs
		}
		html += " value="" + data[i][0] + "" /> ";
		html += data[i][0];
		html += "</div>";
	}
	html += "</div>";
	$('#console').html(html);
}
function getCountries() {
	var elements = $("input:checkbox[name=countries]:checked");
	var countries = [];
	$.each(elements, function () {
		countries.push($(this).val());
	});
	return countries;
}

Note that buildConsole sets whichever country is first in our dataset to visible by default.

The next functionality we'll have to add is a cloning mechanism for the data array. Arrays of simple data objects can be copied; arrays containing arrays or objects are always passed by reference. Since we'll be aggregating and de-aggregating metric, we need to always pass a copy of the original data array whenever a user re-configures the console. To do this, we'll create two functions: cloneMetric and cloneData. cloneMetric creates a clone of a single metric (ie one element of the data array -- in our case, one country). cloneData iterates through the data array and clones each of its elements.

function cloneData(data) {
	var metrics = [];
	for (var i = 0; i < data.length; i++) {
		metrics.push(cloneMetric(data[i]));
	}
	return metrics;
}
function cloneMetric(metric) {
	var tmp = [];
	tmp[0] = metric[0];
	for (var k = 1; k < metric.length; k++) {
		tmp.push( jQuery.extend(true, {}, metric[k]) );
	}
	return tmp;
}

Now we'll need a function that can aggregate metrics over multiple dimensions (countries). We'll create a function called aggregateMetric, which takes the list of selected countries from the console (provided by getCountries) and a copy of the data array and combines the counts for each country selected. The function then runs the createAverages function from the last post to build aggregated metrics like the retention metrics and session length and average session count.

function aggregateMetric(metrics, countries) {
	var metric = []; //the single metric array of dates that we'll return,
	//aggregated over the selected countries
	var count = 0; // a count of countries being aggregated over
	for (var i = 0; i < metrics.length; i++) {
		if (jQuery.inArray(metrics[i][0], countries) > -1) { //this is a country we should aggregate for
			if (count == 0) {
				metric = cloneMetric(metrics[i]); // since metric is empty, set metric to the first set of metrics we find
				count++;
			} else {
				metric[0] = metric[0] + metrics[i][0]; //combine the country names for auditing
				for (var j = 1; j < metric.length; j++) {
					//iterate through metric[j] object and add metrics[i][j] values to it.
					//note: this requires that each country has the same number of days' worth of data!
					for (var key in metrics[i][j]) {
						if (key != "date") { // don't add the date
							metric[j][""+key] += metrics[i][j][""+key];
						}
					}
				}
			count++;
			}
		}
	}
	metric = createAverages(metric); //create the averaged values from the counts
	//(like retention metrics, average session length, etc.)
	return metric;
}

Note that aggregateMetrics returns a single metric, not the entire data array.

The last functionality we lack is a function to reset the visualization object each time a user changes the console. We'll create a function called resetCharts which takes a copy of the data array, builds the country list, aggregates the metrics, clears the visualization div, and then builds the graphs we want using the buildLineChart function we wrote last time.

function resetCharts(metrics) {
	var countries = getCountries(); // get checked items
	var metric = aggregateMetric(metrics, countries); // build one metric item out of the selected countries and the full dataset
	$('#metrics').html(''); // reset the metrics div html
	buildLineChart(metric, "Retention", ['d1_retention_pct', 'd7_retention_pct', 'd30_retention_pct'], 1000, 300, "Date", "Retention (percentage)");
	buildLineChart(metric, "Daily New Users", ['DNU'], 500, 300, "Date", "New Users");
	buildLineChart(metric, "Daily Active Users", ['DAU'], 500, 300, "Date", "Users");
	buildLineChart(metric, "Average Session Length", ['average_session_length'], 500, 300, "Date", "Session Length (minutes)");
	buildLineChart(metric, "Average Sessions per User", ['average_sessions_per_user'], 500, 300, "Date", "Sessions per User");
}

To tie this all together, we'll modify the document.ready function to accommodate our new functions. Upon document.ready, we'll create out dataset, build the console, reset the charts (which also builds the charts), and then establish some functionality for when the console (which is contained within the "country-selector" div) is changed.

$(document).ready(function() {
	var data = getData();
	buildConsole(data);
	resetCharts(data);
	$('#country-selector').change(function() {
		var metrics = cloneData(data);
		$('#country-selector').stop().css("background-color", "#FFFF9C").animate({ backgroundColor: "#CCFFE6"}, 1500);
		resetCharts(metrics);
	});
});

While the examples in this series of posts have been simple, I hope they have illustrated the power and flexibility of dashboards created with D3.js. Not only is D3.js as a dashboard platform infinitely customizable, it's also cheap, requiring about the same amount of time to create as a dashboard as in a visualization tool once a framework has been established. I genuinely think D3.js is the way forward for start-up analytics, especially given the ubiquity of JSON streams and the strength of enterprise Javascript expertise most start-ups possess.

© 2012-2013 Mobile Dev Memo