Visualizing Data with React and SVG

Although I am a great fan of D3.js when it comes to data visualization and have used it with great success in the past, it might not be the best tool for the job in all cases. In applications where the advanced visualization features of D3 are not needed and React is in use so that efficient mapping of data to DOM is taken care of, the extra learning curve of D3 might not be justified.

This post is a step-by-step introduction to building a simple line chart that visualizes the price of a stock over a couple of days. It is assumed that the reader has experience with React and modern JavaScript, basic knowledge of SVG is useful too. The impatient can find a link to the entire working solution in a Git repo at the end.

To get started, we need some data:

const data = [
  { day: new Date("2020-01-06"), price: 299.799988 },
  { day: new Date("2020-01-07"), price: 298.390015 },
  { day: new Date("2020-01-08"), price: 303.190002 },
  // ...
];

The fundamental first step to visualizing data is establishing a scale that allows mapping the abstract data (time and price in our example) into a visual representation.

For our use case SVG elements offer a convenient way to render our data onto a two-dimensional canvas. The coordinate system of an SVG image looks like this:

SVG Grid
Image source: Wikipedia

To get started we will show individual data points - stock prices on a given day - as small circles using a simple React component:

function LineChart({ data, width, height }) {
  // Calculate the extent of the data
  // (not the most efficient way but easily readable)
  const minDay = Math.min(...data.map(({ day }) => day));
  const maxDay = Math.max(...data.map(({ day }) => day));
  const minPrice = Math.min(...data.map(({ price }) => price));
  const maxPrice = Math.max(...data.map(({ price }) => price));

  // Create the horizontal and vertical scale functions
  const x = day =>
     width * ((day - minDay) / (maxDay - minDay));
  const y = price =>
    height - height * ((price - minPrice) / (maxPrice - minPrice));

  return (
    <svg width={width} height={height}>
      {data.map(({ day, price }) => (
        <circle key={day} cx={x(day)} cy={y(price)} r="4" />
      ))}
    </svg>
  );
}

The most interesting part are the scale functions: x(someDate) returns the x coordinate in pixels for a date and y(somePrice) returns the y coordinate in pixels for a price. This means <circle cx={x(date)} cy={y(price)}/> will be rendered to the right place in our two-dimensional space. The resulting SVG looks like this:

To make the chart less empty and allow reading price change trends easier we will connect the circles with a line, turning it into a line chart:

return (
  <svg>
    { /* ... */ }
    <path
      strokeWidth="2"
      fill="none"
      d={line(
        data.map(({ day, price }) => ({
          x: x(day),
          y: y(price)
        }))
      )}
    />
  </svg>
)

To create a <path> that follows a certain course we need to pass in the path coordinates in the d attribute. It is a string of path comands and can be generated using a small helper function:

// Returns a string like M1,2L3,4L5,6
const line = data =>
  "M" + data.map(({ x, y }) => x + "," + y).join("L");

Our chart should look much nicer now:

Because a chart without labels is not a chart (can still be a piece of art but we are doing data visualization here), we will have to make further changes.

But first, in order to prevent our main render function from growing too large, we break it up into a couple of tiny components:

function LineChart({ data, width, height }) {
  // ...

  return (
    <svg width={width} height={height}>
      <Circles data={data} x={x} y={y} />
      <Line data={data} x={x} y={y} />
    </svg>
  );
}

const Circles = ({ data, x, y }) => (
  <g>
    {data.map(({ day, price }) => (
      <circle key={day} cx={x(day)} cy={y(price)} r="4" />
    ))}
  </g>
);

const Line = ({ data, x, y }) => (
  <path
    strokeWidth="2"
    fill="none"
    d={line(
      data.map(({ day, price }) => ({
        x: x(day),
        y: y(price)
      }))
    )}
  />
);

To make room for the labels along the x and y axes we need to slightly shrink the actual line chart and then move it a bit towards the bottom and right. We do this using the translate(x, y) transformation which moves it’s target by x horizontally and by y vertically.

const padding = 40;

function LineChart({
  data,
  width: outerWidth,
  height: outerHeight
}) {

  // Accommodate for the room around the coordinate system
  const width = outerWidth - 2 * padding;
  const height = outerHeight - 2 * padding;

  // Create the horizontal and vertical scale functions
  const x = day => width * ((day - minDay) / (maxDay - minDay));
  const y = price =>
    height - height * ((price - minPrice) / (maxPrice - minPrice));

  // ...

  return (
    <svg width={outerWidth} height={outerHeight}>
      {/* Move down and right to leave room for labels */}
      <g transform={translate(padding, padding)}>
        <Circles data={data} x={x} y={y} />
        <Line data={data} x={x} y={y} />
      </g>
    </svg>
  );
}

// Create SVG translate functions in a readable way
const translate = (x, y) => `translate(${x}, ${y})`;

And now we can add labels along the axes positioned using translate() to the left and bottom side. The x axis is easier to implement:

// ...
return (
  { /* ... */ }
  <XAxis
    data={data}
    x={x}
    transform={translate(padding, outerHeight - padding / 2)}
  />
);

const XAxis = ({ data, x, ...props }) => (
  <g {...props}>
    {data.map(({ day }) => (
      <text key={day} x={x(day)} textAnchor="middle">
        {formatDate(day)}
      </text>
    ))}
  </g>
);

// Return short date like "Jan 10"
const formatDate = day =>
  day.toLocaleDateString("en-US", {
    day: "numeric",
    month: "short",
    timeZone: "UTC"
  });

Because price along the y axis is a continuous value we need to come up with our own discrete values (“ticks”) for placing the labels.

// Calculate ticks for y axis labels
const ticksCount = 5;
const yTicks = [
  ...range(
    // Start from the lowest price
    Math.floor(minPrice),
    // End at the highest price
    Math.ceil(maxPrice),
    // Divide the range into equal parts
    Math.round(
      (Math.ceil(maxPrice) - Math.floor(minPrice)) / ticksCount
    )
  )
];

return (
  { /* ... */ }
  <YAxis
    values={yTicks}
    y={y}
    transform={translate(padding / 2, padding)}
  />
);

const YAxis = ({ values, y, ...props }) => (
  <g {...props}>
    {values.map((price, i) => (
      <text key={i} y={y(price)} textAnchor="middle">
        {price}
      </text>
    ))}
  </g>
);

// Generate numeric values within a range
function* range(min, max, step) {
  for (let i = min; i < max; i += step) {
    yield i;
  }
}

The final result will look like this:

Jan 6Jan 7Jan 8Jan 9Jan 10Jan 13Jan 14Jan 15Jan 16Jan 17298302306310314318

There are may ways how this basic chart can be improved and I have not mentioned testing or usability of the components at all (might do it in a follow-up post). I hope that the basic concepts introduced are a good start for your next data visualization in React!

Once things get more complicated however, I strongly recommend looking at D3. It has amazing documentation, lots of interactive of examples and supports many advanced techniques and optimizations that are hard to get right when writing them from scratch. In fact, the whole approach taken here was inspired with my previous experience with D3.

D3 is also available as small modules which means even if you use React for managing the DOM, you can use part of D3 under the hood. For example the x and y scale functions are available in the standalone d3-scale module, or the <path> commands generator in d3-shape.

You can see the chart component from above running here and the full source can be found in a Git repo.