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,137 @@
|
||||
import { useId } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Bar, BarChart, Rectangle, XAxis, YAxis, type BarShapeProps } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "./chart"
|
||||
import { BAR_GRADIENT_FADED_OPACITY, BAR_GRADIENT_FULL_OPACITY } from "./bar-gradient"
|
||||
import { seriesToConfig } from "./series"
|
||||
|
||||
export type HMHorizontalBarChartProps = {
|
||||
data: ReadonlyArray<Record<string, string | number>>
|
||||
/** Field of each data row used as the item name (shown inside the bar, full text in the tooltip). */
|
||||
categoryKey: string
|
||||
/** Numeric field of each data row (bar length, shown after the bar end). */
|
||||
valueKey: string
|
||||
/** Name of the value, used as the tooltip row label. */
|
||||
valueLabel: string
|
||||
/** Any CSS color. Defaults to the first HM series color. */
|
||||
color?: string
|
||||
/** Formats the value shown after the bar end. Defaults to `toLocaleString()`. */
|
||||
valueFormatter?: (value: number) => string
|
||||
/** Accessible name of the chart. */
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FONT_SIZE = 12
|
||||
const ROW_HEIGHT = 40
|
||||
const BAR_SIZE = 26
|
||||
const NAME_PADDING = 10 // space between the bar start / end and the item name
|
||||
const VALUE_GAP = 8 // space between the bar end and the value
|
||||
const RIGHT_SAFE = 6 // extra room so the value never touches the chart edge
|
||||
const CHART_PADDING = 8
|
||||
|
||||
// Text is measured by estimate (full-width glyphs 1em, others ~0.6em) so truncation needs no DOM access.
|
||||
function textWidth(text: string) {
|
||||
let width = 0
|
||||
for (const char of text) width += (char.codePointAt(0) ?? 0) > 0x2e7f ? FONT_SIZE : FONT_SIZE * 0.6
|
||||
return width
|
||||
}
|
||||
|
||||
/** Keeps the longest start of `text` (plus an ellipsis) that fits `maxWidth`; "" when not even one character fits. */
|
||||
function fitText(text: string, maxWidth: number) {
|
||||
if (textWidth(text) <= maxWidth) return text
|
||||
const chars = [...text]
|
||||
for (let count = chars.length - 1; count > 0; count -= 1) {
|
||||
const candidate = `${chars.slice(0, count).join("").trimEnd()}…`
|
||||
if (textWidth(candidate) <= maxWidth) return candidate
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal bar chart with custom labels (shadcn Chart + Recharts): the item name sits inside the bar at the left, the value
|
||||
* after the bar end. The gradient runs left 30% -> right 100% of the HM color. Names that do not fit the bar are shortened with
|
||||
* an ellipsis; a bar too short for any of its name (including zero) shows the name after the value instead. Names never run into
|
||||
* the value, and the tooltip always shows the full name.
|
||||
*/
|
||||
export function HMHorizontalBarChart({
|
||||
data,
|
||||
categoryKey,
|
||||
valueKey,
|
||||
valueLabel,
|
||||
color,
|
||||
valueFormatter = value => value.toLocaleString(),
|
||||
label,
|
||||
className,
|
||||
}: HMHorizontalBarChartProps) {
|
||||
const gradientId = `hm-hbar-${useId().replace(/:/g, "")}`
|
||||
const config = seriesToConfig([{ key: valueKey, label: valueLabel, color }])
|
||||
// Reserve the room the longest value needs on the right so no value is clipped by the chart edge.
|
||||
const valueRoom = Math.ceil(
|
||||
Math.max(0, ...data.map(row => textWidth(valueFormatter(Number(row[valueKey]) || 0)))) + VALUE_GAP + RIGHT_SAFE,
|
||||
)
|
||||
|
||||
const renderBar = (props: BarShapeProps) => {
|
||||
const { x, y, width, height, payload, value, parentViewBox } = props
|
||||
const name = String(payload?.[categoryKey] ?? "")
|
||||
const amount = Number(Array.isArray(value) ? value[1] : value) || 0
|
||||
const barWidth = Math.max(width, 0)
|
||||
const shownName = fitText(name, barWidth - NAME_PADDING * 2)
|
||||
const centerY = y + height / 2
|
||||
const valueText = valueFormatter(amount)
|
||||
// A bar too short to hold any of its name shows the name after the value instead, shortened to the space left of the plot edge.
|
||||
const outsideX = x + barWidth + VALUE_GAP + textWidth(valueText) + NAME_PADDING
|
||||
const outsideName = shownName ? "" : fitText(name, parentViewBox.x + parentViewBox.width - outsideX)
|
||||
return (
|
||||
<g role="img" aria-label={`${name}: ${valueText}`}>
|
||||
{barWidth > 0 ? <Rectangle {...props} fill={`url(#${gradientId})`} /> : null}
|
||||
{shownName ? (
|
||||
<text data-slot="chart-bar-name" x={x + NAME_PADDING} y={centerY} dominantBaseline="central">{shownName}</text>
|
||||
) : null}
|
||||
<text data-slot="chart-bar-value" x={x + barWidth + VALUE_GAP} y={centerY} dominantBaseline="central">
|
||||
{valueText}
|
||||
</text>
|
||||
{outsideName ? (
|
||||
<text data-slot="chart-bar-outside-name" x={outsideX} y={centerY} dominantBaseline="central">{outsideName}</text>
|
||||
) : null}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartContainer
|
||||
config={config}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
// Same default height as the vertical bar charts so cards line up; many rows grow past it via the min-height.
|
||||
className={cn("aspect-auto h-64 w-full sm:h-80", className)}
|
||||
style={{ minHeight: data.length * ROW_HEIGHT + CHART_PADDING * 2 }}
|
||||
>
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
layout="vertical"
|
||||
data={data as Record<string, string | number>[]}
|
||||
margin={{ top: CHART_PADDING, right: valueRoom, bottom: CHART_PADDING, left: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FADED_OPACITY }} />
|
||||
<stop offset="100%" style={{ stopColor: `var(--color-${valueKey})`, stopOpacity: BAR_GRADIENT_FULL_OPACITY }} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis type="number" hide domain={[0, "dataMax"]} />
|
||||
<YAxis type="category" dataKey={categoryKey} hide />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar
|
||||
dataKey={valueKey}
|
||||
fill={`var(--color-${valueKey})`}
|
||||
barSize={BAR_SIZE}
|
||||
radius={[0, 4, 4, 0]}
|
||||
shape={renderBar}
|
||||
// Labels are positioned from the final bar width, so the bars do not animate in.
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user