add lots of tests and reorganize the code a bit

This commit is contained in:
Paul Marbach
2025-12-16 11:15:19 -05:00
parent a8325b1e87
commit 942ed4b4e8
20 changed files with 969 additions and 509 deletions
+1 -1
View File
@@ -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, GradientStop } from './themes/types';
export type { ThemeRichColor, GrafanaTheme2 } from './themes/types';
export type { ThemeColors } from './themes/createColors';
export type { ThemeBreakpoints, ThemeBreakpointsKey } from './themes/breakpoints';
export type { ThemeShadows } from './themes/createShadows';
@@ -437,49 +437,4 @@ describe('utils/colorManipulator', () => {
expect(onBackground('rgba(0,0,255,1)', 'rgba(0,0,0,0.5)').toRgbString()).toBe('rgb(0, 0, 255)');
});
});
describe('colorAtGradientPercent', () => {
it('should calculate the color at a given percent in a gradient of two colors', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#0000ff', percent: 1 },
];
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040');
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080');
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf');
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
});
it('should calculate the color at a given percent in a gradient of multiple colors', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000');
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00');
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080');
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
});
it('should not throw an error when percent is outside 0-1 range', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#0000ff', percent: 1 },
];
expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000');
expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff');
});
it('should throw an error when less than two stops are provided', () => {
expect(() => {
colorAtGradientPercent([], 0.5);
}).toThrow('colorAtGradientPercent requires at least two color stops');
expect(() => {
colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5);
}).toThrow('colorAtGradientPercent requires at least two color stops');
});
});
});
@@ -4,8 +4,6 @@
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
@@ -394,53 +392,6 @@ 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: GradientStop[], percent: number): tinycolor.Instance {
if (!stops || stops.length < 2) {
throw new Error('colorAtGradientPercent requires at least two color stops');
}
// normalize and sort stops by percent
const sorted = stops
.map((s) => ({ color: s.color, percent: clamp(s.percent, 0, 1) }))
.sort((a, b) => a.percent - b.percent);
// percent outside range
if (percent <= sorted[0].percent) {
return tinycolor(sorted[0].color);
}
if (percent >= sorted[sorted.length - 1].percent) {
return tinycolor(sorted[sorted.length - 1].color);
}
// find surrounding stops
let left = sorted[0];
let right = sorted[sorted.length - 1];
for (let i = 1; i < sorted.length; i++) {
if (percent <= sorted[i].percent) {
left = sorted[i - 1];
right = sorted[i];
break;
}
}
const range = right.percent - left.percent;
const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1
// tinycolor.mix expects amount as percentage of the second color
const mixed = tinycolor.mix(left.color, right.color, t * 100);
// return hex6 if opaque, hex8 if has alpha
return mixed;
}
interface DecomposeColor {
type: string;
values: any;
@@ -463,5 +414,4 @@ export const colorManipulator = {
darken,
lighten,
onBackground,
colorAtGradientPercent,
};
@@ -59,9 +59,3 @@ export interface ThemeRichColor {
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
/** @alpha */
export interface GradientStop {
color: string;
percent: number;
}
@@ -1,18 +1,17 @@
import { useId, useMemo, memo } from 'react';
import { DisplayProcessor, FieldDisplay } from '@grafana/data';
import { 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';
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
import { drawRadialArcPath, toRad } from './utils';
export interface RadialArcPathPropsBase {
arcLengthDeg: number;
color?: string;
dimensions: GaugeDimensions;
displayProcessor: DisplayProcessor;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
glowFilter?: string;
gradientMode: RadialGradientMode;
@@ -38,7 +37,6 @@ export const RadialArcPath = memo(
arcLengthDeg,
color,
dimensions,
displayProcessor,
fieldDisplay,
glowFilter,
gradientMode,
@@ -54,8 +52,8 @@ export const RadialArcPath = memo(
if (gradientMode === 'none') {
return [];
}
return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color);
}, [gradientMode, fieldDisplay, theme, displayProcessor]);
return buildGradientColors(gradientMode, theme, fieldDisplay, fieldDisplay.display.color);
}, [gradientMode, fieldDisplay, theme]);
const { guideDotColors, endpointColors } = useMemo(() => {
if (!showGuideDots || gradientStops.length === 0) {
@@ -67,7 +65,7 @@ export const RadialArcPath = memo(
return {
guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0),
endpointColors:
shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0) : undefined,
shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 1) : undefined,
};
}, [showGuideDots, fieldDisplay, gradientStops, shape]);
@@ -1,16 +1,14 @@
import { DisplayProcessor, FieldDisplay } from '@grafana/data';
import { FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
export interface RadialBarProps {
angle: number;
angleRange: number;
dimensions: GaugeDimensions;
displayProcessor: DisplayProcessor;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
glowFilter?: string;
gradientMode: RadialGradientMode;
@@ -22,7 +20,6 @@ export function RadialBar({
angle,
angleRange,
dimensions,
displayProcessor,
fieldDisplay,
glowFilter,
gradientMode,
@@ -36,10 +33,9 @@ export function RadialBar({
{/** Track */}
<RadialArcPath
arcLengthDeg={angleRange - angle}
fieldDisplay={fieldDisplay}
color={theme.colors.action.hover}
dimensions={dimensions}
displayProcessor={displayProcessor}
fieldDisplay={fieldDisplay}
gradientMode="none"
roundedBars={roundedBars}
shape={shape}
@@ -50,7 +46,6 @@ export function RadialBar({
arcLengthDeg={angle}
color={gradientMode === 'none' ? fieldDisplay.display.color : undefined}
dimensions={dimensions}
displayProcessor={displayProcessor}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
gradientMode={gradientMode}
@@ -1,15 +1,21 @@
import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { memo } from 'react';
import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
import {
getAngleBetweenSegments,
getFieldConfigMinMax,
getFieldDisplayProcessor,
getOptimalSegmentCount,
} from './utils';
export interface RadialBarSegmentedProps {
fieldDisplay: FieldDisplay;
displayProcessor: DisplayProcessor;
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
angleRange: number;
startAngle: number;
glowFilter?: string;
@@ -18,75 +24,55 @@ export interface RadialBarSegmentedProps {
shape: RadialShape;
gradientMode: RadialGradientMode;
}
export function RadialBarSegmented({
fieldDisplay,
displayProcessor,
dimensions,
startAngle,
angleRange,
glowFilter,
segmentCount,
segmentSpacing,
shape,
gradientMode,
}: RadialBarSegmentedProps) {
const segments: React.ReactNode[] = [];
const theme = useTheme2();
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
const min = fieldDisplay.field.min ?? 0;
const max = fieldDisplay.field.max ?? 100;
const value = fieldDisplay.display.numeric;
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
export const RadialBarSegmented = memo(
({
fieldDisplay,
dimensions,
startAngle,
angleRange,
glowFilter,
segmentCount,
segmentSpacing,
shape,
gradientMode,
}: RadialBarSegmentedProps) => {
const theme = useTheme2();
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 (gradientMode === 'none') {
segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR;
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;
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 (gradientMode === 'none') {
segmentColor = getFieldDisplayProcessor(fieldDisplay)(angleValue).color ?? FALLBACK_COLOR;
}
segments.push(
<RadialArcPath
key={i}
startAngle={segmentAngle}
dimensions={dimensions}
fieldDisplay={fieldDisplay}
color={segmentColor}
shape={shape}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
gradientMode={gradientMode}
/>
);
}
segments.push(
<RadialArcPath
key={i}
startAngle={segmentAngle}
dimensions={dimensions}
color={segmentColor}
shape={shape}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
gradientMode={gradientMode}
fieldDisplay={fieldDisplay}
displayProcessor={displayProcessor}
/>
);
return <g>{segments}</g>;
}
);
return <g>{segments}</g>;
}
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
// Max spacing is 8 degrees between segments
// Changing this constant could be considered a breaking change
const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2);
return segmentSpacing * maxAngleBetweenSegments;
}
function getOptimalSegmentCount(
dimensions: GaugeDimensions,
segmentSpacing: number,
segmentCount: number,
range: number
) {
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range);
const innerRadius = dimensions.radius - dimensions.barWidth / 2;
const circumference = Math.PI * innerRadius * 2 * (range / 360);
const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3));
return Math.min(maxSegments, segmentCount);
}
RadialBarSegmented.displayName = 'RadialBarSegmented';
@@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema';
import { useTheme2 } from '../../themes/ThemeContext';
import { Stack } from '../Layout/Stack/Stack';
import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge';
import { RadialGauge, RadialGaugeProps } from './RadialGauge';
import { RadialGradientMode, RadialShape, RadialTextMode } from './types';
interface StoryProps extends RadialGaugeProps {
value: number;
@@ -1,14 +1,7 @@
import { css, cx } from '@emotion/css';
import { isNumber } from 'lodash';
import { useId } from 'react';
import {
DisplayValueAlignmentFactors,
FieldDisplay,
getDisplayProcessor,
GrafanaTheme2,
TimeRange,
} from '@grafana/data';
import { DisplayValueAlignmentFactors, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
@@ -21,6 +14,7 @@ import { RadialSparkline } from './RadialSparkline';
import { RadialText } from './RadialText';
import { ThresholdsBar } from './ThresholdsBar';
import { GlowGradient, MiddleCircleGlow } from './effects';
import { RadialGradientMode, RadialShape, RadialTextMode } from './types';
import { calculateDimensions, getValueAngleForValue } from './utils';
export interface RadialGaugeProps {
@@ -72,10 +66,6 @@ export interface RadialGaugeProps {
timeRange?: TimeRange;
}
export type RadialGradientMode = 'none' | 'auto';
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
export type RadialShape = 'circle' | 'gauge';
/**
* https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs
*/
@@ -130,7 +120,6 @@ export function RadialGauge(props: RadialGaugeProps) {
showScaleLabels
);
const displayProcessor = getFieldDisplayProcessor(displayValue);
const glowFilterId = `glow-${gaugeId}`;
if (segmentCount > 1) {
@@ -146,7 +135,6 @@ export function RadialGauge(props: RadialGaugeProps) {
segmentSpacing={segmentSpacing}
shape={shape}
gradientMode={gradient}
displayProcessor={displayProcessor}
/>
);
} else {
@@ -161,7 +149,6 @@ export function RadialGauge(props: RadialGaugeProps) {
glowFilter={`url(#${glowFilterId})`}
shape={shape}
gradientMode={gradient}
displayProcessor={displayProcessor}
fieldDisplay={displayValue}
/>
);
@@ -224,7 +211,6 @@ export function RadialGauge(props: RadialGaugeProps) {
glowFilter={`url(#${glowFilterId})`}
shape={shape}
gradientMode={gradient}
displayProcessor={displayProcessor}
/>
);
}
@@ -270,17 +256,6 @@ export function RadialGauge(props: RadialGaugeProps) {
);
}
function getFieldDisplayProcessor(displayValue: FieldDisplay) {
if (displayValue.view && isNumber(displayValue.colIndex)) {
const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex);
if (dp) {
return dp;
}
}
return getDisplayProcessor();
}
function getStyles(theme: GrafanaTheme2) {
return {
vizWrapper: css({
@@ -1,87 +1,84 @@
import { memo } from 'react';
import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data';
import { t } from '@grafana/i18n';
import { measureText } from '../../utils/measureText';
import { GaugeDimensions, toCartesian } from './utils';
import { RadialGaugeDimensions } from './types';
import { getFieldConfigMinMax, toCartesian } from './utils';
interface RadialScaleLabelsProps {
fieldDisplay: FieldDisplay;
theme: GrafanaTheme2;
thresholds: Threshold[];
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
startAngle: number;
endAngle: number;
angleRange: number;
}
export function RadialScaleLabels({
fieldDisplay,
thresholds,
theme,
dimensions,
startAngle,
endAngle,
angleRange,
}: RadialScaleLabelsProps) {
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
const LINE_HEIGHT_FACTOR = 1.2;
const fieldConfig = fieldDisplay.field;
const min = fieldConfig.min ?? 0;
const max = fieldConfig.max ?? 100;
export const RadialScaleLabels = memo(
({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => {
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
const [min, max] = getFieldConfigMinMax(fieldDisplay);
const fontSize = scaleLabelsFontSize;
const textLineHeight = scaleLabelsFontSize * 1.2;
const radius = scaleLabelsRadius - textLineHeight;
const fontSize = scaleLabelsFontSize;
const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR;
const radius = scaleLabelsRadius - textLineHeight;
function getTextPosition(text: string, value: number, index: number) {
const isLast = index === thresholds.length - 1;
const isFirst = index === 0;
function getTextPosition(text: string, value: number, index: number) {
const isLast = index === thresholds.length - 1;
const isFirst = index === 0;
let valueDeg = ((value - min) / (max - min)) * angleRange;
let finalAngle = startAngle + valueDeg;
let valueDeg = ((value - min) / (max - min)) * angleRange;
let finalAngle = startAngle + valueDeg;
// Now adjust the final angle based on the label text width and the labels position on the arc
let measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
// Now adjust the final angle based on the label text width and the labels position on the arc
let measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
// the centering is different for gauge or circle shapes for some reason
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
// the centering is different for gauge or circle shapes for some reason
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
// For circle gauges we need to shift the first label more
if (isFirst) {
finalAngle += textWidthAngle;
// For circle gauges we need to shift the first label more
if (isFirst) {
finalAngle += textWidthAngle;
}
// For circle gauges we need to shift the last label more
if (isLast && endAngle === 360) {
finalAngle -= textWidthAngle;
}
const position = toCartesian(centerX, centerY, radius, finalAngle);
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
}
// For circle gauges we need to shift the last label more
if (isLast && endAngle === 360) {
finalAngle -= textWidthAngle;
}
const position = toCartesian(centerX, centerY, radius, finalAngle);
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
return (
<g>
{thresholds.map((threshold, index) => {
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
return (
<text
key={index}
x={labelPos.x}
y={labelPos.y}
fontSize={fontSize}
fill={theme.colors.text.primary}
transform={labelPos.transform}
aria-label={t(`gauge.threshold`, 'Threshold {{value}}', { value: threshold.value })}
>
{threshold.value}
</text>
);
})}
</g>
);
}
);
return (
<g>
{thresholds.map((threshold, index) => {
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
return (
<text
key={index}
x={labelPos.x}
y={labelPos.y}
fontSize={fontSize}
fill={theme.colors.text.primary}
transform={labelPos.transform}
aria-label={t(`gauge.threshold`, 'Threshold {{value}}', { value: threshold.value })}
>
{threshold.value}
</text>
);
})}
</g>
);
}
RadialScaleLabels.displayName = 'RadialScaleLabels';
@@ -3,17 +3,25 @@ import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana
import { Sparkline } from '../Sparkline/Sparkline';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
interface RadialSparklineProps {
sparkline: FieldDisplay['sparkline'];
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
theme: GrafanaTheme2;
color?: string;
shape?: RadialShape;
textMode: Exclude<RadialTextMode, 'auto'>;
}
const SPARKLINE_HEIGHT_DIVISOR = 4;
const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4;
const SPARKLINE_WIDTH_FACTOR_ARC = 1.4;
const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6;
const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4;
const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3;
const SPARKLINE_SPACING = 8;
export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) {
const { radius, barWidth } = dimensions;
@@ -22,12 +30,12 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape, te
}
const showNameAndValue = textMode === 'value_and_name';
const height = radius / (showNameAndValue ? 4 : 3);
const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth;
const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR);
const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth;
const topPos =
shape === 'gauge'
? `${dimensions.gaugeBottomY - height}px`
: `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`;
? dimensions.gaugeBottomY - height - SPARKLINE_SPACING
: `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`;
const config: FieldConfig<GraphFieldConfig> = {
color: {
@@ -1,4 +1,5 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import {
DisplayValue,
@@ -11,13 +12,12 @@ import {
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
interface RadialTextProps {
displayValue: DisplayValue;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
textMode: Exclude<RadialTextMode, 'auto'>;
shape: RadialShape;
sparkline?: FieldSparkline;
@@ -26,123 +26,131 @@ interface RadialTextProps {
nameManualFontSize?: number;
}
export function RadialText({
displayValue,
theme,
dimensions,
textMode,
shape,
sparkline,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
const LINE_HEIGHT_FACTOR = 1.21;
const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82;
const NAME_TO_HEIGHT_FACTOR = 0.45;
const LARGE_RADIUS_SCALING_DECAY = 0.86;
const MAX_TEXT_WIDTH_DIVISOR = 7;
const MAX_NAME_HEIGHT_DIVISOR = 4;
const VALUE_SPACE_PERCENTAGE = 0.7;
const SPARKLINE_SPACING = 8;
const MIN_UNIT_FONT_SIZE = 5;
if (textMode === 'none') {
return null;
}
export const RadialText = memo(
({
displayValue,
theme,
dimensions,
textMode,
shape,
sparkline,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) => {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
if (textMode === 'none') {
return null;
}
const showValue = textMode === 'value' || textMode === 'value_and_name';
const showName = textMode === 'name' || textMode === 'value_and_name';
const maxTextWidth = radius * 2 - barWidth - radius / 7;
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
// Not sure where this comes from but svg text is not using body line-height
const lineHeight = 1.21;
const valueWidthToRadiusFactor = 0.82;
const nameToHeightFactor = 0.45;
const largeRadiusScalingDecay = 0.86;
const showValue = textMode === 'value' || textMode === 'value_and_name';
const showName = textMode === 'name' || textMode === 'value_and_name';
const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR;
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
let maxNameHeight = radius / 4;
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR;
if (showValue && showName) {
maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay);
}
if (showValue && showName) {
maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
}
const valueFontSize =
valueManualFontSize ??
calculateFontSize(
valueToAlignTo,
maxTextWidth,
maxValueHeight,
lineHeight,
undefined,
theme.typography.body.fontWeight
const valueFontSize =
valueManualFontSize ??
calculateFontSize(
valueToAlignTo,
maxTextWidth,
maxValueHeight,
LINE_HEIGHT_FACTOR,
undefined,
theme.typography.body.fontWeight
);
const nameFontSize =
nameManualFontSize ??
calculateFontSize(
nameToAlignTo,
maxTextWidth,
maxNameHeight,
LINE_HEIGHT_FACTOR,
undefined,
theme.typography.body.fontWeight
);
const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE);
const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR;
const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR;
const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY;
const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
}
if (sparkline) {
yOffset -= SPARKLINE_SPACING;
}
return (
<g transform={`translate(0, ${yOffset})`}>
{showValue && (
<text
x={centerX}
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
{displayValue.suffix ?? ''}
</tspan>
</text>
)}
{showName && (
<text
fontSize={nameFontSize}
x={centerX}
y={nameY}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
>
{displayValue.title}
</text>
)}
</g>
);
const nameFontSize =
nameManualFontSize ??
calculateFontSize(
nameToAlignTo,
maxTextWidth,
maxNameHeight,
lineHeight,
undefined,
theme.typography.body.fontWeight
);
const unitFontSize = Math.max(valueFontSize * 0.7, 5);
const valueHeight = valueFontSize * lineHeight;
const nameHeight = nameFontSize * lineHeight;
const valueY = showName ? centerY - nameHeight * 0.3 : centerY;
const nameY = showValue ? valueY + valueHeight * 0.7 : centerY;
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
}
if (sparkline) {
yOffset -= 8;
}
);
return (
<g transform={`translate(0, ${yOffset})`}>
{showValue && (
<text
x={centerX}
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
{displayValue.suffix ?? ''}
</tspan>
</text>
)}
{showName && (
<text
fontSize={nameFontSize}
x={centerX}
y={nameY}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
>
{displayValue.title}
</text>
)}
</g>
);
}
RadialText.displayName = 'RadialText';
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = (_theme: GrafanaTheme2) => ({
text: css({
verticalAlign: 'bottom',
}),
@@ -1,11 +1,11 @@
import { DisplayProcessor, FieldDisplay, Threshold } from '@grafana/data';
import { FieldDisplay, Threshold } from '@grafana/data';
import { RadialArcPath } from './RadialArcPath';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
import { RadialGaugeDimensions, RadialGradientMode, RadialShape } from './types';
import { getFieldConfigMinMax } from './utils';
export interface Props {
dimensions: GaugeDimensions;
interface ThresholdsBarProps {
dimensions: RadialGaugeDimensions;
angleRange: number;
startAngle: number;
endAngle: number;
@@ -15,8 +15,8 @@ export interface Props {
glowFilter?: string;
thresholds: Threshold[];
gradientMode: RadialGradientMode;
displayProcessor: DisplayProcessor;
}
export function ThresholdsBar({
dimensions,
fieldDisplay,
@@ -27,18 +27,15 @@ export function ThresholdsBar({
thresholds,
shape,
gradientMode,
displayProcessor,
}: Props) {
const fieldConfig = fieldDisplay.field;
const min = fieldConfig.min ?? 0;
const max = fieldConfig.max ?? 100;
}: ThresholdsBarProps) {
const thresholdDimensions = {
...dimensions,
barWidth: dimensions.thresholdsBarWidth,
radius: dimensions.thresholdsBarRadius,
};
const [min, max] = getFieldConfigMinMax(fieldDisplay);
let currentStart = startAngle;
let paths: React.ReactNode[] = [];
@@ -52,21 +49,21 @@ export function ThresholdsBar({
valueDeg = 0;
}
let lengthDeg = valueDeg - currentStart + startAngle;
const lengthDeg = valueDeg - currentStart + startAngle;
const color = gradientMode === 'none' ? threshold.color : undefined;
paths.push(
<RadialArcPath
key={i}
startAngle={currentStart}
arcLengthDeg={lengthDeg}
color={gradientMode === 'none' ? threshold.color : undefined}
shape={shape}
color={color}
dimensions={thresholdDimensions}
roundedBars={roundedBars}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
gradientMode={gradientMode}
displayProcessor={displayProcessor}
fieldDisplay={fieldDisplay}
roundedBars={roundedBars}
shape={shape}
startAngle={currentStart}
/>
);
@@ -0,0 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`;
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`;
@@ -0,0 +1,286 @@
import { defaultsDeep } from 'lodash';
import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import {
buildGradientColors,
colorAtGradientPercent,
getEndpointColors,
getGradientCss,
getGuideDotColors,
} from './colors';
export type DeepPartial<T> = {
[P in keyof T]?: DeepPartial<T[P]>;
};
describe('RadialGauge color utils', () => {
describe('buildGradientColors', () => {
const createField = (colorMode: FieldColorModeId): Field =>
({
type: FieldType.number,
name: 'Test Field',
config: {
color: {
mode: colorMode,
},
thresholds: {
mode: ThresholdsMode.Absolute,
steps: [
{ value: -Infinity, color: 'green' },
{ value: 50, color: 'yellow' },
{ value: 80, color: 'red' },
],
},
},
values: [70, 40, 30, 90, 55],
}) satisfies Field;
const buildFieldDisplay = (field: Field, part = {}): FieldDisplay =>
defaultsDeep(part, {
field: field.config,
colIndex: 0,
view: {
getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: undefined }))),
},
display: {
numeric: 75,
},
});
it('should return the baseColor if gradientMode is none', () => {
expect(
buildGradientColors('none', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
).toEqual([
{ color: '#FF0000', percent: 0 },
{ color: '#FF0000', percent: 1 },
]);
});
it('uses the fallback color if no baseColor is set', () => {
expect(
buildGradientColors('none', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))
).toEqual([
{ color: FALLBACK_COLOR, percent: 0 },
{ color: FALLBACK_COLOR, percent: 1 },
]);
});
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
expect(
buildGradientColors(
'auto',
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) },
})
)
).toEqual([
{ color: '#444444', percent: 0 },
{ color: '#FADE2A', percent: 0.5 },
{ color: '#F2495C', percent: 0.8 },
{ color: '#444444', percent: 1 },
]);
});
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
expect(
buildGradientColors(
'auto',
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.Thresholds)),
'#FF0000'
)
).toEqual([
{ color: '#FF0000', percent: 0 },
{ color: '#FADE2A', percent: 0.5 },
{ color: '#F2495C', percent: 0.8 },
{ color: '#FF0000', percent: 1 },
]);
});
it('should return gradient colors for continuous color modes', () => {
expect(
buildGradientColors(
'auto',
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
'#00FF00'
)
).toEqual([
{
color: 'rgb(0, 32, 81)',
percent: 0,
},
{
color: 'rgb(17, 54, 108)',
percent: 0.125,
},
{
color: 'rgb(60, 77, 110)',
percent: 0.25,
},
{
color: 'rgb(98, 100, 111)',
percent: 0.375,
},
{
color: 'rgb(127, 124, 117)',
percent: 0.5,
},
{
color: 'rgb(154, 148, 120)',
percent: 0.625,
},
{
color: 'rgb(187, 175, 113)',
percent: 0.75,
},
{
color: 'rgb(226, 203, 92)',
percent: 0.875,
},
{
color: 'rgb(253, 234, 69)',
percent: 1,
},
]);
});
it('should return gradient colors for by-value color modes', () => {
expect(
buildGradientColors('auto', createTheme(), buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues)))
).toEqual([
{ color: '#181b1f', percent: 0 },
{ color: '#1F60C4', percent: 1 },
]);
});
it('should return gradient colors for fixed color mode', () => {
expect(
buildGradientColors('auto', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#442299')
).toEqual([
{ color: '#14175a', percent: 0 },
{ color: '#a146da', percent: 1 },
]);
});
});
describe('colorAtGradientPercent', () => {
it('should calculate the color at a given percent in a gradient of two colors', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#0000ff', percent: 1 },
];
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040');
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080');
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf');
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
});
it('should calculate the color at a given percent in a gradient of multiple colors', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000');
expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000');
expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00');
expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080');
expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff');
});
it('should not throw an error when percent is outside 0-1 range', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#0000ff', percent: 1 },
];
expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000');
expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff');
});
it('should throw an error when less than two stops are provided', () => {
expect(() => {
colorAtGradientPercent([], 0.5);
}).toThrow('colorAtGradientPercent requires at least two color stops');
expect(() => {
colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5);
}).toThrow('colorAtGradientPercent requires at least two color stops');
});
});
describe('getEndpointColors', () => {
it('should return the first and last colors in the gradient', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
const [startColor, endColor] = getEndpointColors(gradient);
expect(startColor).toBe('#ff0000');
expect(endColor).toBe('#0000ff');
});
it('should return the correct end color based on percent', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
const [startColor, endColor] = getEndpointColors(gradient, 0.25);
expect(startColor).toBe('#ff0000');
expect(endColor).toBe('#808000');
});
it('should handle gradients with only one colors', () => {
const gradient = [{ color: '#ff0000', percent: 0 }];
const [startColor, endColor] = getEndpointColors(gradient);
expect(startColor).toBe('#ff0000');
expect(endColor).toBe('#ff0000');
});
it('should throw an error when no colors are provided', () => {
expect(() => {
getEndpointColors([]);
}).toThrow('getEndpointColors requires at least one color stop');
});
});
describe('getGradientCss', () => {
it('should return conic-gradient CSS for circle shape', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
const css = getGradientCss(gradient, 'circle');
expect(css).toBe('conic-gradient(from 0deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)');
});
it('should return linear-gradient CSS for arc shape', () => {
const gradient = [
{ color: '#ff0000', percent: 0 },
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
const css = getGradientCss(gradient, 'gauge');
expect(css).toBe('linear-gradient(90deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)');
});
});
describe('getGuideDotColors', () => {
it('should return contrasting guide dot colors based on the gradient endpoints and percent', () => {
const gradient = [
{ color: '#000000', percent: 0 },
{ color: '#ffffff', percent: 0.5 },
{ color: '#ffffff', percent: 1 },
];
const [startDotColor, endDotColor] = getGuideDotColors(gradient, 0.35);
expect(startDotColor).toBe('#fbfbfb');
expect(endDotColor).toBe('#111217');
});
});
});
@@ -1,25 +1,16 @@
import tinycolor from 'tinycolor2';
import {
colorManipulator,
DisplayProcessor,
FALLBACK_COLOR,
FieldDisplay,
getFieldColorMode,
GradientStop,
GrafanaTheme2,
} from '@grafana/data';
import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GradientStop, RadialGradientMode, RadialShape } from './types';
import { getFieldConfigMinMax, getFieldDisplayProcessor } from './utils';
export function buildGradientColors(
gradientMode: RadialGradientMode,
theme: GrafanaTheme2,
displayProcessor: DisplayProcessor,
fieldDisplay: FieldDisplay,
baseColor = FALLBACK_COLOR,
forSegment?: boolean
baseColor = FALLBACK_COLOR
): GradientStop[] {
if (gradientMode === 'none') {
return [
@@ -30,13 +21,14 @@ export function buildGradientColors(
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
// thresholds get special handling
if (colorMode.id === FieldColorModeId.Thresholds) {
const displayProcessor = getFieldDisplayProcessor(fieldDisplay);
const [min, max] = getFieldConfigMinMax(fieldDisplay);
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 ?? FALLBACK_COLOR, percent: 0 },
{ color: displayProcessor(min).color ?? baseColor, percent: 0 },
];
for (const threshold of thresholds) {
@@ -51,15 +43,15 @@ export function buildGradientColors(
return result;
}
if (colorMode.isContinuous && colorMode.getColors && !forSegment) {
// Handle continuous color modes first
// Handle continuous color modes before other by-value modes
if (colorMode.isContinuous && colorMode.getColors) {
const colors = colorMode.getColors(theme);
return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 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
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);
@@ -73,8 +65,7 @@ export function buildGradientColors(
];
}
// 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
// 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(-20).darken(5);
const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10);
@@ -89,13 +80,74 @@ export function buildGradientColors(
];
}
export function getEndpointColors(gradientStops: GradientStop[], percent = 0): [string, string] {
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);
}
/**
* @alpha - perhaps this should go in colorManipulator.ts
* 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: GradientStop[], percent: number): tinycolor.Instance {
if (!stops || stops.length < 2) {
throw new Error('colorAtGradientPercent requires at least two color stops');
}
// normalize and sort stops by percent
const sorted = stops
.map((s) => ({ color: s.color, percent: clamp(s.percent, 0, 1) }))
.sort((a, b) => a.percent - b.percent);
// percent outside range
if (percent <= sorted[0].percent) {
return tinycolor(sorted[0].color);
}
if (percent >= sorted[sorted.length - 1].percent) {
return tinycolor(sorted[sorted.length - 1].color);
}
// find surrounding stops
let left = sorted[0];
let right = sorted[sorted.length - 1];
for (let i = 1; i < sorted.length; i++) {
if (percent <= sorted[i].percent) {
left = sorted[i - 1];
right = sorted[i];
break;
}
}
const range = right.percent - left.percent;
const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1
// tinycolor.mix expects amount as percentage of the second color
const mixed = tinycolor.mix(left.color, right.color, t * 100);
// return hex6 if opaque, hex8 if has alpha
return mixed;
}
export function getEndpointColors(gradientStops: GradientStop[], percent = 1): [string, string] {
if (gradientStops.length === 0) {
throw new Error('getEndpointColors requires at least one color stop');
}
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);
const endColorByPercentage = colorAtGradientPercent(gradientStops, percent);
endColor =
endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String();
}
@@ -109,14 +161,18 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape
: `linear-gradient(90deg, ${colorStrings.join(', ')})`;
}
// the theme does not make the full palette available to us, and we
// don't want transparent colors which our grays usually have.
const GRAY_05 = '#111217';
const GRAY_90 = '#fbfbfb';
const CONTRAST_THRESHOLD_MAX = 4.5;
const getGuideDotColor = (color: string): string => {
const darkColor = '#111217'; // gray05
const lightColor = '#fbfbfb'; // gray90
const darkColor = GRAY_05;
const lightColor = GRAY_90;
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;
};
export function getGuideDotColors(gradientStops: GradientStop[], percent = 0): [string, string] {
export function getGuideDotColors(gradientStops: GradientStop[], percent: number): [string, string] {
const [startColor, endColor] = getEndpointColors(gradientStops, percent);
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
}
@@ -1,4 +1,4 @@
import { GaugeDimensions } from './utils';
import { RadialGaugeDimensions } from './types';
export interface GlowGradientProps {
id: string;
@@ -27,7 +27,7 @@ const CENTER_GLOW_OPACITY = 0.15;
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
return (
<radialGradient id={`circle-glow-${gaugeId}`} r={'50%'} fr={'0%'}>
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
</radialGradient>
@@ -35,7 +35,7 @@ export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color:
}
export interface CenterGlowProps {
dimensions: GaugeDimensions;
dimensions: RadialGaugeDimensions;
gaugeId: string;
color?: string;
}
@@ -46,7 +46,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
return (
<>
<defs>
<radialGradient id={gradientId} r={'50%'} fr={'0%'}>
<radialGradient id={gradientId} r="50%" fr="0%">
<stop offset="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
</radialGradient>
@@ -0,0 +1,26 @@
export type RadialGradientMode = 'none' | 'auto';
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
export type RadialShape = 'circle' | 'gauge';
export interface RadialGaugeDimensions {
margin: number;
radius: number;
centerX: number;
centerY: number;
barWidth: number;
endAngle?: number;
barIndex: number;
thresholdsBarRadius: number;
thresholdsBarWidth: number;
thresholdsBarSpacing: number;
scaleLabelsFontSize: number;
scaleLabelsSpacing: number;
scaleLabelsRadius: number;
gaugeBottomY: number;
}
/** @alpha - perhaps this should go in @grafana/data */
export interface GradientStop {
color: string;
percent: number;
}
@@ -1,24 +1,111 @@
import { FieldDisplay } from '@grafana/data';
import { DataFrameView, FieldDisplay } from '@grafana/data';
import type { RadialGaugeProps } from './RadialGauge';
import { calculateDimensions, toRad, getValueAngleForValue } from './utils';
import { RadialGaugeDimensions } from './types';
import {
calculateDimensions,
toRad,
getValueAngleForValue,
drawRadialArcPath,
getFieldConfigMinMax,
getFieldDisplayProcessor,
getAngleBetweenSegments,
getOptimalSegmentCount,
} from './utils';
describe('RadialGauge utils', () => {
function calc(overrides: Partial<RadialGaugeProps & { barIndex: number }> = {}) {
return calculateDimensions(
overrides.width ?? 200,
overrides.height ?? 200,
overrides.shape === 'gauge' ? 110 : 360,
overrides.glowBar ?? false,
overrides.roundedBars ?? false,
overrides.barWidthFactor ?? 0.4,
overrides.barIndex ?? 0,
overrides.thresholdsBar ?? false,
overrides.showScaleLabels ?? false
);
}
describe('getFieldDisplayProcessor', () => {
it('should return display processor from view when available', () => {
const mockProcessor = jest.fn();
const mockView = {
getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor),
} as unknown as DataFrameView;
const fieldDisplay: FieldDisplay = {
display: { numeric: 50, text: '50', color: 'blue' },
field: {},
view: mockView,
colIndex: 0,
rowIndex: 0,
name: 'test',
getLinks: () => [],
hasLinks: false,
};
const dp = getFieldDisplayProcessor(fieldDisplay);
expect(dp).toBe(mockProcessor);
expect(mockView.getFieldDisplayProcessor).toHaveBeenCalledWith(0);
});
it('should return default display processor when view is not available', () => {
const fieldDisplay: FieldDisplay = {
display: { numeric: 50, text: '50', color: 'blue' },
field: {},
view: undefined,
colIndex: 0,
rowIndex: 0,
name: 'test',
getLinks: () => [],
hasLinks: false,
};
const dp = getFieldDisplayProcessor(fieldDisplay);
expect(dp).toBeDefined();
expect(typeof dp).toBe('function');
});
});
describe('getFieldConfigMinMax', () => {
it('should return min and max from field config when defined', () => {
const fieldDisplay: FieldDisplay = {
display: { numeric: 50, text: '50', color: 'blue' },
field: { min: 10, max: 90 },
view: undefined,
colIndex: 0,
rowIndex: 0,
name: 'test',
getLinks: () => [],
hasLinks: false,
};
const [min, max] = getFieldConfigMinMax(fieldDisplay);
expect(min).toBe(10);
expect(max).toBe(90);
});
it('should return default min and max when not defined in field config', () => {
const fieldDisplay: FieldDisplay = {
display: { numeric: 50, text: '50', color: 'blue' },
field: {},
view: undefined,
colIndex: 0,
rowIndex: 0,
name: 'test',
getLinks: () => [],
hasLinks: false,
};
const [min, max] = getFieldConfigMinMax(fieldDisplay);
expect(min).toBe(0);
expect(max).toBe(100);
});
});
describe('calculateDimensions', () => {
function calc(overrides: Partial<RadialGaugeProps & { barIndex: number }> = {}) {
return calculateDimensions(
overrides.width ?? 200,
overrides.height ?? 200,
overrides.shape === 'gauge' ? 110 : 360,
overrides.glowBar ?? false,
overrides.roundedBars ?? false,
overrides.barWidthFactor ?? 0.4,
overrides.barIndex ?? 0,
overrides.thresholdsBar ?? false,
overrides.showScaleLabels ?? false
);
}
it('should calculate basic dimensions for a square gauge', () => {
const result = calc();
@@ -194,4 +281,86 @@ describe('RadialGauge utils', () => {
expect(result.angle).toBe(240);
});
});
describe('drawRadialArcPath', () => {
const defaultDims: RadialGaugeDimensions = Object.freeze({
centerX: 100,
centerY: 100,
radius: 80,
barWidth: 20,
margin: 0,
barIndex: 0,
thresholdsBarWidth: 0,
thresholdsBarSpacing: 0,
thresholdsBarRadius: 0,
scaleLabelsFontSize: 0,
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
});
it.each([
{ description: 'quarter arc', startAngle: 0, endAngle: 90 },
{ description: 'half arc', startAngle: 0, endAngle: 180 },
{ description: 'three quarter arc', startAngle: 0, endAngle: 270 },
{ description: 'rounded bars', startAngle: 0, endAngle: 270, roundedBars: true },
{ description: 'wide bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 50 } },
{ description: 'narrow bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 5 } },
{ description: 'narrow radius', startAngle: 0, endAngle: 180, dimensions: { radius: 50 } },
{
description: 'center x and y',
startAngle: 0,
endAngle: 360,
roundedBars: true,
dimensions: { centerX: 150, centerY: 200 },
},
])(`should draw correct path for $description`, ({ startAngle, endAngle, dimensions, roundedBars }) => {
const path = drawRadialArcPath(startAngle, endAngle, { ...defaultDims, ...dimensions }, roundedBars);
expect(path).toMatchSnapshot();
});
describe('edge cases', () => {
it('should adjust 360deg or greater arcs to avoid SVG rendering issues', () => {
expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims));
expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims));
});
it('should throw an error if inner radius collapses to zero or below', () => {
const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 };
expect(() => drawRadialArcPath(0, 180, smallRadiusDims)).toThrow(
'Inner radius collapsed to zero or below, cannot draw radial arc path'
);
});
});
});
describe('getAngleBetweenSegments', () => {
it('should calculate angle between segments based on spacing and count', () => {
expect(getAngleBetweenSegments(2, 10, 360)).toBe(48);
expect(getAngleBetweenSegments(5, 15, 180)).toBe(40);
});
});
describe('getOptimalSegmentCount', () => {
it('should adjust segment count based on dimensions and spacing', () => {
const dimensions: RadialGaugeDimensions = {
centerX: 100,
centerY: 100,
radius: 80,
barWidth: 20,
margin: 0,
barIndex: 0,
thresholdsBarWidth: 0,
thresholdsBarSpacing: 0,
thresholdsBarRadius: 0,
scaleLabelsFontSize: 0,
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
};
expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8);
expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5);
});
});
});
@@ -1,11 +1,34 @@
import { FieldDisplay } from '@grafana/data';
import { FieldDisplay, getDisplayProcessor } from '@grafana/data';
export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) {
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
import { RadialGaugeDimensions } from './types';
export function getFieldDisplayProcessor(displayValue: FieldDisplay) {
if (displayValue.view && displayValue.colIndex != null) {
const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex);
if (dp) {
return dp;
}
}
return getDisplayProcessor();
}
export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) {
const min = fieldDisplay.field.min ?? 0;
const max = fieldDisplay.field.max ?? 100;
return [min, max];
}
let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange;
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;
if (angle > angleRange) {
angle = angleRange;
@@ -26,24 +49,19 @@ export function toRad(angle: number) {
return ((angle - 90) * Math.PI) / 180;
}
export interface GaugeDimensions {
margin: number;
radius: number;
centerX: number;
centerY: number;
barWidth: number;
endAngle?: number;
barIndex: number;
thresholdsBarRadius: number;
thresholdsBarWidth: number;
thresholdsBarSpacing: number;
showScaleLabels?: boolean;
scaleLabelsFontSize: number;
scaleLabelsSpacing: number;
scaleLabelsRadius: number;
gaugeBottomY: number;
}
/**
* returns the calculated dimensions for the radial gauge
* @param width
* @param height
* @param endAngle
* @param glow
* @param roundedBars
* @param barWidthFactor
* @param barIndex
* @param thresholdBar
* @param showScaleLabels
* @returns {RadialGaugeDimensions}
*/
export function calculateDimensions(
width: number,
height: number,
@@ -54,7 +72,7 @@ export function calculateDimensions(
barIndex: number,
thresholdBar?: boolean,
showScaleLabels?: boolean
): GaugeDimensions {
): RadialGaugeDimensions {
const yMaxAngle = endAngle > 180 ? 180 : endAngle;
let margin = 0;
@@ -157,26 +175,32 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an
}
export function drawRadialArcPath(
angle: number,
arcLengthDeg: number,
dimensions: GaugeDimensions,
startAngle: number,
endAngle: number,
dimensions: RadialGaugeDimensions,
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;
// For some reason a 100% full arc cannot be rendered
if (endAngle >= 360) {
endAngle = 359.99;
}
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
const startRadians = toRad(startAngle);
const endRadians = toRad(startAngle + endAngle);
const largeArc = arcLengthDeg > 180 ? 1 : 0;
const largeArc = endAngle > 180 ? 1 : 0;
const outerR = radius + barWidth / 2;
const innerR = Math.max(0, radius - barWidth / 2);
if (innerR <= 0) {
throw new Error('Inner radius collapsed to zero or below, cannot draw radial arc path');
}
// get points for both an inner and outer arc. we draw
// the arc entirely with a path's fill instead of using stroke
// so that it can be used as a clip-path.
const ox1 = centerX + outerR * Math.cos(startRadians);
const oy1 = centerY + outerR * Math.sin(startRadians);
const ox2 = centerX + outerR * Math.cos(endRadians);
@@ -187,6 +211,7 @@ export function drawRadialArcPath(
const ix2 = centerX + innerR * Math.cos(endRadians);
const iy2 = centerY + innerR * Math.sin(endRadians);
// calculate the cap width in case we're drawing rounded bars
const capR = barWidth / 2;
const pathParts = [
@@ -209,27 +234,44 @@ export function drawRadialArcPath(
// 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
// straight line to inner end (square butt)
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');
// 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 {
// 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');
// straight line back to outer start (square butt)
pathParts.push('L', ox1, oy1);
}
pathParts.push('Z');
return pathParts.join(' ');
}
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
// Max spacing is 8 degrees between segments
// Changing this constant could be considered a breaking change
const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2);
return segmentSpacing * maxAngleBetweenSegments;
}
export function getOptimalSegmentCount(
dimensions: RadialGaugeDimensions,
segmentSpacing: number,
segmentCount: number,
range: number
) {
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range);
const innerRadius = dimensions.radius - dimensions.barWidth / 2;
const circumference = Math.PI * innerRadius * 2 * (range / 360);
const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3));
return Math.min(maxSegments, segmentCount);
}