Recording implemented ideas.

Implementing graph components with D3.js

01 Jun 2014

Declarative approach is the most common way for constructing graphs with D3.js. However, by leveraging features of the Ember.js this approach can be improved in several areas, such as testing, code structure and rendering speed.

I have been using D3.js for quite a while and always liked it. It's an awesome project, it brought to the community so many powerful and flexible tools, that you can solve wide ranges of tasks: from complex svg construction to the simple html generation. However, I always looked for a ways to structure code differently and ways to improve testability of the code. I believe, this 2 problems are quite common, complex joins can get out of hand pretty fast. Unit testing svg generation code was also a problem, mostly because declarative approach resulted in glued view and model layers.

Once I started using Ember.js more, I noticed that computed properties and observers for the complex graph can provide flexible and structured way for refreshing graphs. With a declarative approach, I had to invoke join function on updates or even redraw pieces completely. Also I was thinking that templating with Handlebars can separate view layer and potentially simplify graph construction in conjunction with computed properties. So, I decided to use core mathematical abstractions from D3.js (scales, shape generators, formatters etc.), expose them as a computed properties and use this properties as template bindings. Such approach allows me to have separate model (behavior) layer and view (presentation) layer, it could open a way for easier unit testing too.

Sample Task

Consider common task to construct svg graph that looks like this:

Graph Example Example Graph

This is quite a common graph with time domain, although there are several interesting things about it:

Result Ember.js component has to be dynamic, i.e. it has to refresh on input values change. Provided input values are:

Graph Layout

I'll start with layout, result graph looks like a complex problem, but ultimately it's just a set of smaller problems: axis, lines, areas, separator, clip zones etc. Thus, by starting with layout I will extract this smaller problems and solve them separately.

<svg>
  <g {{bind-attr transform='innerGroupTransform'}}>

    <defs>
      <clipPath id="clip-area-before">
        <rect {{bind-attr
                height='innerHeight'
                width='flagPosition'}}></rect>
      </clipPath>
      <clipPath id="clip-area-after">
        <rect {{bind-attr
                x='flagPosition'
                height='innerHeight'
                width='rightClipAreaWidth'}}></rect>
      </clipPath>
    </defs>

    {{graph-axis class="x axis"
      transform=xAxisTransform
      scale=xScale
      tickFormat=xAxisFormat
      ticks=17
      tickSize=12
      tickPadding=3}}

    {{graph-axis class="y axis"
      orient='left'
      ticks=4
      scale=yScale
      tickSize=yTickSize
      tickFormat=yAxisFormat}}

    <path {{bind-attr d='lineData'}}
      class="line before"
      clip-path="url(#clip-area-before)"></path>
    <path {{bind-attr d='lineData'}}
      class="line after"
      clip-path="url(#clip-area-after)"></path>

    <path {{bind-attr d='areaData'}}
      class="area before"
      clip-path="url(#clip-area-before)"></path>
    <path {{bind-attr d='areaData'}}
      class="area after"
      clip-path="url(#clip-area-after)"></path>

    <line class="flag-line" y2='0'
      {{bind-attr
        x2='flagPosition'
        y1='flagLineHeight'
        x1='flagPosition'}}></line>
    <line class="ending-line"
      {{bind-attr
        y1='lastPointY'
        y2='innerHeight'
        x1='lastPointX'
        x2='lastPointX'}}></line>
    <circle class="point" r="4"
      {{bind-attr
        cx='lastPointX'
        cy='lastPointY'}}></circle>
  </g>
</svg>

<div class="response-tooltip" {{bind-attr style='flagTooltipPosition'}}>
  <strong>Sent: </strong>
  <span class="message-sent-time">
    {{moment-format sentAt format='h:mma Do MMMM'}}
  </span>
</div>

