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:
shkim4 committed 2026-11-06 15:52:47 +09:00
1 parent 228944a06b
commit dc9258599c
56 files changed
+3979 -28

No files matched your search

@@ -0,0 +1,144 @@
import { useId, type ComponentProps } from "react"
import { cn } from "cn"
import { Area, AreaChart, CartesianGrid, LabelList, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { POINT_AXIS_PADDING, PointColorTooltip, PointDot, PointValueLabel } from "./line-points"
import { seriesToConfig, type ChartSeries } from "./series"
// Area fill: the series color is strongest at the top of each area and fades towards the bottom. The overlap of two plain areas stays
// readable because each fill is translucent; stacked areas are a bit denser because they never overlap.
const FILL_TOP = 0.5
const FILL_BOTTOM = 0.05
const STACKED_FILL_TOP = 0.65
const STACKED_FILL_BOTTOM = 0.2
export type HMAreaChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
series: readonly ChartSeries[]
/** Line interpolation for every series: `monotone` (default), `linear` or `step`. */
curve?: "monotone" | "linear" | "step"
/** Small Dot on every point (default off; the active point and tooltip always appear on hover). Ignored when `pointLabels` or `pointColorKey` draw their own Dots. */
dots?: boolean
/** Larger Dot and value Label on every point, placed like the Line chart labels (omitted where they would collide). Not for stacked or expanded areas. */
pointLabels?: boolean
/** Per-point colors for Dots, Labels and tooltip indicators; same as `HMLineChart`. */
pointColorKey?: string | Readonly<Record<string, string>>
/** Stack the series: each area sits on the previous one, so the top edge is the running total. */
stacked?: boolean
/** Stack and normalize each category to 100% (Recharts `stackOffset="expand"`). The Y axis shows percent; the tooltip shows the original value and its share. A total of 0 shows 0%. */
expanded?: boolean
/** Accessible name of the chart. */
label: string
className?: string
}
/** Area chart (shadcn Chart + Recharts): vertical gradient fills in HM series colors, plain, stacked or expanded to 100%. */
export function HMAreaChart({
data,
categoryKey,
series,
curve = "monotone",
dots = false,
pointLabels = false,
pointColorKey,
stacked = false,
expanded = false,
label,
className,
}: HMAreaChartProps) {
const scope = `hm-area-${useId().replace(/:/g, "")}`
const gradientId = (key: string) => `${scope}-${key}`
const stack = stacked || expanded
const colorKeys: Readonly<Record<string, string>> =
typeof pointColorKey === "string" ? (series[0] ? { [series[0].key]: pointColorKey } : {}) : (pointColorKey ?? {})
const customPoints = !stack && (pointLabels || Object.keys(colorKeys).length > 0)
const seriesKeys = series.map(item => item.key)
const share: ComponentProps<typeof ChartTooltipContent>["formatter"] = (_value, _name, item) => {
const row = (item?.payload ?? {}) as Record<string, unknown>
const key = String(item?.dataKey ?? "")
const total = seriesKeys.reduce((sum, k) => sum + (Number(row[k]) || 0), 0)
const value = Number(row[key]) || 0
const percent = total > 0 ? Math.round((value / total) * 100) : 0
return (
<div className="flex w-full items-center gap-2">
<div className="h-2.5 w-2.5 shrink-0 rounded-[2px]" style={{ backgroundColor: item?.color }} />
<span className="text-muted-foreground">{series.find(s => s.key === key)?.label ?? key}</span>
<span className="ml-auto font-medium text-foreground tabular-nums">{value.toLocaleString()} · {percent}%</span>
</div>
)
}
return (
<ChartContainer
config={seriesToConfig(series)}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<AreaChart
accessibilityLayer
data={data as Record<string, string | number>[]}
margin={{ top: 24, right: 12, left: 0, bottom: 0 }}
stackOffset={expanded ? "expand" : undefined}
>
<defs>
{series.map(item => (
<linearGradient key={item.key} id={gradientId(item.key)} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: stack ? STACKED_FILL_TOP : FILL_TOP }} />
<stop offset="100%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: stack ? STACKED_FILL_BOTTOM : FILL_BOTTOM }} />
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} />
{/* Inner padding keeps the first and last Dot, Label and active point from being clipped at the plot edge. */}
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} padding={{ left: POINT_AXIS_PADDING, right: POINT_AXIS_PADDING }} />
<YAxis
tickLine={false}
axisLine={false}
tickMargin={4}
width={expanded ? 44 : 40}
domain={expanded ? [0, 1] : undefined}
tickFormatter={expanded ? (value: number) => `${Math.round(value * 100)}%` : undefined}
/>
<ChartTooltip
cursor
content={
expanded
? <ChartTooltipContent indicator="line" formatter={share} />
: Object.keys(colorKeys).length > 0 && customPoints
? <PointColorTooltip indicator="line" colorKeys={colorKeys} />
: <ChartTooltipContent indicator="line" />
}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{series.map((item, index) => {
const color = `var(--color-${item.key})`
const colorKey = colorKeys[item.key]
return (
<Area
key={item.key}
dataKey={item.key}
type={curve}
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId(item.key)})`}
stackId={stack ? "stack" : undefined}
dot={customPoints ? <PointDot colorKey={colorKey} fallback={color} /> : dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
activeDot={customPoints ? <PointDot colorKey={colorKey} fallback={color} radius={6} /> : { r: 5 }}
>
{customPoints && pointLabels ? (
<LabelList
dataKey={item.key}
content={<PointValueLabel data={data} seriesKeys={seriesKeys} seriesIndex={index} curve={curve} colorKey={colorKey} fallback={color} />}
/>
) : null}
</Area>
)
})}
</AreaChart>
</ChartContainer>
)
}
@@ -0,0 +1,74 @@
import { useId } from "react"
import { cn } from "cn"
import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
import { seriesLightColor, seriesToConfig, type ChartSeries } from "./series"
export type HMBarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
series: readonly ChartSeries[]
/** Stack the series instead of grouping them. */
stacked?: boolean
/** Accessible name of the chart. */
label: string
className?: string
}
/**
* Vertical bar chart (shadcn Chart + Recharts) with HM series colors and a vertical gradient per bar, tooltip and legend.
* Each bar (each segment when stacked) paints its own `linearGradient` in objectBoundingBox units, so the gradient follows
* that bar's height. The `Bar` itself keeps the solid HM color, which the legend and tooltip indicators use.
*/
export function HMBarChart({ data, categoryKey, series, stacked = false, label, className }: HMBarChartProps) {
// Unique per chart instance so several charts on one page never share gradient ids.
const gradientScope = `hm-bar-${useId().replace(/:/g, "")}`
const gradientId = (key: string) => `${gradientScope}-${key}`
return (
<ChartContainer
config={seriesToConfig(series)}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<BarChart accessibilityLayer data={data as Record<string, string | number>[]} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid vertical={false} />
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
<defs>
{series.map((item, index) => (
<linearGradient key={item.key} id={gradientId(item.key)} x1="0" y1="0" x2="0" y2="1">
{stacked ? (
<>
{/* Stacked segments stay opaque at both ends so gridlines never show through: the series color at the top, the
lighter palette color of the same family at the bottom. */}
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: seriesLightColor(index, item.color, item.lightColor), stopOpacity: 1 }} />
</>
) : (
<>
<stop offset="0%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
<stop offset="100%" style={{ stopColor: `var(--color-${item.key})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
</>
)}
</linearGradient>
))}
</defs>
{series.map((item, index) => (
<Bar
key={item.key}
dataKey={item.key}
fill={`var(--color-${item.key})`}
shape={(props: BarShapeProps) => <Rectangle {...props} fill={`url(#${gradientId(item.key)})`} />}
stackId={stacked ? "stack" : undefined}
radius={stacked ? (index === series.length - 1 ? [4, 4, 0, 0] : 0) : [4, 4, 0, 0]}
/>
))}
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,4 @@
// Shared by the bar charts: the gradient runs from a fully opaque HM color to 30% of it.
// Vertical bars: top 100% -> bottom 30%. Horizontal bars: left 30% -> right 100%.
export const BAR_GRADIENT_FULL_OPACITY = 1
export const BAR_GRADIENT_FADED_OPACITY = 0.3
+380
View File
@@ -0,0 +1,380 @@
"use client"
/* Official shadcn/ui Chart (registry radix-nova, `npx shadcn view chart`, recharts@3.8.0), kept as published.
HM adaptations (the only differences):
- THEMES has no `dark` entry: HM has no Dark theme (docs/guidelines/테마-토큰-가이드.md).
- `data-slot="chart-tooltip-content"` / `"chart-legend-content"` hooks so chart.css can map HM tokens.
- Tooltip values drop `font-mono` so numbers use the HM base font. */
import * as React from "react"
import { cn } from "cn"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "" } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
data-slot="chart-tooltip-content"
className={cn(
"grid min-w-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
data-slot="chart-legend-content"
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -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>
)
}
@@ -0,0 +1,137 @@
import { useId } from "react"
import { cn } from "cn"
import { Bar, BarChart, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
import { seriesToConfig } from "./series"
export type HMHorizontalBarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used as the item name (shown inside the bar, full text in the tooltip). */
categoryKey: string
/** Numeric field of each data row (bar length, shown after the bar end). */
valueKey: string
/** Name of the value, used as the tooltip row label. */
valueLabel: string
/** Any CSS color. Defaults to the first HM series color. */
color?: string
/** Formats the value shown after the bar end. Defaults to `toLocaleString()`. */
valueFormatter?: (value: number) => string
/** Accessible name of the chart. */
label: string
className?: string
}
const FONT_SIZE = 12
const ROW_HEIGHT = 40
const BAR_SIZE = 26
const NAME_PADDING = 10 // space between the bar start / end and the item name
const VALUE_GAP = 8 // space between the bar end and the value
const RIGHT_SAFE = 6 // extra room so the value never touches the chart edge
const CHART_PADDING = 8
// Text is measured by estimate (full-width glyphs 1em, others ~0.6em) so truncation needs no DOM access.
function textWidth(text: string) {
let width = 0
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? FONT_SIZE : FONT_SIZE * 0.6
return width
}
/** Keeps the longest start of `text` (plus an ellipsis) that fits `maxWidth`; "" when not even one character fits. */
function fitText(text: string, maxWidth: number) {
if (textWidth(text) <= maxWidth) return text
const chars = [...text]
for (let count = chars.length - 1; count > 0; count -= 1) {
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
if (textWidth(candidate) <= maxWidth) return candidate
}
return ""
}
/**
* Horizontal bar chart with custom labels (shadcn Chart + Recharts): the item name sits inside the bar at the left, the value
* after the bar end. The gradient runs left 30% -> right 100% of the HM color. Names that do not fit the bar are shortened with
* an ellipsis; a bar too short for any of its name (including zero) shows the name after the value instead. Names never run into
* the value, and the tooltip always shows the full name.
*/
export function HMHorizontalBarChart({
data,
categoryKey,
valueKey,
valueLabel,
color,
valueFormatter = value => value.toLocaleString(),
label,
className,
}: HMHorizontalBarChartProps) {
const gradientId = `hm-hbar-${useId().replace(/:/g, "")}`
const config = seriesToConfig([{ key: valueKey, label: valueLabel, color }])
// Reserve the room the longest value needs on the right so no value is clipped by the chart edge.
const valueRoom = Math.ceil(
Math.max(0, ...data.map(row => textWidth(valueFormatter(Number(row[valueKey]) || 0)))) + VALUE_GAP + RIGHT_SAFE,
)
const renderBar = (props: BarShapeProps) => {
const { x, y, width, height, payload, value, parentViewBox } = props
const name = String(payload?.[categoryKey] ?? "")
const amount = Number(Array.isArray(value) ? value[1] : value) || 0
const barWidth = Math.max(width, 0)
const shownName = fitText(name, barWidth - NAME_PADDING * 2)
const centerY = y + height / 2
const valueText = valueFormatter(amount)
// A bar too short to hold any of its name shows the name after the value instead, shortened to the space left of the plot edge.
const outsideX = x + barWidth + VALUE_GAP + textWidth(valueText) + NAME_PADDING
const outsideName = shownName ? "" : fitText(name, parentViewBox.x + parentViewBox.width - outsideX)
return (
<g role="img" aria-label={`${name}: ${valueText}`}>
{barWidth > 0 ? <Rectangle {...props} fill={`url(#${gradientId})`} /> : null}
{shownName ? (
<text data-slot="chart-bar-name" x={x + NAME_PADDING} y={centerY} dominantBaseline="central">{shownName}</text>
) : null}
<text data-slot="chart-bar-value" x={x + barWidth + VALUE_GAP} y={centerY} dominantBaseline="central">
{valueText}
</text>
{outsideName ? (
<text data-slot="chart-bar-outside-name" x={outsideX} y={centerY} dominantBaseline="central">{outsideName}</text>
) : null}
</g>
)
}
return (
<ChartContainer
config={config}
role="group"
aria-label={label}
// Same default height as the vertical bar charts so cards line up; many rows grow past it via the min-height.
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
style={{ minHeight: data.length * ROW_HEIGHT + CHART_PADDING * 2 }}
>
<BarChart
accessibilityLayer
layout="vertical"
data={data as Record<string, string | number>[]}
margin={{ top: CHART_PADDING, right: valueRoom, bottom: CHART_PADDING, left: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
<stop offset="100%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
</linearGradient>
</defs>
<XAxis type="number" hide domain={[0, "dataMax"]} />
<YAxis type="category" dataKey={categoryKey} hide />
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
barSize={BAR_SIZE}
radius={[0, 4, 4, 0]}
shape={renderBar}
// Labels are positioned from the final bar width, so the bars do not animate in.
isAnimationActive={false}
/>
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,104 @@
import { cn } from "cn"
import { CartesianGrid, LabelList, Line, LineChart, XAxis, YAxis } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { POINT_AXIS_PADDING, PointColorTooltip, PointDot, PointValueLabel } from "./line-points"
import { seriesToConfig, type ChartSeries } from "./series"
export type HMLineChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
series: readonly ChartSeries[]
/** Line interpolation for every series. `monotone` (default) is a smooth curve that never overshoots the data; `linear` joins the points with straight segments; `step` runs horizontal then vertical between points. */
curve?: "monotone" | "linear" | "step"
/** Show the small default Dot on every point (default). `false` draws only the line; the active point and tooltip still appear on hover. Ignored when `pointLabels` or `pointColorKey` draw their own Dots. */
dots?: boolean
/** Draw a larger Dot on every point and its value next to it. With several series the higher value is labelled above its Dot and the lower
* one below, and labels are thinned on narrow charts (the tooltip always shows every value). */
pointLabels?: boolean
/** Per-point colors (CSS colors, e.g. HM palette tokens) for Dots, labels and tooltip indicators; lines and the legend keep the series color.
* A string names the row field for the first series; an object maps a series key to its row field. Implies the larger Dot. */
pointColorKey?: string | Readonly<Record<string, string>>
/** Accessible name of the chart. */
label: string
className?: string
}
/** Line chart (shadcn Chart + Recharts) with HM series colors, line-cursor tooltip and legend. */
export function HMLineChart({
data,
categoryKey,
series,
curve = "monotone",
dots = true,
pointLabels = false,
pointColorKey,
label,
className,
}: HMLineChartProps) {
const colorKeys: Readonly<Record<string, string>> =
typeof pointColorKey === "string" ? (series[0] ? { [series[0].key]: pointColorKey } : {}) : (pointColorKey ?? {})
const customPoints = pointLabels || Object.keys(colorKeys).length > 0
const seriesKeys = series.map(item => item.key)
// A line without Dots keeps the same inner room as the Dot variants, so both align when shown side by side.
const roomy = customPoints || !dots
return (
<ChartContainer
config={seriesToConfig(series)}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<LineChart
accessibilityLayer
data={data as Record<string, string | number>[]}
margin={{ top: roomy ? 24 : 8, right: 12, left: 0, bottom: 0 }}
>
<CartesianGrid vertical={false} />
{/* Inner padding keeps the first and last Dot, label and active point from being clipped at the plot edge. */}
<XAxis
dataKey={categoryKey}
tickLine={false}
axisLine={false}
tickMargin={8}
padding={roomy ? { left: POINT_AXIS_PADDING, right: POINT_AXIS_PADDING } : undefined}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={40} />
<ChartTooltip
cursor
content={
Object.keys(colorKeys).length > 0
? <PointColorTooltip indicator="line" colorKeys={colorKeys} />
: <ChartTooltipContent indicator="line" />
}
/>
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{series.map((item, index) => {
const color = `var(--color-${item.key})`
const colorKey = colorKeys[item.key]
return (
<Line
key={item.key}
dataKey={item.key}
type={curve}
stroke={color}
strokeWidth={2}
dot={customPoints ? <PointDot colorKey={colorKey} fallback={color} /> : dots ? { r: 3, fill: color, strokeWidth: 0 } : false}
activeDot={customPoints ? <PointDot colorKey={colorKey} fallback={color} radius={6} /> : { r: 5 }}
>
{pointLabels ? (
<LabelList
dataKey={item.key}
content={
<PointValueLabel data={data} seriesKeys={seriesKeys} seriesIndex={index} curve={curve} colorKey={colorKey} fallback={color} />
}
/>
) : null}
</Line>
)
})}
</LineChart>
</ChartContainer>
)
}
@@ -0,0 +1,254 @@
import type { ComponentProps } from "react"
import { usePlotArea, useYAxisScale } from "recharts"
import { ChartTooltipContent } from "./chart"
// Custom Dot, value label and tooltip for HMLineChart. They are rendered inside the chart, so they can read the plot area and scales.
/** Space kept between the plot edge and the first / last point so their Dot and label are not clipped. */
export const POINT_AXIS_PADDING = 22
const DOT_RADIUS = 5
const LABEL_GAP = 7 // between the Dot edge and the label
const FAR_LABEL_GAP = 18 // same, for the second try when the near spot touches a line
const CHAR_WIDTH = 7.2 // estimated 12px digit width
const LABEL_ASCENT = 10
const LABEL_DESCENT = 2
type Row = Record<string, unknown>
type Curve = "monotone" | "linear" | "step"
type Point = { x: number; y: number }
type Box = { left: number; right: number; top: number; bottom: number }
type Placement = { x: number; y: number; anchor: "start" | "middle" | "end"; box: Box }
function pointColor(row: Row | undefined, colorKey: string | undefined, fallback: string) {
const own = colorKey ? row?.[colorKey] : undefined
return typeof own === "string" && own ? own : fallback
}
/** Dot with a surface-colored ring: the series color, or the row's own color when `colorKey` is set. */
export function PointDot({
cx,
cy,
payload,
colorKey,
fallback,
radius = DOT_RADIUS,
}: {
cx?: number | string
cy?: number | string
payload?: Row
colorKey?: string
fallback: string
radius?: number
}) {
const x = Number(cx)
const y = Number(cy)
if (!Number.isFinite(x) || !Number.isFinite(y)) return null
return (
<circle
data-slot="chart-point-dot"
cx={x}
cy={y}
r={radius}
fill={pointColor(payload, colorKey, fallback)}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
/>
)
}
function inflate(box: Box, by: number): Box {
return { left: box.left - by, right: box.right + by, top: box.top - by, bottom: box.bottom + by }
}
const inside = (box: Box, x: number, y: number) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom
/** Number of sample points (about every 2px) of the segment that fall inside the box. */
function segmentHits(box: Box, a: Point, b: Point) {
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2))
let hits = 0
for (let i = 0; i <= steps; i += 1) {
const t = i / steps
if (inside(box, a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)) hits += 1
}
return hits
}
function circleTouchesBox(box: Box, c: Point, radius: number) {
const dx = Math.max(box.left - c.x, 0, c.x - box.right)
const dy = Math.max(box.top - c.y, 0, c.y - box.bottom)
return Math.hypot(dx, dy) < radius
}
const boxesOverlap = (a: Box, b: Box) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top)
/** Line segments a series draws between two neighbouring points (a curve is approximated by its chord). */
function connection(a: Point, b: Point, curve: Curve): Array<[Point, Point]> {
if (curve !== "step") return [[a, b]]
const mid = (a.x + b.x) / 2
return [
[a, { x: mid, y: a.y }],
[{ x: mid, y: a.y }, { x: mid, y: b.y }],
[{ x: mid, y: b.y }, b],
]
}
function candidateBoxes(p: Point, width: number, below: boolean): Placement[] {
const above = (gap: number): Placement => {
const baseline = p.y - DOT_RADIUS - gap
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top: baseline - LABEL_ASCENT, bottom: baseline + LABEL_DESCENT } }
}
const under = (gap: number): Placement => {
const top = p.y + DOT_RADIUS + gap
const baseline = top + LABEL_ASCENT
return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top, bottom: baseline + LABEL_DESCENT } }
}
const side = (dir: 1 | -1): Placement => {
const x = p.x + dir * (DOT_RADIUS + 4)
return {
x,
y: p.y + 4,
anchor: dir === 1 ? "start" : "end",
box: { left: dir === 1 ? x : x - width, right: dir === 1 ? x + width : x, top: p.y - 6, bottom: p.y + 6 },
}
}
const [first, second] = below ? [under, above] : [above, under]
return [first(LABEL_GAP), first(FAR_LABEL_GAP), second(LABEL_GAP), second(FAR_LABEL_GAP), side(1), side(-1)]
}
/**
* Value label of a Dot, in the Dot's color.
* - Default side: one series is labelled above its Dot. With several series the higher value at a category goes above and the lower
* below (a tie keeps the series order).
* - The label then takes the first free spot of: default side near, default side farther, the other side near and farther, right,
* left. A spot is free when it touches no line (curve, straight or step), no Dot, no earlier series' label and is neither above
* the plot top nor on the axis labels. With no free spot the label is omitted; the Dot and the tooltip remain.
* - When points are closer than a label is wide (narrow screens), only every n-th label is drawn; the tooltip shows every value.
*/
export function PointValueLabel({
x,
y,
index,
data,
seriesKeys,
seriesIndex,
curve,
colorKey,
fallback,
}: {
x?: number | string
y?: number | string
index?: number
data: ReadonlyArray<Row>
seriesKeys: readonly string[]
seriesIndex: number
curve: Curve
colorKey?: string
fallback: string
}) {
const plot = usePlotArea()
const yScale = useYAxisScale()
const px = Number(x)
const py = Number(y)
const at = index ?? 0
const row = data[at]
if (!Number.isFinite(px) || !Number.isFinite(py) || !row) return null
const textOf = (key: string) => Number(row[key]).toLocaleString()
const spacing = plot && data.length > 1 ? (plot.width - 2 * POINT_AXIS_PADDING) / (data.length - 1) : Infinity
const widest = Math.max(...seriesKeys.flatMap(key => data.map(item => Number(item[key]).toLocaleString().length)))
const step = Math.max(1, Math.ceil((widest * CHAR_WIDTH + 6) / spacing))
if (at % step !== 0) return null
// Pixel position of any series' point at any category, from this point and the evenly spaced category axis.
const scaleOk = typeof yScale === "function" && Number.isFinite(Number(yScale(Number(row[seriesKeys[seriesIndex]])))) &&
Math.abs(Number(yScale(Number(row[seriesKeys[seriesIndex]]))) - py) < 1.5
const pointOf = (seriesNo: number, i: number): Point | undefined => {
if (!scaleOk || !Number.isFinite(spacing)) return undefined
const value = Number(data[i]?.[seriesKeys[seriesNo]])
const py2 = Number(yScale?.(value))
return Number.isFinite(value) && Number.isFinite(py2) ? { x: px + (i - at) * spacing, y: py2 } : undefined
}
const first = Math.max(0, at - 2)
const last = Math.min(data.length - 1, at + 2)
const bottomLimit = plot ? plot.y + plot.height + 4 : Infinity
const topLimit = 2
const cost = (candidate: Placement, taken: Box[]) => {
const box = inflate(candidate.box, 1.5)
let total = 0
if (candidate.box.bottom > bottomLimit || candidate.box.top < topLimit) total += 200
for (const other of taken) if (boxesOverlap(candidate.box, other)) total += 30
for (let s = 0; s < seriesKeys.length; s += 1) {
for (let i = first; i <= last; i += 1) {
const here = pointOf(s, i)
if (!here) continue
if (circleTouchesBox(box, here, DOT_RADIUS + 1)) total += 8
const next = i < last ? pointOf(s, i + 1) : undefined
if (next) for (const [a, b] of connection(here, next, curve)) total += segmentHits(box, a, b)
}
}
return total
}
const place = (seriesNo: number): Placement | null => {
const own = seriesNo === seriesIndex ? { x: px, y: py } : pointOf(seriesNo, at)
if (!own) return null
const taken: Box[] = []
for (let earlier = 0; earlier < seriesNo; earlier += 1) {
const placed = place(earlier)
if (placed) taken.push(placed.box)
}
const mine = Number(row[seriesKeys[seriesNo]])
const below = seriesKeys.some((key, i) => i !== seriesNo && (Number(row[key]) > mine || (Number(row[key]) === mine && i < seriesNo)))
const width = textOf(seriesKeys[seriesNo]).length * CHAR_WIDTH + 2
let best: Placement | null = null
let bestCost = Infinity
for (const candidate of candidateBoxes(own, width, below)) {
const c = cost(candidate, taken)
if (c < bestCost) {
best = candidate
bestCost = c
}
if (c === 0) break
}
// No free spot: omit this label (its Dot and the tooltip stay) rather than touch a line, a Dot or another label.
return bestCost === 0 ? best : null
}
// Without a usable scale (it never happens inside HMLineChart) fall back to "first series above, others below".
const fallbackPlacement = (): Placement => {
const below = seriesIndex > 0
return candidateBoxes({ x: px, y: py }, textOf(seriesKeys[seriesIndex]).length * CHAR_WIDTH + 2, below)[0]
}
const placement = scaleOk ? place(seriesIndex) : fallbackPlacement()
if (!placement) return null
return (
<text
data-slot="chart-point-label"
x={placement.x}
y={placement.y}
textAnchor={placement.anchor}
fill={pointColor(row, colorKey, fallback)}
>
{textOf(seriesKeys[seriesIndex])}
</text>
)
}
/**
* Tooltip whose row indicators use each series' per-point color (`colorKeys`: series key -> row field). The legend and the
* line keep the series color.
*/
export function PointColorTooltip({
colorKeys,
payload,
...props
}: ComponentProps<typeof ChartTooltipContent> & { colorKeys: Readonly<Record<string, string>> }) {
const colored = payload?.map(item => {
const field = colorKeys[String(item.dataKey)]
const color = field ? (item.payload as Row | undefined)?.[field] : undefined
return typeof color === "string" && color ? { ...item, payload: { ...(item.payload as Row), fill: color } } : item
})
return <ChartTooltipContent {...props} payload={colored} />
}
+193
View File
@@ -0,0 +1,193 @@
import type { ComponentProps } from "react"
import { cn } from "cn"
import { Pie, PieChart, usePlotArea, useChartWidth } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import type { HMDonutDatum } from "./donut-chart"
import { seriesToConfig } from "./series"
import { fitText, textWidth } from "./text-fit"
const RADIAN = Math.PI / 180
const LABEL_SIZE = 12
const LINE_ELBOW = 16 // radial length of the connecting line
const LINE_RUN = 10 // horizontal length of the connecting line
const MIN_SHOWN_CHARS = 3
type Bounds = { left: number; right: number; top: number; bottom: number }
type Box = { left: number; right: number; top: number; bottom: number }
type OutsidePlacement = { path: string; x: number; y: number; anchor: "start" | "end"; text: string }
/** Middle angle of every slice (degrees, Recharts order: from 0° counter-clockwise), assuming the default start at 0° and no padding. */
function midAngles(values: readonly number[]) {
const total = values.reduce((sum, value) => sum + Math.max(value, 0), 0)
let acc = 0
return values.map(value => {
const v = Math.max(value, 0)
const mid = total > 0 ? ((acc + v / 2) / total) * 360 : 0
acc += v
return mid
})
}
/**
* Where the "name value" labels go outside the slices, with a connecting line from each slice edge.
* Labels are placed in slice order; a label is omitted when it would leave the chart, run into an earlier label, or
* not even `MIN_SHOWN_CHARS` characters fit. Its slice and the tooltip still carry the full information.
*/
function layoutOutside(items: readonly HMDonutDatum[], cx: number, cy: number, outerRadius: number, bounds: Bounds): Array<OutsidePlacement | null> {
const mids = midAngles(items.map(item => item.value))
const placed: Box[] = []
return items.map((item, index) => {
if (!(item.value > 0)) return null
const cos = Math.cos(-mids[index] * RADIAN)
const sin = Math.sin(-mids[index] * RADIAN)
const edge = { x: cx + outerRadius * cos, y: cy + outerRadius * sin }
const elbow = { x: cx + (outerRadius + LINE_ELBOW) * cos, y: cy + (outerRadius + LINE_ELBOW) * sin }
const side = cos >= 0 ? 1 : -1
const end = { x: elbow.x + side * LINE_RUN, y: elbow.y }
const available = side > 0 ? bounds.right - end.x - 6 : end.x - bounds.left - 6
const text = fitText(`${item.label} ${item.value.toLocaleString()}`, available, LABEL_SIZE)
if ([...text].length < MIN_SHOWN_CHARS) return null
const width = textWidth(text, LABEL_SIZE)
const box: Box = {
left: side > 0 ? end.x + 3 : end.x - 3 - width,
right: side > 0 ? end.x + 3 + width : end.x - 3,
top: end.y - 10,
bottom: end.y + 4,
}
if (box.top < bounds.top || box.bottom > bounds.bottom) return null
if (placed.some(other => !(box.right <= other.left || other.right <= box.left || box.bottom <= other.top || other.bottom <= box.top))) return null
placed.push(box)
return { path: `M${edge.x},${edge.y}L${elbow.x},${elbow.y}L${end.x},${end.y}`, x: end.x + side * 3, y: end.y + 4, anchor: side > 0 ? "start" : "end", text }
})
}
type PieLabelProps = { cx?: number; cy?: number; outerRadius?: number; index?: number; midAngle?: number }
function PieOutsideLabel({ cx, cy, outerRadius, index, items }: PieLabelProps & { items: readonly HMDonutDatum[] }) {
const plot = usePlotArea()
const width = useChartWidth()
if (cx === undefined || cy === undefined || outerRadius === undefined || index === undefined || !plot || !width) return null
const bounds: Bounds = { left: 2, right: width - 2, top: plot.y + 2, bottom: plot.y + plot.height - 2 }
const placement = layoutOutside(items, cx, cy, outerRadius, bounds)[index]
if (!placement) return null
return (
<g>
<path d={placement.path} fill="none" stroke="var(--hm-chart-axis-text)" strokeWidth={1} />
<text data-slot="chart-pie-label" x={placement.x} y={placement.y} textAnchor={placement.anchor}>{placement.text}</text>
</g>
)
}
/** Value inside its slice; omitted when the slice is too narrow at that radius (its name is in the legend, everything in the tooltip). */
function PieSliceLabel({ cx, cy, outerRadius, index, midAngle, items }: PieLabelProps & { items: readonly HMDonutDatum[] }) {
if (cx === undefined || cy === undefined || outerRadius === undefined || index === undefined || midAngle === undefined) return null
const item = items[index]
if (!item || !(item.value > 0)) return null
const total = items.reduce((sum, entry) => sum + Math.max(entry.value, 0), 0)
const radius = outerRadius * 0.62
const text = item.value.toLocaleString()
const arc = radius * (item.value / total) * 2 * Math.PI
if (arc < textWidth(text, LABEL_SIZE) + 8) return null
return (
<text
data-slot="chart-slice-label"
x={cx + radius * Math.cos(-midAngle * RADIAN)}
y={cy + radius * Math.sin(-midAngle * RADIAN)}
textAnchor="middle"
dominantBaseline="central"
>
{text}
</text>
)
}
export type HMPieChartProps = {
data: readonly HMDonutDatum[]
/** `"outside"`: "name value" outside each slice with a connecting line; `"inside"`: the value inside each slice (names in the legend).
* Labels that find no free spot are omitted; the tooltip always has the full information. Omit for a plain pie. */
labels?: "outside" | "inside"
/** Accessible name of the chart. */
label: string
className?: string
}
/** Pie chart (shadcn Chart + Recharts): no center hole, HM series colors, tooltip, legend and optional slice labels. */
export function HMPieChart({ data, labels, label, className }: HMPieChartProps) {
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 outside = labels === "outside"
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" />} />
<Pie
data={slices}
dataKey="value"
nameKey="id"
outerRadius={outside ? "58%" : "80%"}
stroke="var(--hm-sem-bg-surface)"
strokeWidth={2}
label={
outside ? <PieOutsideLabel items={data} /> : labels === "inside" ? <PieSliceLabel items={data} /> : false
}
labelLine={false}
/>
</PieChart>
</ChartContainer>
)
}
export type HMPieRing = {
/** Series id of the ring (not used as a CSS variable). */
id: string
/** Series name shown in the ring key and the tooltip. */
label: string
data: readonly HMDonutDatum[]
}
export type HMStackedPieChartProps = {
/** Inner ring first, outer ring second. Items with the same `id` share one color in both rings. */
rings: readonly [HMPieRing, HMPieRing]
/** Accessible name of the chart. */
label: string
className?: string
}
/** Tooltip row label: the series (ring) name, then the item name and value of the hovered slice. */
function RingTooltip(props: ComponentProps<typeof ChartTooltipContent>) {
const ring = (props.payload?.[0]?.payload as { ringLabel?: string } | undefined)?.ringLabel
return <ChartTooltipContent {...props} label={ring} nameKey="id" indicator="line" />
}
/** Legend: which ring is which series, then the shared item legend (each item once). */
function RingLegend({ rings, items, ...props }: ComponentProps<typeof ChartLegendContent> & { rings: readonly HMPieRing[]; items: readonly HMDonutDatum[] }) {
const payload = items.map(item => ({ id: item.id, value: item.label, color: `var(--color-${item.id})`, type: "square" as const }))
return (
<div data-slot="chart-ring-legend" className="grid gap-1.5">
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-0.5 text-xs text-muted-foreground">
<span>안쪽 링 · {rings[0].label}</span>
<span>바깥쪽 링 · {rings[1].label}</span>
</div>
<ChartLegendContent {...props} payload={payload} nameKey="id" className="flex-wrap gap-x-4 gap-y-1" />
</div>
)
}
/** Two data series as concentric rings (shadcn "pie stacked"): inner disc and outer ring, one color per item across both. */
export function HMStackedPieChart({ rings, label, className }: HMStackedPieChartProps) {
const items: HMDonutDatum[] = []
for (const ring of rings) for (const item of ring.data) if (!items.some(known => known.id === item.id)) items.push(item)
const config = seriesToConfig(items.map(item => ({ key: item.id, label: item.label, color: item.color })))
const slices = rings.map(ring => ring.data.map(item => ({ ...item, fill: `var(--color-${item.id})`, ringLabel: ring.label })))
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={<RingTooltip />} />
<ChartLegend itemSorter={null} content={<RingLegend rings={rings} items={items} />} />
<Pie data={slices[0]} dataKey="value" nameKey="id" outerRadius="46%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} />
<Pie data={slices[1]} dataKey="value" nameKey="id" innerRadius="54%" outerRadius="76%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} />
</PieChart>
</ChartContainer>
)
}
@@ -0,0 +1,92 @@
import { cn } from "cn"
import { PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, useChartWidth } from "recharts"
import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent } from "./chart"
import { seriesToConfig, type ChartSeries } from "./series"
import { fitText, textWidth } from "./text-fit"
const LABEL_SIZE = 12
const LABEL_LINE = 14
/** Up to two lines: words fill the first line, the rest goes on the second line, shortened with an ellipsis if still too long. */
function wrapLabel(text: string, maxWidth: number): string[] {
if (maxWidth <= 0) return []
if (textWidth(text, LABEL_SIZE) <= maxWidth) return [text]
const words = text.split(/\s+/).filter(Boolean)
let count = 0
let first = ""
while (count < words.length) {
const next = first ? `${first} ${words[count]}` : words[count]
if (textWidth(next, LABEL_SIZE) > maxWidth) break
first = next
count += 1
}
if (!first || count >= words.length) return [fitText(text, maxWidth, LABEL_SIZE)].filter(Boolean)
return [first, fitText(words.slice(count).join(" "), maxWidth, LABEL_SIZE)].filter(Boolean)
}
type TickProps = { x?: number | string; y?: number | string; textAnchor?: "start" | "middle" | "end"; payload?: { value?: string | number } }
/** Axis name as a custom label: wrapped to two lines and shortened to the room between the axis end and the chart edge. */
function RadarAxisLabel({ x, y, textAnchor = "middle", payload }: TickProps) {
const width = useChartWidth()
const px = Number(x)
const py = Number(y)
if (!Number.isFinite(px) || !Number.isFinite(py) || !width) return null
const room = textAnchor === "start" ? width - px - 4 : textAnchor === "end" ? px - 4 : Math.min(px, width - px) * 2 - 8
const lines = wrapLabel(String(payload?.value ?? ""), room)
if (lines.length === 0) return null
// Lines grow away from the chart: upwards above the plot, downwards below, centered beside it.
const first = py - ((lines.length - 1) * LABEL_LINE) / 2
return (
<text data-slot="chart-axis-label" x={px} y={first} textAnchor={textAnchor}>
<title>{String(payload?.value ?? "")}</title>
{lines.map((line, i) => (
<tspan key={i} x={px} dy={i === 0 ? 0 : LABEL_LINE}>{line}</tspan>
))}
</text>
)
}
export type HMRadarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used as the axis name. */
categoryKey: string
series: readonly ChartSeries[]
/** Fill each series area (default). `false` draws only the lines. */
filled?: boolean
/** Dot on every axis of every series. */
dots?: boolean
/** Axis names as custom labels: wrapped to two lines, shortened to the room at the chart edge (full name in the label's title and the tooltip). */
customLabels?: boolean
/** Upper end of the value axis, shared by every series (the axis starts at 0). Default 100. */
maxValue?: number
/** Accessible name of the chart. */
label: string
className?: string
}
/** Radar chart (shadcn Chart + Recharts): semi-transparent areas, outlines, optional dots, lines-only mode and custom axis labels. */
export function HMRadarChart({ data, categoryKey, series, filled = true, dots = false, customLabels = false, maxValue = 100, label, className }: HMRadarChartProps) {
return (
<ChartContainer config={seriesToConfig(series)} role="group" aria-label={label} className={cn("aspect-auto h-72 w-full sm:h-80", className)}>
<RadarChart accessibilityLayer data={data as Record<string, string | number>[]} outerRadius="62%">
<ChartTooltip cursor={false} content={<ChartTooltipContent indicator="line" />} />
<PolarGrid />
<PolarAngleAxis dataKey={categoryKey} tick={customLabels ? <RadarAxisLabel /> : undefined} />
<PolarRadiusAxis domain={[0, maxValue]} tick={false} axisLine={false} />
<ChartLegend itemSorter={null} content={<ChartLegendContent />} />
{series.map(item => (
<Radar
key={item.key}
dataKey={item.key}
fill={`var(--color-${item.key})`}
fillOpacity={filled ? 0.25 : 0}
stroke={`var(--color-${item.key})`}
strokeWidth={2}
dot={dots ? { r: 3, fillOpacity: 1 } : false}
/>
))}
</RadarChart>
</ChartContainer>
)
}
@@ -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>
)
}
+44
View File
@@ -0,0 +1,44 @@
import type { ChartConfig } from "./chart"
/** HM series colors. They resolve to existing HM palette tokens (see chart.css), never to HEX values. */
export const chartColors = [
"var(--hm-chart-1)",
"var(--hm-chart-2)",
"var(--hm-chart-3)",
"var(--hm-chart-4)",
"var(--hm-chart-5)",
] as const
/** Light end of each HM series color (stacked bar segments): the same palette family, lighter (see chart.css). */
export const chartLightColors = [
"var(--hm-chart-1-light)",
"var(--hm-chart-2-light)",
"var(--hm-chart-3-light)",
"var(--hm-chart-4-light)",
"var(--hm-chart-5-light)",
] as const
/** One data series. `key` becomes a CSS variable name (`--color-<key>`), so use letters, digits, `-` or `_` only. */
export type ChartSeries = {
key: string
label: string
/** Any CSS color. Defaults to the HM series color for the series position. */
color?: string
/** Light end of a stacked bar segment (any CSS color). Defaults to the HM light color of the series position.
* A series with a custom `color` and no `lightColor` is drawn as a flat segment. */
lightColor?: string
}
export function seriesColor(index: number, color?: string) {
return color ?? chartColors[index % chartColors.length]
}
export function seriesLightColor(index: number, color?: string, lightColor?: string) {
return lightColor ?? color ?? chartLightColors[index % chartLightColors.length]
}
export function seriesToConfig(series: readonly ChartSeries[]): ChartConfig {
return Object.fromEntries(
series.map((item, index) => [item.key, { label: item.label, color: seriesColor(index, item.color) }]),
)
}
@@ -0,0 +1,86 @@
import { useId } from "react"
import { cn } from "cn"
import { Bar, BarChart, CartesianGrid, Rectangle, ReferenceLine, XAxis, YAxis, type BarShapeProps } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "./chart"
import { chartColors, chartLightColors } from "./series"
export type HMSignedBarChartProps = {
data: ReadonlyArray<Record<string, string | number>>
/** Field of each data row used for the category (X) axis. */
categoryKey: string
/** Numeric field of each data row. Positive values rise above the 0 line, negative values fall below it. */
valueKey: string
/** Name of the value, used as the tooltip row label. */
valueLabel: string
/** Accessible name of the chart. */
label: string
className?: string
}
const POSITIVE = 0 // HM series 1 (green)
const NEGATIVE = 1 // HM series 2 (brown)
/**
* Vertical bar chart for positive and negative values (shadcn Chart + Recharts) around a clearly marked 0 line.
* Color follows the sign only: positive uses the first HM series color, negative the second. Each bar is a gradient that is
* light at the 0 line and full color at the outer end (opaque at both ends, light end = the palette's lighter step of the same
* family), and only the outer end is rounded. The tooltip indicator uses the same solid color as the bar.
*/
export function HMSignedBarChart({ data, categoryKey, valueKey, valueLabel, label, className }: HMSignedBarChartProps) {
const scope = `hm-signed-${useId().replace(/:/g, "")}`
const positiveGradient = `${scope}-positive`
const negativeGradient = `${scope}-negative`
const config: ChartConfig = {
[valueKey]: { label: valueLabel, color: chartColors[POSITIVE] },
positive: { label: "양수", color: chartColors[POSITIVE] },
negative: { label: "음수", color: chartColors[NEGATIVE] },
}
// `fill` on the row feeds the tooltip indicator, so it always matches the bar's solid color.
const rows = data.map(row => ({ ...row, fill: Number(row[valueKey]) < 0 ? "var(--color-negative)" : "var(--color-positive)" }))
const isNegative = (value: BarShapeProps["value"]) => Number(Array.isArray(value) ? value[1] : value) < 0
return (
<ChartContainer
config={config}
role="group"
aria-label={label}
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
>
<BarChart accessibilityLayer data={rows} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<defs>
{/* Positive: full color at the top (outer end), light at the bottom (0 line). */}
<linearGradient id={positiveGradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: "var(--color-positive)", stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: chartLightColors[POSITIVE], stopOpacity: 1 }} />
</linearGradient>
{/* Negative: light at the top (0 line), full color at the bottom (outer end). */}
<linearGradient id={negativeGradient} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style={{ stopColor: chartLightColors[NEGATIVE], stopOpacity: 1 }} />
<stop offset="100%" style={{ stopColor: "var(--color-negative)", stopOpacity: 1 }} />
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis dataKey={categoryKey} tickLine={false} axisLine={false} tickMargin={8} />
<YAxis tickLine={false} axisLine={false} tickMargin={4} width={44} />
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<ReferenceLine y={0} stroke="var(--hm-chart-zero-line)" strokeWidth={1.5} />
<Bar
dataKey={valueKey}
fill="var(--color-positive)"
shape={(props: BarShapeProps) => {
const negative = isNegative(props.value)
return (
<Rectangle
{...props}
fill={`url(#${negative ? negativeGradient : positiveGradient})`}
// Flat at the 0 line, rounded only at the outer end. Recharts gives a negative bar a negative height, which
// mirrors the corner order, so the same [4, 4, 0, 0] rounds the outer (bottom) end of a negative bar too.
radius={[4, 4, 0, 0]}
/>
)
}}
/>
</BarChart>
</ChartContainer>
)
}
+18
View File
@@ -0,0 +1,18 @@
// SVG text cannot ellipsize itself, so labels are measured by estimate (full-width glyphs 1em, others ~0.6em) and shortened here.
export function textWidth(text: string, fontSize: number) {
let width = 0
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? fontSize : fontSize * 0.6
return width
}
/** The longest start of `text` plus an ellipsis that fits `maxWidth`; "" when not even one character fits. */
export function fitText(text: string, maxWidth: number, fontSize: number) {
if (textWidth(text, fontSize) <= maxWidth) return text
const chars = [...text]
for (let count = chars.length - 1; count > 0; count -= 1) {
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
if (textWidth(candidate, fontSize) <= maxWidth) return candidate
}
return ""
}