d-Threeact: How the Sift Science Console Team Made d3 and React the Best of Friends

Noah GrantMay 19, 2015

A little less than a year ago, the Sift Science Console Team decided to migrate its Backbone and Marionette Views to ReactJS (see also our post on specific React tips we learned along the way). Among the many facets of a piece-by-piece migration like this was figuring out how best to manage (err…’reactify’) our d3 charts. There were already a couple good reads/listens on this topic—with very different views on the responsibilities of each library—which we found quite helpful in establishing our own approach.

Ultimately, we decided that our d3 code should be isolated from our React code and only be available to React via a simple React Component interface. This way, we could take advantage of React’s one-way data flow into the component but still let the powerful d3 take care of all things svg-related (layout, data-binding, transitions, etc). So our ideal <Chart /> component would look like:

<Chart
  type=''
  data={}
  options={}
/>

With this setup, we could plant this generic <Chart /> component all over our codebase, supplying just the type of chart we want to produce and the data we want to display, and let d3 take care of the rest. Also, the <Chart /> component would have no notion of state with regards to data!

Okay, so now let’s work backwards from our end goal to figure out how we’re going to get there!

The Chart Component

First, we need to create the Chart component and integrate d3’s key selection operations (enter, update, exit) into React’s lifecycle methods. Luckily, as Nicolas Hery pointed out, that integration happens pretty naturally. Here’s a first pass at Chart.jsx:

// chart.jsx
module.exports = React.createClass({
  propTypes: {
    type: React.PropTypes.string.isRequired,
    data: React.PropTypes.array.isRequired,
    options: React.PropTypes.object
  },
  
  componentDidMount() {
    // create chart and do first data bind
  },
  
  componentDidUpdate() {
    // update chart with new data
  },
  
  componentWillUnmount() {
    // cleanup after chart
  },
  
  render() {
    return (
      <div className={'sift-chart ' + _.dasherize(this.props.type)}></div>
    );
  }
});

Note that we don’t actually need to remove the chart in componentWillUnmount, since those DOM elements are being unmounted by React, but we’ll want to detach anything the chart is tied to.

SiftChartFactory

Okay, so now that we have a skeleton for our component, we need a way to create a chart out of our data given a chart type. This is an excellent use case for the Factory Pattern. Here’s our first pass at a simple SiftChartFactory:

// sift_chart_factory.js
var MyAwesomeScatterPlot = require('./my_awesome_scatterplot'),
    MyEvenCoolerBarGraph = require('./my_even_cooler_bar_graph');

SiftChartFactory = function(type) {
  var newChart;
  
  // throw an error if the chart type doesn't exist
  if (typeof SiftChartFactory[type] !== 'function') {
    throw new Error(type + ' is not a valid Sift Chart!');
  }
  
  newChart = new SiftChartFactory[type]();

  return newChart;
};

// attach all chart types as static properties
SiftChartFactory.MyAwesomeScatterPlot = MyAwesomeScatterPlot;
SiftChartFactory.MyEvenCoolerBarGraph = MyEvenCoolerBarGraph;

module.exports = SiftChartFactory;

Putting aside our individual chart constructors, we can flesh out the <Chart /> component a little more with what we want out of the SiftChartFactory interface:

// Chart.jsx
SiftChartFactory = require('./sift_chart_factory');

module.exports = React.createClass({
  propTypes: {
    type: React.PropTypes.string.isRequired,
    data: React.PropTypes.array.isRequired,
    options: React.PropTypes.object
  },
  
  componentDidMount() {
    // create chart and do first data bind
    this._chart = new SiftChartFactory(
      this.props.type,
      this.props.data,
      this.getDOMNode(),
      this.props.options
    );
  },
  
  componentDidUpdate() {
    // update chart with new data
    this._chart.update(this.props.data);
  },
  
  componentWillUnmount() {
    // cleanup after chart
    this._chart.remove();
  },
  
  render() {
    return (
      <div className={'sift-chart ' + _.dasherize(this.props.type)}></div>
    );
  }
});

