- 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
145 lines
6.6 KiB
TypeScript
145 lines
6.6 KiB
TypeScript
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>
|
|
)
|
|
}
|