@alpic-ai/ui 0.0.0-dev.g378c8b6 → 0.0.0-dev.g380fab7

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 (109) hide show
  1. package/dist/components/accordion-card.d.mts +5 -5
  2. package/dist/components/accordion.d.mts +5 -5
  3. package/dist/components/alert.d.mts +8 -8
  4. package/dist/components/area-chart.d.mts +62 -0
  5. package/dist/components/area-chart.mjs +269 -0
  6. package/dist/components/attachment-tile.d.mts +1 -1
  7. package/dist/components/avatar.d.mts +7 -7
  8. package/dist/components/badge.d.mts +1 -1
  9. package/dist/components/bar-chart.d.mts +48 -0
  10. package/dist/components/bar-chart.mjs +245 -0
  11. package/dist/components/bar-list.d.mts +28 -0
  12. package/dist/components/bar-list.mjs +98 -0
  13. package/dist/components/breadcrumb.d.mts +10 -10
  14. package/dist/components/button.d.mts +5 -5
  15. package/dist/components/card.d.mts +9 -9
  16. package/dist/components/chart-card.d.mts +25 -0
  17. package/dist/components/chart-card.mjs +48 -0
  18. package/dist/components/chart-container.d.mts +20 -0
  19. package/dist/components/chart-container.mjs +37 -0
  20. package/dist/components/chart-legend.d.mts +16 -0
  21. package/dist/components/chart-legend.mjs +26 -0
  22. package/dist/components/chart-tooltip.d.mts +33 -0
  23. package/dist/components/chart-tooltip.mjs +52 -0
  24. package/dist/components/checkbox.d.mts +2 -2
  25. package/dist/components/collapsible.d.mts +4 -4
  26. package/dist/components/combobox.d.mts +10 -10
  27. package/dist/components/command.d.mts +9 -9
  28. package/dist/components/copyable.d.mts +2 -2
  29. package/dist/components/description-list.d.mts +5 -5
  30. package/dist/components/dialog.d.mts +13 -13
  31. package/dist/components/donut-chart.d.mts +46 -0
  32. package/dist/components/donut-chart.mjs +185 -0
  33. package/dist/components/dropdown-menu.d.mts +17 -17
  34. package/dist/components/form.d.mts +18 -18
  35. package/dist/components/form.mjs +6 -6
  36. package/dist/components/github-button.d.mts +1 -1
  37. package/dist/components/grid-fx.d.mts +13 -0
  38. package/dist/components/grid-fx.mjs +188 -0
  39. package/dist/components/heatmap-chart.d.mts +40 -0
  40. package/dist/components/heatmap-chart.mjs +198 -0
  41. package/dist/components/input-group.d.mts +4 -4
  42. package/dist/components/input.d.mts +4 -4
  43. package/dist/components/input.mjs +2 -2
  44. package/dist/components/label.d.mts +2 -2
  45. package/dist/components/line-chart.d.mts +55 -0
  46. package/dist/components/line-chart.mjs +211 -0
  47. package/dist/components/page-loader.d.mts +1 -1
  48. package/dist/components/pagination.d.mts +3 -3
  49. package/dist/components/popover.d.mts +5 -5
  50. package/dist/components/radio-group.d.mts +3 -3
  51. package/dist/components/scroll-area.d.mts +3 -3
  52. package/dist/components/select.d.mts +9 -9
  53. package/dist/components/separator.d.mts +2 -2
  54. package/dist/components/sheet.d.mts +11 -11
  55. package/dist/components/shimmer-text.d.mts +3 -1
  56. package/dist/components/sidebar.d.mts +33 -33
  57. package/dist/components/sidebar.mjs +10 -10
  58. package/dist/components/skeleton.d.mts +1 -1
  59. package/dist/components/sonner.d.mts +5 -5
  60. package/dist/components/spinner.d.mts +2 -2
  61. package/dist/components/stat.d.mts +30 -0
  62. package/dist/components/stat.mjs +107 -0
  63. package/dist/components/status-dot.d.mts +1 -1
  64. package/dist/components/switch.d.mts +2 -2
  65. package/dist/components/table.d.mts +10 -10
  66. package/dist/components/tabs.d.mts +10 -10
  67. package/dist/components/tag.d.mts +3 -3
  68. package/dist/components/task-progress.d.mts +1 -1
  69. package/dist/components/textarea.d.mts +3 -3
  70. package/dist/components/textarea.mjs +2 -2
  71. package/dist/components/toggle-group.d.mts +3 -3
  72. package/dist/components/toggle-group.mjs +3 -3
  73. package/dist/components/tooltip.d.mts +5 -5
  74. package/dist/components/typography.d.mts +4 -4
  75. package/dist/components/wizard.d.mts +4 -4
  76. package/dist/hooks/use-chart-theme.d.mts +18 -0
  77. package/dist/hooks/use-chart-theme.mjs +57 -0
  78. package/dist/hooks/use-mobile.mjs +3 -3
  79. package/dist/hooks/use-reduced-motion.d.mts +4 -0
  80. package/dist/hooks/use-reduced-motion.mjs +16 -0
  81. package/dist/lib/chart-palette.d.mts +4 -0
  82. package/dist/lib/chart-palette.mjs +95 -0
  83. package/dist/lib/chart.d.mts +14 -0
  84. package/dist/lib/chart.mjs +27 -0
  85. package/package.json +30 -29
  86. package/src/components/area-chart.tsx +339 -0
  87. package/src/components/bar-chart.tsx +300 -0
  88. package/src/components/bar-list.tsx +150 -0
  89. package/src/components/chart-card.tsx +63 -0
  90. package/src/components/chart-container.tsx +49 -0
  91. package/src/components/chart-legend.tsx +41 -0
  92. package/src/components/chart-tooltip.tsx +93 -0
  93. package/src/components/donut-chart.tsx +217 -0
  94. package/src/components/grid-fx.tsx +238 -0
  95. package/src/components/heatmap-chart.tsx +287 -0
  96. package/src/components/line-chart.tsx +264 -0
  97. package/src/components/stat.tsx +109 -0
  98. package/src/hooks/use-chart-theme.ts +75 -0
  99. package/src/hooks/use-reduced-motion.ts +17 -0
  100. package/src/lib/chart-palette.ts +110 -0
  101. package/src/lib/chart.ts +56 -0
  102. package/src/stories/area-chart.stories.tsx +200 -0
  103. package/src/stories/bar-chart.stories.tsx +169 -0
  104. package/src/stories/bar-list.stories.tsx +85 -0
  105. package/src/stories/donut-chart.stories.tsx +112 -0
  106. package/src/stories/heatmap-chart.stories.tsx +107 -0
  107. package/src/stories/line-chart.stories.tsx +146 -0
  108. package/src/stories/stat.stories.tsx +64 -0
  109. package/src/styles/tokens.css +63 -0
