React, D3, SVG and Animation

One possible approach to creating data visualization charts is to use React and D3, where React handles building all of the SVG and D3 does the calculations required. D3 has the capacity to create SVG elements and manipulate the DOM directly but it's also a toolbox with just about everything needed to build charts in any way imaginable. For this blog post's experiments, SVG and React were used but could have also been done using the Canvas element and Pixi.js, for example, thanks to D3's flexibility.

The Approach

The item built here will be a generic line chart and will plot a series of random values. It will be a simple React component and not be tied to any other system such as Redux. Supporting animation will be a matter of creating a higher-order component that will add this behavior to the chart components without modifying their code.

Chart Container

The essence of a chart is the data it's going to represent; it will control the shape of the line plot, the ticks and labels of the axes, and possibly many other qualities. There are many different types of charts and they are separated by the type of information they try to convey, so the data format they require will be just as varied. For the line chart used in this demonstration, the format of the data set will be an array of integers.

  const dataSet = [32, 19, 47, 38, 17, 62, 94, 21, 59, 62]; // data for the line chart

Another critical ingredient in creating charts are the scales, which will handle fitting the line plot to the dimensions of the container. They will will also determine the labels of the horizontal and vertical axes. Scales basically map their input domain to an output range; they can be used to convert a value from the data set to a pixel value on the screen.

  function buildScale ([domainMin, domainMax], range) {
  return d3.scaleLinear().domain([domainMin, domainMax]).range(range);
}

const [domainXMin, domainXMax] = d3.extent(data, (value, index) => index);
const xScale = buildScale([domainXMin, domainXMax], [0, width]);

const yScale = buildScale(d3.extent(data), [height, 0]);

