OwnKng

Faceting charts with visx and CSS grid

How to create faceted line charts

Published4 January 2021
Data viz

GDP Per Capita ($)

United States2000010k20k30k40k50k60k
United Kingdom2000010k20k30k40k50k60k
Japan2000010k20k30k40k50k60k
Korea, Rep.2000010k20k30k40k50k60k
Chile2000010k20k30k40k50k60k
China2000010k20k30k40k50k60k
Mexico2000010k20k30k40k50k60k
Indonesia2000010k20k30k40k50k60k
Nigeria2000010k20k30k40k50k60k
India2000010k20k30k40k50k60k

Visx is a collection of visualisation packages for React developed by Airbnb. The power of visx is that it combines low-level primitives for constructing data visulisations out of basic geometries and scales, while also providing high-level components and functions to handle the more finicky elements of creating graphs for the web - such as tooltips, legends or resizing. This makes visx a highly expressive library for data visualisation, while also conforming with the declarative syntax of React.

I’ve written a more extensive introduction to visx. This post is to intended to instead demonstrate how to create a visualisation feature I use extensively when working with R and ggplot: facets.

A faceted visualisation (also known as small multiple charts) is composed of a matrix of plot panels where each plot shares the same x and y variables. Each panel represents another variable that the observations share.

In the example above, time is represented on the x axis; GDP per capita ($) is represented on the y axis. Each facet represents a country. This is an alternative to a line graph in which we encode country using different colors and plot onto a single panel, which with ten countries would be difficult to do effectively.

Another advantage we have when using facets is that we can meaningfully order them. Here, our facets are ordered by GDP per capita in 2019. This makes it easy to discern that India is the poorest country in our sample, and that Japan has a higher GDP per capita than South Korea.

Creating a faceted visualisation with visx

The finished visualisation we’re going to create, the code and data are all available here. The data comes from the World Bank Open Data portal.

We’ll need to install the visx packages for this project. Airbnb have provided visx as several standalone packages, but as we’ll be using many of them in this project the easiest way to install them is by running the following command.

npm i @visx/visx

We’re also going to be using some functions from the D3 library, so we’ll need to install D3 too.

npm i d3

Creating our lines

When developing a visualisation with visx, I’ve found it helpful to divide the process into four fundamental stages that we repeat for all our projects. These are:

  • Set the chart dimensions
  • Create accessor functions to select our data
  • Create scales to translate our data into coordinates
  • Return the visualisation

After we’ve done these tasks, we can then work on any elements that are more specific to a particular visualisation, like interactions or specific layouts.

Set the chart dimensions

Let’s create a new function component in our React application. This function component will take several props - data, width, height and margin (which we’ll provide with some sensible default values). Add the following code in a new file called Line.js.

import React from "react"

const Line = ({
  data,
  width,
  height,
  margin = { top: 40, left: 10, right: 15, bottom: 25 },
}) => {
  // Set dimensions
  const innerWidth = width - margin.left - margin.right
  const innerHeight = height - margin.top - margin.bottom

  // Create accessor functions

  // Create scales

  // Return our chart
  return <svg width={width} height={height}></svg>
}

export default Line

Create accessor functions to select our data

Our visualisations are going have year on the x axis and GDP per capita on the y axis. Let’s create some accessor functions to make selecting these values easier.

// create accessor functions
const xAccessor = (d) => d.year;
const yAccessor = (d) => d.gdpPerCap;
...

Create scales to translate our data into coordinates

We’ll need to convert our year and GDP per capita values to units of pixels that fit inside our <svg> element. We’ll do this with the scaleLinear() function from @visx/scale

Import the scaleLinear() function into our Line.js file.

import { scaleLinear } from "@visx/scale"

We can then use this function to create our scales. Note that we’ve provided fixed values to the domain argument, rather than mapping them to the minimum and maximum of the values. This is because we want all our lines to share the same scales to make comparisons across the charts simpler.

// Create scales
const xScale = scaleLinear({
  range: [margin.left, innerWidth + margin.left],
  domain: [1960, 2019],
  nice: true,
})

const yScale = scaleLinear({
  range: [innerHeight + margin.top, margin.top],
  domain: [0, 70000],
  nice: true,
})

Return the visualisation

With our accessor functions and scales created, we can now return the visalisation from our <Line /> component.

Import the geometry primitives into our Line.js file, as well as the curve type we’re going to use in the visulisation.

import { LinePath, AreaClosed } from "@visx/shape"
import { curveLinear } from "@visx/curve"

We can now use these geometries inside our return statement, providing them with the scales and accessors we’ve defined.

