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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waffle-charts-cli",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "CLI to add WaffleCharts components to your project",
5
5
  "main": "bin/index.js",
6
6
  "bin": {
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
+ }