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

Part 1
Part 3

(You can find the complete sourcecode for the below example here and an interactive example here. Click open in new window to see everything in its pre-determined size).

The first post in this series introduced the D3.js library and walked through the steps necessary to produce a very simple line graph. This post will build upon that tutorial to deliver a framework for programmatically creating charts for the different metrics present in a JSON dataset.

The dataset in the first post was extremely simple and not representative of what one would work with in a real dashboard. So we'll add to it: we'll create an actual JSON object containing multiple metrics over multiple days, aggregated over one dimension: country (and we'll use just one country). The first thing we'll do is create a helper function called getData to populate the dataset:

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
                }
            ]
        }
    ]
};
$.each(metrics.countries, function() {
    data[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.push(metric);
    });
});
return data;
}

This function creates a JSON object called metric containing three days' worth of metrics, aggregated under the dimension country. It then iterates through that object and populates the array data with elements: the first element is the country of the metrics, and the next elements are the days worth of metrics for that country. We only have one country in this example, but in the next post, we'll explore a situation where multiple days worth of metrics are delivered for multiple countries. Data is an array containing one element, which contains an array of four elements: the country in question, and three days' worth of metrics. (Note: the retention data is knowingly unrealistic. A normal retention graph should show Day 1 retention for today at 0, Day 7 retention for 4 days ago at 0, etc.)

The next step is creating a helper function which can create averages for a specific metric. You may notice that we include retention metrics and session metrics in the JSON object; these aren't instructive as-is because they represents counts, so we should create averages based on new users (for retention) and total users and total sessions (for sessions). The helper function we'll create is called createAverages:

function createAverages(metric) {
    for (var i = 1; i < metric.length; i++) {
        metric[i].d1_retention = safeDivide(metric[i].d1_retention, metric[i].DNU) * 100;
        metric[i].d7_retention = safeDivide(metric[i].d7_retention, metric[i].DNU) * 100;
        metric[i].d30_retention = safeDivide(metric[i].d30_retention, metric[i].DNU) * 100;
        metric[i].average_session_length = safeDivide(metric[i].sessions_length, metric[i].sessions) / 60;
        metric[i].average_sessions_per_user = safeDivide(metric[i].sessions, metric[i].DAU);
    }
    return metric;
}

createAverages takes one metric (one element of the data array) and returns it with averages creates for retention (where the retention metrics are recorded relative to the new users for that day) and sessions. You may notice that createAverages calls a function called safeDivide; this is because the SVG object will render all data points as NaN if it receives a single NaN value in graph data. To avoid that, the safeDivide returns 0 when any NaN values are input. The safeDivide function is:

function safeDivide(num1, num2) {
    if (isNaN(parseFloat(num2)) || isNaN(parseFloat(num1)) || num2 == 0) {
        return 0;
    }
    return Math.round( parseFloat(num1) / parseFloat(num2) * 100 ) / 100;
}

Now we must modify two helper functions from the previous post: getMinObjectValue and getMaxObjectValue. Because we will want to plot more than one graph on each chart, we'll have to adapt these helper functions to accept arrays: the metric object is now an array of data points, and we'll also need to provide it with an array of object elements from which to take minimum and maximum values (to create the axes). We'll modify them to look like this:

function getMaxObjectValue(metric, graph_metric) {
    var values = [];
    for (var i = 0; i < metric.length; i++) {
        for (var k = 0; k < graph_metric.length; k++) {
            if (parseFloat(metric[i][""+graph_metric[k]]) < 1) {
                values.push(parseFloat(metric[i][""+graph_metric[k]]));
            } else {
                values.push(Math.ceil(parseFloat(metric[i][""+graph_metric[k]])));
            }
        }
    }
    values.sort(function(a,b){return a-b});
    return values[values.length-1];
}
function getMinObjectValue(metric, graph_metric) {
    var values = [];
    for (var i = 0; i < metric.length; i++) {
        for (var k = 0; k < graph_metric.length; k++) {
            if (parseFloat(metric[i][""+graph_metric[k]]) < 1) {
                values.push(parseFloat(metric[i][""+graph_metric[k]]));
            } else {
                values.push(Math.floor(parseFloat(metric[i][""+graph_metric[k]])));
            }
        }
    }
    values.sort(function(a,b){return a-b});
    return values[0];
}

