Create an Svg Doughnut Chart from scratch for your Gatsby blog

Date published: 11-Jan-2021
3 min read / 872 words
Author: Paul Scanlon

JavaScript
React
SVG
Gatsby
CSS

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 ๐Ÿฆœ

Top 5 tags
paulie.dev
Gatsby
35.11%
React
24.47%
JavaScript
23.40%
Theme UI
9.57%
MDX
7.45%


If you've enjoyed this post I'd love to hear from you: @PaulieScanlon