By Paul Scanlon

Create an SVG Doughnut Chart from scratch for your Gatsby blog

Charting libraries are great don’t get me wrong but sometimes… you just need a bloody doughnut! 🍩

This post is largely based on this article by Mark Caron and using this as the foundation i’m going to explain how I created the “tags” chart seen in my dashboard

To start with I created a new React component and called it MrDoughnut and by using useStaticQuery and graphql from gatsby I’m able to query the tags used in my blog posts

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)

  console.log(JSON.stringify(tagData.allMdx.edges, null, 2))

  return <div>MrDoughnut</div>

Which outputs something similar to the below 👇

[
  {
    node: {
      frontmatter: {
        tags: ['JavaScript', 'React', 'Gatsby', 'SVG', 'Netlify Functions', 'GitHub REST'],
      },
    },
  },
  {
    node: {
      frontmatter: {
        tags: ['React'],
      },
    },
  },
  {
    node: {
      frontmatter: {
        tags: ['JavaScript', 'React', 'SVG'],
      },
    },
  },
];

This is a good start but to use this data to drive <MrDoughnut /> it needs to be massaged into a slightly different shape.

Ideally what I need is an object for each tag name containing a value for the tag, count, percentage and remainder.

A shape like this will do the trick 👇

[
  {
    tag: 'React',
    count: 3,
    percentage: x,
    remainder: x,
  },
];

To modify the shape of any data the most common method to use is Array.prototype.reduce

The Data

Step one

Reduce all the tag names into one array

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
-  `)
+  `) .allMdx.edges.reduce((items, item) => {
    const { tags } = item.node.frontmatter
    tags.map((tag) => items.push(tag))
    return items
  }, [])

-  console.log(JSON.stringify(tagData.allMdx.edges, null, 2))
+  console.log(JSON.stringify(tagData, null, 2))

  return <div>MrDoughnut</div>

Which results in an array of strings similar to the below 👇

[
  "Dummy",
  "Tags",
  "JavaScript",
  "React",
  "Gatsby",
  "SVG",
  "Netlify Functions",
  "GitHub REST",
  "React",
  "React",
  "Gatsby",
  "Gatsby Cloud",
  ...

You’ll notice there are duplicates in this array and in step two i’ll count the duplicates which will give me enough data to work out what percentage each tag represents.

Step two

Count the amount of duplicate items and assign a value in a new object key called count and return this value along with the tag name

// MrDoughnut.js


import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)
    .allMdx.edges.reduce((items, item) => {
      const { tags } = item.node.frontmatter
      tags.map((tag) => items.push(tag))
      return items
    }, [])
+    .reduce((items, item) => {
+      const existingItem = items.find((index) => index.tag === item)
+
+      if (existingItem) {
+        existingItem.count += 1
+      } else {
+        items.push({
+          tag: item,
+          count: 1,
+        })
+      }

+      return items
+    }, [])

  console.log(JSON.stringify(tagData, null, 2))

  return <div>MrDoughnut</div>
}

In the above snippet I use array.reduce() in order to determine if it’s an existingItem where i’ll increment the count or return a new item with a count of 1 . Using array.find() I loop over the items to see if the array contains the item passed from the params in the parent array.reduce() 😓

This gives me an output similar to the below 👇

  {
    "tag": "JavaScript",
    "count": 18
  },
  {
    "tag": "React",
    "count": 19
  },
  {
    "tag": "Gatsby",
    "count": 32
  },
  {
    "tag": "SVG",
    "count": 5
  },
  {
    "tag": "Netlify Functions",
    "count": 2
  },

Step three

Using the new count value I need to calculate how this corresponds to the total count from the array which I’ll use to create the percentage value for each tag

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)
    .allMdx.edges.reduce((items, item) => {
      const { tags } = item.node.frontmatter
      tags.map((tag) => items.push(tag))
      return items
    }, [])
    .reduce((items, item) => {
      const existingItem = items.find((index) => index.tag === item)

      if (existingItem) {
        existingItem.count += 1
      } else {
        items.push({
          tag: item,
          count: 1,
        })
      }

      return items
    }, [])
+    .map((item, index, array) => {
+      const { count } = item
+      const countTotal = array.reduce((a, b) => a + b.count, 0)
+      const percentage = (count / countTotal) * 100
+      const remainder = 100 - percentage
+      return {
+        ...item,
+        percentage: percentage,
+        remainder: remainder,
+      }
+    })

  console.log(JSON.stringify(tagData, null, 2))

  return <div>MrDoughnut</div>
}

In this step I use array.map() to loop over the newly constructed name: '', count: '' object and using array.reduce() I sum up all the count values for each tag name to create a countTotal. A percentage can then be calculated by dividing the current count value by the countTotal value and multiplying it by 100. The remainder is calculated in a similar way by subtracting it from 100. I’ll need the remainder later when I come to draw the <circle /> element used in the chart.

The final return statement spreads the item object along with the new percentage and remainder values.

Step four

