@apptimate/ui 1.2.0 → 1.4.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/package.json +1 -1
- package/src/common-components/charts/RAreaChart.tsx +139 -0
- package/src/common-components/charts/RBarChart.tsx +45 -19
- package/src/common-components/charts/RLineChart.tsx +57 -17
- package/src/common-components/charts/RPieChart.tsx +215 -0
- package/src/common-components/charts/RStatCard.tsx +138 -0
- package/src/common-components/charts/index.ts +4 -0
- package/src/common-components/charts/palette.ts +74 -0
package/package.json
CHANGED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React from "react";
|
|
4
|
+
import {
|
|
5
|
+
AreaChart,
|
|
6
|
+
Area,
|
|
7
|
+
XAxis,
|
|
8
|
+
YAxis,
|
|
9
|
+
CartesianGrid,
|
|
10
|
+
Tooltip,
|
|
11
|
+
Legend,
|
|
12
|
+
ResponsiveContainer,
|
|
13
|
+
} from "recharts";
|
|
14
|
+
import {
|
|
15
|
+
CHART_COLORS,
|
|
16
|
+
CHART_GRADIENTS,
|
|
17
|
+
TOOLTIP_STYLE,
|
|
18
|
+
TOOLTIP_ITEM_STYLE,
|
|
19
|
+
AXIS_TICK_STYLE,
|
|
20
|
+
GRID_STROKE,
|
|
21
|
+
ANIMATION_DURATION,
|
|
22
|
+
ANIMATION_EASING,
|
|
23
|
+
} from "./palette";
|
|
24
|
+
|
|
25
|
+
export interface RAreaChartSeries {
|
|
26
|
+
dataKey: string;
|
|
27
|
+
name?: string;
|
|
28
|
+
color?: string;
|
|
29
|
+
strokeWidth?: number;
|
|
30
|
+
type?: "monotone" | "linear" | "step" | "basis";
|
|
31
|
+
stackId?: string;
|
|
32
|
+
fillOpacity?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RAreaChartProps {
|
|
36
|
+
data: Record<string, any>[];
|
|
37
|
+
series: RAreaChartSeries[];
|
|
38
|
+
xAxisKey: string;
|
|
39
|
+
height?: number;
|
|
40
|
+
colors?: string[];
|
|
41
|
+
showGrid?: boolean;
|
|
42
|
+
showTooltip?: boolean;
|
|
43
|
+
showLegend?: boolean;
|
|
44
|
+
legendPosition?: "top" | "bottom";
|
|
45
|
+
xAxisFormatter?: (value: any) => string;
|
|
46
|
+
yAxisFormatter?: (value: any) => string;
|
|
47
|
+
tooltipFormatter?: (value: any, name: string) => [string, string];
|
|
48
|
+
className?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function RAreaChart({
|
|
52
|
+
data,
|
|
53
|
+
series,
|
|
54
|
+
xAxisKey,
|
|
55
|
+
height = 350,
|
|
56
|
+
colors = [...CHART_COLORS],
|
|
57
|
+
showGrid = true,
|
|
58
|
+
showTooltip = true,
|
|
59
|
+
showLegend = true,
|
|
60
|
+
legendPosition = "bottom",
|
|
61
|
+
xAxisFormatter,
|
|
62
|
+
yAxisFormatter,
|
|
63
|
+
tooltipFormatter,
|
|
64
|
+
className = "",
|
|
65
|
+
}: RAreaChartProps) {
|
|
66
|
+
const gradientId = React.useId();
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className={`w-full ${className}`} style={{ height }}>
|
|
70
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
71
|
+
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 10 }}>
|
|
72
|
+
<defs>
|
|
73
|
+
{series.map((s, idx) => {
|
|
74
|
+
const gradientPair = CHART_GRADIENTS[idx % CHART_GRADIENTS.length];
|
|
75
|
+
const id = `area-gradient-${gradientId}-${idx}`;
|
|
76
|
+
return (
|
|
77
|
+
<linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
|
|
78
|
+
<stop offset="0%" stopColor={s.color || gradientPair[0]} stopOpacity={0.4} />
|
|
79
|
+
<stop offset="50%" stopColor={s.color || gradientPair[1]} stopOpacity={0.1} />
|
|
80
|
+
<stop offset="100%" stopColor={s.color || gradientPair[1]} stopOpacity={0} />
|
|
81
|
+
</linearGradient>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</defs>
|
|
85
|
+
|
|
86
|
+
{showGrid && (
|
|
87
|
+
<CartesianGrid stroke={GRID_STROKE} strokeDasharray="0" />
|
|
88
|
+
)}
|
|
89
|
+
<XAxis
|
|
90
|
+
dataKey={xAxisKey}
|
|
91
|
+
tickFormatter={xAxisFormatter}
|
|
92
|
+
tick={AXIS_TICK_STYLE}
|
|
93
|
+
tickLine={false}
|
|
94
|
+
axisLine={false}
|
|
95
|
+
/>
|
|
96
|
+
<YAxis
|
|
97
|
+
tickFormatter={yAxisFormatter}
|
|
98
|
+
tick={AXIS_TICK_STYLE}
|
|
99
|
+
tickLine={false}
|
|
100
|
+
axisLine={false}
|
|
101
|
+
/>
|
|
102
|
+
{showTooltip && (
|
|
103
|
+
<Tooltip
|
|
104
|
+
formatter={tooltipFormatter}
|
|
105
|
+
contentStyle={TOOLTIP_STYLE}
|
|
106
|
+
itemStyle={TOOLTIP_ITEM_STYLE}
|
|
107
|
+
animationDuration={300}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
{showLegend && (
|
|
111
|
+
<Legend
|
|
112
|
+
verticalAlign={legendPosition}
|
|
113
|
+
wrapperStyle={{ fontSize: "12px", fontWeight: 700, paddingTop: "8px" }}
|
|
114
|
+
iconType="circle"
|
|
115
|
+
iconSize={8}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
118
|
+
{series.map((s, idx) => (
|
|
119
|
+
<Area
|
|
120
|
+
key={s.dataKey}
|
|
121
|
+
type={s.type || "monotone"}
|
|
122
|
+
dataKey={s.dataKey}
|
|
123
|
+
name={s.name || s.dataKey}
|
|
124
|
+
stroke={s.color || colors[idx % colors.length]}
|
|
125
|
+
strokeWidth={s.strokeWidth || 2.5}
|
|
126
|
+
fill={`url(#area-gradient-${gradientId}-${idx})`}
|
|
127
|
+
fillOpacity={s.fillOpacity ?? 1}
|
|
128
|
+
stackId={s.stackId}
|
|
129
|
+
activeDot={{ r: 5, fill: "#fff", stroke: s.color || colors[idx % colors.length], strokeWidth: 2.5 }}
|
|
130
|
+
isAnimationActive={true}
|
|
131
|
+
animationDuration={ANIMATION_DURATION}
|
|
132
|
+
animationEasing={ANIMATION_EASING}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
</AreaChart>
|
|
136
|
+
</ResponsiveContainer>
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -11,7 +11,16 @@ import {
|
|
|
11
11
|
Legend,
|
|
12
12
|
ResponsiveContainer,
|
|
13
13
|
} from "recharts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CHART_COLORS,
|
|
16
|
+
CHART_GRADIENTS,
|
|
17
|
+
TOOLTIP_STYLE,
|
|
18
|
+
TOOLTIP_ITEM_STYLE,
|
|
19
|
+
AXIS_TICK_STYLE,
|
|
20
|
+
GRID_STROKE,
|
|
21
|
+
ANIMATION_DURATION,
|
|
22
|
+
ANIMATION_EASING,
|
|
23
|
+
} from "./palette";
|
|
15
24
|
|
|
16
25
|
export interface RBarChartSeries {
|
|
17
26
|
dataKey: string;
|
|
@@ -36,6 +45,7 @@ export interface RBarChartProps {
|
|
|
36
45
|
xAxisFormatter?: (value: any) => string;
|
|
37
46
|
yAxisFormatter?: (value: any) => string;
|
|
38
47
|
tooltipFormatter?: (value: any, name: string) => [string, string];
|
|
48
|
+
enableGradient?: boolean;
|
|
39
49
|
className?: string;
|
|
40
50
|
}
|
|
41
51
|
|
|
@@ -54,27 +64,46 @@ export function RBarChart({
|
|
|
54
64
|
xAxisFormatter,
|
|
55
65
|
yAxisFormatter,
|
|
56
66
|
tooltipFormatter,
|
|
67
|
+
enableGradient = true,
|
|
57
68
|
className = "",
|
|
58
69
|
}: RBarChartProps) {
|
|
70
|
+
const gradientId = React.useId();
|
|
71
|
+
|
|
59
72
|
return (
|
|
60
73
|
<div className={`w-full ${className}`} style={{ height }}>
|
|
61
74
|
<ResponsiveContainer width="100%" height="100%">
|
|
62
75
|
<BarChart data={data} layout={layout} margin={{ top: 10, right: 10, left: 0, bottom: 10 }}>
|
|
76
|
+
{/* Gradient definitions */}
|
|
77
|
+
{enableGradient && (
|
|
78
|
+
<defs>
|
|
79
|
+
{series.map((s, idx) => {
|
|
80
|
+
const gradientPair = CHART_GRADIENTS[idx % CHART_GRADIENTS.length];
|
|
81
|
+
const id = `bar-gradient-${gradientId}-${idx}`;
|
|
82
|
+
return (
|
|
83
|
+
<linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
|
|
84
|
+
<stop offset="0%" stopColor={s.color || gradientPair[0]} stopOpacity={1} />
|
|
85
|
+
<stop offset="100%" stopColor={s.color || gradientPair[1]} stopOpacity={0.7} />
|
|
86
|
+
</linearGradient>
|
|
87
|
+
);
|
|
88
|
+
})}
|
|
89
|
+
</defs>
|
|
90
|
+
)}
|
|
91
|
+
|
|
63
92
|
{showGrid && (
|
|
64
|
-
<CartesianGrid stroke=
|
|
93
|
+
<CartesianGrid stroke={GRID_STROKE} strokeDasharray="0" />
|
|
65
94
|
)}
|
|
66
95
|
{layout === "horizontal" ? (
|
|
67
96
|
<>
|
|
68
97
|
<XAxis
|
|
69
98
|
dataKey={xAxisKey}
|
|
70
99
|
tickFormatter={xAxisFormatter}
|
|
71
|
-
tick={
|
|
100
|
+
tick={AXIS_TICK_STYLE}
|
|
72
101
|
tickLine={false}
|
|
73
102
|
axisLine={false}
|
|
74
103
|
/>
|
|
75
104
|
<YAxis
|
|
76
105
|
tickFormatter={yAxisFormatter}
|
|
77
|
-
tick={
|
|
106
|
+
tick={AXIS_TICK_STYLE}
|
|
78
107
|
tickLine={false}
|
|
79
108
|
axisLine={false}
|
|
80
109
|
/>
|
|
@@ -84,7 +113,7 @@ export function RBarChart({
|
|
|
84
113
|
<XAxis
|
|
85
114
|
type="number"
|
|
86
115
|
tickFormatter={xAxisFormatter}
|
|
87
|
-
tick={
|
|
116
|
+
tick={AXIS_TICK_STYLE}
|
|
88
117
|
tickLine={false}
|
|
89
118
|
axisLine={false}
|
|
90
119
|
/>
|
|
@@ -92,7 +121,7 @@ export function RBarChart({
|
|
|
92
121
|
type="category"
|
|
93
122
|
dataKey={xAxisKey}
|
|
94
123
|
tickFormatter={yAxisFormatter}
|
|
95
|
-
tick={
|
|
124
|
+
tick={AXIS_TICK_STYLE}
|
|
96
125
|
tickLine={false}
|
|
97
126
|
axisLine={false}
|
|
98
127
|
/>
|
|
@@ -101,26 +130,22 @@ export function RBarChart({
|
|
|
101
130
|
{showTooltip && (
|
|
102
131
|
<Tooltip
|
|
103
132
|
formatter={tooltipFormatter}
|
|
104
|
-
contentStyle={
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
border: "none",
|
|
109
|
-
}}
|
|
110
|
-
itemStyle={{ color: "#fff" }}
|
|
111
|
-
cursor={{ fill: "transparent" }}
|
|
112
|
-
animationDuration={1000}
|
|
133
|
+
contentStyle={TOOLTIP_STYLE}
|
|
134
|
+
itemStyle={TOOLTIP_ITEM_STYLE}
|
|
135
|
+
cursor={{ fill: "rgba(99,102,241,0.06)" }}
|
|
136
|
+
animationDuration={300}
|
|
113
137
|
/>
|
|
114
138
|
)}
|
|
115
139
|
{showLegend && (
|
|
116
140
|
<Legend
|
|
117
141
|
verticalAlign={legendPosition}
|
|
118
|
-
wrapperStyle={{ fontSize: "12px", fontWeight: 700 }}
|
|
142
|
+
wrapperStyle={{ fontSize: "12px", fontWeight: 700, paddingTop: "8px" }}
|
|
119
143
|
iconType="circle"
|
|
144
|
+
iconSize={8}
|
|
120
145
|
/>
|
|
121
146
|
)}
|
|
122
147
|
{series.map((s, idx) => {
|
|
123
|
-
const radius = s.radius !== undefined ? s.radius :
|
|
148
|
+
const radius = s.radius !== undefined ? s.radius : 6;
|
|
124
149
|
const radiusArray =
|
|
125
150
|
layout === "vertical" ? [0, radius, radius, 0] : [radius, radius, 0, 0];
|
|
126
151
|
|
|
@@ -129,12 +154,13 @@ export function RBarChart({
|
|
|
129
154
|
key={s.dataKey}
|
|
130
155
|
dataKey={s.dataKey}
|
|
131
156
|
name={s.name || s.dataKey}
|
|
132
|
-
fill={s.color || colors[idx % colors.length]}
|
|
157
|
+
fill={enableGradient ? `url(#bar-gradient-${gradientId}-${idx})` : (s.color || colors[idx % colors.length])}
|
|
133
158
|
stackId={s.stackId}
|
|
134
159
|
barSize={barSize}
|
|
135
160
|
radius={radiusArray as any}
|
|
136
161
|
isAnimationActive={true}
|
|
137
|
-
animationDuration={
|
|
162
|
+
animationDuration={ANIMATION_DURATION}
|
|
163
|
+
animationEasing={ANIMATION_EASING}
|
|
138
164
|
/>
|
|
139
165
|
);
|
|
140
166
|
})}
|
|
@@ -11,7 +11,16 @@ import {
|
|
|
11
11
|
Legend,
|
|
12
12
|
ResponsiveContainer,
|
|
13
13
|
} from "recharts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
CHART_COLORS,
|
|
16
|
+
CHART_GRADIENTS,
|
|
17
|
+
TOOLTIP_STYLE,
|
|
18
|
+
TOOLTIP_ITEM_STYLE,
|
|
19
|
+
AXIS_TICK_STYLE,
|
|
20
|
+
GRID_STROKE,
|
|
21
|
+
ANIMATION_DURATION,
|
|
22
|
+
ANIMATION_EASING,
|
|
23
|
+
} from "./palette";
|
|
15
24
|
|
|
16
25
|
export interface RLineChartSeries {
|
|
17
26
|
dataKey: string;
|
|
@@ -36,9 +45,24 @@ export interface RLineChartProps {
|
|
|
36
45
|
xAxisFormatter?: (value: any) => string;
|
|
37
46
|
yAxisFormatter?: (value: any) => string;
|
|
38
47
|
tooltipFormatter?: (value: any, name: string) => [string, string];
|
|
48
|
+
enableGradient?: boolean;
|
|
39
49
|
className?: string;
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
// Pulsing active dot component
|
|
53
|
+
function PulsingActiveDot(props: any) {
|
|
54
|
+
const { cx, cy, fill } = props;
|
|
55
|
+
return (
|
|
56
|
+
<g>
|
|
57
|
+
<circle cx={cx} cy={cy} r={8} fill={fill} opacity={0.15}>
|
|
58
|
+
<animate attributeName="r" values="8;14;8" dur="2s" repeatCount="indefinite" />
|
|
59
|
+
<animate attributeName="opacity" values="0.15;0.05;0.15" dur="2s" repeatCount="indefinite" />
|
|
60
|
+
</circle>
|
|
61
|
+
<circle cx={cx} cy={cy} r={5} fill="#fff" stroke={fill} strokeWidth={2.5} />
|
|
62
|
+
</g>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
export function RLineChart({
|
|
43
67
|
data,
|
|
44
68
|
series,
|
|
@@ -52,46 +76,61 @@ export function RLineChart({
|
|
|
52
76
|
xAxisFormatter,
|
|
53
77
|
yAxisFormatter,
|
|
54
78
|
tooltipFormatter,
|
|
79
|
+
enableGradient = true,
|
|
55
80
|
className = "",
|
|
56
81
|
}: RLineChartProps) {
|
|
82
|
+
const gradientId = React.useId();
|
|
83
|
+
|
|
57
84
|
return (
|
|
58
85
|
<div className={`w-full ${className}`} style={{ height }}>
|
|
59
86
|
<ResponsiveContainer width="100%" height="100%">
|
|
60
87
|
<LineChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 10 }}>
|
|
88
|
+
{/* Gradient definitions for area fill beneath lines */}
|
|
89
|
+
{enableGradient && (
|
|
90
|
+
<defs>
|
|
91
|
+
{series.map((s, idx) => {
|
|
92
|
+
const gradientPair = CHART_GRADIENTS[idx % CHART_GRADIENTS.length];
|
|
93
|
+
const id = `line-gradient-${gradientId}-${idx}`;
|
|
94
|
+
return (
|
|
95
|
+
<linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
|
|
96
|
+
<stop offset="0%" stopColor={s.color || gradientPair[0]} stopOpacity={0.3} />
|
|
97
|
+
<stop offset="100%" stopColor={s.color || gradientPair[1]} stopOpacity={0} />
|
|
98
|
+
</linearGradient>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</defs>
|
|
102
|
+
)}
|
|
103
|
+
|
|
61
104
|
{showGrid && (
|
|
62
|
-
<CartesianGrid stroke=
|
|
105
|
+
<CartesianGrid stroke={GRID_STROKE} strokeDasharray="0" />
|
|
63
106
|
)}
|
|
64
107
|
<XAxis
|
|
65
108
|
dataKey={xAxisKey}
|
|
66
109
|
tickFormatter={xAxisFormatter}
|
|
67
|
-
tick={
|
|
110
|
+
tick={AXIS_TICK_STYLE}
|
|
68
111
|
tickLine={false}
|
|
69
112
|
axisLine={false}
|
|
70
113
|
/>
|
|
71
114
|
<YAxis
|
|
72
115
|
tickFormatter={yAxisFormatter}
|
|
73
|
-
tick={
|
|
116
|
+
tick={AXIS_TICK_STYLE}
|
|
74
117
|
tickLine={false}
|
|
75
118
|
axisLine={false}
|
|
76
119
|
/>
|
|
77
120
|
{showTooltip && (
|
|
78
121
|
<Tooltip
|
|
79
122
|
formatter={tooltipFormatter}
|
|
80
|
-
contentStyle={
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
borderRadius: "8px",
|
|
84
|
-
border: "none",
|
|
85
|
-
}}
|
|
86
|
-
itemStyle={{ color: "#fff" }}
|
|
87
|
-
animationDuration={1000}
|
|
123
|
+
contentStyle={TOOLTIP_STYLE}
|
|
124
|
+
itemStyle={TOOLTIP_ITEM_STYLE}
|
|
125
|
+
animationDuration={300}
|
|
88
126
|
/>
|
|
89
127
|
)}
|
|
90
128
|
{showLegend && (
|
|
91
129
|
<Legend
|
|
92
130
|
verticalAlign={legendPosition}
|
|
93
|
-
wrapperStyle={{ fontSize: "12px", fontWeight: 700 }}
|
|
131
|
+
wrapperStyle={{ fontSize: "12px", fontWeight: 700, paddingTop: "8px" }}
|
|
94
132
|
iconType="circle"
|
|
133
|
+
iconSize={8}
|
|
95
134
|
/>
|
|
96
135
|
)}
|
|
97
136
|
{series.map((s, idx) => (
|
|
@@ -101,12 +140,13 @@ export function RLineChart({
|
|
|
101
140
|
dataKey={s.dataKey}
|
|
102
141
|
name={s.name || s.dataKey}
|
|
103
142
|
stroke={s.color || colors[idx % colors.length]}
|
|
104
|
-
strokeWidth={s.strokeWidth || 2}
|
|
143
|
+
strokeWidth={s.strokeWidth || 2.5}
|
|
105
144
|
strokeDasharray={s.dashed ? "5 5" : undefined}
|
|
106
|
-
dot={s.showDot ? { r:
|
|
107
|
-
activeDot={{
|
|
145
|
+
dot={s.showDot ? { r: 3, fill: "#fff", stroke: s.color || colors[idx % colors.length], strokeWidth: 2 } : false}
|
|
146
|
+
activeDot={<PulsingActiveDot fill={s.color || colors[idx % colors.length]} />}
|
|
108
147
|
isAnimationActive={true}
|
|
109
|
-
animationDuration={
|
|
148
|
+
animationDuration={ANIMATION_DURATION}
|
|
149
|
+
animationEasing={ANIMATION_EASING}
|
|
110
150
|
/>
|
|
111
151
|
))}
|
|
112
152
|
</LineChart>
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback } from "react";
|
|
4
|
+
import {
|
|
5
|
+
PieChart,
|
|
6
|
+
Pie,
|
|
7
|
+
Cell,
|
|
8
|
+
Tooltip,
|
|
9
|
+
Legend,
|
|
10
|
+
ResponsiveContainer,
|
|
11
|
+
Sector,
|
|
12
|
+
} from "recharts";
|
|
13
|
+
import {
|
|
14
|
+
CHART_COLORS,
|
|
15
|
+
CHART_GRADIENTS,
|
|
16
|
+
TOOLTIP_STYLE,
|
|
17
|
+
TOOLTIP_ITEM_STYLE,
|
|
18
|
+
ANIMATION_DURATION,
|
|
19
|
+
ANIMATION_EASING,
|
|
20
|
+
} from "./palette";
|
|
21
|
+
|
|
22
|
+
export interface RPieChartProps {
|
|
23
|
+
data: Record<string, any>[];
|
|
24
|
+
dataKey: string;
|
|
25
|
+
nameKey: string;
|
|
26
|
+
height?: number;
|
|
27
|
+
colors?: string[];
|
|
28
|
+
innerRadius?: number | string;
|
|
29
|
+
outerRadius?: number | string;
|
|
30
|
+
showTooltip?: boolean;
|
|
31
|
+
showLegend?: boolean;
|
|
32
|
+
legendPosition?: "top" | "bottom" | "right";
|
|
33
|
+
showLabels?: boolean;
|
|
34
|
+
labelType?: "percentage" | "value" | "name";
|
|
35
|
+
centerLabel?: string;
|
|
36
|
+
centerValue?: string | number;
|
|
37
|
+
enableGradient?: boolean;
|
|
38
|
+
tooltipFormatter?: (value: any, name: string) => [string, string];
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Custom active shape for hover-expand effect
|
|
43
|
+
function renderActiveShape(props: any) {
|
|
44
|
+
const {
|
|
45
|
+
cx, cy, innerRadius, outerRadius, startAngle, endAngle,
|
|
46
|
+
fill, payload, percent, value, nameKey, dataKey,
|
|
47
|
+
} = props;
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<g>
|
|
51
|
+
<Sector
|
|
52
|
+
cx={cx}
|
|
53
|
+
cy={cy}
|
|
54
|
+
innerRadius={innerRadius - 2}
|
|
55
|
+
outerRadius={outerRadius + 8}
|
|
56
|
+
startAngle={startAngle}
|
|
57
|
+
endAngle={endAngle}
|
|
58
|
+
fill={fill}
|
|
59
|
+
opacity={1}
|
|
60
|
+
style={{ filter: "drop-shadow(0 4px 12px rgba(0,0,0,0.15))", transition: "all 0.3s ease" }}
|
|
61
|
+
/>
|
|
62
|
+
<Sector
|
|
63
|
+
cx={cx}
|
|
64
|
+
cy={cy}
|
|
65
|
+
innerRadius={innerRadius}
|
|
66
|
+
outerRadius={outerRadius}
|
|
67
|
+
startAngle={startAngle}
|
|
68
|
+
endAngle={endAngle}
|
|
69
|
+
fill={fill}
|
|
70
|
+
/>
|
|
71
|
+
</g>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Percentage label renderer
|
|
76
|
+
function renderLabel(props: any) {
|
|
77
|
+
const { cx, cy, midAngle, innerRadius, outerRadius, percent, name, labelType, value } = props;
|
|
78
|
+
const RADIAN = Math.PI / 180;
|
|
79
|
+
const radius = outerRadius + 20;
|
|
80
|
+
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
|
81
|
+
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
|
82
|
+
|
|
83
|
+
if (percent < 0.04) return null; // Hide labels for tiny slices
|
|
84
|
+
|
|
85
|
+
let displayText = `${(percent * 100).toFixed(0)}%`;
|
|
86
|
+
if (labelType === "value") displayText = `${value}`;
|
|
87
|
+
if (labelType === "name") displayText = name;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<text
|
|
91
|
+
x={x}
|
|
92
|
+
y={y}
|
|
93
|
+
fill="#64748b"
|
|
94
|
+
textAnchor={x > cx ? "start" : "end"}
|
|
95
|
+
dominantBaseline="central"
|
|
96
|
+
fontSize={11}
|
|
97
|
+
fontWeight={600}
|
|
98
|
+
>
|
|
99
|
+
{displayText}
|
|
100
|
+
</text>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function RPieChart({
|
|
105
|
+
data,
|
|
106
|
+
dataKey,
|
|
107
|
+
nameKey,
|
|
108
|
+
height = 350,
|
|
109
|
+
colors = [...CHART_COLORS],
|
|
110
|
+
innerRadius = "55%",
|
|
111
|
+
outerRadius = "80%",
|
|
112
|
+
showTooltip = true,
|
|
113
|
+
showLegend = true,
|
|
114
|
+
legendPosition = "bottom",
|
|
115
|
+
showLabels = false,
|
|
116
|
+
labelType = "percentage",
|
|
117
|
+
centerLabel,
|
|
118
|
+
centerValue,
|
|
119
|
+
enableGradient = true,
|
|
120
|
+
tooltipFormatter,
|
|
121
|
+
className = "",
|
|
122
|
+
}: RPieChartProps) {
|
|
123
|
+
const [activeIndex, setActiveIndex] = useState<number | undefined>(undefined);
|
|
124
|
+
const gradientId = React.useId();
|
|
125
|
+
|
|
126
|
+
const onMouseEnter = useCallback((_: any, index: number) => {
|
|
127
|
+
setActiveIndex(index);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
130
|
+
const onMouseLeave = useCallback(() => {
|
|
131
|
+
setActiveIndex(undefined);
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className={`w-full relative ${className}`} style={{ height }}>
|
|
136
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
137
|
+
<PieChart>
|
|
138
|
+
{enableGradient && (
|
|
139
|
+
<defs>
|
|
140
|
+
{data.map((_, idx) => {
|
|
141
|
+
const gradientPair = CHART_GRADIENTS[idx % CHART_GRADIENTS.length];
|
|
142
|
+
const id = `pie-gradient-${gradientId}-${idx}`;
|
|
143
|
+
return (
|
|
144
|
+
<linearGradient key={id} id={id} x1="0" y1="0" x2="1" y2="1">
|
|
145
|
+
<stop offset="0%" stopColor={gradientPair[0]} stopOpacity={1} />
|
|
146
|
+
<stop offset="100%" stopColor={gradientPair[1]} stopOpacity={0.85} />
|
|
147
|
+
</linearGradient>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
</defs>
|
|
151
|
+
)}
|
|
152
|
+
<Pie
|
|
153
|
+
data={data}
|
|
154
|
+
dataKey={dataKey}
|
|
155
|
+
nameKey={nameKey}
|
|
156
|
+
cx="50%"
|
|
157
|
+
cy="50%"
|
|
158
|
+
innerRadius={innerRadius}
|
|
159
|
+
outerRadius={outerRadius}
|
|
160
|
+
paddingAngle={2}
|
|
161
|
+
activeIndex={activeIndex}
|
|
162
|
+
activeShape={renderActiveShape}
|
|
163
|
+
onMouseEnter={onMouseEnter}
|
|
164
|
+
onMouseLeave={onMouseLeave}
|
|
165
|
+
isAnimationActive={true}
|
|
166
|
+
animationDuration={ANIMATION_DURATION}
|
|
167
|
+
animationEasing={ANIMATION_EASING}
|
|
168
|
+
label={showLabels ? (props: any) => renderLabel({ ...props, labelType }) : undefined}
|
|
169
|
+
>
|
|
170
|
+
{data.map((_, idx) => (
|
|
171
|
+
<Cell
|
|
172
|
+
key={`cell-${idx}`}
|
|
173
|
+
fill={enableGradient ? `url(#pie-gradient-${gradientId}-${idx})` : colors[idx % colors.length]}
|
|
174
|
+
stroke="none"
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</Pie>
|
|
178
|
+
{showTooltip && (
|
|
179
|
+
<Tooltip
|
|
180
|
+
formatter={tooltipFormatter}
|
|
181
|
+
contentStyle={TOOLTIP_STYLE}
|
|
182
|
+
itemStyle={TOOLTIP_ITEM_STYLE}
|
|
183
|
+
animationDuration={300}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
{showLegend && (
|
|
187
|
+
<Legend
|
|
188
|
+
verticalAlign={legendPosition === "right" ? "middle" : legendPosition}
|
|
189
|
+
align={legendPosition === "right" ? "right" : "center"}
|
|
190
|
+
layout={legendPosition === "right" ? "vertical" : "horizontal"}
|
|
191
|
+
wrapperStyle={{ fontSize: "12px", fontWeight: 700, paddingTop: legendPosition === "bottom" ? "8px" : "0" }}
|
|
192
|
+
iconType="circle"
|
|
193
|
+
iconSize={8}
|
|
194
|
+
/>
|
|
195
|
+
)}
|
|
196
|
+
</PieChart>
|
|
197
|
+
</ResponsiveContainer>
|
|
198
|
+
|
|
199
|
+
{/* Center label for donut charts */}
|
|
200
|
+
{(centerLabel || centerValue) && (
|
|
201
|
+
<div
|
|
202
|
+
className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"
|
|
203
|
+
style={{ paddingBottom: showLegend && legendPosition === "bottom" ? "40px" : "0" }}
|
|
204
|
+
>
|
|
205
|
+
{centerValue && (
|
|
206
|
+
<span className="text-2xl font-bold text-[#2D3142] tracking-tight">{centerValue}</span>
|
|
207
|
+
)}
|
|
208
|
+
{centerLabel && (
|
|
209
|
+
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{centerLabel}</span>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, useRef } from "react";
|
|
4
|
+
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
5
|
+
import { SEMANTIC_COLORS } from "./palette";
|
|
6
|
+
|
|
7
|
+
export interface RStatCardProps {
|
|
8
|
+
label: string;
|
|
9
|
+
value: number | string;
|
|
10
|
+
prefix?: string;
|
|
11
|
+
suffix?: string;
|
|
12
|
+
decimals?: number;
|
|
13
|
+
trend?: "up" | "down" | "neutral";
|
|
14
|
+
trendValue?: string;
|
|
15
|
+
subtitle?: string;
|
|
16
|
+
icon?: React.ReactNode;
|
|
17
|
+
color?: string;
|
|
18
|
+
animate?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Animated count-up hook
|
|
23
|
+
function useCountUp(target: number, duration: number = 1000, enabled: boolean = true) {
|
|
24
|
+
const [current, setCurrent] = useState(0);
|
|
25
|
+
const frameRef = useRef<number | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!enabled || typeof target !== "number" || isNaN(target)) {
|
|
29
|
+
setCurrent(target);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const startTime = performance.now();
|
|
34
|
+
const startValue = 0;
|
|
35
|
+
|
|
36
|
+
function tick(now: number) {
|
|
37
|
+
const elapsed = now - startTime;
|
|
38
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
39
|
+
// Ease-out cubic
|
|
40
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
41
|
+
setCurrent(startValue + (target - startValue) * eased);
|
|
42
|
+
|
|
43
|
+
if (progress < 1) {
|
|
44
|
+
frameRef.current = requestAnimationFrame(tick);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
frameRef.current = requestAnimationFrame(tick);
|
|
49
|
+
return () => {
|
|
50
|
+
if (frameRef.current) cancelAnimationFrame(frameRef.current);
|
|
51
|
+
};
|
|
52
|
+
}, [target, duration, enabled]);
|
|
53
|
+
|
|
54
|
+
return current;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function TrendIndicator({ trend, trendValue }: { trend: "up" | "down" | "neutral"; trendValue?: string }) {
|
|
58
|
+
const config = {
|
|
59
|
+
up: {
|
|
60
|
+
Icon: TrendingUp,
|
|
61
|
+
color: SEMANTIC_COLORS.success,
|
|
62
|
+
bg: "rgba(34,197,94,0.1)",
|
|
63
|
+
},
|
|
64
|
+
down: {
|
|
65
|
+
Icon: TrendingDown,
|
|
66
|
+
color: SEMANTIC_COLORS.danger,
|
|
67
|
+
bg: "rgba(239,68,68,0.1)",
|
|
68
|
+
},
|
|
69
|
+
neutral: {
|
|
70
|
+
Icon: Minus,
|
|
71
|
+
color: SEMANTIC_COLORS.neutral,
|
|
72
|
+
bg: "rgba(100,116,139,0.1)",
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const { Icon, color, bg } = config[trend];
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div
|
|
80
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-bold"
|
|
81
|
+
style={{ backgroundColor: bg, color }}
|
|
82
|
+
>
|
|
83
|
+
<Icon size={12} strokeWidth={2.5} />
|
|
84
|
+
{trendValue && <span>{trendValue}</span>}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function RStatCard({
|
|
90
|
+
label,
|
|
91
|
+
value,
|
|
92
|
+
prefix = "",
|
|
93
|
+
suffix = "",
|
|
94
|
+
decimals = 0,
|
|
95
|
+
trend,
|
|
96
|
+
trendValue,
|
|
97
|
+
subtitle,
|
|
98
|
+
icon,
|
|
99
|
+
color,
|
|
100
|
+
animate = true,
|
|
101
|
+
className = "",
|
|
102
|
+
}: RStatCardProps) {
|
|
103
|
+
const isNumeric = typeof value === "number" && !isNaN(value);
|
|
104
|
+
const animatedValue = useCountUp(isNumeric ? value : 0, 1200, animate && isNumeric);
|
|
105
|
+
|
|
106
|
+
const displayValue = isNumeric
|
|
107
|
+
? `${prefix}${animatedValue.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })}${suffix}`
|
|
108
|
+
: `${prefix}${value}${suffix}`;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={`flex flex-col gap-2 p-4 rounded-xl bg-white border border-gray-100 hover:border-gray-200 transition-all duration-200 hover:shadow-sm ${className}`}>
|
|
112
|
+
<div className="flex items-center justify-between">
|
|
113
|
+
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wider">{label}</span>
|
|
114
|
+
{icon && (
|
|
115
|
+
<div
|
|
116
|
+
className="w-8 h-8 rounded-lg flex items-center justify-center"
|
|
117
|
+
style={{ backgroundColor: color ? `${color}15` : "rgba(99,102,241,0.08)" }}
|
|
118
|
+
>
|
|
119
|
+
{React.isValidElement(icon)
|
|
120
|
+
? React.cloneElement(icon as React.ReactElement, { size: 16, style: { color: color || "#6366f1" } } as any)
|
|
121
|
+
: icon}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
<div className="flex items-end gap-2">
|
|
127
|
+
<span className="text-2xl font-bold text-[#2D3142] tracking-tight leading-none">
|
|
128
|
+
{displayValue}
|
|
129
|
+
</span>
|
|
130
|
+
{trend && <TrendIndicator trend={trend} trendValue={trendValue} />}
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{subtitle && (
|
|
134
|
+
<span className="text-[11px] font-medium text-gray-400 leading-tight">{subtitle}</span>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// ── Chart Color Palette ──────────────────────────────────────────────────────
|
|
2
|
+
// 16 curated colors with gradient pairs for premium chart aesthetics.
|
|
3
|
+
// Organized into primary rotation + semantic sets.
|
|
4
|
+
|
|
1
5
|
export const CHART_COLORS = [
|
|
2
6
|
'#6366f1', // Indigo
|
|
3
7
|
'#22c55e', // Green
|
|
@@ -7,4 +11,74 @@ export const CHART_COLORS = [
|
|
|
7
11
|
'#8b5cf6', // Violet
|
|
8
12
|
'#ec4899', // Pink
|
|
9
13
|
'#14b8a6', // Teal
|
|
14
|
+
'#f97316', // Orange
|
|
15
|
+
'#3b82f6', // Blue
|
|
16
|
+
'#a855f7', // Purple
|
|
17
|
+
'#10b981', // Emerald
|
|
18
|
+
'#e11d48', // Rose
|
|
19
|
+
'#0ea5e9', // Sky
|
|
20
|
+
'#84cc16', // Lime
|
|
21
|
+
'#d946ef', // Fuchsia
|
|
10
22
|
] as const;
|
|
23
|
+
|
|
24
|
+
// Gradient pairs: [from, to] for gradient fills on charts
|
|
25
|
+
export const CHART_GRADIENTS: [string, string][] = [
|
|
26
|
+
['#6366f1', '#818cf8'], // Indigo
|
|
27
|
+
['#22c55e', '#4ade80'], // Green
|
|
28
|
+
['#f59e0b', '#fbbf24'], // Amber
|
|
29
|
+
['#ef4444', '#f87171'], // Red
|
|
30
|
+
['#06b6d4', '#22d3ee'], // Cyan
|
|
31
|
+
['#8b5cf6', '#a78bfa'], // Violet
|
|
32
|
+
['#ec4899', '#f472b6'], // Pink
|
|
33
|
+
['#14b8a6', '#2dd4bf'], // Teal
|
|
34
|
+
['#f97316', '#fb923c'], // Orange
|
|
35
|
+
['#3b82f6', '#60a5fa'], // Blue
|
|
36
|
+
['#a855f7', '#c084fc'], // Purple
|
|
37
|
+
['#10b981', '#34d399'], // Emerald
|
|
38
|
+
['#e11d48', '#fb7185'], // Rose
|
|
39
|
+
['#0ea5e9', '#38bdf8'], // Sky
|
|
40
|
+
['#84cc16', '#a3e635'], // Lime
|
|
41
|
+
['#d946ef', '#e879f9'], // Fuchsia
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// Semantic color sets for specific use-cases
|
|
45
|
+
export const SEMANTIC_COLORS = {
|
|
46
|
+
success: '#22c55e',
|
|
47
|
+
successLight: '#4ade80',
|
|
48
|
+
warning: '#f59e0b',
|
|
49
|
+
warningLight: '#fbbf24',
|
|
50
|
+
danger: '#ef4444',
|
|
51
|
+
dangerLight: '#f87171',
|
|
52
|
+
info: '#3b82f6',
|
|
53
|
+
infoLight: '#60a5fa',
|
|
54
|
+
neutral: '#64748b',
|
|
55
|
+
neutralLight: '#94a3b8',
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
// Tooltip styling constants (shared across all chart components)
|
|
59
|
+
export const TOOLTIP_STYLE = {
|
|
60
|
+
backgroundColor: '#1e293b',
|
|
61
|
+
color: '#fff',
|
|
62
|
+
borderRadius: '10px',
|
|
63
|
+
border: 'none',
|
|
64
|
+
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
|
65
|
+
padding: '10px 14px',
|
|
66
|
+
fontSize: '12px',
|
|
67
|
+
fontWeight: 600,
|
|
68
|
+
} as const;
|
|
69
|
+
|
|
70
|
+
export const TOOLTIP_ITEM_STYLE = {
|
|
71
|
+
color: '#cbd5e1',
|
|
72
|
+
fontSize: '12px',
|
|
73
|
+
fontWeight: 500,
|
|
74
|
+
} as const;
|
|
75
|
+
|
|
76
|
+
export const AXIS_TICK_STYLE = {
|
|
77
|
+
fontSize: 11,
|
|
78
|
+
fontWeight: 600,
|
|
79
|
+
fill: '#94a3b8',
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
82
|
+
export const GRID_STROKE = '#f1f5f9';
|
|
83
|
+
export const ANIMATION_DURATION = 1000;
|
|
84
|
+
export const ANIMATION_EASING = 'ease-out' as const;
|