D3.js is very cool. It works at the jQuery level as a wrapper around DOM and SVG with an expressive, functional API. There are a ton of great examples of what can be done with D3.js. By creating AngularJS directives that wrap D3.js, you can use the power of AngularJS's data binding to create apps with beautiful visualizations.
We're going to build an app that uses the Github API to allow users to figure out which of their coworkers have been slacking off analyze commit data for a given project. I was inspired by this very slick app by David Haglund, which is also written with AngularJS.
You can see a demo of the app here, and the source for the finished app is available on Github.
This is going to be a very simple application, so we're going to write all of the JavaScript in a single file, and include that file in our HTML. We'll start off with this boilerplate HTML file:
<!DOCTYPE html>
<html ng-app="d3DemoApp">
<head>
<meta charset="utf-8">
<title>AngularJS + D3.js</title>
<script src="http://d3js.org/d3.v2.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.0.1/angular.js"></script>
<script src="app.js"></script>
</head>
<body ng-controller="AppCtrl">
<!-- Here's where our visualization will go -->
<gh-visualization val="data"></gh-visualization>
</body>
</html>
And then create app.js
and add this to the file:
// create module for custom directives
var d3DemoApp = angular.module('d3DemoApp', []);
// controller business logic
d3DemoApp.controller('AppCtrl', function AppCtrl ($scope) {
});
There are a few things to note about the boilerplate. The <gh-visualization>
tag is for a directive that we'll write later. When we register the directive, we'll use the normalized form: ghVisualization
.
We'll start by adding a controller that makes requests to the Github API. Most of the code in the controller is for reformating the response from Github as a two-dimensional array: the first dimension is the author, the second dimension is the number of commits for that author for each day.
// controller business logic
d3DemoApp.controller('AppCtrl', function AppCtrl ($scope, $http) {
// initialize the model
$scope.user = 'angular';
$scope.repo = 'angular.js';
// helper for formatting date
var humanReadableDate = function (d) {
return d.getUTCMonth()+1 + '/' + d.getUTCDate();
};
// helper for reformatting the Github API response into a form we can pass to D3
var reformatGithubResponse = function (data) {
// sort the data by author date (rather than commit date)
data.sort(function (a, b) {
if (new Date(a.commit.author.date) > new Date(b.commit.author.date)) {
return -1;
} else {
return 1;
}
});
// date objects representing the first/last commit dates
var date0 = new Date(data[data.length - 1].commit.author.date);
var dateN = new Date(data[0].commit.author.date);
// the number of days between the first and last commit
var days = Math.floor((dateN - date0) / 86400000) + 1;
// map authors and indexes
var uniqueAuthors = []; // map index -> author
var authorMap = {}; // map author -> index
data.forEach(function (datum) {
var name = datum.commit.author.name;
if (uniqueAuthors.indexOf(name) === -1) {
authorMap[name] = uniqueAuthors.length;
uniqueAuthors.push(name);
}
});
// build up the data to be passed to our d3 visualization
var formattedData = [];
formattedData.length = uniqueAuthors.length;
var i, j;
for (i = 0; i < formattedData.length; i++) {
formattedData[i] = [];
formattedData[i].length = days;
for (j = 0; j < formattedData[i].length; j++) {
formattedData[i][j] = {
x: j,
y: 0
};
}
}
data.forEach(function (datum) {
var date = new Date(datum.commit.author.date);
var curDay = Math.floor((date - date0) / 86400000);
formattedData[authorMap[datum.commit.author.name]][curDay].y += 1;
formattedData[0][curDay].date = humanReadableDate(date);
});
// add author names to data for the chart's key
for (i = 0; i < uniqueAuthors.length; i++) {
formattedData[i][0].user = uniqueAuthors[i];
}
return formattedData;
};
$scope.getCommitData = function () {
$http({
method: 'GET',
url:'https://api.github.com/repos/' +
$scope.user +
'/' +
$scope.repo +
'/commits'
}).
success(function (data) {
// attach this data to the scope
$scope.data = reformatGithubResponse(data);
// clear the error messages
$scope.error = '';
}).
error(function (data, status) {
if (status === 404) {
$scope.error = 'That repository does not exist';
} else {
$scope.error = 'Error: ' + status;
}
});
};
// get the commit data immediately
$scope.getCommitData();
});
In a larger application, it would probably be a good idea to move reformatGithubResponse
into its own service so that it can be easily reused.
Another thing to make note of is that if you want to use Github's API, you'll need to register it if you're hosting it somewhere other than localhost
.
The chart we're going to make is based on the stacked bar chart example. We want to bind some model on the scope to the data used by the chart. The chart also has the option of "stacked" and "grouped" layouts. We can also setup a binding to this option as well.
In order to write this, there are a few things to understand about directives. Here is a quick rundown of the way we're going to structure the directive:
app.directive('myDirective', function ( /* dependencies */ ) {
// define constants and helpers used for the directive
// ...
return {
restrict: 'E', // the directive can be invoked only by using <my-directive> tag in the template
scope: { // attributes bound to the scope of the directive
val: '='
},
link: function (scope, element, attrs) {
// initialization, done once per my-directive tag in template. If my-directive is within an
// ng-repeat-ed template then it will be called every time ngRepeat creates a new copy of the template.
// ...
// whenever the bound 'exp' expression changes, execute this
scope.$watch('exp', function (newVal, oldVal) {
// ...
});
}
};
});
There's a lot more to directives than what I just mentioned, so I recommend reading the documentation on the AngularJS site to learn more.
So using the example from D3, and breaking up the code from that example, we are able to write this directive:
d3DemoApp.directive('ghVisualization', function () {
// constants
var margin = 20,
width = 960,
height = 500 - .5 - margin,
color = d3.interpolateRgb("#f77", "#77f");
return {
restrict: 'E',
scope: {
val: '=',
grouped: '='
},
link: function (scope, element, attrs) {
// set up initial svg object
var vis = d3.select(element[0])
.append("svg")
.attr("width", width)
.attr("height", height + margin + 100);
scope.$watch('val', function (newVal, oldVal) {
// clear the elements inside of the directive
vis.selectAll('*').remove();
// if 'val' is undefined, exit
if (!newVal) {
return;
}
// Based on: http://mbostock.github.com/d3/ex/stack.html
var n = newVal.length, // number of layers
m = newVal[0].length, // number of samples per layer
data = d3.layout.stack()(newVal);
var mx = m,
my = d3.max(data, function(d) {
return d3.max(d, function(d) {
return d.y0 + d.y;
});
}),
mz = d3.max(data, function(d) {
return d3.max(d, function(d) {
return d.y;
});
}),
x = function(d) { return d.x * width / mx; },
y0 = function(d) { return height - d.y0 * height / my; },
y1 = function(d) { return height - (d.y + d.y0) * height / my; },
y2 = function(d) { return d.y * height / mz; }; // or `my` not rescale
// Layers for each color
// =====================
var layers = vis.selectAll("g.layer")
.data(data)
.enter().append("g")
.style("fill", function(d, i) {
return color(i / (n - 1));
})
.attr("class", "layer");
// Bars
// ====
var bars = layers.selectAll("g.bar")
.data(function(d) { return d; })
.enter().append("g")
.attr("class", "bar")
.attr("transform", function(d) {
return "translate(" + x(d) + ",0)";
});
bars.append("rect")
.attr("width", x({x: .9}))
.attr("x", 0)
.attr("y", height)
.attr("height", 0)
.transition()
.delay(function(d, i) { return i * 10; })
.attr("y", y1)
.attr("height", function(d) {
return y0(d) - y1(d);
});
// X-axis labels
// =============
var labels = vis.selectAll("text.label")
.data(data[0])
.enter().append("text")
.attr("class", "label")
.attr("x", x)
.attr("y", height + 6)
.attr("dx", x({x: .45}))
.attr("dy", ".71em")
.attr("text-anchor", "middle")
.text(function(d, i) {
return d.date;
});
// Chart Key
// =========
var keyText = vis.selectAll("text.key")
.data(data)
.enter().append("text")
.attr("class", "key")
.attr("y", function (d, i) {
return height + 42 + 30*(i%3);
})
.attr("x", function (d, i) {
return 155 * Math.floor(i/3) + 15;
})
.attr("dx", x({x: .45}))
.attr("dy", ".71em")
.attr("text-anchor", "left")
.text(function(d, i) {
return d[0].user;
});
var keySwatches = vis.selectAll("rect.swatch")
.data(data)
.enter().append("rect")
.attr("class", "swatch")
.attr("width", 20)
.attr("height", 20)
.style("fill", function(d, i) {
return color(i / (n - 1));
})
.attr("y", function (d, i) {
return height + 36 + 30*(i%3);
})
.attr("x", function (d, i) {
return 155 * Math.floor(i/3);
});
// Animate between grouped and stacked
// ===================================
function transitionGroup() {
vis.selectAll("g.layer rect")
.transition()
.duration(500)
.delay(function(d, i) { return (i % m) * 10; })
.attr("x", function(d, i) { return x({x: .9 * ~~(i / m) / n}); })
.attr("width", x({x: .9 / n}))
.each("end", transitionEnd);
function transitionEnd() {
d3.select(this)
.transition()
.duration(500)
.attr("y", function(d) { return height - y2(d); })
.attr("height", y2);
}
}
function transitionStack() {
vis.selectAll("g.layer rect")
.transition()
.duration(500)
.delay(function(d, i) { return (i % m) * 10; })
.attr("y", y1)
.attr("height", function(d) {
return y0(d) - y1(d);
})
.each("end", transitionEnd);
function transitionEnd() {
d3.select(this)
.transition()
.duration(500)
.attr("x", 0)
.attr("width", x({x: .9}));
}
}
// reset grouped state to false
scope.grouped = false;
// setup a watch on 'grouped' to switch between views
scope.$watch('grouped', function (newVal, oldVal) {
// ignore first call which happens before we even have data from the Github API
if (newVal === oldVal) {
return;
}
if (newVal) {
transitionGroup();
} else {
transitionStack();
}
});
});
}
}
});
We setup margins, dimensions, and colors inside of the directive's closure. We bind val
and grouped
to the visualization's internal scope based on the attributes on <visualization>
. Then, we append an SVG element to the base <visualization>
element within the link function. Finally, all of the code to draw and redraw the chart goes inside of the $watch
.
Let's add some styling to the app. Create a CSS file with the following contents, and add it to the head of the HTML:
body {
font: 14px Helvetica Neue;
text-rendering: optimizeLegibility;
margin-top: 1em;
overflow-y: scroll;
}
.label {
font-size: .65em;
}
body {
width: 960px;
margin: auto;
}
h1 {
font-size: 36px;
font-weight: 300;
margin-bottom: .3em;
}
.error {
color: red;
}
We still need some controls to handle choosing which repository to look at. It would also be nice to have an error message displayed when a user attempts to access a repository that doesn't exist.
<body ng-controller="AppCtrl">
<h1>Github Commit Graph</h1>
<form ng-submit="getCommitData()">
User: <input ng-model="user"></input>
Repo: <input ng-model="repo"></input>
<input type="submit" value="Get Data">
Grouped <input type="checkbox" ng-model="grouped">
</form>
<p class="error">{{error}}</p>
<gh-visualization val="data" grouped="grouped"></gh-visualization>
</body>
So whenever the user presses "enter" or submits the form, the controller makes a request for the data on the Github API. When the call to Github resolves, the model updates, and because our visualization directive is listening to the data
model on the scope, it knows to update itself. Similarly, we listen to grouped
on the scope and run the animated transition accordingly.