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
package/bin/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
import { add } from '../src/commands/add.js';
|
|
9
|
+
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
const packageJson = JSON.parse(
|
|
13
|
+
fs.readFileSync(
|
|
14
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), '../package.json'),
|
|
15
|
+
'utf-8'
|
|
16
|
+
)
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.name('waffle-charts')
|
|
21
|
+
.description('CLI to easily add WaffleCharts components to your React project')
|
|
22
|
+
.version(packageJson.version);
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.command('add')
|
|
26
|
+
.description('Add a chart component to your project')
|
|
27
|
+
.argument('[component]', 'The component to add (e.g. bar-chart)')
|
|
28
|
+
.option('-p, --path <path>', 'Path to add the component to', 'src/components/ui/waffle')
|
|
29
|
+
.action(add);
|
|
30
|
+
|
|
31
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "waffle-charts-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI to add WaffleCharts components to your project",
|
|
5
|
+
"main": "bin/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"waffle-charts": "./bin/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"charts",
|
|
15
|
+
"cli",
|
|
16
|
+
"visx"
|
|
17
|
+
],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/mbuchthal/waffle-charts.git"
|
|
21
|
+
},
|
|
22
|
+
"author": "WaffleCharts Team",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"files": [
|
|
25
|
+
"bin",
|
|
26
|
+
"src",
|
|
27
|
+
"templates"
|
|
28
|
+
],
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"chalk": "^5.3.0",
|
|
34
|
+
"commander": "^12.1.0",
|
|
35
|
+
"fs-extra": "^11.2.0",
|
|
36
|
+
"ora": "^8.0.1",
|
|
37
|
+
"prompts": "^2.4.2"
|
|
38
|
+
},
|
|
39
|
+
"type": "module"
|
|
40
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import prompts from 'prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import { registry } from '../registry.js';
|
|
8
|
+
import { execSync } from 'child_process';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
export async function add(component, options) {
|
|
14
|
+
let selectedComponents = component ? [component] : [];
|
|
15
|
+
|
|
16
|
+
// 1. If no component provided, ask user
|
|
17
|
+
if (!component) {
|
|
18
|
+
const response = await prompts({
|
|
19
|
+
type: 'multiselect',
|
|
20
|
+
name: 'components',
|
|
21
|
+
message: 'Which charts would you like to add?',
|
|
22
|
+
choices: Object.entries(registry).map(([key, value]) => ({
|
|
23
|
+
title: value.label,
|
|
24
|
+
value: key
|
|
25
|
+
})),
|
|
26
|
+
min: 1
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!response.components || response.components.length === 0) {
|
|
30
|
+
console.log(chalk.yellow('No components selected. Exiting.'));
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
selectedComponents = response.components;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. Validate selections
|
|
37
|
+
for (const comp of selectedComponents) {
|
|
38
|
+
if (!registry[comp]) {
|
|
39
|
+
console.error(chalk.red(`Error: Component "${comp}" not found in registry.`));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const targetDir = path.resolve(process.cwd(), options.path);
|
|
45
|
+
|
|
46
|
+
// 3. Ensure target directory exists
|
|
47
|
+
if (!fs.existsSync(targetDir)) {
|
|
48
|
+
const spinner = ora(`Creating directory ${options.path}...`).start();
|
|
49
|
+
fs.mkdirpSync(targetDir);
|
|
50
|
+
spinner.succeed(`Created directory ${options.path}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 4. Install Dependencies
|
|
54
|
+
const dependencies = new Set();
|
|
55
|
+
selectedComponents.forEach(comp => {
|
|
56
|
+
registry[comp].dependencies.forEach(dep => dependencies.add(dep));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const dependencySpinner = ora('Installing dependencies...').start();
|
|
60
|
+
try {
|
|
61
|
+
// Detect package manager (simple check)
|
|
62
|
+
const packageManager = fs.existsSync('yarn.lock') ? 'yarn add' : 'npm install';
|
|
63
|
+
const installCmd = `${packageManager} ${Array.from(dependencies).join(' ')}`;
|
|
64
|
+
execSync(installCmd, { stdio: 'ignore' });
|
|
65
|
+
dependencySpinner.succeed('Dependencies installed');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
dependencySpinner.fail('Failed to install dependencies');
|
|
68
|
+
console.error(error);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 5. Copy Files
|
|
72
|
+
for (const comp of selectedComponents) {
|
|
73
|
+
const spinner = ora(`Adding ${registry[comp].label}...`).start();
|
|
74
|
+
const templatePath = path.join(__dirname, '../../templates', registry[comp].file);
|
|
75
|
+
const destPath = path.join(targetDir, registry[comp].file);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
if (fs.existsSync(templatePath)) {
|
|
79
|
+
fs.copyFileSync(templatePath, destPath);
|
|
80
|
+
spinner.succeed(`Added ${registry[comp].label} to ${options.path}/${registry[comp].file}`);
|
|
81
|
+
} else {
|
|
82
|
+
spinner.fail(`Template for ${comp} not found at ${templatePath}`);
|
|
83
|
+
}
|
|
84
|
+
} catch (error) {
|
|
85
|
+
spinner.fail(`Failed to copy ${comp}`);
|
|
86
|
+
console.error(error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(chalk.green('\nDone! Happy charting with WaffleCharts. 🧇'));
|
|
91
|
+
}
|
package/src/registry.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const registry = {
|
|
2
|
+
"bar-chart": {
|
|
3
|
+
file: "BarChart.tsx",
|
|
4
|
+
label: "Bar Chart",
|
|
5
|
+
dependencies: ["@visx/shape", "@visx/scale", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/axis", "@visx/grid", "clsx", "tailwind-merge"],
|
|
6
|
+
},
|
|
7
|
+
"line-chart": {
|
|
8
|
+
file: "LineChart.tsx",
|
|
9
|
+
label: "Line Chart",
|
|
10
|
+
dependencies: ["@visx/shape", "@visx/curve", "@visx/scale", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/axis", "@visx/grid", "@visx/glyph", "clsx", "tailwind-merge"],
|
|
11
|
+
},
|
|
12
|
+
"area-chart": {
|
|
13
|
+
file: "AreaChart.tsx",
|
|
14
|
+
label: "Area Chart",
|
|
15
|
+
dependencies: ["@visx/shape", "@visx/curve", "@visx/scale", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/axis", "@visx/grid", "clsx", "tailwind-merge"],
|
|
16
|
+
},
|
|
17
|
+
"pie-chart": {
|
|
18
|
+
file: "PieChart.tsx",
|
|
19
|
+
label: "Pie Chart",
|
|
20
|
+
dependencies: ["@visx/shape", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/text", "clsx", "tailwind-merge"],
|
|
21
|
+
},
|
|
22
|
+
"radar-chart": {
|
|
23
|
+
file: "RadarChart.tsx",
|
|
24
|
+
label: "Radar Chart",
|
|
25
|
+
dependencies: ["@visx/shape", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/scale", "@visx/point", "@visx/grid", "@visx/curve", "clsx", "tailwind-merge"],
|
|
26
|
+
},
|
|
27
|
+
"bubble-chart": {
|
|
28
|
+
file: "BubbleChart.tsx",
|
|
29
|
+
label: "Bubble Chart",
|
|
30
|
+
dependencies: ["@visx/shape", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/scale", "@visx/axis", "@visx/grid", "clsx", "tailwind-merge"],
|
|
31
|
+
},
|
|
32
|
+
"heatmap-chart": {
|
|
33
|
+
file: "HeatmapChart.tsx",
|
|
34
|
+
label: "Heatmap Chart",
|
|
35
|
+
dependencies: ["@visx/heatmap", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/scale", "@visx/axis", "clsx", "tailwind-merge"],
|
|
36
|
+
},
|
|
37
|
+
"treemap-chart": {
|
|
38
|
+
file: "TreemapChart.tsx",
|
|
39
|
+
label: "Treemap Chart",
|
|
40
|
+
dependencies: ["@visx/hierarchy", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/scale", "clsx", "tailwind-merge"],
|
|
41
|
+
},
|
|
42
|
+
"scatter-chart": {
|
|
43
|
+
file: "ScatterChart.tsx",
|
|
44
|
+
label: "Scatter Chart",
|
|
45
|
+
dependencies: ["@visx/shape", "@visx/group", "@visx/responsive", "@visx/tooltip", "@visx/scale", "@visx/axis", "@visx/grid", "@visx/glyph", "clsx", "tailwind-merge"],
|
|
46
|
+
}
|
|
47
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { AreaStack } from '@visx/shape';
|
|
3
|
+
import { Group } from '@visx/group';
|
|
4
|
+
import { scaleTime, scaleLinear } from '@visx/scale';
|
|
5
|
+
import { AxisBottom, AxisLeft } from '@visx/axis';
|
|
6
|
+
import { GridRows, GridColumns } from '@visx/grid';
|
|
7
|
+
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
|
|
8
|
+
import { localPoint } from '@visx/event';
|
|
9
|
+
import { ParentSize } from '@visx/responsive';
|
|
10
|
+
import { cn } from '../../lib/utils';
|
|
11
|
+
import { bisector } from 'd3-array';
|
|
12
|
+
|
|
13
|
+
export type AreaChartProps<T> = {
|
|
14
|
+
data: T[];
|
|
15
|
+
xKey: keyof T;
|
|
16
|
+
keys: (keyof T)[]; // Keys to stack
|
|
17
|
+
colors?: string[]; // CSS text-color classes
|
|
18
|
+
className?: string;
|
|
19
|
+
width?: number;
|
|
20
|
+
height?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type AreaChartContentProps<T> = AreaChartProps<T> & {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function AreaChartContent<T>({
|
|
29
|
+
data,
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
xKey,
|
|
33
|
+
keys,
|
|
34
|
+
colors = ['text-blue-500', 'text-indigo-500', 'text-purple-500'],
|
|
35
|
+
className,
|
|
36
|
+
}: AreaChartContentProps<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) => new Date(d[xKey] as string | number | Date);
|
|
44
|
+
const getY0 = (d: unknown) => (d as { [key: string]: number })[0];
|
|
45
|
+
const getY1 = (d: unknown) => (d as { [key: string]: number })[1];
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
// Scales
|
|
49
|
+
const xScale = useMemo(
|
|
50
|
+
() =>
|
|
51
|
+
scaleTime({
|
|
52
|
+
range: [0, xMax],
|
|
53
|
+
domain: [Math.min(...data.map(d => getX(d).getTime())), Math.max(...data.map(d => getX(d).getTime()))],
|
|
54
|
+
}),
|
|
55
|
+
[xMax, data, xKey],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const yScale = useMemo(
|
|
59
|
+
() =>
|
|
60
|
+
scaleLinear<number>({
|
|
61
|
+
range: [yMax, 0],
|
|
62
|
+
round: true,
|
|
63
|
+
// Calculate max value from stacked data could be complex, assuming simplistic sum or finding max from data
|
|
64
|
+
// For accurate stacking, we should ideally calculate the max stack value.
|
|
65
|
+
// For this demo, let's just make sure we cover the data range generously.
|
|
66
|
+
domain: [0, Math.max(...data.map(d => keys.reduce((acc, k) => acc + Number(d[k]), 0))) * 1.1],
|
|
67
|
+
nice: true,
|
|
68
|
+
}),
|
|
69
|
+
[yMax, data, keys],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Tooltip - Simplified for AreaStack (just showing nearest X for now)
|
|
73
|
+
const {
|
|
74
|
+
tooltipOpen,
|
|
75
|
+
tooltipLeft,
|
|
76
|
+
tooltipTop,
|
|
77
|
+
tooltipData,
|
|
78
|
+
hideTooltip,
|
|
79
|
+
showTooltip,
|
|
80
|
+
} = useTooltip<T>();
|
|
81
|
+
|
|
82
|
+
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
|
83
|
+
scroll: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const bisectDate = bisector<T, Date>(d => getX(d)).left;
|
|
87
|
+
|
|
88
|
+
const handleTooltip = (event: React.MouseEvent<SVGRectElement> | React.TouchEvent<SVGRectElement>) => {
|
|
89
|
+
const { x } = localPoint(event) || { x: 0 };
|
|
90
|
+
const x0 = xScale.invert(x - margin.left);
|
|
91
|
+
const index = bisectDate(data, x0, 1);
|
|
92
|
+
const d0 = data[index - 1];
|
|
93
|
+
const d1 = data[index];
|
|
94
|
+
let d = d0;
|
|
95
|
+
if (d1 && getX(d1)) {
|
|
96
|
+
d = x0.valueOf() - getX(d0).valueOf() > getX(d1).valueOf() - x0.valueOf() ? d1 : d0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (d) {
|
|
100
|
+
showTooltip({
|
|
101
|
+
tooltipData: d,
|
|
102
|
+
tooltipLeft: xScale(getX(d)) + margin.left,
|
|
103
|
+
tooltipTop: yScale(0) + margin.top, // Snap to bottom or follow mouse
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (width < 10) return null;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn("relative", className)}>
|
|
112
|
+
<svg ref={containerRef} width={width} height={height} className="overflow-visible">
|
|
113
|
+
<Group left={margin.left} top={margin.top}>
|
|
114
|
+
<GridRows scale={yScale} width={xMax} height={yMax} strokeDasharray="3,3" stroke="hsl(var(--border))" />
|
|
115
|
+
<GridColumns scale={xScale} width={xMax} height={yMax} strokeDasharray="3,3" stroke="hsl(var(--border))" />
|
|
116
|
+
|
|
117
|
+
<AxisBottom
|
|
118
|
+
top={yMax}
|
|
119
|
+
scale={xScale}
|
|
120
|
+
stroke="hsl(var(--border))"
|
|
121
|
+
tickStroke="hsl(var(--border))"
|
|
122
|
+
tickLabelProps={{
|
|
123
|
+
fill: "hsl(var(--muted-foreground))",
|
|
124
|
+
fontSize: 11,
|
|
125
|
+
textAnchor: "middle",
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
<AxisLeft
|
|
129
|
+
scale={yScale}
|
|
130
|
+
stroke="transparent"
|
|
131
|
+
tickStroke="hsl(var(--border))"
|
|
132
|
+
tickLabelProps={{
|
|
133
|
+
fill: "hsl(var(--muted-foreground))",
|
|
134
|
+
fontSize: 11,
|
|
135
|
+
textAnchor: "end",
|
|
136
|
+
dx: -4,
|
|
137
|
+
dy: 4,
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
|
|
141
|
+
<AreaStack
|
|
142
|
+
data={data}
|
|
143
|
+
keys={keys as string[]}
|
|
144
|
+
x={d => xScale(getX(d.data)) ?? 0}
|
|
145
|
+
y0={d => yScale(getY0(d)) ?? 0}
|
|
146
|
+
y1={d => yScale(getY1(d)) ?? 0}
|
|
147
|
+
>
|
|
148
|
+
{({ stacks, path }) =>
|
|
149
|
+
stacks.map((stack, i) => (
|
|
150
|
+
<path
|
|
151
|
+
key={`stack-${stack.key}`}
|
|
152
|
+
d={path(stack) || ''}
|
|
153
|
+
stroke="transparent"
|
|
154
|
+
// Use currentColor to inherit color from text-class
|
|
155
|
+
className={cn("fill-current opacity-80 hover:opacity-100 transition-opacity", colors[i % colors.length])}
|
|
156
|
+
/>
|
|
157
|
+
))
|
|
158
|
+
}
|
|
159
|
+
</AreaStack>
|
|
160
|
+
|
|
161
|
+
{/* Invisible Overlay for Tooltip */}
|
|
162
|
+
<rect
|
|
163
|
+
x={0}
|
|
164
|
+
y={0}
|
|
165
|
+
width={xMax}
|
|
166
|
+
height={yMax}
|
|
167
|
+
fill="transparent"
|
|
168
|
+
onTouchStart={handleTooltip}
|
|
169
|
+
onTouchMove={handleTooltip}
|
|
170
|
+
onMouseMove={handleTooltip}
|
|
171
|
+
onMouseLeave={() => hideTooltip()}
|
|
172
|
+
/>
|
|
173
|
+
</Group>
|
|
174
|
+
</svg>
|
|
175
|
+
{tooltipOpen && tooltipData && (
|
|
176
|
+
<TooltipInPortal
|
|
177
|
+
top={tooltipTop}
|
|
178
|
+
left={tooltipLeft}
|
|
179
|
+
style={{ ...defaultStyles, padding: 0, borderRadius: 0, boxShadow: 'none', background: 'transparent' }}
|
|
180
|
+
>
|
|
181
|
+
<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">
|
|
182
|
+
<p className="font-semibold text-xs text-muted-foreground mb-1">{getX(tooltipData).toLocaleDateString()}</p>
|
|
183
|
+
{keys.map((key, i) => (
|
|
184
|
+
<div key={key as string} className="flex items-center gap-2">
|
|
185
|
+
<div className={cn("w-2 h-2 rounded-full bg-current", colors[i % colors.length])} />
|
|
186
|
+
<span className="text-xs capitalize">{key as string}:</span>
|
|
187
|
+
<span className="font-mono text-xs font-bold">{String(tooltipData[key])}</span>
|
|
188
|
+
</div>
|
|
189
|
+
))}
|
|
190
|
+
</div>
|
|
191
|
+
</TooltipInPortal>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const AreaChart = <T,>(props: AreaChartProps<T>) => {
|
|
198
|
+
return (
|
|
199
|
+
<div className="w-full h-[300px]">
|
|
200
|
+
<ParentSize>
|
|
201
|
+
{({ width, height }) => <AreaChartContent {...props} width={width} height={height} />}
|
|
202
|
+
</ParentSize>
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { Bar } from '@visx/shape';
|
|
3
|
+
import { Group } from '@visx/group';
|
|
4
|
+
import { scaleBand, scaleLinear } from '@visx/scale';
|
|
5
|
+
import { AxisBottom, AxisLeft } from '@visx/axis';
|
|
6
|
+
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
|
|
7
|
+
import { localPoint } from '@visx/event';
|
|
8
|
+
import { ParentSize } from '@visx/responsive';
|
|
9
|
+
import { cn } from '../../lib/utils';
|
|
10
|
+
|
|
11
|
+
// Types
|
|
12
|
+
export type BarChartProps<T> = {
|
|
13
|
+
data: T[];
|
|
14
|
+
xKey: keyof T;
|
|
15
|
+
yKey: keyof T;
|
|
16
|
+
className?: string;
|
|
17
|
+
barColor?: string;
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Internal component with required dimensions
|
|
23
|
+
type BarChartContentProps<T> = BarChartProps<T> & {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function BarChartContent<T>({
|
|
29
|
+
data,
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
xKey,
|
|
33
|
+
yKey,
|
|
34
|
+
className,
|
|
35
|
+
barColor = "bg-primary"
|
|
36
|
+
}: BarChartContentProps<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) => d[xKey] as string;
|
|
44
|
+
const getY = (d: T) => Number(d[yKey]);
|
|
45
|
+
|
|
46
|
+
// Scales
|
|
47
|
+
const xScale = useMemo(
|
|
48
|
+
() =>
|
|
49
|
+
scaleBand<string>({
|
|
50
|
+
range: [0, xMax],
|
|
51
|
+
round: true,
|
|
52
|
+
domain: data.map(getX),
|
|
53
|
+
padding: 0.4,
|
|
54
|
+
}),
|
|
55
|
+
[xMax, data, xKey],
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const yScale = useMemo(
|
|
59
|
+
() =>
|
|
60
|
+
scaleLinear<number>({
|
|
61
|
+
range: [yMax, 0],
|
|
62
|
+
round: true,
|
|
63
|
+
domain: [0, Math.max(...data.map(getY))],
|
|
64
|
+
}),
|
|
65
|
+
[yMax, data, yKey],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Tooltip
|
|
69
|
+
const {
|
|
70
|
+
tooltipOpen,
|
|
71
|
+
tooltipLeft,
|
|
72
|
+
tooltipTop,
|
|
73
|
+
tooltipData,
|
|
74
|
+
hideTooltip,
|
|
75
|
+
showTooltip,
|
|
76
|
+
} = useTooltip<T>();
|
|
77
|
+
|
|
78
|
+
const { containerRef, TooltipInPortal } = useTooltipInPortal({
|
|
79
|
+
scroll: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (width < 10) return null;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={cn("relative", className)}>
|
|
86
|
+
<svg ref={containerRef} width={width} height={height} className="overflow-visible">
|
|
87
|
+
<Group left={margin.left} top={margin.top}>
|
|
88
|
+
<AxisBottom
|
|
89
|
+
top={yMax}
|
|
90
|
+
scale={xScale}
|
|
91
|
+
stroke="hsl(var(--border))"
|
|
92
|
+
tickStroke="hsl(var(--border))"
|
|
93
|
+
tickLabelProps={{
|
|
94
|
+
fill: "hsl(var(--muted-foreground))",
|
|
95
|
+
fontSize: 11,
|
|
96
|
+
textAnchor: "middle",
|
|
97
|
+
}}
|
|
98
|
+
/>
|
|
99
|
+
<AxisLeft
|
|
100
|
+
scale={yScale}
|
|
101
|
+
stroke="transparent"
|
|
102
|
+
tickStroke="hsl(var(--border))"
|
|
103
|
+
tickLabelProps={{
|
|
104
|
+
fill: "hsl(var(--muted-foreground))",
|
|
105
|
+
fontSize: 11,
|
|
106
|
+
textAnchor: "end",
|
|
107
|
+
dx: -4,
|
|
108
|
+
dy: 4,
|
|
109
|
+
}}
|
|
110
|
+
numTicks={5}
|
|
111
|
+
/>
|
|
112
|
+
{data.map((d) => {
|
|
113
|
+
const letter = getX(d);
|
|
114
|
+
const barWidth = xScale.bandwidth();
|
|
115
|
+
const barHeight = yMax - (yScale(getY(d)) ?? 0);
|
|
116
|
+
const barX = xScale(letter);
|
|
117
|
+
const barY = yMax - barHeight;
|
|
118
|
+
return (
|
|
119
|
+
<Bar
|
|
120
|
+
key={`bar-${letter}`}
|
|
121
|
+
x={barX}
|
|
122
|
+
y={barY}
|
|
123
|
+
width={barWidth}
|
|
124
|
+
height={barHeight}
|
|
125
|
+
className={cn("fill-primary transition-all duration-300 hover:opacity-80 cursor-pointer", barColor)}
|
|
126
|
+
onMouseLeave={() => hideTooltip()}
|
|
127
|
+
onMouseMove={(event) => {
|
|
128
|
+
const eventSvgCoords = localPoint(event);
|
|
129
|
+
const left = barX! + barWidth / 2;
|
|
130
|
+
showTooltip({
|
|
131
|
+
tooltipData: d,
|
|
132
|
+
tooltipTop: eventSvgCoords?.y,
|
|
133
|
+
tooltipLeft: left,
|
|
134
|
+
});
|
|
135
|
+
}}
|
|
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
|
+
<p className="font-semibold">{String(getY(tooltipData))}</p>
|
|
149
|
+
<p className="text-xs text-muted-foreground">{getX(tooltipData)}</p>
|
|
150
|
+
</div>
|
|
151
|
+
</TooltipInPortal>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const BarChart = <T,>(props: BarChartProps<T>) => {
|
|
158
|
+
return (
|
|
159
|
+
<div className="w-full h-[300px]">
|
|
160
|
+
<ParentSize>
|
|
161
|
+
{({ width, height }) => <BarChartContent {...props} width={width} height={height} />}
|
|
162
|
+
</ParentSize>
|
|
163
|
+
</div>
|
|
164
|
+
)
|
|
165
|
+
}
|