This post is not yet published and still wip! You can read it, but a lot of things are missing.

Everyone who build (or tried to build) a chart with React knows its biggest pain point: How to gracefully handle new elements and elements that will be removed. By using TransitionGroup and CSSTransition one can try to emulate something that might come close, but it's not possible with them to completely simulate the enter/update/exit cycle of d3.

Building a scatter plot for comparing imbd and rotten tomatoe ratings

We'll build a simple scatter plot, first in d3, then in React, then in React + mobx. The scatter plot compares the ratings of imdb and rotten tomatoes by using the imdb ratings on the x axis, and the rotten tomatoes on the y axis. The underlying data looks like

const someMovie = {
  rotten: 8.5,
  imdb: 9,
  title: "Some movie",
  description: "Such a good movie.",
};

The current data is fake, maybe I'll update the data to look more real, but it makes reading the code a bit simpler.

Requirements

The resulting chart should look like this and also have these requirements:

  • New / changing datapoints need to be handlen gracefully. A new point should fade in, a deleted one should fade out.

  • No jumps! A point should never jump - it doesn't matter the user abuses the controls and doesn't let the animation finish.

  • Good performance - the visualisations should run at 60fps, even on low end devices

D3

Let's start with the d3 version, it will be easy to do and create a high bar of what we want to do with React.

Number Of Frames: 0

The code for this chart can be seen below. We use a React component called ScatterD3 to create the dom element and manage the data updates of the d3 code. We do this by creating a svg, which ref we're saving and using this ref to initialize the d3 code by calling scatterD3 with it. scatterD3 returns a function that takes the movie data as an argument und does its thing. We're saving this function in our component and call it everytime we update our data.

import * as React from 'react';
import * as d3Selection from 'd3-selection';
import * as d3Scale from 'd3-scale';
import 'd3-transition';
import { Movie, createMockData } from './data';
import ScatterWrapper from './ScatterWrapper';
import config from './config';


function scatterD3(root: SVGElement): (data: Movie[]) => any  {
  if (!root) {
    return;
  }
  const svg = d3Selection.select(root);
  const height = root.clientHeight;
  const width = root.clientWidth;

  const rottenRange = [0, height];
  const imdbRange = [0, width];

  const transitionDuration = config.transitionTime;

  return function update(data: Movie[]) {
    const rottenRatings = data.map(d => d.rotten);
    const rottenMin = Math.min(...rottenRatings);
    const rottenMax = Math.max(...rottenRatings);

    const rottenAxis = d3Scale.scaleLinear()
      .domain([rottenMin, rottenMax])
      .range(rottenRange);

    const imdbRatings = data.map(d => d.imdb);
    const imdbMin = Math.min(...imdbRatings);
    const imdbMax = Math.max(...imdbRatings);

    const imdbAxis = d3Scale.scaleLinear()
      .domain([imdbMin, imdbMax])
      .range(imdbRange);

    const circleClassName = 'd3-circle';
    const circles = svg.selectAll(`.${circleClassName}`)
      .data(data, (d: Movie) => d.title);

    const transformCircle = (d: Movie) => `translate(${imdbAxis(d.imdb)}, ${rottenAxis(d.rotten)})`;

    circles.enter()
      .append('circle')
      .attr('class', circleClassName)
      .attr('r', config.radius)
      .attr('cx', 0)
      .attr('cy', 0)
      .attr('opacity', 0)
      .attr('transform', transformCircle)
        .transition()
        .duration(transitionDuration)
        .attr('opacity', 1);

    circles
      .transition()
      .duration(transitionDuration)
      .attr('transform', transformCircle)
      .attr('opacity', 1)

    circles
      .exit()
      .transition()
      .duration(transitionDuration)
      // .attr('transform', transformCircle)
      .attr('opacity', 0);
  }

}

class ScatterD3 extends React.PureComponent<{}, {
  data: Movie[];
  numberOfPoints: number;
}> {
  data: Movie[];
  root: SVGElement;
  updateFn: (movies: Movie[]) => any;
  constructor(props: any) {
    super(props);
    this.setRef = this.setRef.bind(this);
    this.updateData = this.updateData.bind(this);
    const numberOfPoints = 100;
    this.state = {
      data: createMockData(numberOfPoints),
      numberOfPoints,
    };
  }

  updateData(data: Movie[]) {
    if (this.updateFn) {
      this.updateFn(data);
    } else {
      this.data = data;
    }
  }

  setRef(dom: any) {
    this.root = dom;
  }

  componentDidMount() {
    this.updateFn = scatterD3(this.root);
    this.updateFn(this.data);
  }

  render() {
    return (
      <div className="scatter-chart-container">
        <ScatterWrapper updateData={this.updateData} />
        <svg
          style={{
            overflow: 'visible'
          }}
          ref={this.setRef}
          className="scatter-chart"
        />
      </div>
    );
  }
}

