@alpic-ai/ui 0.0.0-dev.g16d80c2 → 0.0.0-dev.g172fb9f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/area-chart.d.mts +62 -0
- package/dist/components/area-chart.mjs +269 -0
- package/dist/components/badge.d.mts +1 -1
- package/dist/components/bar-chart.d.mts +48 -0
- package/dist/components/bar-chart.mjs +256 -0
- package/dist/components/bar-list.d.mts +28 -0
- package/dist/components/bar-list.mjs +98 -0
- package/dist/components/button.d.mts +1 -1
- package/dist/components/chart-card.d.mts +25 -0
- package/dist/components/chart-card.mjs +48 -0
- package/dist/components/chart-container.d.mts +20 -0
- package/dist/components/chart-container.mjs +37 -0
- package/dist/components/chart-legend.d.mts +16 -0
- package/dist/components/chart-legend.mjs +26 -0
- package/dist/components/chart-tooltip.d.mts +33 -0
- package/dist/components/chart-tooltip.mjs +52 -0
- package/dist/components/donut-chart.d.mts +46 -0
- package/dist/components/donut-chart.mjs +185 -0
- package/dist/components/grid-fx.d.mts +13 -0
- package/dist/components/grid-fx.mjs +188 -0
- package/dist/components/heatmap-chart.d.mts +40 -0
- package/dist/components/heatmap-chart.mjs +198 -0
- package/dist/components/line-chart.d.mts +55 -0
- package/dist/components/line-chart.mjs +211 -0
- package/dist/components/spinner.d.mts +1 -1
- package/dist/components/stat.d.mts +30 -0
- package/dist/components/stat.mjs +107 -0
- package/dist/hooks/use-chart-theme.d.mts +18 -0
- package/dist/hooks/use-chart-theme.mjs +57 -0
- package/dist/hooks/use-reduced-motion.d.mts +4 -0
- package/dist/hooks/use-reduced-motion.mjs +16 -0
- package/dist/lib/chart-palette.d.mts +4 -0
- package/dist/lib/chart-palette.mjs +95 -0
- package/dist/lib/chart.d.mts +14 -0
- package/dist/lib/chart.mjs +27 -0
- package/package.json +2 -1
- package/src/components/area-chart.tsx +339 -0
- package/src/components/bar-chart.tsx +309 -0
- package/src/components/bar-list.tsx +150 -0
- package/src/components/chart-card.tsx +63 -0
- package/src/components/chart-container.tsx +49 -0
- package/src/components/chart-legend.tsx +41 -0
- package/src/components/chart-tooltip.tsx +93 -0
- package/src/components/donut-chart.tsx +217 -0
- package/src/components/grid-fx.tsx +238 -0
- package/src/components/heatmap-chart.tsx +287 -0
- package/src/components/line-chart.tsx +264 -0
- package/src/components/stat.tsx +109 -0
- package/src/hooks/use-chart-theme.ts +75 -0
- package/src/hooks/use-reduced-motion.ts +17 -0
- package/src/lib/chart-palette.ts +110 -0
- package/src/lib/chart.ts +56 -0
- package/src/stories/area-chart.stories.tsx +200 -0
- package/src/stories/bar-chart.stories.tsx +169 -0
- package/src/stories/bar-list.stories.tsx +85 -0
- package/src/stories/donut-chart.stories.tsx +112 -0
- package/src/stories/heatmap-chart.stories.tsx +107 -0
- package/src/stories/line-chart.stories.tsx +146 -0
- package/src/stories/stat.stories.tsx +64 -0
- package/src/styles/tokens.css +63 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
Area,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
LabelList,
|
|
8
|
+
AreaChart as RechartsAreaChart,
|
|
9
|
+
ReferenceArea,
|
|
10
|
+
ReferenceDot,
|
|
11
|
+
ReferenceLine,
|
|
12
|
+
ResponsiveContainer,
|
|
13
|
+
Tooltip,
|
|
14
|
+
XAxis,
|
|
15
|
+
YAxis,
|
|
16
|
+
} from "recharts";
|
|
17
|
+
|
|
18
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion";
|
|
19
|
+
import { type ChartSeries, orderByLuminance, resolveSeries } from "../lib/chart";
|
|
20
|
+
import type { ChartPaletteName } from "../lib/chart-palette";
|
|
21
|
+
import { cn } from "../lib/cn";
|
|
22
|
+
import { useChartContext } from "./chart-container";
|
|
23
|
+
import { ChartLegend } from "./chart-legend";
|
|
24
|
+
import { ChartTooltipContent } from "./chart-tooltip";
|
|
25
|
+
|
|
26
|
+
const CURVE_TYPE = { monotone: "monotone", linear: "linear", step: "stepAfter" } as const;
|
|
27
|
+
|
|
28
|
+
export interface ChartMarker {
|
|
29
|
+
x: string | number;
|
|
30
|
+
y: number;
|
|
31
|
+
label?: string;
|
|
32
|
+
color?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AreaChartProps {
|
|
36
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
37
|
+
index: string;
|
|
38
|
+
series: ChartSeries[];
|
|
39
|
+
variant?: "stacked" | "grouped" | "expand";
|
|
40
|
+
curve?: keyof typeof CURVE_TYPE;
|
|
41
|
+
legend?: boolean;
|
|
42
|
+
valueFlags?: boolean;
|
|
43
|
+
height?: number;
|
|
44
|
+
yAxisWidth?: number;
|
|
45
|
+
palette?: ChartPaletteName;
|
|
46
|
+
referenceLine?: { y: number; label?: string; band?: boolean };
|
|
47
|
+
markers?: ChartMarker[];
|
|
48
|
+
lastValueLabel?: boolean;
|
|
49
|
+
texture?: boolean;
|
|
50
|
+
loading?: boolean;
|
|
51
|
+
valueFormatter?: (value: number) => string;
|
|
52
|
+
labelFormatter?: (label: string | number) => string;
|
|
53
|
+
className?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function AreaChart({
|
|
57
|
+
data,
|
|
58
|
+
index,
|
|
59
|
+
series,
|
|
60
|
+
variant = "stacked",
|
|
61
|
+
curve = "monotone",
|
|
62
|
+
legend = false,
|
|
63
|
+
valueFlags = false,
|
|
64
|
+
height = 200,
|
|
65
|
+
yAxisWidth = 48,
|
|
66
|
+
palette,
|
|
67
|
+
referenceLine,
|
|
68
|
+
markers,
|
|
69
|
+
lastValueLabel = false,
|
|
70
|
+
texture = false,
|
|
71
|
+
loading = false,
|
|
72
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
73
|
+
labelFormatter,
|
|
74
|
+
className,
|
|
75
|
+
}: AreaChartProps) {
|
|
76
|
+
const { palette: paletteColors, theme } = useChartContext(palette);
|
|
77
|
+
const reactId = React.useId().replace(/:/g, "");
|
|
78
|
+
const reducedMotion = useReducedMotion();
|
|
79
|
+
const animated = !reducedMotion;
|
|
80
|
+
|
|
81
|
+
const resolved = resolveSeries(series, paletteColors, theme);
|
|
82
|
+
const stacked = variant === "stacked" || variant === "expand";
|
|
83
|
+
const rendered = stacked ? orderByLuminance(resolved) : resolved;
|
|
84
|
+
|
|
85
|
+
const strokeOnly = variant === "grouped" && rendered.length > 1;
|
|
86
|
+
const filled = !strokeOnly;
|
|
87
|
+
const lead = resolved[0];
|
|
88
|
+
const withTotal = stacked && rendered.length > 1;
|
|
89
|
+
|
|
90
|
+
// Stacked bands must reach the stack height (sum per x-point), not the tallest
|
|
91
|
+
// single series, or the reference band stops short of the chart top.
|
|
92
|
+
const numericMax = React.useMemo(() => {
|
|
93
|
+
let max = 0;
|
|
94
|
+
for (const row of data) {
|
|
95
|
+
let rowTotal = 0;
|
|
96
|
+
for (const entry of series) {
|
|
97
|
+
const value = Number(row[entry.key]);
|
|
98
|
+
if (Number.isFinite(value)) {
|
|
99
|
+
rowTotal += value;
|
|
100
|
+
if (!stacked && value > max) {
|
|
101
|
+
max = value;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (stacked && rowTotal > max) {
|
|
106
|
+
max = rowTotal;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return max;
|
|
110
|
+
}, [data, series, stacked]);
|
|
111
|
+
|
|
112
|
+
const curveType = CURVE_TYPE[curve];
|
|
113
|
+
const margin = { top: markers?.length ? 18 : 8, right: lastValueLabel ? 56 : 8, bottom: 2, left: 0 };
|
|
114
|
+
|
|
115
|
+
const axis = {
|
|
116
|
+
stroke: theme.border,
|
|
117
|
+
tick: { fill: theme.axisForeground, fontSize: 10, fontFamily: theme.fontMono },
|
|
118
|
+
tickLine: false as const,
|
|
119
|
+
axisLine: { stroke: theme.border, strokeOpacity: 0.6 },
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const legendItems = resolved.map((entry) => ({ name: entry.name, color: entry.color, dashed: entry.dashed }));
|
|
123
|
+
|
|
124
|
+
const activeDotFor = (entry: (typeof rendered)[number]) =>
|
|
125
|
+
valueFlags
|
|
126
|
+
? (dotProps: { cx?: number; cy?: number; value?: number | string }) => {
|
|
127
|
+
if (dotProps.cx == null || dotProps.cy == null) {
|
|
128
|
+
return <g />;
|
|
129
|
+
}
|
|
130
|
+
return (
|
|
131
|
+
<g>
|
|
132
|
+
<circle
|
|
133
|
+
cx={dotProps.cx}
|
|
134
|
+
cy={dotProps.cy}
|
|
135
|
+
r={3.5}
|
|
136
|
+
fill={entry.color}
|
|
137
|
+
stroke={theme.card}
|
|
138
|
+
strokeWidth={2}
|
|
139
|
+
/>
|
|
140
|
+
<text
|
|
141
|
+
x={dotProps.cx}
|
|
142
|
+
y={dotProps.cy - 8}
|
|
143
|
+
textAnchor="middle"
|
|
144
|
+
fill={entry.color}
|
|
145
|
+
fontFamily={theme.fontMono}
|
|
146
|
+
fontSize={10}
|
|
147
|
+
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
148
|
+
>
|
|
149
|
+
{valueFormatter(Number(dotProps.value ?? 0))}
|
|
150
|
+
</text>
|
|
151
|
+
</g>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
: { r: 3.5, fill: entry.color, stroke: theme.card, strokeWidth: 2 };
|
|
155
|
+
|
|
156
|
+
const fillFor = (entry: (typeof rendered)[number], slot: number) => {
|
|
157
|
+
if (!filled) {
|
|
158
|
+
return "none";
|
|
159
|
+
}
|
|
160
|
+
if (texture && lead && entry.key === lead.key) {
|
|
161
|
+
return `url(#hatch-${reactId})`;
|
|
162
|
+
}
|
|
163
|
+
return `url(#area-${reactId}-${slot})`;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const renderLastLabel =
|
|
167
|
+
(color: string) =>
|
|
168
|
+
(props: {
|
|
169
|
+
x?: string | number;
|
|
170
|
+
y?: string | number;
|
|
171
|
+
value?: string | number | boolean | Array<string | number | boolean> | null;
|
|
172
|
+
index?: number;
|
|
173
|
+
}) => {
|
|
174
|
+
if (props.index !== data.length - 1 || props.x == null || props.y == null) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return (
|
|
178
|
+
<text
|
|
179
|
+
x={Number(props.x) + 6}
|
|
180
|
+
y={Number(props.y)}
|
|
181
|
+
dy={3}
|
|
182
|
+
fill={color}
|
|
183
|
+
fontFamily={theme.fontMono}
|
|
184
|
+
fontSize={10}
|
|
185
|
+
textAnchor="start"
|
|
186
|
+
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
187
|
+
>
|
|
188
|
+
{valueFormatter(Number(props.value ?? 0))}
|
|
189
|
+
</text>
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const isEmpty = data.length === 0 || rendered.length === 0;
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div data-slot="area-chart" className={cn("flex w-full flex-col gap-3", className)}>
|
|
197
|
+
<div className="w-full" style={{ height }}>
|
|
198
|
+
{loading ? (
|
|
199
|
+
<div className="flex h-full items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs">
|
|
200
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
201
|
+
loading…
|
|
202
|
+
</div>
|
|
203
|
+
) : isEmpty ? (
|
|
204
|
+
<div className="flex h-full items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs">
|
|
205
|
+
no data in range
|
|
206
|
+
</div>
|
|
207
|
+
) : (
|
|
208
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
209
|
+
<RechartsAreaChart
|
|
210
|
+
data={data as Record<string, string | number>[]}
|
|
211
|
+
stackOffset={variant === "expand" ? "expand" : "none"}
|
|
212
|
+
margin={margin}
|
|
213
|
+
>
|
|
214
|
+
<defs>
|
|
215
|
+
{filled &&
|
|
216
|
+
rendered.map((entry, slot) => (
|
|
217
|
+
<linearGradient key={entry.key} id={`area-${reactId}-${slot}`} x1="0" y1="0" x2="0" y2="1">
|
|
218
|
+
<stop offset="0%" stopColor={entry.color} stopOpacity={stacked ? 0.78 : 0.5} />
|
|
219
|
+
<stop offset="100%" stopColor={entry.color} stopOpacity={stacked ? 0.32 : 0.04} />
|
|
220
|
+
</linearGradient>
|
|
221
|
+
))}
|
|
222
|
+
{texture && filled && lead && (
|
|
223
|
+
<pattern
|
|
224
|
+
id={`hatch-${reactId}`}
|
|
225
|
+
patternUnits="userSpaceOnUse"
|
|
226
|
+
width={6}
|
|
227
|
+
height={6}
|
|
228
|
+
patternTransform="rotate(45)"
|
|
229
|
+
>
|
|
230
|
+
<rect width={6} height={6} fill={lead.color} fillOpacity={0.18} />
|
|
231
|
+
<line x1={0} y1={0} x2={0} y2={6} stroke={lead.color} strokeWidth={1.2} strokeOpacity={0.6} />
|
|
232
|
+
</pattern>
|
|
233
|
+
)}
|
|
234
|
+
</defs>
|
|
235
|
+
|
|
236
|
+
<CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
|
|
237
|
+
<XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
|
|
238
|
+
<YAxis
|
|
239
|
+
{...axis}
|
|
240
|
+
width={yAxisWidth}
|
|
241
|
+
tickFormatter={(value: number) =>
|
|
242
|
+
variant === "expand" ? `${Math.round(value * 100)}%` : valueFormatter(value)
|
|
243
|
+
}
|
|
244
|
+
/>
|
|
245
|
+
<Tooltip
|
|
246
|
+
offset={12}
|
|
247
|
+
allowEscapeViewBox={{ x: false, y: false }}
|
|
248
|
+
cursor={{ stroke: theme.axisForeground, strokeWidth: 1, strokeDasharray: "3 3" }}
|
|
249
|
+
content={
|
|
250
|
+
<ChartTooltipContent
|
|
251
|
+
valueFormatter={valueFormatter}
|
|
252
|
+
labelFormatter={labelFormatter}
|
|
253
|
+
showTotal={withTotal}
|
|
254
|
+
/>
|
|
255
|
+
}
|
|
256
|
+
/>
|
|
257
|
+
{referenceLine?.band && (
|
|
258
|
+
<ReferenceArea
|
|
259
|
+
y1={referenceLine.y}
|
|
260
|
+
y2={numericMax}
|
|
261
|
+
fill={theme.warning}
|
|
262
|
+
fillOpacity={0.06}
|
|
263
|
+
ifOverflow="extendDomain"
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
{referenceLine && (
|
|
267
|
+
<ReferenceLine
|
|
268
|
+
y={referenceLine.y}
|
|
269
|
+
stroke={theme.warning}
|
|
270
|
+
strokeDasharray="4 4"
|
|
271
|
+
strokeOpacity={0.6}
|
|
272
|
+
label={
|
|
273
|
+
referenceLine.label
|
|
274
|
+
? {
|
|
275
|
+
value: referenceLine.label,
|
|
276
|
+
fill: theme.warning,
|
|
277
|
+
fontSize: 9,
|
|
278
|
+
fontFamily: theme.fontMono,
|
|
279
|
+
position: "insideBottomRight",
|
|
280
|
+
}
|
|
281
|
+
: undefined
|
|
282
|
+
}
|
|
283
|
+
/>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{rendered.map((entry, slot) => (
|
|
287
|
+
<Area
|
|
288
|
+
key={entry.key}
|
|
289
|
+
type={curveType}
|
|
290
|
+
dataKey={entry.key}
|
|
291
|
+
name={entry.name}
|
|
292
|
+
stackId={stacked ? "stack" : undefined}
|
|
293
|
+
stroke={entry.color}
|
|
294
|
+
strokeWidth={entry.dashed ? 2 : 1.6}
|
|
295
|
+
strokeDasharray={entry.dashed ? "5 3" : undefined}
|
|
296
|
+
fill={fillFor(entry, slot)}
|
|
297
|
+
dot={false}
|
|
298
|
+
activeDot={activeDotFor(entry)}
|
|
299
|
+
isAnimationActive={animated}
|
|
300
|
+
animationDuration={650}
|
|
301
|
+
animationEasing="ease-out"
|
|
302
|
+
>
|
|
303
|
+
{lastValueLabel && <LabelList dataKey={entry.key} content={renderLastLabel(entry.color)} />}
|
|
304
|
+
</Area>
|
|
305
|
+
))}
|
|
306
|
+
|
|
307
|
+
{markers?.map((marker) => (
|
|
308
|
+
<ReferenceDot
|
|
309
|
+
key={`${marker.x}-${marker.y}`}
|
|
310
|
+
x={marker.x}
|
|
311
|
+
y={marker.y}
|
|
312
|
+
r={3.5}
|
|
313
|
+
fill={marker.color ?? theme.foreground}
|
|
314
|
+
stroke={theme.card}
|
|
315
|
+
strokeWidth={2}
|
|
316
|
+
label={
|
|
317
|
+
marker.label
|
|
318
|
+
? {
|
|
319
|
+
value: marker.label,
|
|
320
|
+
fill: marker.color ?? theme.foreground,
|
|
321
|
+
fontSize: 9,
|
|
322
|
+
fontFamily: theme.fontMono,
|
|
323
|
+
position: "top",
|
|
324
|
+
}
|
|
325
|
+
: undefined
|
|
326
|
+
}
|
|
327
|
+
/>
|
|
328
|
+
))}
|
|
329
|
+
</RechartsAreaChart>
|
|
330
|
+
</ResponsiveContainer>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export { AreaChart };
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
Bar,
|
|
6
|
+
CartesianGrid,
|
|
7
|
+
LabelList,
|
|
8
|
+
BarChart as RechartsBarChart,
|
|
9
|
+
ReferenceArea,
|
|
10
|
+
ReferenceDot,
|
|
11
|
+
ReferenceLine,
|
|
12
|
+
ResponsiveContainer,
|
|
13
|
+
Tooltip,
|
|
14
|
+
XAxis,
|
|
15
|
+
YAxis,
|
|
16
|
+
} from "recharts";
|
|
17
|
+
|
|
18
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion";
|
|
19
|
+
import { type ChartSeries, orderByLuminance, resolveSeries } from "../lib/chart";
|
|
20
|
+
import type { ChartPaletteName } from "../lib/chart-palette";
|
|
21
|
+
import { cn } from "../lib/cn";
|
|
22
|
+
import type { ChartMarker } from "./area-chart";
|
|
23
|
+
import { useChartContext } from "./chart-container";
|
|
24
|
+
import { ChartLegend } from "./chart-legend";
|
|
25
|
+
import { ChartTooltipContent } from "./chart-tooltip";
|
|
26
|
+
|
|
27
|
+
const BAR_RADIUS = 4;
|
|
28
|
+
const MAX_BAR_SIZE = 48;
|
|
29
|
+
|
|
30
|
+
export interface BarChartProps {
|
|
31
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
32
|
+
index: string;
|
|
33
|
+
series: ChartSeries[];
|
|
34
|
+
variant?: "stacked" | "grouped" | "expand";
|
|
35
|
+
legend?: boolean;
|
|
36
|
+
valueLabels?: boolean;
|
|
37
|
+
height?: number;
|
|
38
|
+
yAxisWidth?: number;
|
|
39
|
+
palette?: ChartPaletteName;
|
|
40
|
+
referenceLine?: { y: number; label?: string; band?: boolean };
|
|
41
|
+
markers?: ChartMarker[];
|
|
42
|
+
texture?: boolean;
|
|
43
|
+
loading?: boolean;
|
|
44
|
+
valueFormatter?: (value: number) => string;
|
|
45
|
+
labelFormatter?: (label: string | number) => string;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function BarChart({
|
|
50
|
+
data,
|
|
51
|
+
index,
|
|
52
|
+
series,
|
|
53
|
+
variant = "stacked",
|
|
54
|
+
legend = false,
|
|
55
|
+
valueLabels = false,
|
|
56
|
+
height = 200,
|
|
57
|
+
yAxisWidth = 48,
|
|
58
|
+
palette,
|
|
59
|
+
referenceLine,
|
|
60
|
+
markers,
|
|
61
|
+
texture = false,
|
|
62
|
+
loading = false,
|
|
63
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
64
|
+
labelFormatter,
|
|
65
|
+
className,
|
|
66
|
+
}: BarChartProps) {
|
|
67
|
+
const { palette: paletteColors, theme } = useChartContext(palette);
|
|
68
|
+
const reactId = React.useId().replace(/:/g, "");
|
|
69
|
+
const reducedMotion = useReducedMotion();
|
|
70
|
+
const animated = !reducedMotion;
|
|
71
|
+
|
|
72
|
+
const resolved = resolveSeries(series, paletteColors, theme);
|
|
73
|
+
const stacked = variant === "stacked" || variant === "expand";
|
|
74
|
+
const rendered = stacked ? orderByLuminance(resolved) : resolved;
|
|
75
|
+
const lead = resolved[0];
|
|
76
|
+
const withTotal = stacked && rendered.length > 1;
|
|
77
|
+
|
|
78
|
+
// Stacked bars reach the stack height (sum per x-point), not the tallest single
|
|
79
|
+
// series, so the explicit YAxis domain must not clip a tall stack short.
|
|
80
|
+
const numericMax = React.useMemo(() => {
|
|
81
|
+
let max = 0;
|
|
82
|
+
for (const row of data) {
|
|
83
|
+
let rowTotal = 0;
|
|
84
|
+
for (const entry of series) {
|
|
85
|
+
const value = Number(row[entry.key]);
|
|
86
|
+
if (Number.isFinite(value)) {
|
|
87
|
+
rowTotal += value;
|
|
88
|
+
if (!stacked && value > max) {
|
|
89
|
+
max = value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (stacked && rowTotal > max) {
|
|
94
|
+
max = rowTotal;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return max;
|
|
98
|
+
}, [data, series, stacked]);
|
|
99
|
+
|
|
100
|
+
const margin = { top: markers?.length || valueLabels ? 18 : 8, right: 8, bottom: 2, left: 0 };
|
|
101
|
+
|
|
102
|
+
const axis = {
|
|
103
|
+
stroke: theme.border,
|
|
104
|
+
tick: { fill: theme.axisForeground, fontSize: 10, fontFamily: theme.fontMono },
|
|
105
|
+
tickLine: false as const,
|
|
106
|
+
axisLine: { stroke: theme.border, strokeOpacity: 0.6 },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const legendItems = resolved.map((entry) => ({ name: entry.name, color: entry.color, dashed: entry.dashed }));
|
|
110
|
+
|
|
111
|
+
const radiusFor = (slot: number): [number, number, number, number] => {
|
|
112
|
+
if (!stacked || slot === rendered.length - 1) {
|
|
113
|
+
return [BAR_RADIUS, BAR_RADIUS, 0, 0];
|
|
114
|
+
}
|
|
115
|
+
return [0, 0, 0, 0];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const fillFor = (entry: (typeof rendered)[number], slot: number) => {
|
|
119
|
+
if (texture && lead && entry.key === lead.key) {
|
|
120
|
+
return `url(#bar-hatch-${reactId})`;
|
|
121
|
+
}
|
|
122
|
+
// Stacked segments read cleanest as flat solids — a per-segment gradient
|
|
123
|
+
// banding looks like a drop shadow between bands. Grouped bars keep the fade.
|
|
124
|
+
if (stacked) {
|
|
125
|
+
return entry.color;
|
|
126
|
+
}
|
|
127
|
+
return `url(#bar-${reactId}-${slot})`;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const renderValueLabel =
|
|
131
|
+
(color: string) =>
|
|
132
|
+
(props: {
|
|
133
|
+
x?: string | number;
|
|
134
|
+
y?: string | number;
|
|
135
|
+
width?: string | number;
|
|
136
|
+
value?: string | number | boolean | Array<string | number | boolean> | null;
|
|
137
|
+
}) => {
|
|
138
|
+
if (props.x == null || props.y == null) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
return (
|
|
142
|
+
<text
|
|
143
|
+
x={Number(props.x) + Number(props.width ?? 0) / 2}
|
|
144
|
+
y={Number(props.y) - 5}
|
|
145
|
+
textAnchor="middle"
|
|
146
|
+
fill={color}
|
|
147
|
+
fontFamily={theme.fontMono}
|
|
148
|
+
fontSize={10}
|
|
149
|
+
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
150
|
+
>
|
|
151
|
+
{valueFormatter(Number(props.value ?? 0))}
|
|
152
|
+
</text>
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const isEmpty = data.length === 0 || rendered.length === 0;
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div data-slot="bar-chart" className={cn("flex w-full flex-col gap-3", className)}>
|
|
160
|
+
<div className="w-full" style={{ height }}>
|
|
161
|
+
{loading ? (
|
|
162
|
+
<div className="flex h-full items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs">
|
|
163
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
164
|
+
loading…
|
|
165
|
+
</div>
|
|
166
|
+
) : isEmpty ? (
|
|
167
|
+
<div className="flex h-full items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs">
|
|
168
|
+
no data in range
|
|
169
|
+
</div>
|
|
170
|
+
) : (
|
|
171
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
172
|
+
<RechartsBarChart
|
|
173
|
+
data={data as Record<string, string | number>[]}
|
|
174
|
+
stackOffset={variant === "expand" ? "expand" : "none"}
|
|
175
|
+
margin={margin}
|
|
176
|
+
barCategoryGap={stacked ? "20%" : "16%"}
|
|
177
|
+
>
|
|
178
|
+
<defs>
|
|
179
|
+
{!stacked &&
|
|
180
|
+
rendered.map((entry, slot) => (
|
|
181
|
+
<linearGradient key={entry.key} id={`bar-${reactId}-${slot}`} x1="0" y1="0" x2="0" y2="1">
|
|
182
|
+
<stop offset="0%" stopColor={entry.color} stopOpacity={0.95} />
|
|
183
|
+
<stop offset="100%" stopColor={entry.color} stopOpacity={0.5} />
|
|
184
|
+
</linearGradient>
|
|
185
|
+
))}
|
|
186
|
+
{texture && lead && (
|
|
187
|
+
<pattern
|
|
188
|
+
id={`bar-hatch-${reactId}`}
|
|
189
|
+
patternUnits="userSpaceOnUse"
|
|
190
|
+
width={6}
|
|
191
|
+
height={6}
|
|
192
|
+
patternTransform="rotate(45)"
|
|
193
|
+
>
|
|
194
|
+
<rect width={6} height={6} fill={lead.color} fillOpacity={0.22} />
|
|
195
|
+
<line x1={0} y1={0} x2={0} y2={6} stroke={lead.color} strokeWidth={1.2} strokeOpacity={0.65} />
|
|
196
|
+
</pattern>
|
|
197
|
+
)}
|
|
198
|
+
</defs>
|
|
199
|
+
|
|
200
|
+
<CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
|
|
201
|
+
<XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
|
|
202
|
+
<YAxis
|
|
203
|
+
{...axis}
|
|
204
|
+
width={yAxisWidth}
|
|
205
|
+
domain={
|
|
206
|
+
referenceLine && variant !== "expand"
|
|
207
|
+
? [0, Math.ceil(Math.max(numericMax, referenceLine.y) * 1.15)]
|
|
208
|
+
: undefined
|
|
209
|
+
}
|
|
210
|
+
tickFormatter={(value: number) =>
|
|
211
|
+
variant === "expand" ? `${Math.round(value * 100)}%` : valueFormatter(value)
|
|
212
|
+
}
|
|
213
|
+
/>
|
|
214
|
+
<Tooltip
|
|
215
|
+
offset={12}
|
|
216
|
+
allowEscapeViewBox={{ x: false, y: false }}
|
|
217
|
+
cursor={{ fill: lead?.color ?? theme.mutedForeground, fillOpacity: theme.isDark ? 0.1 : 0.06 }}
|
|
218
|
+
content={
|
|
219
|
+
<ChartTooltipContent
|
|
220
|
+
valueFormatter={valueFormatter}
|
|
221
|
+
labelFormatter={labelFormatter}
|
|
222
|
+
showTotal={withTotal}
|
|
223
|
+
/>
|
|
224
|
+
}
|
|
225
|
+
/>
|
|
226
|
+
{referenceLine?.band && (
|
|
227
|
+
<ReferenceArea
|
|
228
|
+
y1={referenceLine.y}
|
|
229
|
+
y2={numericMax}
|
|
230
|
+
fill={theme.warning}
|
|
231
|
+
fillOpacity={0.06}
|
|
232
|
+
ifOverflow="extendDomain"
|
|
233
|
+
/>
|
|
234
|
+
)}
|
|
235
|
+
{referenceLine && (
|
|
236
|
+
<ReferenceLine
|
|
237
|
+
y={referenceLine.y}
|
|
238
|
+
stroke={theme.warning}
|
|
239
|
+
strokeDasharray="4 4"
|
|
240
|
+
strokeOpacity={0.6}
|
|
241
|
+
label={
|
|
242
|
+
referenceLine.label
|
|
243
|
+
? {
|
|
244
|
+
value: referenceLine.label,
|
|
245
|
+
fill: theme.warning,
|
|
246
|
+
fontSize: 9,
|
|
247
|
+
fontFamily: theme.fontMono,
|
|
248
|
+
position: "insideBottomRight",
|
|
249
|
+
}
|
|
250
|
+
: undefined
|
|
251
|
+
}
|
|
252
|
+
/>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{rendered.map((entry, slot) => (
|
|
256
|
+
<Bar
|
|
257
|
+
key={entry.key}
|
|
258
|
+
dataKey={entry.key}
|
|
259
|
+
name={entry.name}
|
|
260
|
+
stackId={stacked ? "stack" : undefined}
|
|
261
|
+
fill={fillFor(entry, slot)}
|
|
262
|
+
stroke={entry.color}
|
|
263
|
+
strokeWidth={0}
|
|
264
|
+
radius={radiusFor(slot)}
|
|
265
|
+
maxBarSize={MAX_BAR_SIZE}
|
|
266
|
+
activeBar={{ fillOpacity: 1, stroke: theme.card, strokeWidth: 1 }}
|
|
267
|
+
isAnimationActive={animated}
|
|
268
|
+
animationDuration={650}
|
|
269
|
+
animationEasing="ease-out"
|
|
270
|
+
>
|
|
271
|
+
{valueLabels && (!stacked || rendered.length === 1) && (
|
|
272
|
+
<LabelList dataKey={entry.key} content={renderValueLabel(entry.color)} />
|
|
273
|
+
)}
|
|
274
|
+
</Bar>
|
|
275
|
+
))}
|
|
276
|
+
|
|
277
|
+
{markers?.map((marker) => (
|
|
278
|
+
<ReferenceDot
|
|
279
|
+
key={`${marker.x}-${marker.y}`}
|
|
280
|
+
x={marker.x}
|
|
281
|
+
y={marker.y}
|
|
282
|
+
r={3.5}
|
|
283
|
+
fill={marker.color ?? theme.foreground}
|
|
284
|
+
stroke={theme.card}
|
|
285
|
+
strokeWidth={2}
|
|
286
|
+
label={
|
|
287
|
+
marker.label
|
|
288
|
+
? {
|
|
289
|
+
value: marker.label,
|
|
290
|
+
fill: marker.color ?? theme.foreground,
|
|
291
|
+
fontSize: 9,
|
|
292
|
+
fontFamily: theme.fontMono,
|
|
293
|
+
position: "top",
|
|
294
|
+
}
|
|
295
|
+
: undefined
|
|
296
|
+
}
|
|
297
|
+
/>
|
|
298
|
+
))}
|
|
299
|
+
</RechartsBarChart>
|
|
300
|
+
</ResponsiveContainer>
|
|
301
|
+
)}
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
|
|
305
|
+
</div>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export { BarChart };
|