Using the D3.js Visualization Library with AngularJS
2016-06-24 15:07
555 查看
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.
And then create
There are a few things to note about the boilerplate. The
commits for that author for each day.
In a larger application, it would probably be a good idea to move
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
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:
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:
We setup margins, dimensions, and colors inside of the directive's closure. We bind
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.
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
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.
Getting Started
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.jsand 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.
Getting the Data from Github
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 ofcommits 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
reformatGithubResponseinto 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
The chart we're going to make is based on thestacked 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
valand
groupedto 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.
Finishing Touches
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
datamodel on the scope, it knows to update itself. Similarly, we listen to
groupedon the scope and run the animated transition accordingly.
Conclusion
D3.js is a very powerful tool for making visualizations that plays well with AngularJS. Besides D3.js, you can integrate many other libraries by writing services and directives to handle imperatively manipulating the DOM and binding to native browser events相关文章推荐
- AngularJS基础教程之简单介绍
- AngularJS中处理多个promise的方式
- AngularJS入门(用ng-repeat指令实现循环输出
- angularJS 中$attrs方法使用指南
- AngularJS实现textarea记录只能输入规定数量的字符并显示
- 深入浅析AngularJS和DataModel
- 简述AngularJS相关的一些编程思想
- 不能不知道的10个angularjs英文学习网站
- AngularJS中的$watch(),$digest()和$apply()区分
- Angularjs中的事件广播 ―全面解析$broadcast,$emit,$on
- AngularJS的一些基本样式初窥
- AngularJS 如何在控制台进行错误调试
- 简单讲解AngularJS的Routing路由的定义与使用
- 创建你的第一个AngularJS应用的方法
- Angularjs过滤器使用详解
- 详解AngularJS中的作用域
- Angular发布1.5正式版,专注于向Angular 2的过渡
- 简介AngularJS的视图功能应用
- AngularJS语法详解
- AngularJS 2.0新特性有哪些