From 8aa4f518d7113cc6b20fbe209eded73741309afb Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 7 Jan 2026 17:34:57 -0500 Subject: [PATCH] Gauge: Neutral option --- .../panelcfg/x/NewGaugePanelCfg_types.gen.ts | 1 + .../src/components/RadialGauge/RadialBar.tsx | 39 ++++++++++++++----- .../RadialGauge/RadialBarSegmented.tsx | 19 +++++---- .../components/RadialGauge/RadialGauge.tsx | 25 ++++++++++-- .../src/components/RadialGauge/utils.test.ts | 18 +++++---- .../src/components/RadialGauge/utils.ts | 25 ++++++++---- .../panel/radialbar/RadialBarPanel.tsx | 25 ++++++------ public/app/plugins/panel/radialbar/module.tsx | 11 ++++++ .../app/plugins/panel/radialbar/panelcfg.cue | 3 +- .../plugins/panel/radialbar/panelcfg.gen.ts | 1 + public/locales/en-US/grafana.json | 5 +++ 11 files changed, 125 insertions(+), 47 deletions(-) diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index c0f8481a7f5..af5eb1b7e3c 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -29,6 +29,7 @@ export interface Options extends common.SingleStatBaseOptions { barWidthFactor: number; effects: GaugePanelEffects; endpointMarker?: ('point' | 'glow' | 'none'); + neutral?: number; segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index 719ec52c625..392b0a4981c 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,4 +1,6 @@ -import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; +import { useMemo } from 'react'; + +import { colorManipulator, FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; @@ -6,7 +8,6 @@ import { RadialArcPath } from './RadialArcPath'; import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; export interface RadialBarProps { - angle: number; angleRange: number; dimensions: RadialGaugeDimensions; fieldDisplay: FieldDisplay; @@ -15,11 +16,12 @@ export interface RadialBarProps { endpointMarker?: 'point' | 'glow'; shape: RadialShape; startAngle: number; + startValueAngle: number; + endValueAngle: number; glowFilter?: string; endpointMarkerGlowFilter?: string; } export function RadialBar({ - angle, angleRange, dimensions, fieldDisplay, @@ -28,26 +30,45 @@ export function RadialBar({ endpointMarker, shape, startAngle, + startValueAngle, + endValueAngle, glowFilter, endpointMarkerGlowFilter, }: RadialBarProps) { const theme = useTheme2(); const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR }; + const trackColor = useMemo( + () => colorManipulator.onBackground(theme.colors.action.hover, theme.colors.background.primary).toHexString(), + [theme] + ); + return ( <> - {/** Track */} + {/** Track before value */} + {startValueAngle !== 0 && ( + + )} + {/** Track after value */} {/** The colored bar */} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index b51cb4ce2f1..61d10044802 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -11,6 +11,7 @@ import { getFieldConfigMinMax, getFieldDisplayProcessor, getOptimalSegmentCount, + getValuePercentageForValue, } from './utils'; export interface RadialBarSegmentedProps { @@ -18,6 +19,8 @@ export interface RadialBarSegmentedProps { dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; + startValueAngle: number; + endValueAngle: number; glowFilter?: string; segmentCount: number; segmentSpacing: number; @@ -36,22 +39,24 @@ export const RadialBarSegmented = memo( segmentCount, segmentSpacing, shape, + startValueAngle, + endValueAngle, }: RadialBarSegmentedProps) => { const theme = useTheme2(); 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; const displayProcessor = getFieldDisplayProcessor(fieldDisplay); for (let i = 0; i < segmentCountAdjusted; i++) { - const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = - angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR); - const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor }; + const value = min + ((max - min) / segmentCountAdjusted) * i; + const segmentAngle = getValuePercentageForValue(fieldDisplay, value) * angleRange; + const isTrack = segmentAngle < startValueAngle || segmentAngle > startValueAngle + endValueAngle; + const segmentStartAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; + const segmentColor = isTrack ? theme.colors.border.medium : (displayProcessor(value).color ?? FALLBACK_COLOR); + const colorProps = !isTrack && gradient ? { gradient } : { color: segmentColor }; segments.push( ); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index e60a3dfde31..7fc3ae4a383 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -17,7 +17,7 @@ import { ThresholdsBar } from './ThresholdsBar'; import { buildGradientColors } from './colors'; import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects'; import { RadialShape, RadialTextMode } from './types'; -import { calculateDimensions, getValueAngleForValue } from './utils'; +import { calculateDimensions, getFieldConfigMinMax, getValueAngleForValue } from './utils'; export interface RadialGaugeProps { values: FieldDisplay[]; @@ -67,6 +67,11 @@ export interface RadialGaugeProps { /** Specify which text should be visible */ textMode?: RadialTextMode; showScaleLabels?: boolean; + /** + * If set, the gauge will use the neutral value instead of the min value as the starting point for a gauge. + * this is most useful when you need to show positive and negative values on a gauge. + */ + neutral?: number; /** For data links */ onClick?: React.MouseEventHandler; timeRange?: TimeRange; @@ -91,6 +96,7 @@ export function RadialGauge(props: RadialGaugeProps) { roundedBars = true, thresholdsBar = false, showScaleLabels = false, + neutral: rawNeutral, endpointMarker, onClick, values, @@ -113,7 +119,15 @@ export function RadialGauge(props: RadialGaugeProps) { for (let barIndex = 0; barIndex < values.length; barIndex++) { const displayValue = values[barIndex]; - const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle); + const [min, max] = getFieldConfigMinMax(displayValue); + const neutral = typeof rawNeutral === 'number' ? Math.min(Math.max(min, rawNeutral), max) : undefined; + const { startValueAngle, endValueAngle, angleRange } = getValueAngleForValue( + displayValue, + startAngle, + endAngle, + neutral + ); + const gradientStops = gradient ? buildGradientColors(theme, displayValue) : undefined; const color = displayValue.display.color ?? FALLBACK_COLOR; const dimensions = calculateDimensions( @@ -140,7 +154,7 @@ export function RadialGauge(props: RadialGaugeProps) { { const fieldDisplay = createFieldDisplay(50, 0, 100); const result = getValueAngleForValue(fieldDisplay, 0, 360); - expect(result.angle).toBe(180); // 50% of 360° + expect(result.startValueAngle).toBe(0); + expect(result.endValueAngle).toBe(180); // 50% of 360° expect(result.angleRange).toBe(360); }); @@ -241,7 +242,8 @@ describe('RadialGauge utils', () => { const fieldDisplay = createFieldDisplay(50, 0, 100); const result = getValueAngleForValue(fieldDisplay, 90, 270); - expect(result.angle).toBe(135); // 50% of 360° range + expect(result.startValueAngle).toBe(0); + expect(result.endValueAngle).toBe(135); // 50% of 360° range expect(result.angleRange).toBe(270); }); @@ -249,28 +251,28 @@ describe('RadialGauge utils', () => { const fieldDisplay = createFieldDisplay(150, 0, 100); // value exceeds max const result = getValueAngleForValue(fieldDisplay, 0, 360); - expect(result.angle).toBe(360); // clamped to angleRange + expect(result.endValueAngle).toBe(360); // clamped to angleRange }); it('should handle minimum values', () => { const fieldDisplay = createFieldDisplay(0, 0, 100); const result = getValueAngleForValue(fieldDisplay, 0, 360); - expect(result.angle).toBe(0); + expect(result.endValueAngle).toBe(0); }); it('should handle maximum values', () => { const fieldDisplay = createFieldDisplay(100, 0, 100); const result = getValueAngleForValue(fieldDisplay, 0, 360); - expect(result.angle).toBe(360); + expect(result.endValueAngle).toBe(360); }); it('should handle values lower than min', () => { const fieldDisplay = createFieldDisplay(-50, 0, 100); const result = getValueAngleForValue(fieldDisplay, 240, 120); - expect(result.angle).toBe(0); + expect(result.endValueAngle).toBe(0); }); it('should handle values higher than max', () => { @@ -278,8 +280,10 @@ describe('RadialGauge utils', () => { const result = getValueAngleForValue(fieldDisplay, 240, 120); // Expect the angle to be clamped to the maximum range - expect(result.angle).toBe(240); + expect(result.endValueAngle).toBe(240); }); + + // TODO add tests for neutral handling once implemented }); describe('drawRadialArcPath', () => { diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index a18efc42699..b2cef2b21ba 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -28,19 +28,30 @@ export function getValueAngleForValue( fieldDisplay: FieldDisplay, startAngle: number, endAngle: number, - value = fieldDisplay.display.numeric + neutral?: number ) { const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; + const value = fieldDisplay.display.numeric; - let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange; + const valueAngle = getValuePercentageForValue(fieldDisplay, value) * angleRange; - if (angle > angleRange) { - angle = angleRange; - } else if (angle < 0) { - angle = 0; + let endValueAngle = valueAngle; + + let startValueAngle = 0; + if (typeof neutral === 'number') { + const neutralAngle = getValuePercentageForValue(fieldDisplay, neutral) * angleRange; + if (neutralAngle <= valueAngle) { + startValueAngle = neutralAngle; + endValueAngle = valueAngle - neutralAngle; + } else { + startValueAngle = valueAngle; + endValueAngle = neutralAngle - valueAngle; + } } - return { angleRange, angle }; + const clampedEndValueAngle = Math.min(Math.max(endValueAngle, 0), angleRange); + + return { angleRange, startValueAngle, endValueAngle: clampedEndValueAngle }; } /** diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 42c70511977..979dcbe3db4 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -33,26 +33,27 @@ export function RadialBarPanel({ return ( ); } diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index f7afa0ef2c9..93c40c4f158 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -115,6 +115,17 @@ export const plugin = new PanelPlugin(RadialBarPanel) defaultValue: defaultOptions.textMode, }); + builder.addNumberInput({ + path: 'neutral', + name: t('radialbar.config.neutral.title', 'Neutral value'), + description: t('radialbar.config.neutral.description', 'Leave empty to use Min as neutral point'), + category, + settings: { + placeholder: t('radialbar.config.neutral.placeholder', 'none'), + step: 1, + }, + }); + builder.addBooleanSwitch({ path: 'sparkline', name: t('radialbar.config.sparkline', 'Show sparkline'), diff --git a/public/app/plugins/panel/radialbar/panelcfg.cue b/public/app/plugins/panel/radialbar/panelcfg.cue index 6e5fd7eec21..5f95de9428d 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -42,7 +42,8 @@ composableKinds: PanelCfg: { barWidthFactor: number | *0.5 barShape: "flat" | "rounded" | *"flat" endpointMarker?: "point" | "glow" | "none" | *"point" - textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto" + textMode?: "auto" | "value_and_name" | "value" | "name" | "none" | *"auto" + neutral?: number effects: GaugePanelEffects | *{} } @cuetsy(kind="interface") } diff --git a/public/app/plugins/panel/radialbar/panelcfg.gen.ts b/public/app/plugins/panel/radialbar/panelcfg.gen.ts index 594747136ef..c51c71d23c9 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -27,6 +27,7 @@ export interface Options extends common.SingleStatBaseOptions { barWidthFactor: number; effects: GaugePanelEffects; endpointMarker?: ('point' | 'glow' | 'none'); + neutral?: number; segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 2174ff0adbc..6b6d360ea07 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12528,6 +12528,11 @@ "endpoint-marker-glow": "Glow", "endpoint-marker-none": "None", "endpoint-marker-point": "Point", + "neutral": { + "description": "Leave empty to use Min as neutral point", + "placeholder": "none", + "title": "Neutral value" + }, "segment-count": "Segments", "segment-spacing": "Segment spacing", "shape": "Style",