- 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
93 lines
4.3 KiB
TypeScript
93 lines
4.3 KiB
TypeScript
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>
|
|
)
|
|
}
|