Here I render the PieChart. The component takes only one prop - a list of items, where an item is an object with a numerical value and a color.
The pie chart container is a regular div
element that takes the full size of the parent. To access it's bounding box, we'll wrap it with BoundingBoxAware
component. You can learn more about it here.
import { useMemo } from "react"
import { useTheme } from "styled-components"
import { BoundingBoxAware } from "ui/BoundingBoxAware"
import { HSLA } from "ui/colors/HSLA"
import { VStack } from "ui/Stack"
import { sum } from "utils/generic"
import { SvgArc } from "./SvgArc"
import { SvgDisk } from "./SvgDisk"
export interface PieChartItem {
value: number
color: HSLA
}
interface Props {
items: PieChartItem[]
}
const cutoutRadiusShare = 0.68
const totalDegrees = 360
const spaceBetweenInDegrees = 0.8
interface PieChartItemWithAngle extends PieChartItem {
startAngle: number
endAngle: number
}
const getItemsWithAngles = (items: PieChartItem[]): PieChartItemWithAngle[] => {
const total = sum(items.map((item) => item.value))
const itemsWithAngles: PieChartItemWithAngle[] = []
items.forEach((item, index) => {
const startAngle = index === 0 ? 0 : itemsWithAngles[index - 1].endAngle
const endAngle = startAngle + (item.value / total) * totalDegrees
itemsWithAngles.push({
...item,
startAngle,
endAngle,
})
})
return itemsWithAngles
}
export const PieChart = ({ items }: Props) => {
const itemsWithAngles = useMemo(() => getItemsWithAngles(items), [items])
const { colors } = useTheme()
return (
<BoundingBoxAware
render={({ setElement, boundingBox }) => {
const renderContent = () => {
if (!boundingBox) return
const diameter = Math.min(boundingBox.width, boundingBox.height)
const radius = diameter / 2
const cutoutRadius = radius * cutoutRadiusShare
return (
<svg style={{ width: diameter, height: diameter }}>
{items.length < 2 ? (
<SvgDisk
color={
items.length === 0 ? colors.backgroundGlass : items[0].color
}
radius={radius}
cutoutRadius={cutoutRadius}
/>
) : (
itemsWithAngles.map(
({ color, startAngle, endAngle }, index) => {
return (
<SvgArc
key={index}
color={color}
radius={radius}
cutoutRadius={cutoutRadius}
startAngle={startAngle}
endAngle={endAngle - spaceBetweenInDegrees}
/>
)
}
)
)}
</svg>
)
}
return (
<VStack fullWidth fullHeight ref={setElement}>
{boundingBox && renderContent()}
</VStack>
)
}}
/>
)
}
We'll make a diameter the smallest size of the parent rectangle. The cutout radius is an empty part of the donut. We'll make it 0.68 of radius size.
If there's only one item, we'll render a disk. Otherwise, we'll make an arc for each item.
To render the disk, we'll use a circle SVG element with a stroke width of radius minus cutout radius. I use an HSLA format for colors in my project. If you're curious, check the link in the description.
import { HSLA } from "ui/colors/HSLA"
interface Props {
color: HSLA
radius: number
cutoutRadius: number
}
export const SvgDisk = ({ color, radius, cutoutRadius }: Props) => (
<circle
stroke={color.toCssValue()}
strokeWidth={radius - cutoutRadius}
fill="transparent"
r={radius - (radius - cutoutRadius) / 2}
cx={radius}
cy={radius}
/>
)
To render arcs, we need to calculate start and end angles for every item. First, we get a sum of every item's value - it would be 100% of the pie chart. To get those angles, we go over every item. If it's the first one, the start angle is zero. Otherwise, it's the last item's end angle. To calculate the end angle, we convert the item's value to the angle, and add it to the start angle.
import { degreesToRadians } from "shared/utils/degreesToRadians"
import { HSLA } from "ui/colors/HSLA"
interface Props {
color: HSLA
startAngle: number
endAngle: number
radius: number
cutoutRadius: number
}
const polarToCartesian = (
radius: number,
cutoutRadius: number,
angleInDegrees: number
) => {
const angleInRadians = degreesToRadians(angleInDegrees - 90)
return {
x: radius + cutoutRadius * Math.cos(angleInRadians),
y: radius + cutoutRadius * Math.sin(angleInRadians),
}
}
const getArcPath = (
radius: number,
cutoutRadius: number,
startAngle: number,
endAngle: number
) => {
const start = polarToCartesian(radius, radius, endAngle)
const end = polarToCartesian(radius, radius, startAngle)
const largeArcFlag = endAngle - startAngle <= 180 ? 0 : 1
const start2 = polarToCartesian(radius, cutoutRadius, endAngle)
const end2 = polarToCartesian(radius, cutoutRadius, startAngle)
return [
"M",
start.x,
start.y,
"A",
radius,
radius,
0,
largeArcFlag,
0,
end.x,
end.y,
"L",
radius,
radius,
"Z",
"M",
start2.x,
start2.y,
"A",
cutoutRadius,
cutoutRadius,
0,
largeArcFlag,
0,
end2.x,
end2.y,
"L",
radius,
radius,
"Z",
].join(" ")
}
export const SvgArc = ({
color,
startAngle,
endAngle,
radius,
cutoutRadius,
}: Props) => {
const path = getArcPath(radius, cutoutRadius, startAngle, endAngle)
return <path fillRule="evenodd" fill={color.toCssValue()} d={path} />
}
To add a little bit of distance between pie slices, we can subtract a small value from every end angle before rendering.
To get a trajectory for the path element, we have the getArcPath function. It converts angles to cartesian coordinates and uses them to draw the path. You can copy the code from the blog post in the description below.