Files
HMDesign/packages/chart/src/components/signed-bar-chart.tsx
T
shkim4 dc9258599c 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
2026-11-06 15:52:47 +09:00

87 lines
4.3 KiB
TypeScript

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