addressing PR comments

This commit is contained in:
Paul Marbach
2025-12-18 11:52:34 -05:00
parent c7af2be682
commit b123e24d86
9 changed files with 131 additions and 126 deletions
@@ -2,18 +2,15 @@ import { useId, memo, HTMLAttributes, ReactNode } from 'react';
import { FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { buildGradientColors, getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
import { RadialShape, RadialGaugeDimensions } from './types';
import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import { drawRadialArcPath, toRad } from './utils';
export interface RadialArcPathPropsBase {
arcLengthDeg: number;
color?: string;
barEndcaps?: boolean;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
gradient?: boolean;
roundedBars?: boolean;
shape: RadialShape;
endpointMarker?: 'point' | 'glow';
@@ -22,13 +19,15 @@ export interface RadialArcPathPropsBase {
endpointMarkerGlowFilter?: string;
}
interface RadialArcPathPropsWithGuideDot extends RadialArcPathPropsBase {
showGuideDots: true;
guideDotStartColor: string;
guideDotEndColor: string;
interface RadialArcPathPropsWithColor extends RadialArcPathPropsBase {
color: string;
}
type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDot;
interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase {
gradient: GradientStop[];
}
type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient;
const ENDPOINT_MARKER_MIN_ANGLE = 10;
const DOT_OPACITY = 0.5;
@@ -38,27 +37,24 @@ const MAX_DOT_RADIUS = 8;
export const RadialArcPath = memo(
({
arcLengthDeg,
color,
dimensions,
fieldDisplay,
gradient,
roundedBars,
shape,
endpointMarker,
barEndcaps,
startAngle: angle,
glowFilter,
endpointMarkerGlowFilter,
...rest
}: RadialArcPathProps) => {
const theme = useTheme2();
const id = useId();
const gradientStops = buildGradientColors(gradient, theme, fieldDisplay, fieldDisplay.display.color);
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
if (color) {
bgDivStyle.backgroundColor = color;
if ('color' in rest) {
bgDivStyle.backgroundColor = rest.color;
} else {
bgDivStyle.backgroundImage = getGradientCss(gradientStops, shape);
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
}
const { radius, centerX, centerY, barWidth } = dimensions;
@@ -76,44 +72,48 @@ export const RadialArcPath = memo(
const dotRadius =
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
let barEndcapColors: [string | undefined, string | undefined] | undefined;
let barEndcapColors: [string, string] | undefined;
let endpointMarks: ReactNode = null;
if (endpointMarker && gradientStops.length > 0) {
switch (endpointMarker) {
case 'point':
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(gradientStops, fieldDisplay.display.percent);
endpointMarks = (
<>
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
)}
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
</>
);
break;
case 'glow':
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
endpointMarks =
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
<path
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
fill="none"
strokeWidth={barWidth}
stroke={endpointMarkerGlowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
) : null;
break;
default:
break;
if ('gradient' in rest) {
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
switch (endpointMarker) {
case 'point':
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(
rest.gradient!,
fieldDisplay.display.percent
);
endpointMarks = (
<>
{arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && (
<circle cx={xStart} cy={yStart} r={dotRadius} fill={pointColorStart} opacity={DOT_OPACITY} />
)}
<circle cx={xEnd} cy={yEnd} r={dotRadius} fill={pointColorEnd} opacity={DOT_OPACITY} />
</>
);
break;
case 'glow':
const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE);
const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle);
const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle);
endpointMarks =
arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? (
<path
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
fill="none"
strokeWidth={barWidth}
stroke={endpointMarkerGlowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
) : null;
break;
default:
break;
}
}
if (shape === 'circle') {
barEndcapColors = getBarEndcapColors(gradientStops, fieldDisplay.display.percent);
if (barEndcaps) {
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
}
}
@@ -1,16 +1,16 @@
import { FieldDisplay } from '@grafana/data';
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialShape, RadialGaugeDimensions } from './types';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
export interface RadialBarProps {
angle: number;
angleRange: number;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
gradient?: boolean;
gradient?: GradientStop[];
roundedBars?: boolean;
endpointMarker?: 'point' | 'glow';
shape: RadialShape;
@@ -32,6 +32,7 @@ export function RadialBar({
endpointMarkerGlowFilter,
}: RadialBarProps) {
const theme = useTheme2();
const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR };
return (
<>
{/** Track */}
@@ -47,16 +48,16 @@ export function RadialBar({
{/** The colored bar */}
<RadialArcPath
arcLengthDeg={angle}
color={gradient ? undefined : fieldDisplay.display.color}
barEndcaps={shape === 'circle' && roundedBars}
dimensions={dimensions}
endpointMarker={roundedBars ? endpointMarker : undefined}
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
fieldDisplay={fieldDisplay}
gradient={gradient}
glowFilter={glowFilter}
roundedBars={roundedBars}
shape={shape}
endpointMarker={roundedBars ? endpointMarker : undefined}
startAngle={startAngle}
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
glowFilter={glowFilter}
{...colorProps}
/>
</>
);
@@ -5,7 +5,7 @@ import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialShape, RadialGaugeDimensions } from './types';
import { RadialShape, RadialGaugeDimensions, GradientStop } from './types';
import {
getAngleBetweenSegments,
getFieldConfigMinMax,
@@ -22,7 +22,7 @@ export interface RadialBarSegmentedProps {
segmentCount: number;
segmentSpacing: number;
shape: RadialShape;
gradient?: boolean;
gradient?: GradientStop[];
}
export const RadialBarSegmented = memo(
@@ -38,7 +38,6 @@ export const RadialBarSegmented = memo(
shape,
}: RadialBarSegmentedProps) => {
const theme = useTheme2();
const segments: React.ReactNode[] = [];
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
const [min, max] = getFieldConfigMinMax(fieldDisplay);
@@ -50,24 +49,20 @@ export const RadialBarSegmented = memo(
for (let i = 0; i < segmentCountAdjusted; i++) {
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
let segmentColor: string | undefined;
if (angleValue >= value) {
segmentColor = theme.colors.action.hover;
} else if (!gradient) {
segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR;
}
const segmentColor =
angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR);
const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor };
segments.push(
<RadialArcPath
key={i}
arcLengthDeg={segmentArcLengthDeg}
color={segmentColor}
dimensions={dimensions}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
gradient={gradient}
shape={shape}
startAngle={segmentAngle}
{...colorProps}
/>
);
}
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { useId } from 'react';
import { DisplayValueAlignmentFactors, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
@@ -13,6 +13,7 @@ import { RadialScaleLabels } from './RadialScaleLabels';
import { RadialSparkline } from './RadialSparkline';
import { RadialText } from './RadialText';
import { ThresholdsBar } from './ThresholdsBar';
import { buildGradientColors } from './colors';
import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects';
import { RadialShape, RadialTextMode } from './types';
import { calculateDimensions, getValueAngleForValue } from './utils';
@@ -112,7 +113,8 @@ 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 color = displayValue.display.color ?? 'gray';
const gradientStops = buildGradientColors(gradient, theme, displayValue);
const color = displayValue.display.color ?? FALLBACK_COLOR;
const dimensions = calculateDimensions(
width,
height,
@@ -155,7 +157,7 @@ export function RadialGauge(props: RadialGaugeProps) {
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
shape={shape}
gradient={gradient}
gradient={gradientStops}
/>
);
} else {
@@ -170,7 +172,7 @@ export function RadialGauge(props: RadialGaugeProps) {
glowFilter={`url(#${glowFilterId})`}
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
shape={shape}
gradient={gradient}
gradient={gradientStops}
fieldDisplay={displayValue}
endpointMarker={endpointMarker}
/>
@@ -233,7 +235,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
shape={shape}
gradient={gradient}
gradient={gradientStops}
/>
);
}
@@ -1,7 +1,7 @@
import { FieldDisplay, Threshold } from '@grafana/data';
import { RadialArcPath } from './RadialArcPath';
import { RadialGaugeDimensions, RadialShape } from './types';
import { GradientStop, RadialGaugeDimensions, RadialShape } from './types';
import { getFieldConfigMinMax } from './utils';
interface ThresholdsBarProps {
@@ -14,7 +14,7 @@ interface ThresholdsBarProps {
roundedBars?: boolean;
glowFilter?: string;
thresholds: Threshold[];
gradient?: boolean;
gradient?: GradientStop[];
}
export function ThresholdsBar({
@@ -50,20 +50,20 @@ export function ThresholdsBar({
}
const lengthDeg = valueDeg - currentStart + startAngle;
const color = gradient ? undefined : threshold.color;
const colorProps = gradient ? { gradient } : { color: threshold.color };
paths.push(
<RadialArcPath
key={i}
arcLengthDeg={lengthDeg}
color={color}
barEndcaps={shape === 'circle' && roundedBars}
dimensions={thresholdDimensions}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
gradient={gradient}
roundedBars={roundedBars}
shape={shape}
startAngle={currentStart}
{...colorProps}
/>
);
@@ -112,15 +112,15 @@ exports[`RadialGauge color utils buildGradientColors should return gradient colo
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = `
[
{
"color": "#210550",
"color": "#37237a",
"percent": 0,
},
{
"color": "#442299",
"percent": 0.33,
"color": "#a146da",
"percent": 0.75,
},
{
"color": "#ffffff",
"color": "#a146da",
"percent": 1,
},
]
@@ -129,15 +129,15 @@ exports[`RadialGauge color utils buildGradientColors should return gradient colo
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = `
[
{
"color": "#9181d3",
"color": "#a146da",
"percent": 0,
},
{
"color": "#442299",
"percent": 0.33,
"color": "#3e2b9a",
"percent": 0.75,
},
{
"color": "#210550",
"color": "#3e2b9a",
"percent": 1,
},
]
@@ -4,13 +4,13 @@ import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, Graf
import { FieldColorModeId } from '@grafana/schema';
import { GradientStop, RadialShape } from './types';
import { getFieldConfigMinMax, getFieldDisplayProcessor } from './utils';
import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils';
export function buildGradientColors(
gradient = false,
theme: GrafanaTheme2,
fieldDisplay: FieldDisplay,
baseColor = FALLBACK_COLOR
baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR
): GradientStop[] {
if (!gradient) {
return [
@@ -65,28 +65,31 @@ export function buildGradientColors(
];
}
// for fixed colors and other modes, we create a simple two-color gradient
// we set the highest contrast color second based on the theme.
const darkerColor = tinycolor(baseColor).spin(5).darken(20).saturate(25);
const lighterColor = tinycolor(baseColor)
.spin(-5)
.brighten(theme.isDark ? 30 : 15)
.lighten(theme.isDark ? 35 : 15);
return [
// For fixed / palette based color scales we can create a more hue and light
// based linear gradient that we rotate with the value
const darkerColor = tinycolor(baseColor)
.spin(-20)
.darken(theme.isDark ? 15 : 5);
const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10).lighten(10);
const underlyingGradient = [
{ color: theme.isDark ? darkerColor.toString() : lighterColor.toString(), percent: 0 },
{ color: baseColor, percent: 0.33 },
{ color: theme.isDark ? lighterColor.toString() : darkerColor.toString(), percent: 1 },
];
}
function clamp(value: number, min = 0, max = 1) {
if (process.env.NODE_ENV !== 'production') {
if (value < min || value > max) {
console.error(`The value provided ${value} is out of range [${min}, ${max}].`);
}
}
return Math.min(Math.max(min, value), max);
// rotate the gradient so that the highest contrasting point is the value, depending on theme.
const valuePercent = getValuePercentageForValue(fieldDisplay);
const startColor = theme.isDark
? colorAtGradientPercent(underlyingGradient, 1 - valuePercent).toHexString()
: underlyingGradient[0].color;
const endColor = theme.isDark
? underlyingGradient[1].color
: colorAtGradientPercent(underlyingGradient, valuePercent).toHexString();
return [
{ color: startColor, percent: 0 },
{ color: endColor, percent: valuePercent },
{ color: endColor, percent: 1 },
];
}
/**
@@ -104,7 +107,7 @@ export function colorAtGradientPercent(stops: GradientStop[], percent: number):
// normalize and sort stops by percent. TODO: is this necessary? is gradientstops always sorted?
const sorted = stops
.map((s) => ({ color: s.color, percent: clamp(s.percent, 0, 1) }))
.map((s) => ({ color: s.color, percent: Math.min(Math.max(0, s.percent), 1) }))
.sort((a, b) => a.percent - b.percent);
// percent outside range
@@ -19,16 +19,20 @@ export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) {
return [min, max];
}
export function getValuePercentageForValue(fieldDisplay: FieldDisplay, value = fieldDisplay.display.numeric) {
const [min, max] = getFieldConfigMinMax(fieldDisplay);
return (value - min) / (max - min);
}
export function getValueAngleForValue(
fieldDisplay: FieldDisplay,
startAngle: number,
endAngle: number,
value = fieldDisplay.display.numeric
) {
const [min, max] = getFieldConfigMinMax(fieldDisplay);
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
let angle = ((value - min) / (max - min)) * angleRange;
let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange;
if (angle > angleRange) {
angle = angleRange;
+12 -12
View File
@@ -32,6 +32,18 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
},
});
builder.addSliderInput({
path: 'barWidthFactor',
name: t('radialbar.config.bar-width', 'Bar width'),
category,
defaultValue: defaultOptions.barWidthFactor,
settings: {
min: 0.1,
max: 1,
step: 0.01,
},
});
builder.addSliderInput({
path: 'segmentCount',
name: t('radialbar.config.segment-count', 'Segments'),
@@ -87,18 +99,6 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1,
});
builder.addSliderInput({
path: 'barWidthFactor',
name: t('radialbar.config.bar-width', 'Bar width'),
category,
defaultValue: defaultOptions.barWidthFactor,
settings: {
min: 0.1,
max: 1,
step: 0.01,
},
});
builder.addBooleanSwitch({
path: 'sparkline',
name: t('radialbar.config.sparkline', 'Show sparkline'),