export default ScatterD3;

So far so good. The result works as expected, we have some points which gracefully animate between each state update. Try to click really fast and see what happens - the state is always animated from the current state of the last animation, exactly as it shoud be! The shortcomings of this solution are the general shortcomings when using d3:

  • It's not possible to server side render this easily, d3s rendering heavily relies on the dom
  • Transitions are JS-based and a switch to CSS animations would either be hacky or not as beatiful. CSS animations are needed when the amount of elements grows really large. The normal way here is to use canvas rendering.
  • It's a pain to create more and more dom nodes, even the simple text hover increased the complexity a lot. It also gets really confusing quickly.

React

We now build this chart with React and use TransitionGroup and CSSTransition for the animations. Let's see how this works.

Number Of Frames: 0

The code below now uses React for rendering the chart. We still use d3 to set up the scales, but nothing more. The code is exactly the same as in the version above. We now use setState to update the state and let React handle the rest. TransitionGroup and CSSTransition are used for animated new and deleted elements. As we are now using CSS for the animation, there are also some new CSS classes. Beware that this code currently does not work in Edge, as SVG CSS animations are not functional there.

import * as React from 'react';
import * as d3Scale from 'd3-scale';
import { Movie } from './data';
import { TransitionGroup, CSSTransition } from 'react-transition-group';
import ScatterWrapper from './ScatterWrapper';
import config from './config';

class ScatterReact extends React.PureComponent<{}, {
  data: Movie[];
  width: number;
  height: number;
}> {
  constructor(props: any) {
    super(props);
    this.updateData = this.updateData.bind(this);
    this.setRef = this.setRef.bind(this);
    this.state = {
      data: [],
      width: 1,
      height: 1,
    };
  }

  setRef(dom: SVGElement) {
    if (!dom) {
      return;
    }
    const height = dom.clientHeight;
    const width = dom.clientWidth;
    this.setState({
      height,
      width
    });
  }

  updateData(data: Movie[]) {
    this.setState({data});
  }

  render() {

    const width = this.state.width;
    const height = this.state.height;

    const transitionTime = config.transitionTime;
    const radius = config.radius;
    const rangeRotten = [
      0,
      height,
    ];
    const rangeImdb = [
      0,
      width,
    ]

    const rottenRatings = this.state.data.map(d => d.rotten);
    const rottenMin = Math.min(...rottenRatings);
    const rottenMax = Math.max(...rottenRatings);

    const rottenAxis = d3Scale.scaleLinear()
      .domain([rottenMin, rottenMax])
      .range(rangeRotten);

    const imdbRatings = this.state.data.map(d => d.imdb);
    const imdbMin = Math.min(...imdbRatings);
    const imdbMax = Math.max(...imdbRatings);

    const imdbAxis = d3Scale.scaleLinear()
      .domain([imdbMin, imdbMax])
      .range(rangeImdb);

    return (
      <div className="scatter-chart-container">
        <ScatterWrapper
          updateData={this.updateData}
        />
        <svg
          className="scatter-chart"
          ref={this.setRef}
        >
          <TransitionGroup component="g">
            {this.state.data.map(d => {
              return (
                <CSSTransition
                  component={'g'}
                  timeout={transitionTime}
                  classNames='fade'
                  key={d.title}
                >
                  <circle
                    cx={0}
                    cy={0}
                    r={radius}
                    style={{
                      transitionDuration: config.transitionTime + 'ms',
                      transitionTimingFunction: 'ease-in-out',
                      transitionProperty: 'transform, opacity',
                      transform: `translate3d(${imdbAxis(d.imdb)}px, ${rottenAxis(d.rotten)}px, 0)`,
                    }}
                  />
                </CSSTransition>
              );
            })}
          </TransitionGroup>
        </svg>
      </div>
    );
  }
}

export default ScatterReact;