@@ -0,0 +1,238 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { useReducedMotion } from "../hooks/use-reduced-motion";
6
+ import { cn } from "../lib/cn";
7
+
8
+ const CELL_SIZE = 46;
9
+ const TTL_MIN = 42;
10
+ const TTL_MAX = 78;
11
+ const SPAWN_MIN = 180;
12
+ const SPAWN_MAX = 480;
13
+
14
+ interface GlitchLine {
15
+ horiz: boolean;
16
+ at: number;
17
+ life: number;
18
+ ttl: number;
19
+ color: string;
20
+ colorHi: string;
21
+ }
22
+
23
+ const rand = (min: number, max: number) => min + Math.random() * (max - min);
24
+
25
+ function resolveColors(element: HTMLElement) {
26
+ const styles = getComputedStyle(element);
27
+ return {
28
+ color: styles.getPropertyValue("--color-primary").trim() || "#e90060" /* --color-primary */,
29
+ colorHi: styles.getPropertyValue("--color-primary-hover").trim() || "#f22b79" /* --color-primary-hover */,
30
+ };
31
+ }
32
+
33
+ function strokeFull(ctx: CanvasRenderingContext2D, horiz: boolean, at: number, width: number, height: number) {
34
+ ctx.beginPath();
35
+ if (horiz) {
36
+ ctx.moveTo(0, at);
37
+ ctx.lineTo(width, at);
38
+ } else {
39
+ ctx.moveTo(at, 0);
40
+ ctx.lineTo(at, height);
41
+ }
42
+ ctx.stroke();
43
+ }
44
+
45
+ function drawGlitchLine(ctx: CanvasRenderingContext2D, line: GlitchLine, width: number, height: number) {
46
+ const { horiz, at, color, colorHi } = line;
47
+ const span = horiz ? width : height;
48
+ const progress = line.life / line.ttl;
49
+ const envelope = progress < 0.08 ? progress / 0.08 : 1 - (progress - 0.08) / 0.92;
50
+ const base = Math.max(0, envelope);
51
+
52
+ ctx.lineCap = "round";
53
+
54
+ const ghosts = [
55
+ { offset: 0, alpha: 0.85, blur: 12 },
56
+ { offset: rand(-3, 3), alpha: 0.35, blur: 0 },
57
+ { offset: rand(-7, 7), alpha: 0.2, blur: 0 },
58
+ ];
59
+ for (const ghost of ghosts) {
60
+ ctx.globalAlpha = base * ghost.alpha * (Math.random() < 0.1 ? 0.3 : 1);
61
+ ctx.strokeStyle = color;
62
+ ctx.lineWidth = 1.5;
63
+ ctx.shadowBlur = ghost.blur;
64
+ ctx.shadowColor = color;
65
+ strokeFull(ctx, horiz, at + ghost.offset, width, height);
66
+ }
67
+
68
+ if (Math.random() < 0.5) {
69
+ ctx.globalAlpha = base * 0.6;
70
+ ctx.lineWidth = 2;
71
+ ctx.shadowBlur = 0;
72
+ ctx.strokeStyle = colorHi;
73
+ for (let segment = 0; segment < 3; segment++) {
74
+ const start = rand(0, span * 0.85);
75
+ const end = start + rand(20, 80);
76
+ const jitter = rand(-4, 4);
77
+ ctx.beginPath();
78
+ if (horiz) {
79
+ ctx.moveTo(start, at + jitter);
80
+ ctx.lineTo(end, at + jitter);
81
+ } else {
82
+ ctx.moveTo(at + jitter, start);
83
+ ctx.lineTo(at + jitter, end);
84
+ }
85
+ ctx.stroke();
86
+ }
87
+ }
88
+ }
89
+
90
+ function GridFx({
91
+ className,
92
+ cellSize = CELL_SIZE,
93
+ style,
94
+ ...props
95
+ }: React.ComponentProps<"canvas"> & { cellSize?: number }) {
96
+ const reduced = useReducedMotion();
97
+ const canvasRef = React.useRef<HTMLCanvasElement>(null);
98
+
99
+ React.useEffect(() => {
100
+ if (reduced) {
101
+ return;
102
+ }
103
+ const canvas = canvasRef.current;
104
+ const parent = canvas?.parentElement;
105
+ const ctx = canvas?.getContext("2d");
106
+ if (!canvas || !parent || !ctx) {
107
+ return;
108
+ }
109
+
110
+ let width = 0;
111
+ let height = 0;
112
+ let dpr = 1;
113
+ let frame = 0;
114
+ let nextIn = rand(SPAWN_MIN, SPAWN_MAX);
115
+ let onScreen = true;
116
+ const lines: GlitchLine[] = [];
117
+
118
+ const resize = () => {
119
+ dpr = Math.min(window.devicePixelRatio || 1, 2);
120
+ width = parent.clientWidth;
121
+ height = parent.clientHeight;
122
+ canvas.width = width * dpr;
123
+ canvas.height = height * dpr;
124
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
125
+ };
126
+
127
+ const spawn = () => {
128
+ const horiz = Math.random() < 0.5;
129
+ const tracks = Math.ceil((horiz ? height : width) / cellSize);
130
+ const { color, colorHi } = resolveColors(canvas);
131
+ lines.push({
132
+ horiz,
133
+ at: Math.floor(Math.random() * tracks) * cellSize + 0.5,
134
+ life: 0,
135
+ ttl: rand(TTL_MIN, TTL_MAX),
136
+ color,
137
+ colorHi,
138
+ });
139
+ };
140
+
141
+ const tick = () => {
142
+ ctx.globalAlpha = 1;
143
+ ctx.shadowBlur = 0;
144
+ ctx.clearRect(0, 0, width, height);
145
+
146
+ if (--nextIn <= 0) {
147
+ spawn();
148
+ nextIn = rand(SPAWN_MIN, SPAWN_MAX);
149
+ }
150
+ for (let index = lines.length - 1; index >= 0; index--) {
151
+ const line = lines[index];
152
+ if (!line) {
153
+ continue;
154
+ }
155
+ line.life++;
156
+ drawGlitchLine(ctx, line, width, height);
157
+ if (line.life >= line.ttl) {
158
+ lines.splice(index, 1);
159
+ }
160
+ }
161
+
162
+ ctx.globalAlpha = 1;
163
+ ctx.shadowBlur = 0;
164
+ frame = requestAnimationFrame(tick);
165
+ };
166
+
167
+ const running = () => onScreen && document.visibilityState === "visible";
168
+ const start = () => {
169
+ if (!frame && running()) {
170
+ frame = requestAnimationFrame(tick);
171
+ }
172
+ };
173
+ const stop = () => {
174
+ if (frame) {
175
+ cancelAnimationFrame(frame);
176
+ }
177
+ frame = 0;
178
+ ctx.clearRect(0, 0, width, height);
179
+ };
180
+
181
+ resize();
182
+ start();
183
+
184
+ const resizeObserver = new ResizeObserver(() => resize());
185
+ resizeObserver.observe(parent);
186
+
187
+ const intersectionObserver = new IntersectionObserver(([entry]) => {
188
+ if (!entry) {
189
+ return;
190
+ }
191
+ onScreen = entry.isIntersecting;
192
+ if (onScreen) {
193
+ start();
194
+ } else {
195
+ stop();
196
+ }
197
+ });
198
+ intersectionObserver.observe(canvas);
199
+
200
+ const onVisibility = () => {
201
+ if (running()) {
202
+ start();
203
+ } else {
204
+ stop();
205
+ }
206
+ };
207
+ document.addEventListener("visibilitychange", onVisibility);
208
+
209
+ return () => {
210
+ stop();
211
+ resizeObserver.disconnect();
212
+ intersectionObserver.disconnect();
213
+ document.removeEventListener("visibilitychange", onVisibility);
214
+ };
215
+ }, [reduced, cellSize]);
216
+
217
+ if (reduced) {
218
+ return null;
219
+ }
220
+
221
+ return (
222
+ <canvas
223
+ ref={canvasRef}
224
+ aria-hidden
225
+ data-slot="grid-fx"
226
+ className={cn("pointer-events-none absolute inset-0 h-full w-full", className)}
227
+ style={{
228
+ zIndex: -1,
229
+ WebkitMaskImage: "radial-gradient(120% 95% at 50% 0%, #000 30%, transparent 100%)",
230
+ maskImage: "radial-gradient(120% 95% at 50% 0%, #000 30%, transparent 100%)",
231
+ ...style,
232
+ }}
233
+ {...props}
234
+ />
235
+ );
236
+ }
237
+
238
+ export { GridFx };
@@ -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 };