Skip to main content Skip to footer

Build Great Data Visualizations with React

Great data visualizations tell important stories.

One of the best I've ever seen is Gapminder's famous Income vs. Life Expectancy visualization.

It is more than a chart. It is a bubble chart with built-in animation that shows how income, life expectancy, and population have changed over the last 200 years, in almost 200 countries. As you watch the animation, you see how progress over the last 200 years affected the lives of people, making them longer and richer. The trend is clear and obvious, but you can see hiccups along the way, in events like the great wars, stock market crises, and so on.

In short, this visualization is an example of the effective use of animation to show massive amounts of multi-dimensional data in a clear and intuitive way.

In this article, we'll walk through the steps required to create a similar visualization using React.

Before we getting into the details, you may want to click these links to compare the original version and our React version of the visualization.

Getting the Data

At the heart of any great data visualization is a good data set. Luckily, the folks at Gapminder have collected tons of useful data and make it all available to everyone as as CSV files.

We used Excel to consolidate Gapminder's tables of population, income, and life expectancy into a single JSON data source for our app. We posted this JSON data source on myjson.com so it is available to our app and to anyone else who might be interested in it: https://api.myjson.com/bins/dk6nk.

Defining the App State

The state of our app's main component is initialized as follows:

// initialize state
this.state = {
  minYear: 0,
  maxYear: 1,
  currentYear: 1,
  showTrend: true,
  data: [], // data for the current year
  trend: [], // trend line for the current year
  parms: null // regression parameters
};

The state contains the starting and ending years (which are defined by the data set), the current year being displayed, a Boolean variable that defines whether to show a trend line for the current year, and the data and trends for the current year.

This is the code that loads and initializes the data:

// get the raw data, initialize year when done
fetch('https://api.myjson.com/bins/dk6nk')
  .then(result => result.json())
  .then(json => {
    this._json = json;
    this._minYear = json.baseYear;
    this._maxYear = json.baseYear + json.data[0].pop.length - 1;
    this.setCurrentYear(this._maxYear);
  }
);

The code uses the fetch and json commands to load and parse the data asynchronously. When done loading, it saves the raw JSON data, the first and last years, and calls the setCurrentYear method to initialize the visualization.

The setCurrentYear method is implemented as follows:

// update data to reflect the current year
setCurrentYear(year) {

  // update country data for the current year
  let index = year - this._minYear;
  let data = this._json.data.map(item => {
    return {
      country: item.country,
      region: item.rgn - 1, // rgn is one-based
      pop: item.pop[index] * 1000, // pop in thousands
      inc: item.inc[index], // income in $/capita/year
      life: item.life[index] // life expectancy in years
    }
  });

  // sort countries by population (small bubbles above big ones)
  data.sort((c1, c2) => {
    return c2.pop - c1.pop;
  });

  // compute regression for countries this year
  // …

  // update state with the new data
  this.setState({
    minYear: this._minYear,
    maxYear: this._maxYear,
    currentYear: year,
    data: data,
    trend: trend,
    parms: parms
  });
}

The code starts by creating an array to hold the data for the currently selected year into a "data" member. This array is sorted by country population so larger bubbles are shown below smaller ones and don't hide them on the chart.

The code uses the data for the currently selected year to calculate a regression line that can be added to the chart. We did not copy that code here but you can look at it in the app source if you are interested. The most interesting detail is that we use the log of the income values instead of the raw values. This is because income values in the data are not evenly distributed, so our chart will show them using a logarithmic scale. If we didn't use logs in the regression, the trend would not be a straight line and the fit would not be as good.

Creating the Chart

Now that the data has been loaded, we can create the chart. This is done by adding a FlexChart component to the page:

<FlexChart
  chartType="Bubble"
  options=\{{
    bubble: { minSize: 2, maxSize: 50 } 
  \}}
  tooltip=\{{
    content: (ht) => {
      if (ht.series.itemsSource == this.state.data) {
        let tpl = '<b>{country}</b><br/>{pop:g1,,} million people'
        return format(tpl, ht.item);
      } else {
        let tpl = "y = {a:n2} + {b:n2} * ln(x) (R<sup>2</sup>: {r2:n2})"
        return format(tpl, this.state.parms);
      }
    }
  \}}
  itemFormatter={(engine, ht, defaultFormat) => {
    engine.fill = this._regionColors[ht.item.region];
    engine.stroke = 'black';
    engine.strokeWidth = 1;
    defaultFormat();
  \}}>

This markup adds the chart to the page, sets the "chartType" property to "Bubble," and defines callback functions for the chart's "tooltip" and "itemFormatter" properties.