So this suggests that each chart constructor should have an update and a remove method, as well as some initialization during the instantiation within componentWillMount. Additionally, however, we can leverage a common d3 pattern to separate the initialization into a setup portion and an enter portion, where the former establishes chart dimensions and margins, and the latter can be thought of simply as an initial update. Therefore, we can write a shared (and overwritable, if need be) initialize method from within the factory where all non-data setup occurs:

// sift_chart_factory.js
var MyAwesomeScatterPlot = require('./my_awesome_scatterplot'),
    MyEvenCoolerBarGraph = require('./my_even_cooler_bar_graph');

SiftChartFactory = function(type, data, node, options) {
  var newChart;
  
  // throw an error if the chart type doesn't exist
  if (typeof SiftChartFactory[type] !== 'function' ||
      typeof SiftChartFactory[type].prototype.update !== 'function') {
    throw new Error(type + ' is not a valid Sift Chart!');
  }
  
  // copy over shared prototype methods to the child chart prototype
  if (!SiftChartFactory[type].prototype.initialize)) {
    _.extend(SiftChartFactory[type].prototype, SiftChartFactory.prototype);
  }
  
  newChart = new SiftChartFactory[type]();
  newChart.initialize(data, node, options);

  return newChart;
};

// initial d3 setup, like merging options and defaults, and setting chart dimensions,
// common for all charts. imagine we've defined a `defaults` hash of default options.
SiftChartFactory.prototype.initialize = function(data, node, opts) {
  var options = this.options = _.defaults(opts || {}, defaults);
  
  // set dimensions, translation offset for axes, etc. nothing related to data!
  // more or less taken from d3 BarChart Tutorial at http://bost.ocks.org/mike/bar/3/
  this.height = options.height - (options.margin.top + options.margin.bottom);
  this.width = chartWidth - (options.margin.right + options.margin.left);
  this.xAxis = d3.svg.axis().orient(options.xaxis.orientation);
  this.yAxis = d3.svg.axis().orient(options.yaxis.orientation);
  
  // main chart svg width, height, and margins
  this.svg = d3.select(node).append('svg')
      .attr('width', this.width + options.margin.left + options.margin.right)
      .attr('height', this.height + options.margin.top + options.margin.bottom)
    .append('g')
      .attr('transform', 'translate(' + options.margin.left + ',' + options.margin.top + ')');

  // setup axes positions only (scaling involves data and should be chart-specific)
  this.svg.append('g').attr('class', 'x axis')
      .attr('transform', 'translate(0, ' + this.height + ')');
  this.svg.append('g').attr('class', 'y axis')
      .append('text').attr('transform', 'rotate(-90)');
  
  // now make first data bind (update) via chart-specific update method
  this.update(data);
};

SiftChartFactory.MyAwesomeScatterPlot = MyAwesomeScatterPlot;
SiftChartFactory.MyEvenCoolerBarGraph = MyEvenCoolerBarGraph;

module.exports = SiftChartFactory;

Okay, so leaving remove from componentWillUnmount empty for now, this means that each custom chart constructor needs only an update method to comply with the SiftChartsFactory interface. Sweet!

// MyAwesomeScatterPlot.js, custom chart constructor example
MyAwesomeScatterPlot = function() {};

MyAwesomeScatterPlot.prototype.update = function(data) {
  // custom MyAwesomeScatterPlot update selection logic
};

Example Time!!!!!!11

Here’s an example of Sift’s StackedBarChart type, used here to show customers how many successful/unsuccessful requests they’ve made to our Events API. The StackedBarChart is simply an (empty) constructor with a custom update method (we’ll leave the d3 implementation details out, since this post is much more about React integration):

chart no tooltip

To finish off this section, here is a rough example of what the parent component might look like for the chart above, simply passing the appropriate data to the <Chart /> component:

