From 942ed4b4e898ca781c27f6eac71d1dca4d9bcded Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 16 Dec 2025 11:15:19 -0500 Subject: [PATCH] add lots of tests and reorganize the code a bit --- packages/grafana-data/src/index.ts | 2 +- .../src/themes/colorManipulator.test.ts | 45 --- .../src/themes/colorManipulator.ts | 50 --- packages/grafana-data/src/themes/types.ts | 6 - .../components/RadialGauge/RadialArcPath.tsx | 16 +- .../src/components/RadialGauge/RadialBar.tsx | 13 +- .../RadialGauge/RadialBarSegmented.tsx | 128 ++++---- .../RadialGauge/RadialGauge.story.tsx | 3 +- .../components/RadialGauge/RadialGauge.tsx | 29 +- .../RadialGauge/RadialScaleLabels.tsx | 119 ++++---- .../RadialGauge/RadialSparkline.tsx | 22 +- .../src/components/RadialGauge/RadialText.tsx | 226 +++++++------- .../components/RadialGauge/ThresholdsBar.tsx | 35 +-- .../__snapshots__/utils.test.ts.snap | 17 ++ .../src/components/RadialGauge/colors.test.ts | 286 ++++++++++++++++++ .../src/components/RadialGauge/colors.ts | 110 +++++-- .../src/components/RadialGauge/effects.tsx | 8 +- .../src/components/RadialGauge/types.ts | 26 ++ .../src/components/RadialGauge/utils.test.ts | 199 +++++++++++- .../src/components/RadialGauge/utils.ts | 138 ++++++--- 20 files changed, 969 insertions(+), 509 deletions(-) create mode 100644 packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/RadialGauge/colors.test.ts create mode 100644 packages/grafana-ui/src/components/RadialGauge/types.ts diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 0c6ff34517a..63c36639e25 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -319,7 +319,7 @@ export { type MonacoLanguageRegistryItem, monacoLanguageRegistry } from './monac export { createTheme } from './themes/createTheme'; export { getThemeById, getBuiltInThemes, type ThemeRegistryItem } from './themes/registry'; export type { NewThemeOptions } from './themes/createTheme'; -export type { ThemeRichColor, GrafanaTheme2, GradientStop } from './themes/types'; +export type { ThemeRichColor, GrafanaTheme2 } from './themes/types'; export type { ThemeColors } from './themes/createColors'; export type { ThemeBreakpoints, ThemeBreakpointsKey } from './themes/breakpoints'; export type { ThemeShadows } from './themes/createShadows'; diff --git a/packages/grafana-data/src/themes/colorManipulator.test.ts b/packages/grafana-data/src/themes/colorManipulator.test.ts index b003e4baf63..4407fc3ad8a 100644 --- a/packages/grafana-data/src/themes/colorManipulator.test.ts +++ b/packages/grafana-data/src/themes/colorManipulator.test.ts @@ -437,49 +437,4 @@ describe('utils/colorManipulator', () => { expect(onBackground('rgba(0,0,255,1)', 'rgba(0,0,0,0.5)').toRgbString()).toBe('rgb(0, 0, 255)'); }); }); - - describe('colorAtGradientPercent', () => { - it('should calculate the color at a given percent in a gradient of two colors', () => { - const gradient = [ - { color: '#ff0000', percent: 0 }, - { color: '#0000ff', percent: 1 }, - ]; - expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); - expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040'); - expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080'); - expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf'); - expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); - }); - - it('should calculate the color at a given percent in a gradient of multiple colors', () => { - const gradient = [ - { color: '#ff0000', percent: 0 }, - { color: '#00ff00', percent: 0.5 }, - { color: '#0000ff', percent: 1 }, - ]; - expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); - expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); - expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); - expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); - expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); - }); - - it('should not throw an error when percent is outside 0-1 range', () => { - const gradient = [ - { color: '#ff0000', percent: 0 }, - { color: '#0000ff', percent: 1 }, - ]; - expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000'); - expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff'); - }); - - it('should throw an error when less than two stops are provided', () => { - expect(() => { - colorAtGradientPercent([], 0.5); - }).toThrow('colorAtGradientPercent requires at least two color stops'); - expect(() => { - colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5); - }).toThrow('colorAtGradientPercent requires at least two color stops'); - }); - }); }); diff --git a/packages/grafana-data/src/themes/colorManipulator.ts b/packages/grafana-data/src/themes/colorManipulator.ts index 844671d50da..eccb661b7d8 100644 --- a/packages/grafana-data/src/themes/colorManipulator.ts +++ b/packages/grafana-data/src/themes/colorManipulator.ts @@ -4,8 +4,6 @@ import tinycolor from 'tinycolor2'; -import { GradientStop } from './types'; - /** * Returns a number whose value is limited to the given range. * @param value The value to be clamped @@ -394,53 +392,6 @@ export const onBackground = ( }); }; -/** - * @alpha - * Given color stops (each with a color and percentage 0..1) returns the color at a given percentage. - * Uses tinycolor.mix for interpolation. - * @params stops - array of color stops (percentages 0..1) - * @params percent - percentage 0..1 - * @returns color at the given percentage - */ -export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { - if (!stops || stops.length < 2) { - throw new Error('colorAtGradientPercent requires at least two color stops'); - } - - // normalize and sort stops by percent - const sorted = stops - .map((s) => ({ color: s.color, percent: clamp(s.percent, 0, 1) })) - .sort((a, b) => a.percent - b.percent); - - // percent outside range - if (percent <= sorted[0].percent) { - return tinycolor(sorted[0].color); - } - if (percent >= sorted[sorted.length - 1].percent) { - return tinycolor(sorted[sorted.length - 1].color); - } - - // find surrounding stops - let left = sorted[0]; - let right = sorted[sorted.length - 1]; - for (let i = 1; i < sorted.length; i++) { - if (percent <= sorted[i].percent) { - left = sorted[i - 1]; - right = sorted[i]; - break; - } - } - - const range = right.percent - left.percent; - const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1 - - // tinycolor.mix expects amount as percentage of the second color - const mixed = tinycolor.mix(left.color, right.color, t * 100); - - // return hex6 if opaque, hex8 if has alpha - return mixed; -} - interface DecomposeColor { type: string; values: any; @@ -463,5 +414,4 @@ export const colorManipulator = { darken, lighten, onBackground, - colorAtGradientPercent, }; diff --git a/packages/grafana-data/src/themes/types.ts b/packages/grafana-data/src/themes/types.ts index 93f34705522..f586937cf3c 100644 --- a/packages/grafana-data/src/themes/types.ts +++ b/packages/grafana-data/src/themes/types.ts @@ -59,9 +59,3 @@ export interface ThemeRichColor { export type DeepPartial = { [P in keyof T]?: DeepPartial; }; - -/** @alpha */ -export interface GradientStop { - color: string; - percent: number; -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index 128566cec97..b08085af48e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,18 +1,17 @@ import { useId, useMemo, memo } from 'react'; -import { DisplayProcessor, FieldDisplay } from '@grafana/data'; +import { FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; -import { RadialGradientMode, RadialShape } from './RadialGauge'; import { buildGradientColors, getEndpointColors, getGradientCss, getGuideDotColors } from './colors'; -import { drawRadialArcPath, GaugeDimensions, toRad } from './utils'; +import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types'; +import { drawRadialArcPath, toRad } from './utils'; export interface RadialArcPathPropsBase { arcLengthDeg: number; color?: string; - dimensions: GaugeDimensions; - displayProcessor: DisplayProcessor; + dimensions: RadialGaugeDimensions; fieldDisplay: FieldDisplay; glowFilter?: string; gradientMode: RadialGradientMode; @@ -38,7 +37,6 @@ export const RadialArcPath = memo( arcLengthDeg, color, dimensions, - displayProcessor, fieldDisplay, glowFilter, gradientMode, @@ -54,8 +52,8 @@ export const RadialArcPath = memo( if (gradientMode === 'none') { return []; } - return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color); - }, [gradientMode, fieldDisplay, theme, displayProcessor]); + return buildGradientColors(gradientMode, theme, fieldDisplay, fieldDisplay.display.color); + }, [gradientMode, fieldDisplay, theme]); const { guideDotColors, endpointColors } = useMemo(() => { if (!showGuideDots || gradientStops.length === 0) { @@ -67,7 +65,7 @@ export const RadialArcPath = memo( return { guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0), endpointColors: - shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0) : undefined, + shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 1) : undefined, }; }, [showGuideDots, fieldDisplay, gradientStops, shape]); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index 786c506af00..cf2a50b55cb 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,16 +1,14 @@ -import { DisplayProcessor, FieldDisplay } from '@grafana/data'; +import { FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialGradientMode, RadialShape } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types'; export interface RadialBarProps { angle: number; angleRange: number; - dimensions: GaugeDimensions; - displayProcessor: DisplayProcessor; + dimensions: RadialGaugeDimensions; fieldDisplay: FieldDisplay; glowFilter?: string; gradientMode: RadialGradientMode; @@ -22,7 +20,6 @@ export function RadialBar({ angle, angleRange, dimensions, - displayProcessor, fieldDisplay, glowFilter, gradientMode, @@ -36,10 +33,9 @@ export function RadialBar({ {/** Track */} { + const theme = useTheme2(); - for (let i = 0; i < segmentCountAdjusted; i++) { - const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - let segmentColor: string | undefined; - if (angleValue >= value) { - segmentColor = theme.colors.action.hover; - } else if (gradientMode === 'none') { - segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR; + const segments: React.ReactNode[] = []; + const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const value = fieldDisplay.display.numeric; + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); + const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; + + for (let i = 0; i < segmentCountAdjusted; i++) { + const angleValue = min + ((max - min) / segmentCountAdjusted) * i; + const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; + let segmentColor: string | undefined; + if (angleValue >= value) { + segmentColor = theme.colors.action.hover; + } else if (gradientMode === 'none') { + segmentColor = getFieldDisplayProcessor(fieldDisplay)(angleValue).color ?? FALLBACK_COLOR; + } + + segments.push( + + ); } - segments.push( - - ); + return {segments}; } +); - return {segments}; -} - -export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { - // Max spacing is 8 degrees between segments - // Changing this constant could be considered a breaking change - const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); - return segmentSpacing * maxAngleBetweenSegments; -} - -function getOptimalSegmentCount( - dimensions: GaugeDimensions, - segmentSpacing: number, - segmentCount: number, - range: number -) { - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); - - const innerRadius = dimensions.radius - dimensions.barWidth / 2; - const circumference = Math.PI * innerRadius * 2 * (range / 360); - const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); - - return Math.min(maxSegments, segmentCount); -} +RadialBarSegmented.displayName = 'RadialBarSegmented'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx index 79d8b9444e7..416594a2901 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx @@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema'; import { useTheme2 } from '../../themes/ThemeContext'; import { Stack } from '../Layout/Stack/Stack'; -import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge'; +import { RadialGauge, RadialGaugeProps } from './RadialGauge'; +import { RadialGradientMode, RadialShape, RadialTextMode } from './types'; interface StoryProps extends RadialGaugeProps { value: number; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 0771a52f9d1..7cf4ab3fa76 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -1,14 +1,7 @@ import { css, cx } from '@emotion/css'; -import { isNumber } from 'lodash'; import { useId } from 'react'; -import { - DisplayValueAlignmentFactors, - FieldDisplay, - getDisplayProcessor, - GrafanaTheme2, - TimeRange, -} from '@grafana/data'; +import { DisplayValueAlignmentFactors, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; @@ -21,6 +14,7 @@ import { RadialSparkline } from './RadialSparkline'; import { RadialText } from './RadialText'; import { ThresholdsBar } from './ThresholdsBar'; import { GlowGradient, MiddleCircleGlow } from './effects'; +import { RadialGradientMode, RadialShape, RadialTextMode } from './types'; import { calculateDimensions, getValueAngleForValue } from './utils'; export interface RadialGaugeProps { @@ -72,10 +66,6 @@ export interface RadialGaugeProps { timeRange?: TimeRange; } -export type RadialGradientMode = 'none' | 'auto'; -export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; -export type RadialShape = 'circle' | 'gauge'; - /** * https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs */ @@ -130,7 +120,6 @@ export function RadialGauge(props: RadialGaugeProps) { showScaleLabels ); - const displayProcessor = getFieldDisplayProcessor(displayValue); const glowFilterId = `glow-${gaugeId}`; if (segmentCount > 1) { @@ -146,7 +135,6 @@ export function RadialGauge(props: RadialGaugeProps) { segmentSpacing={segmentSpacing} shape={shape} gradientMode={gradient} - displayProcessor={displayProcessor} /> ); } else { @@ -161,7 +149,6 @@ export function RadialGauge(props: RadialGaugeProps) { glowFilter={`url(#${glowFilterId})`} shape={shape} gradientMode={gradient} - displayProcessor={displayProcessor} fieldDisplay={displayValue} /> ); @@ -224,7 +211,6 @@ export function RadialGauge(props: RadialGaugeProps) { glowFilter={`url(#${glowFilterId})`} shape={shape} gradientMode={gradient} - displayProcessor={displayProcessor} /> ); } @@ -270,17 +256,6 @@ export function RadialGauge(props: RadialGaugeProps) { ); } -function getFieldDisplayProcessor(displayValue: FieldDisplay) { - if (displayValue.view && isNumber(displayValue.colIndex)) { - const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); - if (dp) { - return dp; - } - } - - return getDisplayProcessor(); -} - function getStyles(theme: GrafanaTheme2) { return { vizWrapper: css({ diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx index 994b3b35eac..6588150602a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx @@ -1,87 +1,84 @@ +import { memo } from 'react'; + import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data'; import { t } from '@grafana/i18n'; import { measureText } from '../../utils/measureText'; -import { GaugeDimensions, toCartesian } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { getFieldConfigMinMax, toCartesian } from './utils'; interface RadialScaleLabelsProps { fieldDisplay: FieldDisplay; theme: GrafanaTheme2; thresholds: Threshold[]; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; startAngle: number; endAngle: number; angleRange: number; } -export function RadialScaleLabels({ - fieldDisplay, - thresholds, - theme, - dimensions, - startAngle, - endAngle, - angleRange, -}: RadialScaleLabelsProps) { - const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; +const LINE_HEIGHT_FACTOR = 1.2; - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; +export const RadialScaleLabels = memo( + ({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => { + const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; + const [min, max] = getFieldConfigMinMax(fieldDisplay); - const fontSize = scaleLabelsFontSize; - const textLineHeight = scaleLabelsFontSize * 1.2; - const radius = scaleLabelsRadius - textLineHeight; + const fontSize = scaleLabelsFontSize; + const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR; + const radius = scaleLabelsRadius - textLineHeight; - function getTextPosition(text: string, value: number, index: number) { - const isLast = index === thresholds.length - 1; - const isFirst = index === 0; + function getTextPosition(text: string, value: number, index: number) { + const isLast = index === thresholds.length - 1; + const isFirst = index === 0; - let valueDeg = ((value - min) / (max - min)) * angleRange; - let finalAngle = startAngle + valueDeg; + let valueDeg = ((value - min) / (max - min)) * angleRange; + let finalAngle = startAngle + valueDeg; - // Now adjust the final angle based on the label text width and the labels position on the arc - let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); - let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; + // Now adjust the final angle based on the label text width and the labels position on the arc + let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); + let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; - // the centering is different for gauge or circle shapes for some reason - finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; + // the centering is different for gauge or circle shapes for some reason + finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; - // For circle gauges we need to shift the first label more - if (isFirst) { - finalAngle += textWidthAngle; + // For circle gauges we need to shift the first label more + if (isFirst) { + finalAngle += textWidthAngle; + } + + // For circle gauges we need to shift the last label more + if (isLast && endAngle === 360) { + finalAngle -= textWidthAngle; + } + + const position = toCartesian(centerX, centerY, radius, finalAngle); + + return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; } - // For circle gauges we need to shift the last label more - if (isLast && endAngle === 360) { - finalAngle -= textWidthAngle; - } - - const position = toCartesian(centerX, centerY, radius, finalAngle); - - return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; + return ( + + {thresholds.map((threshold, index) => { + const labelPos = getTextPosition(String(threshold.value), threshold.value, index); + return ( + + {threshold.value} + + ); + })} + + ); } +); - return ( - - {thresholds.map((threshold, index) => { - const labelPos = getTextPosition(String(threshold.value), threshold.value, index); - - return ( - - {threshold.value} - - ); - })} - - ); -} +RadialScaleLabels.displayName = 'RadialScaleLabels'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index acb255a3f3e..4603426e8f9 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -3,17 +3,25 @@ import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana import { Sparkline } from '../Sparkline/Sparkline'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialSparklineProps { sparkline: FieldDisplay['sparkline']; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; theme: GrafanaTheme2; color?: string; shape?: RadialShape; textMode: Exclude; } + +const SPARKLINE_HEIGHT_DIVISOR = 4; +const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4; +const SPARKLINE_WIDTH_FACTOR_ARC = 1.4; +const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3; +const SPARKLINE_SPACING = 8; + export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) { const { radius, barWidth } = dimensions; @@ -22,12 +30,12 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape, te } const showNameAndValue = textMode === 'value_and_name'; - const height = radius / (showNameAndValue ? 4 : 3); - const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth; + const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR); + const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth; const topPos = shape === 'gauge' - ? `${dimensions.gaugeBottomY - height}px` - : `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`; + ? dimensions.gaugeBottomY - height - SPARKLINE_SPACING + : `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`; const config: FieldConfig = { color: { diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index 51a1c64c842..c1e9b0a616e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { memo } from 'react'; import { DisplayValue, @@ -11,13 +12,12 @@ import { import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialTextProps { displayValue: DisplayValue; theme: GrafanaTheme2; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; textMode: Exclude; shape: RadialShape; sparkline?: FieldSparkline; @@ -26,123 +26,131 @@ interface RadialTextProps { nameManualFontSize?: number; } -export function RadialText({ - displayValue, - theme, - dimensions, - textMode, - shape, - sparkline, - alignmentFactors, - valueManualFontSize, - nameManualFontSize, -}: RadialTextProps) { - const styles = useStyles2(getStyles); - const { centerX, centerY, radius, barWidth } = dimensions; +const LINE_HEIGHT_FACTOR = 1.21; +const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82; +const NAME_TO_HEIGHT_FACTOR = 0.45; +const LARGE_RADIUS_SCALING_DECAY = 0.86; +const MAX_TEXT_WIDTH_DIVISOR = 7; +const MAX_NAME_HEIGHT_DIVISOR = 4; +const VALUE_SPACE_PERCENTAGE = 0.7; +const SPARKLINE_SPACING = 8; +const MIN_UNIT_FONT_SIZE = 5; - if (textMode === 'none') { - return null; - } +export const RadialText = memo( + ({ + displayValue, + theme, + dimensions, + textMode, + shape, + sparkline, + alignmentFactors, + valueManualFontSize, + nameManualFontSize, + }: RadialTextProps) => { + const styles = useStyles2(getStyles); + const { centerX, centerY, radius, barWidth } = dimensions; - const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; - const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); + if (textMode === 'none') { + return null; + } - const showValue = textMode === 'value' || textMode === 'value_and_name'; - const showName = textMode === 'name' || textMode === 'value_and_name'; - const maxTextWidth = radius * 2 - barWidth - radius / 7; + const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; + const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); - // Not sure where this comes from but svg text is not using body line-height - const lineHeight = 1.21; - const valueWidthToRadiusFactor = 0.82; - const nameToHeightFactor = 0.45; - const largeRadiusScalingDecay = 0.86; + const showValue = textMode === 'value' || textMode === 'value_and_name'; + const showName = textMode === 'name' || textMode === 'value_and_name'; + const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR; - // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels - let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - let maxNameHeight = radius / 4; + // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels + let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR; - if (showValue && showName) { - maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay); - } + if (showValue && showName) { + maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + } - const valueFontSize = - valueManualFontSize ?? - calculateFontSize( - valueToAlignTo, - maxTextWidth, - maxValueHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const valueFontSize = + valueManualFontSize ?? + calculateFontSize( + valueToAlignTo, + maxTextWidth, + maxValueHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ); + + const nameFontSize = + nameManualFontSize ?? + calculateFontSize( + nameToAlignTo, + maxTextWidth, + maxNameHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ); + + const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE); + const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR; + const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR; + + const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY; + const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY; + const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; + const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2; + + // adjust the text up on gauges and when sparklines are present + let yOffset = 0; + if (shape === 'gauge') { + // we render from the center of the gauge, so move up by half of half of the total height + yOffset -= (valueHeight + nameHeight) / 4; + } + if (sparkline) { + yOffset -= SPARKLINE_SPACING; + } + + return ( + + {showValue && ( + + {displayValue.prefix ?? ''} + {displayValue.text} + + {displayValue.suffix ?? ''} + + + )} + {showName && ( + + {displayValue.title} + + )} + ); - - const nameFontSize = - nameManualFontSize ?? - calculateFontSize( - nameToAlignTo, - maxTextWidth, - maxNameHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight - ); - - const unitFontSize = Math.max(valueFontSize * 0.7, 5); - const valueHeight = valueFontSize * lineHeight; - const nameHeight = nameFontSize * lineHeight; - - const valueY = showName ? centerY - nameHeight * 0.3 : centerY; - const nameY = showValue ? valueY + valueHeight * 0.7 : centerY; - const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; - const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2; - - // adjust the text up on gauges and when sparklines are present - let yOffset = 0; - if (shape === 'gauge') { - // we render from the center of the gauge, so move up by half of half of the total height - yOffset -= (valueHeight + nameHeight) / 4; - } - if (sparkline) { - yOffset -= 8; } +); - return ( - - {showValue && ( - - {displayValue.prefix ?? ''} - {displayValue.text} - - {displayValue.suffix ?? ''} - - - )} - {showName && ( - - {displayValue.title} - - )} - - ); -} +RadialText.displayName = 'RadialText'; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (_theme: GrafanaTheme2) => ({ text: css({ verticalAlign: 'bottom', }), diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 784e14aa397..5db86596dbe 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,11 +1,11 @@ -import { DisplayProcessor, FieldDisplay, Threshold } from '@grafana/data'; +import { FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; -import { RadialGradientMode, RadialShape } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialGaugeDimensions, RadialGradientMode, RadialShape } from './types'; +import { getFieldConfigMinMax } from './utils'; -export interface Props { - dimensions: GaugeDimensions; +interface ThresholdsBarProps { + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; endAngle: number; @@ -15,8 +15,8 @@ export interface Props { glowFilter?: string; thresholds: Threshold[]; gradientMode: RadialGradientMode; - displayProcessor: DisplayProcessor; } + export function ThresholdsBar({ dimensions, fieldDisplay, @@ -27,18 +27,15 @@ export function ThresholdsBar({ thresholds, shape, gradientMode, - displayProcessor, -}: Props) { - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; - +}: ThresholdsBarProps) { const thresholdDimensions = { ...dimensions, barWidth: dimensions.thresholdsBarWidth, radius: dimensions.thresholdsBarRadius, }; + const [min, max] = getFieldConfigMinMax(fieldDisplay); + let currentStart = startAngle; let paths: React.ReactNode[] = []; @@ -52,21 +49,21 @@ export function ThresholdsBar({ valueDeg = 0; } - let lengthDeg = valueDeg - currentStart + startAngle; + const lengthDeg = valueDeg - currentStart + startAngle; + const color = gradientMode === 'none' ? threshold.color : undefined; paths.push( ); diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..db4c1c40882 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts new file mode 100644 index 00000000000..f72f050e51a --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts @@ -0,0 +1,286 @@ +import { defaultsDeep } from 'lodash'; + +import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { + buildGradientColors, + colorAtGradientPercent, + getEndpointColors, + getGradientCss, + getGuideDotColors, +} from './colors'; + +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +describe('RadialGauge color utils', () => { + describe('buildGradientColors', () => { + const createField = (colorMode: FieldColorModeId): Field => + ({ + type: FieldType.number, + name: 'Test Field', + config: { + color: { + mode: colorMode, + }, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 50, color: 'yellow' }, + { value: 80, color: 'red' }, + ], + }, + }, + values: [70, 40, 30, 90, 55], + }) satisfies Field; + + const buildFieldDisplay = (field: Field, part = {}): FieldDisplay => + defaultsDeep(part, { + field: field.config, + colIndex: 0, + view: { + getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: undefined }))), + }, + display: { + numeric: 75, + }, + }); + + it('should return the baseColor if gradientMode is none', () => { + expect( + buildGradientColors('none', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + }); + + it('uses the fallback color if no baseColor is set', () => { + expect( + buildGradientColors('none', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed))) + ).toEqual([ + { color: FALLBACK_COLOR, percent: 0 }, + { color: FALLBACK_COLOR, percent: 1 }, + ]); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors( + 'auto', + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.Thresholds), { + view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) }, + }) + ) + ).toEqual([ + { color: '#444444', percent: 0 }, + { color: '#FADE2A', percent: 0.5 }, + { color: '#F2495C', percent: 0.8 }, + { color: '#444444', percent: 1 }, + ]); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors( + 'auto', + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.Thresholds)), + '#FF0000' + ) + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FADE2A', percent: 0.5 }, + { color: '#F2495C', percent: 0.8 }, + { color: '#FF0000', percent: 1 }, + ]); + }); + + it('should return gradient colors for continuous color modes', () => { + expect( + buildGradientColors( + 'auto', + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)), + '#00FF00' + ) + ).toEqual([ + { + color: 'rgb(0, 32, 81)', + percent: 0, + }, + { + color: 'rgb(17, 54, 108)', + percent: 0.125, + }, + { + color: 'rgb(60, 77, 110)', + percent: 0.25, + }, + { + color: 'rgb(98, 100, 111)', + percent: 0.375, + }, + { + color: 'rgb(127, 124, 117)', + percent: 0.5, + }, + { + color: 'rgb(154, 148, 120)', + percent: 0.625, + }, + { + color: 'rgb(187, 175, 113)', + percent: 0.75, + }, + { + color: 'rgb(226, 203, 92)', + percent: 0.875, + }, + { + color: 'rgb(253, 234, 69)', + percent: 1, + }, + ]); + }); + + it('should return gradient colors for by-value color modes', () => { + expect( + buildGradientColors('auto', createTheme(), buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues))) + ).toEqual([ + { color: '#181b1f', percent: 0 }, + { color: '#1F60C4', percent: 1 }, + ]); + }); + + it('should return gradient colors for fixed color mode', () => { + expect( + buildGradientColors('auto', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#442299') + ).toEqual([ + { color: '#14175a', percent: 0 }, + { color: '#a146da', percent: 1 }, + ]); + }); + }); + + describe('colorAtGradientPercent', () => { + it('should calculate the color at a given percent in a gradient of two colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should calculate the color at a given percent in a gradient of multiple colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should not throw an error when percent is outside 0-1 range', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff'); + }); + + it('should throw an error when less than two stops are provided', () => { + expect(() => { + colorAtGradientPercent([], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + expect(() => { + colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + }); + }); + + describe('getEndpointColors', () => { + it('should return the first and last colors in the gradient', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getEndpointColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#0000ff'); + }); + + it('should return the correct end color based on percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getEndpointColors(gradient, 0.25); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#808000'); + }); + + it('should handle gradients with only one colors', () => { + const gradient = [{ color: '#ff0000', percent: 0 }]; + const [startColor, endColor] = getEndpointColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#ff0000'); + }); + + it('should throw an error when no colors are provided', () => { + expect(() => { + getEndpointColors([]); + }).toThrow('getEndpointColors requires at least one color stop'); + }); + }); + + describe('getGradientCss', () => { + it('should return conic-gradient CSS for circle shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'circle'); + expect(css).toBe('conic-gradient(from 0deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + + it('should return linear-gradient CSS for arc shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'gauge'); + expect(css).toBe('linear-gradient(90deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + }); + + describe('getGuideDotColors', () => { + it('should return contrasting guide dot colors based on the gradient endpoints and percent', () => { + const gradient = [ + { color: '#000000', percent: 0 }, + { color: '#ffffff', percent: 0.5 }, + { color: '#ffffff', percent: 1 }, + ]; + const [startDotColor, endDotColor] = getGuideDotColors(gradient, 0.35); + expect(startDotColor).toBe('#fbfbfb'); + expect(endDotColor).toBe('#111217'); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index e1d323b88a5..c390e57d09d 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -1,25 +1,16 @@ import tinycolor from 'tinycolor2'; -import { - colorManipulator, - DisplayProcessor, - FALLBACK_COLOR, - FieldDisplay, - getFieldColorMode, - GradientStop, - GrafanaTheme2, -} from '@grafana/data'; +import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema'; -import { RadialGradientMode, RadialShape } from './RadialGauge'; +import { GradientStop, RadialGradientMode, RadialShape } from './types'; +import { getFieldConfigMinMax, getFieldDisplayProcessor } from './utils'; export function buildGradientColors( gradientMode: RadialGradientMode, theme: GrafanaTheme2, - displayProcessor: DisplayProcessor, fieldDisplay: FieldDisplay, - baseColor = FALLBACK_COLOR, - forSegment?: boolean + baseColor = FALLBACK_COLOR ): GradientStop[] { if (gradientMode === 'none') { return [ @@ -30,13 +21,14 @@ export function buildGradientColors( const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); + // thresholds get special handling if (colorMode.id === FieldColorModeId.Thresholds) { + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); + const [min, max] = getFieldConfigMinMax(fieldDisplay); const thresholds = fieldDisplay.field.thresholds?.steps ?? []; - const min = fieldDisplay.field.min ?? 0; - const max = fieldDisplay.field.max ?? 100; const result: Array<{ color: string; percent: number }> = [ - { color: displayProcessor(min).color ?? FALLBACK_COLOR, percent: 0 }, + { color: displayProcessor(min).color ?? baseColor, percent: 0 }, ]; for (const threshold of thresholds) { @@ -51,15 +43,15 @@ export function buildGradientColors( return result; } - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - // Handle continuous color modes first + // Handle continuous color modes before other by-value modes + if (colorMode.isContinuous && colorMode.getColors) { const colors = colorMode.getColors(theme); return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); } + // For value-based colors, we want to stay more true to the specific color, + // so a radial gradient that adds a bit of light and shade works best if (colorMode.isByValue) { - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best const darkerColor = tinycolor(baseColor).darken(5); const lighterColor = tinycolor(baseColor).spin(20).lighten(10); @@ -73,8 +65,7 @@ export function buildGradientColors( ]; } - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best + // for fixed colors and other modes, we create a simple two-color gradient // we set the highest contrast color second based on the theme. const darkerColor = tinycolor(baseColor).spin(-20).darken(5); const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10); @@ -89,13 +80,74 @@ export function buildGradientColors( ]; } -export function getEndpointColors(gradientStops: GradientStop[], percent = 0): [string, string] { +function clamp(value: number, min = 0, max = 1) { + if (process.env.NODE_ENV !== 'production') { + if (value < min || value > max) { + console.error(`The value provided ${value} is out of range [${min}, ${max}].`); + } + } + + return Math.min(Math.max(min, value), max); +} + +/** + * @alpha - perhaps this should go in colorManipulator.ts + * Given color stops (each with a color and percentage 0..1) returns the color at a given percentage. + * Uses tinycolor.mix for interpolation. + * @params stops - array of color stops (percentages 0..1) + * @params percent - percentage 0..1 + * @returns color at the given percentage + */ +export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { + if (!stops || stops.length < 2) { + throw new Error('colorAtGradientPercent requires at least two color stops'); + } + + // normalize and sort stops by percent + const sorted = stops + .map((s) => ({ color: s.color, percent: clamp(s.percent, 0, 1) })) + .sort((a, b) => a.percent - b.percent); + + // percent outside range + if (percent <= sorted[0].percent) { + return tinycolor(sorted[0].color); + } + if (percent >= sorted[sorted.length - 1].percent) { + return tinycolor(sorted[sorted.length - 1].color); + } + + // find surrounding stops + let left = sorted[0]; + let right = sorted[sorted.length - 1]; + for (let i = 1; i < sorted.length; i++) { + if (percent <= sorted[i].percent) { + left = sorted[i - 1]; + right = sorted[i]; + break; + } + } + + const range = right.percent - left.percent; + const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1 + + // tinycolor.mix expects amount as percentage of the second color + const mixed = tinycolor.mix(left.color, right.color, t * 100); + + // return hex6 if opaque, hex8 if has alpha + return mixed; +} + +export function getEndpointColors(gradientStops: GradientStop[], percent = 1): [string, string] { + if (gradientStops.length === 0) { + throw new Error('getEndpointColors requires at least one color stop'); + } + const startColor = gradientStops[0].color; let endColor = gradientStops[gradientStops.length - 1].color; // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates if (gradientStops.length >= 2) { - const endColorByPercentage = colorManipulator.colorAtGradientPercent(gradientStops, percent); + const endColorByPercentage = colorAtGradientPercent(gradientStops, percent); endColor = endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); } @@ -109,14 +161,18 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape : `linear-gradient(90deg, ${colorStrings.join(', ')})`; } +// the theme does not make the full palette available to us, and we +// don't want transparent colors which our grays usually have. +const GRAY_05 = '#111217'; +const GRAY_90 = '#fbfbfb'; const CONTRAST_THRESHOLD_MAX = 4.5; const getGuideDotColor = (color: string): string => { - const darkColor = '#111217'; // gray05 - const lightColor = '#fbfbfb'; // gray90 + const darkColor = GRAY_05; + const lightColor = GRAY_90; return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; }; -export function getGuideDotColors(gradientStops: GradientStop[], percent = 0): [string, string] { +export function getGuideDotColors(gradientStops: GradientStop[], percent: number): [string, string] { const [startColor, endColor] = getEndpointColors(gradientStops, percent); return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; } diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 0f1163e21de..c8f7afb542f 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -1,4 +1,4 @@ -import { GaugeDimensions } from './utils'; +import { RadialGaugeDimensions } from './types'; export interface GlowGradientProps { id: string; @@ -27,7 +27,7 @@ const CENTER_GLOW_OPACITY = 0.15; export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { return ( - + @@ -35,7 +35,7 @@ export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: } export interface CenterGlowProps { - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; gaugeId: string; color?: string; } @@ -46,7 +46,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps return ( <> - + diff --git a/packages/grafana-ui/src/components/RadialGauge/types.ts b/packages/grafana-ui/src/components/RadialGauge/types.ts new file mode 100644 index 00000000000..67084a3fb63 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/types.ts @@ -0,0 +1,26 @@ +export type RadialGradientMode = 'none' | 'auto'; +export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; +export type RadialShape = 'circle' | 'gauge'; + +export interface RadialGaugeDimensions { + margin: number; + radius: number; + centerX: number; + centerY: number; + barWidth: number; + endAngle?: number; + barIndex: number; + thresholdsBarRadius: number; + thresholdsBarWidth: number; + thresholdsBarSpacing: number; + scaleLabelsFontSize: number; + scaleLabelsSpacing: number; + scaleLabelsRadius: number; + gaugeBottomY: number; +} + +/** @alpha - perhaps this should go in @grafana/data */ +export interface GradientStop { + color: string; + percent: number; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts index 5e3f34d62cd..c153faae173 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts @@ -1,24 +1,111 @@ -import { FieldDisplay } from '@grafana/data'; +import { DataFrameView, FieldDisplay } from '@grafana/data'; import type { RadialGaugeProps } from './RadialGauge'; -import { calculateDimensions, toRad, getValueAngleForValue } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { + calculateDimensions, + toRad, + getValueAngleForValue, + drawRadialArcPath, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getAngleBetweenSegments, + getOptimalSegmentCount, +} from './utils'; describe('RadialGauge utils', () => { - function calc(overrides: Partial = {}) { - return calculateDimensions( - overrides.width ?? 200, - overrides.height ?? 200, - overrides.shape === 'gauge' ? 110 : 360, - overrides.glowBar ?? false, - overrides.roundedBars ?? false, - overrides.barWidthFactor ?? 0.4, - overrides.barIndex ?? 0, - overrides.thresholdsBar ?? false, - overrides.showScaleLabels ?? false - ); - } + describe('getFieldDisplayProcessor', () => { + it('should return display processor from view when available', () => { + const mockProcessor = jest.fn(); + const mockView = { + getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor), + } as unknown as DataFrameView; + + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: mockView, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBe(mockProcessor); + expect(mockView.getFieldDisplayProcessor).toHaveBeenCalledWith(0); + }); + + it('should return default display processor when view is not available', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBeDefined(); + expect(typeof dp).toBe('function'); + }); + }); + + describe('getFieldConfigMinMax', () => { + it('should return min and max from field config when defined', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: { min: 10, max: 90 }, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(10); + expect(max).toBe(90); + }); + + it('should return default min and max when not defined in field config', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(0); + expect(max).toBe(100); + }); + }); describe('calculateDimensions', () => { + function calc(overrides: Partial = {}) { + return calculateDimensions( + overrides.width ?? 200, + overrides.height ?? 200, + overrides.shape === 'gauge' ? 110 : 360, + overrides.glowBar ?? false, + overrides.roundedBars ?? false, + overrides.barWidthFactor ?? 0.4, + overrides.barIndex ?? 0, + overrides.thresholdsBar ?? false, + overrides.showScaleLabels ?? false + ); + } + it('should calculate basic dimensions for a square gauge', () => { const result = calc(); @@ -194,4 +281,86 @@ describe('RadialGauge utils', () => { expect(result.angle).toBe(240); }); }); + + describe('drawRadialArcPath', () => { + const defaultDims: RadialGaugeDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }); + + it.each([ + { description: 'quarter arc', startAngle: 0, endAngle: 90 }, + { description: 'half arc', startAngle: 0, endAngle: 180 }, + { description: 'three quarter arc', startAngle: 0, endAngle: 270 }, + { description: 'rounded bars', startAngle: 0, endAngle: 270, roundedBars: true }, + { description: 'wide bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 50 } }, + { description: 'narrow bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 5 } }, + { description: 'narrow radius', startAngle: 0, endAngle: 180, dimensions: { radius: 50 } }, + { + description: 'center x and y', + startAngle: 0, + endAngle: 360, + roundedBars: true, + dimensions: { centerX: 150, centerY: 200 }, + }, + ])(`should draw correct path for $description`, ({ startAngle, endAngle, dimensions, roundedBars }) => { + const path = drawRadialArcPath(startAngle, endAngle, { ...defaultDims, ...dimensions }, roundedBars); + expect(path).toMatchSnapshot(); + }); + + describe('edge cases', () => { + it('should adjust 360deg or greater arcs to avoid SVG rendering issues', () => { + expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims)); + expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims)); + }); + + it('should throw an error if inner radius collapses to zero or below', () => { + const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 }; + expect(() => drawRadialArcPath(0, 180, smallRadiusDims)).toThrow( + 'Inner radius collapsed to zero or below, cannot draw radial arc path' + ); + }); + }); + }); + + describe('getAngleBetweenSegments', () => { + it('should calculate angle between segments based on spacing and count', () => { + expect(getAngleBetweenSegments(2, 10, 360)).toBe(48); + expect(getAngleBetweenSegments(5, 15, 180)).toBe(40); + }); + }); + + describe('getOptimalSegmentCount', () => { + it('should adjust segment count based on dimensions and spacing', () => { + const dimensions: RadialGaugeDimensions = { + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }; + + expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8); + expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5); + }); + }); }); diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index a0746ff54de..e682fbbd37d 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -1,11 +1,34 @@ -import { FieldDisplay } from '@grafana/data'; +import { FieldDisplay, getDisplayProcessor } from '@grafana/data'; -export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) { - const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; +import { RadialGaugeDimensions } from './types'; + +export function getFieldDisplayProcessor(displayValue: FieldDisplay) { + if (displayValue.view && displayValue.colIndex != null) { + const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); + if (dp) { + return dp; + } + } + + return getDisplayProcessor(); +} + +export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) { const min = fieldDisplay.field.min ?? 0; const max = fieldDisplay.field.max ?? 100; + return [min, max]; +} - let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange; +export function getValueAngleForValue( + fieldDisplay: FieldDisplay, + startAngle: number, + endAngle: number, + value = fieldDisplay.display.numeric +) { + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; + + let angle = ((value - min) / (max - min)) * angleRange; if (angle > angleRange) { angle = angleRange; @@ -26,24 +49,19 @@ export function toRad(angle: number) { return ((angle - 90) * Math.PI) / 180; } -export interface GaugeDimensions { - margin: number; - radius: number; - centerX: number; - centerY: number; - barWidth: number; - endAngle?: number; - barIndex: number; - thresholdsBarRadius: number; - thresholdsBarWidth: number; - thresholdsBarSpacing: number; - showScaleLabels?: boolean; - scaleLabelsFontSize: number; - scaleLabelsSpacing: number; - scaleLabelsRadius: number; - gaugeBottomY: number; -} - +/** + * returns the calculated dimensions for the radial gauge + * @param width + * @param height + * @param endAngle + * @param glow + * @param roundedBars + * @param barWidthFactor + * @param barIndex + * @param thresholdBar + * @param showScaleLabels + * @returns {RadialGaugeDimensions} + */ export function calculateDimensions( width: number, height: number, @@ -54,7 +72,7 @@ export function calculateDimensions( barIndex: number, thresholdBar?: boolean, showScaleLabels?: boolean -): GaugeDimensions { +): RadialGaugeDimensions { const yMaxAngle = endAngle > 180 ? 180 : endAngle; let margin = 0; @@ -157,26 +175,32 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an } export function drawRadialArcPath( - angle: number, - arcLengthDeg: number, - dimensions: GaugeDimensions, + startAngle: number, + endAngle: number, + dimensions: RadialGaugeDimensions, roundedBars?: boolean ): string { const { radius, centerX, centerY, barWidth } = dimensions; - if (arcLengthDeg === 360) { - // For some reason a 100% full arc cannot be rendered - arcLengthDeg = 359.99; + // For some reason a 100% full arc cannot be rendered + if (endAngle >= 360) { + endAngle = 359.99; } - const startRadians = toRad(angle); - const endRadians = toRad(angle + arcLengthDeg); + const startRadians = toRad(startAngle); + const endRadians = toRad(startAngle + endAngle); - const largeArc = arcLengthDeg > 180 ? 1 : 0; + const largeArc = endAngle > 180 ? 1 : 0; const outerR = radius + barWidth / 2; const innerR = Math.max(0, radius - barWidth / 2); + if (innerR <= 0) { + throw new Error('Inner radius collapsed to zero or below, cannot draw radial arc path'); + } + // get points for both an inner and outer arc. we draw + // the arc entirely with a path's fill instead of using stroke + // so that it can be used as a clip-path. const ox1 = centerX + outerR * Math.cos(startRadians); const oy1 = centerY + outerR * Math.sin(startRadians); const ox2 = centerX + outerR * Math.cos(endRadians); @@ -187,6 +211,7 @@ export function drawRadialArcPath( const ix2 = centerX + innerR * Math.cos(endRadians); const iy2 = centerY + innerR * Math.sin(endRadians); + // calculate the cap width in case we're drawing rounded bars const capR = barWidth / 2; const pathParts = [ @@ -209,27 +234,44 @@ export function drawRadialArcPath( // rounded end cap: small arc connecting outer end to inner end pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); } else { - // straight line to inner end + // straight line to inner end (square butt) pathParts.push('L', ix2, iy2); } - if (innerR <= 0) { - // if inner radius collapsed to center, line to center and close - pathParts.push('L', centerX, centerY, 'Z'); + // inner arc from end back to start (counter-clockwise) + pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); + + if (roundedBars) { + // rounded start cap: small arc connecting inner start back to outer start + pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); } else { - // inner arc from end back to start (counter-clockwise) - pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); - - if (roundedBars) { - // rounded start cap: small arc connecting inner start back to outer start - pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); - } else { - // straight line back to outer start - pathParts.push('L', ox1, oy1); - } - - pathParts.push('Z'); + // straight line back to outer start (square butt) + pathParts.push('L', ox1, oy1); } + pathParts.push('Z'); + return pathParts.join(' '); } + +export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { + // Max spacing is 8 degrees between segments + // Changing this constant could be considered a breaking change + const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); + return segmentSpacing * maxAngleBetweenSegments; +} + +export function getOptimalSegmentCount( + dimensions: RadialGaugeDimensions, + segmentSpacing: number, + segmentCount: number, + range: number +) { + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); + + const innerRadius = dimensions.radius - dimensions.barWidth / 2; + const circumference = Math.PI * innerRadius * 2 * (range / 360); + const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); + + return Math.min(maxSegments, segmentCount); +}