{{#if tooltipEventData}}
<div class="response-tooltip opens" {{bind-attr style='tooltipPosition'}}>
  {{tooltipEventData.value}} opens at
  {{moment-format tooltipEventData.date format='h:mma Do MMMM'}}
</div>
{{/if}}

This template is quite verbose. I created inner group (g tag) to pad inner area a bit. I created 2 clipPath tags with rect tag inside to define clip areas for the graph. After that I added 2 axis that will be defined as a component, which just wraps axis generator from D3.js. Then I defined 4 path tags, 2 for lines and 2 for areas, each of them is declared as clipped by corresponding clipPath area. In the end I defined line for the separator and line with circle for the last data point. All declared shapes contain bindings definitions with bind-attr for the data parameters (d, x, y etc.).

I moved tooltip definitions outside of the svg DOM on the purpose. It's a really frustrating process working with text in svg, lack of the simple things like text wrap, makes this process much harder. Back to the tooltips, first tooltip is static (defined with the flagTooltipPositions) and second one is hidden behind guard clause, it will be shown with mouse event.

I believe this Handlebars template is easily readable, it's not much different from a regular html templates, even if it represents svg DOM. One thing to notice, while bind-attr works perfectly, it's impossible to use block Handlebars helpers, because they will generate metamorph script tags, which are not allowed in svg DOM. Hopefully this will be resolved with HTMLBars. Another thing that I noticed is bind-attr doesn't work on CSS class. After short investigation I figured that it's a jQuery problem, svg DOM is not fully supported (ticket), so I had to manually generate classes when needed.

Graph Axis Component

I will continue with graph-axis component. As I mentioned this will be just a wrapper around axis generator from D3.js, which means that I won't be creating layout in this case. I found only one such case for the D3.js API so far and believe that such approach is viable in some situations. For example, axis generator has a nice API for refreshing axis, this allows me to creating simple wrapper with observers:

App.GraphAxisComponent = Ember.Component.extend({
  tagName:    'g',
  classNames: ['axis'],

  attributeBindings: ['transform'],

  scale:       null,
  orient:      'bottom',
  ticks:       10,
  tickSize:    15,
  tickFormat:  null,
  tickPadding: 15,

  d3Axis: function() {
    return d3.svg.axis()
      .scale(this.get('scale'))
      .orient(this.get('orient'))
      .ticks(this.get('ticks'))
      .tickSize(this.get('tickSize'))
      .tickFormat(this.get('tickFormat'))
      .tickPadding(this.get('tickPadding'));
  }.property('scale', 'orient', 'ticks', 'tickSize',
             'tickFormat', 'tickPadding'),

  didInsertElement: function() {
    this._updateAxis();
  },

  onD3AxisChange: function() {
    if (this.state !== 'inDOM') return;

    this._updateAxis();
  }.observes('d3Axis'),

  _updateAxis: function() {
    d3.select(this.$()[0]).call(this.get('d3Axis'));
  }
});

Component will be wrapped in g tag and it binds transform property of the svg element to the variable with the same name. By using scoped jQuery selector I accessed generated element and invoked axis generator on it. Notice that generator is wrapped into computed property, which means that it will be cacheable.

Graph Component Bindings

The only thing left at this point is a bindings definitions for the component layout. I will wrap most common API from the D3.js into computed properties, this allows me to leverage internal caching, activate dynamic behaviour and significantly simplify unit testing bootstrapping process.

App.FancyGraphComponent = Ember.Component.extend({

  width:  992,
  height: 300,

  margin: {
    top:    10,
    right:  10,
    bottom: 40,
    left:   40
  },

  didInsertElement: function() {
    this.set('width', this.$().width());
    this.set('height', this.$().height());
  },

  mouseMove: function(e) {
    var bisectX     = d3.bisector(function(d) { return d[0]; }).left,
        time        = this.get('xScale').invert(e.offsetX),
        index       = bisectX(this.get('data'), time, 1),
        tooltipData = this.get('data')[index - 1];

    this.set('tooltipEventData', Ember.Object.create({
      date:  tooltipData[0],
      value: tooltipData[1]
    }));
  },

  innerWidth: function() {
    var margin = this.get('margin');
    return this.get('width') - margin.left - margin.right;
  }.property('width', 'margin'),

  innerHeight: function() {
    var margin = this.get('margin');
    return this.get('height') - margin.top - margin.bottom;
  }.property('height', 'margin'),

  innerGroupTransform: function() {
    var margin = this.get('margin');
    return 'translate(%@, %@)'.fmt(margin.left, margin.top);
  }.property('margin'),

  xAxisTransform: function() {
    return 'translate(0, %@)'.fmt(this.get('innerHeight'));
  }.property('innerHeight'),

  xScale: function() {
    return d3.time.scale()
      .range([0, this.get('innerWidth')])
      .domain(this.get('xDomain'));
  }.property('innerWidth', 'xDomain'),

  yScale: function() {
    var data        = this.get('data'),
        maxValue    = data ? d3.max(data, function(d){ return d[1] }) : 0,
        upperDomain = Math.max(maxValue, 0) + 1;

    return d3.scale.linear()
      .range([this.get('innerHeight'), 0])
      .domain([0, upperDomain]);
  }.property('innerHeight', 'data'),

  d3Line: function() {
    var xScale = this.get('xScale'),
        yScale = this.get('yScale');
    return d3.svg.line()
      .x(function(d) { return xScale(d[0]); })
      .y(function(d) { return yScale(d[1]); })
      .interpolate('monotone');
  }.property('xScale', 'yScale'),

  d3Area: function() {
    var xScale = this.get('xScale'),
        yScale = this.get('yScale');
    return d3.svg.area()
      .x(function(d) { return xScale(d[0]); })
      .y1(function(d) { return yScale(d[1]); })
      .y0(this.get('innerHeight'))
      .interpolate('monotone');
  }.property('xScale', 'yScale'),

  lineData: function() {
    if (!this.get('data')) return 'M0,0';

    return this.get('d3Line')(this.get('data')) || 'M0,0';
  }.property('data', 'd3Line'),

  areaData: function() {
    if (!this.get('data')) return 'M0,0';

    return this.get('d3Area')(this.get('data')) || 'M0,0';
  }.property('data', 'd3Area'),

  xAxisFormat: function() {
    return function(d) { return moment(d).format('ha'); };
  }.property(),

  yAxisFormat: function() {
    return d3.format('f');
  }.property(),

  yTickSize: function() {
    return -this.get('innerWidth');
  }.property('innerWidth'),

  flagPosition: function() {
    return d3.max([this.get('xScale')(this.get('sentAt')), 0]);
  }.property('xScale', 'sentAt'),

  rightClipAreaWidth: function() {
    return this.get('innerWidth') - this.get('flagPosition');
  }.property('innerWidth', 'flagPosition'),

  lastPointX: function() {
    var lastPoint = this.get('data.lastObject');
    if (!lastPoint) return 0;
    return this.get('xScale')(lastPoint[0]);
  }.property('data', 'xScale'),

  lastPointY: function() {
    var lastPoint = this.get('data.lastObject');
    if (!lastPoint) return 0;
    return this.get('yScale')(lastPoint[1]);
  }.property('data', 'yScale'),

  flagLineHeight: function() {
    return this.get('innerHeight') + this.get('margin.top');
  }.property('innerHeight', 'margin'),

  flagTooltipPosition: function() {
    return 'left: %@px; top: -10px'.fmt(this.get('flagPosition'));
  }.property('flagPosition', 'margin'),

  tooltipPosition: function() {
    if (!this.get('tooltipEventData')) return '';

    var tooltipX = this.get('xScale')(this.get('tooltipEventData.date')),
        tooltipY = this.get('yScale')(this.get('tooltipEventData.value'));

    return 'left: %@px; top: %@px'.fmt(tooltipX, tooltipY);
  }.property('tooltipEventData', 'xScale', 'yScale')
});

Once again, nothing extraordinary in this code, just a simple data transformations using D3.js API, most of the properties return primitives (strings and numbers). Most interesting part of this code is a mouse event handler. With Ember.js API I didn't have to fallback to D3.js or jQuery API for the event listeners. By using such API I used internal run loop, which helps with event throttling. One interesting moment I noticed as a part of the small investigation: D3.js stores data of the shapes generated with joins in __data__ property, so it can be recovered in Ember.js handler from the target of the event, for example e.target.__data__.

In the end I was really satisfied with the result. Firs of all, dynamic behaviour is seamlessly provided by computed properties, so I removed code that specifically handled this situation. Separation into the different layers resulted in more structured code. It also helped a lot with testing, I easily covered all code with unit tests. Additionally, I noticed that code works much faster, I believe partially due to the more optimal implementation and partially due to the cache on the computed properties. Overall, combination of the D3.js and Ember.js worked really well. By using proper API from each library I easily improved previous implementation in a several hours.

« Back to blog