Post category: Articles

Aaron Beppu •

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 :

module . exports = React . createClass ({ propTypes : { type : React . PropTypes . string . isRequired , data : React . PropTypes . array . isRequired , options : React . PropTypes . object }, componentDidMount () { }, componentDidUpdate () { }, componentWillUnmount () { }, 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:

var MyAwesomeScatterPlot = require ( './my_awesome_scatterplot' ), MyEvenCoolerBarGraph = require ( './my_even_cooler_bar_graph' ); SiftChartFactory = function ( type ) { var newChart ; if ( typeof SiftChartFactory [ type ] !== 'function' ) { throw new Error ( type + ' is not a valid Sift Chart!' ); } newChart = new SiftChartFactory [ type ](); return newChart ; }; 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:

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 () { this . _chart = new SiftChartFactory ( this . props . type , this . props . data , this . getDOMNode (), this . props . options ); }, componentDidUpdate () { this . _chart . update ( this . props . data ); }, componentWillUnmount () { 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:

var MyAwesomeScatterPlot = require ( './my_awesome_scatterplot' ), MyEvenCoolerBarGraph = require ( './my_even_cooler_bar_graph' ); SiftChartFactory = function ( type , data , node , options ) { var newChart ; if ( typeof SiftChartFactory [ type ] !== 'function' || typeof SiftChartFactory [ type ]. prototype . update !== 'function' ) { throw new Error ( type + ' is not a valid Sift Chart!' ); } if ( ! SiftChartFactory [ type ]. prototype . initialize )) { _ . extend ( SiftChartFactory [ type ]. prototype , SiftChartFactory . prototype ); } newChart = new SiftChartFactory [ type ](); newChart . initialize ( data , node , options ); return newChart ; }; SiftChartFactory . prototype . initialize = function ( data , node , opts ) { var options = this . options = _ . defaults ( opts || {}, defaults ); 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 ); 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 + ')' ); 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)' ); 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 = function () {}; MyAwesomeScatterPlot . prototype . update = function ( data ) { };

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):

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:

module . exports = React . createClass ({ getInitialState () { return { errorsOnly : false } }, onToggleErrorsOnly () { this . setState ({ errorsOnly : ! this . state . errorsOnly }); }, _aggregateAppropriateData () { if ( this . state . errorsOnly ) { return errorsOnlyData ; } return totalData ; }, render () { return ( < h2 > Events API Activity < /h2> { } < 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 . 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:

module . exports = React . createClass ({ getInitialState () { return { errorsOnly : false } }, onToggleErrorsOnly () { this . setState ({ errorsOnly : ! this . state . errorsOnly }); }, _aggregateAppropriateData () { if ( this . state . errorsOnly ) { return errorsOnlyData ; } return totalData ; }, 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> { } < input type = 'checkbox' onChange = { this . onToggleErrorsOnly } value = { this . state . errorsOnly } /> < Chart type = 'StackedBarChart' data = { this . _aggregateAppropriateData ()} options = {{ height : 400 , tooltip : this . renderTooltip , width : 600 }} /> ); } });

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

SiftChartFactory = require ( './sift_chart_factory' ); module . exports = React . createClass ({ componentWillUnmount () { 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 = function () {}; StackedBarChart . prototype . update = function ( data ) { }; StackedBarChart . prototype . _addListeners = function () { if ( this . options . tooltip ) { 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 ); } }; _onMouseOver = function ( options ) { var barGroup , intervalData ; this . tooltipNode = this . parentNode . parentNode . children [ 0 ]; if ( d3 . select ( d3 . event . target ). classed ( 'bar' )) { barGroup = d3 . event . target . parentNode ; intervalData = barGroup . __data__ ; React . render ( options . tooltip ( intervalData , { x : transX , y : transY }), this . tooltipNode ); }; _onMouseOut = function () { if ( d3 . select ( d3 . event . target ). classed ( 'bar' )) { React . unmountComponentAtNode ( this . tooltipNode ); } };

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

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!