Return the values with the largest count first

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)
    .allMdx.edges.reduce((items, item) => {
      const { tags } = item.node.frontmatter
      tags.map((tag) => items.push(tag))
      return items
    }, [])
    .reduce((items, item) => {
      const existingItem = items.find((index) => index.tag === item)

      if (existingItem) {
        existingItem.count += 1
      } else {
        items.push({
          tag: item,
          count: 1,
        })
      }

      return items
    }, [])
+    .sort((a, b) => b.count - a.count)
+    .slice(0, 5)
    .map((item, index, array) => {
      const { count } = item
      const countTotal = array.reduce((a, b) => a + b.count, 0)
      const percentage = (count / countTotal) * 100
      const remainder = 100 - percentage
      return {
        ...item,
        percentage: percentage,
        remainder: remainder,
      }
    })

  console.log(JSON.stringify(tagData, null, 2))

  return <div>MrDoughnut</div>
}

Using array.sort() I return the array ordered by the largest count in ascending order. I’ve added an array.slice(0,5) because I’m only concerned with the top five results

Now that I have the data in the correct shape it’s time to tackle the Doughnut Chart itself.

The general idea is to use array.map() again but this time in Jsx to loop over each of the tag objects and return a <circle /> for each one.

By using a combination of CSS values; strokeDashoffset and strokeDasharray I’m able to determine where to start and end the stroke

The Chart

Return a circle for each tag item and set a stroke color

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)
    .allMdx.edges.reduce((items, item) => {
      const { tags } = item.node.frontmatter
      tags.map((tag) => items.push(tag))
      return items
    }, [])
    .reduce((items, item) => {
      const existingItem = items.find((index) => index.tag === item)

      if (existingItem) {
        existingItem.count += 1
      } else {
        items.push({
          tag: item,
          count: 1,
        })
      }

      return items
    }, [])
    .sort((a, b) => b.count - a.count)
    .slice(0, 5)
    .map((item, index, array) => {
      const { count } = item
      const countTotal = array.reduce((a, b) => a + b.count, 0)
      const percentage = (count / countTotal) * 100
      const remainder = 100 - percentage
      return {
        ...item,
        percentage: percentage,
        remainder: remainder,
      }
    })

  console.log(JSON.stringify(tagData, null, 2))

+ const colors = ["#ff6090", "#3f51b5", "#00bcd4", "#8bc34a", "#ffc107"]

-  return <div>MrDoughnut</div>

+  return (
+    <div
+      style={{
+        width: 300,
+      }}
+    >
+      <svg width="100%" height="100%" viewBox="0 0 40 40">
+        {tagData.map((tag, index) => {
+          return (
+            <circle
+              key={index}
+              cx="20"
+              cy="20"
+              r="15.91549430918954"
+              strokeWidth="6"
+              fill="transparent"
+              stroke={colors[index]}
+            />
+          )
+        })}
+      </svg>
+    </div>
+  )
}

In this step I return an svg with a viewBox property of 0,0,40,40, to be honest this value doesn’t really matter because the s in svg stands for scalable however, it is important that the cx and cy values for each circle are half the viewBox value, e.g 20

The svg has a width and height of 100% which means it will fill 100% of whatever it’s contained within. In this case I have a parent div with a width of 300

The r value is where things get a bit mathematical. r is the radius of the circle.

As Mark Caron mentions in this article keeping things human readable helps with the complexity and basing everything off 100 goes someway to achieving this.

The calculation to create the r value looks like this 100/(2π) which roughly translates to 100 divided by 2, multiplied by pi, where pi is approximately 3.14… or in actual numbers 100 / 6.28

For the stroke color i’ve defined an array of colours that’s the same length as the tagData

The next step is a bit tricky and to prevent the strokes from overlapping I’m using strokeDashoffset and strokeDasharray to determine how much of the circumference of the <circle /> the stroke should cover and where the stroke should start

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)
    .allMdx.edges.reduce((items, item) => {
      const { tags } = item.node.frontmatter
      tags.map((tag) => items.push(tag))
      return items
    }, [])
    .reduce((items, item) => {
      const existingItem = items.find((index) => index.tag === item)

      if (existingItem) {
        existingItem.count += 1
      } else {
        items.push({
          tag: item,
          count: 1,
        })
      }

      return items
    }, [])
    .sort((a, b) => b.count - a.count)
    .slice(0, 5)
    .map((item, index, array) => {
      const { count } = item
      const countTotal = array.reduce((a, b) => a + b.count, 0)
      const percentage = (count / countTotal) * 100
      const remainder = 100 - percentage
      return {
        ...item,
        percentage: percentage,
        remainder: remainder,
      }
    })

  console.log(JSON.stringify(tagData, null, 2))

  const colors = ["#ff6090", "#3f51b5", "#00bcd4", "#8bc34a", "#ffc107"]

  return (
    <div
      style={{
        width: 300,
      }}
    >
      <svg width="100%" height="100%" viewBox="0 0 40 40">
        {tagData.map((tag, index) => {
          const { percentage, remainder } = tag

          return (
            <circle
              key={index}
              cx="20"
              cy="20"
              r="15.91549430918954"
+             strokeDasharray={`${percentage} ${remainder}`}
+             strokeDashoffset={100 - tagData.slice(0, index).reduce((a, b) => a + b.percentage, 0) + 25}
              strokeWidth="6"
              fill="transparent"
              stroke={colors[index]}
            />
          )
        })}
      </svg>
    </div>
  )
}

