From 87521b03482cca332215ac280050defcd472ba92 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Dec 2025 17:38:26 -0500 Subject: [PATCH 1/6] wip: using clip-path and CSS for drawing the gauge --- .../components/RadialGauge/RadialArcPath.tsx | 139 ++++++++++++++---- .../src/components/RadialGauge/RadialBar.tsx | 2 +- .../RadialGauge/RadialColorDefs.tsx | 54 ++----- .../src/components/RadialGauge/colors.ts | 59 ++++++++ 4 files changed, 183 insertions(+), 71 deletions(-) create mode 100644 packages/grafana-ui/src/components/RadialGauge/colors.ts diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index a6278294029..b1a068c93a6 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,11 +1,18 @@ +import { useId } from 'react'; + +import { colorManipulator } from '@grafana/data'; + +import { RadialColorDefs } from './RadialColorDefs'; import { GaugeDimensions, toRad } from './utils'; export interface RadialArcPathPropsBase { startAngle: number; dimensions: GaugeDimensions; - color: string; - glowFilter?: string; + colorDef: RadialColorDefs; arcLengthDeg: number; + color?: string; + gradient?: string; + glowFilter?: string; roundedBars?: boolean; showGuideDots?: boolean; guideDotStartColor?: string; @@ -22,17 +29,17 @@ type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDo const MAX_DOT_RADIUS = 8; -export function RadialArcPath({ - startAngle: angle, - dimensions, - color, - glowFilter, +function drawRadialArcPath({ + angle, arcLengthDeg, + dimensions, roundedBars, - showGuideDots, - guideDotStartColor, - guideDotEndColor, -}: RadialArcPathProps) { +}: { + angle: number; + dimensions: GaugeDimensions; + arcLengthDeg: number; + roundedBars?: boolean; +}): string { const { radius, centerX, centerY, barWidth } = dimensions; if (arcLengthDeg === 360) { @@ -43,29 +50,111 @@ export function RadialArcPath({ const startRadians = toRad(angle); const endRadians = toRad(angle + arcLengthDeg); + const largeArc = arcLengthDeg > 180 ? 1 : 0; + + const outerR = radius + barWidth / 2; + const innerR = Math.max(0, radius - barWidth / 2); + + const ox1 = centerX + outerR * Math.cos(startRadians); + const oy1 = centerY + outerR * Math.sin(startRadians); + const ox2 = centerX + outerR * Math.cos(endRadians); + const oy2 = centerY + outerR * Math.sin(endRadians); + + const ix1 = centerX + innerR * Math.cos(startRadians); + const iy1 = centerY + innerR * Math.sin(startRadians); + const ix2 = centerX + innerR * Math.cos(endRadians); + const iy2 = centerY + innerR * Math.sin(endRadians); + + const capR = barWidth / 2; + + const pathParts = [ + // start at outer start + 'M', + ox1, + oy1, + // outer arc from start to end (clockwise) + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + ox2, + oy2, + ]; + + if (roundedBars) { + // 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 + 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'); + } 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'); + } + + return pathParts.join(' '); +} + +export function RadialArcPath({ + startAngle: angle, + dimensions, + color, + colorDef, + gradient, + arcLengthDeg, + roundedBars, + showGuideDots, + guideDotStartColor, + guideDotEndColor, +}: RadialArcPathProps) { + const id = useId(); + const { radius, centerX, centerY, barWidth } = dimensions; + + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); + const gradientList = colorDef?.getGradient(); + + const path = drawRadialArcPath({ angle, arcLengthDeg, dimensions, roundedBars }); let x1 = centerX + radius * Math.cos(startRadians); let y1 = centerY + radius * Math.sin(startRadians); let x2 = centerX + radius * Math.cos(endRadians); let y2 = centerY + radius * Math.sin(endRadians); - const largeArc = arcLengthDeg > 180 ? 1 : 0; - - const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' '); const dotRadius = Math.min((barWidth / 2) * 0.4, MAX_DOT_RADIUS); return ( <> - + + + + +
+ + + {roundedBars && gradientList && ( + <> + + {/* this would actually need to be the color determined by the display */} + + + )} {showGuideDots && ( <> {arcLengthDeg > 5 && } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index c5552ad5bab..bb0dac60d45 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -41,7 +41,7 @@ export function RadialBar({ dimensions={dimensions} startAngle={startAngle} arcLengthDeg={angle} - color={colorDefs.getMainBarColor()} + gradient={colorDefs.getGradientDef()} roundedBars={roundedBars} glowFilter={glowFilter} showGuideDots={roundedBars} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx index 9e42d7b1bc2..97b24b99054 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx @@ -1,5 +1,3 @@ -import tinycolor from 'tinycolor2'; - import { colorManipulator, DisplayProcessor, @@ -10,6 +8,7 @@ import { } from '@grafana/data'; import { RadialGradientMode, RadialShape } from './RadialGauge'; +import { buildGradientColors } from './colors'; import { GaugeDimensions } from './utils'; export interface RadialColorDefsOptions { @@ -124,50 +123,15 @@ export class RadialColorDefs { getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> { const { gradient, fieldDisplay, theme } = this.options; - if (gradient === 'none') { - return [ - { color: baseColor, percent: 0 }, - { color: baseColor, percent: 1 }, - ]; - } + return buildGradientColors(gradient, baseColor, theme, fieldDisplay.field.color?.mode, forSegment); + } - const colorModeId = fieldDisplay.field.color?.mode; - const colorMode = getFieldColorMode(colorModeId); - - // Handle continusous color modes first - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - const colors = colorMode.getColors(theme); - return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); - } else 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); - - const color1 = theme.isDark ? lighterColor : darkerColor; - const color2 = theme.isDark ? darkerColor : lighterColor; - - return [ - { color: color1.toString(), percent: 0 }, - { color: color2.toString(), percent: 0.6 }, - { color: color2.toString(), percent: 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 - // 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); - return theme.isDark - ? [ - { color: darkerColor.darken(10).toString(), percent: 0 }, - { color: lighterColor.lighten(10).toString(), percent: 1 }, - ] - : [ - { color: lighterColor.lighten(10).toString(), percent: 0 }, - { color: darkerColor.toString(), percent: 1 }, - ]; + getGradientDef(): string { + const gradientStops = this.getGradient(); + const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); + return this.options.shape === 'circle' + ? `conic-gradient(from -10deg, ${colorStrings.join(', ')})` + : 'linear-gradient(90deg, ' + colorStrings.join(', ') + ')'; } getGuideDotColors(): [string, string] { diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts new file mode 100644 index 00000000000..62a31470ed3 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -0,0 +1,59 @@ +import tinycolor from 'tinycolor2'; + +import { FieldColorModeId, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; + +import { RadialGradientMode } from './RadialGauge'; + +export function buildGradientColors( + gradientMode: RadialGradientMode, + baseColor: string, + theme: GrafanaTheme2, + colorModeId?: FieldColorModeId | string, + forSegment?: boolean +): Array<{ color: string; percent: number }> { + if (gradientMode === 'none') { + return [ + { color: baseColor, percent: 0 }, + { color: baseColor, percent: 1 }, + ]; + } + + const colorMode = getFieldColorMode(colorModeId); + + // TODO we need to return thresholded values here. those will have breakpoints + // which map to exact percentages, and we should show that correctly. + // Handle continuous color modes first + if (colorMode.isContinuous && colorMode.getColors && !forSegment) { + const colors = colorMode.getColors(theme); + return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); + } else 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); + + const color1 = theme.isDark ? lighterColor : darkerColor; + const color2 = theme.isDark ? darkerColor : lighterColor; + + return [ + { color: color1.toString(), percent: 0 }, + { color: color2.toString(), percent: 0.6 }, + { color: color2.toString(), percent: 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 + // 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); + return theme.isDark + ? [ + { color: darkerColor.darken(10).toString(), percent: 0 }, + { color: lighterColor.lighten(10).toString(), percent: 1 }, + ] + : [ + { color: lighterColor.lighten(10).toString(), percent: 0 }, + { color: darkerColor.toString(), percent: 1 }, + ]; +} From 23e6c3301baafe87b3bb66657f8808a681e48ce4 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Dec 2025 20:40:38 -0500 Subject: [PATCH 2/6] wip: overhaul color in gauge --- .../components/RadialGauge/RadialArcPath.tsx | 48 ++++++++++++------- .../src/components/RadialGauge/RadialBar.tsx | 7 +++ .../RadialGauge/RadialBarSegmented.tsx | 9 +++- .../RadialGauge/RadialColorDefs.tsx | 29 +++++------ .../components/RadialGauge/RadialGauge.tsx | 3 ++ .../components/RadialGauge/ThresholdsBar.tsx | 6 ++- .../src/components/RadialGauge/colors.ts | 37 +++++++++++--- 7 files changed, 95 insertions(+), 44 deletions(-) diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index b1a068c93a6..abf142bc46c 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,15 +1,15 @@ import { useId } from 'react'; -import { colorManipulator } from '@grafana/data'; - import { RadialColorDefs } from './RadialColorDefs'; +import { RadialShape } from './RadialGauge'; import { GaugeDimensions, toRad } from './utils'; export interface RadialArcPathPropsBase { startAngle: number; dimensions: GaugeDimensions; - colorDef: RadialColorDefs; + colorDefs: RadialColorDefs; arcLengthDeg: number; + shape: RadialShape; color?: string; gradient?: string; glowFilter?: string; @@ -116,9 +116,10 @@ export function RadialArcPath({ startAngle: angle, dimensions, color, - colorDef, - gradient, + colorDefs, + shape, arcLengthDeg, + glowFilter, roundedBars, showGuideDots, guideDotStartColor, @@ -129,7 +130,7 @@ export function RadialArcPath({ const startRadians = toRad(angle); const endRadians = toRad(angle + arcLengthDeg); - const gradientList = colorDef?.getGradient(); + const [startColor, endColor] = colorDefs.getEndpointColors(); const path = drawRadialArcPath({ angle, arcLengthDeg, dimensions, roundedBars }); let x1 = centerX + radius * Math.cos(startRadians); @@ -144,19 +145,34 @@ export function RadialArcPath({ - -
- + + +
+ + - {roundedBars && gradientList && ( - <> - - {/* this would actually need to be the color determined by the display */} - - - )} {showGuideDots && ( <> + {shape === 'circle' && ( + <> + + + + )} + {arcLengthDeg > 5 && } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index bb0dac60d45..729f60ed630 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -2,6 +2,7 @@ import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; import { RadialColorDefs } from './RadialColorDefs'; +import { RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarProps { @@ -12,6 +13,7 @@ export interface RadialBarProps { startAngle: number; roundedBars?: boolean; glowFilter?: string; + shape: RadialShape; } export function RadialBar({ dimensions, @@ -21,6 +23,7 @@ export function RadialBar({ startAngle, roundedBars, glowFilter, + shape, }: RadialBarProps) { const theme = useTheme2(); const [startDotColor, endDotColor] = colorDefs.getGuideDotColors(); @@ -33,20 +36,24 @@ export function RadialBar({ startAngle={startAngle + angle} dimensions={dimensions} arcLengthDeg={angleRange - angle} + colorDefs={colorDefs} color={theme.colors.action.hover} roundedBars={roundedBars} + shape={shape} /> {/** The colored bar */} {colorDefs.getDefs()} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index dd2ec0ac9bf..dbd668f3a00 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -4,6 +4,7 @@ import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; import { RadialColorDefs } from './RadialColorDefs'; +import { RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarSegmentedProps { @@ -15,6 +16,7 @@ export interface RadialBarSegmentedProps { glowFilter?: string; segmentCount: number; segmentSpacing: number; + shape: RadialShape; } export function RadialBarSegmented({ fieldDisplay, @@ -25,6 +27,7 @@ export function RadialBarSegmented({ segmentCount, segmentSpacing, colorDefs, + shape, }: RadialBarSegmentedProps) { const segments: React.ReactNode[] = []; const theme = useTheme2(); @@ -38,9 +41,9 @@ export function RadialBarSegmented({ for (let i = 0; i < segmentCountAdjusted; i++) { const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const angleColor = colorDefs.getSegmentColor(angleValue, i); + // const angleColor = colorDefs.getSegmentColor(angleValue, i); const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor; + const segmentColor = angleValue >= value ? theme.colors.action.hover : undefined; segments.push( diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx index 97b24b99054..87a6a0d4ba8 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx @@ -122,35 +122,24 @@ export class RadialColorDefs { } getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> { - const { gradient, fieldDisplay, theme } = this.options; - return buildGradientColors(gradient, baseColor, theme, fieldDisplay.field.color?.mode, forSegment); + const { displayProcessor, gradient, fieldDisplay, theme } = this.options; + return buildGradientColors(gradient, baseColor, theme, displayProcessor, fieldDisplay, forSegment); } getGradientDef(): string { const gradientStops = this.getGradient(); const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); return this.options.shape === 'circle' - ? `conic-gradient(from -10deg, ${colorStrings.join(', ')})` + ? `conic-gradient(from 0deg, ${colorStrings.join(', ')})` : 'linear-gradient(90deg, ' + colorStrings.join(', ') + ')'; } - getGuideDotColors(): [string, string] { - const { dimensions, fieldDisplay, shape } = this.options; + getEndpointColors(): [string, string] { + const { fieldDisplay } = this.options; const gradient = this.getGradient(); - let valuePercent = fieldDisplay.display.percent ?? 0; - - // the linear gradient used in circular gradients means that we want to use the - // y position of the edge of the bar to determine the color. If we ever address - // that shortcoming, we could delete this block. - if (shape === 'circle') { - const angleDeg = ((valuePercent - 0.25) % 1) * 360; - const angleRad = (angleDeg * Math.PI) / 180; - const yPos = dimensions.centerY + dimensions.radius * Math.sin(angleRad); - valuePercent = yPos / (dimensions.centerY * 2); - } - - let startColor = gradient[0].color; + const valuePercent = fieldDisplay.display.percent ?? 0; + const startColor = gradient[0].color; let endColor = gradient[gradient.length - 1].color; // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates @@ -161,7 +150,11 @@ export class RadialColorDefs { ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); } + return [startColor, endColor]; + } + getGuideDotColors(): [string, string] { + const [startColor, endColor] = this.getEndpointColors(); return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index d20665872c7..fc03f9845e3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -155,6 +155,7 @@ export function RadialGauge(props: RadialGaugeProps) { segmentCount={segmentCount} segmentSpacing={segmentSpacing} colorDefs={colorDefs} + shape={shape} /> ); } else { @@ -168,6 +169,7 @@ export function RadialGauge(props: RadialGaugeProps) { startAngle={startAngle} roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} + shape={shape} /> ); } @@ -228,6 +230,7 @@ export function RadialGauge(props: RadialGaugeProps) { roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} colorDefs={colorDefs} + shape={shape} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 8977bbb6642..15e5349e8a5 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -2,6 +2,7 @@ import { FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; import { RadialColorDefs } from './RadialColorDefs'; +import { RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface Props { @@ -9,6 +10,7 @@ export interface Props { angleRange: number; startAngle: number; endAngle: number; + shape: RadialShape; fieldDisplay: FieldDisplay; roundedBars?: boolean; glowFilter?: string; @@ -24,6 +26,7 @@ export function ThresholdsBar({ glowFilter, colorDefs, thresholds, + shape, }: Props) { const fieldConfig = fieldDisplay.field; const min = fieldConfig.min ?? 0; @@ -55,10 +58,11 @@ export function ThresholdsBar({ key={i} startAngle={currentStart} arcLengthDeg={lengthDeg} + colorDefs={colorDefs} + shape={shape} dimensions={thresholdDimensions} roundedBars={roundedBars} glowFilter={glowFilter} - color={colorDefs.getColor(threshold.color, i)} /> ); diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index 62a31470ed3..99e23452898 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -1,6 +1,7 @@ import tinycolor from 'tinycolor2'; -import { FieldColorModeId, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; +import { DisplayProcessor, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; import { RadialGradientMode } from './RadialGauge'; @@ -8,7 +9,8 @@ export function buildGradientColors( gradientMode: RadialGradientMode, baseColor: string, theme: GrafanaTheme2, - colorModeId?: FieldColorModeId | string, + displayProcessor: DisplayProcessor, + fieldDisplay: FieldDisplay, forSegment?: boolean ): Array<{ color: string; percent: number }> { if (gradientMode === 'none') { @@ -18,15 +20,36 @@ export function buildGradientColors( ]; } - const colorMode = getFieldColorMode(colorModeId); + const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); + + if (colorMode.id === FieldColorModeId.Thresholds) { + 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 ?? baseColor, percent: 0 }, + ]; + + for (const threshold of thresholds) { + if (threshold.value > min && threshold.value < max) { + const percent = (threshold.value - min) / (max - min); + result.push({ color: threshold.color, percent }); + } + } + + result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 }); + + return result; + } - // TODO we need to return thresholded values here. those will have breakpoints - // which map to exact percentages, and we should show that correctly. - // Handle continuous color modes first if (colorMode.isContinuous && colorMode.getColors && !forSegment) { + // Handle continuous color modes first const colors = colorMode.getColors(theme); return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); - } else if (colorMode.isByValue) { + } + + 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); From 59ec3cc8a9f279e6395374e051513e833014ecc7 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Dec 2025 22:29:53 -0500 Subject: [PATCH 3/6] wip: progress on everything --- .../components/RadialGauge/RadialArcPath.tsx | 3 +- .../src/components/RadialGauge/RadialBar.tsx | 2 - .../RadialGauge/RadialBarSegmented.tsx | 19 ++-- .../RadialGauge/RadialColorDefs.tsx | 100 +----------------- .../components/RadialGauge/RadialGauge.tsx | 2 + .../components/RadialGauge/ThresholdsBar.tsx | 15 +-- 6 files changed, 25 insertions(+), 116 deletions(-) diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index abf142bc46c..7492bc27199 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,4 +1,4 @@ -import { useId } from 'react'; +import { useId, useEffect } from 'react'; import { RadialColorDefs } from './RadialColorDefs'; import { RadialShape } from './RadialGauge'; @@ -11,7 +11,6 @@ export interface RadialArcPathPropsBase { arcLengthDeg: number; shape: RadialShape; color?: string; - gradient?: string; glowFilter?: string; roundedBars?: boolean; showGuideDots?: boolean; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index 729f60ed630..f3d5d987d55 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -47,7 +47,6 @@ export function RadialBar({ startAngle={startAngle} arcLengthDeg={angle} colorDefs={colorDefs} - gradient={colorDefs.getGradientDef()} roundedBars={roundedBars} glowFilter={glowFilter} showGuideDots={roundedBars} @@ -56,7 +55,6 @@ export function RadialBar({ shape={shape} /> - {colorDefs.getDefs()} ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index dbd668f3a00..e6858b6cec2 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -4,7 +4,7 @@ import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; import { RadialColorDefs } from './RadialColorDefs'; -import { RadialShape } from './RadialGauge'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarSegmentedProps { @@ -17,6 +17,7 @@ export interface RadialBarSegmentedProps { segmentCount: number; segmentSpacing: number; shape: RadialShape; + gradientMode: RadialGradientMode; } export function RadialBarSegmented({ fieldDisplay, @@ -28,6 +29,7 @@ export function RadialBarSegmented({ segmentSpacing, colorDefs, shape, + gradientMode, }: RadialBarSegmentedProps) { const segments: React.ReactNode[] = []; const theme = useTheme2(); @@ -41,9 +43,13 @@ export function RadialBarSegmented({ for (let i = 0; i < segmentCountAdjusted; i++) { const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - // const angleColor = colorDefs.getSegmentColor(angleValue, i); const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = angleValue >= value ? theme.colors.action.hover : undefined; + let segmentColor: string | undefined; + if (angleValue >= value) { + segmentColor = theme.colors.action.hover; + } else if (gradientMode === 'none') { + segmentColor = colorDefs.getSegmentColor(angleValue); + } segments.push( - {segments} - {colorDefs.getDefs()} - - ); + return {segments}; } export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx index 87a6a0d4ba8..fabd4b8dd3f 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx @@ -1,11 +1,4 @@ -import { - colorManipulator, - DisplayProcessor, - FALLBACK_COLOR, - FieldDisplay, - getFieldColorMode, - GrafanaTheme2, -} from '@grafana/data'; +import { colorManipulator, DisplayProcessor, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2 } from '@grafana/data'; import { RadialGradientMode, RadialShape } from './RadialGauge'; import { buildGradientColors } from './colors'; @@ -29,98 +22,17 @@ const getGuideDotColor = (color: string): string => { }; export class RadialColorDefs { - private colorToIds: Record = {}; - private defs: React.ReactNode[] = []; - constructor(private options: RadialColorDefsOptions) {} - getSegmentColor(forValue: number, segmentIdx: number): string { + getSegmentColor(forValue: number): string { const { displayProcessor } = this.options; - const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR; - return this.getColor(baseColor, segmentIdx); - } - - getColor(baseColor: string, segmentIdx?: number): string { - const { gradient, dimensions, gaugeId, fieldDisplay, shape } = this.options; - - let id = `value-color-${baseColor}-${gaugeId}`; - const forSegment = segmentIdx !== undefined; - if (forSegment) { - id += `-segment-${segmentIdx}`; - } - - if (this.colorToIds[id]) { - return this.colorToIds[id]; - } - - // If no gradient, just return the base color - if (gradient === 'none') { - this.colorToIds[id] = baseColor; - return baseColor; - } - - const returnColor = (this.colorToIds[id] = `url(#${id})`); - const colorModeId = fieldDisplay.field.color?.mode; - const colorMode = getFieldColorMode(colorModeId); - const valuePercent = fieldDisplay.display.percent ?? 0; - - const gradientStops = this.getGradient(baseColor, forSegment); - const stops = gradientStops.map((stop, i) => ( - - )); - - // circular gradients are a little awkward today. we don't exactly have the result we - // want for continuous color modes, which would be to have the radial bar fill from the top - // around the circle. But SVG doesn't support that kind of gradient on stroke paths out-of-the-box, - // we'd need to implement something like https://gist.github.com/mbostock/4163057 - - // Handle continusous color modes first - // If it's a segment color we don't want to do continuous gradients - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - this.defs.push( - - {stops} - - ); - - return returnColor; - } - - // 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) { - const x2 = shape === 'circle' ? 0 : 1 / valuePercent; - const y2 = shape === 'circle' ? 1 : 0; - this.defs.push( - - {stops} - - ); - return returnColor; - } - - // For fixed / palette based color scales we can create a more fun - // hue and light based linear gradient that we rotate/move with the value - const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius; - const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0; - - this.defs.push( - - {stops} - - ); - - return returnColor; + return displayProcessor(forValue).color ?? FALLBACK_COLOR; } getFieldBaseColor(): string { return this.options.fieldDisplay.display.color ?? FALLBACK_COLOR; } - getMainBarColor(): string { - return this.getColor(this.getFieldBaseColor()); - } - getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> { const { displayProcessor, gradient, fieldDisplay, theme } = this.options; return buildGradientColors(gradient, baseColor, theme, displayProcessor, fieldDisplay, forSegment); @@ -131,7 +43,7 @@ export class RadialColorDefs { const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); return this.options.shape === 'circle' ? `conic-gradient(from 0deg, ${colorStrings.join(', ')})` - : 'linear-gradient(90deg, ' + colorStrings.join(', ') + ')'; + : `linear-gradient(90deg, ${colorStrings.join(', ')})`; } getEndpointColors(): [string, string] { @@ -157,8 +69,4 @@ export class RadialColorDefs { const [startColor, endColor] = this.getEndpointColors(); return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; } - - getDefs(): React.ReactNode[] { - return this.defs; - } } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index fc03f9845e3..97048b6674a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -156,6 +156,7 @@ export function RadialGauge(props: RadialGaugeProps) { segmentSpacing={segmentSpacing} colorDefs={colorDefs} shape={shape} + gradientMode={gradient} /> ); } else { @@ -231,6 +232,7 @@ export function RadialGauge(props: RadialGaugeProps) { glowFilter={`url(#${glowFilterId})`} colorDefs={colorDefs} shape={shape} + gradientMode={gradient} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 15e5349e8a5..bf9ebdcc46a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,8 +1,10 @@ import { FieldDisplay, Threshold } from '@grafana/data'; +import { useTheme2 } from '../../themes/ThemeContext'; + import { RadialArcPath } from './RadialArcPath'; import { RadialColorDefs } from './RadialColorDefs'; -import { RadialShape } from './RadialGauge'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface Props { @@ -16,6 +18,7 @@ export interface Props { glowFilter?: string; colorDefs: RadialColorDefs; thresholds: Threshold[]; + gradientMode: RadialGradientMode; } export function ThresholdsBar({ dimensions, @@ -27,7 +30,9 @@ export function ThresholdsBar({ colorDefs, thresholds, shape, + gradientMode, }: Props) { + const theme = useTheme2(); const fieldConfig = fieldDisplay.field; const min = fieldConfig.min ?? 0; const max = fieldConfig.max ?? 100; @@ -59,6 +64,7 @@ export function ThresholdsBar({ startAngle={currentStart} arcLengthDeg={lengthDeg} colorDefs={colorDefs} + color={gradientMode === 'none' ? threshold.color : undefined} shape={shape} dimensions={thresholdDimensions} roundedBars={roundedBars} @@ -69,10 +75,5 @@ export function ThresholdsBar({ currentStart += lengthDeg; } - return ( - <> - {paths} - {colorDefs.getDefs()} - - ); + return {paths}; } From b300bd8b85fa84b2bdc31b1852acccbb792b32ff Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Dec 2025 23:13:08 -0500 Subject: [PATCH 4/6] refactoring defs into utils --- packages/grafana-data/src/index.ts | 2 +- .../src/themes/colorManipulator.ts | 8 +- packages/grafana-data/src/themes/types.ts | 6 + .../components/RadialGauge/RadialArcPath.tsx | 180 ++++++------------ .../src/components/RadialGauge/RadialBar.tsx | 82 ++++---- .../RadialGauge/RadialBarSegmented.tsx | 54 +----- .../RadialGauge/RadialColorDefs.tsx | 72 ------- .../components/RadialGauge/RadialGauge.tsx | 18 +- .../components/RadialGauge/ThresholdsBar.tsx | 14 +- .../src/components/RadialGauge/colors.ts | 48 ++++- .../src/components/RadialGauge/utils.ts | 78 ++++++++ 11 files changed, 255 insertions(+), 307 deletions(-) delete mode 100644 packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 63c36639e25..0c6ff34517a 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 } from './themes/types'; +export type { ThemeRichColor, GrafanaTheme2, GradientStop } 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.ts b/packages/grafana-data/src/themes/colorManipulator.ts index 42bbf454b1a..844671d50da 100644 --- a/packages/grafana-data/src/themes/colorManipulator.ts +++ b/packages/grafana-data/src/themes/colorManipulator.ts @@ -4,6 +4,8 @@ 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 @@ -393,16 +395,14 @@ 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: Array<{ color: string; percent: number }>, - percent: number -): tinycolor.Instance { +export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { if (!stops || stops.length < 2) { throw new Error('colorAtGradientPercent requires at least two color stops'); } diff --git a/packages/grafana-data/src/themes/types.ts b/packages/grafana-data/src/themes/types.ts index f586937cf3c..93f34705522 100644 --- a/packages/grafana-data/src/themes/types.ts +++ b/packages/grafana-data/src/themes/types.ts @@ -59,3 +59,9 @@ 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 7492bc27199..a7c43aeb63c 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,21 +1,25 @@ -import { useId, useEffect } from 'react'; +import { useId, useMemo } from 'react'; -import { RadialColorDefs } from './RadialColorDefs'; -import { RadialShape } from './RadialGauge'; -import { GaugeDimensions, toRad } from './utils'; +import { DisplayProcessor, 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'; export interface RadialArcPathPropsBase { - startAngle: number; - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; arcLengthDeg: number; - shape: RadialShape; color?: string; + dimensions: GaugeDimensions; + displayProcessor: DisplayProcessor; + fieldDisplay: FieldDisplay; glowFilter?: string; + gradientMode: RadialGradientMode; roundedBars?: boolean; + shape: RadialShape; showGuideDots?: boolean; - guideDotStartColor?: string; - guideDotEndColor?: string; + startAngle: number; } interface RadialArcPathPropsWithGuideDot extends RadialArcPathPropsBase { @@ -28,110 +32,60 @@ type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDo const MAX_DOT_RADIUS = 8; -function drawRadialArcPath({ - angle, - arcLengthDeg, - dimensions, - roundedBars, -}: { - angle: number; - dimensions: GaugeDimensions; - arcLengthDeg: number; - 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; - } - - const startRadians = toRad(angle); - const endRadians = toRad(angle + arcLengthDeg); - - const largeArc = arcLengthDeg > 180 ? 1 : 0; - - const outerR = radius + barWidth / 2; - const innerR = Math.max(0, radius - barWidth / 2); - - const ox1 = centerX + outerR * Math.cos(startRadians); - const oy1 = centerY + outerR * Math.sin(startRadians); - const ox2 = centerX + outerR * Math.cos(endRadians); - const oy2 = centerY + outerR * Math.sin(endRadians); - - const ix1 = centerX + innerR * Math.cos(startRadians); - const iy1 = centerY + innerR * Math.sin(startRadians); - const ix2 = centerX + innerR * Math.cos(endRadians); - const iy2 = centerY + innerR * Math.sin(endRadians); - - const capR = barWidth / 2; - - const pathParts = [ - // start at outer start - 'M', - ox1, - oy1, - // outer arc from start to end (clockwise) - 'A', - outerR, - outerR, - 0, - largeArc, - 1, - ox2, - oy2, - ]; - - if (roundedBars) { - // 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 - 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'); - } 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'); - } - - return pathParts.join(' '); -} - export function RadialArcPath({ - startAngle: angle, - dimensions, - color, - colorDefs, - shape, arcLengthDeg, + color, + dimensions, + displayProcessor, + fieldDisplay, glowFilter, + gradientMode, roundedBars, + shape, showGuideDots, - guideDotStartColor, - guideDotEndColor, + startAngle: angle, }: RadialArcPathProps) { + const theme = useTheme2(); const id = useId(); + const { radius, centerX, centerY, barWidth } = dimensions; + const gradientStops = useMemo(() => { + if (gradientMode === 'none') { + return []; + } + return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color); + }, [gradientMode, fieldDisplay, theme, displayProcessor]); + + const { guideDotColors, endpointColors } = useMemo(() => { + if (!showGuideDots || shape !== 'circle' || gradientStops.length === 0) { + return { + guideDotStartColor: undefined, + guideDotEndColor: undefined, + }; + } + return { + guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0), + endpointColors: getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0), + }; + }, [showGuideDots, shape, fieldDisplay, gradientStops]); + + const bgDivStyle = useMemo(() => { + const baseStyles = { width: '100%', height: '100%' }; + if (color) { + return { backgroundColor: color, ...baseStyles }; + } + const gradientCss = getGradientCss(gradientStops, shape); + return { backgroundImage: gradientCss, ...baseStyles }; + }, [color, gradientStops, shape]); + const startRadians = toRad(angle); const endRadians = toRad(angle + arcLengthDeg); - const [startColor, endColor] = colorDefs.getEndpointColors(); - const path = drawRadialArcPath({ angle, arcLengthDeg, dimensions, roundedBars }); + const path = useMemo( + () => drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars), + [angle, arcLengthDeg, dimensions, roundedBars] + ); let x1 = centerX + radius * Math.cos(startRadians); let y1 = centerY + radius * Math.sin(startRadians); let x2 = centerX + radius * Math.cos(endRadians); @@ -152,28 +106,16 @@ export function RadialArcPath({ height={(radius + barWidth) * 2} clipPath={`url(#${id})`} > -
+
{showGuideDots && ( <> - {shape === 'circle' && ( - <> - - - - )} - - {arcLengthDeg > 5 && } - + {endpointColors && } + {endpointColors && } + {guideDotColors && arcLengthDeg > 5 && } + {guideDotColors && } )} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index f3d5d987d55..786c506af00 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,60 +1,64 @@ +import { DisplayProcessor, FieldDisplay } from '@grafana/data'; + import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { RadialShape } from './RadialGauge'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarProps { - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; - angleRange: number; angle: number; - startAngle: number; - roundedBars?: boolean; + angleRange: number; + dimensions: GaugeDimensions; + displayProcessor: DisplayProcessor; + fieldDisplay: FieldDisplay; glowFilter?: string; + gradientMode: RadialGradientMode; + roundedBars?: boolean; shape: RadialShape; + startAngle: number; } export function RadialBar({ - dimensions, - colorDefs, - angleRange, angle, - startAngle, - roundedBars, + angleRange, + dimensions, + displayProcessor, + fieldDisplay, glowFilter, + gradientMode, + roundedBars, shape, + startAngle, }: RadialBarProps) { const theme = useTheme2(); - const [startDotColor, endDotColor] = colorDefs.getGuideDotColors(); - return ( <> - - {/** Track */} - - {/** The colored bar */} - - + {/** Track */} + + {/** The colored bar */} + ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index e6858b6cec2..bff45475181 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -1,16 +1,15 @@ -import { FieldDisplay } from '@grafana/data'; +import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarSegmentedProps { fieldDisplay: FieldDisplay; + displayProcessor: DisplayProcessor; dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; angleRange: number; startAngle: number; glowFilter?: string; @@ -21,13 +20,13 @@ export interface RadialBarSegmentedProps { } export function RadialBarSegmented({ fieldDisplay, + displayProcessor, dimensions, startAngle, angleRange, glowFilter, segmentCount, segmentSpacing, - colorDefs, shape, gradientMode, }: RadialBarSegmentedProps) { @@ -48,7 +47,7 @@ export function RadialBarSegmented({ if (angleValue >= value) { segmentColor = theme.colors.action.hover; } else if (gradientMode === 'none') { - segmentColor = colorDefs.getSegmentColor(angleValue); + segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR; } segments.push( @@ -58,9 +57,11 @@ export function RadialBarSegmented({ dimensions={dimensions} color={segmentColor} shape={shape} - colorDefs={colorDefs} glowFilter={glowFilter} arcLengthDeg={segmentArcLengthDeg} + gradientMode={gradientMode} + fieldDisplay={fieldDisplay} + displayProcessor={displayProcessor} /> ); } @@ -89,44 +90,3 @@ function getOptimalSegmentCount( return Math.min(maxSegments, segmentCount); } - -// export function RadialSegmentLine({ -// gaugeId, -// center, -// angle, -// size, -// color, -// barWidth, -// roundedBars, -// glow, -// margin, -// segmentWidth, -// }: RadialSegmentProps) { -// const arcSize = size - barWidth; -// const radius = arcSize / 2 - margin; - -// const angleRad = (Math.PI * (angle - 90)) / 180; -// const lineLength = radius - barWidth; - -// const x1 = center + radius * Math.cos(angleRad); -// const y1 = center + radius * Math.sin(angleRad); -// const x2 = center + lineLength * Math.cos(angleRad); -// const y2 = center + lineLength * Math.sin(angleRad); - -// return ( -// -// ); -// } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx deleted file mode 100644 index fabd4b8dd3f..00000000000 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { colorManipulator, DisplayProcessor, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2 } from '@grafana/data'; - -import { RadialGradientMode, RadialShape } from './RadialGauge'; -import { buildGradientColors } from './colors'; -import { GaugeDimensions } from './utils'; - -export interface RadialColorDefsOptions { - gradient: RadialGradientMode; - fieldDisplay: FieldDisplay; - theme: GrafanaTheme2; - dimensions: GaugeDimensions; - shape: RadialShape; - gaugeId: string; - displayProcessor: DisplayProcessor; -} - -const CONTRAST_THRESHOLD_MAX = 4.5; -const getGuideDotColor = (color: string): string => { - const darkColor = '#111217'; // gray05 - const lightColor = '#fbfbfb'; // gray90 - return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; -}; - -export class RadialColorDefs { - constructor(private options: RadialColorDefsOptions) {} - - getSegmentColor(forValue: number): string { - const { displayProcessor } = this.options; - return displayProcessor(forValue).color ?? FALLBACK_COLOR; - } - - getFieldBaseColor(): string { - return this.options.fieldDisplay.display.color ?? FALLBACK_COLOR; - } - - getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> { - const { displayProcessor, gradient, fieldDisplay, theme } = this.options; - return buildGradientColors(gradient, baseColor, theme, displayProcessor, fieldDisplay, forSegment); - } - - getGradientDef(): string { - const gradientStops = this.getGradient(); - const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); - return this.options.shape === 'circle' - ? `conic-gradient(from 0deg, ${colorStrings.join(', ')})` - : `linear-gradient(90deg, ${colorStrings.join(', ')})`; - } - - getEndpointColors(): [string, string] { - const { fieldDisplay } = this.options; - - const gradient = this.getGradient(); - const valuePercent = fieldDisplay.display.percent ?? 0; - const startColor = gradient[0].color; - let endColor = gradient[gradient.length - 1].color; - - // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates - if (gradient.length >= 2) { - const endColorByPercentage = colorManipulator.colorAtGradientPercent(gradient, valuePercent); - endColor = - endColorByPercentage.getAlpha() === 1 - ? endColorByPercentage.toHexString() - : endColorByPercentage.toHex8String(); - } - return [startColor, endColor]; - } - - getGuideDotColors(): [string, string] { - const [startColor, endColor] = this.getEndpointColors(); - return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; - } -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index 97048b6674a..0771a52f9d1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -16,7 +16,6 @@ import { getFormattedThresholds } from '../Gauge/utils'; import { RadialBar } from './RadialBar'; import { RadialBarSegmented } from './RadialBarSegmented'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialScaleLabels } from './RadialScaleLabels'; import { RadialSparkline } from './RadialSparkline'; import { RadialText } from './RadialText'; @@ -133,15 +132,6 @@ export function RadialGauge(props: RadialGaugeProps) { const displayProcessor = getFieldDisplayProcessor(displayValue); const glowFilterId = `glow-${gaugeId}`; - const colorDefs = new RadialColorDefs({ - gradient, - fieldDisplay: displayValue, - theme, - dimensions, - shape, - gaugeId, - displayProcessor, - }); if (segmentCount > 1) { graphics.push( @@ -154,9 +144,9 @@ export function RadialGauge(props: RadialGaugeProps) { glowFilter={`url(#${glowFilterId})`} segmentCount={segmentCount} segmentSpacing={segmentSpacing} - colorDefs={colorDefs} shape={shape} gradientMode={gradient} + displayProcessor={displayProcessor} /> ); } else { @@ -164,13 +154,15 @@ export function RadialGauge(props: RadialGaugeProps) { ); } @@ -230,9 +222,9 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} - colorDefs={colorDefs} shape={shape} gradientMode={gradient} + displayProcessor={displayProcessor} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index bf9ebdcc46a..784e14aa397 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,9 +1,6 @@ -import { FieldDisplay, Threshold } from '@grafana/data'; - -import { useTheme2 } from '../../themes/ThemeContext'; +import { DisplayProcessor, FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; @@ -16,9 +13,9 @@ export interface Props { fieldDisplay: FieldDisplay; roundedBars?: boolean; glowFilter?: string; - colorDefs: RadialColorDefs; thresholds: Threshold[]; gradientMode: RadialGradientMode; + displayProcessor: DisplayProcessor; } export function ThresholdsBar({ dimensions, @@ -27,12 +24,11 @@ export function ThresholdsBar({ angleRange, roundedBars, glowFilter, - colorDefs, thresholds, shape, gradientMode, + displayProcessor, }: Props) { - const theme = useTheme2(); const fieldConfig = fieldDisplay.field; const min = fieldConfig.min ?? 0; const max = fieldConfig.max ?? 100; @@ -63,12 +59,14 @@ export function ThresholdsBar({ key={i} startAngle={currentStart} arcLengthDeg={lengthDeg} - colorDefs={colorDefs} color={gradientMode === 'none' ? threshold.color : undefined} shape={shape} dimensions={thresholdDimensions} roundedBars={roundedBars} glowFilter={glowFilter} + gradientMode={gradientMode} + displayProcessor={displayProcessor} + fieldDisplay={fieldDisplay} /> ); diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index 99e23452898..ed1ace25f5b 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -1,18 +1,26 @@ import tinycolor from 'tinycolor2'; -import { DisplayProcessor, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; +import { + colorManipulator, + DisplayProcessor, + FALLBACK_COLOR, + FieldDisplay, + getFieldColorMode, + GradientStop, + GrafanaTheme2, +} from '@grafana/data'; import { FieldColorModeId } from '@grafana/schema'; -import { RadialGradientMode } from './RadialGauge'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; export function buildGradientColors( gradientMode: RadialGradientMode, - baseColor: string, theme: GrafanaTheme2, displayProcessor: DisplayProcessor, fieldDisplay: FieldDisplay, + baseColor = FALLBACK_COLOR, forSegment?: boolean -): Array<{ color: string; percent: number }> { +): GradientStop[] { if (gradientMode === 'none') { return [ { color: baseColor, percent: 0 }, @@ -80,3 +88,35 @@ export function buildGradientColors( { color: darkerColor.toString(), percent: 1 }, ]; } + +export function getEndpointColors(gradientStops: GradientStop[], percent = 0): [string, string] { + 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); + endColor = + endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); + } + return [startColor, endColor]; +} + +export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape): string { + const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); + return shape === 'circle' + ? `conic-gradient(from 0deg, ${colorStrings.join(', ')})` + : `linear-gradient(90deg, ${colorStrings.join(', ')})`; +} + +const CONTRAST_THRESHOLD_MAX = 4.5; +const getGuideDotColor = (color: string): string => { + const darkColor = '#111217'; // gray05 + const lightColor = '#fbfbfb'; // gray90 + return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; +}; + +export function getGuideDotColors(gradientStops: GradientStop[], percent = 0): [string, string] { + const [startColor, endColor] = getEndpointColors(gradientStops, percent); + return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index 44f767d89b2..a0746ff54de 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -155,3 +155,81 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an y: centerY + radius * Math.sin(radian), }; } + +export function drawRadialArcPath( + angle: number, + arcLengthDeg: number, + dimensions: GaugeDimensions, + 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; + } + + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); + + const largeArc = arcLengthDeg > 180 ? 1 : 0; + + const outerR = radius + barWidth / 2; + const innerR = Math.max(0, radius - barWidth / 2); + + const ox1 = centerX + outerR * Math.cos(startRadians); + const oy1 = centerY + outerR * Math.sin(startRadians); + const ox2 = centerX + outerR * Math.cos(endRadians); + const oy2 = centerY + outerR * Math.sin(endRadians); + + const ix1 = centerX + innerR * Math.cos(startRadians); + const iy1 = centerY + innerR * Math.sin(startRadians); + const ix2 = centerX + innerR * Math.cos(endRadians); + const iy2 = centerY + innerR * Math.sin(endRadians); + + const capR = barWidth / 2; + + const pathParts = [ + // start at outer start + 'M', + ox1, + oy1, + // outer arc from start to end (clockwise) + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + ox2, + oy2, + ]; + + if (roundedBars) { + // 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 + 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'); + } 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'); + } + + return pathParts.join(' '); +} From 77138f640a8de8c72891e0b3b9ffc8ceac418356 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Dec 2025 23:23:30 -0500 Subject: [PATCH 5/6] its all working --- .../components/RadialGauge/RadialArcPath.tsx | 170 +++++++++--------- .../src/components/RadialGauge/colors.ts | 4 +- 2 files changed, 92 insertions(+), 82 deletions(-) diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index a7c43aeb63c..65fe54ae285 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,4 +1,4 @@ -import { useId, useMemo } from 'react'; +import { useId, useMemo, memo } from 'react'; import { DisplayProcessor, FieldDisplay } from '@grafana/data'; @@ -30,94 +30,104 @@ interface RadialArcPathPropsWithGuideDot extends RadialArcPathPropsBase { type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDot; +const DOT_RADIUS_FACTOR = 0.4; const MAX_DOT_RADIUS = 8; -export function RadialArcPath({ - arcLengthDeg, - color, - dimensions, - displayProcessor, - fieldDisplay, - glowFilter, - gradientMode, - roundedBars, - shape, - showGuideDots, - startAngle: angle, -}: RadialArcPathProps) { - const theme = useTheme2(); - const id = useId(); +export const RadialArcPath = memo( + ({ + arcLengthDeg, + color, + dimensions, + displayProcessor, + fieldDisplay, + glowFilter, + gradientMode, + roundedBars, + shape, + showGuideDots, + startAngle: angle, + }: RadialArcPathProps) => { + const theme = useTheme2(); + const id = useId(); - const { radius, centerX, centerY, barWidth } = dimensions; + const gradientStops = useMemo(() => { + if (gradientMode === 'none') { + return []; + } + return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color); + }, [gradientMode, fieldDisplay, theme, displayProcessor]); - const gradientStops = useMemo(() => { - if (gradientMode === 'none') { - return []; - } - return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color); - }, [gradientMode, fieldDisplay, theme, displayProcessor]); - - const { guideDotColors, endpointColors } = useMemo(() => { - if (!showGuideDots || shape !== 'circle' || gradientStops.length === 0) { + const { guideDotColors, endpointColors } = useMemo(() => { + if (!showGuideDots || gradientStops.length === 0) { + return { + guideDotStartColor: undefined, + guideDotEndColor: undefined, + }; + } return { - guideDotStartColor: undefined, - guideDotEndColor: undefined, + guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0), + endpointColors: + shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0) : undefined, }; - } - return { - guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0), - endpointColors: getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0), - }; - }, [showGuideDots, shape, fieldDisplay, gradientStops]); + }, [showGuideDots, fieldDisplay, gradientStops, shape]); - const bgDivStyle = useMemo(() => { - const baseStyles = { width: '100%', height: '100%' }; - if (color) { - return { backgroundColor: color, ...baseStyles }; - } - const gradientCss = getGradientCss(gradientStops, shape); - return { backgroundImage: gradientCss, ...baseStyles }; - }, [color, gradientStops, shape]); + const bgDivStyle = useMemo(() => { + const baseStyles = { width: '100%', height: '100%' }; + if (color) { + return { backgroundColor: color, ...baseStyles }; + } + const gradientCss = getGradientCss(gradientStops, shape); + return { backgroundImage: gradientCss, ...baseStyles }; + }, [color, gradientStops, shape]); - const startRadians = toRad(angle); - const endRadians = toRad(angle + arcLengthDeg); + const { radius, centerX, centerY, barWidth } = dimensions; - const path = useMemo( - () => drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars), - [angle, arcLengthDeg, dimensions, roundedBars] - ); - let x1 = centerX + radius * Math.cos(startRadians); - let y1 = centerY + radius * Math.sin(startRadians); - let x2 = centerX + radius * Math.cos(endRadians); - let y2 = centerY + radius * Math.sin(endRadians); + const path = useMemo( + () => drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars), + [angle, arcLengthDeg, dimensions, roundedBars] + ); - const dotRadius = Math.min((barWidth / 2) * 0.4, MAX_DOT_RADIUS); + const { x1, x2, y1, y2 } = useMemo(() => { + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); - return ( - <> - - - - - -
- - + let x1 = centerX + radius * Math.cos(startRadians); + let y1 = centerY + radius * Math.sin(startRadians); + let x2 = centerX + radius * Math.cos(endRadians); + let y2 = centerY + radius * Math.sin(endRadians); + return { x1, y1, x2, y2 }; + }, [angle, arcLengthDeg, centerX, centerY, radius]); - {showGuideDots && ( - <> - {endpointColors && } - {endpointColors && } - {guideDotColors && arcLengthDeg > 5 && } - {guideDotColors && } - - )} - - ); -} + const dotRadius = Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS); + + return ( + <> + + + + + +
+ + + + {showGuideDots && ( + <> + {endpointColors && } + {endpointColors && } + {guideDotColors && arcLengthDeg > 5 && } + {guideDotColors && } + + )} + + ); + } +); + +RadialArcPath.displayName = 'RadialArcPath'; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts index ed1ace25f5b..e1d323b88a5 100644 --- a/packages/grafana-ui/src/components/RadialGauge/colors.ts +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -36,13 +36,13 @@ export function buildGradientColors( const max = fieldDisplay.field.max ?? 100; const result: Array<{ color: string; percent: number }> = [ - { color: displayProcessor(min).color ?? baseColor, percent: 0 }, + { color: displayProcessor(min).color ?? FALLBACK_COLOR, percent: 0 }, ]; for (const threshold of thresholds) { if (threshold.value > min && threshold.value < max) { const percent = (threshold.value - min) / (max - min); - result.push({ color: threshold.color, percent }); + result.push({ color: theme.visualization.getColorByName(threshold.color), percent }); } } From e3bced33a79a3eacc77ebebf237f0700c1dce4e5 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 15 Dec 2025 23:25:39 -0500 Subject: [PATCH 6/6] fixme comment --- packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index 65fe54ae285..f105b821577 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -102,6 +102,7 @@ export const RadialArcPath = memo( return ( <> + {/* FIXME: optimize this by only using clippath + foreign obj for gradients */}