We use a callback for the tooltips because our chart has two series (the bubbles and the trendline), and we want different tooltips for each.

We use the "itemFormatter" property to apply colors to the bubbles. In our chart, the color is defined by the country's region.

After defining the chart, we add a plain div element to act as a watermark:

<div className="watermark">
  {this.isLoading() ? '...' : this.state.currentYear}
</div>

The watermark element shows three dots while the data is loading, and shows the current year after that.

The next few elements are used to hide the chart legend (we don't need it) and the chart's axes:

<FlexChartLegend
  position="None"/>

<FlexChartAxis
  wjProperty="axisX"
  title="income per capita (US$/person/year)"
  majorGrid={true}
  axisLine={true} 
  min={this._minInc}
  max={this._maxInc}
  logBase={10}/>

<FlexChartAxis
  wjProperty="axisY"
  title="life expectancy (years)" 
  majorGrid={true}
  axisLine={true}
  min={this._minLife} 
  max={this._maxLife}
  majorUnit={10}/>

The "axis" elements define the axes title, scaling, gridlines, and type. As we mentioned above, our chart uses a logarithmic scale for the X-axis, because the income values tend to bunch around lower values.

Now that we have the data and the chart has been set up, we are ready to add the main series:

<FlexChartSeries 
  itemsSource={this.state.data}
  bindingX="inc"
  binding="life,pop"/>

This element will show the bubbles for each country. The X value is bound to the country's per-capita income, the Y value is bound to the life expectancy, and the bubble size is bound to the country's population. As we mentioned above, the bubble color is bound to the country's region.

The final element is the trend line, based on the regression calculated when the current year was set:

<FlexChartSeries 
  chartType="Line"
  itemsSource={this.state.trend}
  bindingX="x"
  binding="y"
  style=\{{stroke:'black', strokeWidth:'2px'\}}
  visibility={this.state.showTrend ? 'Visible': 'Hidden'}
/> 

The trend series is a simple line series. It has its own data source, X and Y bindings, and is rendered only if the "showTrend" member is set to true.

At this point, the chart already works and shows the data for the last year in the data set. This is a rich chart, showing four data dimensions: income on the X axis, life expectancy on the Y axis, population as bubble size, and region as a color.

Now we are ready to add a fifth dimension: time.

Traveling in Time

To allow users to travel in time, we must allow them to change the value of the "currentYear" member in our state. We did that by adding a LinearGauge control to the page and binding its value to the "currentYear" member:

<LinearGauge 
  min={this.state.minYear}
  max={this.state.maxYear}
  isReadOnly={this.state.data.length == 0}
  isAnimated={false}
  thumbSize={30}
  face=\{{ thickness: 0.25 \}}
  pointer=\{{ thickness: 0.25, color: '#00916f' \}}
  value={this.state.currentYear}
  valueChanged={(s, e) => {
  if (s.value != this.state.currentYear) {
    this.toggleAnimation(false);
    this.setCurrentYear(s.value);
  }
\}}/>

The markup sets up the LinearGauge by providing its min and max values, making it read/write, and setting some properties that define its appearance. The gauge is bound to the "currentYear" state variable through its "value" property. When the user changes the gauge's value, the "valueChanged" event handler suspends any on-going animations and updates the state by calling the "setCurrentYear" method.

Now users can drag the gauge's thumb element to travel in time and see the evolution of income and life expectancy in the world over the last 200 years!

Adding Animation to Enhance Data Visualization

To add animation, we define a JavaScript "interval" that updates the "currentYear" at fixed intervals. The animation can be toggled using a button:

<button 
  className="btn" 
  disabled={this.isLoading()}
  onClick={() => this.toggleAnimation()}>
  &#x25b6;
</button>

The "toggleAnimation" method is implemented as follows:

// start/stop current year animation
toggleAnimation(start) {
  if (start == null) {
    start = !this._animation;
  }
  if (start) {
    let min = this._minYear;
    let max = this._maxYear;
    this._animation = animate(pct => {
      this.setCurrentYear(Math.round(min + (max - min) * pct));
      if (pct >= 1) {
        this._animation = null;
      }
    }, 15000); // animation duration in ms
  } else {
    clearInterval(this._animation);
    this._animation = null;
  }
}

The method uses Wijmo's "animate" method, which creates a JavaScript "interval" that sets the current year, waits for a while, and updates it again. The animation may be interrupted at any time by clearing the interval.

When users click the animation button, the chart comes alive and shows the role of time in the evolution of wealth and health in the whole world.

Our visualization already looks a lot like Gapminder's. You can see the clear correlation between income and life expectancy across countries and across time. We decided to make the correlation even more obvious by adding a trend line to the chart.

The trend line is just another series in the chart. It is calculated by the "setCurrentYear" method we listed above. Here is the calculation of the trend line data:

// update data to reflect the current year
setCurrentYear(year) {

  // update country data for the current year
  // …

  // sort countries by population (small bubbles above big ones)
  // …

  // compute regression for countries this year
  let trend = [];
  let min = data.reduce((p, c) => p.inc < c.inc ? p : c).inc;
  let max = data.reduce((p, c) => p.inc > c.inc ? p : c).inc;
  let step = (max - min) / 5;
  let parms = this.regression(
    data.map(item => Math.log2(item.inc)), // log scale
    data.map(item => item.life)
  );
  for (let x = min; x <= max; x += step) {
    trend.push({
      x: x,
      y: parms.a + Math.log2(x) * parms.b
    });
  }

  // update state with the new data
  this.setState({
    minYear: this._minYear,
    maxYear: this._maxYear,
    currentYear: year,
    data: data,
    trend: trend,
    parms: parms
  });
}

We could have used the wijmo.chart.analytics module to create the trendline, but since this is a just a simple regression we decided to do it directly in the app. Note that we use logs for the income data since that's the nature of the data. There is indeed a strong linear correlation between the log of the income and life expectancy data.

We also added a checkbox to allow users to turn the trend line on and off:

<label>
  Trendline
  <input 
    type="checkbox"
    checked={this.state.showTrend}
    onClick={(e) => {
      this.setState({
        showTrend: !this.state.showTrend
      })
  \}}/>
</label>

The regression adds to the story. The image below shows a superposition of three charts including the trendlines:

income per capita

In the 1800's, the slope was about three. The data was all bunched together: most people were poor and lived short lives. In the 1940's and 50's, the data started to spread out. People in some countries were getting wealthier and living much longer than others. The slope of the trend line grew from three to six or even higher. After that, wealth started to spread to more countries and more people, and health care improved overall. The slope of the trend went back down to about 3.5, reflecting the fact that life expectancy grew even in poorer countries (which became less poor).

Switching Charts

We promised to tell you how to create a great visualization in React. The sample did it using the FlexChart control, but we thought it would be interesting to show how you could use the same infrastructure to show the data using a different chart control.

So we made a new version of the sample using the react-vis library, an open-source charting library designed to be used with React.

To do this, we simply added react-vis to the list of project dependencies and replaced the FlexChart component with a react-vis XYPlot component:

<XYPlot
  width={600} 
  height={450}
  yDomain={[this._minLife, this._maxLife]}
  xDomain={[this._minInc, this._maxInc]}
  xType="log">
  <HorizontalGridLines />
  <VerticalGridLines />
  <XAxis />
  <YAxis />

  <MarkSeries
    sizeRange={[2, 50]}
    colorType="literal"
    opacity={0.75}
    data={this.state.data.map(item => {
      return {
        x: item.inc,
        y: item.life,
        size: item.pop,
        color: this._regionColors[item.region]
      }
    })
  }/>

  {
    this.state.showTrend 
      ? <LineSeries 
          color="black"
          data={this.state.trend}/>
      : <noscript/>
    }

  <div className="watermark">
    {this.isLoading() ? '...' : this.state.currentYear}
  </div>
</XYPlot>

The markup is self-explanatory. The rect-vis library allowed us to map most features and properties quite easily, without any additional changes to the component. You can click this link to see the react-vis version of the sample.

The main differences between the FlexChart and react-vis versions of the sample are:

  1. The react-vis chart is always 600 by 450 pixels regardless of the window size. The FlexChart is automatically updates when the window is reasized.
  2. The X-axis on the react-vis chart uses a base 2 log since the control does not seem to support base 10 logs.
  3. The react-vis chart does not show tooltips. It seems it is possible to add tooltips to react-vis charts, but that is not a built-in feature and we decided to leave them out.

Conclusion

We hope you found this article interesting and exciting, and that it will give you ideas for showing your data and ideas with great visualizations in React and other JavaScript frameworks.

We also hope you will find useful data for great visualizations at Gapminder's data source page and more ideas, interesting questions, answers, and tools on their site.

Finally, we hope you will send any questions or suggestions on charting and data visualizations to us at wijmoexperts@grapecity.com.

Thank you Gapminder! And happy coding to all!

Bernardo de Castilho

comments powered by Disqus