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 🦜