Here I’m using the percentage and the remainder to determine how much of the circle the stroke should cover and to determine where the stroke starts. To calculate these values here’s the formula 👇

(circumference) - (all preceding segments percentage) + (starting position)

  • The circumference = 100
  • All preceding segments percentage = a sum of a slice of the total tagData percentage
  • The start position = 25 and is to offset the start point for each stroke so it’s at the top of the circle

The legend

A lot of charting libraries will plot a title next to each segments to help users identify what each segment represents along with a value. However, and i’ve wrestled with this so many times because I like to develop mobile first and more often than not this approach results in text too small to read on smaller screens.

Instead, I generally opt for positioning a color coded legend below the chart to ensure the text always remains legible.

The complete component now looks like the below 👇 and the src can be found here

// MrDoughnut.js

import React from "react"
import { useStaticQuery, graphql } from "gatsby"

export const MrDoughnut = () => {
  const tagData = useStaticQuery(graphql`
    query TagsQuery {
      allMdx(filter: { frontmatter: { tags: { ne: null } } }) {
        edges {
          node {
            frontmatter {
              tags
            }
          }
        }
      }
    }
  `)
    .allMdx.edges.reduce((items, item) => {
      const { tags } = item.node.frontmatter
      tags.map((tag) => items.push(tag))
      return items
    }, [])
    .reduce((items, item) => {
      const existingItem = items.find((index) => index.tag === item)

      if (existingItem) {
        existingItem.count += 1
      } else {
        items.push({
          tag: item,
          count: 1,
        })
      }

      return items
    }, [])
    .sort((a, b) => b.count - a.count)
    .slice(0, 5)
    .map((item, index, array) => {
      const { count } = item
      const countTotal = array.reduce((a, b) => a + b.count, 0)
      const percentage = (count / countTotal) * 100
      const remainder = 100 - percentage
      return {
        ...item,
        percentage: percentage,
        remainder: remainder,
      }
    })

- console.log(JSON.stringify(tagData, null, 2))

  const colors = ["#ff6090", "#3f51b5", "#00bcd4", "#8bc34a", "#ffc107"]

  return (
+    <div
+      style={{
+        margin: "0 auto",
+        width: 300,
+      }}
+    >
     <div
      style={{
        position: 'relative',
-       width: 300
       }}
      >
        <svg width="100%" height="100%" viewBox="0 0 40 40">
          {tagData.map((tag, index) => {
            const { percentage, remainder } = tag

            return (
              <circle
                key={index}
                cx="20"
                cy="20"
                r="15.91549430918954"
                strokeDasharray={`${percentage} ${remainder}`}
                strokeDashoffset={
                  100 -
                  tagData
                    .slice(0, index)
                    .reduce((a, b) => a + b.percentage, 0) +
                  25
                }
                strokeWidth="6"
                fill="transparent"
                stroke={colors[index]}
              />
            )
          })}
        </svg>
+       <div
+         style={{
+            position: "absolute",
+            transform: "translate(-50%, -50%)",
+            top: "50%",
+            left: "50%",
+            textAlign: "center",
+          }}
+        >
+          <div
+            style={{
+              fontSize: "20px",
+              fontWeight: "bold",
+              lineHeight: "14px",
+            }}
+          >
+            Top 5 tags
+          </div>
+          <a
+            href="https://paulie.dev"
+            target="_blank"
+            rel="noreferrer"
+            style={{ color: "#ff6090" }}
+          >
+            paulie.dev
+          </a>
+        </div>
      </div>
+      <div>
+        {tagData.map((item, index) => {
+          const { tag, percentage } = item
+          return (
+            <div
+              key={index}
+              style={{
+                alignItems: "center",
+                display: "grid",
+                gridTemplateColumns: "1fr auto",
+              }}
+            >
+              <div
+                style={{
+                  alignItems: "center",
+                  display: "grid",
+                  gridGap: 8,
+                  gridTemplateColumns: "12px auto",
+                }}
+              >
+                <div
+                  style={{
+                    width: 12,
+                    height: 12,
+                    borderRadius: "100%",
+                    backgroundColor: colors[index],
+                  }}
+                />
+                <div>{tag}</div>
+              </div>
+              <div>{`${Math.abs(percentage).toFixed(2)}%`}</div>
+            </div>
+          )
+        })}
      </div>
+    </div>
  )
}

The Result

Finally, here’s the result 🦜

Hey!

Leave a reaction and let me know how I'm doing.

  • 0
  • 0
  • 0
  • 0
  • 0
Powered byNeon