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,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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user