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 +25 -12
- package/package.json +2 -2
- package/src/registry.js +20 -0
- package/templates/CandlestickChart.tsx +267 -0
- package/templates/ChartLegend.tsx +38 -0
- package/templates/FunnelChart.tsx +194 -0
- package/templates/RadialBarChart.tsx +187 -0
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` |
|
|
45
|
-
|
|
46
|
-
|
|
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.
|
|
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
|
+
}
|