aplance-ui 1.0.1

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.
Files changed (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/api.d.ts +175 -0
  4. package/dist/api.js +1954 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/frameworks/astro/astro.config.snippet.md +23 -0
  7. package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
  8. package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
  9. package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
  10. package/dist/frameworks/next/layout-import.snippet.md +19 -0
  11. package/dist/frameworks/next/next.config.snippet.md +15 -0
  12. package/dist/frameworks/next/postcss.config.js.template +16 -0
  13. package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
  14. package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
  15. package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
  16. package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
  17. package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
  18. package/dist/index.js +2047 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +63 -0
  21. package/registry/components/primitives/icon/icon.module.css +63 -0
  22. package/registry/components/primitives/icon/icon.tsx +53 -0
  23. package/registry/components/primitives/modal/modal-context.ts +19 -0
  24. package/registry/components/primitives/modal/modal.module.css +415 -0
  25. package/registry/components/primitives/modal/modal.tsx +550 -0
  26. package/registry/components/primitives/popover/popover-context.ts +28 -0
  27. package/registry/components/primitives/popover/popover.module.css +28 -0
  28. package/registry/components/primitives/popover/popover.tsx +465 -0
  29. package/registry/components/ui/accordion/accordion.module.css +208 -0
  30. package/registry/components/ui/accordion/accordion.tsx +327 -0
  31. package/registry/components/ui/avatar/avatar.module.css +32 -0
  32. package/registry/components/ui/avatar/avatar.tsx +37 -0
  33. package/registry/components/ui/badge/badge.module.css +103 -0
  34. package/registry/components/ui/badge/badge.tsx +29 -0
  35. package/registry/components/ui/button/button.module.css +216 -0
  36. package/registry/components/ui/button/button.tsx +133 -0
  37. package/registry/components/ui/chart/chart.module.css +168 -0
  38. package/registry/components/ui/chart/chart.tsx +1293 -0
  39. package/registry/components/ui/chart/types.ts +210 -0
  40. package/registry/components/ui/chart/utils.ts +59 -0
  41. package/registry/components/ui/copy-button/copy-button.module.css +21 -0
  42. package/registry/components/ui/copy-button/copy-button.tsx +123 -0
  43. package/registry/components/ui/countdown/countdown.module.css +5 -0
  44. package/registry/components/ui/countdown/countdown.tsx +152 -0
  45. package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
  46. package/registry/components/ui/data-table/data-table.module.css +594 -0
  47. package/registry/components/ui/data-table/data-table.tsx +1109 -0
  48. package/registry/components/ui/data-table/filter-bar.tsx +33 -0
  49. package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
  50. package/registry/components/ui/data-table/types.ts +594 -0
  51. package/registry/components/ui/data-table/useDataTable.tsx +573 -0
  52. package/registry/components/ui/data-table/utils.ts +445 -0
  53. package/registry/components/ui/empty-state/empty-state.module.css +26 -0
  54. package/registry/components/ui/empty-state/empty-state.tsx +32 -0
  55. package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
  56. package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
  57. package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
  58. package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
  59. package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
  60. package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
  61. package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
  62. package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
  63. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
  64. package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
  65. package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
  66. package/registry/components/ui/input-fields/common/types.ts +38 -0
  67. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
  68. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
  69. package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
  70. package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
  71. package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
  72. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
  73. package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
  74. package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
  75. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
  76. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
  77. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
  78. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
  79. package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
  80. package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
  81. package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
  82. package/registry/components/ui/input-fields/input/input.tsx +319 -0
  83. package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
  84. package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
  85. package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
  86. package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
  87. package/registry/components/ui/input-fields/media-input/types.ts +129 -0
  88. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
  89. package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
  90. package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
  91. package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
  92. package/registry/components/ui/input-fields/select/select.tsx +118 -0
  93. package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
  94. package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
  95. package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
  96. package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
  97. package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
  98. package/registry/components/ui/loader/loader.module.css +156 -0
  99. package/registry/components/ui/loader/loader.tsx +102 -0
  100. package/registry/components/ui/stepper/stepper.module.css +294 -0
  101. package/registry/components/ui/stepper/stepper.tsx +197 -0
  102. package/registry/components/ui/tabs/tabs.module.css +150 -0
  103. package/registry/components/ui/tabs/tabs.tsx +134 -0
  104. package/registry/components/ui/toast/toast.module.css +159 -0
  105. package/registry/components/ui/toast/toast.tsx +104 -0
  106. package/registry/components/ui/tooltip/tooltip.module.css +82 -0
  107. package/registry/components/ui/tooltip/tooltip.tsx +72 -0
  108. package/registry/global.d.ts +4 -0
  109. package/registry/hooks/useFocusOutside.tsx +33 -0
  110. package/registry/hooks/useResizeObserver.tsx +32 -0
  111. package/registry/lib/dom.ts +25 -0
  112. package/registry/lib/string.ts +23 -0
  113. package/registry/registry.json +719 -0
  114. package/registry/styles/app.css +240 -0
  115. package/registry/styles/components.css +78 -0
  116. package/registry/styles/fonts/geist-regular.woff2 +0 -0
  117. package/registry/styles/globals.css +7 -0
  118. package/registry/styles/media.css +25 -0
  119. package/registry/styles/utilities.css +90 -0
  120. package/registry/themes/colors/blue.css +37 -0
  121. package/registry/themes/colors/green.css +37 -0
  122. package/registry/themes/colors/rose.css +37 -0
  123. package/registry/themes/colors/slate.css +37 -0
  124. package/registry/themes/colors/stone.css +37 -0
  125. package/registry/themes/colors/violet.css +37 -0
  126. package/registry/themes/colors/zinc.css +37 -0
  127. package/registry/themes/radius/pill.css +8 -0
  128. package/registry/themes/radius/rounded.css +8 -0
  129. package/registry/themes/radius/sharp.css +8 -0
  130. package/registry/themes/spacing/compact.css +17 -0
  131. package/registry/themes/spacing/spacious.css +17 -0
  132. package/registry/tsconfig.json +25 -0
@@ -0,0 +1,1293 @@
1
+ import { useEffect, useId, useRef, useState } from "react";
2
+ import clsx from "clsx";
3
+ import {
4
+ Area,
5
+ AreaChart as RechartsAreaChart,
6
+ Bar,
7
+ BarChart as RechartsBarChart,
8
+ CartesianGrid,
9
+ Cell,
10
+ ComposedChart as RechartsComposedChart,
11
+ Line,
12
+ LineChart as RechartsLineChart,
13
+ Pie,
14
+ PieChart,
15
+ PolarAngleAxis,
16
+ RadialBar,
17
+ RadialBarChart as RechartsRadialBarChart,
18
+ ReferenceLine,
19
+ ResponsiveContainer,
20
+ Scatter as RechartsScatter,
21
+ ScatterChart as RechartsScatterChart,
22
+ Tooltip,
23
+ XAxis,
24
+ YAxis,
25
+ ZAxis,
26
+ } from "recharts";
27
+ import type { ReactNode } from "react";
28
+ import s from "./chart.module.css";
29
+ import { Loader } from "../loader/loader";
30
+ import type {
31
+ AreaChartProps,
32
+ AxisFormatter,
33
+ BarChartProps,
34
+ ChartDatum,
35
+ ChartProps,
36
+ ChartSeries,
37
+ ChartSeriesType,
38
+ ComposedChartProps,
39
+ DonutChartProps,
40
+ LineChartProps,
41
+ RadialBarChartProps,
42
+ ReferenceLineConfig,
43
+ ScatterChartProps,
44
+ TooltipLabelFormatter,
45
+ TooltipValueFormatter,
46
+ } from "./types";
47
+ import {
48
+ createChartPalette,
49
+ pickSeriesColor,
50
+ resolveColorToken,
51
+ toLookupKey,
52
+ type ChartPalette,
53
+ } from "./utils";
54
+
55
+ export { CHART_COLOR_TOKENS } from "./types";
56
+ export type {
57
+ AreaChartProps,
58
+ AxisFormatter,
59
+ BarChartProps,
60
+ ChartColorMap,
61
+ ChartColorTokenName,
62
+ ChartDatum,
63
+ ChartProps,
64
+ ChartSeries,
65
+ ChartSeriesType,
66
+ ComposedChartProps,
67
+ DonutChartProps,
68
+ LineChartProps,
69
+ RadialBarChartProps,
70
+ ReferenceLineConfig,
71
+ ScatterChartProps,
72
+ ScatterSeries,
73
+ TooltipLabelFormatter,
74
+ TooltipValueFormatter,
75
+ } from "./types";
76
+
77
+ // ─── Constants ────────────────────────────────────────────────────────────────
78
+
79
+ const DEFAULT_CHART_HEIGHT = 260;
80
+ // left is intentionally negative: pulls Y-axis labels into the card's padding
81
+ // so the visible whitespace on both sides feels balanced.
82
+ const CARTESIAN_MARGIN = { top: 10, right: 10, bottom: 10, left: -8 } as const;
83
+ const YAXIS_WIDTH = 56;
84
+ const YAXIS_WIDTH_HORIZONTAL = 80;
85
+ const BAR_TOP_RADIUS: [number, number, number, number] = [6, 6, 0, 0];
86
+ const BAR_RIGHT_RADIUS: [number, number, number, number] = [0, 6, 6, 0];
87
+ const BAR_FLAT_RADIUS: [number, number, number, number] = [0, 0, 0, 0];
88
+ const DEFAULT_MAX_BAR_SIZE = 44;
89
+ const AXIS_TICK_FONT_SIZE = 11;
90
+ const AXIS_TICK_MARGIN = 10;
91
+ const DEFAULT_LINE_CURVE = "monotone";
92
+ const DEFAULT_AREA_FILL_OPACITY = 0.24;
93
+ const DOT_PROPS = { r: 3, strokeWidth: 2 } as const;
94
+
95
+ // ─── Internal types ───────────────────────────────────────────────────────────
96
+
97
+ type TooltipValue = string | number | null | undefined;
98
+
99
+ type TooltipPayloadItem = {
100
+ name?: string | number;
101
+ value?: TooltipValue;
102
+ color?: string;
103
+ dataKey?: string | number;
104
+ payload?: Record<string, unknown>;
105
+ };
106
+
107
+ type ChartTooltipProps = {
108
+ active?: boolean;
109
+ payload?: TooltipPayloadItem[];
110
+ label?: string | number;
111
+ valueFormatter?: TooltipValueFormatter;
112
+ labelFormatter?: TooltipLabelFormatter;
113
+ // For charts whose Recharts series has no per-cell name (e.g. RadialBar uses
114
+ // a single dataKey across all sectors), read the row label from the original
115
+ // datum at this key so the tooltip shows "Aarav" instead of the dataKey.
116
+ nameKey?: string;
117
+ };
118
+
119
+ type CartesianRenderOptions = {
120
+ showGrid?: boolean;
121
+ showXAxis?: boolean;
122
+ showYAxis?: boolean;
123
+ showTooltip?: boolean;
124
+ showLegend?: boolean;
125
+ allowDecimals?: boolean;
126
+ xTickFormatter?: AxisFormatter;
127
+ yTickFormatter?: AxisFormatter;
128
+ tooltipValueFormatter?: TooltipValueFormatter;
129
+ tooltipLabelFormatter?: TooltipLabelFormatter;
130
+ referenceLines?: ReferenceLineConfig[];
131
+ };
132
+
133
+ type PreparedSeries = {
134
+ key: string;
135
+ label: string;
136
+ color: string;
137
+ type: ChartSeriesType;
138
+ stackId?: string;
139
+ yAxisId: "left" | "right";
140
+ strokeWidth: number;
141
+ fillOpacity: number;
142
+ dot: boolean;
143
+ connectNulls: boolean;
144
+ };
145
+
146
+ type AxisRenderConfig = {
147
+ showLeftAxis: boolean;
148
+ showRightAxis: boolean;
149
+ };
150
+
151
+ type TooltipCursor = {
152
+ fill?: string;
153
+ stroke?: string;
154
+ strokeWidth?: number;
155
+ strokeDasharray?: string;
156
+ };
157
+
158
+ type LegendItem = { key: string; label: string; color: string };
159
+
160
+ const toLegendItems = (series: PreparedSeries[]): LegendItem[] =>
161
+ series.map(({ key, label, color }) => ({ key, label, color }));
162
+
163
+ // ─── Option resolution ────────────────────────────────────────────────────────
164
+
165
+ const resolveCartesianOptions = (opts: CartesianRenderOptions) => ({
166
+ showGrid: opts.showGrid ?? true,
167
+ showXAxis: opts.showXAxis ?? true,
168
+ showYAxis: opts.showYAxis ?? true,
169
+ showTooltip: opts.showTooltip ?? true,
170
+ showLegend: opts.showLegend ?? false,
171
+ allowDecimals: opts.allowDecimals ?? false,
172
+ xTickFormatter: opts.xTickFormatter,
173
+ yTickFormatter: opts.yTickFormatter,
174
+ tooltipValueFormatter: opts.tooltipValueFormatter,
175
+ tooltipLabelFormatter: opts.tooltipLabelFormatter,
176
+ referenceLines: opts.referenceLines ?? [],
177
+ });
178
+
179
+ type ResolvedCartesianOptions = ReturnType<typeof resolveCartesianOptions>;
180
+
181
+ type CartesianContext = {
182
+ options: ResolvedCartesianOptions;
183
+ preparedSeries: PreparedSeries[];
184
+ axisConfig: AxisRenderConfig;
185
+ };
186
+
187
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
188
+
189
+ const resolveAxisRenderConfig = (series: PreparedSeries[]): AxisRenderConfig => ({
190
+ showLeftAxis: series.some((s) => s.yAxisId !== "right"),
191
+ showRightAxis: series.some((s) => s.yAxisId === "right"),
192
+ });
193
+
194
+ const resolveStackTopSeriesKeys = (series: PreparedSeries[]): Set<string> => {
195
+ const topByStack = new Map<string, string>();
196
+ for (const item of series) {
197
+ if (!item.stackId || item.type !== "bar") continue;
198
+ topByStack.set(`${item.yAxisId}:${item.stackId}`, item.key);
199
+ }
200
+ return new Set(topByStack.values());
201
+ };
202
+
203
+ const resolveBarRadius = (
204
+ item: PreparedSeries,
205
+ topStackSeriesKeys: Set<string>,
206
+ orientation: "vertical" | "horizontal" = "vertical",
207
+ ): [number, number, number, number] => {
208
+ const leadingRadius = orientation === "horizontal" ? BAR_RIGHT_RADIUS : BAR_TOP_RADIUS;
209
+ if (!item.stackId) return leadingRadius;
210
+ return topStackSeriesKeys.has(item.key) ? leadingRadius : BAR_FLAT_RADIUS;
211
+ };
212
+
213
+ const prepareSeries = ({
214
+ dataKey,
215
+ series,
216
+ defaultType,
217
+ palette,
218
+ colors,
219
+ color,
220
+ seriesLabel,
221
+ defaultDot = false,
222
+ defaultConnectNulls = false,
223
+ }: {
224
+ dataKey?: string;
225
+ series?: ChartSeries[];
226
+ defaultType: ChartSeriesType;
227
+ palette: ChartPalette;
228
+ colors?: Record<string, string>;
229
+ color?: string;
230
+ seriesLabel?: string;
231
+ defaultDot?: boolean;
232
+ defaultConnectNulls?: boolean;
233
+ }): PreparedSeries[] => {
234
+ if (series && series.length > 0) {
235
+ return series.map((item, index) => {
236
+ const mappedColor = colors?.[toLookupKey(item.key)];
237
+ return {
238
+ key: item.key,
239
+ label: item.label ?? item.key,
240
+ color: resolveColorToken(item.color ?? mappedColor, pickSeriesColor(palette, index)),
241
+ type: item.type ?? defaultType,
242
+ stackId: item.stackId,
243
+ yAxisId: item.yAxisId ?? "left",
244
+ strokeWidth: item.strokeWidth ?? 2,
245
+ fillOpacity: item.fillOpacity ?? DEFAULT_AREA_FILL_OPACITY,
246
+ dot: item.dot ?? defaultDot,
247
+ connectNulls: item.connectNulls ?? defaultConnectNulls,
248
+ };
249
+ });
250
+ }
251
+
252
+ if (!dataKey) return [];
253
+
254
+ return [
255
+ {
256
+ key: dataKey,
257
+ label: seriesLabel ?? dataKey,
258
+ color: resolveColorToken(color ?? colors?.[toLookupKey(dataKey)], palette.primary),
259
+ type: defaultType,
260
+ yAxisId: "left",
261
+ strokeWidth: 2,
262
+ fillOpacity: DEFAULT_AREA_FILL_OPACITY,
263
+ dot: defaultDot,
264
+ connectNulls: defaultConnectNulls,
265
+ },
266
+ ];
267
+ };
268
+
269
+ const prepareCartesianContext = ({
270
+ palette,
271
+ dataKey,
272
+ series,
273
+ defaultType,
274
+ color,
275
+ colors,
276
+ seriesLabel,
277
+ defaultDot,
278
+ defaultConnectNulls,
279
+ ...renderOptions
280
+ }: CartesianRenderOptions & {
281
+ palette: ChartPalette;
282
+ dataKey?: string;
283
+ series?: ChartSeries[];
284
+ defaultType: ChartSeriesType;
285
+ color?: string;
286
+ colors?: Record<string, string>;
287
+ seriesLabel?: string;
288
+ defaultDot?: boolean;
289
+ defaultConnectNulls?: boolean;
290
+ }): CartesianContext => {
291
+ const options = resolveCartesianOptions(renderOptions);
292
+ const preparedSeries = prepareSeries({
293
+ dataKey,
294
+ series,
295
+ defaultType,
296
+ palette,
297
+ color,
298
+ colors,
299
+ seriesLabel,
300
+ defaultDot,
301
+ defaultConnectNulls,
302
+ });
303
+ return { options, preparedSeries, axisConfig: resolveAxisRenderConfig(preparedSeries) };
304
+ };
305
+
306
+ // ─── Tooltip ──────────────────────────────────────────────────────────────────
307
+
308
+ const formatTooltipValue = (value: TooltipValue): string => {
309
+ if (value === null || value === undefined || value === "") return "-";
310
+ if (typeof value === "number") return new Intl.NumberFormat().format(value);
311
+ return String(value);
312
+ };
313
+
314
+ const formatTooltipEntryValue = (
315
+ value: TooltipValue,
316
+ valueFormatter: TooltipValueFormatter | undefined,
317
+ meta: { name?: string | number; dataKey?: string | number },
318
+ ): string => {
319
+ if (valueFormatter && (typeof value === "number" || typeof value === "string")) {
320
+ return valueFormatter(value, meta);
321
+ }
322
+ return formatTooltipValue(value);
323
+ };
324
+
325
+ const formatTooltipLabel = (
326
+ label: string | number | undefined,
327
+ labelFormatter: TooltipLabelFormatter | undefined,
328
+ ): string => {
329
+ if (label === undefined || label === "") return "";
330
+ if (labelFormatter) return labelFormatter(label);
331
+ return String(label);
332
+ };
333
+
334
+ function ChartTooltip({
335
+ active,
336
+ payload,
337
+ label,
338
+ valueFormatter,
339
+ labelFormatter,
340
+ nameKey,
341
+ }: ChartTooltipProps) {
342
+ if (!active || !payload?.length) return null;
343
+
344
+ const tooltipLabel = formatTooltipLabel(label, labelFormatter);
345
+
346
+ return (
347
+ <div className={s.tooltip}>
348
+ {tooltipLabel && <div className={s.tooltip_label}>{tooltipLabel}</div>}
349
+ {payload.map((entry, index) => {
350
+ const fromPayload = nameKey ? entry.payload?.[nameKey] : undefined;
351
+ const rowName = fromPayload ?? entry.name ?? entry.dataKey ?? "Value";
352
+ return (
353
+ <div
354
+ key={`${entry.dataKey ?? entry.name ?? "value"}-${index}`}
355
+ className={s.tooltip_row}
356
+ >
357
+ <span className={s.tooltip_dot} style={{ backgroundColor: entry.color }} />
358
+ <span>{String(rowName)}</span>
359
+ <span className={s.tooltip_value}>
360
+ {formatTooltipEntryValue(entry.value, valueFormatter, {
361
+ name: entry.name,
362
+ dataKey: entry.dataKey,
363
+ })}
364
+ </span>
365
+ </div>
366
+ );
367
+ })}
368
+ </div>
369
+ );
370
+ }
371
+
372
+ // ─── Shared legend ────────────────────────────────────────────────────────────
373
+
374
+ function ChartLegend({ items }: { items: LegendItem[] }) {
375
+ return (
376
+ <div className={s.legend}>
377
+ {items.map((item) => (
378
+ <div key={item.key} className={s.legend_item}>
379
+ <span className={s.legend_dot} style={{ backgroundColor: item.color }} />
380
+ <span>{item.label}</span>
381
+ </div>
382
+ ))}
383
+ </div>
384
+ );
385
+ }
386
+
387
+ // ─── Cartesian frame ──────────────────────────────────────────────────────────
388
+ // Returns an array so Recharts' React.Children.toArray scan finds XAxis/YAxis
389
+ // directly - Fragments are opaque to that scan, arrays are not.
390
+
391
+ const renderCartesianFrame = ({
392
+ categoryKey,
393
+ palette,
394
+ options,
395
+ axisConfig,
396
+ tooltipCursor,
397
+ orientation = "vertical",
398
+ }: {
399
+ categoryKey: string;
400
+ palette: ChartPalette;
401
+ options: ResolvedCartesianOptions;
402
+ axisConfig: AxisRenderConfig;
403
+ tooltipCursor: TooltipCursor;
404
+ orientation?: "vertical" | "horizontal";
405
+ }): ReactNode[] => {
406
+ const isHorizontal = orientation === "horizontal";
407
+
408
+ const items: ReactNode[] = [
409
+ options.showGrid ? (
410
+ <CartesianGrid
411
+ key="grid"
412
+ strokeDasharray="3 3"
413
+ vertical={isHorizontal}
414
+ horizontal={!isHorizontal}
415
+ stroke={palette.grid}
416
+ />
417
+ ) : null,
418
+
419
+ // X axis - category axis (vertical layout) or value axis (horizontal layout)
420
+ options.showXAxis ? (
421
+ isHorizontal ? (
422
+ <XAxis
423
+ key="x-axis"
424
+ type="number"
425
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
426
+ tickMargin={AXIS_TICK_MARGIN}
427
+ tickLine={false}
428
+ axisLine={{ stroke: palette.grid }}
429
+ allowDecimals={options.allowDecimals}
430
+ tickFormatter={options.yTickFormatter}
431
+ />
432
+ ) : (
433
+ <XAxis
434
+ key="x-axis"
435
+ dataKey={categoryKey}
436
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
437
+ tickMargin={AXIS_TICK_MARGIN}
438
+ minTickGap={16}
439
+ tickLine={false}
440
+ axisLine={{ stroke: palette.grid }}
441
+ tickFormatter={options.xTickFormatter}
442
+ />
443
+ )
444
+ ) : null,
445
+
446
+ // Left Y axis - value axis (vertical) or category axis (horizontal)
447
+ options.showYAxis && axisConfig.showLeftAxis ? (
448
+ isHorizontal ? (
449
+ <YAxis
450
+ key="y-axis-left"
451
+ yAxisId="left"
452
+ type="category"
453
+ dataKey={categoryKey}
454
+ width={YAXIS_WIDTH_HORIZONTAL}
455
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
456
+ tickMargin={AXIS_TICK_MARGIN}
457
+ tickLine={false}
458
+ axisLine={{ stroke: palette.grid }}
459
+ tickFormatter={options.xTickFormatter}
460
+ />
461
+ ) : (
462
+ <YAxis
463
+ key="y-axis-left"
464
+ yAxisId="left"
465
+ width={YAXIS_WIDTH}
466
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
467
+ tickMargin={AXIS_TICK_MARGIN}
468
+ tickLine={false}
469
+ axisLine={{ stroke: palette.grid }}
470
+ allowDecimals={options.allowDecimals}
471
+ tickFormatter={options.yTickFormatter}
472
+ />
473
+ )
474
+ ) : null,
475
+
476
+ // Right Y axis - only for vertical orientation with dual-axis series
477
+ options.showYAxis && axisConfig.showRightAxis && !isHorizontal ? (
478
+ <YAxis
479
+ key="y-axis-right"
480
+ yAxisId="right"
481
+ orientation="right"
482
+ width={YAXIS_WIDTH}
483
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
484
+ tickMargin={AXIS_TICK_MARGIN}
485
+ tickLine={false}
486
+ axisLine={{ stroke: palette.grid }}
487
+ allowDecimals={options.allowDecimals}
488
+ tickFormatter={options.yTickFormatter}
489
+ />
490
+ ) : null,
491
+
492
+ options.showTooltip ? (
493
+ <Tooltip
494
+ key="tooltip"
495
+ content={
496
+ <ChartTooltip
497
+ valueFormatter={options.tooltipValueFormatter}
498
+ labelFormatter={options.tooltipLabelFormatter}
499
+ />
500
+ }
501
+ cursor={tooltipCursor}
502
+ />
503
+ ) : null,
504
+ ];
505
+
506
+ for (const [i, rl] of options.referenceLines.entries()) {
507
+ items.push(
508
+ <ReferenceLine
509
+ key={`rl-${i}`}
510
+ {...(rl.axis === "x"
511
+ ? { x: rl.value }
512
+ : {
513
+ y: rl.value,
514
+ yAxisId: options.showYAxis ? (rl.yAxisId ?? "left") : undefined,
515
+ })}
516
+ stroke={resolveColorToken(rl.color, palette.muted)}
517
+ strokeDasharray={rl.strokeDasharray ?? "4 4"}
518
+ strokeWidth={1}
519
+ label={
520
+ rl.label
521
+ ? {
522
+ value: rl.label,
523
+ fontSize: AXIS_TICK_FONT_SIZE,
524
+ fill: palette.muted,
525
+ position: "insideTopLeft",
526
+ }
527
+ : undefined
528
+ }
529
+ />,
530
+ );
531
+ }
532
+
533
+ return items;
534
+ };
535
+
536
+ // ─── Bar chart ────────────────────────────────────────────────────────────────
537
+
538
+ function BarChartRenderer({
539
+ data,
540
+ dataKey,
541
+ categoryKey,
542
+ colors,
543
+ series,
544
+ maxBarSize,
545
+ height,
546
+ showGrid,
547
+ showXAxis,
548
+ showYAxis,
549
+ showTooltip,
550
+ showLegend,
551
+ allowDecimals,
552
+ xTickFormatter,
553
+ yTickFormatter,
554
+ tooltipValueFormatter,
555
+ tooltipLabelFormatter,
556
+ referenceLines,
557
+ orientation = "vertical",
558
+ barCategoryGap,
559
+ }: BarChartProps & { height: number }) {
560
+ const palette = createChartPalette();
561
+ const { options, preparedSeries, axisConfig } = prepareCartesianContext({
562
+ palette,
563
+ dataKey,
564
+ series,
565
+ defaultType: "bar",
566
+ colors,
567
+ showGrid,
568
+ showXAxis,
569
+ showYAxis,
570
+ showTooltip,
571
+ showLegend,
572
+ allowDecimals,
573
+ xTickFormatter,
574
+ yTickFormatter,
575
+ tooltipValueFormatter,
576
+ tooltipLabelFormatter,
577
+ referenceLines,
578
+ });
579
+ const topStackSeriesKeys = resolveStackTopSeriesKeys(preparedSeries);
580
+ const usesCategoryColorMap =
581
+ preparedSeries.length === 1 && !series?.length && Boolean(colors);
582
+ const legendItems = toLegendItems(preparedSeries);
583
+ const isHorizontal = orientation === "horizontal";
584
+
585
+ return (
586
+ <>
587
+ <ResponsiveContainer width="100%" height={height}>
588
+ <RechartsBarChart
589
+ data={data}
590
+ layout={isHorizontal ? "vertical" : "horizontal"}
591
+ margin={CARTESIAN_MARGIN}
592
+ {...(barCategoryGap !== undefined ? { barCategoryGap } : {})}
593
+ >
594
+ {renderCartesianFrame({
595
+ categoryKey,
596
+ palette,
597
+ options,
598
+ axisConfig,
599
+ tooltipCursor: { fill: palette.cursor },
600
+ orientation,
601
+ })}
602
+
603
+ {preparedSeries.map((item) => (
604
+ <Bar
605
+ key={item.key}
606
+ dataKey={item.key}
607
+ name={item.label}
608
+ radius={resolveBarRadius(item, topStackSeriesKeys, orientation)}
609
+ maxBarSize={maxBarSize ?? DEFAULT_MAX_BAR_SIZE}
610
+ fill={item.color}
611
+ stackId={item.stackId}
612
+ yAxisId={options.showYAxis ? item.yAxisId : undefined}
613
+ >
614
+ {usesCategoryColorMap &&
615
+ data.map((entry, index) => {
616
+ const key = toLookupKey(entry[categoryKey]);
617
+ const fill = resolveColorToken(colors?.[key], item.color);
618
+ return <Cell key={`${item.key}-${key}-${index}`} fill={fill} />;
619
+ })}
620
+ </Bar>
621
+ ))}
622
+ </RechartsBarChart>
623
+ </ResponsiveContainer>
624
+ {options.showLegend && legendItems.length > 0 && (
625
+ <ChartLegend items={legendItems} />
626
+ )}
627
+ </>
628
+ );
629
+ }
630
+
631
+ // ─── Area chart ───────────────────────────────────────────────────────────────
632
+
633
+ function AreaChartRenderer({
634
+ data,
635
+ dataKey,
636
+ categoryKey,
637
+ series,
638
+ color,
639
+ seriesLabel,
640
+ height,
641
+ showGrid,
642
+ showXAxis,
643
+ showYAxis,
644
+ showTooltip,
645
+ showLegend,
646
+ allowDecimals,
647
+ xTickFormatter,
648
+ yTickFormatter,
649
+ tooltipValueFormatter,
650
+ tooltipLabelFormatter,
651
+ referenceLines,
652
+ showDots,
653
+ connectNulls,
654
+ }: AreaChartProps & { height: number }) {
655
+ const palette = createChartPalette();
656
+ const { options, preparedSeries, axisConfig } = prepareCartesianContext({
657
+ palette,
658
+ dataKey,
659
+ series,
660
+ defaultType: "area",
661
+ color,
662
+ seriesLabel,
663
+ defaultDot: showDots,
664
+ defaultConnectNulls: connectNulls,
665
+ showGrid,
666
+ showXAxis,
667
+ showYAxis,
668
+ showTooltip,
669
+ showLegend,
670
+ allowDecimals,
671
+ xTickFormatter,
672
+ yTickFormatter,
673
+ tooltipValueFormatter,
674
+ tooltipLabelFormatter,
675
+ referenceLines,
676
+ });
677
+ const idSeed = useId().replace(/:/g, "");
678
+ const gradients = preparedSeries.map((item, index) => ({
679
+ ...item,
680
+ gradientId: `chart-area-${idSeed}-${index}`,
681
+ }));
682
+ const legendItems = toLegendItems(preparedSeries);
683
+
684
+ return (
685
+ <>
686
+ <ResponsiveContainer width="100%" height={height}>
687
+ <RechartsAreaChart data={data} margin={CARTESIAN_MARGIN}>
688
+ <defs>
689
+ {gradients.map((item) => (
690
+ <linearGradient
691
+ key={item.gradientId}
692
+ id={item.gradientId}
693
+ x1="0"
694
+ y1="0"
695
+ x2="0"
696
+ y2="1"
697
+ >
698
+ <stop offset="0%" stopColor={item.color} stopOpacity={item.fillOpacity} />
699
+ <stop offset="95%" stopColor={item.color} stopOpacity={0} />
700
+ </linearGradient>
701
+ ))}
702
+ </defs>
703
+
704
+ {renderCartesianFrame({
705
+ categoryKey,
706
+ palette,
707
+ options,
708
+ axisConfig,
709
+ tooltipCursor: { stroke: palette.cursor, strokeWidth: 1 },
710
+ })}
711
+
712
+ {gradients.map((item) => (
713
+ <Area
714
+ key={item.key}
715
+ type={DEFAULT_LINE_CURVE}
716
+ dataKey={item.key}
717
+ name={item.label}
718
+ stroke={item.color}
719
+ strokeWidth={item.strokeWidth}
720
+ fill={`url(#${item.gradientId})`}
721
+ dot={item.dot ? DOT_PROPS : false}
722
+ activeDot={{ r: 4 }}
723
+ connectNulls={item.connectNulls}
724
+ yAxisId={options.showYAxis ? item.yAxisId : undefined}
725
+ stackId={item.stackId}
726
+ />
727
+ ))}
728
+ </RechartsAreaChart>
729
+ </ResponsiveContainer>
730
+ {options.showLegend && legendItems.length > 0 && (
731
+ <ChartLegend items={legendItems} />
732
+ )}
733
+ </>
734
+ );
735
+ }
736
+
737
+ // ─── Line chart ───────────────────────────────────────────────────────────────
738
+
739
+ function LineChartRenderer({
740
+ data,
741
+ dataKey,
742
+ categoryKey,
743
+ series,
744
+ color,
745
+ seriesLabel,
746
+ curveType,
747
+ height,
748
+ showGrid,
749
+ showXAxis,
750
+ showYAxis,
751
+ showTooltip,
752
+ showLegend,
753
+ allowDecimals,
754
+ xTickFormatter,
755
+ yTickFormatter,
756
+ tooltipValueFormatter,
757
+ tooltipLabelFormatter,
758
+ referenceLines,
759
+ showDots,
760
+ connectNulls,
761
+ }: LineChartProps & { height: number }) {
762
+ const palette = createChartPalette();
763
+ const { options, preparedSeries, axisConfig } = prepareCartesianContext({
764
+ palette,
765
+ dataKey,
766
+ series,
767
+ defaultType: "line",
768
+ color,
769
+ seriesLabel,
770
+ defaultDot: showDots,
771
+ defaultConnectNulls: connectNulls,
772
+ showGrid,
773
+ showXAxis,
774
+ showYAxis,
775
+ showTooltip,
776
+ showLegend,
777
+ allowDecimals,
778
+ xTickFormatter,
779
+ yTickFormatter,
780
+ tooltipValueFormatter,
781
+ tooltipLabelFormatter,
782
+ referenceLines,
783
+ });
784
+ const legendItems = toLegendItems(preparedSeries);
785
+
786
+ return (
787
+ <>
788
+ <ResponsiveContainer width="100%" height={height}>
789
+ <RechartsLineChart data={data} margin={CARTESIAN_MARGIN}>
790
+ {renderCartesianFrame({
791
+ categoryKey,
792
+ palette,
793
+ options,
794
+ axisConfig,
795
+ tooltipCursor: { stroke: palette.cursor, strokeWidth: 1 },
796
+ })}
797
+
798
+ {preparedSeries.map((item) => (
799
+ <Line
800
+ key={item.key}
801
+ type={curveType ?? DEFAULT_LINE_CURVE}
802
+ dataKey={item.key}
803
+ name={item.label}
804
+ stroke={item.color}
805
+ strokeWidth={item.strokeWidth}
806
+ dot={item.dot ? DOT_PROPS : false}
807
+ activeDot={{ r: 4 }}
808
+ connectNulls={item.connectNulls}
809
+ yAxisId={options.showYAxis ? item.yAxisId : undefined}
810
+ />
811
+ ))}
812
+ </RechartsLineChart>
813
+ </ResponsiveContainer>
814
+ {options.showLegend && legendItems.length > 0 && (
815
+ <ChartLegend items={legendItems} />
816
+ )}
817
+ </>
818
+ );
819
+ }
820
+
821
+ // ─── Composed chart ───────────────────────────────────────────────────────────
822
+
823
+ function ComposedChartRenderer({
824
+ data,
825
+ categoryKey,
826
+ series,
827
+ height,
828
+ showGrid,
829
+ showXAxis,
830
+ showYAxis,
831
+ showTooltip,
832
+ showLegend,
833
+ allowDecimals,
834
+ xTickFormatter,
835
+ yTickFormatter,
836
+ tooltipValueFormatter,
837
+ tooltipLabelFormatter,
838
+ referenceLines,
839
+ }: ComposedChartProps & { height: number }) {
840
+ const palette = createChartPalette();
841
+ const { options, preparedSeries, axisConfig } = prepareCartesianContext({
842
+ palette,
843
+ series,
844
+ defaultType: "bar",
845
+ showGrid,
846
+ showXAxis,
847
+ showYAxis,
848
+ showTooltip,
849
+ showLegend,
850
+ allowDecimals,
851
+ xTickFormatter,
852
+ yTickFormatter,
853
+ tooltipValueFormatter,
854
+ tooltipLabelFormatter,
855
+ referenceLines,
856
+ });
857
+ const idSeed = useId().replace(/:/g, "");
858
+ const topStackSeriesKeys = resolveStackTopSeriesKeys(preparedSeries);
859
+ const areaSeries = preparedSeries.filter((s) => s.type === "area");
860
+ const gradientMap = new Map(
861
+ areaSeries.map((item, index) => [item.key, `chart-composed-${idSeed}-${index}`]),
862
+ );
863
+ const legendItems = toLegendItems(preparedSeries);
864
+
865
+ return (
866
+ <>
867
+ <ResponsiveContainer width="100%" height={height}>
868
+ <RechartsComposedChart data={data} margin={CARTESIAN_MARGIN}>
869
+ {areaSeries.length > 0 && (
870
+ <defs>
871
+ {areaSeries.map((item) => {
872
+ const id = gradientMap.get(item.key)!;
873
+ return (
874
+ <linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
875
+ <stop offset="0%" stopColor={item.color} stopOpacity={item.fillOpacity} />
876
+ <stop offset="95%" stopColor={item.color} stopOpacity={0} />
877
+ </linearGradient>
878
+ );
879
+ })}
880
+ </defs>
881
+ )}
882
+
883
+ {renderCartesianFrame({
884
+ categoryKey,
885
+ palette,
886
+ options,
887
+ axisConfig,
888
+ tooltipCursor: { stroke: palette.cursor, strokeWidth: 1 },
889
+ })}
890
+
891
+ {preparedSeries.map((item) => {
892
+ if (item.type === "line") {
893
+ return (
894
+ <Line
895
+ key={item.key}
896
+ type={DEFAULT_LINE_CURVE}
897
+ dataKey={item.key}
898
+ name={item.label}
899
+ stroke={item.color}
900
+ strokeWidth={item.strokeWidth}
901
+ dot={item.dot ? DOT_PROPS : false}
902
+ activeDot={{ r: 4 }}
903
+ connectNulls={item.connectNulls}
904
+ yAxisId={options.showYAxis ? item.yAxisId : undefined}
905
+ />
906
+ );
907
+ }
908
+
909
+ if (item.type === "area") {
910
+ const gradientId = gradientMap.get(item.key);
911
+ return (
912
+ <Area
913
+ key={item.key}
914
+ type={DEFAULT_LINE_CURVE}
915
+ dataKey={item.key}
916
+ name={item.label}
917
+ stroke={item.color}
918
+ strokeWidth={item.strokeWidth}
919
+ fill={gradientId ? `url(#${gradientId})` : item.color}
920
+ fillOpacity={gradientId ? 1 : item.fillOpacity}
921
+ dot={item.dot ? DOT_PROPS : false}
922
+ activeDot={{ r: 4 }}
923
+ connectNulls={item.connectNulls}
924
+ yAxisId={options.showYAxis ? item.yAxisId : undefined}
925
+ stackId={item.stackId}
926
+ />
927
+ );
928
+ }
929
+
930
+ return (
931
+ <Bar
932
+ key={item.key}
933
+ dataKey={item.key}
934
+ name={item.label}
935
+ radius={resolveBarRadius(item, topStackSeriesKeys)}
936
+ maxBarSize={DEFAULT_MAX_BAR_SIZE}
937
+ fill={item.color}
938
+ stackId={item.stackId}
939
+ yAxisId={options.showYAxis ? item.yAxisId : undefined}
940
+ />
941
+ );
942
+ })}
943
+ </RechartsComposedChart>
944
+ </ResponsiveContainer>
945
+ {options.showLegend && legendItems.length > 0 && (
946
+ <ChartLegend items={legendItems} />
947
+ )}
948
+ </>
949
+ );
950
+ }
951
+
952
+ // ─── Donut chart ──────────────────────────────────────────────────────────────
953
+
954
+ function DonutChartRenderer({
955
+ data,
956
+ dataKey,
957
+ nameKey,
958
+ colors,
959
+ centerContent,
960
+ showLegend = true,
961
+ innerRadius,
962
+ outerRadius,
963
+ paddingAngle,
964
+ tooltipValueFormatter,
965
+ tooltipLabelFormatter,
966
+ height,
967
+ }: DonutChartProps & { height: number }) {
968
+ const palette = createChartPalette();
969
+
970
+ const chartData = data.map((entry) => ({
971
+ ...entry,
972
+ __chartValue:
973
+ typeof entry[dataKey] === "number"
974
+ ? (entry[dataKey] as number)
975
+ : Number(entry[dataKey] ?? 0) || 0,
976
+ }));
977
+
978
+ const legendItems: LegendItem[] = data.map((entry, index) => {
979
+ const rawLabel = entry[nameKey];
980
+ const label =
981
+ rawLabel !== undefined && rawLabel !== null && tooltipLabelFormatter
982
+ ? tooltipLabelFormatter(rawLabel as string | number)
983
+ : String(rawLabel ?? `Item ${index + 1}`);
984
+ const lookup = toLookupKey(rawLabel);
985
+ return {
986
+ key: label,
987
+ label,
988
+ // Fix: use series color rotation, not palette.muted, for uncolored segments
989
+ color: resolveColorToken(colors?.[lookup], pickSeriesColor(palette, index)),
990
+ };
991
+ });
992
+
993
+ const resolvedOuterRadius = Math.max(outerRadius ?? Math.max(height * 0.34, 88), 36);
994
+ const resolvedInnerRadius = Math.max(
995
+ 16,
996
+ Math.min(innerRadius ?? Math.max(height * 0.22, 56), resolvedOuterRadius - 12),
997
+ );
998
+ const resolvedCenterSize = Math.max(resolvedInnerRadius * 2 - 12, 56);
999
+ const resolvedPaddingAngle = paddingAngle ?? (chartData.length > 5 ? 2 : 3);
1000
+
1001
+ return (
1002
+ <>
1003
+ <div className={s.canvas}>
1004
+ <ResponsiveContainer width="100%" height={height}>
1005
+ <PieChart>
1006
+ <Pie
1007
+ data={chartData}
1008
+ cx="50%"
1009
+ cy="50%"
1010
+ innerRadius={resolvedInnerRadius}
1011
+ outerRadius={resolvedOuterRadius}
1012
+ paddingAngle={resolvedPaddingAngle}
1013
+ dataKey="__chartValue"
1014
+ nameKey={nameKey}
1015
+ stroke="var(--color-background)"
1016
+ strokeWidth={2}
1017
+ >
1018
+ {legendItems.map((item, index) => (
1019
+ <Cell key={`${item.key}-${index}`} fill={item.color} />
1020
+ ))}
1021
+ </Pie>
1022
+ <Tooltip
1023
+ content={
1024
+ <ChartTooltip
1025
+ valueFormatter={tooltipValueFormatter}
1026
+ labelFormatter={tooltipLabelFormatter}
1027
+ />
1028
+ }
1029
+ />
1030
+ </PieChart>
1031
+ </ResponsiveContainer>
1032
+
1033
+ {centerContent && (
1034
+ <div
1035
+ className={s.center_overlay}
1036
+ style={{ width: resolvedCenterSize, height: resolvedCenterSize }}
1037
+ >
1038
+ {centerContent}
1039
+ </div>
1040
+ )}
1041
+ </div>
1042
+
1043
+ {showLegend && legendItems.length > 0 && <ChartLegend items={legendItems} />}
1044
+ </>
1045
+ );
1046
+ }
1047
+
1048
+ // ─── Radial bar chart ─────────────────────────────────────────────────────────
1049
+
1050
+ function RadialBarChartRenderer({
1051
+ data,
1052
+ dataKey,
1053
+ nameKey,
1054
+ colors,
1055
+ showLegend = true,
1056
+ maxValue,
1057
+ tooltipValueFormatter,
1058
+ tooltipLabelFormatter,
1059
+ height,
1060
+ }: RadialBarChartProps & { height: number }) {
1061
+ const palette = createChartPalette();
1062
+
1063
+ const chartData = data.map((entry, index) => {
1064
+ const rawLabel = entry[nameKey];
1065
+ const lookup = toLookupKey(rawLabel);
1066
+ const color = resolveColorToken(colors?.[lookup], pickSeriesColor(palette, index));
1067
+ const value =
1068
+ typeof entry[dataKey] === "number"
1069
+ ? (entry[dataKey] as number)
1070
+ : Number(entry[dataKey] ?? 0) || 0;
1071
+ return {
1072
+ ...entry,
1073
+ __value: value,
1074
+ __color: color,
1075
+ __label: String(rawLabel ?? `Item ${index + 1}`),
1076
+ };
1077
+ });
1078
+
1079
+ const legendItems: LegendItem[] = chartData.map((item) => ({
1080
+ key: item.__label,
1081
+ label: item.__label,
1082
+ color: item.__color,
1083
+ }));
1084
+
1085
+ const domainMax = maxValue ?? Math.max(...chartData.map((d) => d.__value), 1);
1086
+
1087
+ return (
1088
+ <>
1089
+ <div className={s.canvas}>
1090
+ <ResponsiveContainer width="100%" height={height}>
1091
+ <RechartsRadialBarChart
1092
+ data={chartData}
1093
+ innerRadius="20%"
1094
+ outerRadius="90%"
1095
+ barSize={12}
1096
+ startAngle={90}
1097
+ endAngle={-270}
1098
+ >
1099
+ <PolarAngleAxis type="number" domain={[0, domainMax]} tick={false} />
1100
+ <RadialBar
1101
+ dataKey="__value"
1102
+ cornerRadius={6}
1103
+ background={{ fill: palette.grid }}
1104
+ >
1105
+ {chartData.map((item, index) => (
1106
+ <Cell key={index} fill={item.__color} />
1107
+ ))}
1108
+ </RadialBar>
1109
+ <Tooltip
1110
+ content={
1111
+ <ChartTooltip
1112
+ valueFormatter={tooltipValueFormatter}
1113
+ labelFormatter={tooltipLabelFormatter}
1114
+ nameKey={nameKey}
1115
+ />
1116
+ }
1117
+ />
1118
+ </RechartsRadialBarChart>
1119
+ </ResponsiveContainer>
1120
+ </div>
1121
+ {showLegend && legendItems.length > 0 && <ChartLegend items={legendItems} />}
1122
+ </>
1123
+ );
1124
+ }
1125
+
1126
+ // ─── Scatter chart ────────────────────────────────────────────────────────────
1127
+
1128
+ function ScatterChartRenderer({
1129
+ series,
1130
+ xKey,
1131
+ yKey,
1132
+ zKey,
1133
+ showGrid,
1134
+ showXAxis,
1135
+ showYAxis,
1136
+ showTooltip,
1137
+ showLegend,
1138
+ allowDecimals,
1139
+ xTickFormatter,
1140
+ yTickFormatter,
1141
+ tooltipValueFormatter,
1142
+ height,
1143
+ }: ScatterChartProps & { height: number }) {
1144
+ const palette = createChartPalette();
1145
+
1146
+ const prepared = series.map((s, index) => ({
1147
+ ...s,
1148
+ color: resolveColorToken(s.color, pickSeriesColor(palette, index)),
1149
+ label: s.label ?? s.key,
1150
+ }));
1151
+
1152
+ const legendItems: LegendItem[] = prepared.map((s) => ({
1153
+ key: s.key,
1154
+ label: s.label,
1155
+ color: s.color,
1156
+ }));
1157
+
1158
+ const resolvedShowGrid = showGrid ?? true;
1159
+ const resolvedShowXAxis = showXAxis ?? true;
1160
+ const resolvedShowYAxis = showYAxis ?? true;
1161
+ const resolvedShowTooltip = showTooltip ?? true;
1162
+ const resolvedAllowDecimals = allowDecimals ?? false;
1163
+
1164
+ return (
1165
+ <>
1166
+ <ResponsiveContainer width="100%" height={height}>
1167
+ <RechartsScatterChart margin={CARTESIAN_MARGIN}>
1168
+ {resolvedShowGrid && (
1169
+ <CartesianGrid strokeDasharray="3 3" stroke={palette.grid} />
1170
+ )}
1171
+ {resolvedShowXAxis && (
1172
+ <XAxis
1173
+ dataKey={xKey}
1174
+ type="number"
1175
+ name={xKey}
1176
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
1177
+ tickMargin={AXIS_TICK_MARGIN}
1178
+ tickLine={false}
1179
+ axisLine={{ stroke: palette.grid }}
1180
+ allowDecimals={resolvedAllowDecimals}
1181
+ tickFormatter={xTickFormatter}
1182
+ />
1183
+ )}
1184
+ {resolvedShowYAxis && (
1185
+ <YAxis
1186
+ dataKey={yKey}
1187
+ type="number"
1188
+ name={yKey}
1189
+ width={YAXIS_WIDTH}
1190
+ tick={{ fontSize: AXIS_TICK_FONT_SIZE, fill: palette.muted }}
1191
+ tickMargin={AXIS_TICK_MARGIN}
1192
+ tickLine={false}
1193
+ axisLine={{ stroke: palette.grid }}
1194
+ allowDecimals={resolvedAllowDecimals}
1195
+ tickFormatter={yTickFormatter}
1196
+ />
1197
+ )}
1198
+ {zKey && <ZAxis dataKey={zKey} range={[40, 400]} />}
1199
+ {resolvedShowTooltip && (
1200
+ <Tooltip
1201
+ content={<ChartTooltip valueFormatter={tooltipValueFormatter} />}
1202
+ cursor={{ strokeDasharray: "3 3" }}
1203
+ />
1204
+ )}
1205
+ {prepared.map((s) => (
1206
+ <RechartsScatter
1207
+ key={s.key}
1208
+ name={s.label}
1209
+ data={s.data}
1210
+ fill={s.color}
1211
+ fillOpacity={0.8}
1212
+ />
1213
+ ))}
1214
+ </RechartsScatterChart>
1215
+ </ResponsiveContainer>
1216
+ {showLegend && prepared.length > 1 && <ChartLegend items={legendItems} />}
1217
+ </>
1218
+ );
1219
+ }
1220
+
1221
+ // ─── Public Chart component ───────────────────────────────────────────────────
1222
+
1223
+ // Grace period after `isLoading` flips false before committing to the empty
1224
+ // state. Eliminates the flash when consumers set isLoading=false a render
1225
+ // before populating data - without delaying the genuinely-empty case enough
1226
+ // to feel sluggish.
1227
+ const EMPTY_STATE_GRACE_MS = 150;
1228
+
1229
+ export function Chart(props: ChartProps) {
1230
+ const { title, description, className, emptyMessage, isLoading = false } = props;
1231
+ const height = props.height ?? DEFAULT_CHART_HEIGHT;
1232
+
1233
+ const isEmpty =
1234
+ props.type === "scatter"
1235
+ ? !props.series.length || props.series.every((s) => !s.data.length)
1236
+ : !(props as { data?: ChartDatum[] }).data?.length;
1237
+
1238
+ // Defer the empty state by one tick after a loading→idle transition so the
1239
+ // loader doesn't flicker into "no data" for one paint before data lands.
1240
+ const [showEmptyState, setShowEmptyState] = useState(() => !isLoading && isEmpty);
1241
+ const wasLoadingRef = useRef(isLoading);
1242
+
1243
+ useEffect(() => {
1244
+ const wasLoading = wasLoadingRef.current;
1245
+ wasLoadingRef.current = isLoading;
1246
+
1247
+ if (isLoading || !isEmpty) {
1248
+ setShowEmptyState(false);
1249
+ return;
1250
+ }
1251
+ if (wasLoading) {
1252
+ const id = window.setTimeout(
1253
+ () => setShowEmptyState(true),
1254
+ EMPTY_STATE_GRACE_MS,
1255
+ );
1256
+ return () => window.clearTimeout(id);
1257
+ }
1258
+ setShowEmptyState(true);
1259
+ }, [isLoading, isEmpty]);
1260
+
1261
+ // While loading OR while bridging the loading→empty grace window, show loader.
1262
+ const showLoader = isLoading || (isEmpty && !showEmptyState);
1263
+ const showEmpty = !showLoader && isEmpty;
1264
+
1265
+ return (
1266
+ <section className={clsx(s.card, className)} aria-label={title}>
1267
+ <header className={s.header}>
1268
+ <h3 className={s.title}>{title}</h3>
1269
+ {description && <p className={s.description}>{description}</p>}
1270
+ </header>
1271
+
1272
+ {showLoader ? (
1273
+ <div className={s.loading_state} style={{ height }}>
1274
+ <Loader variant="dots" size="md" color="var(--color-muted-foreground)" />
1275
+ </div>
1276
+ ) : showEmpty ? (
1277
+ <div className={s.empty_state} style={{ height }}>
1278
+ {emptyMessage ?? "No data available"}
1279
+ </div>
1280
+ ) : (
1281
+ <div className={s.plot}>
1282
+ {props.type === "bar" && <BarChartRenderer {...props} height={height} />}
1283
+ {props.type === "area" && <AreaChartRenderer {...props} height={height} />}
1284
+ {props.type === "line" && <LineChartRenderer {...props} height={height} />}
1285
+ {props.type === "composed" && <ComposedChartRenderer {...props} height={height} />}
1286
+ {props.type === "donut" && <DonutChartRenderer {...props} height={height} />}
1287
+ {props.type === "radialbar" && <RadialBarChartRenderer {...props} height={height} />}
1288
+ {props.type === "scatter" && <ScatterChartRenderer {...props} height={height} />}
1289
+ </div>
1290
+ )}
1291
+ </section>
1292
+ );
1293
+ }