// parent component to <Chart />
module.exports = React.createClass({
  getInitialState() {
    return {
      errorsOnly: false
    }
  },
  
  onToggleErrorsOnly() {
    this.setState({
      errorsOnly: !this.state.errorsOnly
    });
  },
  
  _aggregateAppropriateData() {
    if (this.state.errorsOnly) {
      // logic to return errors only data
      return errorsOnlyData;
    }
    
    // logic to return all data
    return totalData;
  },
  
  render() {
    return (
      <h2>Events API Activity</h2>
      {/* ...other stuff... */}
      <input type='checkbox' onChange={this.onToggleErrorsOnly} value={this.state.errorsOnly} />
      <Chart
         type='StackedBarChart'
         data={this._aggregateAppropriateData()}
         options={{
           height: 400,
           width: 600
         }}
      />
    );
  }
});

The animation is left for d3 to handle—something it does very well—via its selection.transition. All we have to do in React is pass it the data! The parent component handles the state of whether to pass errors-only data or total data. So easy!!

Adding event handlers

Okay, so the previous chart example is fine and all, but let’s take it a step further and provide some user interaction within the chart. Unfortunately, since we are not treating each
in our bar chart as a React component, we can’t take advantage of the nice abstractions React provides, i.e. <rect onClick={myClickHandler} />. Let’s first figure out how we can integrate event listeners with our current setup, and then we can discuss how to implement them.

Integrating into the SiftChartFactory

Each chart type might optionally have certain hover or click interactions, etc, which should be bound as the component is mounted and unbound before the element leaves the DOM. The binding can happen as part of the factory’s common initialize method, just after the initial update—easy enough. The unbinding can be part of the aforementioned empty remove method invoked in the <Chart /> component’s componentWillUnmount method, which can also be refactored into a factory method! Our SiftChartFactory becomes:

// SiftChartFactory.js 
// ... 
// ... 
SiftChartFactory.prototype.initialize = function(data, node, opts) {
  // ...
  // ...

  this.update(data);
  if (typeof this._addListeners === 'function') {
    this._addListeners();
  }
};

SiftChartFactory.prototype.remove = function() {
  if (typeof this._removeListeners === 'function') { 
    this._removeListeners();
  }
};

Each chart constructor is now simply responsible for one required method (update) and two optional methods (_addListeners and _removeListeners). Now we’re getting somewhere! Let’s take our previous example and add a tooltip to display an individual bar’s event count on hover.

Binding Mouse Events and Rendering a Tooltip

Making a tooltip is pretty tricky given our desired separation of responsibilities, because the mouse event needs to be registered in d3, but dynamically positioning and rendering a tooltip dialog is a lot easier in React. This is where our <Chart /> options prop comes in handy. Let’s add a renderTooltip method to the parent component that takes in data and coordinates and returns the JSX for our desired tooltip, and then pass that method to our <Chart /> component:

// parent component to <Chart />
module.exports = React.createClass({
  getInitialState() {
    return {
      errorsOnly: false
    }
  },
  
  onToggleErrorsOnly() {
    this.setState({
      errorsOnly: !this.state.errorsOnly
    });
  },
  
  _aggregateAppropriateData() {
    if (this.state.errorsOnly) {
      // logic to return errors-only data
      return errorsOnlyData;
    }
    
    // logic to return all data
    return totalData;
  },
  
  // callback passed to Chart component
  // @param {object} intervalData - the event counts for the hovered bar group, of the shape:
  //    {
  //        x: <timestamp>,
  //        y: [
  //            {y0: <bar1-begin-height>, y1: <bar1-end-height>},
  //            {y0: <bar2-begin-height>, y1: <bar2-end-height>},
  //            ...
  //        ]
  //    }
  // @param {object} translateCoords - for positioning the tooltip correctly
  renderTooltip(intervalData, translateCoords) {
    var style = {
      transform: 'translate3d(calc(' + translateCoords.x + 'px - 50%), ' +
          translateCoords.y + 'px, 0)'
    };

    return (
      <div className='sift-chart-tooltip' style={style}>
        <p>{this._hourTooltipHelper(intervalData.x)}</p>
        {!this.state.errorsOnly ?
          <p>events {intervalData.y[1].y1 - intervalData.y[0].y0}</p>
        : null}
        <p>errors {intervalData.y[0].y1}</p>
      </div>
    );
  },
  
  render() {
    return (
      <h2>Events API Activity</h2>
      {/* ...other stuff... */}
      <input type='checkbox' onChange={this.onToggleErrorsOnly} value={this.state.errorsOnly} />
      <Chart
         type='StackedBarChart'
         data={this._aggregateAppropriateData()}
         options={{
           height: 400,
           tooltip: this.renderTooltip, // component methods are auto-bound to the component! :+1:
           width: 600
         }}
      />
    );
  }
});

