waffle-charts-cli 0.1.0
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/bin/index.js +31 -0
- package/package.json +40 -0
- package/src/commands/add.js +91 -0
- package/src/registry.js +47 -0
- package/templates/AreaChart.tsx +205 -0
- package/templates/BarChart.tsx +165 -0
- package/templates/BubbleChart.tsx +193 -0
- package/templates/HeatmapChart.tsx +155 -0
- package/templates/LineChart.tsx +230 -0
- package/templates/PieChart.tsx +177 -0
- package/templates/RadarChart.tsx +163 -0
- package/templates/ScatterChart.tsx +167 -0
- package/templates/TreemapChart.tsx +135 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { Pie } from '@visx/shape';
|
|
2
|
+
import { Group } from '@visx/group';
|
|
3
|
+
import { scaleOrdinal } from '@visx/scale';
|
|
4
|
+
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
import { ParentSize } from '@visx/responsive';
|
|
7
|
+
import { cn } from '../../lib/utils';
|
|
8
|
+
import { arc as d3arc } from 'd3-shape'; // Direct import for custom arc generation
|
|
9
|
+
|
|
10
|
+
// Types
|
|
11
|
+
export type PieChartProps<T> = {
|
|
12
|
+
data: T[];
|
|
13
|
+
valueKey: keyof T;
|
|
14
|
+
labelKey: keyof T; // Used for tooltip or legend
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
className?: string; // Wrapper class
|
|
18
|
+
innerRadius?: number; // 0 for Pie, >0 for Donut
|
|
19
|
+
colors?: string[]; // CSS variable strings or hex
|
|
20
|
+
centerText?: {
|
|
21
|
+
title: string;
|
|
22
|
+
subtitle?: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type PieChartContentProps<T> = PieChartProps<T> & {
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function PieChartContent<T>({
|
|
32
|
+
data,
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
valueKey,
|
|
36
|
+
labelKey,
|
|
37
|
+
className,
|
|
38
|
+
innerRadius = 0, // Default to full pie
|
|
39
|
+
colors,
|
|
40
|
+
centerText,
|
|
41
|
+
}: PieChartContentProps<T>) {
|
|
42
|
+
const margin = { top: 20, right: 20, bottom: 20, left: 20 };
|
|
43
|
+
const innerWidth = width - margin.left - margin.right;
|
|
44
|
+
const innerHeight = height - margin.top - margin.bottom;
|
|
45
|
+
const radius = Math.min(innerWidth, innerHeight) / 2;
|
|
46
|
+
const centerY = innerHeight / 2;
|
|
47
|
+
const centerX = innerWidth / 2;
|
|
48
|
+
|
|
49
|
+
// Accessors
|
|
50
|
+
const getValue = (d: T) => Number(d[valueKey]);
|
|
51
|
+
|
|
52
|
+
// Scales (Color)
|
|
53
|
+
// We prefer using CSS classes/variables, but Visx Pie returns arcs.
|
|
54
|
+
// We will map index to a Tailwind color class if provided, or default ordinals.
|
|
55
|
+
const defaultColors = ['text-primary', 'text-blue-500', 'text-indigo-500', 'text-sky-500', 'text-cyan-500']
|
|
56
|
+
const colorScale = scaleOrdinal({
|
|
57
|
+
domain: data.map((_, i) => i),
|
|
58
|
+
range: colors || defaultColors,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Tooltip
|
|
62
|
+
const {
|
|
63
|
+
tooltipOpen,
|
|
64
|
+
tooltipLeft,
|
|
65
|
+
tooltipTop,
|
|
66
|
+
tooltipData,
|
|
67
|
+
hideTooltip,
|
|
68
|
+
showTooltip,
|
|
69
|
+
} = useTooltip<T>();
|
|
70
|
+
|
|
71
|
+
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
|
72
|
+
scroll: true,
|
|
73
|
+
detectBounds: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Interaction State
|
|
77
|
+
const [activeShape, setActiveShape] = useState<number | null>(null);
|
|
78
|
+
|
|
79
|
+
if (width < 10) return null;
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className={cn("relative flex items-center justify-center", className)}>
|
|
83
|
+
<svg ref={containerRef} width={width} height={height} className="overflow-visible">
|
|
84
|
+
<Group top={centerY + margin.top} left={centerX + margin.left}>
|
|
85
|
+
<Pie
|
|
86
|
+
data={data}
|
|
87
|
+
pieValue={getValue}
|
|
88
|
+
outerRadius={radius}
|
|
89
|
+
innerRadius={innerRadius}
|
|
90
|
+
padAngle={0.02}
|
|
91
|
+
cornerRadius={3}
|
|
92
|
+
>
|
|
93
|
+
{(pie) => {
|
|
94
|
+
return pie.arcs.map((arc, index) => {
|
|
95
|
+
const [centroidX, centroidY] = pie.path.centroid(arc);
|
|
96
|
+
const isHovered = activeShape === index;
|
|
97
|
+
const currentOuterRadius = isHovered ? radius + 5 : radius;
|
|
98
|
+
|
|
99
|
+
// Create custom arc generator for hover effect
|
|
100
|
+
// Create custom arc generator for hover effect
|
|
101
|
+
const arcGenerator = d3arc().cornerRadius(3);
|
|
102
|
+
// @ts-ignore - d3 types might still complain about explicit config matching
|
|
103
|
+
const arcPath = arcGenerator({
|
|
104
|
+
innerRadius,
|
|
105
|
+
outerRadius: currentOuterRadius,
|
|
106
|
+
startAngle: arc.startAngle,
|
|
107
|
+
endAngle: arc.endAngle,
|
|
108
|
+
padAngle: arc.padAngle,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<g key={`arc-${index}`}>
|
|
113
|
+
<path
|
|
114
|
+
d={arcPath || ''}
|
|
115
|
+
className={cn("fill-current transition-all duration-300 cursor-pointer hover:opacity-80", colorScale(index))}
|
|
116
|
+
// If colors are passed as specific colors (not classes), you might use fill={...} instead.
|
|
117
|
+
// This implementation assumes 'colors' prop contains Tailwind TEXT color classes (e.g. 'text-blue-500'),
|
|
118
|
+
// which fill-current will inherit.
|
|
119
|
+
onMouseEnter={() => {
|
|
120
|
+
setActiveShape(index);
|
|
121
|
+
showTooltip({
|
|
122
|
+
tooltipData: arc.data,
|
|
123
|
+
tooltipLeft: centroidX + centerX + margin.left,
|
|
124
|
+
tooltipTop: centroidY + centerY + margin.top,
|
|
125
|
+
})
|
|
126
|
+
}}
|
|
127
|
+
onMouseLeave={() => {
|
|
128
|
+
setActiveShape(null);
|
|
129
|
+
hideTooltip();
|
|
130
|
+
}}
|
|
131
|
+
/>
|
|
132
|
+
</g>
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
}}
|
|
136
|
+
</Pie>
|
|
137
|
+
|
|
138
|
+
{/* Center Text (Donut only) */}
|
|
139
|
+
{innerRadius > 0 && centerText && (
|
|
140
|
+
<text
|
|
141
|
+
textAnchor="middle"
|
|
142
|
+
pointerEvents="none"
|
|
143
|
+
>
|
|
144
|
+
<tspan x="0" dy="-0.5em" className="fill-foreground text-2xl font-bold">{centerText.title}</tspan>
|
|
145
|
+
{centerText.subtitle && (
|
|
146
|
+
<tspan x="0" dy="1.5em" className="fill-muted-foreground text-sm uppercase tracking-wider">{centerText.subtitle}</tspan>
|
|
147
|
+
)}
|
|
148
|
+
</text>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
</Group>
|
|
152
|
+
</svg>
|
|
153
|
+
{tooltipOpen && tooltipData && (
|
|
154
|
+
<TooltipInPortal
|
|
155
|
+
top={tooltipTop}
|
|
156
|
+
left={tooltipLeft}
|
|
157
|
+
style={{ ...defaultStyles, padding: 0, borderRadius: 0, boxShadow: 'none', background: 'transparent' }}
|
|
158
|
+
>
|
|
159
|
+
<div className="rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
|
|
160
|
+
<p className="font-semibold">{String(getValue(tooltipData))}</p>
|
|
161
|
+
<p className="text-xs text-muted-foreground">{String(tooltipData[labelKey])}</p>
|
|
162
|
+
</div>
|
|
163
|
+
</TooltipInPortal>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const PieChart = <T,>(props: PieChartProps<T>) => {
|
|
170
|
+
return (
|
|
171
|
+
<div className="w-full h-[300px]">
|
|
172
|
+
<ParentSize>
|
|
173
|
+
{({ width, height }) => <PieChartContent {...props} width={width} height={height} />}
|
|
174
|
+
</ParentSize>
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { Group } from '@visx/group';
|
|
2
|
+
import { scaleLinear } from '@visx/scale';
|
|
3
|
+
import { Point } from '@visx/point';
|
|
4
|
+
import { ParentSize } from '@visx/responsive';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
import { useMemo } from 'react';
|
|
7
|
+
|
|
8
|
+
export type RadarChartProps<T> = {
|
|
9
|
+
data: T[];
|
|
10
|
+
radiusKey: keyof T;
|
|
11
|
+
angleKey: keyof T;
|
|
12
|
+
className?: string;
|
|
13
|
+
gridColor?: string;
|
|
14
|
+
polygonColor?: string;
|
|
15
|
+
width?: number;
|
|
16
|
+
height?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type RadarChartContentProps<T> = RadarChartProps<T> & {
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function RadarChartContent<T>({
|
|
25
|
+
data,
|
|
26
|
+
width,
|
|
27
|
+
height,
|
|
28
|
+
radiusKey,
|
|
29
|
+
angleKey,
|
|
30
|
+
className,
|
|
31
|
+
gridColor = "stroke-border",
|
|
32
|
+
polygonColor = "fill-primary",
|
|
33
|
+
}: RadarChartContentProps<T>) {
|
|
34
|
+
const margin = { top: 40, right: 40, bottom: 40, left: 40 };
|
|
35
|
+
const xMax = width - margin.left - margin.right;
|
|
36
|
+
const yMax = height - margin.top - margin.bottom;
|
|
37
|
+
const radius = Math.min(xMax, yMax) / 2;
|
|
38
|
+
|
|
39
|
+
const getRadius = (d: T) => Number(d[radiusKey]);
|
|
40
|
+
const getAngle = (d: T) => d[angleKey] as string;
|
|
41
|
+
|
|
42
|
+
const yScale = useMemo(
|
|
43
|
+
() =>
|
|
44
|
+
scaleLinear<number>({
|
|
45
|
+
range: [0, radius],
|
|
46
|
+
domain: [0, Math.max(...data.map(getRadius)) * 1.1],
|
|
47
|
+
}),
|
|
48
|
+
[radius, data, radiusKey]
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const angleStep = (Math.PI * 2) / data.length;
|
|
52
|
+
|
|
53
|
+
// Generate grid points
|
|
54
|
+
// 5 concentric circles
|
|
55
|
+
const gridLevels = [1, 2, 3, 4, 5];
|
|
56
|
+
const gridPoints = gridLevels.map((level) => {
|
|
57
|
+
const r = (radius / 5) * level;
|
|
58
|
+
return data.map((_, i) => {
|
|
59
|
+
const angle = i * angleStep - Math.PI / 2;
|
|
60
|
+
return new Point({
|
|
61
|
+
x: r * Math.cos(angle),
|
|
62
|
+
y: r * Math.sin(angle),
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Calculate polygon points
|
|
68
|
+
const points = data.map((d, i) => {
|
|
69
|
+
const angle = i * angleStep - Math.PI / 2;
|
|
70
|
+
const r = yScale(getRadius(d));
|
|
71
|
+
return new Point({
|
|
72
|
+
x: r * Math.cos(angle),
|
|
73
|
+
y: r * Math.sin(angle),
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (width < 10) return null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn("relative flex items-center justify-center", className)}>
|
|
81
|
+
<svg width={width} height={height} className="overflow-visible">
|
|
82
|
+
<Group top={height / 2} left={width / 2}>
|
|
83
|
+
{/* Grid Rings (Polygonal) */}
|
|
84
|
+
{gridPoints.map((levelPoints, i) => (
|
|
85
|
+
<polygon
|
|
86
|
+
key={`grid-level-${i}`}
|
|
87
|
+
points={levelPoints.map(p => `${p.x},${p.y}`).join(' ')}
|
|
88
|
+
fill="transparent"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
className={cn("stroke-1 opacity-20", gridColor)}
|
|
91
|
+
/>
|
|
92
|
+
))}
|
|
93
|
+
|
|
94
|
+
{/* Axes */}
|
|
95
|
+
{data.map((_, i) => {
|
|
96
|
+
const angle = i * angleStep - Math.PI / 2;
|
|
97
|
+
const r = radius;
|
|
98
|
+
const x = r * Math.cos(angle);
|
|
99
|
+
const y = r * Math.sin(angle);
|
|
100
|
+
return (
|
|
101
|
+
<line
|
|
102
|
+
key={`axis-${i}`}
|
|
103
|
+
x1={0}
|
|
104
|
+
y1={0}
|
|
105
|
+
x2={x}
|
|
106
|
+
y2={y}
|
|
107
|
+
className={cn("stroke-1 opacity-20", gridColor)}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
|
|
112
|
+
{/* Labels */}
|
|
113
|
+
{data.map((d, i) => {
|
|
114
|
+
const angle = i * angleStep - Math.PI / 2;
|
|
115
|
+
const r = radius + 20; // Offset label
|
|
116
|
+
const x = r * Math.cos(angle);
|
|
117
|
+
const y = r * Math.sin(angle);
|
|
118
|
+
return (
|
|
119
|
+
<text
|
|
120
|
+
key={`label-${i}`}
|
|
121
|
+
x={x}
|
|
122
|
+
y={y}
|
|
123
|
+
dy="0.35em"
|
|
124
|
+
textAnchor={x > 0 ? 'start' : x < 0 ? 'end' : 'middle'}
|
|
125
|
+
className="text-xs fill-muted-foreground capit"
|
|
126
|
+
>
|
|
127
|
+
{getAngle(d)}
|
|
128
|
+
</text>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
|
|
132
|
+
{/* The Radar Polygon */}
|
|
133
|
+
<polygon
|
|
134
|
+
points={points.map(p => `${p.x},${p.y}`).join(' ')}
|
|
135
|
+
className={cn("stroke-primary stroke-2 fill-primary/20 hover:opacity-80 transition-opacity", polygonColor)}
|
|
136
|
+
/>
|
|
137
|
+
|
|
138
|
+
{/* Dots on corners */}
|
|
139
|
+
{points.map((p, i) => (
|
|
140
|
+
<circle
|
|
141
|
+
key={`point-${i}`}
|
|
142
|
+
cx={p.x}
|
|
143
|
+
cy={p.y}
|
|
144
|
+
r={4}
|
|
145
|
+
className="fill-background stroke-primary stroke-2"
|
|
146
|
+
/>
|
|
147
|
+
))}
|
|
148
|
+
|
|
149
|
+
</Group>
|
|
150
|
+
</svg>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const RadarChart = <T,>(props: RadarChartProps<T>) => {
|
|
156
|
+
return (
|
|
157
|
+
<div className="w-full h-[300px]">
|
|
158
|
+
<ParentSize>
|
|
159
|
+
{({ width, height }) => <RadarChartContent {...props} width={width} height={height} />}
|
|
160
|
+
</ParentSize>
|
|
161
|
+
</div>
|
|
162
|
+
)
|
|
163
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Group } from '@visx/group';
|
|
3
|
+
import { Circle } from '@visx/shape';
|
|
4
|
+
import { scaleLinear } from '@visx/scale';
|
|
5
|
+
import { AxisBottom, AxisLeft } from '@visx/axis';
|
|
6
|
+
import { Grid } from '@visx/grid';
|
|
7
|
+
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
|
|
8
|
+
import { ParentSize } from '@visx/responsive';
|
|
9
|
+
import { cn } from '../../lib/utils';
|
|
10
|
+
|
|
11
|
+
// Types
|
|
12
|
+
export type ScatterChartProps<T> = {
|
|
13
|
+
data: T[];
|
|
14
|
+
xKey: keyof T;
|
|
15
|
+
yKey: keyof T;
|
|
16
|
+
className?: string; // Wrapper class
|
|
17
|
+
pointClassName?: string; // Point color/style
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Internal component
|
|
23
|
+
type ScatterChartContentProps<T> = ScatterChartProps<T> & {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function ScatterChartContent<T>({
|
|
29
|
+
data,
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
xKey,
|
|
33
|
+
yKey,
|
|
34
|
+
className,
|
|
35
|
+
pointClassName = "fill-primary",
|
|
36
|
+
}: ScatterChartContentProps<T>) {
|
|
37
|
+
// Config
|
|
38
|
+
const margin = { top: 40, right: 30, bottom: 50, left: 50 };
|
|
39
|
+
const xMax = width - margin.left - margin.right;
|
|
40
|
+
const yMax = height - margin.top - margin.bottom;
|
|
41
|
+
|
|
42
|
+
// Accessors
|
|
43
|
+
const getX = (d: T) => Number(d[xKey]);
|
|
44
|
+
const getY = (d: T) => Number(d[yKey]);
|
|
45
|
+
|
|
46
|
+
// Scales
|
|
47
|
+
const xScale = useMemo(
|
|
48
|
+
() =>
|
|
49
|
+
scaleLinear<number>({
|
|
50
|
+
range: [0, xMax],
|
|
51
|
+
round: true,
|
|
52
|
+
domain: [0, Math.max(...data.map(getX)) * 1.1], // Add padding
|
|
53
|
+
}),
|
|
54
|
+
[xMax, data, xKey],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const yScale = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
scaleLinear<number>({
|
|
60
|
+
range: [yMax, 0],
|
|
61
|
+
round: true,
|
|
62
|
+
domain: [0, Math.max(...data.map(getY)) * 1.1],
|
|
63
|
+
}),
|
|
64
|
+
[yMax, data, yKey],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Tooltip
|
|
68
|
+
const {
|
|
69
|
+
tooltipOpen,
|
|
70
|
+
tooltipLeft,
|
|
71
|
+
tooltipTop,
|
|
72
|
+
tooltipData,
|
|
73
|
+
hideTooltip,
|
|
74
|
+
showTooltip,
|
|
75
|
+
} = useTooltip<T>();
|
|
76
|
+
|
|
77
|
+
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
|
78
|
+
scroll: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (width < 10) return null;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn("relative", className)}>
|
|
85
|
+
<svg ref={containerRef} width={width} height={height} className="overflow-visible">
|
|
86
|
+
<Group left={margin.left} top={margin.top}>
|
|
87
|
+
<Grid
|
|
88
|
+
xScale={xScale}
|
|
89
|
+
yScale={yScale}
|
|
90
|
+
width={xMax}
|
|
91
|
+
height={yMax}
|
|
92
|
+
stroke="hsl(var(--border))"
|
|
93
|
+
strokeOpacity={0.4}
|
|
94
|
+
/>
|
|
95
|
+
<AxisBottom
|
|
96
|
+
top={yMax}
|
|
97
|
+
scale={xScale}
|
|
98
|
+
stroke="hsl(var(--border))"
|
|
99
|
+
tickStroke="hsl(var(--border))"
|
|
100
|
+
tickLabelProps={{
|
|
101
|
+
fill: "hsl(var(--muted-foreground))",
|
|
102
|
+
fontSize: 11,
|
|
103
|
+
textAnchor: "middle",
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
<AxisLeft
|
|
107
|
+
scale={yScale}
|
|
108
|
+
stroke="transparent"
|
|
109
|
+
tickStroke="hsl(var(--border))"
|
|
110
|
+
tickLabelProps={{
|
|
111
|
+
fill: "hsl(var(--muted-foreground))",
|
|
112
|
+
fontSize: 11,
|
|
113
|
+
textAnchor: "end",
|
|
114
|
+
dx: -4,
|
|
115
|
+
dy: 4,
|
|
116
|
+
}}
|
|
117
|
+
/>
|
|
118
|
+
{data.map((d, i) => {
|
|
119
|
+
const cx = xScale(getX(d));
|
|
120
|
+
const cy = yScale(getY(d));
|
|
121
|
+
return (
|
|
122
|
+
<Circle
|
|
123
|
+
key={`point-${i}`}
|
|
124
|
+
cx={cx}
|
|
125
|
+
cy={cy}
|
|
126
|
+
r={6}
|
|
127
|
+
className={cn("transition-all duration-300 hover:r-8 hover:opacity-80 cursor-pointer", pointClassName)}
|
|
128
|
+
onMouseEnter={() => {
|
|
129
|
+
showTooltip({
|
|
130
|
+
tooltipData: d,
|
|
131
|
+
tooltipLeft: cx + margin.left, // absolute relative to container
|
|
132
|
+
tooltipTop: cy + margin.top, // absolute relative to container
|
|
133
|
+
});
|
|
134
|
+
}}
|
|
135
|
+
onMouseLeave={() => hideTooltip()}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
138
|
+
})}
|
|
139
|
+
</Group>
|
|
140
|
+
</svg>
|
|
141
|
+
{tooltipOpen && tooltipData && (
|
|
142
|
+
<TooltipInPortal
|
|
143
|
+
top={tooltipTop}
|
|
144
|
+
left={tooltipLeft}
|
|
145
|
+
style={{ ...defaultStyles, padding: 0, borderRadius: 0, boxShadow: 'none', background: 'transparent' }}
|
|
146
|
+
>
|
|
147
|
+
<div className="rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
|
|
148
|
+
<div className="flex flex-col gap-1">
|
|
149
|
+
<span className="font-semibold">{String(getY(tooltipData))}</span>
|
|
150
|
+
<span className="text-xs text-muted-foreground">{String(getX(tooltipData))}</span>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</TooltipInPortal>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const ScatterChart = <T,>(props: ScatterChartProps<T>) => {
|
|
160
|
+
return (
|
|
161
|
+
<div className="w-full h-[300px]">
|
|
162
|
+
<ParentSize>
|
|
163
|
+
{({ width, height }) => <ScatterChartContent {...props} width={width} height={height} />}
|
|
164
|
+
</ParentSize>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Group } from '@visx/group';
|
|
2
|
+
import {
|
|
3
|
+
Treemap,
|
|
4
|
+
hierarchy,
|
|
5
|
+
treemapSquarify,
|
|
6
|
+
treemapBinary,
|
|
7
|
+
treemapDice,
|
|
8
|
+
treemapSlice,
|
|
9
|
+
treemapSliceDice,
|
|
10
|
+
treemapResquarify
|
|
11
|
+
} from '@visx/hierarchy';
|
|
12
|
+
import { ParentSize } from '@visx/responsive';
|
|
13
|
+
import { cn } from '../../lib/utils';
|
|
14
|
+
import { useMemo } from 'react';
|
|
15
|
+
|
|
16
|
+
// Types
|
|
17
|
+
export type TreemapData = {
|
|
18
|
+
name: string;
|
|
19
|
+
size?: number; // Leaf nodes have size
|
|
20
|
+
parent?: string; // For explicit parent-child (optional if using nested structure)
|
|
21
|
+
children?: TreemapData[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const tileMethods = {
|
|
25
|
+
binary: treemapBinary,
|
|
26
|
+
squarify: treemapSquarify,
|
|
27
|
+
resquarify: treemapResquarify,
|
|
28
|
+
slice: treemapSlice,
|
|
29
|
+
dice: treemapDice,
|
|
30
|
+
sliceDice: treemapSliceDice,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type TreemapChartProps = {
|
|
34
|
+
data: TreemapData; // Root node
|
|
35
|
+
width?: number;
|
|
36
|
+
height?: number;
|
|
37
|
+
className?: string;
|
|
38
|
+
tileMethod?: keyof typeof tileMethods;
|
|
39
|
+
background?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type TreemapChartContentProps = TreemapChartProps & {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function TreemapChartContent({
|
|
48
|
+
data,
|
|
49
|
+
width,
|
|
50
|
+
height,
|
|
51
|
+
className,
|
|
52
|
+
background = "fill-background",
|
|
53
|
+
tileMethod = "squarify"
|
|
54
|
+
}: TreemapChartContentProps) {
|
|
55
|
+
|
|
56
|
+
const root = useMemo(() => {
|
|
57
|
+
// If data is already a hierarchy tree
|
|
58
|
+
const rootHierarchy = hierarchy(data)
|
|
59
|
+
.sort((a, b) => (b.value || 0) - (a.value || 0));
|
|
60
|
+
|
|
61
|
+
// Sum values for layout
|
|
62
|
+
return rootHierarchy.sum((d) => d.size ?? 0);
|
|
63
|
+
}, [data]);
|
|
64
|
+
|
|
65
|
+
const tile = tileMethods[tileMethod] || treemapSquarify;
|
|
66
|
+
|
|
67
|
+
if (width < 10) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div className={cn("relative", className)}>
|
|
71
|
+
<svg width={width} height={height} className="overflow-visible">
|
|
72
|
+
<rect width={width} height={height} rx={14} className={background} />
|
|
73
|
+
<Treemap<TreemapData>
|
|
74
|
+
top={0}
|
|
75
|
+
left={0}
|
|
76
|
+
root={root}
|
|
77
|
+
size={[width, height]}
|
|
78
|
+
tile={tile}
|
|
79
|
+
round
|
|
80
|
+
>
|
|
81
|
+
{treemap => {
|
|
82
|
+
// @visx/hierarchy Treemap passes the root node of the layout as the argument
|
|
83
|
+
const nodes = treemap.descendants();
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Group>
|
|
87
|
+
{nodes.map((node, i) => {
|
|
88
|
+
const width = node.x1 - node.x0;
|
|
89
|
+
const height = node.y1 - node.y0;
|
|
90
|
+
// Skip root if we want, or render it as background. usually we skip root rect or render it transparent
|
|
91
|
+
if (node.depth === 0) return null;
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<Group key={`node-${i}`} top={node.y0} left={node.x0}>
|
|
95
|
+
<rect
|
|
96
|
+
width={width}
|
|
97
|
+
height={height}
|
|
98
|
+
className={cn("stroke-background stroke-[2px] transition-all hover:opacity-80",
|
|
99
|
+
node.depth === 1 ? "fill-primary" : "fill-accent"
|
|
100
|
+
)}
|
|
101
|
+
/>
|
|
102
|
+
{width > 30 && height > 20 && (
|
|
103
|
+
<text
|
|
104
|
+
x={width / 2}
|
|
105
|
+
y={height / 2}
|
|
106
|
+
dy=".33em"
|
|
107
|
+
fontSize={10}
|
|
108
|
+
textAnchor="middle"
|
|
109
|
+
fill="white"
|
|
110
|
+
className="pointer-events-none font-medium truncate"
|
|
111
|
+
>
|
|
112
|
+
{node.data.name}
|
|
113
|
+
</text>
|
|
114
|
+
)}
|
|
115
|
+
</Group>
|
|
116
|
+
)
|
|
117
|
+
})}
|
|
118
|
+
</Group>
|
|
119
|
+
)
|
|
120
|
+
}}
|
|
121
|
+
</Treemap>
|
|
122
|
+
</svg>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const TreemapChart = (props: TreemapChartProps) => {
|
|
128
|
+
return (
|
|
129
|
+
<div className="w-full h-[300px]">
|
|
130
|
+
<ParentSize>
|
|
131
|
+
{({ width, height }) => <TreemapChartContent {...props} width={width} height={height} />}
|
|
132
|
+
</ParentSize>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|