waffle-charts-cli 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -29,6 +29,27 @@ npx waffle-charts-cli add bar-chart line-chart
29
29
 
30
30
  ## Available Charts
31
31
 
32
+ - React 16+
33
+ - Tailwind CSS configured in your project.
34
+
35
+ ## Prerequisites
36
+
37
+ The components assume you have a `cn` utility for class merging (standard in shadcn/ui projects).
38
+
39
+ **`lib/utils.ts`**
40
+ ```ts
41
+ import { type ClassValue, clsx } from "clsx"
42
+ import { twMerge } from "tailwind-merge"
43
+
44
+ export function cn(...inputs: ClassValue[]) {
45
+ return twMerge(clsx(inputs))
46
+ }
47
+ ```
48
+
49
+ If your utils are located elsewhere, you may need to update the imports in the added components.
50
+
51
+ ## Available Charts
52
+
32
53
  | Chart | Command | Description |
33
54
  | :--- | :--- | :--- |
34
55
  | **Bar Chart** | `bar-chart` | Standard vertical bar chart for categorical data. |
@@ -41,18 +62,10 @@ npx waffle-charts-cli add bar-chart line-chart
41
62
  | **Heatmap** | `heatmap-chart` | Grid-based density visualization. |
42
63
  | **Treemap** | `treemap-chart` | Hierarchical data visualization. |
43
64
  | **Sankey** | `sankey-chart` | Flow and process visualization. |
44
- | **Composite** | `composite-chart` | **New!** Dual-axis Bar + Line combination. |
45
-
46
- ## What it does
47
-
48
- 1. **Checks dependencies**: Detects if you have the necessary `@visx` packages (`shape`, `scale`, `axis`, etc.) and installs them if missing.
49
- 2. **Copies code**: Downloads the component source code to `src/components/waffle/` (or your configured directory).
50
- 3. **Zero Lock-in**: Once added, the code is yours.
51
-
52
- ## Requirements
53
-
54
- - React 16+
55
- - Tailwind CSS configured in your project.
65
+ | **Composite** | `composite-chart` | Dual-axis Bar + Line combination. |
66
+ | **Funnel Chart** | `funnel-chart` | **New!** Process flow stages and conversion. |
67
+ | **Radial Bar** | `radial-bar-chart` | **New!** Circular gauge/progress visualization. |
68
+ | **Legend** | `chart-legend` | **New!** Reusable chart legend component. |
56
69
 
57
70
  ## License
58
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waffle-charts-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "CLI to add WaffleCharts components to your project",
5
5
  "main": "bin/index.js",
6
6
  "bin": {
@@ -38,4 +38,4 @@
38
38
  "prompts": "^2.4.2"
39
39
  },
40
40
  "type": "module"
41
- }
41
+ }
package/src/registry.js CHANGED
@@ -58,5 +58,25 @@ export const registry = {
58
58
  file: "ChordChart.tsx",
59
59
  label: "Chord Diagram",
60
60
  dependencies: ["@visx/chord", "@visx/scale", "@visx/tooltip", "@visx/shape", "@visx/responsive", "@visx/group", "clsx", "tailwind-merge"],
61
+ },
62
+ "candlestick-chart": {
63
+ file: "CandlestickChart.tsx",
64
+ label: "Candlestick Chart",
65
+ dependencies: ["@visx/group", "@visx/scale", "@visx/shape", "@visx/axis", "@visx/grid", "@visx/responsive", "@visx/tooltip", "@visx/event", "d3-array", "clsx", "tailwind-merge"],
66
+ },
67
+ "chart-legend": {
68
+ file: "ChartLegend.tsx",
69
+ label: "Chart Legend",
70
+ dependencies: ["clsx", "tailwind-merge"],
71
+ },
72
+ "funnel-chart": {
73
+ file: "FunnelChart.tsx",
74
+ label: "Funnel Chart",
75
+ dependencies: ["@visx/group", "@visx/responsive", "@visx/scale", "@visx/tooltip", "clsx", "tailwind-merge"],
76
+ },
77
+ "radial-bar-chart": {
78
+ file: "RadialBarChart.tsx",
79
+ label: "Radial Bar Chart",
80
+ dependencies: ["@visx/group", "@visx/shape", "@visx/scale", "@visx/responsive", "@visx/tooltip", "clsx", "tailwind-merge"],
61
81
  }
