@alpic-ai/ui 0.0.0-dev.g2df19cc → 0.0.0-dev.g2eefcc2
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 +256 -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/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 -23
- package/dist/components/wizard.mjs +1 -19
- 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 +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/combobox.tsx +9 -2
- package/src/components/donut-chart.tsx +217 -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/components/wizard.tsx +1 -35
- 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 +198 -0
- package/src/stories/bar-chart.stories.tsx +167 -0
- package/src/stories/bar-list.stories.tsx +83 -0
- package/src/stories/donut-chart.stories.tsx +110 -0
- package/src/stories/heatmap-chart.stories.tsx +105 -0
- package/src/stories/line-chart.stories.tsx +144 -0
- package/src/stories/stat.stories.tsx +64 -0
- package/src/stories/wizard.stories.tsx +22 -4
- package/src/styles/tokens.css +63 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { useChartContext } from "./chart-container";
|
|
5
|
+
|
|
6
|
+
export interface ChartTooltipItem {
|
|
7
|
+
name?: string | number;
|
|
8
|
+
value?: number | string;
|
|
9
|
+
color?: string;
|
|
10
|
+
stroke?: string;
|
|
11
|
+
fill?: string;
|
|
12
|
+
dataKey?: string | number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ChartTooltipContentProps {
|
|
16
|
+
active?: boolean;
|
|
17
|
+
payload?: ChartTooltipItem[];
|
|
18
|
+
label?: string | number;
|
|
19
|
+
valueFormatter?: (value: number) => string;
|
|
20
|
+
labelFormatter?: (label: string | number) => string;
|
|
21
|
+
hideLabel?: boolean;
|
|
22
|
+
showTotal?: boolean;
|
|
23
|
+
totalLabel?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function ChartTooltipContent({
|
|
28
|
+
active,
|
|
29
|
+
payload,
|
|
30
|
+
label,
|
|
31
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
32
|
+
labelFormatter,
|
|
33
|
+
hideLabel,
|
|
34
|
+
showTotal,
|
|
35
|
+
totalLabel = "Total",
|
|
36
|
+
className,
|
|
37
|
+
}: ChartTooltipContentProps) {
|
|
38
|
+
const { theme } = useChartContext();
|
|
39
|
+
|
|
40
|
+
if (!active || !payload?.length) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const total = payload.reduce(
|
|
45
|
+
(sum, item) => sum + (typeof item.value === "number" ? item.value : Number(item.value ?? 0)),
|
|
46
|
+
0,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
className={cn(
|
|
52
|
+
"min-w-[130px] rounded-lg border px-3 py-2.5 shadow-lg",
|
|
53
|
+
"border-border bg-popover text-popover-foreground",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{!hideLabel && label !== undefined && (
|
|
58
|
+
<p className="font-mono text-[10px] uppercase tracking-wider text-quaternary-foreground mb-1.5">
|
|
59
|
+
{labelFormatter ? labelFormatter(label) : label}
|
|
60
|
+
</p>
|
|
61
|
+
)}
|
|
62
|
+
<div className="flex flex-col gap-1">
|
|
63
|
+
{payload.map((item, index) => {
|
|
64
|
+
const swatch =
|
|
65
|
+
[item.color, item.stroke, item.fill].find(
|
|
66
|
+
(candidate): candidate is string =>
|
|
67
|
+
typeof candidate === "string" && candidate.length > 0 && !candidate.startsWith("url("),
|
|
68
|
+
) ?? theme.mutedForeground;
|
|
69
|
+
const numeric = typeof item.value === "number" ? item.value : Number(item.value ?? 0);
|
|
70
|
+
return (
|
|
71
|
+
<div key={`${item.dataKey ?? index}`} className="flex items-center justify-between gap-4 text-text-xs">
|
|
72
|
+
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
|
73
|
+
<span aria-hidden className="size-2 shrink-0 rounded-[2px]" style={{ background: swatch }} />
|
|
74
|
+
{item.name}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="font-mono font-semibold tabular-nums text-foreground">{valueFormatter(numeric)}</span>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
{showTotal && (
|
|
81
|
+
<div className="mt-1 flex items-center justify-between gap-4 border-border border-t pt-1.5 text-text-xs">
|
|
82
|
+
<span className="font-mono text-quaternary-foreground uppercase tracking-wider text-[10px]">
|
|
83
|
+
{totalLabel}
|
|
84
|
+
</span>
|
|
85
|
+
<span className="font-mono font-semibold tabular-nums text-foreground">{valueFormatter(total)}</span>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export { ChartTooltipContent };
|
|
@@ -21,6 +21,8 @@ interface ComboboxContextValue {
|
|
|
21
21
|
isSelected: (itemValue: string) => boolean;
|
|
22
22
|
open: boolean;
|
|
23
23
|
onOpenChange: (open: boolean) => void;
|
|
24
|
+
/** Resolves a selected value to a display label (e.g. for multi-select tags). Defaults to the value. */
|
|
25
|
+
getOptionLabel?: (value: string) => string;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
const ComboboxContext = createContext<ComboboxContextValue | null>(null);
|
|
@@ -40,6 +42,8 @@ interface ComboboxBaseProps {
|
|
|
40
42
|
open?: boolean;
|
|
41
43
|
defaultOpen?: boolean;
|
|
42
44
|
onOpenChange?: (open: boolean) => void;
|
|
45
|
+
/** Resolves a selected value to a display label (e.g. for multi-select tags). Defaults to the value. */
|
|
46
|
+
getOptionLabel?: (value: string) => string;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
interface ComboboxSingleProps extends ComboboxBaseProps {
|
|
@@ -65,6 +69,7 @@ function Combobox(props: ComboboxProps) {
|
|
|
65
69
|
open: controlledOpen,
|
|
66
70
|
defaultOpen = false,
|
|
67
71
|
onOpenChange: controlledOnOpenChange,
|
|
72
|
+
getOptionLabel,
|
|
68
73
|
} = props;
|
|
69
74
|
|
|
70
75
|
// Single mode state
|
|
@@ -175,8 +180,9 @@ function Combobox(props: ComboboxProps) {
|
|
|
175
180
|
isSelected,
|
|
176
181
|
open,
|
|
177
182
|
onOpenChange,
|
|
183
|
+
getOptionLabel,
|
|
178
184
|
}),
|
|
179
|
-
[multiple, singleValue, multiValues, onSelect, onDeselect, isSelected, open, onOpenChange],
|
|
185
|
+
[multiple, singleValue, multiValues, onSelect, onDeselect, isSelected, open, onOpenChange, getOptionLabel],
|
|
180
186
|
);
|
|
181
187
|
|
|
182
188
|
return (
|
|
@@ -229,6 +235,7 @@ function ComboboxTrigger({ className, size, placeholder, children, ...props }: C
|
|
|
229
235
|
/* ── Tags (internal, for multi-select trigger) ────────────────────────────── */
|
|
230
236
|
|
|
231
237
|
function ComboboxTags({ values, onDeselect }: { values: string[]; onDeselect: (value: string) => void }) {
|
|
238
|
+
const { getOptionLabel } = useComboboxContext();
|
|
232
239
|
return (
|
|
233
240
|
<>
|
|
234
241
|
{values.map((tagValue) => (
|
|
@@ -237,7 +244,7 @@ function ComboboxTags({ values, onDeselect }: { values: string[]; onDeselect: (v
|
|
|
237
244
|
onClick={(event) => event.stopPropagation()}
|
|
238
245
|
onDismiss={() => onDeselect(tagValue)}
|
|
239
246
|
>
|
|
240
|
-
{tagValue}
|
|
247
|
+
{getOptionLabel ? getOptionLabel(tagValue) : tagValue}
|
|
241
248
|
</TagDismissible>
|
|
242
249
|
))}
|
|
243
250
|
</>
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import { Cell, Pie, PieChart as RechartsPieChart, ResponsiveContainer } from "recharts";
|
|
5
|
+
|
|
6
|
+
import { useReducedMotion } from "../hooks/use-reduced-motion";
|
|
7
|
+
import { formatShare } from "../lib/chart";
|
|
8
|
+
import type { ChartPaletteName } from "../lib/chart-palette";
|
|
9
|
+
import { paletteColor } from "../lib/chart-palette";
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
import { useChartContext } from "./chart-container";
|
|
12
|
+
|
|
13
|
+
const GEOMETRY = {
|
|
14
|
+
donut: { inner: "64%", outer: "92%" },
|
|
15
|
+
ring: { inner: "78%", outer: "92%" },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export interface DonutChartProps {
|
|
19
|
+
data: ReadonlyArray<Record<string, string | number | null | undefined>>;
|
|
20
|
+
index: string;
|
|
21
|
+
dataKey?: string;
|
|
22
|
+
variant?: keyof typeof GEOMETRY;
|
|
23
|
+
legend?: boolean;
|
|
24
|
+
paddingAngle?: number;
|
|
25
|
+
height?: number;
|
|
26
|
+
palette?: ChartPaletteName;
|
|
27
|
+
centerLabel?: string;
|
|
28
|
+
loading?: boolean;
|
|
29
|
+
valueFormatter?: (value: number) => string;
|
|
30
|
+
labelFormatter?: (label: string | number) => string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function DonutChart({
|
|
35
|
+
data,
|
|
36
|
+
index,
|
|
37
|
+
dataKey = "value",
|
|
38
|
+
variant = "donut",
|
|
39
|
+
legend = false,
|
|
40
|
+
paddingAngle = 1,
|
|
41
|
+
height = 220,
|
|
42
|
+
palette,
|
|
43
|
+
centerLabel = "total",
|
|
44
|
+
loading = false,
|
|
45
|
+
valueFormatter = (value) => value.toLocaleString("en-US"),
|
|
46
|
+
labelFormatter,
|
|
47
|
+
className,
|
|
48
|
+
}: DonutChartProps) {
|
|
49
|
+
const { palette: paletteColors, theme } = useChartContext(palette);
|
|
50
|
+
const reactId = React.useId().replace(/:/g, "");
|
|
51
|
+
const reducedMotion = useReducedMotion();
|
|
52
|
+
const animated = !reducedMotion;
|
|
53
|
+
const [active, setActive] = React.useState<number | null>(null);
|
|
54
|
+
const rowRefs = React.useRef<Array<HTMLDivElement | null>>([]);
|
|
55
|
+
|
|
56
|
+
const slices = React.useMemo(() => {
|
|
57
|
+
const mapped = data.map((row) => ({
|
|
58
|
+
name: String(row[index] ?? ""),
|
|
59
|
+
value: Number(row[dataKey]) || 0,
|
|
60
|
+
}));
|
|
61
|
+
mapped.sort((lower, upper) => upper.value - lower.value);
|
|
62
|
+
return mapped.map((slice, rank) => ({ ...slice, color: paletteColor(paletteColors, rank) }));
|
|
63
|
+
}, [data, index, dataKey, paletteColors]);
|
|
64
|
+
|
|
65
|
+
const total = slices.reduce((sum, slice) => sum + slice.value, 0);
|
|
66
|
+
const maxValue = slices.reduce((max, slice) => (slice.value > max ? slice.value : max), 0);
|
|
67
|
+
const geometry = GEOMETRY[variant] ?? GEOMETRY.donut;
|
|
68
|
+
const formatName = (name: string) => (labelFormatter ? labelFormatter(name) : name);
|
|
69
|
+
|
|
70
|
+
const isEmpty = slices.length === 0 || total <= 0;
|
|
71
|
+
|
|
72
|
+
const activeSlice = active !== null ? slices[active] : undefined;
|
|
73
|
+
const centerTitle = activeSlice ? formatName(activeSlice.name) : centerLabel;
|
|
74
|
+
const centerValue = valueFormatter(activeSlice ? activeSlice.value : total);
|
|
75
|
+
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
if (active !== null) {
|
|
78
|
+
rowRefs.current[active]?.scrollIntoView({ block: "nearest" });
|
|
79
|
+
}
|
|
80
|
+
}, [active]);
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div data-slot="donut-chart" className={cn("@container flex w-full flex-col gap-3", className)}>
|
|
84
|
+
{loading ? (
|
|
85
|
+
<div
|
|
86
|
+
className="flex items-center justify-center gap-2.5 font-mono text-quaternary-foreground text-text-xs"
|
|
87
|
+
style={{ height }}
|
|
88
|
+
>
|
|
89
|
+
<span className="size-4 animate-spin rounded-full border-2 border-border border-t-foreground" />
|
|
90
|
+
loading…
|
|
91
|
+
</div>
|
|
92
|
+
) : isEmpty ? (
|
|
93
|
+
<div
|
|
94
|
+
className="flex items-center justify-center rounded-lg border border-border border-dashed font-mono text-quaternary-foreground text-text-xs"
|
|
95
|
+
style={{ height }}
|
|
96
|
+
>
|
|
97
|
+
no data in range
|
|
98
|
+
</div>
|
|
99
|
+
) : (
|
|
100
|
+
<div className="flex flex-col items-center gap-5 @md:flex-row">
|
|
101
|
+
<div className="relative shrink-0" style={{ width: height, height }}>
|
|
102
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
103
|
+
<RechartsPieChart>
|
|
104
|
+
<defs>
|
|
105
|
+
{slices.map((slice, slot) => (
|
|
106
|
+
<linearGradient key={slice.name} id={`donut-${reactId}-${slot}`} x1="0" y1="0" x2="0" y2="1">
|
|
107
|
+
<stop offset="0%" stopColor={slice.color} stopOpacity={1} />
|
|
108
|
+
<stop offset="100%" stopColor={slice.color} stopOpacity={0.7} />
|
|
109
|
+
</linearGradient>
|
|
110
|
+
))}
|
|
111
|
+
</defs>
|
|
112
|
+
<Pie
|
|
113
|
+
data={slices}
|
|
114
|
+
dataKey="value"
|
|
115
|
+
nameKey="name"
|
|
116
|
+
innerRadius={geometry.inner}
|
|
117
|
+
outerRadius={geometry.outer}
|
|
118
|
+
paddingAngle={paddingAngle}
|
|
119
|
+
startAngle={90}
|
|
120
|
+
endAngle={-270}
|
|
121
|
+
cornerRadius={2}
|
|
122
|
+
stroke={theme.card}
|
|
123
|
+
strokeWidth={1.5}
|
|
124
|
+
isAnimationActive={animated}
|
|
125
|
+
animationDuration={650}
|
|
126
|
+
animationEasing="ease-out"
|
|
127
|
+
onMouseEnter={(_entry, sliceIndex) => setActive(sliceIndex)}
|
|
128
|
+
onMouseLeave={() => setActive(null)}
|
|
129
|
+
>
|
|
130
|
+
{slices.map((slice, slot) => (
|
|
131
|
+
<Cell
|
|
132
|
+
key={slice.name}
|
|
133
|
+
className="motion-safe:[transition:fill-opacity_180ms_ease-out]"
|
|
134
|
+
fill={`url(#donut-${reactId}-${slot})`}
|
|
135
|
+
fillOpacity={active === null || active === slot ? 1 : 0.55}
|
|
136
|
+
stroke={theme.card}
|
|
137
|
+
strokeWidth={1.5}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</Pie>
|
|
141
|
+
</RechartsPieChart>
|
|
142
|
+
</ResponsiveContainer>
|
|
143
|
+
|
|
144
|
+
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1 text-center">
|
|
145
|
+
<span className="max-w-[72%] truncate font-mono text-[10px] text-quaternary-foreground uppercase tracking-[0.18em]">
|
|
146
|
+
{centerTitle}
|
|
147
|
+
</span>
|
|
148
|
+
<span className="font-mono font-semibold text-[28px] text-foreground leading-none tabular-nums">
|
|
149
|
+
{centerValue}
|
|
150
|
+
</span>
|
|
151
|
+
{activeSlice && (
|
|
152
|
+
<span className="font-mono font-medium text-[11px] tabular-nums" style={{ color: activeSlice.color }}>
|
|
153
|
+
{formatShare(activeSlice.value / total)}
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{legend && (
|
|
160
|
+
<div
|
|
161
|
+
data-slot="donut-readout"
|
|
162
|
+
className="flex min-w-0 flex-1 flex-col overflow-y-auto pr-1"
|
|
163
|
+
style={{ maxHeight: height }}
|
|
164
|
+
>
|
|
165
|
+
{slices.map((slice, slot) => (
|
|
166
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: decorative hover-sync; all values are visible without it
|
|
167
|
+
<div
|
|
168
|
+
key={slice.name}
|
|
169
|
+
ref={(node) => {
|
|
170
|
+
rowRefs.current[slot] = node;
|
|
171
|
+
}}
|
|
172
|
+
onMouseEnter={() => setActive(slot)}
|
|
173
|
+
onMouseLeave={() => setActive(null)}
|
|
174
|
+
className={cn(
|
|
175
|
+
"flex flex-col gap-1.5 border-border/40 border-b px-2 py-2 text-text-xs last:border-b-0 motion-safe:transition-colors",
|
|
176
|
+
active === slot ? "bg-muted/50" : "bg-transparent",
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<div className="flex items-center justify-between gap-3">
|
|
180
|
+
<span className="inline-flex min-w-0 items-center gap-2 text-muted-foreground">
|
|
181
|
+
<span
|
|
182
|
+
aria-hidden
|
|
183
|
+
className="h-2 w-2.5 shrink-0 rounded-[3px]"
|
|
184
|
+
style={{ background: slice.color }}
|
|
185
|
+
/>
|
|
186
|
+
<span className="truncate">{formatName(slice.name)}</span>
|
|
187
|
+
</span>
|
|
188
|
+
<span className="flex shrink-0 items-center gap-3 font-mono tabular-nums">
|
|
189
|
+
<span className="min-w-[3.5rem] text-right font-semibold text-foreground">
|
|
190
|
+
{valueFormatter(slice.value)}
|
|
191
|
+
</span>
|
|
192
|
+
<span className="w-10 text-right text-quaternary-foreground">
|
|
193
|
+
{formatShare(slice.value / total)}
|
|
194
|
+
</span>
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
<span aria-hidden className="relative block h-[2px] w-full overflow-hidden rounded-full bg-border/40">
|
|
198
|
+
<span
|
|
199
|
+
className="absolute inset-y-0 left-0 rounded-full motion-safe:transition-[width] motion-safe:duration-500"
|
|
200
|
+
style={{
|
|
201
|
+
width: `${maxValue > 0 ? (slice.value / maxValue) * 100 : 0}%`,
|
|
202
|
+
background: slice.color,
|
|
203
|
+
opacity: active === null || active === slot ? 1 : 0.45,
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
</span>
|
|
207
|
+
</div>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export { DonutChart };
|
|
@@ -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 };
|