feat(chart): add @hm-design/chart package with Bar, Line, Donut, Pie, Radar, Area and Radial charts
- new workspace package packages/chart (shadcn/ui Chart + Recharts, HM Theme tokens), examples entry with sample data - Storybook Chart group (src/chart), consumer example (examples/chart), build:chart / build:chart-example scripts - README rewritten as the AI usage guide (install, CSS wiring, chart selection, data rules, verification) - docs: package count/list, Submodule guide (chart workspace, React dedupe), shared AI_GUIDE row, MAINTAINING, story inventory
This commit is contained in:
1 parent
228944a06b
commit
dc9258599c
56 files changed
+3979
-28
No files matched your search
@@ -0,0 +1,289 @@
|
||||
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 muted track, the second (smaller) one is the surface color that cuts it into a ring. */}
|
||||
<PolarGrid
|
||||
gridType="circle"
|
||||
radialLines={false}
|
||||
stroke="none"
|
||||
className="first:fill-muted last:fill-[var(--hm-sem-bg-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-sem-bg-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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user