...
// Return our chart
return (
  <svg width={width} height={height}>
    <AreaClosed
      data={data}
      x={(d) => xScale(xAccessor(d))}
      y={(d) => yScale(yAccessor(d))}
      yScale={yScale}
      curve={curveLinear}
      fill='#ffcb8f'
    />
    <LinePath
      data={data}
      x={(d) => xScale(xAccessor(d))}
      y={(d) => yScale(yAccessor(d))}
      curve={curveLinear}
      stroke='black'
    />
  </svg>
);

Setting up our facets

We’ll create another function component to house our facets. This component will supply our data to the <Line /> component.

Create a new file called FacetLineChart.js and add the following code.

import React from "react"
import Line from "./Line"
import { group } from "d3"
import { gdpPerCapData } from "./data"

const FacetLineChart = () => {
  const data = gdpPerCapData

  const dataGrouped = Array.from(
    group(data, (d) => d.country),
    ([key, value]) => ({ key, value })
  )

  return (
    <>
      {dataGrouped.map((data, i) => (
        <Line key={i} data={data.value} width={800} height={400} />
      ))}
    </>
  )
}

export default FacetLineChart

The above code makes use of the group() function from D3. The group() function takes an array and a key, and returns a map from the key to the array values.

By wrapping this in Array.from(), we’ve constructed a nested array in which each element represents a country. Inside each element is another array with all the values for that country. This allows us to map through the array, and supply the values individually as the data prop to our <Line /> component.