A few notes:

  • When the value of the element is less than 1 (such as when we're dealing with a percentage, like retention), we don't want to return the floor or ceiling values of those, because they'll default to the next higher or lower integers. So we test for values less than 1 and proceed accordingly.
  • We can use bracket notation with an object element in javascript.

We'll need to create a new helper function to access the data of an object when graphing our data points, since the JSON object doesn't store dates as javascript Date objects (like in the previous post). This new helper function will be called getDate:

function getDate(d) {
    return new Date(d.date);
}

Now we'll want to include all of our graphing code in an object so that we can create graphs easily. To do this, we'll create a function called buildLineChart. This function takes a number of parameters: data (one array of data points), title (the title of the graph that we'll append to the graph object), graph_metric (an array of metrics that we'll plot on the graph), width (the width of the chart object), height (the height of the chart object), xaxislabel (the label we'll append to the x axis of the chart), and yaxislabel (the label we'll append to the y axis of the chart). This is the code for buildLineChart:

function buildLineChart(data, title, graph_metric, width, height, xaxislabel, yaxislabel) {
    var metric = data.slice(0);
    metric.splice(0,1);
    // define graph size parameters
    var margin = {top: 30, right: 20, bottom: 40, left: 60},
        width = width - margin.left - margin.right,
        height = height - margin.top - margin.bottom;
    //color scale for multiple lines
    var colorscale = d3.scale.category10();
    var minDate = getDate(metric[0]),
    maxDate = getDate(metric[metric.length-1]),
    minObjectValue = getMinObjectValue(metric, graph_metric),
    maxObjectValue = getMaxObjectValue(metric, graph_metric);
    //create the graph object
    var vis= d3.select("#metrics").append("svg")
        .data(metric)
        .attr("class", "metrics-container")
        .attr("width", width + margin.left + margin.right)
        .attr("height", height + margin.top + margin.bottom)
        .append("g")
        .attr("transform", "translate(" + margin.left + "," + margin.top + ")");
    var y = d3.scale.linear().
        domain([ minObjectValue - (.1 * minObjectValue) , maxObjectValue + (.1 * maxObjectValue) ]).range([height, 0]),
    x = d3.time.scale().domain([minDate, maxDate]).range([0, width]);
    var yAxis = d3.svg.axis()
        .scale(y)
        .orient("left")
        .ticks(5);
    var xAxis = d3.svg.axis()
        .scale(x)
        .orient("bottom")
        .ticks(metric.length-1); //set the number of ticks to the number of elements (days), minus 1
    vis.append("text")
        .attr("style", "font-family: Helvetica, Arial; font-size: 18px; font-weight: bold;")
        .attr("dx", function(d) { return 10; })
        .attr("dy", function(d) { return -10; })
        .text(''+title);
    //append the axes
    vis.append("g")
        .attr("class", "axis")
        .call(yAxis);
    vis.append("g")
        .attr("class", "axis")
        .attr("transform", "translate(0," + height + ")")
        .call(xAxis);
    //add the axes labels
    vis.append("text")
        .attr("class", "axis-label")
        .attr("text-anchor", "end")
        .attr("x", 20)
        .attr("y", height + 34)
        .text(xaxislabel);
    vis.append("text")
        .attr("class", "axis-label")
        .attr("text-anchor", "end")
        .attr("y", 6)
        .attr("dy", "-3.4em")
        .attr("transform", "rotate(-90)")
        .text(yaxislabel);
    var lines = [];
    var circles = [];
    //loop through graph objects, if it's a single graph there will only be one object
    for (var z = 0; z < graph_metric.length; z++) {
        lines[z] = d3.svg.line()
        .x(function(d) { return x(getDate(d)); })
        .y(function(d) { return (isNaN( y(d[''+graph_metric[z]])) ? 0 : y(d[''+graph_metric[z]])); })
        vis.append("svg:path")
        .attr("d", lines[z](metric))
        .style("stroke", function() {
            return colorscale(""+z);
        })
        .style("fill", "none")
        .style("stroke-width", "2.5");
        var dataCirclesGroup = vis.append('svg:g');
        var circles = dataCirclesGroup.selectAll('.data-point')
            .data(metric);
        circles
            .enter()
            .append('svg:circle')
            .attr('class', 'dot')
            .attr('fill', function() { return colorscale(""+z); })
            .attr('cx', function(d) { return x(getDate(d)); })
            .attr('cy', function(d) { return (isNaN( y(d[''+graph_metric[z]])) ? 0 : y(d[''+graph_metric[z]])); })
            .attr('r', function() { return 3; })
            .on("mouseover", function(d) {
                d3.select(this)
                .attr("r", 8)
                .attr("class", "dot-selected")
                .transition()
                .duration(750);
            })
            .on("mouseout", function(d) {
                d3.select(this)
                .attr("r", 3)
                .attr("class", "dot")
                .transition()
                .duration(750);
            })
            .append("svg:title")
            .text(function(d) {
            var return_text = "";
            if (graph_metric.length > 1) {
                return_text += graph_metric[z] + "n";
            }
            return_text += $.datepicker.formatDate('yy-mm-dd', new Date(d.date)) + ": ";
            return_text += Math.round(d[''+graph_metric[z]] * 100) / 100;
            return return_text;
        });
    }
}

You'll notice one fundamental difference between this function and the code from the first post: we loop through the graph_metric array and append data points for each set of metrics it contains. We also add something new from the last post: data labels (svg:title objects) for each data point that show natively upon mouseover. When our chart hosts multiple graphs (when graph_metric.length > 1), we include an additional line of text indicating which graph_metric that data point is a member of. We also create the colorscale array, which contains a number of color elements that we can apply to each graph in the chart (when we have more than one). This helps to differentiate between the graphs. The first two lines of code in the function clone metric so that it isn't accessed by reference.

The last step in building our dashboard is creating the document.ready jQuery function to graph everything. We'll create the data array, create the averaged elements, and then create five charts: Retention (containing graphs of day 1, day 7, and day 30 retention data points), Daily New Users, Daily Active Users, Average Session Length (averaged from the original data), and Average Sessions per User. The code for that is:

$(document).ready(function() {
    var data = getData();
    data = createAverages(data);
    buildLineChart(data, "Retention",
        ['d1_retention', 'd7_retention', 'd30_retention'],
        1000, 300, "Date", "Retention (percentage)");
    buildLineChart(data, "Daily New Users", ['DNU'], 500, 300, "Date", "New Users");
    buildLineChart(data, "Daily Active Users", ['DAU'], 500, 300, "Date", "Users");
    buildLineChart(data, "Average Session Length", ['average_session_length'], 500, 300, "Date", "Session Length (minutes)");
    buildLineChart(data, "Average Sessions per User", ['average_sessions_per_user'], 500, 300, "Date", "Sessions per User");
});

When these code fragments are combined and opened in a browser, they produce the following collection of charts (note that the Retention chart was provided with a larger width value than the rest of the charts):

The next -- and final -- post in this series will explore a multi-dimensional dataset and provide a framework for dynamically browsing single and multi-dimension graphs.

© 2012-2013 Mobile Dev Memo