Gauge: Neutral option

This commit is contained in:
Paul Marbach
2026-01-07 17:34:57 -05:00
parent f58ab2a6a1
commit 8aa4f518d7
11 changed files with 125 additions and 47 deletions
@@ -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');
@@ -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 && (
<RadialArcPath
arcLengthDeg={startValueAngle}
fieldDisplay={fieldDisplay}
color={trackColor}
dimensions={dimensions}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle}
/>
)}
{/** Track after value */}
<RadialArcPath
arcLengthDeg={angleRange - angle}
arcLengthDeg={angleRange - endValueAngle - startValueAngle}
fieldDisplay={fieldDisplay}
color={theme.colors.action.hover}
color={trackColor}
dimensions={dimensions}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle + angle}
startAngle={startAngle + startValueAngle + endValueAngle}
/>
{/** The colored bar */}
<RadialArcPath
arcLengthDeg={angle}
arcLengthDeg={endValueAngle}
barEndcaps={shape === 'circle' && roundedBars}
dimensions={dimensions}
endpointMarker={roundedBars ? endpointMarker : undefined}
@@ -56,7 +77,7 @@ export function RadialBar({
glowFilter={glowFilter}
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle}
startAngle={startAngle + startValueAngle}
{...colorProps}
/>
</>
@@ -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(
<RadialArcPath
@@ -61,7 +66,7 @@ export const RadialBarSegmented = memo(
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
shape={shape}
startAngle={segmentAngle}
startAngle={segmentStartAngle}
{...colorProps}
/>
);
@@ -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<HTMLElement>;
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) {
<SpotlightGradient
key={spotlightGradientId}
id={spotlightGradientId}
angle={angle + startAngle}
angle={endValueAngle + startAngle}
dimensions={dimensions}
roundedBars={roundedBars}
theme={theme}
@@ -156,6 +170,8 @@ export function RadialGauge(props: RadialGaugeProps) {
fieldDisplay={displayValue}
angleRange={angleRange}
startAngle={startAngle}
startValueAngle={startValueAngle}
endValueAngle={endValueAngle}
glowFilter={glowFilterRef}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
@@ -168,9 +184,10 @@ export function RadialGauge(props: RadialGaugeProps) {
<RadialBar
key={`radial-bar-${barIndex}-${gaugeId}`}
dimensions={dimensions}
angle={angle}
angleRange={angleRange}
startAngle={startAngle}
startValueAngle={startValueAngle}
endValueAngle={endValueAngle}
roundedBars={roundedBars}
glowFilter={glowFilterRef}
endpointMarkerGlowFilter={spotlightGradientRef}
@@ -233,7 +233,8 @@ describe('RadialGauge utils', () => {
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', () => {
@@ -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 };
}
/**
@@ -33,26 +33,27 @@ export function RadialBarPanel({
return (
<RadialGauge
values={[value]}
width={width}
height={height}
alignmentFactors={valueProps.alignmentFactors}
barWidthFactor={options.barWidthFactor}
gradient={options.effects?.gradient}
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
glowBar={options.effects?.barGlow}
glowCenter={options.effects?.centerGlow}
gradient={options.effects?.gradient}
height={height}
nameManualFontSize={options.text?.titleSize}
neutral={options.neutral}
onClick={menuProps.openMenu}
roundedBars={options.barShape === 'rounded'}
vizCount={valueProps.count}
shape={options.shape}
segmentCount={options.segmentCount}
segmentSpacing={options.segmentSpacing}
thresholdsBar={options.showThresholdMarkers}
shape={options.shape}
showScaleLabels={options.showThresholdLabels}
alignmentFactors={valueProps.alignmentFactors}
valueManualFontSize={options.text?.valueSize}
nameManualFontSize={options.text?.titleSize}
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
onClick={menuProps.openMenu}
textMode={options.textMode}
thresholdsBar={options.showThresholdMarkers}
valueManualFontSize={options.text?.valueSize}
values={[value]}
vizCount={valueProps.count}
width={width}
/>
);
}
@@ -115,6 +115,17 @@ export const plugin = new PanelPlugin<Options>(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'),
@@ -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")
}
+1
View File
@@ -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');
+5
View File
@@ -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",