cynx-ui 1.3.13 → 1.3.15

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.
@@ -1,216 +1,216 @@
1
- import { useEffect, useRef } from 'react';
2
-
3
- const SIZES = {
4
- xs: { svg: 48, stroke: 4, fontSize: '.7rem' },
5
- sm: { svg: 64, stroke: 5, fontSize: '.85rem' },
6
- md: { svg: 80, stroke: 6, fontSize: '1rem' },
7
- lg: { svg: 100, stroke: 7, fontSize: '1.15rem' },
8
- xl: { svg: 120, stroke: 8, fontSize: '1.3rem' },
9
- };
10
-
11
- export default function CircularProgress({
12
- value = 0,
13
- segments,
14
- size = 'md',
15
- customSize,
16
- color = 'var(--blue)',
17
- trackColor = 'var(--bd2, #e0e0e4)',
18
- label = '',
19
- centerText,
20
- showPercent = true,
21
- animated = true,
22
- rounded = true,
23
- indeterminate = false,
24
- speed = 1,
25
- strokeWidth,
26
- gap = 4,
27
- className = '',
28
- style = {},
29
- }) {
30
- const base = SIZES[size] || SIZES.md;
31
- const svg = customSize || base.svg;
32
- const stroke = strokeWidth ?? (customSize ? Math.max(3, svg * 0.06) : base.stroke);
33
- const fontSize = customSize ? `${Math.max(11, svg * 0.13)}px` : base.fontSize;
34
- const cx = svg / 2;
35
- const cy = svg / 2;
36
- const r = (svg - stroke) / 2;
37
- const c = 2 * Math.PI * r;
38
- const pct = Math.min(100, Math.max(0, value));
39
- const BIG = c * 4;
40
- const isRounded = rounded !== false;
41
- const cap = isRounded ? 'round' : 'butt';
42
-
43
- const userGap = gap !== undefined ? gap : (isRounded ? c * 0.05 : c * 0.03);
44
- const capOverrun = isRounded ? stroke : 0;
45
- const gapSize = userGap + capOverrun;
46
- const total = c - 2 * gapSize;
47
-
48
- const isFull = pct >= 100;
49
- const isEmpty = pct <= 0;
50
-
51
- const hasSegments = Array.isArray(segments) && segments.length > 0;
52
- const rawSum = hasSegments ? segments.reduce((sum, s) => sum + (Number(s.value) || 0), 0) : 0;
53
-
54
- let indDash, indOffset, trackDash, trackOffset, indOpacity;
55
-
56
- if (isEmpty) {
57
- indDash = `0,${BIG}`; indOffset = 0;
58
- trackDash = `${c},${BIG}`; trackOffset = 0;
59
- indOpacity = 0;
60
- } else if (isFull) {
61
- indDash = `${c + stroke * 2},${BIG}`; indOffset = 0;
62
- trackDash = `0,${BIG}`; trackOffset = 0;
63
- indOpacity = 1;
64
- } else {
65
- const activeLen = (pct / 100) * total;
66
- const inactiveLen = total - activeLen;
67
- indDash = `${activeLen},${BIG}`; indOffset = 0;
68
- trackDash = `${inactiveLen},${BIG}`; trackOffset = -(activeLen + gapSize);
69
- indOpacity = 1;
70
- }
71
-
72
- const gRef = useRef(null);
73
- const indRef = useRef(null);
74
- const trackRef = useRef(null);
75
-
76
- useEffect(() => {
77
- if (!indeterminate) return;
78
- const capRad = (180 / Math.PI) * (stroke / r) / 2;
79
- const gapRad = (180 / Math.PI) * (gapSize / r);
80
- const gapArcLen = (capRad + gapRad) / 360 * c;
81
- let angle = -90, arcFrac = 0.45, arcDir = 1, raf;
82
- let lastTime = performance.now();
83
- function tick(now) {
84
- const dt = (now - lastTime) / 16.67;
85
- lastTime = now;
86
- angle = (angle + 3.6 * speed * dt) % 360;
87
- arcFrac += 0.006 * arcDir * speed * dt;
88
- if (arcFrac >= 0.72) { arcFrac = 0.72; arcDir = -1; }
89
- if (arcFrac <= 0.18) { arcFrac = 0.18; arcDir = 1; }
90
- const indLen = c * arcFrac;
91
- const trackLen = Math.max(0, c - indLen - gapArcLen * 2);
92
- if (gRef.current) gRef.current.style.transform = `rotate(${angle}deg)`;
93
- if (indRef.current) indRef.current.setAttribute('stroke-dasharray', `${indLen.toFixed(2)},9999`);
94
- if (trackRef.current) {
95
- trackRef.current.setAttribute('stroke-dasharray', `${trackLen.toFixed(2)},9999`);
96
- trackRef.current.setAttribute('stroke-dashoffset', `${(-(indLen + gapArcLen)).toFixed(2)}`);
97
- }
98
- raf = requestAnimationFrame(tick);
99
- }
100
- raf = requestAnimationFrame(tick);
101
- return () => cancelAnimationFrame(raf);
102
- }, [indeterminate, c, stroke, r, gapSize, speed]);
103
-
104
- return (
105
- <div className={className} style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4, ...style }}>
106
- <div style={{ position: 'relative', width: svg, height: svg }}>
107
- <svg width={svg} height={svg} viewBox={`0 0 ${svg} ${svg}`}>
108
- {indeterminate ? (
109
- <g ref={gRef} style={{ transformOrigin: `${cx}px ${cy}px` }}>
110
- <circle
111
- ref={trackRef}
112
- cx={cx} cy={cy} r={r}
113
- fill="none" stroke={trackColor} strokeWidth={stroke} strokeLinecap={cap}
114
- />
115
- <circle
116
- ref={indRef}
117
- cx={cx} cy={cy} r={r}
118
- fill="none" stroke={color} strokeWidth={stroke} strokeLinecap={cap}
119
- />
120
- </g>
121
- ) : hasSegments ? (
122
- <>
123
- {rawSum <= 0 ? (
124
- <circle
125
- cx={cx} cy={cy} r={r}
126
- fill="none" stroke={trackColor} strokeWidth={stroke}
127
- />
128
- ) : (
129
- (() => {
130
- const activeSegs = segments.filter((seg) => (Number(seg.value) || 0) > 0);
131
- const segCount = activeSegs.length;
132
- if (segCount === 0) {
133
- return (
134
- <circle
135
- cx={cx} cy={cy} r={r}
136
- fill="none" stroke={trackColor} strokeWidth={stroke}
137
- />
138
- );
139
- }
140
-
141
- const gapPx = gap !== undefined ? gap : 4;
142
- const capOverrun = isRounded ? stroke : 0;
143
- const gapArc = segCount > 1 ? (gapPx + capOverrun) : 0;
144
- const availableC = Math.max(0, c - segCount * gapArc);
145
-
146
- let accumulatedArc = isRounded ? stroke / 2 : 0;
147
-
148
- return activeSegs.map((seg, idx) => {
149
- const val = Number(seg.value) || 0;
150
- const segLen = (val / rawSum) * availableC;
151
- const currentOffset = accumulatedArc;
152
- accumulatedArc += segLen + gapArc;
153
-
154
- return (
155
- <circle
156
- key={idx}
157
- cx={cx} cy={cy} r={r}
158
- fill="none" stroke={seg.color || color} strokeWidth={stroke}
159
- strokeLinecap={cap}
160
- strokeDasharray={`${Math.max(0.1, segLen).toFixed(2)}, ${BIG}`}
161
- strokeDashoffset={(-currentOffset).toFixed(2)}
162
- style={{
163
- transform: 'rotate(-90deg)', transformOrigin: '50% 50%',
164
- transition: animated ? 'stroke-dasharray .5s cubic-bezier(0.4,0,0.2,1), stroke-dashoffset .5s cubic-bezier(0.4,0,0.2,1)' : 'none',
165
- }}
166
- >
167
- {seg.label && <title>{`${seg.label}: ${val}`}</title>}
168
- </circle>
169
- );
170
- });
171
- })()
172
- )}
173
- </>
174
- ) : (
175
- <>
176
- <circle
177
- cx={cx} cy={cy} r={r}
178
- fill="none" stroke={trackColor} strokeWidth={stroke}
179
- strokeLinecap={cap}
180
- strokeDasharray={trackDash} strokeDashoffset={trackOffset}
181
- style={{
182
- transform: 'rotate(-90deg)', transformOrigin: '50% 50%',
183
- transition: animated ? 'stroke-dasharray .5s cubic-bezier(0.4,0,0.2,1), stroke-dashoffset .5s cubic-bezier(0.4,0,0.2,1)' : 'none',
184
- }}
185
- />
186
- <circle
187
- cx={cx} cy={cy} r={r}
188
- fill="none" stroke={color} strokeWidth={stroke}
189
- strokeLinecap={cap}
190
- strokeDasharray={indDash} strokeDashoffset={indOffset}
191
- style={{
192
- transform: 'rotate(-90deg)', transformOrigin: '50% 50%',
193
- opacity: indOpacity,
194
- transition: animated ? 'stroke-dasharray .5s cubic-bezier(0.4,0,0.2,1), opacity .2s' : 'none',
195
- }}
196
- />
197
- </>
198
- )}
199
- </svg>
200
- {showPercent && !indeterminate && (
201
- <div style={{
202
- position: 'absolute', inset: 0,
203
- display: 'flex', alignItems: 'center', justifyContent: 'center',
204
- fontSize, fontWeight: 600, color: 'var(--primary-text)',
205
- fontVariantNumeric: 'tabular-nums',
206
- }}>
207
- {centerText !== undefined ? centerText : hasSegments ? rawSum : `${Math.round(pct)}%`}
208
- </div>
209
- )}
210
- </div>
211
- {label && (
212
- <span style={{ fontSize: '.6rem', color: 'var(--muted)', fontWeight: 500 }}>{label}</span>
213
- )}
214
- </div>
215
- );
216
- }
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ const SIZES = {
4
+ xs: { svg: 48, stroke: 4, fontSize: '.7rem' },
5
+ sm: { svg: 64, stroke: 5, fontSize: '.85rem' },
6
+ md: { svg: 80, stroke: 6, fontSize: '1rem' },
7
+ lg: { svg: 100, stroke: 7, fontSize: '1.15rem' },
8
+ xl: { svg: 120, stroke: 8, fontSize: '1.3rem' },
9
+ };
10
+
11
+ export default function CircularProgress({
12
+ value = 0,
13
+ segments,
14
+ size = 'md',
15
+ customSize,
16
+ color = 'var(--blue)',
17
+ trackColor = 'var(--bd2, #e0e0e4)',
18
+ label = '',
19
+ centerText,
20
+ showPercent = true,
21
+ animated = true,
22
+ rounded = true,
23
+ indeterminate = false,
24
+ speed = 1,
25
+ strokeWidth,
26
+ gap = 4,
27
+ className = '',
28
+ style = {},
29
+ }) {
30
+ const base = SIZES[size] || SIZES.md;
31
+ const svg = customSize || base.svg;
32
+ const stroke = strokeWidth ?? (customSize ? Math.max(3, svg * 0.06) : base.stroke);
33
+ const fontSize = customSize ? `${Math.max(11, svg * 0.13)}px` : base.fontSize;
34
+ const cx = svg / 2;
35
+ const cy = svg / 2;
36
+ const r = (svg - stroke) / 2;
37
+ const c = 2 * Math.PI * r;
38
+ const pct = Math.min(100, Math.max(0, value));
39
+ const BIG = c * 4;
40
+ const isRounded = rounded !== false;
41
+ const cap = isRounded ? 'round' : 'butt';
42
+
43
+ const userGap = gap !== undefined ? gap : (isRounded ? c * 0.05 : c * 0.03);
44
+ const capOverrun = isRounded ? stroke : 0;
45
+ const gapSize = userGap + capOverrun;
46
+ const total = c - 2 * gapSize;
47
+
48
+ const isFull = pct >= 100;
49
+ const isEmpty = pct <= 0;
50
+
51
+ const hasSegments = Array.isArray(segments) && segments.length > 0;
52
+ const rawSum = hasSegments ? segments.reduce((sum, s) => sum + (Number(s.value) || 0), 0) : 0;
53
+
54
+ let indDash, indOffset, trackDash, trackOffset, indOpacity;
55
+
56
+ if (isEmpty) {
57
+ indDash = `0,${BIG}`; indOffset = 0;
58
+ trackDash = `${c},${BIG}`; trackOffset = 0;
59
+ indOpacity = 0;
60
+ } else if (isFull) {
61
+ indDash = `${c + stroke * 2},${BIG}`; indOffset = 0;
62
+ trackDash = `0,${BIG}`; trackOffset = 0;
63
+ indOpacity = 1;
64
+ } else {
65
+ const activeLen = (pct / 100) * total;
66
+ const inactiveLen = total - activeLen;
67
+ indDash = `${activeLen},${BIG}`; indOffset = 0;
68
+ trackDash = `${inactiveLen},${BIG}`; trackOffset = -(activeLen + gapSize);
69
+ indOpacity = 1;
70
+ }
71
+
72
+ const gRef = useRef(null);
73
+ const indRef = useRef(null);
74
+ const trackRef = useRef(null);
75
+
76
+ useEffect(() => {
77
+ if (!indeterminate) return;
78
+ const capRad = (180 / Math.PI) * (stroke / r) / 2;
79
+ const gapRad = (180 / Math.PI) * (gapSize / r);
80
+ const gapArcLen = (capRad + gapRad) / 360 * c;
81
+ let angle = -90, arcFrac = 0.45, arcDir = 1, raf;
82
+ let lastTime = performance.now();
83
+ function tick(now) {
84
+ const dt = (now - lastTime) / 16.67;
85
+ lastTime = now;
86
+ angle = (angle + 3.6 * speed * dt) % 360;
87
+ arcFrac += 0.006 * arcDir * speed * dt;
88
+ if (arcFrac >= 0.72) { arcFrac = 0.72; arcDir = -1; }
89
+ if (arcFrac <= 0.18) { arcFrac = 0.18; arcDir = 1; }
90
+ const indLen = c * arcFrac;
91
+ const trackLen = Math.max(0, c - indLen - gapArcLen * 2);
92
+ if (gRef.current) gRef.current.style.transform = `rotate(${angle}deg)`;
93
+ if (indRef.current) indRef.current.setAttribute('stroke-dasharray', `${indLen.toFixed(2)},9999`);
94
+ if (trackRef.current) {
95
+ trackRef.current.setAttribute('stroke-dasharray', `${trackLen.toFixed(2)},9999`);
96
+ trackRef.current.setAttribute('stroke-dashoffset', `${(-(indLen + gapArcLen)).toFixed(2)}`);
97
+ }
98
+ raf = requestAnimationFrame(tick);
99
+ }
100
+ raf = requestAnimationFrame(tick);
101
+ return () => cancelAnimationFrame(raf);
102
+ }, [indeterminate, c, stroke, r, gapSize, speed]);
103
+
104
+ return (
105
+ <div className={className} style={{ display: 'inline-flex', flexDirection: 'column', alignItems: 'center', gap: 4, ...style }}>
106
+ <div style={{ position: 'relative', width: svg, height: svg }}>
107
+ <svg width={svg} height={svg} viewBox={`0 0 ${svg} ${svg}`}>
108
+ {indeterminate ? (
109
+ <g ref={gRef} style={{ transformOrigin: `${cx}px ${cy}px` }}>
110
+ <circle
111
+ ref={trackRef}
112
+ cx={cx} cy={cy} r={r}
113
+ fill="none" stroke={trackColor} strokeWidth={stroke} strokeLinecap={cap}
114
+ />
115
+ <circle
116
+ ref={indRef}
117
+ cx={cx} cy={cy} r={r}
118
+ fill="none" stroke={color} strokeWidth={stroke} strokeLinecap={cap}
119
+ />
120
+ </g>
121
+ ) : hasSegments ? (
122
+ <>
123
+ {rawSum <= 0 ? (
124
+ <circle
125
+ cx={cx} cy={cy} r={r}
126
+ fill="none" stroke={trackColor} strokeWidth={stroke}
127
+ />
128
+ ) : (
129
+ (() => {
130
+ const activeSegs = segments.filter((seg) => (Number(seg.value) || 0) > 0);
131
+ const segCount = activeSegs.length;
132
+ if (segCount === 0) {
133
+ return (
134
+ <circle
135
+ cx={cx} cy={cy} r={r}
136
+ fill="none" stroke={trackColor} strokeWidth={stroke}
137
+ />
138
+ );
139
+ }
140
+
141
+ const gapPx = gap !== undefined ? gap : 4;
142
+ const capOverrun = isRounded ? stroke : 0;
143
+ const gapArc = segCount > 1 ? (gapPx + capOverrun) : 0;
144
+ const availableC = Math.max(0, c - segCount * gapArc);
145
+
146
+ let accumulatedArc = isRounded ? stroke / 2 : 0;
147
+
148
+ return activeSegs.map((seg, idx) => {
149
+ const val = Number(seg.value) || 0;
150
+ const segLen = (val / rawSum) * availableC;
151
+ const currentOffset = accumulatedArc;
152
+ accumulatedArc += segLen + gapArc;
153
+
154
+ return (
155
+ <circle
156
+ key={idx}
157
+ cx={cx} cy={cy} r={r}
158
+ fill="none" stroke={seg.color || color} strokeWidth={stroke}
159
+ strokeLinecap={cap}
160
+ strokeDasharray={`${Math.max(0.1, segLen).toFixed(2)}, ${BIG}`}
161
+ strokeDashoffset={(-currentOffset).toFixed(2)}
162
+ style={{
163
+ transform: 'rotate(-90deg)', transformOrigin: '50% 50%',
164
+ transition: animated ? 'stroke-dasharray .5s cubic-bezier(0.4,0,0.2,1), stroke-dashoffset .5s cubic-bezier(0.4,0,0.2,1)' : 'none',
165
+ }}
166
+ >
167
+ {seg.label && <title>{`${seg.label}: ${val}`}</title>}
168
+ </circle>
169
+ );
170
+ });
171
+ })()
172
+ )}
173
+ </>
174
+ ) : (
175
+ <>
176
+ <circle
177
+ cx={cx} cy={cy} r={r}
178
+ fill="none" stroke={trackColor} strokeWidth={stroke}
179
+ strokeLinecap={cap}
180
+ strokeDasharray={trackDash} strokeDashoffset={trackOffset}
181
+ style={{
182
+ transform: 'rotate(-90deg)', transformOrigin: '50% 50%',
183
+ transition: animated ? 'stroke-dasharray .5s cubic-bezier(0.4,0,0.2,1), stroke-dashoffset .5s cubic-bezier(0.4,0,0.2,1)' : 'none',
184
+ }}
185
+ />
186
+ <circle
187
+ cx={cx} cy={cy} r={r}
188
+ fill="none" stroke={color} strokeWidth={stroke}
189
+ strokeLinecap={cap}
190
+ strokeDasharray={indDash} strokeDashoffset={indOffset}
191
+ style={{
192
+ transform: 'rotate(-90deg)', transformOrigin: '50% 50%',
193
+ opacity: indOpacity,
194
+ transition: animated ? 'stroke-dasharray .5s cubic-bezier(0.4,0,0.2,1), opacity .2s' : 'none',
195
+ }}
196
+ />
197
+ </>
198
+ )}
199
+ </svg>
200
+ {showPercent && !indeterminate && (
201
+ <div style={{
202
+ position: 'absolute', inset: 0,
203
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
204
+ fontSize, fontWeight: 600, color: 'var(--primary-text)',
205
+ fontVariantNumeric: 'tabular-nums',
206
+ }}>
207
+ {centerText !== undefined ? centerText : hasSegments ? rawSum : `${Math.round(pct)}%`}
208
+ </div>
209
+ )}
210
+ </div>
211
+ {label && (
212
+ <span style={{ fontSize: '.6rem', color: 'var(--muted)', fontWeight: 500 }}>{label}</span>
213
+ )}
214
+ </div>
215
+ );
216
+ }