Getting Started with elasticsearch and AngularJS: Part3 - Visualization
Mar 20, 2013  (egaumer)

This is the final post of our series on Getting Started with elasticsearch and AngularJS. This is where things get more interesting, at least for me personally. We’re going to look at how to build a basic visualization using D3. I won’t provide a full tutorial on D3 but rather show some of the key concepts for integrating it with AngularJS and elasticsearch.

I’ll build upon the application from the previous post by adding an interactive (horizontal) bar chart for displaying the Tags facet. Just like with the text based facet, users will be able to click on the different bars to dynamically filter the search. The underlying idea is to provide a visual representation of the aggregate values (i.e., counts) returned by elasticsearch.

The main focal point in achieving this will be a custom angular directive. Building visualizations with D3 is a fairly deep topic so I’ve tried to provide a minimalistic example that highlights the basic concepts. If you’d like to look at some more advanced examples then check out dangle.

A Simple Hello World Directive

Before we dive into any actual D3 concepts, let’s start by creating a simple Hello World directive. Angular directives allow us to essentially create new HTML elements to drive specific behaviors in the DOM. During DOM traversal, angular encounters these directives and executes whatever behavior they define. Many of the built in directives are attributes that can be applied to standard HTML elements but angular allows us to also create new elements.

To demonstrate the basics, create a new file called directives.js and add the following code.

// directives.js
angular.module('demo.directives', [])
    .directive('bar', function() {

        return {
            restrict: 'E',
            link: function(scope, element, attrs) {
                element.text("Hello World!");
            }
        };
    });

What we’ve done is create a new module called demo.directives in which we’ll define a new directive called bar. The directive function takes a name and a function and, generally speaking, the function returns an object with several key properties. For our purposes, and most directives in general, a link function must be defined.

The link function essentially allows the directive to register listeners on the DOM element instance and copy any content from the scope into the DOM. The restrict property tells angular that this directive will be an Element as opposed to an Attribute.

In the example above, we’re simply setting the elements text property with some static text. Adding <bar /> to our index page will cause Hello World! to be displayed but first we need add the new module to our application.

angular.module('demo', [
    'demo.controllers', 
    'demo.directives',   // new module 
    'elasticjs.service'
]);

Go ahead and include directives.js, add the bar element to the page, and refresh your browser. You should see Hello World! displayed without having to execute any type of search.

<script src="js/directives.js"></script>
...
<div class="span3">
    <bar />
</div>

Now that we’ve seen the basics of creating a directive, let’s look at how we can enhance it using D3.

Updating the HTML

D3 is a JavaScript library for performing data-driven DOM manipulation. Although it can be used to generate HTML, it’s better practice to generate SVG, especially for visualizations since SVG can be scaled properly based on the viewing device.

Let’s work backward this time as I think it will provide more clarity. Add d3.min.js to the list of dependencies and modify the bar element with the following attributes.

<script src="common/lib/d3.min.js"></script>
...
<div class="span3">
    <bar bind="results.facets.tags.terms" on-click="filter" field="Tags" />
</div>

We’ve defined three new attributes that our directive will need to support. These additional attributes are outlined in the table below.

bind Tells our directive that this is the data that will drive the visualization.
on-click Tells our directive to call this function whenever a mousedown event occurs.
field Tells our directive which elasticsearch field to apply the filter to.

This little bit of HTML is all that’s necessary from a presentation standpoint. The remainder of the article will focus specifically on the code required to create the bar chart and properly bind its behavior to the <bar> element.

Setting up the Scope

The first thing we’ll do is setup a scope on the directive. You can think of this as the context in which the directive’s public facing variables will live. Setting up a proper scope will allow us to create multiple instances of the directive without clobbering each others state. This means we can create several <bar> elements, each of which operates within its own context.

Notice that the scope object contains the same three attributes defined above. Angular automatically converts CamelCase JavaScript variables to hyphenated attributes which causes onClick to become on-click when used as an attribute.

Both = and @ are scope properties used to define data binding characteristics for each variable. An easy way to think about this is that variables marked with = are reference variables where angular sets up two-way binding on the parent scope. Changes to these variables in parent scope (i.e., controller) will be reflected in the the child scope and vise versa.

On the other hand, the @ symbol instructs angular to bind the variable to the value of DOM attribute which is always a string. In a statically typed language, this might look something like bar(Object onClick, Object bind, String field) which is essentially what the directive’s signature looks like (attributes being analogous to parameters).

