Compare commits

...

6 Commits

Author SHA1 Message Date
Paul Marbach 0c4ade9514 Merge branch 'main' into fastfrwrd/gauge-neutral 2026-01-14 13:32:57 -05:00
Paul Marbach e60fb6b1d3 Merge remote-tracking branch 'origin/main' into fastfrwrd/gauge-neutral 2026-01-08 11:14:58 -05:00
Paul Marbach 2992964f32 storybook updates 2026-01-08 11:09:50 -05:00
Paul Marbach 31a806281e fix off-by-one, update tests 2026-01-08 11:00:24 -05:00
Paul Marbach d0178cc95d fix off-by-one in segmented gauge 2026-01-08 10:24:53 -05:00
Paul Marbach 8aa4f518d7 Gauge: Neutral option 2026-01-07 17:34:57 -05:00
13 changed files with 177 additions and 46 deletions
@@ -31,6 +31,7 @@ export interface Options extends common.SingleStatBaseOptions {
endpointMarker?: ('point' | 'glow' | 'none');
minVizHeight: number;
minVizWidth: number;
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}
/>
);
@@ -50,6 +50,7 @@ const meta: Meta<StoryProps> = {
thresholdsBar: false,
colorScheme: FieldColorModeId.Thresholds,
decimals: 0,
neutral: undefined,
},
argTypes: {
barWidthFactor: { control: { type: 'range', min: 0.1, max: 1, step: 0.01 } },
@@ -75,6 +76,7 @@ const meta: Meta<StoryProps> = {
],
},
decimals: { control: { type: 'range', min: 0, max: 7 } },
neutral: { control: { type: 'number' } },
},
};
@@ -270,6 +272,23 @@ export const Examples: StoryFn<StoryProps> = (args) => {
barWidthFactor={0.7}
/>
</Stack>
<div>
Neutral <em>(range -50 to 50, neutral = 0)</em>
</div>
<Stack direction={'row'} gap={3}>
<RadialGaugeExample
min={-50}
max={50}
value={-20}
colorScheme={FieldColorModeId.Thresholds}
gradient
shape="gauge"
glowCenter={true}
roundedBars={false}
barWidthFactor={0.7}
neutral={0}
/>
</Stack>
</Stack>
);
};
@@ -330,6 +349,7 @@ interface ExampleProps {
endpointMarker?: RadialGaugeProps['endpointMarker'];
decimals?: number;
showScaleLabels?: boolean;
neutral?: number;
}
export function RadialGaugeExample({
@@ -357,6 +377,7 @@ export function RadialGaugeExample({
endpointMarker = 'glow',
decimals = 0,
showScaleLabels,
neutral,
}: ExampleProps) {
const theme = useTheme2();
@@ -442,6 +463,7 @@ export function RadialGaugeExample({
thresholdsBar={thresholdsBar}
showScaleLabels={showScaleLabels}
endpointMarker={endpointMarker}
neutral={neutral}
/>
);
}
@@ -16,6 +16,7 @@ describe('RadialGauge', () => {
{ description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } },
{ description: 'with thresholds bar', props: { thresholdsBar: true } },
{ description: 'with sparkline', props: { sparkline: true } },
{ description: 'with neutral value', props: { neutral: 50 } },
] satisfies Array<{ description: string; props?: ComponentProps<typeof RadialGaugeExample> }>)(
'should render $description without throwing',
({ props }) => {
@@ -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,
endpointMarker,
onClick,
values,
@@ -113,7 +119,13 @@ 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 { 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 +152,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 +168,8 @@ export function RadialGauge(props: RadialGaugeProps) {
fieldDisplay={displayValue}
angleRange={angleRange}
startAngle={startAngle}
startValueAngle={startValueAngle}
endValueAngle={endValueAngle}
glowFilter={glowFilterRef}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
@@ -168,9 +182,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,7 +280,39 @@ 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);
});
it('should handle neutral values', () => {
const fieldDisplay = createFieldDisplay(75, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, 50);
expect(result.startValueAngle).toBe(180); // Neutral at 50% of 360°
expect(result.endValueAngle).toBe(90); // 75% - 50% = 25% of 360°
});
it('should handle neutral values equal to value', () => {
const fieldDisplay = createFieldDisplay(50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, 50);
expect(result.startValueAngle).toBe(180); // Neutral at 50% of 360°
expect(result.endValueAngle).toBe(0); // No difference
});
it('should handle neutral values greater than value', () => {
const fieldDisplay = createFieldDisplay(25, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, 150);
expect(result.startValueAngle).toBe(90);
expect(result.endValueAngle).toBe(270); // remaining angle to 360
});
it('should handle neutral values below range', () => {
const fieldDisplay = createFieldDisplay(25, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360, -50);
expect(result.startValueAngle).toBe(0);
expect(result.endValueAngle).toBe(90);
});
});
@@ -28,19 +28,32 @@ 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 [min, max] = getFieldConfigMinMax(fieldDisplay);
const clampedNeutral = Math.min(Math.max(min, neutral), max);
const neutralAngle = getValuePercentageForValue(fieldDisplay, clampedNeutral) * 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 };
}
/**
@@ -32,26 +32,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}
/>
);
}
@@ -161,6 +161,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 | *{}
sizing: common.BarGaugeSizing & (*"auto" | _)
minVizWidth: uint32 | *75
+1
View File
@@ -29,6 +29,7 @@ export interface Options extends common.SingleStatBaseOptions {
endpointMarker?: ('point' | 'glow' | 'none');
minVizHeight: number;
minVizWidth: number;
neutral?: number;
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
+5
View File
@@ -12572,6 +12572,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",