HomeResume

Donut Chart

Traffic Sources
Organic Search
30,000 page views10%

The Donut Chart component is a customizable data visualization component that allows you to display data in the form of a donut chart. It is built using @visx for rendering the chart and @react-spring/web for animations.

Interactivity

The Donut Chart component supports interactivity, including hovering over chart segments and automatic ticking to cycle through data items.

  • Hover over a chart segment to display information about the corresponding data item.
  • The chart automatically ticks through data items at a specified interval (controlled by the tickDuration prop).

Installation

  1. Before using the Donut Chart component, make sure you have the required dependencies installed in your project:
npm install @react-spring/web @visx/group @visx/shape
  1. Copy and paste the following code into your project.
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
SpringValue,
useSpringValue,
useTransition,
animated,
to,
} from "@react-spring/web"
import { Group } from "@visx/group"
import { Pie, arc as arcPath, degreesToRadians } from "@visx/shape"
import { PieArcDatum } from "@visx/shape/lib/shapes/Pie"
interface Item {
id: string
label: string
value: number
unit: string
unknown?: boolean
}
interface ItemWithColor extends Item {
color: string
}
interface DonutChartProps {
title: string
items: Item[]
width?: number
height?: number
margin?: { top: number; right: number; bottom: number; left: number }
tickDuration?: number
}
const defaultMargin = { top: 5, right: 5, bottom: 5, left: 5 }
const colors = [
"#dc2626",
"#d97706",
"#65a30d",
"#059669",
"#0891b2",
"#2563eb",
"#7c3aed",
"#c026d3",
]
const unknownColor = "#d8dbe1"
function getColorIndex(index: number, maxNumber: number): number {
if (index > maxNumber) {
return getColorIndex(index - maxNumber - 1, maxNumber)
}
return index
}
export function DonutChart({
title,
items,
width = 300,
height = 300,
tickDuration = 3500,
margin = defaultMargin,
}: DonutChartProps) {
const itemsWithColor = useMemo(() => {
return items.map((item, index) => {
return {
...item,
color: item.unknown
? unknownColor
: colors[getColorIndex(index, colors.length - 1)],
}
})
}, [items])
const [activeItem, setActiveItem] = useState<ItemWithColor>(() => {
return itemsWithColor[0]
})
const [hoveredItem, setHoveredItem] = useState<ItemWithColor | null>(null)
const [pause, setPause] = useState(false)
const active = hoveredItem ? { ...hoveredItem } : { ...activeItem }
const onMouseEnterDatum = (arc: ItemWithColor) => {
if (activeItem.id === arc.id) return
setHoveredItem(arc)
setPause(true)
}
const onMouseLeaveDatum = () => {
setHoveredItem(null)
setPause(false)
}
const onTick = () => {
const itemIndex = itemsWithColor.findIndex(
(item) => item.id === activeItem?.id
)
setActiveItem(itemsWithColor[itemIndex + 1] ?? itemsWithColor[0])
}
const transitions = useTransition(active, {
from: { opacity: 0 },
enter: { opacity: 1 },
leave: { opacity: 0 },
exitBeforeEnter: true,
config: {
duration: 250,
},
})
return (
<div className="flex flex-col bg-slate-50 rounded-md text-slate-900 w-full">
<div className="flex px-4 py-3 text-base font-bold border-b border-slate-200">
{title}
</div>
<div className="self-center my-4">
<Donut
data={itemsWithColor}
width={width}
height={height}
margin={margin}
activeId={active.id}
onMouseEnterDatum={onMouseEnterDatum}
onMouseLeaveDatum={onMouseLeaveDatum}
/>
</div>
<Timer
tickDuration={tickDuration}
interval={50}
onTick={onTick}
pause={pause}
/>
<div className="px-4 py-3 border-t border-slate-200 text-sm text-left">
{transitions((style, item) => {
return (
<animated.div
style={style}
className="flex justify-between">
<div className="flex gap-2 items-center">
<div
className="w-5 h-5 rounded-full"
style={{
backgroundColor: item.color,
}}
/>
<div className="font-medium">{item.label}</div>
</div>
<div className="flex items-center gap-2">
<span className="whitespace-nowrap">
{new Intl.NumberFormat("en-US").format(
item.value
)}{" "}
{item.unit}
</span>
<span className="text-[8px]"></span>
<span className="font-bold">10%</span>
</div>
</animated.div>
)
})}
</div>
</div>
)
}
interface TimerProps {
tickDuration: number
interval: number
onTick: () => void
pause: boolean
}
function Timer({ tickDuration, interval, onTick, pause }: TimerProps) {
const intervalRef = useRef<NodeJS.Timeout | null>(null)
const [time, setTime] = useState(0)
const handleStart = useCallback(() => {
intervalRef.current = setInterval(() => {
setTime((prev) => prev + interval)
}, interval)
}, [interval])
const handlePause = useCallback(() => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}, [])
useEffect(() => {
if (time >= tickDuration) {
onTick()
setTime(0)
}
}, [time, tickDuration, onTick])
useEffect(() => {
if (pause) {
handlePause()
} else {
handleStart()
}
return handlePause
}, [pause, handlePause, handleStart])
return null
}
interface DonutProps {
data: ItemWithColor[]
activeId: string | null
width: number
height: number
margin: { top: number; right: number; bottom: number; left: number }
onMouseEnterDatum: (arc: ItemWithColor) => void
onMouseLeaveDatum: () => void
}
function Donut({
data,
width,
height,
margin,
activeId,
onMouseEnterDatum,
onMouseLeaveDatum,
}: DonutProps) {
const innerWidth = width - margin.left - margin.right
const innerHeight = height - margin.top - margin.bottom
const radius = Math.min(innerWidth, innerHeight) / 2
const centerY = innerHeight / 2
const centerX = innerWidth / 2
const thickness = 10
const lastItem = useMemo(() => data?.[data.length - 1], [data])
const totalValue = useMemo(() => {
return data?.reduce((acc, item) => {
return acc + item.value
}, 0)
}, [data])
const startAngle = useMemo(
() => degreesToRadians((360 * (lastItem.value / totalValue)) / 2 + 180),
[lastItem, totalValue]
)
const endAngle = useMemo(
() => degreesToRadians((360 * (lastItem.value / totalValue)) / 2 + 540),
[lastItem, totalValue]
)
return (
<svg width={width} height={height}>
<Group top={centerY + margin.top} left={centerX + margin.left}>
<Pie
data={data}
pieValue={(d) => d.value}
cornerRadius={10}
padAngle={0.01}
startAngle={startAngle}
endAngle={endAngle}
pieSort={null}
pieSortValues={null}>
{(pie) => {
return (
<AnimatedPie
arcs={pie.arcs}
getKey={({ data: { id } }) => id}
radius={radius}
thickness={thickness}
activeId={activeId}
onMouseEnterDatum={onMouseEnterDatum}
onMouseLeaveDatum={onMouseLeaveDatum}
/>
)
}}
</Pie>
</Group>
</svg>
)
}
interface AnimatedPieProps {
arcs: PieArcDatum<ItemWithColor>[]
getKey: (d: PieArcDatum<ItemWithColor>) => string
radius: number
thickness: number
activeId: string | null
onMouseEnterDatum: (arc: ItemWithColor) => void
onMouseLeaveDatum: () => void
}
function AnimatedPie({
arcs,
getKey,
radius,
thickness,
activeId,
onMouseEnterDatum,
onMouseLeaveDatum,
}: AnimatedPieProps) {
const transitions = useTransition(arcs, {
from: {
opacity: 0,
},
enter: {
opacity: 1,
},
update: {
opacity: 1,
},
leave: {
opacity: 0,
},
keys: getKey,
})
return transitions((props, arc, { key }) => {
return (
<Arc
arc={arc}
key={key}
radius={radius}
thickness={thickness}
activeId={activeId}
onMouseEnterDatum={() => onMouseEnterDatum(arc.data)}
onMouseLeaveDatum={onMouseLeaveDatum}
{...props}
/>
)
})
}
interface ArcProps {
arc: PieArcDatum<ItemWithColor>
radius: number
thickness: number
opacity: SpringValue<number>
activeId: string | null
onMouseEnterDatum: () => void
onMouseLeaveDatum: () => void
}
function Arc({
radius,
thickness,
arc,
activeId,
opacity,
onMouseEnterDatum,
onMouseLeaveDatum,
}: ArcProps) {
const duration = 200
const innerRadius = useSpringValue(radius, {
config: {
duration,
},
})
const outerRadius = useSpringValue(radius - thickness, {
config: {
duration,
},
})
const clonePathD = useMemo(() => {
const path = arcPath<PieArcDatum<any>>({
innerRadius: radius + 15,
outerRadius: radius - thickness - 15,
cornerRadius: 10,
padRadius: 0.01,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
})
return path(arc)
}, [arc, radius, thickness])
useEffect(() => {
if (activeId === arc.data.id) {
innerRadius.start(radius + 5)
outerRadius.start(radius - thickness - 5)
} else {
innerRadius.start(radius)
outerRadius.start(radius - thickness)
}
}, [activeId, arc.data.id, innerRadius, outerRadius, radius, thickness])
return (
<g>
<animated.path
d={to(
[innerRadius, outerRadius],
(innerRadius, outerRadius) => {
const path = arcPath<PieArcDatum<any>>({
innerRadius,
outerRadius,
cornerRadius: 10,
padRadius: 0.1,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
})
return path(arc)
}
)}
fill={arc.data.color}
style={{
opacity,
}}
/>
<path
onMouseEnter={onMouseEnterDatum}
onMouseLeave={onMouseLeaveDatum}
d={clonePathD ?? undefined}
fill={arc.data.color}
className="opacity-0"
/>
</g>
)
}

