Skip to content

Commit dcbefa1

Browse files
authored
fix: use svg instead of mask for doughnuts (#2600)
1 parent 4937cd3 commit dcbefa1

File tree

5 files changed

+177
-53
lines changed

5 files changed

+177
-53
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ playwright-artifacts
2222
.env.test.local
2323
.env.production.local
2424
.vscode
25+
.cursor
2526

2627
npm-debug.log*
2728
yarn-debug.log*

src/components/DoughnutMetrics/DoughnutMetrics.scss

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,66 @@
11
.ydb-doughnut-metrics {
2-
--doughnut-border: 16px;
3-
--doughnut-width: 100px;
4-
--doughnut-wrapper-indent: calc(var(--doughnut-border) + 5px);
52
--doughnut-color: var(--g-color-base-positive-heavy);
63
--doughnut-backdrop-color: var(--g-color-base-generic);
74
--doughnut-overlap-color: var(--g-color-base-positive-heavy-hover);
85
--doughnut-text-color: var(--g-color-text-positive-heavy);
96

7+
position: relative;
8+
109
&__doughnut {
1110
position: relative;
1211

13-
width: var(--doughnut-width);
14-
aspect-ratio: 1;
15-
16-
border-radius: 50%;
17-
mask: radial-gradient(circle at center, transparent 46%, #000 46.5%);
12+
display: block;
1813

19-
transform: rotate(180deg);
20-
}
14+
// Enable smooth rendering for SVG
15+
shape-rendering: geometricPrecision;
16+
-webkit-font-smoothing: antialiased;
17+
-moz-osx-font-smoothing: grayscale;
2118

22-
// Size modifiers - using visually centered values
23-
&__doughnut_size_small {
24-
--doughnut-border: 12px;
25-
--doughnut-width: 65px;
26-
--doughnut-wrapper-indent: 15px;
27-
}
19+
// Ensure SVG renders smoothly
20+
image-rendering: smooth;
21+
will-change: transform;
2822

29-
&__doughnut_size_medium {
30-
--doughnut-border: 16px;
31-
--doughnut-width: 100px;
32-
--doughnut-wrapper-indent: calc(var(--doughnut-border) + 5px);
23+
// Preserve rotation origin
24+
transform-origin: center;
3325
}
3426

35-
&__doughnut_size_large {
36-
--doughnut-border: 20px;
37-
--doughnut-width: 130px;
38-
--doughnut-wrapper-indent: 25px;
27+
// Progress circle animation
28+
&__progress-circle,
29+
&__overlap-circle {
30+
transition: stroke-dasharray 0.3s ease;
31+
transform-origin: center;
3932
}
4033

34+
// Status modifiers
4135
&_status_warning {
4236
--doughnut-color: var(--g-color-base-warning-heavy);
4337
--doughnut-overlap-color: var(--g-color-base-warning-heavy-hover);
4438
--doughnut-text-color: var(--g-color-text-warning);
4539
}
40+
4641
&_status_danger {
4742
--doughnut-color: var(--g-color-base-danger-heavy);
4843
--doughnut-overlap-color: var(--g-color-base-danger-heavy-hover);
4944
--doughnut-text-color: var(--g-color-base-danger-heavy);
5045
}
46+
5147
&__text-wrapper {
5248
position: absolute;
5349
z-index: 1;
54-
top: var(--doughnut-wrapper-indent);
55-
left: var(--doughnut-wrapper-indent);
50+
top: 50%;
51+
left: 50%;
5652

5753
display: flex;
5854
flex-direction: column;
5955
justify-content: center;
6056
align-items: center;
6157

62-
width: calc(100% - calc(var(--doughnut-wrapper-indent) * 2));
58+
width: 100%;
59+
height: 100%;
6360

6461
text-align: center;
6562

66-
aspect-ratio: 1;
63+
transform: translate(-50%, -50%);
6764
}
6865

6966
&__value {

src/components/DoughnutMetrics/DoughnutMetrics.tsx

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,24 @@ import {Flex, HelpMark, Text} from '@gravity-ui/uikit';
66
import {cn} from '../../utils/cn';
77
import type {ProgressStatus} from '../../utils/progress';
88

9+
import {SvgCircle} from './SvgCircle';
10+
import {
11+
ROTATION_OFFSET,
12+
SIZE_CONFIG,
13+
calculateCircumference,
14+
calculateOverlapDasharray,
15+
calculateStrokeDasharray,
16+
} from './utils';
17+
918
import './DoughnutMetrics.scss';
1019

1120
const b = cn('ydb-doughnut-metrics');
1221

13-
const SizeContext = React.createContext<'small' | 'medium' | 'large'>('medium');
22+
type Size = keyof typeof SIZE_CONFIG;
23+
24+
const SizeContext = React.createContext<Size>('medium');
1425

26+
// Legend component
1527
interface LegendProps {
1628
children?: React.ReactNode;
1729
variant?: TextProps['variant'];
@@ -25,7 +37,7 @@ function Legend({
2537
variant = 'subheader-3',
2638
color = 'primary',
2739
note,
28-
noteIconSize,
40+
noteIconSize = 'm',
2941
}: LegendProps) {
3042
return (
3143
<Flex gap={1} alignItems="center">
@@ -34,7 +46,7 @@ function Legend({
3446
</Text>
3547
{note && (
3648
<HelpMark
37-
iconSize={noteIconSize || 'm'}
49+
iconSize={noteIconSize}
3850
className={b('legend-note')}
3951
popoverProps={{placement: 'right'}}
4052
>
@@ -44,16 +56,11 @@ function Legend({
4456
</Flex>
4557
);
4658
}
59+
60+
// Value component
4761
function Value({children, variant}: LegendProps) {
4862
const size = React.useContext(SizeContext);
49-
50-
const sizeVariantMap = {
51-
small: 'subheader-1',
52-
medium: 'subheader-2',
53-
large: 'subheader-3',
54-
} as const;
55-
56-
const finalVariant = variant || sizeVariantMap[size];
63+
const finalVariant = variant || SIZE_CONFIG[size].textVariant;
5764

5865
return (
5966
<Text variant={finalVariant} className={b('value')}>
@@ -62,12 +69,13 @@ function Value({children, variant}: LegendProps) {
6269
);
6370
}
6471

72+
// Main component
6573
interface DoughnutProps {
6674
status: ProgressStatus;
6775
fillWidth: number;
6876
children?: React.ReactNode;
6977
className?: string;
70-
size?: 'small' | 'medium' | 'large';
78+
size?: Size;
7179
}
7280

7381
export function DoughnutMetrics({
@@ -77,24 +85,57 @@ export function DoughnutMetrics({
7785
className,
7886
size = 'medium',
7987
}: DoughnutProps) {
80-
let filledDegrees = fillWidth * 3.6;
81-
let doughnutFillVar = 'var(--doughnut-color)';
82-
let doughnutBackdropVar = 'var(--doughnut-backdrop-color)';
88+
const config = SIZE_CONFIG[size];
89+
const radius = (config.width - config.strokeWidth) / 2;
90+
const circumference = calculateCircumference(radius);
91+
const strokeDashoffset = circumference * ROTATION_OFFSET;
8392

84-
if (filledDegrees > 360) {
85-
filledDegrees -= 360;
86-
doughnutBackdropVar = 'var(--doughnut-color)';
87-
doughnutFillVar = 'var(--doughnut-overlap-color)';
88-
}
93+
const centerX = config.width / 2;
94+
const centerY = config.width / 2;
8995

90-
const doughnutStyle: React.CSSProperties = {
91-
background: `conic-gradient(${doughnutFillVar} 0deg ${filledDegrees}deg, ${doughnutBackdropVar} ${filledDegrees}deg 360deg)`,
92-
};
96+
const strokeDasharray = calculateStrokeDasharray(fillWidth, circumference);
97+
const overlapDasharray = calculateOverlapDasharray(fillWidth, circumference);
98+
const needsOverlapCircle = fillWidth > 100;
9399

94100
return (
95101
<SizeContext.Provider value={size}>
96-
<div className={b({status}, className)} style={{position: 'relative'}}>
97-
<div style={doughnutStyle} className={b('doughnut', {size})}></div>
102+
<div className={b({status}, className)}>
103+
<svg width={config.width} height={config.width} className={b('doughnut')}>
104+
{/* Background circle */}
105+
<SvgCircle
106+
cx={centerX}
107+
cy={centerY}
108+
r={radius}
109+
stroke="var(--doughnut-backdrop-color)"
110+
strokeWidth={config.strokeWidth}
111+
/>
112+
113+
{/* Progress circle */}
114+
<SvgCircle
115+
cx={centerX}
116+
cy={centerY}
117+
r={radius}
118+
stroke="var(--doughnut-color)"
119+
strokeWidth={config.strokeWidth}
120+
strokeDasharray={strokeDasharray}
121+
strokeDashoffset={strokeDashoffset}
122+
className={b('progress-circle')}
123+
/>
124+
125+
{/* Overlap circle for values > 100% */}
126+
{needsOverlapCircle && (
127+
<SvgCircle
128+
cx={centerX}
129+
cy={centerY}
130+
r={radius}
131+
stroke="var(--doughnut-overlap-color)"
132+
strokeWidth={config.strokeWidth}
133+
strokeDasharray={overlapDasharray}
134+
strokeDashoffset={strokeDashoffset}
135+
className={b('overlap-circle')}
136+
/>
137+
)}
138+
</svg>
98139
<div className={b('text-wrapper')}>{children}</div>
99140
</div>
100141
</SizeContext.Provider>
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
interface SvgCircleProps {
2+
cx: number;
3+
cy: number;
4+
r: number;
5+
stroke: string;
6+
strokeWidth: number;
7+
strokeDasharray?: string;
8+
strokeDashoffset?: number;
9+
strokeLinecap?: 'butt' | 'round' | 'square';
10+
fill?: string;
11+
className?: string;
12+
}
13+
14+
export function SvgCircle({
15+
cx,
16+
cy,
17+
r,
18+
stroke,
19+
strokeWidth,
20+
strokeDasharray,
21+
strokeDashoffset,
22+
strokeLinecap = 'butt',
23+
fill = 'none',
24+
className,
25+
}: SvgCircleProps) {
26+
return (
27+
<circle
28+
cx={cx}
29+
cy={cy}
30+
r={r}
31+
fill={fill}
32+
stroke={stroke}
33+
strokeWidth={strokeWidth}
34+
strokeDasharray={strokeDasharray}
35+
strokeDashoffset={strokeDashoffset}
36+
strokeLinecap={strokeLinecap}
37+
className={className}
38+
/>
39+
);
40+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Constants
2+
export const SIZE_CONFIG = {
3+
small: {width: 65, strokeWidth: 12, textVariant: 'subheader-1'},
4+
medium: {width: 100, strokeWidth: 16, textVariant: 'subheader-2'},
5+
large: {width: 130, strokeWidth: 20, textVariant: 'subheader-3'},
6+
} as const;
7+
8+
export const ROTATION_OFFSET = 0.75; // Start from bottom (270 degrees)
9+
10+
/**
11+
* Calculate the circumference of a circle given its radius
12+
*/
13+
export function calculateCircumference(radius: number): number {
14+
return 2 * Math.PI * radius;
15+
}
16+
17+
/**
18+
* Calculate stroke-dasharray for SVG circle progress fill
19+
* @param fillWidth - Progress percentage (0-100+)
20+
* @param circumference - Circle circumference
21+
* @returns Stroke-dasharray string for filled portion
22+
*/
23+
export function calculateStrokeDasharray(fillWidth: number, circumference: number): string {
24+
if (fillWidth <= 0) {
25+
return '0 0';
26+
}
27+
28+
const filledLength = (Math.min(fillWidth, 100) / 100) * circumference;
29+
return `${filledLength} ${circumference - filledLength}`;
30+
}
31+
32+
/**
33+
* Calculate stroke-dasharray for overlap portion when progress exceeds 100%
34+
* @param fillWidth - Progress percentage (0-100+)
35+
* @param circumference - Circle circumference
36+
* @returns Stroke-dasharray string for overlap portion
37+
*/
38+
export function calculateOverlapDasharray(fillWidth: number, circumference: number): string {
39+
if (fillWidth <= 100) {
40+
return '0 0';
41+
}
42+
43+
const overlapLength = ((fillWidth - 100) / 100) * circumference;
44+
return `${overlapLength} ${circumference - overlapLength}`;
45+
}

0 commit comments

Comments
 (0)