DEV Community

Cover image for React + D3 Sunburst Chart ☀️
Andriy Chemerynskiy
Andriy Chemerynskiy

Posted on • Updated on

React + D3 Sunburst Chart ☀️

In this post, I am going to show you how you can build a sunburst chart (or any chart) using React and D3.

Power of D3 and React 💪

D3 is the king of data visualisation. It appeared around 10 years ago and there are still not so many libraries that can compete with it.

What is more, most of JavaScript data visualisations libraries are built on top of D3, because it is low level and can be customized however you want.

React and D3 integration

If you look into D3 code samples you might notice that it looks similar to... Yes, jQuery! It is not only visualization library but JavaScript library for manipulating documents based on data.

There are 3 ways of integrating React and D3:

  • D3-oriented approach: D3 manages the chart
  • React-oriented approach: React manages the chart
  • Hybrid approach: React for element creation, D3 for updates

More info

One of the key benefits of managing the chart using D3 is that we can easily add transitions, but in this tutorial, we would rely on a React-oriented approach as we would not need transitions (at least yet 🌚).

React.js + D3.js meme

Why not use existing React based component libraries?

Actually, you can (maybe you even should). There are many existing libraries with great API that would allow you creating different charts with low effort.

However, sometimes you might get stuck if that library doesn't support the feature (or chart) you want.

If you want to have full control over your visualisation then you should do it using D3.

Building sunburst chart 👨🏼‍💻

I know that many of you prefer to dive right into the code.

Here is codesandbox with full code for this tutorial:

Finding D3 sunburst chart code

Cool thing about D3 is that it has hundreds of visualisations with code for it. All you need to do is just google it:
Screen

We would use the second link as it is a simpler example: https://observablehq.com/@d3/sunburst

screen

This code might scare you in the beginning but it is okay. You don't have to understand every line of it. Our goal is to integrate it into React.

Basic setup

Building our chart would start with adding svg ref:

import React from "react";

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

  return <svg ref={svgRef} />;
};
Enter fullscreen mode Exit fullscreen mode

We are going to add width (we will name it SIZE) and radius (we will name it RADIUS) from code sample.

screen

import React from "react";
+ const SIZE = 975;
+ const RADIUS = SIZE / 2;

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

-  return <svg ref={svgRef} />;
+  return <svg width={SIZE} height={SIZE} ref={svgRef} />;
};
Enter fullscreen mode Exit fullscreen mode

This chart uses json data and we are going to download it and add into our app.

screen

import React from "react";
+ import data from "./data.json";
const SIZE = 975;
const RADIUS = SIZE / 2;

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

  return <svg width={SIZE} height={SIZE} ref={svgRef} />;
};
Enter fullscreen mode Exit fullscreen mode

D3 manages the chart

Let's install d3 and @types/d3.

npm install d3 @types/d3
Enter fullscreen mode Exit fullscreen mode

When installation is finished, we will put all chart setup code into useEffect with little modifications
screen

import React from "react";
import data from "./data.json";
+ import * as d3 from "d3";

const SIZE = 975;
const RADIUS = SIZE / 2;

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);
+  
+  React.useEffect(() => {
+    const root = partition(data);
+
//   We already created svg element and will select its ref
-    const svg = d3.create("svg");
+    const svg = d3.select(svgRef.current);
+
+    svg
+      .append("g")
+      .attr("fill-opacity", 0.6)
+      .selectAll("path")
+      .data(root.descendants().filter((d) => d.depth))
+      .join("path")
+      .attr("fill", (d) => {
+        while (d.depth > 1) d = d.parent;
+        return color(d.data.name);
+      })
+      .attr("d", arc)
+      .append("title")
+      .text(
+        (d) =>
+          `${d
+            .ancestors()
+            .map((d) => d.data.name)
+            .reverse()
+            .join("/")}\n${format(d.value)}`
+      );
+
+    svg
+      .append("g")
+      .attr("pointer-events", "none")
+      .attr("text-anchor", "middle")
+      .attr("font-size", 10)
+      .attr("font-family", "sans-serif")
+      .selectAll("text")
+      .data(
+        root
+          .descendants()
+          .filter((d) => d.depth && ((d.y0 + d.y1) / 2) * 
+          (d.x1 - d.x0) > 10)
+      )
+      .join("text")
+      .attr("transform", function (d) {
+        const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI;
+        const y = (d.y0 + d.y1) / 2;
+        return `rotate(${
+          x - 90
+        }) translate(${y},0) rotate(${x < 180 ? 0 : 180})`;
+      })
+      .attr("dy", "0.35em")
+      .text((d) => d.data.name);
+
//   We don't need to return svg node anymore
-    return svg.attr("viewBox", getAutoBox).node();
+    svg.attr("viewBox", getAutoBox);
+  }, []);

   return <svg width={SIZE} height={SIZE} ref={svgRef} />;
};
Enter fullscreen mode Exit fullscreen mode

