Gapminder

In this tutorial we recreate the deservedly famous life expectancy chart of Gapminder designed and popularized by Hans Rosling as a tribute to his tremendous efforts to bring understanding of statistics to everyone.

Data

We will use data collected from Our World in Data and transformed in a JSON format that can be directly inserted in the chart's .data() method. The full data set for this tutorial is available as a Gist and it includes the plottable data as well as country-continent mapping:

(async () => {
    // Load data and continents.
    const {data, continents} = await fetch('https://gist.githubusercontent.com/synesenom/794bc8ad12b91f27d5934604361ff0b5/raw/dalian-tutorial-gapminder.json')
      .then(response => response.json())
})()

For the sake of simplicity, we only consider data starting in 1950. Here are the first couple of lines of the data part:

{
  "1950": [
    {
      "name": "Afghanistan",
      "value": {
        "x": 2392.0,
        "y": 27.638,
        "size": 7752000
      }
    },
    {
      "name": "Albania",
      "value": {
        "x": 1478.0,
        "y": 54.191,
        "size": 1263000
      }
    }
    ...
  ]
  "1951": [...],
  "1952": [...],
  ...
}

And continents:

{
  "Afghanistan": "Asia",
  "Albania": "Europe",
  "Algeria": "Africa",
  ...
}

Building a static chart

We will build the interactive chart step-by-step adding one single element to it at each stage. First we build just a static chart with a data for one year:

(async() => {
  // Load data.
  const data = await d3.json('data.json')

  // Add static chart.
  const chart = dalian.BubbleChart('chart', '#chart')
    // Bind data.
    .data(data[1950])

    // Read dimensions from the container.
    .width(parseFloat(d3.select('#chart').style('width')))
    .height(parseFloat(d3.select('#chart').style('padding-bottom')))
    .margins(50)

    // Initialize axes.
    .leftAxis.label('Life expectancy')
    .bottomAxis.label('GDP per capita')
    .render()
})()

As you can see there are quite a number of issues: colors are messed up (most of the bubbles are black), the X axis seems a bit off (linear is not the best for this kind of data) and the chart is not really interactive yet. Let's fix the colors and the axes first!

Logarithmic X-axis

To change the X-axis to logarithmic scale, we simply map the x values of the data and also set the exact values to the axis. Axis related settings are done through the BottomAxis component' API.

// Take the logarithm of the data.
.data(data[1950].map(d => ({
  name: d.name,
  value: {
    x: Math.log10(d.value.x),
    y: d.value.y,
    size: d.value.size
  }
})))

...

// Add $ and a suffix to the values.
.bottomAxis.format(x => `$${Math.floor(Math.pow(10, x - 3))}k`)

Colors

Next we fix the colors. Colors for traditional charts in dalian are managed through the Color component's API. Among others, the API lets us a mapping that we want to apply on the data points before setting the color. Also, the reason why most bubbles are black is because each bubble is a separate plot group and they are assigned from a palette with limited number of colors.

// Load continents.
const continents = await d3.json('continents.json')

// Set color palette and assign colors by continent.
.color.palette('palette-light')
.color.on(d => continents[d.name])

Regarding the color palette, we just went with the default one, which is the Wong color palette (see the default for the categorical policy).

Interactions

There are various types of interactions we can add to the chart. Let's start with a tooltip that shows the details when hovering over a country. We can add tooltip through the ElementTooltip API, quite easily actually:

// Add tooltip. Each entry is formatted differently.
.tooltip.on(true)
.tooltip.labelFormat(d => d === 'size' ? 'Population:' : d + ':')
.tooltip.valueFormat((d, label) => {
  switch (label) {
    case 'GDP per capita':
      return `$${Math.round(Math.pow(10, d))}`
    case 'Life expectancy':
      return d.toFixed(1)
    case 'size':
      if (d < 1e3) {
        return d}
      if (d < 1e6) {
        return (d / 1e3).toFixed(1) + 'k'
      }
      if (d < 1e9) {
        return (d / 1e6).toFixed(1) + 'M'
      }
      return (d / 1e9).toFixed(1) + 'B'
    default:
      return d
  }
})

