Donut Chart
Traffic Sources
Organic Search
30,000 page views●10%
Traffic Sources
Organic Search
30,000 page views●10%
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
- 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
- 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: stringlabel: stringvalue: numberunit: stringunknown?: boolean}interface ItemWithColor extends Item {color: string}interface DonutChartProps {title: stringitems: Item[]width?: numberheight?: numbermargin?: { 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) returnsetHoveredItem(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"><Donutdata={itemsWithColor}width={width}height={height}margin={margin}activeId={active.id}onMouseEnterDatum={onMouseEnterDatum}onMouseLeaveDatum={onMouseLeaveDatum}/></div><TimertickDuration={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.divstyle={style}className="flex justify-between"><div className="flex gap-2 items-center"><divclassName="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: numberinterval: numberonTick: () => voidpause: 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 | nullwidth: numberheight: numbermargin: { top: number; right: number; bottom: number; left: number }onMouseEnterDatum: (arc: ItemWithColor) => voidonMouseLeaveDatum: () => void}function Donut({data,width,height,margin,activeId,onMouseEnterDatum,onMouseLeaveDatum,}: DonutProps) {const innerWidth = width - margin.left - margin.rightconst innerHeight = height - margin.top - margin.bottomconst radius = Math.min(innerWidth, innerHeight) / 2const centerY = innerHeight / 2const centerX = innerWidth / 2const thickness = 10const 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}><Piedata={data}pieValue={(d) => d.value}cornerRadius={10}padAngle={0.01}startAngle={startAngle}endAngle={endAngle}pieSort={null}pieSortValues={null}>{(pie) => {return (<AnimatedPiearcs={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>) => stringradius: numberthickness: numberactiveId: string | nullonMouseEnterDatum: (arc: ItemWithColor) => voidonMouseLeaveDatum: () => 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 (<Arcarc={arc}key={key}radius={radius}thickness={thickness}activeId={activeId}onMouseEnterDatum={() => onMouseEnterDatum(arc.data)}onMouseLeaveDatum={onMouseLeaveDatum}{...props}/>)})}interface ArcProps {arc: PieArcDatum<ItemWithColor>radius: numberthickness: numberopacity: SpringValue<number>activeId: string | nullonMouseEnterDatum: () => voidonMouseLeaveDatum: () => void}function Arc({radius,thickness,arc,activeId,opacity,onMouseEnterDatum,onMouseLeaveDatum,}: ArcProps) {const duration = 200const 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.pathd={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,}}/><pathonMouseEnter={onMouseEnterDatum}onMouseLeave={onMouseLeaveDatum}d={clonePathD ?? undefined}fill={arc.data.color}className="opacity-0"/></g>)}
Usage
To use the Donut Chart component, follow these steps:
- Import the DonutChart component
import { DonutChart } from "./path-to/DonutChart"
- 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 itemlabel: string, // Label or name for the itemvalue: number, // Numeric value representing the item's dataunit: string, // Unit of measurement for the valueunknown?: boolean // (Optional) Indicates if the item is unknown (defaults to false)}
- Use the DonutChart component in your JSX code, passing in the required props:
<DonutCharttitle="Donut Chart Title"items={dataItems} // Replace with your data arraywidth={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