- @hm-design/chart: add --hm-chart-track (radial tracks, horizontal bar hover band; warm neutral light) and --hm-chart-surface (slice / segment gaps, Dot rings, label halos, radial meter center; white by default) - --hm-chart-bar-name-halo stays an optional override and falls back to --hm-chart-surface; official chart.tsx unchanged - Storybook Chart pages (Bar / Line / Area / Donut / Pie / Radar / Radial): open layout without example cards, 2-4 responsive columns, one sample data notice per page, surface token set to the content background - Same sample data, props, tooltip and interaction as the example cards; examples entry and consumer example unchanged - README: surface / track token usage
290 lines
13 KiB
TypeScript
290 lines
13 KiB
TypeScript
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
|
|
import { cn } from "cn"
|
|
import { Label, LabelList, PolarAngleAxis, PolarGrid, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts"
|
|
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
|
|
import { CenterText, type HMDonutDatum } from "./donut-chart"
|
|
import { seriesColor, seriesToConfig, type ChartSeries } from "./series"
|
|
import { fitText } from "./text-fit"
|
|
|
|
// Radial charts follow the official shadcn/ui examples (chart-radial-simple, -label, -grid, -text, -shape, -stacked): the same
|
|
// angle ranges, radii, background tracks, corner rounding, grid and center text. Radii are pixels, as in the official source.
|
|
// Adjustments for HM: values come from `value`/`id` rows with HM series colors; the angle axis domain is explicit (0 to the largest
|
|
// value or stack total, the same range Recharts picks automatically) so an all-zero data set cannot produce NaN; names and center
|
|
// text are shortened to fit.
|
|
|
|
// Bar thickness is 1.5 times the earlier one (2026-11-06) in every radial chart. Recharts draws a bar over 80% of its band (10%
|
|
// margin each side), so a single bar band 1.5 times as wide gives a 1.5 times thicker bar (text, shape, stacked: the outer radius
|
|
// stays and the inner one moves in).
|
|
// Ring charts with several bars (simple, label, grid) set the bar thickness (`barSize`) and the gap between rings directly: 18px
|
|
// bars and 4px gaps (grid 16.5 and 3, the earlier gaps), and keep the earlier center hole (inner band radius 30), so the outer
|
|
// radius grows to inner + bars x (bar + gap): 140 for five simple / label bars, 127.5 for five grid bars (2026-11-06, the outer
|
|
// radius and chart height limits were lifted for this). Where the box is too narrow (320px screens) the three charts shrink all
|
|
// radii, bar thickness and gaps in proportion (`useFitScale`).
|
|
// The legend is drawn under the chart box, not inside it: the chart box is a fixed height that holds the largest ring, however
|
|
// many lines the legend wraps to.
|
|
const RADIAN = Math.PI / 180
|
|
/** Height of the chart box of every radial chart (px): the largest ring (outer radius 140) plus the 5px chart margins. */
|
|
const CHART_BOX = "aspect-auto h-[300px] w-full"
|
|
const EDGE = 6 // px kept free on both sides of a ring (chart margin 5 + 1)
|
|
const MIN_SCALE = 0.5
|
|
const LABEL_SIZE = 11
|
|
const MIN_LABEL_CHARS = 2
|
|
|
|
type PolarSector = { cx: number; cy: number; innerRadius: number; outerRadius: number; startAngle: number; endAngle: number }
|
|
|
|
/** Name inside the start of its bar, curved along the arc like the official "insideStart" label, shortened to the bar length. */
|
|
function RadialNameLabel({ viewBox, value }: { viewBox?: unknown; value?: unknown }) {
|
|
const sector = viewBox as Partial<PolarSector> | undefined
|
|
if (!sector || sector.innerRadius === undefined || sector.outerRadius === undefined || sector.startAngle === undefined || sector.endAngle === undefined) return null
|
|
const radius = (sector.innerRadius + sector.outerRadius) / 2
|
|
const arcLength = radius * Math.abs(sector.endAngle - sector.startAngle) * RADIAN
|
|
const text = fitText(String(value ?? ""), arcLength - 14, LABEL_SIZE)
|
|
if ([...text].length < MIN_LABEL_CHARS) return null
|
|
return <Label viewBox={viewBox as ComponentProps<typeof Label>["viewBox"]} position="insideStart" value={text} className="hm-radial-name" />
|
|
}
|
|
|
|
/** Legend under the chart box, built from the series (same look as the shadcn legend; it needs no Recharts context). */
|
|
function RadialLegend({ series }: { series: readonly ChartSeries[] }) {
|
|
return (
|
|
<div data-slot="chart-legend-content" className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 pt-3 text-xs">
|
|
{series.map((item, index) => (
|
|
<div key={item.key} className="flex items-center gap-1.5">
|
|
<div className="h-2 w-2 shrink-0 rounded-[2px]" style={{ backgroundColor: seriesColor(index, item.color) }} />
|
|
{item.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Scale (at most 1) that makes a ring of `radius` px fit the width of the element behind `ref`; measured, so it follows the layout. */
|
|
function useFitScale(radius: number) {
|
|
const ref = useRef<HTMLDivElement | null>(null)
|
|
const [scale, setScale] = useState(1)
|
|
useLayoutEffect(() => {
|
|
const node = ref.current
|
|
if (!node) return
|
|
const update = () => {
|
|
const width = node.getBoundingClientRect().width
|
|
if (width > 0) setScale(Math.min(1, Math.max(MIN_SCALE, Math.floor(((width / 2 - EDGE) / radius) * 100) / 100)))
|
|
}
|
|
update()
|
|
const observer = new ResizeObserver(update)
|
|
observer.observe(node)
|
|
return () => observer.disconnect()
|
|
}, [radius])
|
|
return [ref, scale] as const
|
|
}
|
|
|
|
const px = (value: number) => Math.round(value * 10) / 10
|
|
|
|
function maxOf(values: readonly number[]) {
|
|
const max = Math.max(0, ...values.filter(Number.isFinite))
|
|
return max > 0 ? max : 1
|
|
}
|
|
|
|
export type HMRadialBarChartProps = {
|
|
data: readonly HMDonutDatum[]
|
|
/** Official `chart-radial-label`: item names inside the start of each bar. */
|
|
labels?: boolean
|
|
/** Official `chart-radial-grid`: circular grid lines, no background track. */
|
|
grid?: boolean
|
|
/** Background track behind each bar (default on, off with `grid`). */
|
|
background?: boolean
|
|
/** Arc range in degrees. Official: simple/grid 0 to 360, label -90 to 380. */
|
|
startAngle?: number
|
|
endAngle?: number
|
|
/** Pixel radius of the innermost bar band (the center hole). Default 30, as before the thickness change. */
|
|
innerRadius?: number
|
|
/** Pixel radius of the outermost bar band. Default: `innerRadius` + one `barSize + ringGap` per item (five items: 140, grid 127.5). */
|
|
outerRadius?: number
|
|
/** Bar thickness in px. Defaults: 18 (grid 16.5), 1.5 times the earlier 12 (grid 11). */
|
|
barSize?: number
|
|
/** Gap between neighboring bars in px. Defaults: 4 (grid 3), the same as before the thickness change. */
|
|
ringGap?: number
|
|
/** Accessible name of the chart. */
|
|
label: string
|
|
className?: string
|
|
}
|
|
|
|
/** Radial bar chart with one bar per item (official simple, label and grid examples). */
|
|
export function HMRadialBarChart({
|
|
data,
|
|
labels = false,
|
|
grid = false,
|
|
background,
|
|
startAngle,
|
|
endAngle,
|
|
innerRadius = 30,
|
|
outerRadius,
|
|
barSize = grid ? 16.5 : 18,
|
|
ringGap = grid ? 3 : 4,
|
|
label,
|
|
className,
|
|
}: HMRadialBarChartProps) {
|
|
const rows = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
|
const series = data.map(item => ({ key: item.id, label: item.label, color: item.color }))
|
|
const config = seriesToConfig(series)
|
|
const outer = outerRadius ?? innerRadius + Math.max(data.length, 1) * (barSize + ringGap)
|
|
const [fitRef, scale] = useFitScale(outer)
|
|
return (
|
|
<div ref={fitRef} data-slot="chart-radial" className="flex w-full flex-col">
|
|
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
|
|
<RadialBarChart
|
|
accessibilityLayer
|
|
data={rows}
|
|
startAngle={startAngle}
|
|
endAngle={endAngle}
|
|
innerRadius={px(innerRadius * scale)}
|
|
outerRadius={px(outer * scale)}
|
|
>
|
|
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel nameKey="id" />} />
|
|
{grid ? <PolarGrid gridType="circle" /> : null}
|
|
<PolarAngleAxis type="number" domain={[0, maxOf(data.map(item => item.value))]} tick={false} axisLine={false} />
|
|
<RadialBar dataKey="value" background={background ?? !grid} barSize={px(barSize * scale)}>
|
|
{labels ? <LabelList dataKey="label" content={<RadialNameLabel />} /> : null}
|
|
</RadialBar>
|
|
</RadialBarChart>
|
|
</ChartContainer>
|
|
<RadialLegend series={series} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export type HMRadialMeterChartProps = {
|
|
/** The single value; `label` is the center caption's fallback. */
|
|
item: HMDonutDatum
|
|
/** Caption under the value in the center. Defaults to the item's name. */
|
|
caption?: string
|
|
/** Arc range in degrees. Official text: 0 to 250, shape: 0 to 100. */
|
|
startAngle?: number
|
|
endAngle?: number
|
|
/** Pixel radii of the bar band. Text: 75 and 90, shape: 50 and 95 (official 80 / 90 and 65 / 95 were 1.5 times thinner). */
|
|
innerRadius?: number
|
|
outerRadius?: number
|
|
/**
|
|
* Pixel radii of the two filled circles that form the ring track behind the bar (official `polarRadius`), the outer one first.
|
|
* The track is as thick as the bar and lines up with it: the bar fills 80% of its band, so the radii sit 10% of the band inside
|
|
* it. Text: [88.5, 76.5], shape: [90.5, 54.5].
|
|
*/
|
|
trackRadii?: readonly [number, number]
|
|
/** Corner rounding of the bar in px (official text: 10). */
|
|
cornerRadius?: number
|
|
/** Accessible name of the chart. */
|
|
label: string
|
|
className?: string
|
|
}
|
|
|
|
/** Single-value radial chart with the value and caption in the center (official text and shape examples). */
|
|
export function HMRadialMeterChart({
|
|
item,
|
|
caption,
|
|
startAngle = 0,
|
|
endAngle = 250,
|
|
innerRadius = 75,
|
|
outerRadius = 90,
|
|
trackRadii = [88.5, 76.5],
|
|
cornerRadius,
|
|
label,
|
|
className,
|
|
}: HMRadialMeterChartProps) {
|
|
const row = { ...item, fill: `var(--color-${item.id})` }
|
|
const config = seriesToConfig([{ key: item.id, label: item.label, color: item.color }])
|
|
return (
|
|
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
|
|
<RadialBarChart data={[row]} startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
|
|
{/* Official: the first circle is the track (official muted, HM --hm-chart-track), the second (smaller) one is the chart surface color that cuts it into a ring. */}
|
|
<PolarGrid
|
|
gridType="circle"
|
|
radialLines={false}
|
|
stroke="none"
|
|
className="first:fill-[var(--hm-chart-track)] last:fill-[var(--hm-chart-surface)]"
|
|
polarRadius={[...trackRadii]}
|
|
/>
|
|
<PolarAngleAxis type="number" domain={[0, maxOf([item.value])]} tick={false} axisLine={false} />
|
|
<RadialBar dataKey="value" background cornerRadius={cornerRadius} />
|
|
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
|
<Label
|
|
content={({ viewBox }) => {
|
|
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
|
|
return (
|
|
<CenterText
|
|
cx={Number(viewBox.cx)}
|
|
cy={Number(viewBox.cy)}
|
|
innerRadius={Math.min(trackRadii[1], innerRadius)}
|
|
value={item.value}
|
|
caption={caption ?? item.label}
|
|
maxSize={36}
|
|
/>
|
|
)
|
|
}}
|
|
/>
|
|
</PolarRadiusAxis>
|
|
</RadialBarChart>
|
|
</ChartContainer>
|
|
)
|
|
}
|
|
|
|
export type HMRadialStackedChartProps = {
|
|
/** Stacked from the first series outwards along the arc. Official order: mobile (570) first, then desktop (1260). */
|
|
series: readonly ChartSeries[]
|
|
/** One value per series key. */
|
|
row: Readonly<Record<string, number>>
|
|
/** Caption under the total in the center. */
|
|
totalLabel: string
|
|
/** Arc range in degrees. Official: 0 to 180 (half circle). */
|
|
startAngle?: number
|
|
endAngle?: number
|
|
innerRadius?: number
|
|
outerRadius?: number
|
|
/** Accessible name of the chart. */
|
|
label: string
|
|
className?: string
|
|
}
|
|
|
|
/** Stacked radial chart: one row, each series a segment of the same arc, total in the center (official `chart-radial-stacked`). */
|
|
export function HMRadialStackedChart({
|
|
series,
|
|
row,
|
|
totalLabel,
|
|
startAngle = 0,
|
|
endAngle = 180,
|
|
innerRadius = 65,
|
|
outerRadius = 110,
|
|
label,
|
|
className,
|
|
}: HMRadialStackedChartProps) {
|
|
const total = series.reduce((sum, item) => sum + (Number(row[item.key]) || 0), 0)
|
|
return (
|
|
<div data-slot="chart-radial" className="flex w-full flex-col">
|
|
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
|
|
{/* The official chart sits at the middle of its square box; the half circle (outer radius 110) is moved down so it is centered in the 300px box. */}
|
|
<RadialBarChart accessibilityLayer data={[row]} cy="68%" startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
|
|
<ChartTooltip cursor={false} content={<ChartTooltipContent hideLabel />} />
|
|
<PolarAngleAxis type="number" domain={[0, total > 0 ? total : 1]} tick={false} axisLine={false} />
|
|
{series.map(item => (
|
|
<RadialBar
|
|
key={item.key}
|
|
dataKey={item.key}
|
|
stackId="a"
|
|
fill={`var(--color-${item.key})`}
|
|
cornerRadius={5}
|
|
stroke="var(--hm-chart-surface)"
|
|
strokeWidth={2}
|
|
/>
|
|
))}
|
|
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
|
|
<Label
|
|
content={({ viewBox }) => {
|
|
if (!viewBox || !("cx" in viewBox) || !("cy" in viewBox)) return null
|
|
return <CenterText cx={Number(viewBox.cx)} cy={Number(viewBox.cy)} innerRadius={innerRadius} value={total} caption={totalLabel} dy={-14} />
|
|
}}
|
|
/>
|
|
</PolarRadiusAxis>
|
|
</RadialBarChart>
|
|
</ChartContainer>
|
|
<RadialLegend series={series} />
|
|
</div>
|
|
)
|
|
}
|