62
82
  };
@@ -0,0 +1,267 @@
1
+ import React, { useMemo } from 'react';
2
+ import { Group } from '@visx/group';
3
+ import { scaleTime, scaleLinear } from '@visx/scale';
4
+ import { Bar, Line } from '@visx/shape';
5
+ import { AxisBottom, AxisLeft } from '@visx/axis';
6
+ import { GridRows, GridColumns } from '@visx/grid';
7
+ import { ParentSize } from '@visx/responsive';
8
+ import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
9
+ import { localPoint } from '@visx/event';
10
+ import { bisector, extent } from 'd3-array';
11
+ import { cn } from '../../lib/utils';
12
+
13
+ export type CandlestickChartProps<T> = {
14
+ data: T[];
15
+ xKey: keyof T;
16
+ openKey: keyof T;
17
+ highKey: keyof T;
18
+ lowKey: keyof T;
19
+ closeKey: keyof T;
20
+ className?: string;
21
+ upColor?: string;
22
+ downColor?: string;
23
+ xAxisLabel?: string;
24
+ yAxisLabel?: string;
25
+ showXAxis?: boolean;
26
+ showYAxis?: boolean;
27
+ showGrid?: boolean;
28
+ };
29
+
30
+ function CandlestickChartContent<T>({
31
+ data,
32
+ width,
33
+ height,
34
+ xKey,
35
+ openKey,
36
+ highKey,
37
+ lowKey,
38
+ closeKey,
39
+ className,
40
+ upColor = "#22c55e", // green-500
41
+ downColor = "#ef4444", // red-500
42
+ xAxisLabel,
43
+ yAxisLabel,
44
+ showXAxis = true,
45
+ showYAxis = true,
46
+ showGrid = true,
47
+ }: CandlestickChartProps<T> & { width: number; height: number }) {
48
+ const margin = { top: 20, right: 30, bottom: 50, left: 50 };
49
+ const innerWidth = width - margin.left - margin.right;
50
+ const innerHeight = height - margin.top - margin.bottom;
51
+
52
+ // Accessors
53
+ const getX = (d: T) => d[xKey] as Date;
54
+ const getOpen = (d: T) => Number(d[openKey]);
55
+ const getHigh = (d: T) => Number(d[highKey]);
56
+ const getLow = (d: T) => Number(d[lowKey]);
57
+ const getClose = (d: T) => Number(d[closeKey]);
58
+
59
+ // Scales
60
+ const xScale = useMemo(
61
+ () =>
62
+ scaleTime({
63
+ range: [0, innerWidth],
64
+ domain: extent(data, getX) as [Date, Date],
65
+ }),
66
+ [innerWidth, data, getX]
67
+ );
68
+
69
+ const yScale = useMemo(
70
+ () =>
71
+ scaleLinear({
72
+ range: [innerHeight, 0],
73
+ domain: [
74
+ Math.min(...data.map(getLow)),
75
+ Math.max(...data.map(getHigh)),
76
+ ],
77
+ nice: true,
78
+ }),
79
+ [innerHeight, data, getHigh, getLow]
80
+ );
81
+
82
+ // Tooltip
83
+ const {
84
+ tooltipOpen,
85
+ tooltipLeft,
86
+ tooltipTop,
87
+ tooltipData,
88
+ hideTooltip,
89
+ showTooltip,
90
+ } = useTooltip<T>();
91
+
92
+ const { containerRef, TooltipInPortal } = useTooltipInPortal({
93
+ scroll: true,
94
+ });
95
+
96
+ const bisectDate = bisector<T, Date>((d) => new Date(d[xKey] as any)).left;
97
+
98
+ const handlePointerMove = (event: React.PointerEvent<SVGRectElement>) => {
99
+ const { x } = localPoint(event) || { x: 0 };
100
+ const x0 = xScale.invert(x);
101
+ const index = bisectDate(data, x0, 1);
102
+ const d0 = data[index - 1];
103
+ const d1 = data[index];
104
+ let d = d0;
105
+ if (d1 && getX(d1)) {
106
+ d = x0.valueOf() - getX(d0).valueOf() > getX(d1).valueOf() - x0.valueOf() ? d1 : d0;
107
+ }
108
+ showTooltip({
109
+ tooltipData: d,
110
+ tooltipLeft: xScale(getX(d)),
111
+ tooltipTop: yScale(Math.max(getOpen(d), getClose(d))),
112
+ });
113
+ };
114
+
115
+ // Calculate candle width dynamically based on data length
116
+ // Use 80% of the available space per data point, capped at a max width
117
+ const candleWidth = Math.min((innerWidth / data.length) * 0.8, 20);
118
+
119
+ return (
120
+ <div className={cn("relative", className)}>
121
+ <svg ref={containerRef} width={width} height={height}>
122
+ <Group left={margin.left} top={margin.top}>
123
+ {showGrid && (
124
+ <>
125
+ <GridRows
126
+ scale={yScale}
127
+ width={innerWidth}
128
+ strokeDasharray="3,3"
129
+ stroke="hsl(var(--border, 214.3 31.8% 91.4%))"
130
+ strokeOpacity={0.5}
131
+ />
132
+ <GridColumns
133
+ scale={xScale}
134
+ height={innerHeight}
135
+ strokeDasharray="3,3"
136
+ stroke="hsl(var(--border, 214.3 31.8% 91.4%))"
137
+ strokeOpacity={0.5}
138
+ />
139
+ </>
140
+ )}
141
+
142
+ {data.map((d, i) => {
143
+ const open = getOpen(d);
144
+ const close = getClose(d);
145
+ const high = getHigh(d);
146
+ const low = getLow(d);
147
+ const x = xScale(getX(d));
148
+ const isUp = close > open;
149
+ const color = isUp ? upColor : downColor;
150
+ const barHeight = Math.abs(yScale(open) - yScale(close));
151
+ const barY = yScale(Math.max(open, close));
152
+
153
+ return (
154
+ <Group key={i} left={x}>
155
+ {/* Wick */}
156
+ <Line
157
+ from={{ x: 0, y: yScale(low) }}
158
+ to={{ x: 0, y: yScale(high) }}
159
+ stroke={color}
160
+ strokeWidth={1.5}
161
+ />
162
+ {/* Body */}
163
+ <Bar
164
+ x={-candleWidth / 2}
165
+ y={barY}
166
+ width={candleWidth}
167
+ height={Math.max(barHeight, 1)} // Ensure at least 1px height
168
+ fill={color}
169
+ className="cursor-pointer"
170
+ onFocus={() => { }}
171
+ onMouseOver={() => showTooltip({
172
+ tooltipData: d,
173
+ tooltipLeft: xScale(getX(d)) + margin.left,
174
+ tooltipTop: barY + margin.top
175
+ })}
176
+ onMouseOut={hideTooltip}
177
+ />
178
+ </Group>
179
+ );
180
+ })}
181
+
182
+ {showXAxis && (
183
+ <AxisBottom
184
+ top={innerHeight}
185
+ scale={xScale}
186
+ stroke="hsl(var(--border, 214.3 31.8% 91.4%))"
187
+ tickStroke="hsl(var(--border, 214.3 31.8% 91.4%))"
188
+ label={xAxisLabel}
189
+ numTicks={width > 500 ? 10 : 5}
190
+ labelProps={{
191
+ fill: "hsl(var(--muted-foreground, 215.4 16.3% 46.9%))",
192
+ fontSize: 12,
193
+ textAnchor: 'middle',
194
+ dy: 0
195
+ }}
196
+ tickLabelProps={{
197
+ fill: "hsl(var(--muted-foreground, 215.4 16.3% 46.9%))",
198
+ fontSize: 11,
199
+ textAnchor: "middle",
200
+ }}
201
+ />
202
+ )}
203
+
204
+ {showYAxis && (
205
+ <AxisLeft
206
+ scale={yScale}
207
+ stroke="hsl(var(--border, 214.3 31.8% 91.4%))"
208
+ tickStroke="hsl(var(--border, 214.3 31.8% 91.4%))"
209
+ label={yAxisLabel}
210
+ labelProps={{
211
+ fill: "hsl(var(--muted-foreground, 215.4 16.3% 46.9%))",
212
+ fontSize: 12,
213
+ textAnchor: 'middle',
214
+ dx: -10
215
+ }}
216
+ tickLabelProps={{
217
+ fill: "hsl(var(--muted-foreground, 215.4 16.3% 46.9%))",
218
+ fontSize: 11,
219
+ textAnchor: "end",
220
+ dx: -4,
221
+ dy: 4 // Center vertically
222
+ }}
223
+ />
224
+ )}
225
+ </Group>
226
+ </svg>
227
+ {tooltipOpen && tooltipData && (
228
+ <TooltipInPortal
229
+ top={tooltipTop}
230
+ left={tooltipLeft}
231
+ style={{
232
+ ...defaultStyles,
233
+ backgroundColor: "hsl(var(--background))",
234
+ color: "hsl(var(--foreground))",
235
+ border: "1px solid hsl(var(--border))",
236
+ borderRadius: "0.5rem",
237
+ padding: "0.5rem",
238
+ boxShadow: "0 4px 6px -1px rgb(0 0 0 / 0.1)",
239
+ zIndex: 50,
240
+ }}
241
+ >
242
+ <div className="text-xs font-semibold mb-1">
243
+ {getX(tooltipData).toLocaleDateString()}
244
+ </div>
245
+ <div className="grid grid-cols-2 gap-x-3 text-xs">
246
+ <span className="text-muted-foreground">Open:</span>
247
+ <span className="font-mono">{getOpen(tooltipData).toFixed(2)}</span>
248
+ <span className="text-muted-foreground">High:</span>
249
+ <span className="font-mono">{getHigh(tooltipData).toFixed(2)}</span>
250
+ <span className="text-muted-foreground">Low:</span>
251
+ <span className="font-mono">{getLow(tooltipData).toFixed(2)}</span>
252
+ <span className="text-muted-foreground">Close:</span>
253
+ <span className="font-mono">{getClose(tooltipData).toFixed(2)}</span>
254
+ </div>
255
+ </TooltipInPortal>
256
+ )}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ export function CandlestickChart<T>(props: CandlestickChartProps<T>) {
262
+ return (
263
+ <ParentSize>
264
+ {({ width, height }) => <CandlestickChartContent {...props} width={width} height={height} />}
265
+ </ParentSize>
266
+ );
267
+ }
@@ -0,0 +1,38 @@
1
+ import { cn } from "../../lib/utils";
2
+
3
+ export type ChartLegendItem = {
4
+ label: string;
5
+ color: string;
6
+ };
7
+
8
+ export type ChartLegendProps = {
9
+ payload: ChartLegendItem[];
10
+ orientation?: "horizontal" | "vertical";
11
+ className?: string;
12
+ };
13
+
14
+ export function ChartLegend({
15
+ payload,
16
+ orientation = "horizontal",
17
+ className,
18
+ }: ChartLegendProps) {
19
+ return (
20
+ <div
21
+ className={cn(
22
+ "flex flex-wrap gap-4 text-sm text-muted-foreground",
23
+ orientation === "vertical" ? "flex-col" : "items-center justify-center",
24
+ className
25
+ )}
26
+ >
27
+ {payload.map((item, i) => (
28
+ <div key={i} className="flex items-center gap-2">
29
+ <span
30
+ className="h-3 w-3 rounded-full"
31
+ style={{ backgroundColor: item.color }}
32
+ />
33
+ <span>{item.label}</span>
34
+ </div>
35
+ ))}
36
+ </div>
37
+ );
38
+ }
@@ -0,0 +1,194 @@
1
+ import { Group } from '@visx/group';
2
+ import { ParentSize } from '@visx/responsive';
3
+ import { scaleOrdinal } from '@visx/scale';
4
+ import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
5
+ import { cn } from '../../lib/utils'; // Assuming this exists based on other files
6
+
7
+ export type FunnelChartProps<T> = {
8
+ data: T[];
9
+ stepKey: keyof T;
10
+ valueKey: keyof T;
11
+ width?: number;
12
+ height?: number;
13
+ className?: string;
14
+ colors?: string[];
15
+ };
16
+
17
+ type FunnelChartContentProps<T> = FunnelChartProps<T> & {
18
+ width: number;
19
+ height: number;
20
+ };
21
+
22
+ function FunnelChartContent<T>({
23
+ data,
24
+ width,
25
+ height,
26
+ stepKey,
27
+ valueKey,
28
+ className,
29
+ colors,
30
+ }: FunnelChartContentProps<T>) {
31
+ const margin = { top: 20, right: 20, bottom: 20, left: 20 };
32
+ const innerWidth = width - margin.left - margin.right;
33
+ const innerHeight = height - margin.top - margin.bottom;
34
+
35
+ // Accessors
36
+ const getStep = (d: T) => String(d[stepKey]);
37
+ const getValue = (d: T) => Number(d[valueKey]);
38
+
39
+ // Scales
40
+ const defaultColors = ['#3b82f6', '#60a5fa', '#93c5fd', '#bfdbfe', '#dbeafe'];
41
+ const colorScale = scaleOrdinal({
42
+ domain: data.map((_, i) => i),
43
+ range: colors || defaultColors,
44
+ });
45
+
46
+ // Calculate geometry
47
+ // Calculate geometry
48
+ const maxValue = Math.max(...data.map(getValue));
49
+ const processData = data;
50
+
51
+ const stepHeight = innerHeight / processData.length;
52
+
53
+ const getPoints = (d: T, i: number) => {
54
+ const val = getValue(d);
55
+ // Center the bar/trapezoid
56
+ // Current width proportional to value
57
+ const w = (val / maxValue) * innerWidth;
58
+ const y = i * stepHeight;
59
+
60
+ // Next width (for trapezoid effect)
61
+ // If it's the last one, maybe it just goes down to a point or same width?
62
+ // Let's do simple stacked rectangles first for "Bar Funnel" or proper Trapezoids.
63
+ // Proper Funnel:
64
+ // Top Left: x, y
65
+ // Top Right: x + w, y
66
+ // Bottom Right: ?
67
+ // Bottom Left: ?
68
+
69
+ // Actually, a nice funnel connects to the next one.
70
+ const nextD = processData[i + 1];
71
+ // Let's just make it a polygon.
72
+
73
+ // Coords for current "Row"
74
+ // We want the TOP of this shape to match the BOTTOM of the previous?
75
+ // Simplified: visual trapezoids.
76
+ // Top width = current Value
77
+ // Bottom width = next Value (or current Value if we want blocks)
78
+ // But usually funnel means flow.
79
+
80
+ // Let's do: Top of shape = proportional to current value. Bottom of shape = proportional to next value.
81
+ // For the last item, bottom = proportional to its own value (rect) or 0 (point).
82
+ // Let's assume the last item maintains width to show "conversion".
83
+
84
+ const nextW = nextD ? (getValue(nextD) / maxValue) * innerWidth : w; // Rectangular ending or taper? let's keep rect for last step visibility.
85
+
86
+ const topX = (innerWidth - w) / 2;
87
+ const topY = y;
88
+
89
+ const bottomX = (innerWidth - nextW) / 2;
90
+ const bottomY = y + stepHeight; // minus a gap?
91
+
92
+ // Polygon points: TopL, TopR, BottomR, BottomL
93
+ return [
94
+ [topX, topY],
95
+ [topX + w, topY],
96
+ [bottomX + nextW, bottomY],
97
+ [bottomX, bottomY]
98
+ ].map(p => p.join(',')).join(' ');
99
+ };
100
+
101
+ // Tooltip
102
+ const {
103
+ tooltipOpen,
104
+ tooltipLeft,
105
+ tooltipTop,
106
+ tooltipData,
107
+ hideTooltip,
108
+ showTooltip,
109
+ } = useTooltip<T>();
110
+
111
+ const { containerRef, TooltipInPortal } = useTooltipInPortal({
112
+ scroll: true,
113
+ detectBounds: true
114
+ });
115
+
116
+ if (width < 50) return null;
117
+
118
+ return (
119
+ <div className={cn("relative", className)}>
120
+ <svg ref={containerRef} width={width} height={height} className="overflow-visible">
121
+ <Group left={margin.left} top={margin.top}>
122
+ {processData.map((d, i) => {
123
+ return (
124
+ <polygon
125
+ key={i}
126
+ points={getPoints(d, i)}
127
+ fill={colorScale(i)}
128
+ className="opacity-80 hover:opacity-100 transition-opacity cursor-pointer"
129
+ onMouseEnter={() => {
130
+ const coords = getPoints(d, i).split(' ');
131
+ // approximate center
132
+ // Top Left is coords[0]
133
+ const topL = coords[0].split(',');
134
+ showTooltip({
135
+ tooltipData: d,
136
+ tooltipLeft: Number(topL[0]) + margin.left + ((getValue(d) / maxValue) * innerWidth) / 2,
137
+ tooltipTop: Number(topL[1]) + margin.top + stepHeight / 2
138
+ })
139
+ }}
140
+ onMouseLeave={hideTooltip}
141
+ />
142
+ );
143
+ })}
144
+
145
+ {/* Labels on top? */}
146
+ {processData.map((d, i) => {
147
+ const val = getValue(d);
148
+ const y = i * stepHeight + stepHeight / 2;
149
+
150
+ if (stepHeight < 20) return null; // Hide if too small
151
+
152
+ return (
153
+ <text
154
+ key={`label-${i}`}
155
+ x={innerWidth / 2}
156
+ y={y}
157
+ dy=".35em"
158
+ textAnchor="middle"
159
+ className="fill-white text-xs font-medium pointer-events-none"
160
+ style={{ textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}
161
+ >
162
+ {getStep(d)} ({val})
163
+ </text>
164
+ )
165
+ })}
166
+ </Group>
167
+ </svg>
168
+
169
+ {tooltipOpen && tooltipData && (
170
+ <TooltipInPortal
171
+ top={tooltipTop}
172
+ left={tooltipLeft}
173
+ style={{ ...defaultStyles, padding: 0, borderRadius: 0, boxShadow: 'none', background: 'transparent', zIndex: 100 }}
174
+ >
175
+ <div className="rounded-md border bg-white dark:bg-slate-900 px-3 py-1.5 text-sm text-slate-900 dark:text-slate-100 shadow-xl">
176
+ <div className="font-semibold">{getStep(tooltipData)}</div>
177
+ <div>Value: {getValue(tooltipData)}</div>
178
+ {/* Calculation of conversion rate could go here if we had index access easily or passed standard props */}
179
+ </div>
180
+ </TooltipInPortal>
181
+ )}
182
+ </div>
183
+ );
184
+ }
185
+
186
+ export function FunnelChart<T>(props: FunnelChartProps<T>) {
187
+ return (
188
+ <div style={{ width: '100%', height: '100%', minHeight: 200 }}>
189
+ <ParentSize>
190
+ {({ width, height }) => <FunnelChartContent {...props} width={width} height={height} />}
191
+ </ParentSize>
192
+ </div>
193
+ );
194
+ }
@@ -0,0 +1,187 @@
1
+ import { Group } from '@visx/group';
2
+ import { Arc } from '@visx/shape';
3
+ import { scaleLinear, scaleOrdinal } from '@visx/scale';
4
+ import { ParentSize } from '@visx/responsive';
5
+ import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
6
+ import { cn } from '../../lib/utils';
7
+
8
+ export type RadialBarChartProps<T> = {
9
+ data: T[];
10
+ valueKey: keyof T;
11
+ labelKey: keyof T; // Used for identifying the ring
12
+ maxValue?: number; // If not provided, calculated from max of data or 100?
13
+ width?: number;
14
+ height?: number;
15
+ className?: string;
16
+ colors?: string[];
17
+ startAngle?: number; // In degrees, 0 is 12 o'clock? D3 is 0 at 12? No, 0 is 12 if mapped correctly. 0 is up usually in Visx usage if we transform. Standard D3 0 is 3 o'clock.
18
+ // Let's use standard d3 angles: 0 is up (if we rotate) or 0 is right.
19
+ // Actually, easiest is: 0 = 12 oclock, 360 = 12 oclock.
20
+ endAngle?: number;
21
+ innerRadius?: number; // 0 to 1, relative to max radius? or absolute pixels?
22
+ // Let's use relative fraction 0-1 for flexibility or just let d3 handle pixels.
23
+ };
24
+
25
+ type RadialBarChartContentProps<T> = RadialBarChartProps<T> & {
26
+ width: number;
27
+ height: number;
28
+ };
29
+
30
+ // Helpers
31
+ const degreesToRadians = (degrees: number) => (degrees * Math.PI) / 180;
32
+
33
+ function RadialBarChartContent<T>({
34
+ data,
35
+ width,
36
+ height,
37
+ valueKey,
38
+ labelKey,
39
+ maxValue,
40
+ className,
41
+ colors,
42
+ startAngle = 0,
43
+ endAngle = 360,
44
+ innerRadius: customInnerRadius = 0.2, // 20% of radius empty in middle
45
+ }: RadialBarChartContentProps<T>) {
46
+ const margin = { top: 20, right: 20, bottom: 20, left: 20 };
47
+ const innerWidth = width - margin.left - margin.right;
48
+ const innerHeight = height - margin.top - margin.bottom;
49
+ const radius = Math.min(innerWidth, innerHeight) / 2;
50
+ const centerX = innerWidth / 2;
51
+ const centerY = innerHeight / 2;
52
+
53
+ // Data helpers
54
+ const getValue = (d: T) => Number(d[valueKey]);
55
+ const getLabel = (d: T) => String(d[labelKey]);
56
+
57
+ const calculatedMax = maxValue ?? Math.max(...data.map(getValue));
58
+
59
+ // Angle Scale (Length of bar)
60
+ // Maps value to angle
61
+ const angleScale = scaleLinear({
62
+ range: [degreesToRadians(startAngle), degreesToRadians(endAngle)],
63
+ domain: [0, calculatedMax],
64
+ });
65
+
66
+ // Color Scale
67
+ const defaultColors = ['#10b981', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6'];
68
+ const colorScale = scaleOrdinal({
69
+ domain: data.map((_, i) => i),
70
+ range: colors || defaultColors,
71
+ });
72
+
73
+ // Radius calculations
74
+ // We need to fit `data.length` rings between customInnerRadius*radius and radius.
75
+ // Gap between rings?
76
+ const gap = 4;
77
+ const totalRingWidth = radius - (radius * customInnerRadius);
78
+ const ringWidth = (totalRingWidth - (gap * (data.length - 1))) / data.length;
79
+
80
+ // Tooltip
81
+ const {
82
+ tooltipOpen,
83
+ tooltipLeft,
84
+ tooltipTop,
85
+ tooltipData,
86
+ hideTooltip,
87
+ showTooltip,
88
+ } = useTooltip<T>();
89
+
90
+ const { containerRef, TooltipInPortal } = useTooltipInPortal({
91
+ scroll: true,
92
+ detectBounds: true
93
+ });
94
+
95
+ if (width < 50) return null;
96
+
97
+ return (
98
+ <div className={cn("relative", className)}>
99
+ <svg ref={containerRef} width={width} height={height} className="overflow-visible">
100
+ <Group top={centerY + margin.top} left={centerX + margin.left}>
101
+ {/* Rotate so 0 is at top if desired? D3 arc 0 is at 12 o'clock? No, 0 is at 12 if using @visx/shape defaults?
102
+ Visx Arc: startAngle 0 is usually 12 o'clock.
103
+ Let's assume default mapping.
104
+ */}
105
+
106
+ {data.map((d, i) => {
107
+ // Outer to Inner? Or Inner to Outer?
108
+ // Usually Outer is the first item?
109
+ const outerR = radius - (i * (ringWidth + gap));
110
+ const innerR = outerR - ringWidth;
111
+ const value = getValue(d);
112
+ const barAngle = angleScale(value);
113
+ const backgroundStart = degreesToRadians(startAngle);
114
+ const backgroundEnd = degreesToRadians(endAngle);
115
+
116
+ return (
117
+ <Group key={`ring-${i}`}>
118
+ {/* Background Track */}
119
+ <Arc
120
+ innerRadius={innerR}
121
+ outerRadius={outerR}
122
+ startAngle={backgroundStart}
123
+ endAngle={backgroundEnd}
124
+ fill="hsl(var(--muted))"
125
+ opacity={0.2}
126
+ cornerRadius={3}
127
+ />
128
+
129
+ {/* Value Bar */}
130
+ <Arc
131
+ innerRadius={innerR}
132
+ outerRadius={outerR}
133
+ startAngle={backgroundStart}
134
+ endAngle={barAngle}
135
+ fill={colorScale(i)}
136
+ cornerRadius={3}
137
+ className="transition-all duration-500 hover:opacity-80 cursor-pointer"
138
+ onMouseEnter={() => {
139
+ // We need a specific point for tooltip.
140
+ // Centroid is okay, but mapped to the bar.
141
+ // Or just mouse coords?
142
+ // Arc.centroid is complex.
143
+ // Just use center + radius offset?
144
+ // Simple: center of the chart
145
+ showTooltip({
146
+ tooltipData: d,
147
+ tooltipLeft: centerX + margin.left,
148
+ tooltipTop: centerY + margin.top
149
+ })
150
+ }}
151
+ onMouseLeave={hideTooltip}
152
+ />
153
+ </Group>
154
+ )
155
+ })}
156
+ </Group>
157
+ </svg>
158
+
159
+ {/* Center Text (if single value? Or generic legend?)
160
+ Maybe leave center empty for now unless needed.
161
+ */}
162
+
163
+ {tooltipOpen && tooltipData && (
164
+ <TooltipInPortal
165
+ top={tooltipTop}
166
+ left={tooltipLeft}
167
+ style={{ ...defaultStyles, padding: 0, borderRadius: 0, boxShadow: 'none', background: 'transparent', transform: 'translate(-50%, -50%)', zIndex: 100 }}
168
+ >
169
+ <div className="rounded-md border bg-white dark:bg-slate-900 px-3 py-1.5 text-sm text-slate-900 dark:text-slate-100 shadow-xl text-center">
170
+ <div className="font-semibold">{getLabel(tooltipData)}</div>
171
+ <div>{getValue(tooltipData)} / {calculatedMax}</div>
172
+ </div>
173
+ </TooltipInPortal>
174
+ )}
175
+ </div>
176
+ );
177
+ }
178
+
179
+ export function RadialBarChart<T>(props: RadialBarChartProps<T>) {
180
+ return (
181
+ <div style={{ width: '100%', height: '100%', minHeight: 200 }}>
182
+ <ParentSize>
183
+ {({ width, height }) => <RadialBarChartContent {...props} width={width} height={height} />}
184
+ </ParentSize>
185
+ </div>
186
+ );
187
+ }