@alpic-ai/ui 0.0.0-dev.g2151e99 → 0.0.0-dev.g21c4835
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/accordion-card.d.mts +5 -6
- package/dist/components/accordion.d.mts +5 -6
- package/dist/components/alert.d.mts +9 -11
- package/dist/components/area-chart.d.mts +62 -0
- package/dist/components/area-chart.mjs +269 -0
- package/dist/components/attachment-tile.d.mts +1 -3
- package/dist/components/avatar.d.mts +8 -10
- package/dist/components/badge.d.mts +2 -4
- package/dist/components/bar-chart.d.mts +48 -0
- package/dist/components/bar-chart.mjs +245 -0
- package/dist/components/bar-list.d.mts +28 -0
- package/dist/components/bar-list.mjs +98 -0
- package/dist/components/breadcrumb.d.mts +10 -11
- package/dist/components/button.d.mts +6 -8
- package/dist/components/card.d.mts +9 -10
- 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/checkbox.d.mts +2 -3
- package/dist/components/collapsible.d.mts +4 -5
- package/dist/components/combobox.d.mts +12 -11
- package/dist/components/combobox.mjs +7 -4
- package/dist/components/command.d.mts +9 -10
- package/dist/components/copyable.d.mts +2 -3
- package/dist/components/description-list.d.mts +5 -6
- package/dist/components/dialog.d.mts +15 -17
- package/dist/components/donut-chart.d.mts +46 -0
- package/dist/components/donut-chart.mjs +185 -0
- package/dist/components/dropdown-menu.d.mts +18 -20
- package/dist/components/form.d.mts +38 -21
- package/dist/components/form.mjs +6 -6
- package/dist/components/github-button.d.mts +1 -2
- 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/input-group.d.mts +5 -7
- package/dist/components/input.d.mts +4 -5
- package/dist/components/input.mjs +2 -2
- package/dist/components/label.d.mts +2 -3
- package/dist/components/line-chart.d.mts +55 -0
- package/dist/components/line-chart.mjs +211 -0
- package/dist/components/page-loader.d.mts +1 -3
- package/dist/components/pagination.d.mts +3 -4
- package/dist/components/popover.d.mts +5 -6
- package/dist/components/radio-group.d.mts +3 -4
- package/dist/components/scroll-area.d.mts +3 -4
- package/dist/components/select-trigger-variants.d.mts +1 -3
- package/dist/components/select.d.mts +9 -10
- package/dist/components/separator.d.mts +2 -3
- package/dist/components/sheet.d.mts +11 -12
- package/dist/components/shimmer-text.d.mts +2 -2
- package/dist/components/sidebar.d.mts +34 -36
- package/dist/components/sidebar.mjs +10 -10
- package/dist/components/skeleton.d.mts +2 -4
- package/dist/components/sonner.d.mts +5 -6
- package/dist/components/spinner.d.mts +3 -5
- package/dist/components/stat.d.mts +30 -0
- package/dist/components/stat.mjs +107 -0
- package/dist/components/status-dot.d.mts +2 -4
- package/dist/components/switch.d.mts +2 -3
- package/dist/components/table.d.mts +10 -11
- package/dist/components/tabs.d.mts +12 -14
- package/dist/components/tag.d.mts +3 -5
- package/dist/components/task-progress.d.mts +1 -3
- package/dist/components/textarea.d.mts +3 -4
- package/dist/components/textarea.mjs +2 -2
- package/dist/components/toggle-group.d.mts +4 -6
- package/dist/components/toggle-group.mjs +3 -3
- package/dist/components/tooltip-icon-button.d.mts +1 -2
- package/dist/components/tooltip.d.mts +5 -6
- package/dist/components/typography.d.mts +4 -5
- package/dist/components/wizard.d.mts +4 -5
- package/dist/hooks/use-chart-theme.d.mts +18 -0
- package/dist/hooks/use-chart-theme.mjs +57 -0
- package/dist/hooks/use-mobile.mjs +3 -3
- 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 +30 -29
- package/src/components/area-chart.tsx +339 -0
- package/src/components/bar-chart.tsx +300 -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/combobox.tsx +9 -2
- 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,287 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
import type { ChartPaletteName } from "../lib/chart-palette";
|
|
7
|
+
import { HEAT_EMPTY, heatColor, heatRamp } from "../lib/chart-palette";
|
|
8
|
+
import { cn } from "../lib/cn";
|
|
9
|
+
import { useChartContext } from "./chart-container";
|
|
10
|
+
|
|
11
|
+
export interface HeatmapChartProps {
|
|
12
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
13
|
+
xKey: string;
|
|
14
|
+
yKey: string;
|
|
15
|
+
dataKey?: string;
|
|
16
|
+
variant?: "square" | "dot";
|
|
17
|
+
palette?: ChartPaletteName;
|
|
18
|
+
xLabels?: readonly string[];
|
|
19
|
+
yLabels?: readonly string[];
|
|
20
|
+
highlightPeak?: boolean;
|
|
21
|
+
loading?: boolean;
|
|
22
|
+
valueFormatter?: (value: number) => string;
|
|
23
|
+
xTickFormatter?: (label: string) => string;
|
|
24
|
+
yTickFormatter?: (label: string) => string;
|
|
25
|
+
ariaLabel?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HoverState {
|
|
30
|
+
x: number;
|
|
31
|
+
y: number;
|
|
32
|
+
rowLabel: string;
|
|
33
|
+
colLabel: string;
|
|
34
|
+
value: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const PLACEHOLDER_HEIGHT = 168;
|
|
38
|
+
// viewBox units — the SVG scales to the container width, so these stay squares.
|
|
39
|
+
const CELL = 22;
|
|
40
|
+
const GAP = 3;
|
|
41
|
+
const PAD = { left: 36, top: 18, right: 6, bottom: 6 };
|
|
42
|
+
const MAX_X_TICKS = 13;
|
|
43
|
+
// Dots never fully vanish at zero and never quite touch their neighbours at peak.
|
|
44
|
+
const DOT_MIN = 0.42;
|
|
45
|
+
const DOT_RANGE = 0.52;
|
|
46
|
+
const TOOLTIP_OFFSET = 14;
|
|
47
|
+
const TOOLTIP_WIDTH = 150;
|
|
48
|
+
const TOOLTIP_HEIGHT = 64;
|
|
49
|
+
|
|
50
|
+
// Unambiguous composite key — labels may themselves contain hyphens, spaces, etc.
|
|
51
|
+
const pairKey = (rowLabel: string, colLabel: string) => JSON.stringify([rowLabel, colLabel]);
|
|
52
|
+
|
|
53
|
+
const uniqueInOrder = (values: readonly string[]) => {
|
|
54
|
+
const seen = new Set<string>();
|
|
55
|
+
const ordered: string[] = [];
|
|
56
|
+
for (const value of values) {
|
|
57
|
+
if (!seen.has(value)) {
|
|
58
|
+
seen.add(value);
|
|
59
|
+
ordered.push(value);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return ordered;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function HeatmapChart({
|
|
66
|
+
data,
|
|
67
|
+
xKey,
|
|
68
|
+
yKey,
|
|
69
|
+
dataKey = "value",
|
|
70
|
+
variant = "square",
|
|
71
|
+
palette,
|
|
72
|
+
xLabels,
|
|
73
|
+
yLabels,
|
|
74
|
+
highlightPeak = true,
|
|
75
|
+
loading = false,
|
|
76
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
77
|
+
xTickFormatter,
|
|
78
|
+
yTickFormatter,
|
|
79
|
+
ariaLabel = "Activity heatmap",
|
|
80
|
+
className,
|
|
81
|
+
}: HeatmapChartProps) {
|
|
82
|
+
const { paletteName, theme } = useChartContext(palette);
|
|
83
|
+
const [hovered, setHovered] = React.useState<HoverState | null>(null);
|
|
84
|
+
const [mounted, setMounted] = React.useState(false);
|
|
85
|
+
|
|
86
|
+
React.useEffect(() => {
|
|
87
|
+
setMounted(true);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const layout = React.useMemo(() => {
|
|
91
|
+
const columns = xLabels ? [...xLabels] : uniqueInOrder(data.map((row) => String(row[xKey] ?? "")));
|
|
92
|
+
const rows = yLabels ? [...yLabels] : uniqueInOrder(data.map((row) => String(row[yKey] ?? "")));
|
|
93
|
+
const valueAt = new Map<string, number>();
|
|
94
|
+
for (const row of data) {
|
|
95
|
+
valueAt.set(pairKey(String(row[yKey] ?? ""), String(row[xKey] ?? "")), Number(row[dataKey]) || 0);
|
|
96
|
+
}
|
|
97
|
+
let maxValue = 0;
|
|
98
|
+
let peakKey = "";
|
|
99
|
+
const cells = rows.flatMap((rowLabel, rowIndex) =>
|
|
100
|
+
columns.map((colLabel, colIndex) => {
|
|
101
|
+
const value = valueAt.get(pairKey(rowLabel, colLabel)) ?? 0;
|
|
102
|
+
if (value > maxValue) {
|
|
103
|
+
maxValue = value;
|
|
104
|
+
peakKey = pairKey(rowLabel, colLabel);
|
|
105
|
+
}
|
|
106
|
+
return { rowLabel, colLabel, rowIndex, colIndex, value };
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
return { columns, rows, cells, maxValue, peakKey };
|
|
110
|
+
}, [data, xKey, yKey, dataKey, xLabels, yLabels]);
|
|
111
|
+
|
|
112
|
+
const { columns, rows, cells, maxValue, peakKey } = layout;
|
|
113
|
+
const isEmpty = columns.length === 0 || rows.length === 0 || maxValue <= 0;
|
|
114
|
+
|
|
115
|
+
if (loading) {
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
className={cn(
|
|
119
|
+
"flex items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs",
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
style={{ minHeight: PLACEHOLDER_HEIGHT }}
|
|
123
|
+
>
|
|
124
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
125
|
+
loading…
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (isEmpty) {
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs",
|
|
135
|
+
className,
|
|
136
|
+
)}
|
|
137
|
+
style={{ minHeight: PLACEHOLDER_HEIGHT }}
|
|
138
|
+
>
|
|
139
|
+
no data in range
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const ramp = heatRamp(paletteName, theme.isDark ? HEAT_EMPTY.dark : HEAT_EMPTY.light);
|
|
145
|
+
const totalWidth = PAD.left + columns.length * CELL + (columns.length - 1) * GAP + PAD.right;
|
|
146
|
+
const totalHeight = PAD.top + rows.length * CELL + (rows.length - 1) * GAP + PAD.bottom;
|
|
147
|
+
const xStride = Math.ceil(columns.length / MAX_X_TICKS);
|
|
148
|
+
const cellX = (col: number) => PAD.left + col * (CELL + GAP);
|
|
149
|
+
const cellY = (row: number) => PAD.top + row * (CELL + GAP);
|
|
150
|
+
const formatX = (label: string) => (xTickFormatter ? xTickFormatter(label) : label);
|
|
151
|
+
const formatY = (label: string) => (yTickFormatter ? yTickFormatter(label) : label);
|
|
152
|
+
|
|
153
|
+
// The tooltip follows the cursor; rendered through a portal so a transformed/
|
|
154
|
+
// clipped ancestor (e.g. the card's mount animation + overflow-hidden) can't
|
|
155
|
+
// shift or trim it. Flip near the viewport edges.
|
|
156
|
+
const tooltipLeft =
|
|
157
|
+
hovered && hovered.x + TOOLTIP_OFFSET + TOOLTIP_WIDTH > window.innerWidth
|
|
158
|
+
? hovered.x - TOOLTIP_OFFSET - TOOLTIP_WIDTH
|
|
159
|
+
: (hovered?.x ?? 0) + TOOLTIP_OFFSET;
|
|
160
|
+
const tooltipTop =
|
|
161
|
+
hovered && hovered.y + TOOLTIP_OFFSET + TOOLTIP_HEIGHT > window.innerHeight
|
|
162
|
+
? hovered.y - TOOLTIP_OFFSET - TOOLTIP_HEIGHT
|
|
163
|
+
: (hovered?.y ?? 0) + TOOLTIP_OFFSET;
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<div data-slot="heatmap-chart" className={cn("w-full", className)}>
|
|
167
|
+
<div className="overflow-x-auto">
|
|
168
|
+
<svg
|
|
169
|
+
viewBox={`0 0 ${totalWidth} ${totalHeight}`}
|
|
170
|
+
className="block min-w-[480px]"
|
|
171
|
+
style={{ width: "100%", height: "auto" }}
|
|
172
|
+
role="img"
|
|
173
|
+
aria-label={ariaLabel}
|
|
174
|
+
onMouseLeave={() => setHovered(null)}
|
|
175
|
+
>
|
|
176
|
+
{columns.map((label, col) =>
|
|
177
|
+
col % xStride === 0 ? (
|
|
178
|
+
<text
|
|
179
|
+
key={`x-${label}`}
|
|
180
|
+
x={cellX(col) + CELL / 2}
|
|
181
|
+
y={PAD.top - 7}
|
|
182
|
+
textAnchor="middle"
|
|
183
|
+
className="fill-quaternary-foreground font-mono text-[8.5px]"
|
|
184
|
+
>
|
|
185
|
+
{formatX(label)}
|
|
186
|
+
</text>
|
|
187
|
+
) : null,
|
|
188
|
+
)}
|
|
189
|
+
{rows.map((label, row) => (
|
|
190
|
+
<text
|
|
191
|
+
key={`y-${label}`}
|
|
192
|
+
x={PAD.left - 8}
|
|
193
|
+
y={cellY(row) + CELL / 2 + 3}
|
|
194
|
+
textAnchor="end"
|
|
195
|
+
className="fill-quaternary-foreground font-mono text-[8.5px]"
|
|
196
|
+
>
|
|
197
|
+
{formatY(label)}
|
|
198
|
+
</text>
|
|
199
|
+
))}
|
|
200
|
+
{cells.map((cell) => {
|
|
201
|
+
const fraction = cell.value / maxValue;
|
|
202
|
+
const fill = heatColor(ramp, fraction);
|
|
203
|
+
const key = pairKey(cell.rowLabel, cell.colLabel);
|
|
204
|
+
const isPeak = highlightPeak && key === peakKey;
|
|
205
|
+
if (variant === "dot") {
|
|
206
|
+
return (
|
|
207
|
+
<circle
|
|
208
|
+
key={key}
|
|
209
|
+
cx={cellX(cell.colIndex) + CELL / 2}
|
|
210
|
+
cy={cellY(cell.rowIndex) + CELL / 2}
|
|
211
|
+
r={(CELL / 2) * (DOT_MIN + DOT_RANGE * fraction)}
|
|
212
|
+
fill={fill}
|
|
213
|
+
stroke={isPeak ? theme.foreground : undefined}
|
|
214
|
+
strokeWidth={isPeak ? 1.4 : undefined}
|
|
215
|
+
/>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return (
|
|
219
|
+
<rect
|
|
220
|
+
key={key}
|
|
221
|
+
x={cellX(cell.colIndex)}
|
|
222
|
+
y={cellY(cell.rowIndex)}
|
|
223
|
+
width={CELL}
|
|
224
|
+
height={CELL}
|
|
225
|
+
rx={2.5}
|
|
226
|
+
fill={fill}
|
|
227
|
+
stroke={isPeak ? theme.foreground : undefined}
|
|
228
|
+
strokeWidth={isPeak ? 1.4 : undefined}
|
|
229
|
+
/>
|
|
230
|
+
);
|
|
231
|
+
})}
|
|
232
|
+
{/* Transparent hit layer over the full pitch so every cell — and the gaps
|
|
233
|
+
between dots — registers hover uniformly. */}
|
|
234
|
+
{cells.map((cell) => (
|
|
235
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: decorative hover tooltip; all values are visible on the cells
|
|
236
|
+
<rect
|
|
237
|
+
key={`hit-${pairKey(cell.rowLabel, cell.colLabel)}`}
|
|
238
|
+
x={cellX(cell.colIndex)}
|
|
239
|
+
y={cellY(cell.rowIndex)}
|
|
240
|
+
width={CELL + GAP}
|
|
241
|
+
height={CELL + GAP}
|
|
242
|
+
fill="transparent"
|
|
243
|
+
onMouseMove={(event) =>
|
|
244
|
+
setHovered({
|
|
245
|
+
x: event.clientX,
|
|
246
|
+
y: event.clientY,
|
|
247
|
+
rowLabel: cell.rowLabel,
|
|
248
|
+
colLabel: cell.colLabel,
|
|
249
|
+
value: cell.value,
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
</svg>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{mounted &&
|
|
258
|
+
hovered &&
|
|
259
|
+
createPortal(
|
|
260
|
+
<div
|
|
261
|
+
className="pointer-events-none fixed z-50 min-w-[130px] rounded-lg border border-border bg-popover px-3 py-2.5 text-popover-foreground shadow-lg"
|
|
262
|
+
style={{ left: tooltipLeft, top: tooltipTop }}
|
|
263
|
+
>
|
|
264
|
+
<p className="mb-1.5 font-mono text-[10px] text-quaternary-foreground uppercase tracking-wider">
|
|
265
|
+
{formatY(hovered.rowLabel)} · {formatX(hovered.colLabel)}
|
|
266
|
+
</p>
|
|
267
|
+
<div className="flex items-center justify-between gap-4 text-text-xs">
|
|
268
|
+
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
|
269
|
+
<span
|
|
270
|
+
aria-hidden
|
|
271
|
+
className="size-2 shrink-0 rounded-[2px]"
|
|
272
|
+
style={{ background: heatColor(ramp, hovered.value / maxValue) }}
|
|
273
|
+
/>
|
|
274
|
+
activity
|
|
275
|
+
</span>
|
|
276
|
+
<span className="font-mono font-semibold tabular-nums text-foreground">
|
|
277
|
+
{valueFormatter(hovered.value)}
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
</div>,
|
|
281
|
+
document.body,
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export { HeatmapChart };
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import {
|
|
5
|
+
CartesianGrid,
|
|
6
|
+
LabelList,
|
|
7
|
+
Line,
|
|
8
|
+
LineChart as RechartsLineChart,
|
|
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, 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 CURVE_TYPE = { monotone: "monotone", linear: "linear", step: "stepAfter" } as const;
|
|
28
|
+
|
|
29
|
+
export interface LineChartProps {
|
|
30
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
31
|
+
index: string;
|
|
32
|
+
series: ChartSeries[];
|
|
33
|
+
curve?: keyof typeof CURVE_TYPE;
|
|
34
|
+
legend?: boolean;
|
|
35
|
+
valueFlags?: boolean;
|
|
36
|
+
dots?: boolean;
|
|
37
|
+
height?: number;
|
|
38
|
+
yAxisWidth?: number;
|
|
39
|
+
palette?: ChartPaletteName;
|
|
40
|
+
referenceLine?: { y: number; label?: string; band?: boolean };
|
|
41
|
+
markers?: ChartMarker[];
|
|
42
|
+
lastValueLabel?: boolean;
|
|
43
|
+
loading?: boolean;
|
|
44
|
+
valueFormatter?: (value: number) => string;
|
|
45
|
+
labelFormatter?: (label: string | number) => string;
|
|
46
|
+
className?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function LineChart({
|
|
50
|
+
data,
|
|
51
|
+
index,
|
|
52
|
+
series,
|
|
53
|
+
curve = "monotone",
|
|
54
|
+
legend = false,
|
|
55
|
+
valueFlags = false,
|
|
56
|
+
dots = false,
|
|
57
|
+
height = 200,
|
|
58
|
+
yAxisWidth = 48,
|
|
59
|
+
palette,
|
|
60
|
+
referenceLine,
|
|
61
|
+
markers,
|
|
62
|
+
lastValueLabel = false,
|
|
63
|
+
loading = false,
|
|
64
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
65
|
+
labelFormatter,
|
|
66
|
+
className,
|
|
67
|
+
}: LineChartProps) {
|
|
68
|
+
const { palette: paletteColors, theme } = useChartContext(palette);
|
|
69
|
+
const reducedMotion = useReducedMotion();
|
|
70
|
+
const animated = !reducedMotion;
|
|
71
|
+
|
|
72
|
+
const resolved = resolveSeries(series, paletteColors, theme);
|
|
73
|
+
|
|
74
|
+
const numericMax = React.useMemo(() => {
|
|
75
|
+
let max = 0;
|
|
76
|
+
for (const row of data) {
|
|
77
|
+
for (const entry of series) {
|
|
78
|
+
const value = Number(row[entry.key]);
|
|
79
|
+
if (Number.isFinite(value) && value > max) {
|
|
80
|
+
max = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return max;
|
|
85
|
+
}, [data, series]);
|
|
86
|
+
|
|
87
|
+
const curveType = CURVE_TYPE[curve];
|
|
88
|
+
const margin = { top: markers?.length ? 18 : 8, right: lastValueLabel ? 56 : 8, bottom: 2, left: 0 };
|
|
89
|
+
|
|
90
|
+
const axis = {
|
|
91
|
+
stroke: theme.border,
|
|
92
|
+
tick: { fill: theme.axisForeground, fontSize: 10, fontFamily: theme.fontMono },
|
|
93
|
+
tickLine: false as const,
|
|
94
|
+
axisLine: { stroke: theme.border, strokeOpacity: 0.6 },
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const legendItems = resolved.map((entry) => ({ name: entry.name, color: entry.color, dashed: entry.dashed }));
|
|
98
|
+
|
|
99
|
+
const activeDotFor = (entry: (typeof resolved)[number]) =>
|
|
100
|
+
valueFlags
|
|
101
|
+
? (dotProps: { cx?: number; cy?: number; value?: number | string }) => {
|
|
102
|
+
if (dotProps.cx == null || dotProps.cy == null) {
|
|
103
|
+
return <g />;
|
|
104
|
+
}
|
|
105
|
+
return (
|
|
106
|
+
<g>
|
|
107
|
+
<circle
|
|
108
|
+
cx={dotProps.cx}
|
|
109
|
+
cy={dotProps.cy}
|
|
110
|
+
r={3.5}
|
|
111
|
+
fill={entry.color}
|
|
112
|
+
stroke={theme.card}
|
|
113
|
+
strokeWidth={2}
|
|
114
|
+
/>
|
|
115
|
+
<text
|
|
116
|
+
x={dotProps.cx}
|
|
117
|
+
y={dotProps.cy - 8}
|
|
118
|
+
textAnchor="middle"
|
|
119
|
+
fill={entry.color}
|
|
120
|
+
fontFamily={theme.fontMono}
|
|
121
|
+
fontSize={10}
|
|
122
|
+
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
123
|
+
>
|
|
124
|
+
{valueFormatter(Number(dotProps.value ?? 0))}
|
|
125
|
+
</text>
|
|
126
|
+
</g>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
: { r: 3.5, fill: entry.color, stroke: theme.card, strokeWidth: 2 };
|
|
130
|
+
|
|
131
|
+
const renderLastLabel =
|
|
132
|
+
(color: string) =>
|
|
133
|
+
(props: {
|
|
134
|
+
x?: string | number;
|
|
135
|
+
y?: string | number;
|
|
136
|
+
value?: string | number | boolean | Array<string | number | boolean> | null;
|
|
137
|
+
index?: number;
|
|
138
|
+
}) => {
|
|
139
|
+
if (props.index !== data.length - 1 || props.x == null || props.y == null) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return (
|
|
143
|
+
<text
|
|
144
|
+
x={Number(props.x) + 6}
|
|
145
|
+
y={Number(props.y)}
|
|
146
|
+
dy={3}
|
|
147
|
+
fill={color}
|
|
148
|
+
fontFamily={theme.fontMono}
|
|
149
|
+
fontSize={10}
|
|
150
|
+
textAnchor="start"
|
|
151
|
+
style={{ fontVariantNumeric: "tabular-nums" }}
|
|
152
|
+
>
|
|
153
|
+
{valueFormatter(Number(props.value ?? 0))}
|
|
154
|
+
</text>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const isEmpty = data.length === 0 || resolved.length === 0;
|
|
159
|
+
|
|
160
|
+
return (
|
|
161
|
+
<div data-slot="line-chart" className={cn("flex w-full flex-col gap-3", className)}>
|
|
162
|
+
<div className="w-full" style={{ height }}>
|
|
163
|
+
{loading ? (
|
|
164
|
+
<div className="flex h-full items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs">
|
|
165
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
166
|
+
loading…
|
|
167
|
+
</div>
|
|
168
|
+
) : isEmpty ? (
|
|
169
|
+
<div className="flex h-full items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs">
|
|
170
|
+
no data in range
|
|
171
|
+
</div>
|
|
172
|
+
) : (
|
|
173
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
174
|
+
<RechartsLineChart data={data as Record<string, string | number>[]} margin={margin}>
|
|
175
|
+
<CartesianGrid vertical={false} stroke={theme.grid} strokeDasharray="2 4" />
|
|
176
|
+
<XAxis dataKey={index} {...axis} interval="preserveStartEnd" minTickGap={44} />
|
|
177
|
+
<YAxis {...axis} width={yAxisWidth} tickFormatter={(value: number) => valueFormatter(value)} />
|
|
178
|
+
<Tooltip
|
|
179
|
+
offset={12}
|
|
180
|
+
allowEscapeViewBox={{ x: false, y: false }}
|
|
181
|
+
cursor={{ stroke: theme.axisForeground, strokeWidth: 1, strokeDasharray: "3 3" }}
|
|
182
|
+
content={<ChartTooltipContent valueFormatter={valueFormatter} labelFormatter={labelFormatter} />}
|
|
183
|
+
/>
|
|
184
|
+
{referenceLine?.band && (
|
|
185
|
+
<ReferenceArea
|
|
186
|
+
y1={referenceLine.y}
|
|
187
|
+
y2={numericMax}
|
|
188
|
+
fill={theme.warning}
|
|
189
|
+
fillOpacity={0.06}
|
|
190
|
+
ifOverflow="extendDomain"
|
|
191
|
+
/>
|
|
192
|
+
)}
|
|
193
|
+
{referenceLine && (
|
|
194
|
+
<ReferenceLine
|
|
195
|
+
y={referenceLine.y}
|
|
196
|
+
stroke={theme.warning}
|
|
197
|
+
strokeDasharray="4 4"
|
|
198
|
+
strokeOpacity={0.6}
|
|
199
|
+
label={
|
|
200
|
+
referenceLine.label
|
|
201
|
+
? {
|
|
202
|
+
value: referenceLine.label,
|
|
203
|
+
fill: theme.warning,
|
|
204
|
+
fontSize: 9,
|
|
205
|
+
fontFamily: theme.fontMono,
|
|
206
|
+
position: "insideBottomRight",
|
|
207
|
+
}
|
|
208
|
+
: undefined
|
|
209
|
+
}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{resolved.map((entry) => (
|
|
214
|
+
<Line
|
|
215
|
+
key={entry.key}
|
|
216
|
+
type={curveType}
|
|
217
|
+
dataKey={entry.key}
|
|
218
|
+
name={entry.name}
|
|
219
|
+
stroke={entry.color}
|
|
220
|
+
strokeWidth={entry.dashed ? 2 : 1.8}
|
|
221
|
+
strokeDasharray={entry.dashed ? "5 3" : undefined}
|
|
222
|
+
dot={dots ? { r: 2.5, fill: entry.color, strokeWidth: 0 } : false}
|
|
223
|
+
activeDot={activeDotFor(entry)}
|
|
224
|
+
isAnimationActive={animated}
|
|
225
|
+
animationDuration={650}
|
|
226
|
+
animationEasing="ease-out"
|
|
227
|
+
>
|
|
228
|
+
{lastValueLabel && <LabelList dataKey={entry.key} content={renderLastLabel(entry.color)} />}
|
|
229
|
+
</Line>
|
|
230
|
+
))}
|
|
231
|
+
|
|
232
|
+
{markers?.map((marker) => (
|
|
233
|
+
<ReferenceDot
|
|
234
|
+
key={`${marker.x}-${marker.y}`}
|
|
235
|
+
x={marker.x}
|
|
236
|
+
y={marker.y}
|
|
237
|
+
r={3.5}
|
|
238
|
+
fill={marker.color ?? theme.foreground}
|
|
239
|
+
stroke={theme.card}
|
|
240
|
+
strokeWidth={2}
|
|
241
|
+
label={
|
|
242
|
+
marker.label
|
|
243
|
+
? {
|
|
244
|
+
value: marker.label,
|
|
245
|
+
fill: marker.color ?? theme.foreground,
|
|
246
|
+
fontSize: 9,
|
|
247
|
+
fontFamily: theme.fontMono,
|
|
248
|
+
position: "top",
|
|
249
|
+
}
|
|
250
|
+
: undefined
|
|
251
|
+
}
|
|
252
|
+
/>
|
|
253
|
+
))}
|
|
254
|
+
</RechartsLineChart>
|
|
255
|
+
</ResponsiveContainer>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{legend && !isEmpty && <ChartLegend items={legendItems} style={{ paddingLeft: yAxisWidth }} />}
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { LineChart };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { Area, AreaChart, ResponsiveContainer } from "recharts";
|
|
7
|
+
|
|
8
|
+
import { cn } from "../lib/cn";
|
|
9
|
+
import { useChartContext } from "./chart-container";
|
|
10
|
+
|
|
11
|
+
const statDeltaVariants = cva(
|
|
12
|
+
"inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 font-mono text-[11px] font-medium leading-none",
|
|
13
|
+
{
|
|
14
|
+
variants: {
|
|
15
|
+
sentiment: {
|
|
16
|
+
positive: "text-success bg-success/12",
|
|
17
|
+
negative: "text-destructive bg-destructive/12",
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
defaultVariants: { sentiment: "positive" },
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
export interface StatDelta {
|
|
25
|
+
value: number;
|
|
26
|
+
direction: "up" | "down";
|
|
27
|
+
invert?: boolean;
|
|
28
|
+
label?: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface StatProps extends React.ComponentProps<"div"> {
|
|
32
|
+
value: React.ReactNode;
|
|
33
|
+
unit?: string;
|
|
34
|
+
delta?: StatDelta;
|
|
35
|
+
sparkline?: number[] | Array<{ value: number }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const toSparkData = (sparkline: StatProps["sparkline"]) =>
|
|
39
|
+
(sparkline ?? []).map((point, index) =>
|
|
40
|
+
typeof point === "number" ? { index, value: point } : { index, value: point.value },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function Stat({ value, unit, delta, sparkline, className, ...props }: StatProps) {
|
|
44
|
+
const { palette } = useChartContext();
|
|
45
|
+
const gradientId = React.useId().replace(/:/g, "");
|
|
46
|
+
const sparkData = React.useMemo(() => toSparkData(sparkline), [sparkline]);
|
|
47
|
+
// biome-ignore lint/style/noNonNullAssertion: palettes are never empty
|
|
48
|
+
const sparkColor = palette[0]!;
|
|
49
|
+
|
|
50
|
+
const sentiment =
|
|
51
|
+
delta && (delta.invert ? delta.direction === "down" : delta.direction === "up") ? "positive" : "negative";
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div data-slot="stat" className={cn("flex flex-col gap-2.5", className)} {...props}>
|
|
55
|
+
<div className="flex items-baseline gap-3">
|
|
56
|
+
<span className="type-display-sm font-bold leading-none tracking-tight tabular-nums text-foreground">
|
|
57
|
+
{value}
|
|
58
|
+
</span>
|
|
59
|
+
{unit && <span className="font-mono text-[11px] leading-none text-quaternary-foreground mb-0.5">{unit}</span>}
|
|
60
|
+
{delta && (
|
|
61
|
+
<DeltaPill sentiment={sentiment} className="mb-0.5">
|
|
62
|
+
{delta.direction === "up" ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" />}
|
|
63
|
+
{delta.label ?? `${delta.value}%`}
|
|
64
|
+
</DeltaPill>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
{sparkData.length > 0 && (
|
|
68
|
+
<div className="h-9 w-full">
|
|
69
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
70
|
+
<AreaChart data={sparkData} margin={{ top: 4, right: 2, bottom: 0, left: 2 }}>
|
|
71
|
+
<defs>
|
|
72
|
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
|
73
|
+
<stop offset="0%" stopColor={sparkColor} stopOpacity={0.4} />
|
|
74
|
+
<stop offset="100%" stopColor={sparkColor} stopOpacity={0} />
|
|
75
|
+
</linearGradient>
|
|
76
|
+
</defs>
|
|
77
|
+
<Area
|
|
78
|
+
type="monotone"
|
|
79
|
+
dataKey="value"
|
|
80
|
+
stroke={sparkColor}
|
|
81
|
+
strokeWidth={1.6}
|
|
82
|
+
fill={`url(#${gradientId})`}
|
|
83
|
+
isAnimationActive={false}
|
|
84
|
+
dot={({ cx, cy, index }) => {
|
|
85
|
+
const isLast = index === sparkData.length - 1;
|
|
86
|
+
return isLast && cx != null && cy != null ? (
|
|
87
|
+
<circle key={index} cx={cx} cy={cy} r={2.5} fill={sparkColor} />
|
|
88
|
+
) : (
|
|
89
|
+
<g key={index} />
|
|
90
|
+
);
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
</AreaChart>
|
|
94
|
+
</ResponsiveContainer>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function DeltaPill({
|
|
102
|
+
sentiment,
|
|
103
|
+
className,
|
|
104
|
+
...props
|
|
105
|
+
}: React.ComponentProps<"span"> & VariantProps<typeof statDeltaVariants>) {
|
|
106
|
+
return <span className={cn(statDeltaVariants({ sentiment }), className)} {...props} />;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { Stat, statDeltaVariants };
|