Nice! Let's add missing functions:
screen
screen

...

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);
+
+  const partition = (data) =>
+    d3.partition().size([2 * Math.PI, RADIUS])(
+      d3
+        .hierarchy(data)
+        .sum((d) => d.value)
+        .sort((a, b) => b.value - a.value)
+    );
+
+  const color = d3.scaleOrdinal(
+    d3.quantize(d3.interpolateRainbow,data.children.length+1)
+  );
+
+  const format = d3.format(",d");
+
+  const arc = d3
+    .arc()
+    .startAngle((d) => d.x0)
+    .endAngle((d) => d.x1)
+    .padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
+    .padRadius(RADIUS / 2)
+    .innerRadius((d) => d.y0)
+    .outerRadius((d) => d.y1 - 1);
+ 
// Custom autoBox function that calculates viewBox
// without doing DOM manipulations
-  function autoBox() {
-    document.body.appendChild(this);
-    const {x, y, width, height} = this.getBBox();
-    document.body.removeChild(this);
-    return [x, y, width, height];
-  }
+  const getAutoBox = () => {
+    if (!svgRef.current) {
+      return "";
+    }
+
+    const { x, y, width, height } = svgRef.current.getBBox();
+
+    return [x, y, width, height].toString();
+  };
+
  React.useEffect(() => {
    ...
Enter fullscreen mode Exit fullscreen mode

At this point, we should see our chart:

Beautiful, isn't it? But it is not finished yet. We append chart elements using D3, but we don't handle updating it or cleaning it up.

We can do it in useEffect hook as well and let D3 manage it, but we will do it in React oriented way.

React manages the chart

To have a better developing experience and avoid bugs we are going to fix types issues before we move on.

...

+ interface Data {
+  name: string;
+  value?: number;
+ }

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);

  const partition = (data: Data) =>
-    d3.partition().size([2 * Math.PI, RADIUS])(
+    d3.partition<Data>().size([2 * Math.PI, RADIUS])(
      d3
        .hierarchy(data)
        .sum((d) => d.value)
        .sort((a, b) => b.value - a.value)
    );

...

  const arc = d3
-   .arc()
+   .arc<d3.HierarchyRectangularNode<Data>>()
    .startAngle((d) => d.x0)
    .endAngle((d) => d.x1)
    .padAngle((d) => Math.min((d.x1 - d.x0) / 2, 0.005))
    .padRadius(RADIUS / 2)
    .innerRadius((d) => d.y0)
    .outerRadius((d) => d.y1 - 1);

...
Enter fullscreen mode Exit fullscreen mode

Remove append function and put everything in render

This part is a bit difficult and might require a bit of D3 understanding. What I like to do is to inspect svg element throw DevTools and slowly move everything in render.

screen

As you can see we have 2 groups. The first group keeps all paths and the other one keeps text elements.

screen
screen

And we are going to repeat the same structure 😉

...

  React.useEffect(() => {
    const root = partition(data);

    const svg = d3.select(svgRef.current);
-
-    svg
-      .append("g")
-      .attr("fill-opacity", 0.6)
-      .selectAll("path")
-      .data(root.descendants().filter((d) => d.depth))
-      .join("path")
-      .attr("fill", (d) => {
-        while (d.depth > 1) d = d.parent;
-        return color(d.data.name);
-      })
-      .attr("d", arc)
-      .append("title")
-      .text(
-        (d) =>
-          `${d
-            .ancestors()
-            .map((d) => d.data.name)
-            .reverse()
-            .join("/")}\n${format(d.value)}`
-      );
-
-    svg
-      .append("g")
-      .attr("pointer-events", "none")
-      .attr("text-anchor", "middle")
-      .attr("font-size", 10)
-      .attr("font-family", "sans-serif")
-      .selectAll("text")
-      .data(
-        root
-          .descendants()
-          .filter((d) => d.depth && ((d.y0 + d.y1) / 2) * 
-          (d.x1 - d.x0) > 10)
-      )
-      .join("text")
-      .attr("transform", function (d) {
-        const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI;
-        const y = (d.y0 + d.y1) / 2;
-        return `rotate(${
-          x - 90
-        }) translate(${y},0) rotate(${x < 180 ? 0 : 180})`;
-      })
-      .attr("dy", "0.35em")
-      .text((d) => d.data.name);

    svg.attr("viewBox", getAutoBox);
  }, []);
+
+ const getColor = (d: d3.HierarchyRectangularNode<Data>) => {
+    while (d.depth > 1) d = d.parent;
+    return color(d.data.name);
+   };
+
+ const getTextTransform = 
+ (d: d3.HierarchyRectangularNode<Data>) => {
+    const x = (((d.x0 + d.x1) / 2) * 180) / Math.PI;
+    const y = (d.y0 + d.y1) / 2;
+    return `rotate(${x - 90}) translate(${y},0) rotate(${x < + 180 ? 0 : 180})`;
+  };
+
+  const root = partition(data);

  return (
    <svg width={SIZE} height={SIZE} ref={svgRef}>
+      <g fillOpacity={0.6}>
+        {root
+          .descendants()
+          .filter((d) => d.depth)
+          .map((d, i) => (
+            <path 
+              key={`${d.data.name}-${i}`}
+              fill={getColor(d)}
+              d={arc(d)}
+             >
+              <text>
+                {d
+                  .ancestors()
+                  .map((d) => d.data.name)
+                  .reverse()
+                  .join("/")}
+                \n${format(d.value)}
+              </text>
+            </path>
+          ))}
+      </g>
+      <g
+        pointerEvents="none"
+        textAnchor="middle"
+        fontSize={10}
+        fontFamily="sans-serif"
+      >
+        {root
+          .descendants()
+          .filter((d) => d.depth && ((d.y0 + d.y1) / 2) * 
+          (d.x1 - d.x0) > 10)
+          .map((d, i) => (
+            <text
+              key={`${d.data.name}-${i}`}
+              transform={getTextTransform(d)}
+              dy="0.35em"
+            >
+              {d.data.name}
+            </text>
+          ))}
+      </g>
    </svg>
  );
};
Enter fullscreen mode Exit fullscreen mode

Awesome, code looks much more readable!

Last thing we are going to do it to pass viewBox value directly without using attr() function.

getAutoBox has to be run only one time and we are going to keep output of this function in the state.


...

export const SunburstChart = () => {
  const svgRef = React.useRef<SVGSVGElement>(null);
+ const [viewBox, setViewBox] = React.useState("0,0,0,0");

...
- React.useEffect(() => {
-  const svg = d3.select(svgRef.current);
-  svg.attr("viewBox", getAutoBox);
- }, []);
+ React.useEffect(() => {
+   setViewBox(getAutoBox());
+ }, []);

...

  return (
    <svg 
     width={SIZE}
     height={SIZE}
+    viewBox={viewBox}
     ref={svgRef}
     >
...
};
Enter fullscreen mode Exit fullscreen mode

Now we have chart fully managed by React with D3 calculations.

Demo + full code: https://codesandbox.io/s/ioop1?file=/src/SunburstChart.tsx


I hope this article was helpful and gave you a basic idea about integrating D3 charts with React 😉

Make sure to follow me as I will post more content related to D3 and React.

Thanks for reading!

Discussion (1)

Collapse
kplc_caxapok profile image
крістінич

Great work!