However, if we run this code, we’ll see that our lines are stacked on top of each other, rather than paginated in panels. To construct our facets, we’re going to add a wrapper and apply some CSS.

  return (
    <>
      <div className="grid">
        {dataGrouped.map((data, i) => (
          <Line key={i} data={data.value} width={800} height={400} />
        ))}
      </div>
    </>

Add the following CSS in our styles.css file.1

.grid {
  display: grid;
  height: 600px;
  width: 100%;
  row-gap: 10px;

  grid-template-columns: repeat(5, minmax(100px, 1fr));
  grid-template-rows: repeat(2, minmax(100px, 1fr));
}

.grid > div {
  position: relative;
}

@media only screen and (max-width: 600px) {
  .grid {
    grid-template-columns: repeat(2, minmax(100px, 1fr));
    grid-template-rows: repeat(5, minmax(100px, 1fr));
    height: 800px;
  }
}

This CSS arranges each plot in a grid. The width of the grid will fill 100% of its parent container, but the height will change between 600px and 800px depending on the screen size. Our media query will also rearrange our grid for smaller screens so that on a mobile device the grid is arranged vertically, rather than horizontally.

We’ll now want to make our individual lines responsive so that they fill the dimensions of our grid cells. In our FacetLineChart.js file, import the <ParentSize /> component from @visx/responsive library.

import ParentSize from "@visx/responsive/lib/components/ParentSize"

Then amend the return statement to use the the <ParentSize /> component, which supplies the width and height props to our <Line /> component.

return (
  <>
    <div className='grid'>
      {dataGrouped.map((data, i) => (
        <ParentSize key={i}>
          {({ width, height }) => (
            <Line data={data.value} width={width} height={height} />
          )}
        </ParentSize>
      ))}
    </div>
  </>
)

Improving our line charts

Now we have our facets set up, we’ll add axes to the visualisations and a facet label to each chart to show which country the data is showing. We’ll also reorder our visualisations from richest country to poorest.

Adding axes

Adding axes to our chart is extremely simple using the @visx/axis package. We’re also going to nicely format our axes text with the format() function from D3.

Add the following imports to the Line.js file.

import { AxisLeft, AxisBottom } from "@visx/axis"
import { format } from "d3"

We’ll then modify our return statement to include our axes.

  return (
    <svg width={width} height={height}>
      ...
      <AxisBottom
        scale={xScale}
        top={innerHeight + margin.top}
        tickFormat={format("d")}
        numTicks={4}
      />
      <AxisLeft
        scale={yScale}
        left={margin.left}
        tickFormat={format("~s")}
        numTicks={8}
      />
      ...
  )

Adding facet labels

When we created dataGrouped array, we used the country name as the key for each group. We can provide this key as an additional prop to our <Line /> component.

return (
  ...
  {dataGrouped.map((data, i) => (
  <ParentSize key={i}>
    {({ width, height }) => (
      <Line dataKey={data.key} data={data.value} width={width} height={height} />
    )}
  </ParentSize>
  ))}
  ...
)

We’ll then destructure this prop in our <Line /> component.

const Line = ({
  dataKey,
  data,
  width,
  height,
  margin = { top: 40, left: 40, right: 15, bottom: 25 }
}) => {
  ...
}

To add the dataKey to our line charts, we’ll use the <Text /> component from the @visx/text library. Add the following code to our Line.js file.

import { Text } from "@visx/text";
...

  return (
    <svg width={width} height={height}>
      <Text
        x={width / 2}
        width={width}
        textAnchor="middle"
        y={margin.top / 2}
        fontSize={14}
      >
        {dataKey}
      </Text>
      ...
  )

Reordering our facets

At the moment, our facets are not in any particular order. To aid interpretation, we want our charts to be ordered from the highest GDP per capita to the lowest.

We can do this by creating a new array for country names - ordered by GDP per capita - and then using this in a sort() method on our original data array.

const FacetLineChart = () => {
  let data = gdpPerCapData;

  // Create a new array of country names from richest to poorest
  const order = data
    .filter((row) => row.year === 2019)
    .sort((a, b) => a.gdpPerCap - b.gdpPerCap)
    .map((row) => row.country);

  // Sort our data array using our ordered country names
  data = data.sort(
    (a, b) => order.indexOf(b.country) - order.indexOf(a.country)
  );

  ...
}

Adding a tooltip

Lastly, it’s helpful to add a tooltip to each of our charts to show the GDP per capita figure when we hover over a particular location.

In our Line.js file, add the following imports.

import React, { useCallback } from "react"
import { format, min, max } from "d3"
import { useTooltip, TooltipWithBounds } from "@visx/tooltip"
import { localPoint } from "@visx/event"

We’ll use the useTooltip() hook from @visx/tooltip, which provides several functions to position our tooltip and set the data to show.

const {
  showTooltip,
  hideTooltip,
  tooltipData,
  tooltipLeft = 0,
  tooltipTop = 0,
} = useTooltip()

Let’s write an event handler that will get the coordinates of our hover position and translate it into an x and y value to display in our tooltip.

const handleTooltip = useCallback(
  (event) => {
    const { x } = localPoint(event) || { x: 0 };
    let x0 = xScale.invert(x);
    x0 = Math.round(x0);
    if (x0 > max(data, xAccessor)) x0 = max(data, xAccessor);
    if (x0 < min(data, xAccessor)) x0 = min(data, xAccessor);

    let d = data.filter((row) => row.year === x0);
    let yMax = max(d, yAccessor);

    showTooltip({
      tooltipData: d,
      tooltipLeft: xScale(x0),
      tooltipTop: yScale(yMax),
    });
  },
  [data, showTooltip, yScale, xScale]
);

return (
  ...

Note that the if statements are necessary here because our axes run to 2020 (where we currently have no data). We also don’t have any data for Indonesia before 1967. The if statements handle this by returning the closest value to the missing year - so if we hover over 2020, our tooltip will stay at 2019 rather than showing an empty container.

In our return statement, we can now add a <rect> element which will register hovering or a touch event.

When tooltipData is not empty, we’ll also add a <circle> to show the point that the user is hovering over.

return (
  <svg width={width} height={height}>
  ...
  <rect
        x={margin.left}
        y={margin.top}
        width={innerWidth}
        height={innerHeight}
        fill="transparent"
        onTouchStart={handleTooltip}
        onTouchMove={handleTooltip}
        onMouseMove={handleTooltip}
        onMouseLeave={() => hideTooltip()}
      />
      {tooltipData &&
        tooltipData.map((row) => (
          <circle
            cx={xScale(xAccessor(row))}
            cy={yScale(yAccessor(row))}
            r={5}
            stroke="black"
            fill="#ffcb8f"
            strokeWidth={2}
            pointerEvents="none"
          />
        ))}
    </svg>
  );
)

Finally, we’ll add our tooltip using the <TooltipWithBounds /> component. Our tooltip only shows the GDP per capita value, but could easily be expanded to show additional data.

Note that our tooltip goes outside our closing <svg> tag, and so we need to wrap our whole return statement in a React fragment.

return (
  <>
    <svg width={width} height={height}>
      ...
    </svg>
    {tooltipData && (
      <TooltipWithBounds
        key={Math.random()}
        top={tooltipTop - 12}
        left={tooltipLeft + 12}
      >
        {tooltipData.map((row) => format("$.2~s")(yAccessor(row)))}
      </TooltipWithBounds>
    )}
  </>
)

And that’s it. The completed code for our faceted visualisation is available here.

Footnotes

  1. Or wherever styles are housed in your application. Because visx isn’t opinionated about styling, it works well with Styled Components, which is my preferred CSS in JavaScript library.

Edit this article on GitHub