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,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>
)
}