From 38bf177a9835a67a04154315937cf75b808c4620 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Wed, 23 Jul 2025 12:47:01 +0300 Subject: [PATCH 1/3] fix: use svg instead of mask --- .gitignore | 1 + .../DoughnutMetrics/DoughnutMetrics.scss | 54 ++++++++----- .../DoughnutMetrics/DoughnutMetrics.tsx | 81 ++++++++++++++++--- 3 files changed, 102 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index 9b0cfc5919..55195ae5b8 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ playwright-artifacts .env.test.local .env.production.local .vscode +.cursor npm-debug.log* yarn-debug.log* diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.scss b/src/components/DoughnutMetrics/DoughnutMetrics.scss index 7210cb77bd..cfa965c4ce 100644 --- a/src/components/DoughnutMetrics/DoughnutMetrics.scss +++ b/src/components/DoughnutMetrics/DoughnutMetrics.scss @@ -1,41 +1,50 @@ .ydb-doughnut-metrics { - --doughnut-border: 16px; - --doughnut-width: 100px; - --doughnut-wrapper-indent: calc(var(--doughnut-border) + 5px); --doughnut-color: var(--g-color-base-positive-heavy); --doughnut-backdrop-color: var(--g-color-base-generic); --doughnut-overlap-color: var(--g-color-base-positive-heavy-hover); --doughnut-text-color: var(--g-color-text-positive-heavy); + position: relative; + &__doughnut { position: relative; - width: var(--doughnut-width); - aspect-ratio: 1; + display: block; + + // Enable smooth rendering for SVG + shape-rendering: geometricPrecision; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; - border-radius: 50%; - mask: radial-gradient(circle at center, transparent 46%, #000 46.5%); + // Ensure SVG renders smoothly + image-rendering: smooth; + will-change: transform; - transform: rotate(180deg); + // Preserve rotation origin + transform-origin: center; } - // Size modifiers - using visually centered values + // Size modifiers &__doughnut_size_small { - --doughnut-border: 12px; - --doughnut-width: 65px; - --doughnut-wrapper-indent: 15px; + width: 65px; + height: 65px; } &__doughnut_size_medium { - --doughnut-border: 16px; - --doughnut-width: 100px; - --doughnut-wrapper-indent: calc(var(--doughnut-border) + 5px); + width: 100px; + height: 100px; } &__doughnut_size_large { - --doughnut-border: 20px; - --doughnut-width: 130px; - --doughnut-wrapper-indent: 25px; + width: 130px; + height: 130px; + } + + // Progress circle animation + &__progress-circle, + &__overlap-circle { + transition: stroke-dasharray 0.3s ease; + transform-origin: center; } &_status_warning { @@ -51,19 +60,20 @@ &__text-wrapper { position: absolute; z-index: 1; - top: var(--doughnut-wrapper-indent); - left: var(--doughnut-wrapper-indent); + top: 50%; + left: 50%; display: flex; flex-direction: column; justify-content: center; align-items: center; - width: calc(100% - calc(var(--doughnut-wrapper-indent) * 2)); + width: 100%; + height: 100%; text-align: center; - aspect-ratio: 1; + transform: translate(-50%, -50%); } &__value { diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.tsx b/src/components/DoughnutMetrics/DoughnutMetrics.tsx index ea3888cdca..4b30dea10c 100644 --- a/src/components/DoughnutMetrics/DoughnutMetrics.tsx +++ b/src/components/DoughnutMetrics/DoughnutMetrics.tsx @@ -77,24 +77,81 @@ export function DoughnutMetrics({ className, size = 'medium', }: DoughnutProps) { - let filledDegrees = fillWidth * 3.6; - let doughnutFillVar = 'var(--doughnut-color)'; - let doughnutBackdropVar = 'var(--doughnut-backdrop-color)'; - - if (filledDegrees > 360) { - filledDegrees -= 360; - doughnutBackdropVar = 'var(--doughnut-color)'; - doughnutFillVar = 'var(--doughnut-overlap-color)'; + // Size configurations + const sizeConfig = { + small: {width: 65, strokeWidth: 12}, + medium: {width: 100, strokeWidth: 16}, + large: {width: 130, strokeWidth: 20}, + }; + + const config = sizeConfig[size]; + const radius = (config.width - config.strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + + // Calculate stroke dash for filled portion + let strokeDasharray: string; + // Start from bottom (270 degrees = 0.75 of circumference) + const strokeDashoffset = circumference * 0.75; + + if (fillWidth <= 100) { + const filledLength = (fillWidth / 100) * circumference; + // Use negative dash to go counter-clockwise + strokeDasharray = `0 ${circumference - filledLength} ${filledLength} 0`; + } else { + // For values over 100%, we need to show overlap + strokeDasharray = `0 0 ${circumference} 0`; + // We'll use a second circle for the overlap } - const doughnutStyle: React.CSSProperties = { - background: `conic-gradient(${doughnutFillVar} 0deg ${filledDegrees}deg, ${doughnutBackdropVar} ${filledDegrees}deg 360deg)`, - }; + const needsOverlapCircle = fillWidth > 100; + const overlapDasharray = needsOverlapCircle + ? `0 ${circumference - ((fillWidth - 100) / 100) * circumference} ${((fillWidth - 100) / 100) * circumference} 0` + : '0 0'; return (
-
+ + {/* Background circle */} + + + {/* Progress circle */} + + + {/* Overlap circle for values > 100% */} + {needsOverlapCircle && ( + + )} +
{children}
From c47290186ad147559f32edb19d454b32a3053353 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Wed, 23 Jul 2025 13:00:45 +0300 Subject: [PATCH 2/3] fix: growth --- src/components/DoughnutMetrics/DoughnutMetrics.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.tsx b/src/components/DoughnutMetrics/DoughnutMetrics.tsx index 4b30dea10c..93f02860bd 100644 --- a/src/components/DoughnutMetrics/DoughnutMetrics.tsx +++ b/src/components/DoughnutMetrics/DoughnutMetrics.tsx @@ -95,17 +95,17 @@ export function DoughnutMetrics({ if (fillWidth <= 100) { const filledLength = (fillWidth / 100) * circumference; - // Use negative dash to go counter-clockwise - strokeDasharray = `0 ${circumference - filledLength} ${filledLength} 0`; + // Use clockwise fill direction + strokeDasharray = `${filledLength} ${circumference - filledLength}`; } else { // For values over 100%, we need to show overlap - strokeDasharray = `0 0 ${circumference} 0`; + strokeDasharray = `${circumference} 0`; // We'll use a second circle for the overlap } const needsOverlapCircle = fillWidth > 100; const overlapDasharray = needsOverlapCircle - ? `0 ${circumference - ((fillWidth - 100) / 100) * circumference} ${((fillWidth - 100) / 100) * circumference} 0` + ? `${((fillWidth - 100) / 100) * circumference} ${circumference - ((fillWidth - 100) / 100) * circumference}` : '0 0'; return ( From 25137bafc18c9ac490bd1d8c58d256c505ef9ec7 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Wed, 23 Jul 2025 13:22:55 +0300 Subject: [PATCH 3/3] fix: fix dougnhut --- .../DoughnutMetrics/DoughnutMetrics.scss | 19 +--- .../DoughnutMetrics/DoughnutMetrics.tsx | 94 ++++++++----------- src/components/DoughnutMetrics/SvgCircle.tsx | 40 ++++++++ src/components/DoughnutMetrics/utils.ts | 45 +++++++++ 4 files changed, 127 insertions(+), 71 deletions(-) create mode 100644 src/components/DoughnutMetrics/SvgCircle.tsx create mode 100644 src/components/DoughnutMetrics/utils.ts diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.scss b/src/components/DoughnutMetrics/DoughnutMetrics.scss index cfa965c4ce..7d72162e97 100644 --- a/src/components/DoughnutMetrics/DoughnutMetrics.scss +++ b/src/components/DoughnutMetrics/DoughnutMetrics.scss @@ -24,22 +24,6 @@ transform-origin: center; } - // Size modifiers - &__doughnut_size_small { - width: 65px; - height: 65px; - } - - &__doughnut_size_medium { - width: 100px; - height: 100px; - } - - &__doughnut_size_large { - width: 130px; - height: 130px; - } - // Progress circle animation &__progress-circle, &__overlap-circle { @@ -47,16 +31,19 @@ transform-origin: center; } + // Status modifiers &_status_warning { --doughnut-color: var(--g-color-base-warning-heavy); --doughnut-overlap-color: var(--g-color-base-warning-heavy-hover); --doughnut-text-color: var(--g-color-text-warning); } + &_status_danger { --doughnut-color: var(--g-color-base-danger-heavy); --doughnut-overlap-color: var(--g-color-base-danger-heavy-hover); --doughnut-text-color: var(--g-color-base-danger-heavy); } + &__text-wrapper { position: absolute; z-index: 1; diff --git a/src/components/DoughnutMetrics/DoughnutMetrics.tsx b/src/components/DoughnutMetrics/DoughnutMetrics.tsx index 93f02860bd..6c7cfe506b 100644 --- a/src/components/DoughnutMetrics/DoughnutMetrics.tsx +++ b/src/components/DoughnutMetrics/DoughnutMetrics.tsx @@ -6,12 +6,24 @@ import {Flex, HelpMark, Text} from '@gravity-ui/uikit'; import {cn} from '../../utils/cn'; import type {ProgressStatus} from '../../utils/progress'; +import {SvgCircle} from './SvgCircle'; +import { + ROTATION_OFFSET, + SIZE_CONFIG, + calculateCircumference, + calculateOverlapDasharray, + calculateStrokeDasharray, +} from './utils'; + import './DoughnutMetrics.scss'; const b = cn('ydb-doughnut-metrics'); -const SizeContext = React.createContext<'small' | 'medium' | 'large'>('medium'); +type Size = keyof typeof SIZE_CONFIG; + +const SizeContext = React.createContext('medium'); +// Legend component interface LegendProps { children?: React.ReactNode; variant?: TextProps['variant']; @@ -25,7 +37,7 @@ function Legend({ variant = 'subheader-3', color = 'primary', note, - noteIconSize, + noteIconSize = 'm', }: LegendProps) { return ( @@ -34,7 +46,7 @@ function Legend({ {note && ( @@ -44,16 +56,11 @@ function Legend({ ); } + +// Value component function Value({children, variant}: LegendProps) { const size = React.useContext(SizeContext); - - const sizeVariantMap = { - small: 'subheader-1', - medium: 'subheader-2', - large: 'subheader-3', - } as const; - - const finalVariant = variant || sizeVariantMap[size]; + const finalVariant = variant || SIZE_CONFIG[size].textVariant; return ( @@ -62,12 +69,13 @@ function Value({children, variant}: LegendProps) { ); } +// Main component interface DoughnutProps { status: ProgressStatus; fillWidth: number; children?: React.ReactNode; className?: string; - size?: 'small' | 'medium' | 'large'; + size?: Size; } export function DoughnutMetrics({ @@ -77,77 +85,53 @@ export function DoughnutMetrics({ className, size = 'medium', }: DoughnutProps) { - // Size configurations - const sizeConfig = { - small: {width: 65, strokeWidth: 12}, - medium: {width: 100, strokeWidth: 16}, - large: {width: 130, strokeWidth: 20}, - }; - - const config = sizeConfig[size]; + const config = SIZE_CONFIG[size]; const radius = (config.width - config.strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; - - // Calculate stroke dash for filled portion - let strokeDasharray: string; - // Start from bottom (270 degrees = 0.75 of circumference) - const strokeDashoffset = circumference * 0.75; - - if (fillWidth <= 100) { - const filledLength = (fillWidth / 100) * circumference; - // Use clockwise fill direction - strokeDasharray = `${filledLength} ${circumference - filledLength}`; - } else { - // For values over 100%, we need to show overlap - strokeDasharray = `${circumference} 0`; - // We'll use a second circle for the overlap - } + const circumference = calculateCircumference(radius); + const strokeDashoffset = circumference * ROTATION_OFFSET; + + const centerX = config.width / 2; + const centerY = config.width / 2; + const strokeDasharray = calculateStrokeDasharray(fillWidth, circumference); + const overlapDasharray = calculateOverlapDasharray(fillWidth, circumference); const needsOverlapCircle = fillWidth > 100; - const overlapDasharray = needsOverlapCircle - ? `${((fillWidth - 100) / 100) * circumference} ${circumference - ((fillWidth - 100) / 100) * circumference}` - : '0 0'; return ( -
- +
+ {/* Background circle */} - {/* Progress circle */} - {/* Overlap circle for values > 100% */} {needsOverlapCircle && ( - )} diff --git a/src/components/DoughnutMetrics/SvgCircle.tsx b/src/components/DoughnutMetrics/SvgCircle.tsx new file mode 100644 index 0000000000..0be1fb3f0a --- /dev/null +++ b/src/components/DoughnutMetrics/SvgCircle.tsx @@ -0,0 +1,40 @@ +interface SvgCircleProps { + cx: number; + cy: number; + r: number; + stroke: string; + strokeWidth: number; + strokeDasharray?: string; + strokeDashoffset?: number; + strokeLinecap?: 'butt' | 'round' | 'square'; + fill?: string; + className?: string; +} + +export function SvgCircle({ + cx, + cy, + r, + stroke, + strokeWidth, + strokeDasharray, + strokeDashoffset, + strokeLinecap = 'butt', + fill = 'none', + className, +}: SvgCircleProps) { + return ( + + ); +} diff --git a/src/components/DoughnutMetrics/utils.ts b/src/components/DoughnutMetrics/utils.ts new file mode 100644 index 0000000000..8e6f13f851 --- /dev/null +++ b/src/components/DoughnutMetrics/utils.ts @@ -0,0 +1,45 @@ +// Constants +export const SIZE_CONFIG = { + small: {width: 65, strokeWidth: 12, textVariant: 'subheader-1'}, + medium: {width: 100, strokeWidth: 16, textVariant: 'subheader-2'}, + large: {width: 130, strokeWidth: 20, textVariant: 'subheader-3'}, +} as const; + +export const ROTATION_OFFSET = 0.75; // Start from bottom (270 degrees) + +/** + * Calculate the circumference of a circle given its radius + */ +export function calculateCircumference(radius: number): number { + return 2 * Math.PI * radius; +} + +/** + * Calculate stroke-dasharray for SVG circle progress fill + * @param fillWidth - Progress percentage (0-100+) + * @param circumference - Circle circumference + * @returns Stroke-dasharray string for filled portion + */ +export function calculateStrokeDasharray(fillWidth: number, circumference: number): string { + if (fillWidth <= 0) { + return '0 0'; + } + + const filledLength = (Math.min(fillWidth, 100) / 100) * circumference; + return `${filledLength} ${circumference - filledLength}`; +} + +/** + * Calculate stroke-dasharray for overlap portion when progress exceeds 100% + * @param fillWidth - Progress percentage (0-100+) + * @param circumference - Circle circumference + * @returns Stroke-dasharray string for overlap portion + */ +export function calculateOverlapDasharray(fillWidth: number, circumference: number): string { + if (fillWidth <= 100) { + return '0 0'; + } + + const overlapLength = ((fillWidth - 100) / 100) * circumference; + return `${overlapLength} ${circumference - overlapLength}`; +}