component — charts
Radial Bar Chart
Bars drawn around a circle on the quebi Chart wrapper — concentric rings, a stacked ring, or a gauge with a fixed angular scale and a value in the middle. Requires the recharts package.
- chart
- radial
- gauge
- progress
- polar
- recharts
- data
- visualization
04 examples
One ring per row
`colorByCategory` takes the colour from the row rather than from the series, which is what a single-series ranking wants — and it is what gives the legend, which names the rings here rather than the series, a swatch of its own. Click one to focus a ring.
Gauge
`angleDomain` fixes the scale, so one row sweeps its share of the arc instead of filling it. Without it, a lone value is always 100% of itself.
Half gauge
The same gauge swept over a semicircle by way of the start and end angles.
Stacked
`type="stacked"` puts every series on one ring per row, so each ring reads as a composition. A ring is no longer one colour, so the legend — which names rings, not series — has nothing to show: the tooltip names the series instead.
source
radial-bar-chart.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { type ComponentProps, useMemo } from "react"
import {
Cell,
PolarAngleAxis,
RadialBar,
RadialBarChart as RadialBarChartPrimitive,
} from "recharts"
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"
import {
type BaseChartProps,
Chart,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
constructCategoryColors,
DEFAULT_COLORS,
getColorValue,
} from "@/components/chart"
/**
* RadialBarChart — quebi design system
*
* Bars drawn around a circle, on the quebi `Chart` wrapper. One ring per row of
* `data` and one series per key in `config`; `type="stacked"` puts the series on
* a single ring. Set `angleDomain` to turn it into a gauge — the sweep then
* measures the value against a fixed scale rather than against the other rows —
* and `showLabel` to print the value in the middle.
*
* Unlike every other chart here, recharts builds a radial bar chart's legend
* from the *rows*, not from the series — each ring is a category, so that is
* what a reader needs named. It pairs with `colorByCategory`, which is what
* gives those legend swatches a colour of their own; with several series and no
* `colorByCategory` the rings have no single colour and the legend is better
* turned off, leaving the tooltip to name the series.
*
* Requires the `recharts` npm package as a peer dependency.
*/
function sumNumericArray(arr: number[]): number {
return arr.reduce((sum, num) => sum + num, 0)
}
export interface RadialBarChartProps<TValue extends ValueType, TName extends NameType>
extends Omit<
BaseChartProps<TValue, TName>,
| "hideGridLines"
| "hideXAxis"
| "hideYAxis"
| "xAxisProps"
| "yAxisProps"
| "cartesianGridProps"
| "displayEdgeLabelsOnly"
| "intervalType"
| "layout"
> {
/**
* Fix the angular scale to this `[min, max]` instead of deriving it from the
* data. This is what makes a single row read as a gauge: 72 out of 100 sweeps
* 72% of the arc, rather than filling it because it is the only value there.
*/
angleDomain?: [number, number]
/** Color each *row* from the palette instead of each series. */
colorByCategory?: boolean
/** Draw the unfilled remainder of every ring. */
showBackground?: boolean
/** Print a value in the middle of the chart. */
showLabel?: boolean
/** Text for the centered label. Defaults to the total of the first series. */
label?: string
/** Second line under the centered label. */
labelDescription?: string
innerRadius?: number | string
outerRadius?: number | string
startAngle?: number
endAngle?: number
barSize?: number
cornerRadius?: number
radialBarProps?: Partial<ComponentProps<typeof RadialBar>>
chartProps?: Omit<ComponentProps<typeof RadialBarChartPrimitive>, "data">
}
export function RadialBarChart<TValue extends ValueType, TName extends NameType>({
data = [],
dataKey,
colors = DEFAULT_COLORS,
type = "default",
config,
children,
// Components
tooltip = true,
tooltipProps,
legend = true,
legendProps,
angleDomain,
colorByCategory = false,
showBackground = true,
showLabel = false,
label,
labelDescription,
innerRadius = "30%",
outerRadius = "95%",
startAngle = 90,
endAngle = -270,
barSize,
cornerRadius = 4,
valueFormatter = (value: number) => value.toString(),
radialBarProps,
chartProps,
overlays,
...props
}: RadialBarChartProps<TValue, TName>) {
// With `colorByCategory` the config doubles as a category-to-colour map, the
// way PieChart's does — so an entry named after a *row* is not a series. Only
// the keys the rows actually carry a value for become bars; with no rows yet
// there is nothing to check against, so every entry stands.
const configEntries = useMemo(() => {
const entries = Object.entries(config)
if (data.length === 0) {
return entries
}
const seriesEntries = entries.filter(([key]) => data.some((row) => key in row))
return seriesEntries.length > 0 ? seriesEntries : entries
}, [config, data])
const seriesKeys = useMemo(() => configEntries.map(([key]) => key), [configEntries])
const categoryColors = useMemo(
() => constructCategoryColors(seriesKeys, colors),
[seriesKeys, colors],
)
const stacked = type === "stacked" || type === "percent"
// Recharts builds *this* chart's legend from the rows rather than from the
// series, and it reads each row's `name` — so rows named by any other key all
// collapse onto one legend entry, and React sees a repeated key. Normalizing
// the field is what makes one legend item per ring. `fill` is the same story:
// the legend swatch is the row's own colour, which only exists once the rows
// are coloured rather than the series.
const rows = useMemo<Record<string, unknown>[]>(
() =>
data.map((row, index) => ({
...row,
name: row.name ?? row[dataKey],
...(colorByCategory
? {
fill: getColorValue(
config[row[dataKey] as string]?.color ?? colors[index % colors.length],
),
}
: {}),
})),
[data, dataKey, colorByCategory, config, colors],
)
// The centered label defaults to the total of the first series, which for the
// one-row gauge is simply that row's value.
const centerLabel = useMemo(() => {
const [firstKey] = seriesKeys
if (label || !firstKey) {
return label ?? ""
}
return valueFormatter(sumNumericArray(rows.map((row) => Number(row[firstKey]) || 0)))
}, [label, seriesKeys, rows, valueFormatter])
return (
<Chart config={config} data={rows} dataKey={dataKey} layout="radial" {...props}>
{({ onLegendSelect, selectedLegend }) => (
<RadialBarChartPrimitive
onClick={() => {
onLegendSelect(null)
}}
data={rows}
innerRadius={innerRadius}
outerRadius={outerRadius}
startAngle={startAngle}
endAngle={endAngle}
barSize={barSize}
margin={{
bottom: 0,
left: 0,
right: 0,
top: 0,
}}
{...chartProps}
>
{angleDomain && (
<PolarAngleAxis type="number" domain={angleDomain} tick={false} axisLine={false} />
)}
{showLabel && (
<text
className="fill-quebi-fg font-display font-extralight"
data-slot="label"
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
>
<tspan className="text-2xl">{centerLabel}</tspan>
{labelDescription && (
<tspan
className="fill-quebi-fg-subtle font-sans text-quebi-caption"
x="50%"
dy="1.6em"
fontWeight="normal"
>
{labelDescription}
</tspan>
)}
</text>
)}
{legend && (
<ChartLegend
content={typeof legend === "boolean" ? <ChartLegendContent /> : legend}
{...legendProps}
/>
)}
{tooltip && (
<ChartTooltip
content={
typeof tooltip === "boolean" ? (
<ChartTooltipContent hideLabel labelSeparator={false} accessibilityLayer />
) : (
tooltip
)
}
{...tooltipProps}
/>
)}
{!children
? configEntries.map(([category, values]) => {
const color = getColorValue(values.color || categoryColors.get(category))
// The legend names rings, not series, so a selection is a row
// name here — dimming every series because none of them matched
// it would blank the chart on the first click.
const dimmed =
selectedLegend !== null &&
seriesKeys.includes(selectedLegend) &&
selectedLegend !== category
return (
<RadialBar
key={category}
name={category}
dataKey={category}
stackId={stacked ? "stack" : undefined}
background={showBackground}
cornerRadius={cornerRadius}
fill={color}
fillOpacity={dimmed ? 0.1 : 1}
isAnimationActive
{...radialBarProps}
>
{colorByCategory &&
rows.map((row, index) => {
const rowName = String(row.name ?? index)
const rowDimmed =
selectedLegend !== null &&
!seriesKeys.includes(selectedLegend) &&
selectedLegend !== rowName
return (
<Cell
key={`${category}-${rowName}`}
fill={String(row.fill)}
fillOpacity={rowDimmed ? 0.15 : 1}
/>
)
})}
</RadialBar>
)
})
: children}
{overlays}
</RadialBarChartPrimitive>
)}
</Chart>
)
}