// directives.js
angular.module('demo.directives', [])
    .directive('bar', function() {
        return {
            restrict: 'E',
            scope: {
                onClick: '=',
                bind:    '=',
                field:   '@'
            },
            link: function(scope, element, attrs) {
                element.text("Hello World!");
            }
        };
    });

With the proper attributes defined, we can proceed with the D3 code.

The Link Function

The directive’s link function is responsible for updating the DOM, registering event listeners, and is generally where most of the directive logic goes. This function will include all the code required to draw the bar chart.

Start by defining an initial width and height which we’ll use to create the x and y scales. Scales allow us to map an input domain to an output range. We can also create and attach the root SVG node to the DOM using the element parameter provided to the link function by angular.

We set the preserveAspectRatio attribute indicating whether or not to force uniform scaling. The viewBox width uses an additional 75px to allow space for the bar labels.

link: function(scope, element, attrs) {

    var width = 300;
    var height = 250;

    var x = d3.scale.linear().range([0, width]);
    var y = d3.scale.ordinal().rangeBands([0, height], .1);

    var svg = d3.select(element[0])
        .append('svg')
            .attr('preserveAspectRatio', 'xMaxYMin meet')
            .attr('viewBox', '0 0 ' + (width + 75) + ' ' + height)
            .append('g');

The remainder of the code needs to be wrapped in a closure and passed to angular’s $watch function. This notifies angular that anytime the bind property changes (remember, this is a reference variable), we want the given function to be executed. We’re essentially watching the data and each time it changes, running some D3 code.

    scope.$watch('bind', function(data) {

        if (data) {

            // provide new input domain to the x,y scales 
            x.domain([0, d3.max(data, function(d) { return d.count; })]);
            y.domain(data.map(function(d) { return d.term; }));

D3 uses a concept called data-join which emphasizes the declarative nature of the library. Rather than telling D3 how to do something, you tell it what you want. The code section below creates a rectangular bar for every facet entry and you’ll notice there are no loop constructs. Instead, we tell D3 what a rect should look like and it handles the details of binding the data for us.

The key function being passed as the second argument to .data() requires a bit of explanation. This is the identity function that D3 uses to determine whether or not a given datum is preexisting. This can help improve performance by allowing D3 to update a selection rather than regenerating it.

I’m using a function that returns a random value so that incoming data is always considered new. This helps keep the example more comprehensible and fits the general pattern of faceting over a large number of distinct terms. For deeper insight, read Mike Bostock’s post on Object Constancy.

            // new values
            var bars = svg.selectAll('rect')
                .data(data, function(d) { return Math.random(); });

            // bind the data
            bars.enter()
                .append('rect')
                    .attr('class', 'bar rect')
                    .attr('y', function(d) { return y(d.term); })
                    .attr('height', y.rangeBand())
                    .attr('width', function(d) { return x(d.count); });

            // wire up event listeners - (registers filter callback)
            bars.on('mousedown', function(d) {
                // notifies angular that the callback was invoked 
                scope.$apply(function() {
                    (scope.onClick || angular.noop)(scope.field, d.term);
                });
            });

            // remove old values
            bars.exit().remove();

Here we’re just repeating the same D3 ceremony but this time we’re creating the labels rather than the rectangle bars.

            // new values
            var labels = svg.selectAll('text')
                .data(data, function(d) { return Math.random(); });

            // bind the data
            labels.enter()
                .append('text')
                    .attr('y', function(d) { return y(d.term) + y.rangeBand() / 2; })
                    .attr('x', function(d) { return x(d.count) + 3; })
                    .attr('dy', '.35em')
                    .attr('text-anchor', function(d) { return 'start'; })
                    .text(function(d) { return d.term + ' (' + d.count + ')'; });

            // remove old values
            labels.exit().remove();
        } 
    })
}

That’s all the D3 code we need to draw a basic horizontal bar chart but it could use some styling.

Styling with CSS

You can use CSS to style SVG elements and customize the look and feel of the chart. The CSS below sets the color of the rectangles but there are a variety of styling properties that can be applied.

.bar.rect { fill: #058dc7; stroke: none; }

Refreshing the page and executing a search should produce something similar to the image below. Clicking on one of the bars will filter the results by the corresponding tag value.

Conclusion

This is just a taste of what’s possible when you combine AngularJS, elasticsearch, and D3. Hopefully this series provides a foundation for understanding how these technologies fit together and encourage you to explore the possibilities.

The complete and final example code can be seen here.


comments powered by Disqus