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,185 @@
|
||||
import { cn } from "cn"
|
||||
import { Pie, PieChart, Sector, usePlotArea, type PieSectorShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { seriesToConfig } from "./series"
|
||||
import { fitText, textWidth } from "./text-fit"
|
||||
|
||||
/** One donut slice. `id` becomes a CSS variable name (`--color-<id>`): letters, digits, `-` or `_` only. */
|
||||
export type HMDonutDatum = {
|
||||
id: string
|
||||
label: string
|
||||
value: number
|
||||
/** Any CSS color. Defaults to the HM series color for the slice position. */
|
||||
color?: string
|
||||
}
|
||||
|
||||
export type HMDonutChartProps = {
|
||||
data: readonly HMDonutDatum[]
|
||||
/** Caption under the total in the donut center. Omit it to hide the center total. */
|
||||
totalLabel?: string
|
||||
/** Id of the selected slice. It is drawn pushed outwards and the center shows its name and value instead of the total.
|
||||
* The chart is controlled: pair it with `onActiveChange` to let clicks change it. Hovering never changes it. */
|
||||
activeId?: string
|
||||
/** Called with the slice id when a slice is clicked (makes the slices clickable). */
|
||||
onActiveChange?: (id: string) => void
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Ring thickness is 1.5 times the earlier one (2026-11-06): the outer radius stays, the inner radius moves in.
|
||||
// Earlier: inner 58%, outer 82% (76% when slices are selectable) of the plot radius, i.e. 24% / 18% thick.
|
||||
const THICKNESS_SCALE = 1.5
|
||||
const LEGACY_INNER_PERCENT = 58
|
||||
const ringRadii = (selectable: boolean) => {
|
||||
const outer = selectable ? 76 : 82
|
||||
return { outer: `${outer}%`, inner: `${outer - (outer - LEGACY_INNER_PERCENT) * THICKNESS_SCALE}%` }
|
||||
}
|
||||
// The gap between slices is the 1 degree padding plus the 2px stroke. A smaller inner radius shortens the arc of that padding at
|
||||
// the middle of the ring, so the padding grows by the same ratio and the gap stays what it was (about 3.7px at the ring middle).
|
||||
const LEGACY_PADDING_ANGLE = 1
|
||||
const paddingAngle = (selectable: boolean) => {
|
||||
const outer = selectable ? 76 : 82
|
||||
const inner = outer - (outer - LEGACY_INNER_PERCENT) * THICKNESS_SCALE
|
||||
return Math.round(LEGACY_PADDING_ANGLE * ((outer + LEGACY_INNER_PERCENT) / (outer + inner)) * 100) / 100
|
||||
}
|
||||
const INNER_RATIO = 0.46// plain ring: inner 46% of the plot radius (used only when the label viewBox carries no radius)
|
||||
const EXPAND = 10 // how far the active slice grows outwards (px)
|
||||
const CENTER_MAX_SIZE = 24
|
||||
const CENTER_MIN_SIZE = 14
|
||||
|
||||
const compact = new Intl.NumberFormat("ko", { notation: "compact", maximumFractionDigits: 1 })
|
||||
|
||||
/** Center text that always stays inside the hole: the value shrinks first, then switches to a compact number, then is cut. */
|
||||
export function CenterText({
|
||||
cx,
|
||||
cy,
|
||||
innerRadius,
|
||||
value,
|
||||
caption,
|
||||
maxSize = CENTER_MAX_SIZE,
|
||||
dy = 0,
|
||||
}: {
|
||||
cx: number
|
||||
cy: number
|
||||
innerRadius?: number
|
||||
value: number
|
||||
caption?: string
|
||||
/** Largest value size in px (default 24). */
|
||||
maxSize?: number
|
||||
/** Vertical shift of the whole text, for centers that are not the middle of the hole. */
|
||||
dy?: number
|
||||
}) {
|
||||
const plot = usePlotArea()
|
||||
// The Label viewBox may not carry the radius; the hole is then INNER_RATIO of the plot radius.
|
||||
const hole = innerRadius !== undefined && innerRadius > 0 ? innerRadius : plot ? (Math.min(plot.width, plot.height) / 2) * INNER_RATIO : 40
|
||||
const maxWidth = Math.max(0, hole * 1.5)
|
||||
const full = value.toLocaleString()
|
||||
let text = full
|
||||
let size = maxSize
|
||||
while (size > CENTER_MIN_SIZE && textWidth(text, size) > maxWidth) size -= 1
|
||||
if (textWidth(text, size) > maxWidth) {
|
||||
text = compact.format(value)
|
||||
size = maxSize
|
||||
while (size > CENTER_MIN_SIZE && textWidth(text, size) > maxWidth) size -= 1
|
||||
if (textWidth(text, size) > maxWidth) text = fitText(text, maxWidth, size)
|
||||
}
|
||||
const shownCaption = caption ? fitText(caption, maxWidth, 12) : ""
|
||||
const baseline = (shownCaption ? cy - 2 : cy + size * 0.35) + dy
|
||||
return (
|
||||
<text x={cx} y={baseline} textAnchor="middle">
|
||||
<title>{caption ? `${caption} ${full}` : full}</title>
|
||||
<tspan data-slot="chart-center-value" x={cx} y={baseline} style={{ fontSize: size }}>
|
||||
{text}
|
||||
</tspan>
|
||||
{shownCaption ? (
|
||||
<tspan data-slot="chart-center-label" x={cx} y={baseline + 20}>
|
||||
{shownCaption}
|
||||
</tspan>
|
||||
) : null}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Donut chart (shadcn Chart + Recharts) with HM series colors, per-slice tooltip, legend and an optional center.
|
||||
* Center: the total with `totalLabel`, or the selected slice's name and value with `activeId`. A total of 0 draws an empty ring.
|
||||
*/
|
||||
export function HMDonutChart({ data, totalLabel, activeId, onActiveChange, label, className }: HMDonutChartProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.value, 0)
|
||||
const slices = data.map(item => ({ ...item, fill: `var(--color-${item.id})` }))
|
||||
const config = seriesToConfig(data.map(item => ({ key: item.id, label: item.label, color: item.color })))
|
||||
const active = activeId === undefined ? undefined : data.find(item => item.id === activeId)
|
||||
const selectable = activeId !== undefined || onActiveChange !== undefined
|
||||
const showCenter = active !== undefined || Boolean(totalLabel)
|
||||
const ring = ringRadii(selectable)
|
||||
|
||||
// Drawn with the first slice (or the empty ring when the total is 0), from the slice's own center. The Label viewBox center
|
||||
// sits half a legend height below the ring center, which a hole as thick as the earlier ones hid but a thicker ring shows
|
||||
// (the caption touched the ring at 320px).
|
||||
const centerText = (props: PieSectorShapeProps, index: number) =>
|
||||
showCenter && index === 0 ? (
|
||||
<CenterText
|
||||
cx={props.cx}
|
||||
cy={props.cy}
|
||||
innerRadius={props.innerRadius}
|
||||
value={active ? active.value : total}
|
||||
caption={active ? active.label : totalLabel}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
className={cn("aspect-auto h-72 w-full sm:h-80", className)}
|
||||
>
|
||||
<PieChart accessibilityLayer>
|
||||
<ChartTooltip content={<ChartTooltipContent hideLabel nameKey="id" />} />
|
||||
<ChartLegend itemSorter={null} content={<ChartLegendContent nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />} />
|
||||
{total <= 0 ? (
|
||||
<Pie
|
||||
data={[{ value: 1 }]}
|
||||
dataKey="value"
|
||||
innerRadius={ring.inner}
|
||||
outerRadius={ring.outer}
|
||||
fill="var(--hm-sem-bg-subtle)"
|
||||
stroke="none"
|
||||
isAnimationActive={false}
|
||||
legendType="none"
|
||||
tooltipType="none"
|
||||
shape={(props: PieSectorShapeProps, index: number) => (
|
||||
<>
|
||||
<Sector {...props} />
|
||||
{centerText(props, index)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
<Pie
|
||||
data={slices}
|
||||
dataKey="value"
|
||||
nameKey="id"
|
||||
innerRadius={ring.inner}
|
||||
// Room for the active slice to grow outwards without leaving the chart.
|
||||
outerRadius={ring.outer}
|
||||
paddingAngle={paddingAngle(selectable)}
|
||||
stroke="var(--hm-sem-bg-surface)"
|
||||
strokeWidth={2}
|
||||
onClick={onActiveChange ? (_slice, index) => onActiveChange(data[index].id) : undefined}
|
||||
shape={(props: PieSectorShapeProps, index: number) => (
|
||||
<>
|
||||
<Sector
|
||||
{...props}
|
||||
outerRadius={props.payload?.id === activeId ? props.outerRadius + EXPAND : props.outerRadius}
|
||||
cursor={onActiveChange ? "pointer" : undefined}
|
||||
/>
|
||||
{total > 0 ? centerText(props, index) : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user