The code above looks a lot simpler. I can totally understand the created html and everything looks more friendly. From a user perspective, it's hard to see a difference. CSS animations work amazingly well here and it is as fluid as the React version. When one often changes the state, it becomes clear, that only the updated elements are updated from their current position, new and deleted elements fade in and out, despite the animation never finishing. But who really cares? Because I have some experience with this approach, some things are not that obvious here:

  • Performance wise, it's quite expensive to use this approach, as when the number of elements grows, React needs to execute the lifecycle for each element.
  • It's not as powerful as the d3 version. For example, it's not possibleu to let the deleted elements move with the new scales, with d3 it would be trivial
  • We can only use CSS transitions here, which makes it impossible to animate things like <path />.
  • Some browsers won't animate the style in SVGs

React with Mobx

We now build it with Mobx and React. For this we need a lot of more work, as we won't rely on CSS transitions or D3s transition function which does all the heavy lifting for us.

Number Of Frames: 0

Oh wow, that's a lot more code 276 lines vs 108 lines. Why's there so much more code? That's due to the fact, that we do the complete interpolation ourselves, it's like rebuilding d3s transition. We have to modes of operating: using javascript for the actual transition or use CSS for the transition.

import * as React from 'react';
import {observable, action, observe} from 'mobx';
import * as d3Scale from 'd3-scale';
import {interpolateNumber} from 'd3-interpolate';
import {observer, Provider, inject} from 'mobx-react';
import { Movie } from './data';
import config from './config';
import ScatterWrapper from './ScatterWrapper';

interface MovieLayouted {
  movie: Movie;
  opacity: number;
  x: number;
  y: number;
}

type MovieInterpolated = (t: number) => MovieLayouted;

function interpolateMovie(fromMovie: MovieLayouted, toMovie: MovieLayouted): MovieInterpolated {
  const opacity = interpolateNumber(fromMovie.opacity, toMovie.opacity);
  const x = interpolateNumber(fromMovie.x, toMovie.x);
  const y = interpolateNumber(fromMovie.y, toMovie.y);
  return (t: number) => ({
    ...toMovie,
    opacity: opacity(t),
    x: x(t),
    y: y(t),
  });
}

function interpolateMovies(fromMovies: MovieLayouted[], toMovies: MovieLayouted[]): MovieInterpolated[] {
  const deletedMovies = fromMovies.filter(m1 => {
    return !toMovies.find(m2 => m2.movie.title === m1.movie.title);
  });

  const updateMovies = toMovies.filter(m2 => {
    return fromMovies.find(m1 => m1.movie.title === m2.movie.title);
  });

  const newMovies = toMovies.filter(m2 => {
    return !fromMovies.find(m1 => m1.movie.title === m2.movie.title);
  });

  const deletedMoviesInterpolated = deletedMovies.map(m => {
    const mTo = {
      ...m,
      opacity: 0,
    };

    return interpolateMovie(m, mTo);
  });

  const newMoviesInterpolated = newMovies.map(m => {
    const mFrom = {
      ...m,
      opacity: 0,
    };

    return interpolateMovie(mFrom, {...m, opacity: 1});
  });

  const updatedMoviesInterpolated = updateMovies.map(m => {
    const mFrom = fromMovies.find(m2 => m2.movie.title === m.movie.title);
    return interpolateMovie(mFrom, m);
  });

  return deletedMoviesInterpolated.concat(
    updatedMoviesInterpolated
  ).concat(
    newMoviesInterpolated
  );
}

function easeInOutQuad(t: number) {
  return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
};

function layoutMovies(width: number, height: number, movies: Movie[]): MovieLayouted[] {

  const rangeRotten = [
    0,
    height,
  ];

  const rangeImdb = [
    0,
    width,
  ];

  const rottenRatings = movies.map(d => d.rotten);
  const rottenMin = Math.min(...rottenRatings);
  const rottenMax = Math.max(...rottenRatings);

  const rottenAxis = d3Scale.scaleLinear()
    .domain([rottenMin, rottenMax])
    .range(rangeRotten);

  const imdbRatings = movies.map(d => d.imdb);
  const imdbMin = Math.min(...imdbRatings);
  const imdbMax = Math.max(...imdbRatings);

  const imdbAxis = d3Scale.scaleLinear()
    .domain([imdbMin, imdbMax])
    .range(rangeImdb);

  return movies.map(movie => {
    return {
      movie,
      x: imdbAxis(movie.imdb),
      y: rottenAxis(movie.rotten),
      opacity: 1,
    };
  })
}

class MovieState {
  @observable.shallow movies: Movie[];
  @observable numberOfPoints: number;
  @observable active: Movie;
  @observable.shallow layoutedMovies: MovieLayouted[] = [];
  @observable width: number = 1;
  @observable height: number = 1;
  @observable useJavascript: boolean = true;

