waffle-charts-cli 0.1.6 → 0.1.7
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 +1 -0
- package/package.json +1 -1
- package/src/registry.js +5 -0
- package/templates/WaffleChart.tsx +203 -0
package/README.md
CHANGED
|
@@ -65,6 +65,7 @@ If your utils are located elsewhere, you may need to update the imports in the a
|
|
|
65
65
|
| **Composite** | `composite-chart` | Dual-axis Bar + Line combination. |
|
|
66
66
|
| **Funnel Chart** | `funnel-chart` | **New!** Process flow stages and conversion. |
|
|
67
67
|
| **Radial Bar** | `radial-bar-chart` | **New!** Circular gauge/progress visualization. |
|
|
68
|
+
| **Waffle Chart** | `waffle-chart` | **New!** 10x10 Grid for parts-to-whole visualization. |
|
|
68
69
|
| **Legend** | `chart-legend` | **New!** Reusable chart legend component. |
|
|
69
70
|
|
|
70
71
|
## License
|
package/package.json
CHANGED
package/src/registry.js
CHANGED
|
@@ -78,5 +78,10 @@ export const registry = {
|
|
|
78
78
|
file: "RadialBarChart.tsx",
|
|
79
79
|
label: "Radial Bar Chart",
|
|
80
80
|
dependencies: ["@visx/group", "@visx/shape", "@visx/scale", "@visx/responsive", "@visx/tooltip", "clsx", "tailwind-merge"],
|
|
81
|
+
},
|
|
82
|
+
"waffle-chart": {
|
|
83
|
+
file: "WaffleChart.tsx",
|
|
84
|
+
label: "Waffle Chart",
|
|
85
|
+
dependencies: ["@visx/group", "@visx/responsive", "@visx/scale", "@visx/tooltip", "clsx", "tailwind-merge"],
|
|
81
86
|
}
|
|
82
87
|
};
|
|
@@ -0,0 +1,203 @@
|
|
|
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';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
export type WaffleChartProps<T> = {
|
|
9
|
+
data: T[];
|
|
10
|
+
labelKey: keyof T;
|
|
11
|
+
valueKey: keyof T;
|
|
12
|
+
total?: number; // Optional total, otherwise sum of values
|
|
13
|
+
rows?: number;
|
|
14
|
+
columns?: number;
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
gap?: number;
|
|
18
|
+
rounding?: number;
|
|
19
|
+
className?: string;
|
|
20
|
+
colors?: string[];
|
|
21
|
+
testId?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type WaffleChartContentProps<T> = WaffleChartProps<T> & {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Helper to generate grid cells
|
|
30
|
+
// Returns array of { r, c, index, dataIndex, ... }
|
|
31
|
+
// We map data to cells.
|
|
32
|
+
// Example: Data A=30, B=20, Total=100. Grid 10x10=100 cells.
|
|
33
|
+
// Cells 0-29 -> A. Cells 30-49 -> B. Cells 50-99 -> Empty/Grey.
|
|
34
|
+
|
|
35
|
+
function WaffleChartContent<T>({
|
|
36
|
+
data,
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
labelKey,
|
|
40
|
+
valueKey,
|
|
41
|
+
total,
|
|
42
|
+
rows = 10,
|
|
43
|
+
columns = 10,
|
|
44
|
+
gap = 2,
|
|
45
|
+
rounding = 2,
|
|
46
|
+
className,
|
|
47
|
+
colors,
|
|
48
|
+
testId = 'waffle-chart',
|
|
49
|
+
}: WaffleChartContentProps<T>) {
|
|
50
|
+
const margin = { top: 0, right: 0, bottom: 0, left: 0 };
|
|
51
|
+
const innerWidth = width - margin.left - margin.right;
|
|
52
|
+
const innerHeight = height - margin.top - margin.bottom;
|
|
53
|
+
|
|
54
|
+
// Accessors
|
|
55
|
+
const getLabel = (d: T) => String(d[labelKey]);
|
|
56
|
+
const getValue = (d: T) => Number(d[valueKey]);
|
|
57
|
+
|
|
58
|
+
// Color Scale
|
|
59
|
+
const defaultColors = ['#a855f7', '#ec4899', '#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
|
60
|
+
const colorScale = scaleOrdinal({
|
|
61
|
+
domain: data.map((_, i) => i),
|
|
62
|
+
range: colors || defaultColors,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Cell Calculation
|
|
66
|
+
const totalCells = rows * columns;
|
|
67
|
+
const dataTotal = useMemo(() => data.reduce((sum, d) => sum + getValue(d), 0), [data, valueKey]);
|
|
68
|
+
const effectiveTotal = total || dataTotal;
|
|
69
|
+
|
|
70
|
+
// Generate Cells
|
|
71
|
+
// We need to assign each cell to a data segment.
|
|
72
|
+
const cells = useMemo(() => {
|
|
73
|
+
const grid = [];
|
|
74
|
+
|
|
75
|
+
// Let's create a flat array of 'types'
|
|
76
|
+
const flatMap: { type: 'data' | 'empty', d?: T, index?: number }[] = [];
|
|
77
|
+
|
|
78
|
+
data.forEach((d, i) => {
|
|
79
|
+
const val = getValue(d);
|
|
80
|
+
// Proportion of grid
|
|
81
|
+
const count = Math.round((val / effectiveTotal) * totalCells);
|
|
82
|
+
for (let k = 0; k < count; k++) {
|
|
83
|
+
if (flatMap.length < totalCells) {
|
|
84
|
+
flatMap.push({ type: 'data', d, index: i });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Fill remaining with empty
|
|
90
|
+
while (flatMap.length < totalCells) {
|
|
91
|
+
flatMap.push({ type: 'empty' });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Now map map to grid coords
|
|
95
|
+
// Default filling: Row by Row default? Or user specified?
|
|
96
|
+
// Let's fill Row by Row (Top Left to Bottom Right)
|
|
97
|
+
for (let r = 0; r < rows; r++) {
|
|
98
|
+
for (let c = 0; c < columns; c++) {
|
|
99
|
+
const i = r * columns + c;
|
|
100
|
+
const cellData = flatMap[i];
|
|
101
|
+
grid.push({
|
|
102
|
+
r,
|
|
103
|
+
c,
|
|
104
|
+
i,
|
|
105
|
+
...cellData
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return grid;
|
|
111
|
+
}, [data, rows, columns, effectiveTotal, totalCells, valueKey]);
|
|
112
|
+
|
|
113
|
+
// Layout
|
|
114
|
+
// fit grid into width/height.
|
|
115
|
+
const cellWidth = (innerWidth - (gap * (columns - 1))) / columns;
|
|
116
|
+
const cellHeight = (innerHeight - (gap * (rows - 1))) / rows;
|
|
117
|
+
|
|
118
|
+
// Tooltip
|
|
119
|
+
const {
|
|
120
|
+
tooltipOpen,
|
|
121
|
+
tooltipLeft,
|
|
122
|
+
tooltipTop,
|
|
123
|
+
tooltipData,
|
|
124
|
+
hideTooltip,
|
|
125
|
+
showTooltip,
|
|
126
|
+
} = useTooltip<{ d: T, index: number }>();
|
|
127
|
+
|
|
128
|
+
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
|
129
|
+
scroll: true,
|
|
130
|
+
detectBounds: true
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (width < 10) return null;
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className={cn("relative", className)} data-testid={testId}>
|
|
137
|
+
<svg ref={containerRef} width={width} height={height} className="overflow-hidden rounded-md">
|
|
138
|
+
<Group left={margin.left} top={margin.top}>
|
|
139
|
+
{cells.map((cell) => {
|
|
140
|
+
const x = cell.c * (cellWidth + gap);
|
|
141
|
+
const y = cell.r * (cellHeight + gap);
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<rect
|
|
145
|
+
key={`cell-${cell.i}`}
|
|
146
|
+
x={x}
|
|
147
|
+
y={y}
|
|
148
|
+
width={Math.max(0, cellWidth)}
|
|
149
|
+
height={Math.max(0, cellHeight)}
|
|
150
|
+
rx={rounding}
|
|
151
|
+
ry={rounding}
|
|
152
|
+
fill={cell.type === 'data' && cell.index !== undefined
|
|
153
|
+
? colorScale(cell.index)
|
|
154
|
+
: 'hsl(var(--muted))'
|
|
155
|
+
}
|
|
156
|
+
className={cn(
|
|
157
|
+
"transition-all duration-200",
|
|
158
|
+
cell.type === 'data' ? "hover:opacity-80 cursor-pointer" : "opacity-20"
|
|
159
|
+
)}
|
|
160
|
+
onMouseEnter={() => {
|
|
161
|
+
if (cell.type === 'data' && cell.d && cell.index !== undefined) {
|
|
162
|
+
showTooltip({
|
|
163
|
+
tooltipData: { d: cell.d, index: cell.index },
|
|
164
|
+
tooltipLeft: x + cellWidth / 2,
|
|
165
|
+
tooltipTop: y
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
onMouseLeave={hideTooltip}
|
|
170
|
+
/>
|
|
171
|
+
)
|
|
172
|
+
})}
|
|
173
|
+
</Group>
|
|
174
|
+
</svg>
|
|
175
|
+
|
|
176
|
+
{tooltipOpen && tooltipData && (
|
|
177
|
+
<TooltipInPortal
|
|
178
|
+
top={tooltipTop}
|
|
179
|
+
left={tooltipLeft}
|
|
180
|
+
style={{ ...defaultStyles, padding: 0, borderRadius: 0, boxShadow: 'none', background: 'transparent', transform: 'translate(-50%, -100%)', marginTop: '-8px', zIndex: 100 }}
|
|
181
|
+
>
|
|
182
|
+
<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">
|
|
183
|
+
<div className="font-semibold flex items-center gap-2 justify-center">
|
|
184
|
+
<div className="w-2 h-2 rounded-full" style={{ background: colorScale(tooltipData.index) }} />
|
|
185
|
+
{getLabel(tooltipData.d)}
|
|
186
|
+
</div>
|
|
187
|
+
<div className="text-muted-foreground">{getValue(tooltipData.d)} ({Math.round(getValue(tooltipData.d) / effectiveTotal * 100)}%)</div>
|
|
188
|
+
</div>
|
|
189
|
+
</TooltipInPortal>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function WaffleChart<T>(props: WaffleChartProps<T>) {
|
|
196
|
+
return (
|
|
197
|
+
<div style={{ width: '100%', height: '100%', minHeight: 200 }}>
|
|
198
|
+
<ParentSize>
|
|
199
|
+
{({ width, height }) => <WaffleChartContent {...props} width={width} height={height} />}
|
|
200
|
+
</ParentSize>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|