Integrating D3 with React

9 June 2016

For a dashboard I was making using React, I needed to generate some graphics and I thought of using D3 since I found an example which was almost exactly what I was looking for.

I have to say that I’m not an expert in D3 nor React, in the case of D3 all the graphs I have done have been using examples from the web (it’s amazing what you can do with it). And React, well it’s my last love.

Searching around I found different ways of integrating these two technologies. The first one that appears us using either (X or Y). These projects create components around different chart components, like axis, legends, lines etc. Which in turn are transformed into D3 elements. The problem with this approach was that it would make it harder to port the example I had to React and also it would mean adding a new library to the codebase (I take this very seriously).

Reusing most of the code of the sample was a priority for me, so the next option I thought was to use all of the D3 code and inject it on the componentRender method. But something didn’t felt right about this approach, as I won’t be leveraging in the power of React.

So, I end up creating a mix between the two. Using D3 to calculate all the coordinates for the graph (the heavy math) and then use React to build the graph with JSX. I like this approach a lot because it gives you a good understanding of the general structure of the resulting graph’s SVG code.

But let’s see some code. I’ll only focus on the code of the render method as everything else is pretty standard.

  var x = d3.time.scale.utc()
    .domain([this.props.startDate, this.props.endDate])
    .range([0, width]);

  var y = d3.scale.linear()
    .rangeRound([height, 0]);

  ...

The code above is pretty standard for all D3 graphs, for any graph you always have to define these objects.

  <Chart className={this.props.className} width={width + margin.left + margin.right} height={height + margin.top + margin.bottom}>
    <g transform={`translate(${margin.left}, ${margin.top})`}>
      { seriesArr.map((d)=>
        <g>
          <path d={area(d.values)} style={ {fill: color(d.name), stroke: 'grey'} }></path>
        </g>
      ) }

      <g className="axis" ref={ (g)=>d3.select(g).call(yAxis) } >
        <text transform="rotate(-90)" y={6} dy=".71em" style=>Revenue</text>
      </g>
      <g className="axis" ref={ (g)=>d3.select(g).call(xAxis) } transform={ `translate(0, ${height})` } />

      { varNames.slice().reverse().map((name, i)=>
        <g className="legend" transform={`translate(55, ${ i*20 })`}>
          <rect x={width - 10} width={10} height={10} style={ { fill: color(name), stroke: 'grey'} } />
          <text x={width - 12} y={6} dy=".35em" style=> { name } </text>
        </g>
      ) }

    </g>
  </Chart>

What I like about this approach, is that you can define very well the structure of the resulting SVG graph. And leverage the power o React to do the rendering. I have to do some tests on the performance of this method compared to using D3 directly, but it worked very well for my needs.

var AreaChart = React.createClass({
  propTypes: {
    data: React.PropTypes.any,
    startDate: React.PropTypes.date,
    endDate: React.PropTypes.date,
  },

  getDefaultProps: function() {
    return {
      width: 600,
      height: 300
    }
  },

  getInitialState: function() {
    return {
      varNames: Object.keys(this.props.data),
      series: this._parseDataSeries(this.props.data)
    }
  },

  componentWillReceiveProps (newProps) {
    this.setState({
      varNames: Object.keys(newProps.data),
      series: this._parseDataSeries(newProps.data)
    });
  },

  render () {
    var seriesArr = this.state.series;
    var varNames = this.state.varNames;

    var margin = {top: 20, right: 55, bottom: 30, left: 80},
      width  = 1000 - margin.left - margin.right,
      height = 500  - margin.top  - margin.bottom;

    var x = d3.time.scale.utc()
      .domain([this.props.startDate, this.props.endDate])
      .range([0, width]);

    var y = d3.scale.linear()
      .rangeRound([height, 0]);

    var stack = d3.layout.stack()
      .offset("zero")
      .values((d) => d.values)
      .x((d) => x(d.date))
      .y((d) => d.revenue);

    var area = d3.svg.area()
      .interpolate("monotone")
      .x((d) => x(d.date)) // dates come as strings from server
      .y0((d)=>y(d.y0))
      .y1((d) => y(d.y0 + d.y));

    var color = d3.scale.category20().domain(varNames);

    // adds y and y0 properties
    stack(seriesArr);

    // calculate the domain of the Y after injecting the area values
    y.domain([0, d3.max(seriesArr, function (c) {
      return d3.max(c.values, function (d) { return d.y0 + d.y; });
    })]);

    var xAxis = d3.svg.axis()
      .scale(x)
      .orient("bottom");

    var yAxis = d3.svg.axis()
      .scale(y)
      .orient("left")
      .tickFormat((d)=>'$' + Math.round(d/100));

    return (
      <Chart className={this.props.className} width={width + margin.left + margin.right} height={height + margin.top + margin.bottom}>
        <g transform={`translate(${margin.left}, ${margin.top})`}>
          { seriesArr.map((d)=>
            <g>
              <path d={area(d.values)} style={ {fill: color(d.name), stroke: 'grey'} }></path>
            </g>
          ) }

          <g className="axis" ref={ (g)=>d3.select(g).call(yAxis) } >
            <text transform="rotate(-90)" y={6} dy=".71em" style=>Revenue</text>
          </g>
          <g className="axis" ref={ (g)=>d3.select(g).call(xAxis) } transform={ `translate(0, ${height})` } />

          { varNames.slice().reverse().map((name, i)=>
            <g className="legend" transform={`translate(55, ${ i*20 })`}>
              <rect x={width - 10} width={10} height={10} style={ { fill: color(name), stroke: 'grey'} } />
              <text x={width - 12} y={6} dy=".35em" style=> { name } </text>
            </g>
          ) }

        </g>
      </Chart>
    );
  },

  _parseDataSeries: function(data) {
    var varNames = Object.keys(data);
    var seriesArr = [];
    varNames.forEach((name) => {
      data[name].forEach((x)=>x.date = new Date(x.date)); // convert all date objects to dates
      seriesArr.push({
        name: name,
        values: this._normalizeSerie(data[name])
      });
    });
    return seriesArr;
  },

  _normalizeSerie: function(serie) {
    var result = [];
    // ensures there's at least one observation for each day in the range
    var start = moment(this.props.startDate);
    while (start.isBefore(this.props.endDate)) {
      let observation = serie.filter((x)=>start.isSame(x.date, 'day'));
      result.push({
        date: start.toDate(),
        revenue: (observation && observation.length) ? observation[0].revenue : 0,
      })
      start = moment(start).add({days: 1});
    }
    return result;
  }

});