D3 on AngularJS

D3, the javascript library for manipulating document-based data is insanely expressive and helps bring data to life through HTML, SVG, and CSS. It has a large, growing community and there are tons of examples for what can be done with it.

D3 stands for Data-Driven Documents, and is described by the authors as follows:

“D3.js is a JavaScript library for manipulating documents based on data. D3 helps you bring data to life using HTML, SVG and CSS. D3’s emphasis on web standards gives you the full capabilities of modern browsers without tying yourself to a proprietary framework, combining powerful visualization components and a data-driven approach to DOM manipulation.”

Combining the power of D3 and Angular can be challenging and confusing. We constantly get asked how to integrate the two in our classes. In this post, we aim to clear the confusion and bring you the best documentation on how to integrate AngularJS and D3.

Examples of using D3 with angular

The Angular way of integrating D3 into AngularJS is by using a directive. If you’re not familiar with directives, they are Angular’s way of extending the functionality of HTML. They are, at their core functions that are executed on a DOM element that give the element functionality. All tags in AngularJS are directives. For more information, see our post about how to Build custom directives with AngularJS.

We’ve released a book called D3 on AngularJS

We’ve released a book called D3 on AngularJS. It’s available now on leanpub. Check it out!

Using d3 with dependency injection

This step is somewhat optional. If you want do not want to use dependency injection you can add d3.js to your index.html file as normal and skip this section, but we highly recommend you don’t.

By using dependecy injection, we can keep our global namespace clean and can inject our dependencies like normal.

All the work that we’ll do with our d3 library, we’ll do on a new module with the name d3.