Usage

To use the Donut Chart component, follow these steps:

  1. Import the DonutChart component
import { DonutChart } from "./path-to/DonutChart"
  1. Create an array of data items that you want to display in the donut chart. Each data item should have the following structure:
{
id: string, // Unique identifier for the item
label: string, // Label or name for the item
value: number, // Numeric value representing the item's data
unit: string, // Unit of measurement for the value
unknown?: boolean // (Optional) Indicates if the item is unknown (defaults to false)
}
  1. Use the DonutChart component in your JSX code, passing in the required props:
<DonutChart
title="Donut Chart Title"
items={dataItems} // Replace with your data array
width={300} // Width of the chart (default: 300)
height={300} // Height of the chart (default: 300)
tickDuration={3500} // Duration of automatic tick animation (default: 3500ms)
margin={{
top: 10, // Margin around the chart (default: { top: 5, right: 5, bottom: 5, left: 5 })
right: 10,
bottom: 10,
left: 10,
}}
/>

Example

Here's an example of how to use the Donut Chart component:

import React from "react"
import { DonutChart } from "./path-to/DonutChart"
const items = [
{
id: "item-1",
label: "Organic Search",
value: 30000,
unit: "page views",
},
{
id: "item-2",
label: "Direct",
value: 35000,
unit: "page views",
},
// Add more data items as needed
]
function App() {
return (
<div className="App">
<DonutChart title="Traffic Sources" items={items} />
</div>
)
}
export default App
THE END

We are what we repeatedly do. Excellence, then, is not an act, but a habit. – Aristotle

2023