Our <Chart /> component needs to be updated to include a container within which we want to render the tooltip:

// Chart.jsx
SiftChartFactory = require('./sift_chart_factory');

module.exports = React.createClass({
  // ...

  // ...

  componentWillUnmount() {
    // cleanup after chart
    this._chart.remove();
  },

  render() {
    return (
      <div classname={'sift-chart ' + _.dasherize(this.props.type)}>
        <div classname='chart-tooltip'></div>
      </div>
    );
  }
});

The awesome thing about data binding in d3 is that the data is actually bound to the DOM element. It’s not stored in some jQuery DOM element wrapper abstraction; you can get the appropriate data for an element simply by calling document.querySelector(<selector>).__data__. That means that since d3 event handlers are called with the DOM element as the context, we should have everything we need in our mouseover listener.

So let’s update StackedBarChart.js:

// StackedBarChart.js
StackedBarChart = function() {};

StackedBarChart.prototype.update = function(data) {
  // custom StackedBarChart update selection logic
};

StackedBarChart.prototype._addListeners = function() {
  if (this.options.tooltip) {
    // use d3 event bindings for hover
    this.svg.on('mouseover', _.partial(_onMouseOver, this.options));
    this.svg.on('mouseout', _.partial(_onMouseOut));
  }
};

StackedBarChart.prototype._removeListeners = function() {
  if (this.options.tooltip) {
    this.svg.on('mouseover', null);
    this.svg.on('mouseout', null);
  }
};

// callback in d3 is invoked with DOM element as current context!
_onMouseOver = function(options) {
  var barGroup,
      intervalData;
  
  // crawl the DOM appropriately to find our tooltip node in which
  // to render our React tooltip component.
  this.tooltipNode = this.parentNode.parentNode.children[0];
  
  // event delegation, d3-style. set one listener on the entire chart
  // as opposed to each individual `.bar` (where each <rect> in the 
  // StackedBarChart has class 'bar') , which could be 100 for a stacked bar
  // chart, 1000 if you have several stacked bar charts on a page
  if (d3.select(d3.event.target).classed('bar')) {
    // get bound data from _actual_ DOM element,
    // in this case the parent of each group of stacked bars
    barGroup = d3.event.target.parentNode;
    intervalData = barGroup.__data__;
    
    // logic to correctly position the tooltip...
    // ...
    // ...
    
    // now call the passed renderTooltip method with the data and translation coords!
    React.render(options.tooltip(intervalData, {x: transX, y: transY}), this.tooltipNode);
};

_onMouseOut = function() {
  // event delegation again
  if (d3.select(d3.event.target).classed('bar')) {
    React.unmountComponentAtNode(this.tooltipNode);
  }
};

Here’s what our final version of this chart looks like:

final chart

This approach definitely blurs the lines between React/d3 responsibilities by actually calling React.render from within our d3 code. But it still feels very Reactive because our tooltip method is defined by the parent component and passed into our d3 code as a prop in the same way that we pass callbacks to other child components. Our StackedBarChart remains very reusable; another parent component with completely different data can call it with a completely different tooltip structure, and the d3 code will simply bind/unbind listeners and call the custom tooltip function with the appropriate data and positioning.

Not all interactions will be so involved. In the actual StackedBarChart used in the Sift Science Console, we also pass a click handler prop, but without rendering anything new, all we need to do is invoke it with the right data. After implementing the tooltip, that one’s easy!

 

These are the kinds of cool projects and innovative solutions that our engineers work on every day. Interested in fighting digital fraud with the rest of the Sift Scientists? Join our team!

13 Comments