1
2
3
4
5
6
angular.module('d3', []) .factory('d3Service', [function(){ var d3; // insert d3 code here return d3; }];

With this factory, we can add our custom code to our d3 element. With this in place, we can inject our d3 service into our code by adding it as a dependency to our app module, like normal.

1
angular.module('app', ['d3']);

We’ll inject our d3Service into our directive to use it. For instance:

1
2
3
4
5
6
7
angular.module('myApp.directives', ['d3']) .directive('barChart', ['d3Service', function(d3Service) { return { restrict: 'EA', // directive code } }]);

In order to actually use our d3 library, we’ll need to include it on the page. We can either do this by copy-and-pasting the d3 code in our factory (as it shows above), or you can inject it on the page in the factory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
angular.module('d3', []) .factory('d3Service', ['$document', '$q', '$rootScope', function($document, $q, $rootScope) { var d = $q.defer(); function onScriptLoad() { // Load client in the browser $rootScope.$apply(function() { d.resolve(window.d3); }); } // Create a script tag with d3 as the source // and call our onScriptLoad callback when it // has been loaded var scriptTag = $document[0].createElement('script'); scriptTag.type = 'text/javascript'; scriptTag.async = true; scriptTag.src = 'http://d3js.org/d3.v3.min.js'; scriptTag.onreadystatechange = function () { if (this.readyState == 'complete') onScriptLoad(); } scriptTag.onload = onScriptLoad; var s = $document[0].getElementsByTagName('body')[0]; s.appendChild(scriptTag); return { d3: function() { return d.promise; } }; }]);

If you choose to use this method, when we use the d3Service, we’ll need to wait on the resolution of the promise to return by using the .then method on the d3Service. For example:

1
2
3
4
5
6
7
8
9
angular.module('myApp.directives', ['d3']) .directive('barChart', ['d3Service', function(d3Service) { return { link: function(scope, element, attrs) { d3Service.d3().then(function(d3) { // d3 is the raw d3 object }); }} }]);

Creating our first basic d3 directive

In this section we will learn how to create a basic d3 directive that will automatically size to the width of parent element. In addition, we’ll set watchers to redraw the d3 when the parent element size changes.

This is advantageous when you want to make an your d3 responsive based on different layouts such as mobile or tablet. Additionally the d3 will re-render on a window re-size so it will never be out of scale with your design.

Note: we won’t be diving too much into details about our d3 source (there are many great tutorials on d3 available on the web), we’ll look at a few of the particulars for working with AngularJS in our code.

In order to set this up we need to divide our d3 code into two sections.

We’ll create a simple bar chart with hardcoded data that we’ll later pull out as a dynamic source.

Assuming we are injecting our d3 service, as we did above, we’ll create the skeleton of our directive:

1
2
3
4
5
angular.module('appApp.directives') .directive('d3Bars', ['d3Service', function(d3Service) { return { }; }]);

Now, it’s debatable where we want to add our d3 code, in either the link property or the compile property. If you’re not familiar with the difference, check out our in-depth directive post. For simplicity, we’ll use the link function to house our d3 code.

In this example, we’re restricting our directive to either be an element (E) or an attribute (A).

1
2
3
4
5
6
7
8
9
10
11
angular.module('appApp.directives') .directive('d3Bars', ['d3Service', function(d3Service) { return { restrict: 'EA', scope: {}, link: function(scope, element, attrs) { d3Service.d3().then(function(d3) { // our d3 code will go here }); }}; }]);

At this point, we have access to our d3 object and can start to build our svg. Appending the svg to the element where the directives is called is fairly trivial. We’ll select the raw element and append the svg element to it.

1
2
var svg = d3.select(element[0]) .append("svg");

In order to make this responsive, we’ll need to set the style of the the element to have a width of 100%. This will force the svg to be contained in the entire containing element.

1
2
3
var svg = d3.select(element[0]) .append("svg") .style('width', '100%');

Next, we’ll set up two watchers. The first watcher will check if the div has been resized using the window.onresize event. When this browser event is fired, we want to apply the change to the angular $scope. We’ll also need to check the size of the parent element to see if the d3 svg element needs to be re-rendered.

We can find the width of the parent element with a bit of DOM-dancing with the following: d3.select(ele[0]).node().offsetWidth (thanks to Adam Pearce) for pointing this out). As of now, we our directive looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
angular.module('appApp.directives') .directive('d3Bars', ['d3Service', function(d3Service) { return { restrict: 'EA', scope: {}, link: function(scope, element, attrs) { d3Service.d3().then(function(d3) { var svg = d3.select(ele[0]) .append('svg') .style('width', '100%'); // Browser onresize event window.onresize = function() { scope.$apply(); }; // hard-code data scope.data = [ {name: "Greg", score: 98}, {name: "Ari", score: 96}, {name: 'Q', score: 75}, {name: "Loser", score: 48} ]; // Watch for resize event scope.$watch(function() { return angular.element($window)[0].innerWidth; }, function() { scope.render(scope.data); }); scope.render = function(data) { // our custom d3 code } }); }}; }]);

Now we can define our d3 just as we would without angular. We can use the directive to customize the properties of our svg element. We’ll allow the user of our directive to define a margin, a bar-height, and bar-padding.

1
2
3
4
5
6
7
8
// ... link: function(scope, ele, attrs) { d3Service.d3().then(function(d3) { var margin = parseInt(attrs.margin) || 20, barHeight = parseInt(attrs.barHeight) || 20, barPadding = parseInt(attrs.barPadding) || 5; // ...

Before we render our new data, we’ll need to make sure that we remove all of our svg elements from the svg first. If we don’t do this, we’ll have remnants of previously rendered svg elements dirtying up our d3 svg.

Picking up from our directive source, we’ll modify our render function:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ... scope.render = function(data) { // remove all previous items before render svg.selectAll('*').remove(); // If we don't pass any data, return out of the element if (!data) return; // setup variables var width = d3.select(ele[0]).node().offsetWidth - margin, // calculate the height height = scope.data.length * (barHeight + barPadding), // Use the category20() scale function for multicolor support color = d3.scale.category20(), // our xScale xScale = d3.scale.linear() .domain([0, d3.max(data, function(d) { return d.score; })]) .range([0, width]); // set the height based on the calculations above svg.attr('height', height); //create the rectangles for the bar chart svg.selectAll('rect') .data(data).enter() .append('rect') .attr('height', barHeight) .attr('width', 140) .attr('x', Math.round(margin/2)) .attr('y', function(d,i) { return i * (barHeight + barPadding); }) .attr('fill', function(d) { return color(d.score); }) .transition() .duration(1000) .attr('width', function(d) { return xScale(d.score); }); // ...

Our directive is complete! Now we just need to add it to the html.

Show full source

1
<div d3-bars bar-height="20" bar-padding="5"></div>

See it

Resize the page to see the re-rendering

Data binding to the svg

Directives can be written to take advantage of Angular’s html functionality. We can pass data from the current scope through an html attribute into the d3 directive, rather than hardcoding it in the directive. This allows us to reuse the directive across multiple controllers.

In this example we will move the dummy data from the directive to the controller.

1
2
3
4
5
6
7
8
9
10
angular.module('myApp.controllers') .controller('MainCtrl', ['$scope', function($scope){ $scope.greeting = "Resize the page to see the re-rendering"; $scope.data = [ {name: "Greg", score: 98}, {name: "Ari", score: 96}, {name: 'Q', score: 75}, {name: "Loser", score: 48} ]; }]);

Now, we can then add the data attribute as the data from our controller into the html directive element:

1
2
3
<div ng-controller="MainCtrl"> <d3-bars data="d3Data"></d3-bars> </div>

Now, we’ll need to modify our directive source to support this bi-directional databinding that we get for free with AngularJS. Obviously we’ll need to remove the hardcoding of data in the directive, but we’ll also need to create our isolate scope object.

Why an isolate scope object? If we use our d3 objective in several places on the same page, every single one will be working with the same data because they’ll all be sharing the same data binding. We can get around this by creating the isolate scope in the directive.

To create this isolate scope, we’ll only need to add the scope: object property to our directive:

1
2
3
4
5
6
7
8
9
10
angular.module('appApp.directives') .directive('d3Bars', ['d3Service', function(d3Service) { return { restrict: 'EA', scope: { data: '=' // bi-directional data-binding }, link: function(scope, element, attrs) { // ...

This will allow the svg to be rendered based on data in the controller.

What if we expect this data to change? We’ll need it to also re-render it on the data being changed. This is where our second watcher comes into play.

We’ll set up a watcher to monitor the ‘bound’ data. We will need to watch the data for objectEquality instead of the default (reference) so that the d3 will re-render when a property in the d3Data object changes, so we’ll set the second parameter as true:

1
2
3
4
// watch for data changes and re-render scope.$watch('data', function(newVals, oldVals) { return scope.render(newVals); }, true);

See it

Resize the page to see the re-rendering

Try editing the values of the data attribute
Name: Score
What happens if one of the scores is an order of magnitude higher than the rest? What if one is zero? Try it

Try removing the true from the watch function above and see what happens. Why does this happen? To learn more read about the objectEquality parameter of the $watch function.

Handling click events

Now that we have our data being watched and drawn based on screen width and it’s set using scope variables, let’s add some interaction handling. Let’s say that we want to trigger an action, such as showing a detailed view of the data in a separate panel when an item is clicked on. We can handle this user action by adding an on-click function to our directive.

1
2
3
4
5
6
7
8
9
10
angular.module('appApp.directives') .directive('d3Bars', ['d3Service', function(d3Service) { return { restrict: 'EA', scope: { data: '=', onClick: '&' // parent execution binding }, // ...

With this binding in place, we can call up to our parent and execute a function in that context using the method: scope.onClick() in an onClick handler. The only tricky part of this set up is passing the local variables up to the parent scope.

In order to pass data through to the scope function an object with a matching parameter key must be used.

For instance, when we set an onClick handler when we call the directive:

1
<div d3-bars on-click="showDetailPanel(item)" data="data"></div>

We must call the onClick handler with an object that has the key of item in our directive:

1
2
3
4
5
scope.onClick({item: [stuff to pass here]}) // notice item // matches the call in // the on-click handler // above

Now, let’s create the function on the controller scope that we’ll call into.

1
2
3
4
5
6
7
8
9
10
11
.controller('MainCtrl4', ['$scope', function($scope) { $scope.onClick = function(item) { $scope.$apply(function() { if (!$scope.showDetailPanel) $scope.showDetailPanel = true; $scope.detailItem = item; }); }; $scope.data = [ // ...

Notice that we need to use $scope.$apply() here. This is because the onClick event happens outside the current angular context.

Then, we’ll need to set the on-click attribute in the HTML where we call the directive:

1
<d3-bars data="d3Data" label="d3Label" on-click="d3OnClick(item)"></d3-bars>

Now, all we need to do is create the onClick handler in our d3 code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
angular.module('appApp.directives') .directive('d3Bars', ['d3Service', function(d3Service) { return { restrict: 'EA', scope: { data: '=', onClick: '&' // parent execution binding }, link: function(scope, element, attrs) { // ... .attr('fill', function(d) { return color(d.score); }) .on('click', function(d, i) { return scope.onClick({item: d}); }) // ...

See it

Details

Item: {{ detailItem.name }}

Hide

Dynamic data over XHR

How about fetching data over XHR? Both AngularJS and D3 can support fetching data across the wire. By using the AngularJS method of fetching data, we get the power of the auto-resolving promises. That is to say, we don’t need to modify our workflow at all, other than setting our data in our controller to be fetched over XHR.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
.controller('MainCtrl5', ['$scope', '$http', function($scope, $http) { $http({ method: 'JSONP', url: 'http://ajax.googleapis.com/ajax/services/feed/load?v=1.0&callback=JSON_CALLBACK&num=10&q=' + encodeURIComponent('http://sports.espn.go.com/espn/rss/espnu/news') }).then(function(data, status) { var entries = data.data.responseData.feed.entries, wordFreq = {}, data = []; angular.forEach(entries, function(article) { angular.forEach(article.content.split(' '), function(word) { if (word.length > 3) { if (!wordFreq[word]) { wordFreq[word] = {score: 0, link: article.link}; } wordFreq[word].score += 1; } }); }); for (key in wordFreq) { data.push({ name: key, score: wordFreq[key].score, link: wordFreq[key].link }); } data.sort(function(a,b) { return b.score - a.score; }) $scope.data = data.slice(0, 5); }); }])

See it

Word frequency counts in the latest ESPN headlines

Details

Item: {{ detailItem.name }} appeared {{ detailItem.score}} times in the latest ESPN articles

Hide

Extending your own d3 directives

Lastly, if we’re going to use d3 for any longer period of time, it’s inevitable that we’ll want to provide easier methods of creating extensions on our d3 object.

We can extend our own d3 service by applying commonly used functions on to it using the decorator pattern. AngularJS makes this easy to do using the $provide service.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
angular.module('d3') .config(['$provide', function($provide) { var customDecorator = function($delegate) { var d3Service = $delegate; d3Service.d3().then(function(d3) { // build our custom functions on the d3 // object here }); return d3Service; // important to return the service }; $provide.decorator('d3Service', customDecorator); }])

Moar!

There are way more interesting ways to integrate d3 and Angular together. We have created a repo that follows this along with this tutorial. The repo is available at: github.com/EpiphanyMachine/d3AngularIntegration

About this post

This is a guest post authored by the brilliant Gregory Hilkert and edited by the team at ng-newsletter.com.

Gregory Hilkert is a fullstack software engineer with experience in JavaScript and CoffeeScript whose most recent projects have focused specifically on Angular among other technologies.

His github profile is github.com/EpiphanyMachine and his writing can be found at blog.ideahaven.co.

Get the weekly email all focused on AngularJS. Sign up below to receive the weekly email and exclusive content.
We will never send you spam and it's a cinch to unsubscribe.

Download a free sample of the ng-book: The Complete Book on AngularJS

ng-book: The Complete Book on AngularJS is the canonical AngularJS book available today.

It's free, so just enter your email address and the PDF will be sent directly to your inbox. Mailchimp can take up to an hour to deliver the free sample chapter, but if you don't receive it within the hour, send us an email and we'll manually send them to you!

We'll send you updates about the book, when it updates and other free content.

We will never send you spam and it's a cinch to unsubscribe.

Comments

comments powered by Disqus