Note that the tooltip API allows for formatting each entry in the tooltip separately as the name of the entry label is passed as the second argument to the value formatter. Also, the labels passed to the tooltip are just the axis labels and 'size' for the bubble sizes (see the docs for BubbleChart).

We can also add some highlight when hovered over a country to see it a bit better. All mouse interactions are added through the Mouse component which simply binds a callback to the bubbles:

// Highlight bubble on hover.
.mouse.over(d => chart.highlight(d.name))
.mouse.leave(() => chart.highlight(null))

Changing data

To complete the chart, we add a Slider to be able to change the data displayed in our chart. Adding the slider and binding the chart update to it is rather easy:

// Add slider.
dalian.Slider('slider', '#chart')
  // Adjusting slider position and size.
  .y(0.8 * parseFloat(d3.select('#chart').style('padding-bottom')))
  .width(parseFloat(d3.select('#chart').style('width')))
  .height(0.2 * parseFloat(d3.select('#chart').style('padding-bottom')))
  .margins(50)
  .min(1950)
  .max(2019)
  .step(1)
  .callback(year => {
    // Update chart data.
    chart.data(data[year].map(d => ({
      name: d.name,
      value: {
        x: Math.log10(d.value.x),
        y: d.value.y,
        size: d.value.size
      }
    }))).render(100)
  })
  .render()

The callback function is simply the copy of the previous data binding from Logarithmic X-axis. Also, when the year is changed, the rendering is not instant but we add a 100ms transition to make the update more smooth.

Final improvements

Our life expectancy tool is basically ready, it is functionally complete and we could just call it a day. But for a really nice end result we can spice the chart a bit up by fixing the following:

Axes

The axes change along with the data so we cannot really observe the overall trends only countries relative to each other. This can be easily resolved by fixing the axis ranges with the XRange and YRange components. For the sake of simplicity we manually set these values now.

// Set fixed ranges.
.xRange.min(Math.log10(300))
.xRange.max(Math.log10(2e5))
.yRange.min(15)
.yRange.max(90)

Country size

The maximum size of the bubbles is fixed which means that only relative population sizes are displayed. To fix this, we change the max radius to follow the size of the larges country. We note that this country is China for the period covered. To account for the changes, we add this single line to the chart creation as well as to the update:

.radius(2.5e-8 * data[1950].find(d => d.name === 'China').value.size)

This will adjust the max size according to China and therefore correctly scale country sizes in all years.

Legend

It is also good to have a legend to highlight specific continents. This is quite easy with the Legend control widget:

// Map continents to list of countries for the continent highlight.
const countries = Object.entries(continents).reduce((map, d) => {
  if (!map.has(d[1])) {
    map.set(d[1], [])
  }
  map.get(d[1]).push(d[0])
  return map
}, new Map())

// Add legend.
const legend = dalian.Legend('legend', '#chart')
  .x(parseFloat(d3.select('#chart').style('width')) - 30)
  .y(40)
  .width(200)
  .height(100)
  .entries(['Africa', 'Asia', 'Europe', 'North America', 'Oceania', 'South America'])
  .color.palette({
    'Africa': '#4477aa',
    'Asia': '#66ccee',
    'Europe': '#228833',
    'North America': '#ccbb44',
    'Oceania': '#ee6677',
    'South America': '#aa3377'
  })

  // Highlight continents on hover.
  .mouse.over(d => {
    legend.highlight(d, 100)
    chart.highlight(countries.get(d), 100)
  })
  .mouse.leave(() => {
    legend.highlight(null, 100)
    chart.highlight(null, 100)
  })

  // Render legend.
  .render()

And this is how the final chart looks like in less than 150 lines of code:

Final script

Here you can download the full working example as a stand-alone HTML file.