This container will then handle positioning the axes and the line plot. Here's a simplified version of what it's responsible for -

  class LineChart extends React.Component {
  render () {
    const xScale = this.buildScale(/* ... */);
    const yScale = this.buildScale(/* ... */);
    return (
      <HorizontalChartAxis {...{xScale} />
      <VerticalChartAxis {...{yScale} />
      <LinePlot {...{xScale, yScale} />
    );
  }
}

The Axes

D3 provides axes already but they're not much use here because they generate SVG which will be out of the control of React. To do this the React way, the SVG for the axes must be created manually. This is a simple task thanks to scale.ticks() which provides the values at which ticks would appear. This information can be fed into the scale again to get a pixel position, providing all the necessary ingredients to create an axis.

Below is an example of a vertical axis. The horizontal version is very similar and it may be possible to combine both into a single component.

  class VerticalAxis extends React.Component {
  static propTypes = {
    labelFn: React.PropTypes.func.isRequired,
    orientation: React.PropTypes.string.isRequired,
    scale: React.PropTypes.func.isRequired,
    tickValues: React.PropTypes.array.isRequired,
    trbl: React.PropTypes.array.isRequired,
    view: React.PropTypes.array.isRequired
  };

  static orientation = {
    LEFT: 'horizontal-axis-left',
    RIGHT: 'horizontal-axis-right'
  };

  buildTicks (tickValues, scale, labelFn, trbl, view, orientation) {
    return tickValues.map((tickValue, key) => {
      const tickLength = view[0] / 6;
      const yPos = scale(tickValue);
      let x2 = view[0];
      let x1 = x2 - tickLength;
      let anchorPosition = 'end';
      let textXPos = x1 - tickLength;
      if (orientation === VerticalAxis.orientation.RIGHT) {
        x1 = 0;
        x2 = tickLength;
        anchorPosition = 'start';
      }
      const transform = `translate(0, ${yPos})`;
      return (
        <g {...{transform, key}}>
          <line
              {...{x1, x2}}
              className="line-chart__axis-tick line-chart__axis-tick--vertical"
              y1={0}
              y2={0}
          />
          <text
              dy={3}
              className="line-chart__axis-text line-chart__axis-text--vertical"
              textAnchor={anchorPosition}
              x={textXPos}
              y={0}
          >{labelFn(tickValue)}</text>
        </g>
      );
    });
  }

  render () {
    const {scale, view, trbl, labelFn, tickValues, orientation} = this.props;
    let x1 = view[0];
    if (orientation === VerticalAxis.orientation.RIGHT) {
      x1 = 0;
    }
    const x2 = x1;
    const transform = `translate(${trbl[3]}, ${trbl[0]})`;
    return (
      <g {...{transform}}>
        <line
            {...{x1, x2}}
            className="line-chart__axis-line line-chart__axis-line--vertical"
            y1={0}
            y2={view[1]}
        />
        {this.buildTicks(tickValues, scale, labelFn, trbl, view, orientation)}
      </g>
    );
  }
}

Line Plot

The actual line plot is very simple and mundane. D3's line function will handle generating the SVG path string of the line plot. This string isn't an SVG element itself, just a simple string such as "M10,12L15,82" which is fed into the d attribute of a <path /> element.

  class LinePlot extends React.Component {
  static propTypes = {
    data: React.PropTypes.array.isRequired,
    trbl: React.PropTypes.array.isRequired,
    view: React.PropTypes.array.isRequired,
    xScale: React.PropTypes.func.isRequired,
    yScale: React.PropTypes.func.isRequired
  };

  buildLinePlot (data, view, trbl, xScale, yScale) {
    const line = d3.line();
    line.x((value, index) => xScale(index));
    line.y(yScale);
    line.curve(d3.curveLinear);
    const d = line(data);
    const className = 'line-chart__area-plot';
    return (
      <path {...{className, d, fill: 'none'}} />
    );
  }

  render () {
    const {trbl, view, data, xScale, yScale, year} = this.props;
    const [width, height] = view;
    const transform = `translate(${trbl[3]}, ${trbl[0]})`;
    return (
      <g {...{transform}}>
        {this.buildLinePlot(data, view, trbl, xScale, yScale)}
      </g>
    );
  }
}

A Basic Chart

With that, the basic line chart is working. It will plot data that is fed into it and generate axes to fit.

Adding Animation

The approach to animation here is very simple and basic - interpolate the scales. As the axes and line plot components receive new data, they also receive new scales based on this data. By easing the values of the upper and lower bounds of the domain over a period of time, a smooth animation of values is achieved. A D3 transition is created to handle the interpolation and runs setState every frame of this animation. State is used here to keep track of these bounds during a transition; a global state/store like Redux is not required and this has trade-offs of its own.

The code to add this animation will be organized as a higher-order component which will take a list of scales to interpolate and an optional transitionDuration to control the animation duration.

  const AnimatedScaleWrapper = (scaleProps = [], transitionDuration = 300) => ComposedComponent => class extends React.Component {
  constructor (props) {
    super(props);
    this.state = scaleProps.map(scaleProp => {
      const scale = this.props[scaleProp];
      const [domainMin, domainMax] = scale.domain();
      return {
        [`${scaleProp}Min`]: domainMin,
        [`${scaleProp}Max`]: domainMax
      };
    }).reduce((prev, curr) => ({...prev, ...curr}), {});
  }

  componentWillReceiveProps (nextProps) {
    const scalesUnchanged = scaleProps.map(scaleProp => {
      const [nextDomainMin, nextDomainMax] = nextProps[scaleProp].domain();
      const [domainMin, domainMax] = this.props[scaleProp].domain();
      return nextDomainMin === domainMin && nextDomainMax === domainMax;
    }).reduce((prev, curr) => (curr && prev), true);
    if (scalesUnchanged) {
      return;
    }
    d3.select(this).transition().tween('attr.scale', null);
    d3.select(this).transition().duration(transitionDuration).ease(d3.easeLinear).tween('attr.scale', () => {
      const interpolators = scaleProps.map(scaleProp => {
        const [nextDomainMin, nextDomainMax] = nextProps[scaleProp].domain();
        const minInterpolator = d3.interpolateNumber(this.state[`${scaleProp}Min`], nextDomainMin);
        const maxInterpolator = d3.interpolateNumber(this.state[`${scaleProp}Max`], nextDomainMax);
        return {scaleProp, minInterpolator, maxInterpolator};
      });
      return (t) => {
        const newState = interpolators.map(({scaleProp, minInterpolator, maxInterpolator}) => ({
          [`${scaleProp}Min`]: minInterpolator(t),
          [`${scaleProp}Max`]: maxInterpolator(t)
        })).reduce((prev, curr) => ({...prev, ...curr}), {});
        this.setState(newState);
      };
    });
  }

  render () {
    const {props, state} = this;
    const newScaleProps = scaleProps.map(scaleProp => {
      const scale = props[scaleProp];
      const domainMin = state[`${scaleProp}Min`];
      const domainMax = state[`${scaleProp}Max`];
      const newScale = scale.copy();
      newScale.domain([domainMin, domainMax]);
      return {
        [scaleProp]: newScale
      };
    }).reduce((prev, curr) => ({...prev, ...curr}), {});
    const newProps = {...props, ...newScaleProps};
    return (
      <ComposedComponent {...newProps} />
    );
  }
}

All that remains is the simple task of wrapping the components to be animated and updating the container's render which will use them; usages of LineChart are changed to AnimatedLineChart for example. There's very little change required otherwise, the original axes and line plot components don't need to be modified at all.

  const AnimatedVerticalAxis = AnimatedScaleWrapper(['scale'])(VerticalAxis);
const AnimatedHorizontalAxis = AnimatedScaleWrapper(['scale'])(HorizontalAxis);
const AnimatedLinePlot = AnimatedScaleWrapper(['xScale', 'yScale'])(LinePlot);

An Animated Chart

Once the wrapped components are in place, the chart gains the new behavior of smoothly animating the line plot and axes to accommodate the new data.

Conclusion

This technique is very simple and flexible, all thanks to D3's scales. Below is a more complex animated chart example using these principles -

The following example is a very simple Redux app that manages two animated chart components.


Thanks To

  • sghall for demonstrating that axes can be built manually

8,933 11 47