  lastUpdate: number;
  currentInterpolation: MovieInterpolated[];

  constructor() {
    this.numberOfPoints = 100;
    this.active = null;
    this.movies = [];
    this.setActive = this.setActive.bind(this);
    this.setData = this.setData.bind(this);
    this.setNumberOfPoints = this.setNumberOfPoints.bind(this);
    this.setUseJavascript = this.setUseJavascript.bind(this);

    observe(this, 'movies', (change) => {
      const oldValue: Movie[] = change.oldValue;
      const newValue: Movie[] = change.newValue;

      if (this.width === 1 || this.height === 1) {
        return;
      }

      if (oldValue === newValue) {
        return;
      }

      const now = +(new Date());
      const diffToLastUpdate = now - (this.lastUpdate || now);

      let fromInterpolate = layoutMovies(this.width, this.height, oldValue);
      const toInterpolate = layoutMovies(this.width, this.height, newValue);

      if (diffToLastUpdate > 0 && diffToLastUpdate < config.transitionTime) {
        const t = diffToLastUpdate / config.transitionTime;
        fromInterpolate = this.currentInterpolation.map(m => m(easeInOutQuad(t)));
      }
      const interpolation = interpolateMovies(
        fromInterpolate,
        toInterpolate,
      );

      this.lastUpdate = now;
      this.currentInterpolation = interpolation;

      // with this method we can easily use javascript to update the properties...
      if (this.useJavascript) {
        const updater = () => {
          const now = +(new Date());
          const diffToLastUpdate = now - (this.lastUpdate || now);
          const t = Math.min(diffToLastUpdate / config.transitionTime, 1);
          this.layoutedMovies = this.currentInterpolation.map(m => m(easeInOutQuad(t)));
          if (t < 1) {
            requestAnimationFrame(updater);
          }
        }
        updater();
      // ... or css to update them
      } else {
        // first we apply the start state
        this.layoutedMovies = interpolation.map(m => m(0));
        // then we apply the end state
        setTimeout(() => {
          this.layoutedMovies = interpolation.map(m => m(1));
        });
      }


    });
  }

  @action
  setUseJavascript(useJavascript) {
    this.useJavascript = useJavascript;
  }

  @action
  setWidthHeight(width: number, height: number) {
    this.width = width;
    this.height = height;
    this.layoutedMovies = layoutMovies(width, height, this.movies);
  }

  @action
  setNumberOfPoints(e: any) {
    const numberOfPoints = parseInt(e.target.value);
    this.numberOfPoints = numberOfPoints;
  }

  @action
  setData(data: Movie[]) {
    this.movies = data;
  }

  @action
  setActive(movie: Movie) {
    this.active = movie;
  }
}

const movieStore = new MovieState();

@inject('movieStore')
@observer
class ScatterReactMobx extends React.Component<{
  movieStore?: MovieState;
}> {
  constructor(props) {
    super(props);
    this.setRef = this.setRef.bind(this);
  }
  setRef(dom: SVGElement) {
    if (!dom) {
      return
    }
    const height = dom.clientHeight;
    const width = dom.clientWidth;
    this.props.movieStore.setWidthHeight(width, height);
  }
  render() {

    const movieStore = this.props.movieStore;

    const data = movieStore.layoutedMovies;

    return (
      <div className="scatter-chart-container">
        <ScatterWrapper
          updateData={movieStore.setData}
        />
        <label>
          {"Use Javascript for the animations "}
          <input
            type="checkbox"
            checked={movieStore.useJavascript}
            onChange={() => movieStore.setUseJavascript(!movieStore.useJavascript)}
          />
        </label>
        <svg
          ref={this.setRef}
          className="scatter-chart"
        >
          {data.map(d => {
            return (
              <circle
                key={d.movie.title}
                cx={0}
                cy={0}
                r={config.radius}
                style={{
                  transitionDuration: (movieStore.useJavascript ? 0 : config.transitionTime) + 'ms',
                  transitionTimingFunction: 'ease-in-out',
                  transitionProperty: 'transform, opacity',
                  transform: `translate3d(${d.x}px, ${d.y}px, 0)`,
                  opacity: d.opacity,
                }}
              />
            );
          })}
        </svg>
      </div>
    );
  }
}

const ScatterReactMobxContainer = () => (
  <Provider movieStore={movieStore}>
    <ScatterReactMobx />
  </Provider>
);

export default ScatterReactMobxContainer;