add lots of tests and reorganize the code a bit
This commit is contained in:
@@ -319,7 +319,7 @@ export { type MonacoLanguageRegistryItem, monacoLanguageRegistry } from './monac
|
|||||||
export { createTheme } from './themes/createTheme';
|
export { createTheme } from './themes/createTheme';
|
||||||
export { getThemeById, getBuiltInThemes, type ThemeRegistryItem } from './themes/registry';
|
export { getThemeById, getBuiltInThemes, type ThemeRegistryItem } from './themes/registry';
|
||||||
export type { NewThemeOptions } from './themes/createTheme';
|
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 { ThemeColors } from './themes/createColors';
|
||||||
export type { ThemeBreakpoints, ThemeBreakpointsKey } from './themes/breakpoints';
|
export type { ThemeBreakpoints, ThemeBreakpointsKey } from './themes/breakpoints';
|
||||||
export type { ThemeShadows } from './themes/createShadows';
|
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)');
|
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 tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
import { GradientStop } from './types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a number whose value is limited to the given range.
|
* Returns a number whose value is limited to the given range.
|
||||||
* @param value The value to be clamped
|
* @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 {
|
interface DecomposeColor {
|
||||||
type: string;
|
type: string;
|
||||||
values: any;
|
values: any;
|
||||||
@@ -463,5 +414,4 @@ export const colorManipulator = {
|
|||||||
darken,
|
darken,
|
||||||
lighten,
|
lighten,
|
||||||
onBackground,
|
onBackground,
|
||||||
colorAtGradientPercent,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -59,9 +59,3 @@ export interface ThemeRichColor {
|
|||||||
export type DeepPartial<T> = {
|
export type DeepPartial<T> = {
|
||||||
[P in keyof T]?: DeepPartial<T[P]>;
|
[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 { useId, useMemo, memo } from 'react';
|
||||||
|
|
||||||
import { DisplayProcessor, FieldDisplay } from '@grafana/data';
|
import { FieldDisplay } from '@grafana/data';
|
||||||
|
|
||||||
import { useTheme2 } from '../../themes/ThemeContext';
|
import { useTheme2 } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
import { RadialGradientMode, RadialShape } from './RadialGauge';
|
|
||||||
import { buildGradientColors, getEndpointColors, getGradientCss, getGuideDotColors } from './colors';
|
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 {
|
export interface RadialArcPathPropsBase {
|
||||||
arcLengthDeg: number;
|
arcLengthDeg: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
displayProcessor: DisplayProcessor;
|
|
||||||
fieldDisplay: FieldDisplay;
|
fieldDisplay: FieldDisplay;
|
||||||
glowFilter?: string;
|
glowFilter?: string;
|
||||||
gradientMode: RadialGradientMode;
|
gradientMode: RadialGradientMode;
|
||||||
@@ -38,7 +37,6 @@ export const RadialArcPath = memo(
|
|||||||
arcLengthDeg,
|
arcLengthDeg,
|
||||||
color,
|
color,
|
||||||
dimensions,
|
dimensions,
|
||||||
displayProcessor,
|
|
||||||
fieldDisplay,
|
fieldDisplay,
|
||||||
glowFilter,
|
glowFilter,
|
||||||
gradientMode,
|
gradientMode,
|
||||||
@@ -54,8 +52,8 @@ export const RadialArcPath = memo(
|
|||||||
if (gradientMode === 'none') {
|
if (gradientMode === 'none') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color);
|
return buildGradientColors(gradientMode, theme, fieldDisplay, fieldDisplay.display.color);
|
||||||
}, [gradientMode, fieldDisplay, theme, displayProcessor]);
|
}, [gradientMode, fieldDisplay, theme]);
|
||||||
|
|
||||||
const { guideDotColors, endpointColors } = useMemo(() => {
|
const { guideDotColors, endpointColors } = useMemo(() => {
|
||||||
if (!showGuideDots || gradientStops.length === 0) {
|
if (!showGuideDots || gradientStops.length === 0) {
|
||||||
@@ -67,7 +65,7 @@ export const RadialArcPath = memo(
|
|||||||
return {
|
return {
|
||||||
guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0),
|
guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0),
|
||||||
endpointColors:
|
endpointColors:
|
||||||
shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0) : undefined,
|
shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 1) : undefined,
|
||||||
};
|
};
|
||||||
}, [showGuideDots, fieldDisplay, gradientStops, shape]);
|
}, [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 { useTheme2 } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
import { RadialArcPath } from './RadialArcPath';
|
import { RadialArcPath } from './RadialArcPath';
|
||||||
import { RadialGradientMode, RadialShape } from './RadialGauge';
|
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
|
||||||
import { GaugeDimensions } from './utils';
|
|
||||||
|
|
||||||
export interface RadialBarProps {
|
export interface RadialBarProps {
|
||||||
angle: number;
|
angle: number;
|
||||||
angleRange: number;
|
angleRange: number;
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
displayProcessor: DisplayProcessor;
|
|
||||||
fieldDisplay: FieldDisplay;
|
fieldDisplay: FieldDisplay;
|
||||||
glowFilter?: string;
|
glowFilter?: string;
|
||||||
gradientMode: RadialGradientMode;
|
gradientMode: RadialGradientMode;
|
||||||
@@ -22,7 +20,6 @@ export function RadialBar({
|
|||||||
angle,
|
angle,
|
||||||
angleRange,
|
angleRange,
|
||||||
dimensions,
|
dimensions,
|
||||||
displayProcessor,
|
|
||||||
fieldDisplay,
|
fieldDisplay,
|
||||||
glowFilter,
|
glowFilter,
|
||||||
gradientMode,
|
gradientMode,
|
||||||
@@ -36,10 +33,9 @@ export function RadialBar({
|
|||||||
{/** Track */}
|
{/** Track */}
|
||||||
<RadialArcPath
|
<RadialArcPath
|
||||||
arcLengthDeg={angleRange - angle}
|
arcLengthDeg={angleRange - angle}
|
||||||
|
fieldDisplay={fieldDisplay}
|
||||||
color={theme.colors.action.hover}
|
color={theme.colors.action.hover}
|
||||||
dimensions={dimensions}
|
dimensions={dimensions}
|
||||||
displayProcessor={displayProcessor}
|
|
||||||
fieldDisplay={fieldDisplay}
|
|
||||||
gradientMode="none"
|
gradientMode="none"
|
||||||
roundedBars={roundedBars}
|
roundedBars={roundedBars}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
@@ -50,7 +46,6 @@ export function RadialBar({
|
|||||||
arcLengthDeg={angle}
|
arcLengthDeg={angle}
|
||||||
color={gradientMode === 'none' ? fieldDisplay.display.color : undefined}
|
color={gradientMode === 'none' ? fieldDisplay.display.color : undefined}
|
||||||
dimensions={dimensions}
|
dimensions={dimensions}
|
||||||
displayProcessor={displayProcessor}
|
|
||||||
fieldDisplay={fieldDisplay}
|
fieldDisplay={fieldDisplay}
|
||||||
glowFilter={glowFilter}
|
glowFilter={glowFilter}
|
||||||
gradientMode={gradientMode}
|
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 { useTheme2 } from '../../themes/ThemeContext';
|
||||||
|
|
||||||
import { RadialArcPath } from './RadialArcPath';
|
import { RadialArcPath } from './RadialArcPath';
|
||||||
import { RadialGradientMode, RadialShape } from './RadialGauge';
|
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
|
||||||
import { GaugeDimensions } from './utils';
|
import {
|
||||||
|
getAngleBetweenSegments,
|
||||||
|
getFieldConfigMinMax,
|
||||||
|
getFieldDisplayProcessor,
|
||||||
|
getOptimalSegmentCount,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
export interface RadialBarSegmentedProps {
|
export interface RadialBarSegmentedProps {
|
||||||
fieldDisplay: FieldDisplay;
|
fieldDisplay: FieldDisplay;
|
||||||
displayProcessor: DisplayProcessor;
|
dimensions: RadialGaugeDimensions;
|
||||||
dimensions: GaugeDimensions;
|
|
||||||
angleRange: number;
|
angleRange: number;
|
||||||
startAngle: number;
|
startAngle: number;
|
||||||
glowFilter?: string;
|
glowFilter?: string;
|
||||||
@@ -18,75 +24,55 @@ export interface RadialBarSegmentedProps {
|
|||||||
shape: RadialShape;
|
shape: RadialShape;
|
||||||
gradientMode: RadialGradientMode;
|
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);
|
export const RadialBarSegmented = memo(
|
||||||
const min = fieldDisplay.field.min ?? 0;
|
({
|
||||||
const max = fieldDisplay.field.max ?? 100;
|
fieldDisplay,
|
||||||
const value = fieldDisplay.display.numeric;
|
dimensions,
|
||||||
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
|
startAngle,
|
||||||
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
|
angleRange,
|
||||||
|
glowFilter,
|
||||||
|
segmentCount,
|
||||||
|
segmentSpacing,
|
||||||
|
shape,
|
||||||
|
gradientMode,
|
||||||
|
}: RadialBarSegmentedProps) => {
|
||||||
|
const theme = useTheme2();
|
||||||
|
|
||||||
for (let i = 0; i < segmentCountAdjusted; i++) {
|
const segments: React.ReactNode[] = [];
|
||||||
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
|
const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange);
|
||||||
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
|
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||||
let segmentColor: string | undefined;
|
const value = fieldDisplay.display.numeric;
|
||||||
if (angleValue >= value) {
|
const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange);
|
||||||
segmentColor = theme.colors.action.hover;
|
const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments;
|
||||||
} else if (gradientMode === 'none') {
|
|
||||||
segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR;
|
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(
|
return <g>{segments}</g>;
|
||||||
<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>;
|
RadialBarSegmented.displayName = 'RadialBarSegmented';
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema';
|
|||||||
import { useTheme2 } from '../../themes/ThemeContext';
|
import { useTheme2 } from '../../themes/ThemeContext';
|
||||||
import { Stack } from '../Layout/Stack/Stack';
|
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 {
|
interface StoryProps extends RadialGaugeProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { isNumber } from 'lodash';
|
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
|
|
||||||
import {
|
import { DisplayValueAlignmentFactors, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||||
DisplayValueAlignmentFactors,
|
|
||||||
FieldDisplay,
|
|
||||||
getDisplayProcessor,
|
|
||||||
GrafanaTheme2,
|
|
||||||
TimeRange,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
|
|
||||||
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
|
||||||
@@ -21,6 +14,7 @@ import { RadialSparkline } from './RadialSparkline';
|
|||||||
import { RadialText } from './RadialText';
|
import { RadialText } from './RadialText';
|
||||||
import { ThresholdsBar } from './ThresholdsBar';
|
import { ThresholdsBar } from './ThresholdsBar';
|
||||||
import { GlowGradient, MiddleCircleGlow } from './effects';
|
import { GlowGradient, MiddleCircleGlow } from './effects';
|
||||||
|
import { RadialGradientMode, RadialShape, RadialTextMode } from './types';
|
||||||
import { calculateDimensions, getValueAngleForValue } from './utils';
|
import { calculateDimensions, getValueAngleForValue } from './utils';
|
||||||
|
|
||||||
export interface RadialGaugeProps {
|
export interface RadialGaugeProps {
|
||||||
@@ -72,10 +66,6 @@ export interface RadialGaugeProps {
|
|||||||
timeRange?: TimeRange;
|
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
|
* https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs
|
||||||
*/
|
*/
|
||||||
@@ -130,7 +120,6 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
showScaleLabels
|
showScaleLabels
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayProcessor = getFieldDisplayProcessor(displayValue);
|
|
||||||
const glowFilterId = `glow-${gaugeId}`;
|
const glowFilterId = `glow-${gaugeId}`;
|
||||||
|
|
||||||
if (segmentCount > 1) {
|
if (segmentCount > 1) {
|
||||||
@@ -146,7 +135,6 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
segmentSpacing={segmentSpacing}
|
segmentSpacing={segmentSpacing}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
gradientMode={gradient}
|
gradientMode={gradient}
|
||||||
displayProcessor={displayProcessor}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -161,7 +149,6 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
glowFilter={`url(#${glowFilterId})`}
|
glowFilter={`url(#${glowFilterId})`}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
gradientMode={gradient}
|
gradientMode={gradient}
|
||||||
displayProcessor={displayProcessor}
|
|
||||||
fieldDisplay={displayValue}
|
fieldDisplay={displayValue}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -224,7 +211,6 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
glowFilter={`url(#${glowFilterId})`}
|
glowFilter={`url(#${glowFilterId})`}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
gradientMode={gradient}
|
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) {
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
return {
|
return {
|
||||||
vizWrapper: css({
|
vizWrapper: css({
|
||||||
|
|||||||
@@ -1,87 +1,84 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data';
|
import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
|
|
||||||
import { measureText } from '../../utils/measureText';
|
import { measureText } from '../../utils/measureText';
|
||||||
|
|
||||||
import { GaugeDimensions, toCartesian } from './utils';
|
import { RadialGaugeDimensions } from './types';
|
||||||
|
import { getFieldConfigMinMax, toCartesian } from './utils';
|
||||||
|
|
||||||
interface RadialScaleLabelsProps {
|
interface RadialScaleLabelsProps {
|
||||||
fieldDisplay: FieldDisplay;
|
fieldDisplay: FieldDisplay;
|
||||||
theme: GrafanaTheme2;
|
theme: GrafanaTheme2;
|
||||||
thresholds: Threshold[];
|
thresholds: Threshold[];
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
startAngle: number;
|
startAngle: number;
|
||||||
endAngle: number;
|
endAngle: number;
|
||||||
angleRange: number;
|
angleRange: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RadialScaleLabels({
|
const LINE_HEIGHT_FACTOR = 1.2;
|
||||||
fieldDisplay,
|
|
||||||
thresholds,
|
|
||||||
theme,
|
|
||||||
dimensions,
|
|
||||||
startAngle,
|
|
||||||
endAngle,
|
|
||||||
angleRange,
|
|
||||||
}: RadialScaleLabelsProps) {
|
|
||||||
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
|
|
||||||
|
|
||||||
const fieldConfig = fieldDisplay.field;
|
export const RadialScaleLabels = memo(
|
||||||
const min = fieldConfig.min ?? 0;
|
({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => {
|
||||||
const max = fieldConfig.max ?? 100;
|
const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions;
|
||||||
|
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||||
|
|
||||||
const fontSize = scaleLabelsFontSize;
|
const fontSize = scaleLabelsFontSize;
|
||||||
const textLineHeight = scaleLabelsFontSize * 1.2;
|
const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR;
|
||||||
const radius = scaleLabelsRadius - textLineHeight;
|
const radius = scaleLabelsRadius - textLineHeight;
|
||||||
|
|
||||||
function getTextPosition(text: string, value: number, index: number) {
|
function getTextPosition(text: string, value: number, index: number) {
|
||||||
const isLast = index === thresholds.length - 1;
|
const isLast = index === thresholds.length - 1;
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
|
|
||||||
let valueDeg = ((value - min) / (max - min)) * angleRange;
|
let valueDeg = ((value - min) / (max - min)) * angleRange;
|
||||||
let finalAngle = startAngle + valueDeg;
|
let finalAngle = startAngle + valueDeg;
|
||||||
|
|
||||||
// Now adjust the final angle based on the label text width and the labels position on the arc
|
// 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 measure = measureText(text, fontSize, theme.typography.fontWeightMedium);
|
||||||
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
|
let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange;
|
||||||
|
|
||||||
// the centering is different for gauge or circle shapes for some reason
|
// the centering is different for gauge or circle shapes for some reason
|
||||||
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
|
finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2;
|
||||||
|
|
||||||
// For circle gauges we need to shift the first label more
|
// For circle gauges we need to shift the first label more
|
||||||
if (isFirst) {
|
if (isFirst) {
|
||||||
finalAngle += textWidthAngle;
|
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
|
return (
|
||||||
if (isLast && endAngle === 360) {
|
<g>
|
||||||
finalAngle -= textWidthAngle;
|
{thresholds.map((threshold, index) => {
|
||||||
}
|
const labelPos = getTextPosition(String(threshold.value), threshold.value, index);
|
||||||
|
return (
|
||||||
const position = toCartesian(centerX, centerY, radius, finalAngle);
|
<text
|
||||||
|
key={index}
|
||||||
return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` };
|
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 (
|
RadialScaleLabels.displayName = 'RadialScaleLabels';
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,17 +3,25 @@ import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana
|
|||||||
|
|
||||||
import { Sparkline } from '../Sparkline/Sparkline';
|
import { Sparkline } from '../Sparkline/Sparkline';
|
||||||
|
|
||||||
import { RadialShape, RadialTextMode } from './RadialGauge';
|
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
|
||||||
import { GaugeDimensions } from './utils';
|
|
||||||
|
|
||||||
interface RadialSparklineProps {
|
interface RadialSparklineProps {
|
||||||
sparkline: FieldDisplay['sparkline'];
|
sparkline: FieldDisplay['sparkline'];
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
theme: GrafanaTheme2;
|
theme: GrafanaTheme2;
|
||||||
color?: string;
|
color?: string;
|
||||||
shape?: RadialShape;
|
shape?: RadialShape;
|
||||||
textMode: Exclude<RadialTextMode, 'auto'>;
|
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) {
|
export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) {
|
||||||
const { radius, barWidth } = dimensions;
|
const { radius, barWidth } = dimensions;
|
||||||
|
|
||||||
@@ -22,12 +30,12 @@ export function RadialSparkline({ sparkline, dimensions, theme, color, shape, te
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showNameAndValue = textMode === 'value_and_name';
|
const showNameAndValue = textMode === 'value_and_name';
|
||||||
const height = radius / (showNameAndValue ? 4 : 3);
|
const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR);
|
||||||
const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth;
|
const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth;
|
||||||
const topPos =
|
const topPos =
|
||||||
shape === 'gauge'
|
shape === 'gauge'
|
||||||
? `${dimensions.gaugeBottomY - height}px`
|
? dimensions.gaugeBottomY - height - SPARKLINE_SPACING
|
||||||
: `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`;
|
: `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`;
|
||||||
|
|
||||||
const config: FieldConfig<GraphFieldConfig> = {
|
const config: FieldConfig<GraphFieldConfig> = {
|
||||||
color: {
|
color: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DisplayValue,
|
DisplayValue,
|
||||||
@@ -11,13 +12,12 @@ import {
|
|||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
import { useStyles2 } from '../../themes/ThemeContext';
|
||||||
import { calculateFontSize } from '../../utils/measureText';
|
import { calculateFontSize } from '../../utils/measureText';
|
||||||
|
|
||||||
import { RadialShape, RadialTextMode } from './RadialGauge';
|
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
|
||||||
import { GaugeDimensions } from './utils';
|
|
||||||
|
|
||||||
interface RadialTextProps {
|
interface RadialTextProps {
|
||||||
displayValue: DisplayValue;
|
displayValue: DisplayValue;
|
||||||
theme: GrafanaTheme2;
|
theme: GrafanaTheme2;
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
textMode: Exclude<RadialTextMode, 'auto'>;
|
textMode: Exclude<RadialTextMode, 'auto'>;
|
||||||
shape: RadialShape;
|
shape: RadialShape;
|
||||||
sparkline?: FieldSparkline;
|
sparkline?: FieldSparkline;
|
||||||
@@ -26,123 +26,131 @@ interface RadialTextProps {
|
|||||||
nameManualFontSize?: number;
|
nameManualFontSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RadialText({
|
const LINE_HEIGHT_FACTOR = 1.21;
|
||||||
displayValue,
|
const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82;
|
||||||
theme,
|
const NAME_TO_HEIGHT_FACTOR = 0.45;
|
||||||
dimensions,
|
const LARGE_RADIUS_SCALING_DECAY = 0.86;
|
||||||
textMode,
|
const MAX_TEXT_WIDTH_DIVISOR = 7;
|
||||||
shape,
|
const MAX_NAME_HEIGHT_DIVISOR = 4;
|
||||||
sparkline,
|
const VALUE_SPACE_PERCENTAGE = 0.7;
|
||||||
alignmentFactors,
|
const SPARKLINE_SPACING = 8;
|
||||||
valueManualFontSize,
|
const MIN_UNIT_FONT_SIZE = 5;
|
||||||
nameManualFontSize,
|
|
||||||
}: RadialTextProps) {
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const { centerX, centerY, radius, barWidth } = dimensions;
|
|
||||||
|
|
||||||
if (textMode === 'none') {
|
export const RadialText = memo(
|
||||||
return null;
|
({
|
||||||
}
|
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) ?? '';
|
if (textMode === 'none') {
|
||||||
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const showValue = textMode === 'value' || textMode === 'value_and_name';
|
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
|
||||||
const showName = textMode === 'name' || textMode === 'value_and_name';
|
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
|
||||||
const maxTextWidth = radius * 2 - barWidth - radius / 7;
|
|
||||||
|
|
||||||
// Not sure where this comes from but svg text is not using body line-height
|
const showValue = textMode === 'value' || textMode === 'value_and_name';
|
||||||
const lineHeight = 1.21;
|
const showName = textMode === 'name' || textMode === 'value_and_name';
|
||||||
const valueWidthToRadiusFactor = 0.82;
|
const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR;
|
||||||
const nameToHeightFactor = 0.45;
|
|
||||||
const largeRadiusScalingDecay = 0.86;
|
|
||||||
|
|
||||||
// This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels
|
// 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 maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
|
||||||
let maxNameHeight = radius / 4;
|
let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR;
|
||||||
|
|
||||||
if (showValue && showName) {
|
if (showValue && showName) {
|
||||||
maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
|
maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
|
||||||
maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay);
|
maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueFontSize =
|
const valueFontSize =
|
||||||
valueManualFontSize ??
|
valueManualFontSize ??
|
||||||
calculateFontSize(
|
calculateFontSize(
|
||||||
valueToAlignTo,
|
valueToAlignTo,
|
||||||
maxTextWidth,
|
maxTextWidth,
|
||||||
maxValueHeight,
|
maxValueHeight,
|
||||||
lineHeight,
|
LINE_HEIGHT_FACTOR,
|
||||||
undefined,
|
undefined,
|
||||||
theme.typography.body.fontWeight
|
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 (
|
RadialText.displayName = 'RadialText';
|
||||||
<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 getStyles = (theme: GrafanaTheme2) => ({
|
const getStyles = (_theme: GrafanaTheme2) => ({
|
||||||
text: css({
|
text: css({
|
||||||
verticalAlign: 'bottom',
|
verticalAlign: 'bottom',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { DisplayProcessor, FieldDisplay, Threshold } from '@grafana/data';
|
import { FieldDisplay, Threshold } from '@grafana/data';
|
||||||
|
|
||||||
import { RadialArcPath } from './RadialArcPath';
|
import { RadialArcPath } from './RadialArcPath';
|
||||||
import { RadialGradientMode, RadialShape } from './RadialGauge';
|
import { RadialGaugeDimensions, RadialGradientMode, RadialShape } from './types';
|
||||||
import { GaugeDimensions } from './utils';
|
import { getFieldConfigMinMax } from './utils';
|
||||||
|
|
||||||
export interface Props {
|
interface ThresholdsBarProps {
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
angleRange: number;
|
angleRange: number;
|
||||||
startAngle: number;
|
startAngle: number;
|
||||||
endAngle: number;
|
endAngle: number;
|
||||||
@@ -15,8 +15,8 @@ export interface Props {
|
|||||||
glowFilter?: string;
|
glowFilter?: string;
|
||||||
thresholds: Threshold[];
|
thresholds: Threshold[];
|
||||||
gradientMode: RadialGradientMode;
|
gradientMode: RadialGradientMode;
|
||||||
displayProcessor: DisplayProcessor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThresholdsBar({
|
export function ThresholdsBar({
|
||||||
dimensions,
|
dimensions,
|
||||||
fieldDisplay,
|
fieldDisplay,
|
||||||
@@ -27,18 +27,15 @@ export function ThresholdsBar({
|
|||||||
thresholds,
|
thresholds,
|
||||||
shape,
|
shape,
|
||||||
gradientMode,
|
gradientMode,
|
||||||
displayProcessor,
|
}: ThresholdsBarProps) {
|
||||||
}: Props) {
|
|
||||||
const fieldConfig = fieldDisplay.field;
|
|
||||||
const min = fieldConfig.min ?? 0;
|
|
||||||
const max = fieldConfig.max ?? 100;
|
|
||||||
|
|
||||||
const thresholdDimensions = {
|
const thresholdDimensions = {
|
||||||
...dimensions,
|
...dimensions,
|
||||||
barWidth: dimensions.thresholdsBarWidth,
|
barWidth: dimensions.thresholdsBarWidth,
|
||||||
radius: dimensions.thresholdsBarRadius,
|
radius: dimensions.thresholdsBarRadius,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||||
|
|
||||||
let currentStart = startAngle;
|
let currentStart = startAngle;
|
||||||
let paths: React.ReactNode[] = [];
|
let paths: React.ReactNode[] = [];
|
||||||
|
|
||||||
@@ -52,21 +49,21 @@ export function ThresholdsBar({
|
|||||||
valueDeg = 0;
|
valueDeg = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
let lengthDeg = valueDeg - currentStart + startAngle;
|
const lengthDeg = valueDeg - currentStart + startAngle;
|
||||||
|
const color = gradientMode === 'none' ? threshold.color : undefined;
|
||||||
|
|
||||||
paths.push(
|
paths.push(
|
||||||
<RadialArcPath
|
<RadialArcPath
|
||||||
key={i}
|
key={i}
|
||||||
startAngle={currentStart}
|
|
||||||
arcLengthDeg={lengthDeg}
|
arcLengthDeg={lengthDeg}
|
||||||
color={gradientMode === 'none' ? threshold.color : undefined}
|
color={color}
|
||||||
shape={shape}
|
|
||||||
dimensions={thresholdDimensions}
|
dimensions={thresholdDimensions}
|
||||||
roundedBars={roundedBars}
|
fieldDisplay={fieldDisplay}
|
||||||
glowFilter={glowFilter}
|
glowFilter={glowFilter}
|
||||||
gradientMode={gradientMode}
|
gradientMode={gradientMode}
|
||||||
displayProcessor={displayProcessor}
|
roundedBars={roundedBars}
|
||||||
fieldDisplay={fieldDisplay}
|
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 tinycolor from 'tinycolor2';
|
||||||
|
|
||||||
import {
|
import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
|
||||||
colorManipulator,
|
|
||||||
DisplayProcessor,
|
|
||||||
FALLBACK_COLOR,
|
|
||||||
FieldDisplay,
|
|
||||||
getFieldColorMode,
|
|
||||||
GradientStop,
|
|
||||||
GrafanaTheme2,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { FieldColorModeId } from '@grafana/schema';
|
import { FieldColorModeId } from '@grafana/schema';
|
||||||
|
|
||||||
import { RadialGradientMode, RadialShape } from './RadialGauge';
|
import { GradientStop, RadialGradientMode, RadialShape } from './types';
|
||||||
|
import { getFieldConfigMinMax, getFieldDisplayProcessor } from './utils';
|
||||||
|
|
||||||
export function buildGradientColors(
|
export function buildGradientColors(
|
||||||
gradientMode: RadialGradientMode,
|
gradientMode: RadialGradientMode,
|
||||||
theme: GrafanaTheme2,
|
theme: GrafanaTheme2,
|
||||||
displayProcessor: DisplayProcessor,
|
|
||||||
fieldDisplay: FieldDisplay,
|
fieldDisplay: FieldDisplay,
|
||||||
baseColor = FALLBACK_COLOR,
|
baseColor = FALLBACK_COLOR
|
||||||
forSegment?: boolean
|
|
||||||
): GradientStop[] {
|
): GradientStop[] {
|
||||||
if (gradientMode === 'none') {
|
if (gradientMode === 'none') {
|
||||||
return [
|
return [
|
||||||
@@ -30,13 +21,14 @@ export function buildGradientColors(
|
|||||||
|
|
||||||
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
|
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
|
||||||
|
|
||||||
|
// thresholds get special handling
|
||||||
if (colorMode.id === FieldColorModeId.Thresholds) {
|
if (colorMode.id === FieldColorModeId.Thresholds) {
|
||||||
|
const displayProcessor = getFieldDisplayProcessor(fieldDisplay);
|
||||||
|
const [min, max] = getFieldConfigMinMax(fieldDisplay);
|
||||||
const thresholds = fieldDisplay.field.thresholds?.steps ?? [];
|
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 }> = [
|
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) {
|
for (const threshold of thresholds) {
|
||||||
@@ -51,15 +43,15 @@ export function buildGradientColors(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (colorMode.isContinuous && colorMode.getColors && !forSegment) {
|
// Handle continuous color modes before other by-value modes
|
||||||
// Handle continuous color modes first
|
if (colorMode.isContinuous && colorMode.getColors) {
|
||||||
const colors = colorMode.getColors(theme);
|
const colors = colorMode.getColors(theme);
|
||||||
return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) }));
|
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) {
|
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 darkerColor = tinycolor(baseColor).darken(5);
|
||||||
const lighterColor = tinycolor(baseColor).spin(20).lighten(10);
|
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
|
// for fixed colors and other modes, we create a simple two-color gradient
|
||||||
// So a radial gradient that adds a bit of light and shade works best
|
|
||||||
// we set the highest contrast color second based on the theme.
|
// we set the highest contrast color second based on the theme.
|
||||||
const darkerColor = tinycolor(baseColor).spin(-20).darken(5);
|
const darkerColor = tinycolor(baseColor).spin(-20).darken(5);
|
||||||
const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10);
|
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;
|
const startColor = gradientStops[0].color;
|
||||||
let endColor = gradientStops[gradientStops.length - 1].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 we have a percentageFilled, use it to get a the correct end color based on where the bar terminates
|
||||||
if (gradientStops.length >= 2) {
|
if (gradientStops.length >= 2) {
|
||||||
const endColorByPercentage = colorManipulator.colorAtGradientPercent(gradientStops, percent);
|
const endColorByPercentage = colorAtGradientPercent(gradientStops, percent);
|
||||||
endColor =
|
endColor =
|
||||||
endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String();
|
endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String();
|
||||||
}
|
}
|
||||||
@@ -109,14 +161,18 @@ export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape
|
|||||||
: `linear-gradient(90deg, ${colorStrings.join(', ')})`;
|
: `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 CONTRAST_THRESHOLD_MAX = 4.5;
|
||||||
const getGuideDotColor = (color: string): string => {
|
const getGuideDotColor = (color: string): string => {
|
||||||
const darkColor = '#111217'; // gray05
|
const darkColor = GRAY_05;
|
||||||
const lightColor = '#fbfbfb'; // gray90
|
const lightColor = GRAY_90;
|
||||||
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;
|
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);
|
const [startColor, endColor] = getEndpointColors(gradientStops, percent);
|
||||||
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
|
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { GaugeDimensions } from './utils';
|
import { RadialGaugeDimensions } from './types';
|
||||||
|
|
||||||
export interface GlowGradientProps {
|
export interface GlowGradientProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -27,7 +27,7 @@ const CENTER_GLOW_OPACITY = 0.15;
|
|||||||
|
|
||||||
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
||||||
return (
|
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="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
@@ -35,7 +35,7 @@ export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CenterGlowProps {
|
export interface CenterGlowProps {
|
||||||
dimensions: GaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
gaugeId: string;
|
gaugeId: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<defs>
|
<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="0%" stopColor={color} stopOpacity={CENTER_GLOW_OPACITY} />
|
||||||
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
<stop offset="90%" stopColor={color} stopOpacity={0} />
|
||||||
</radialGradient>
|
</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 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', () => {
|
describe('RadialGauge utils', () => {
|
||||||
function calc(overrides: Partial<RadialGaugeProps & { barIndex: number }> = {}) {
|
describe('getFieldDisplayProcessor', () => {
|
||||||
return calculateDimensions(
|
it('should return display processor from view when available', () => {
|
||||||
overrides.width ?? 200,
|
const mockProcessor = jest.fn();
|
||||||
overrides.height ?? 200,
|
const mockView = {
|
||||||
overrides.shape === 'gauge' ? 110 : 360,
|
getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor),
|
||||||
overrides.glowBar ?? false,
|
} as unknown as DataFrameView;
|
||||||
overrides.roundedBars ?? false,
|
|
||||||
overrides.barWidthFactor ?? 0.4,
|
const fieldDisplay: FieldDisplay = {
|
||||||
overrides.barIndex ?? 0,
|
display: { numeric: 50, text: '50', color: 'blue' },
|
||||||
overrides.thresholdsBar ?? false,
|
field: {},
|
||||||
overrides.showScaleLabels ?? false
|
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', () => {
|
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', () => {
|
it('should calculate basic dimensions for a square gauge', () => {
|
||||||
const result = calc();
|
const result = calc();
|
||||||
|
|
||||||
@@ -194,4 +281,86 @@ describe('RadialGauge utils', () => {
|
|||||||
expect(result.angle).toBe(240);
|
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) {
|
import { RadialGaugeDimensions } from './types';
|
||||||
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
|
|
||||||
|
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 min = fieldDisplay.field.min ?? 0;
|
||||||
const max = fieldDisplay.field.max ?? 100;
|
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) {
|
if (angle > angleRange) {
|
||||||
angle = angleRange;
|
angle = angleRange;
|
||||||
@@ -26,24 +49,19 @@ export function toRad(angle: number) {
|
|||||||
return ((angle - 90) * Math.PI) / 180;
|
return ((angle - 90) * Math.PI) / 180;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GaugeDimensions {
|
/**
|
||||||
margin: number;
|
* returns the calculated dimensions for the radial gauge
|
||||||
radius: number;
|
* @param width
|
||||||
centerX: number;
|
* @param height
|
||||||
centerY: number;
|
* @param endAngle
|
||||||
barWidth: number;
|
* @param glow
|
||||||
endAngle?: number;
|
* @param roundedBars
|
||||||
barIndex: number;
|
* @param barWidthFactor
|
||||||
thresholdsBarRadius: number;
|
* @param barIndex
|
||||||
thresholdsBarWidth: number;
|
* @param thresholdBar
|
||||||
thresholdsBarSpacing: number;
|
* @param showScaleLabels
|
||||||
showScaleLabels?: boolean;
|
* @returns {RadialGaugeDimensions}
|
||||||
scaleLabelsFontSize: number;
|
*/
|
||||||
scaleLabelsSpacing: number;
|
|
||||||
scaleLabelsRadius: number;
|
|
||||||
gaugeBottomY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateDimensions(
|
export function calculateDimensions(
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
@@ -54,7 +72,7 @@ export function calculateDimensions(
|
|||||||
barIndex: number,
|
barIndex: number,
|
||||||
thresholdBar?: boolean,
|
thresholdBar?: boolean,
|
||||||
showScaleLabels?: boolean
|
showScaleLabels?: boolean
|
||||||
): GaugeDimensions {
|
): RadialGaugeDimensions {
|
||||||
const yMaxAngle = endAngle > 180 ? 180 : endAngle;
|
const yMaxAngle = endAngle > 180 ? 180 : endAngle;
|
||||||
let margin = 0;
|
let margin = 0;
|
||||||
|
|
||||||
@@ -157,26 +175,32 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function drawRadialArcPath(
|
export function drawRadialArcPath(
|
||||||
angle: number,
|
startAngle: number,
|
||||||
arcLengthDeg: number,
|
endAngle: number,
|
||||||
dimensions: GaugeDimensions,
|
dimensions: RadialGaugeDimensions,
|
||||||
roundedBars?: boolean
|
roundedBars?: boolean
|
||||||
): string {
|
): string {
|
||||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
const { radius, centerX, centerY, barWidth } = dimensions;
|
||||||
|
|
||||||
if (arcLengthDeg === 360) {
|
// For some reason a 100% full arc cannot be rendered
|
||||||
// For some reason a 100% full arc cannot be rendered
|
if (endAngle >= 360) {
|
||||||
arcLengthDeg = 359.99;
|
endAngle = 359.99;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRadians = toRad(angle);
|
const startRadians = toRad(startAngle);
|
||||||
const endRadians = toRad(angle + arcLengthDeg);
|
const endRadians = toRad(startAngle + endAngle);
|
||||||
|
|
||||||
const largeArc = arcLengthDeg > 180 ? 1 : 0;
|
const largeArc = endAngle > 180 ? 1 : 0;
|
||||||
|
|
||||||
const outerR = radius + barWidth / 2;
|
const outerR = radius + barWidth / 2;
|
||||||
const innerR = Math.max(0, 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 ox1 = centerX + outerR * Math.cos(startRadians);
|
||||||
const oy1 = centerY + outerR * Math.sin(startRadians);
|
const oy1 = centerY + outerR * Math.sin(startRadians);
|
||||||
const ox2 = centerX + outerR * Math.cos(endRadians);
|
const ox2 = centerX + outerR * Math.cos(endRadians);
|
||||||
@@ -187,6 +211,7 @@ export function drawRadialArcPath(
|
|||||||
const ix2 = centerX + innerR * Math.cos(endRadians);
|
const ix2 = centerX + innerR * Math.cos(endRadians);
|
||||||
const iy2 = centerY + innerR * Math.sin(endRadians);
|
const iy2 = centerY + innerR * Math.sin(endRadians);
|
||||||
|
|
||||||
|
// calculate the cap width in case we're drawing rounded bars
|
||||||
const capR = barWidth / 2;
|
const capR = barWidth / 2;
|
||||||
|
|
||||||
const pathParts = [
|
const pathParts = [
|
||||||
@@ -209,27 +234,44 @@ export function drawRadialArcPath(
|
|||||||
// rounded end cap: small arc connecting outer end to inner end
|
// rounded end cap: small arc connecting outer end to inner end
|
||||||
pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2);
|
pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2);
|
||||||
} else {
|
} else {
|
||||||
// straight line to inner end
|
// straight line to inner end (square butt)
|
||||||
pathParts.push('L', ix2, iy2);
|
pathParts.push('L', ix2, iy2);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (innerR <= 0) {
|
// inner arc from end back to start (counter-clockwise)
|
||||||
// if inner radius collapsed to center, line to center and close
|
pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1);
|
||||||
pathParts.push('L', centerX, centerY, 'Z');
|
|
||||||
|
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 {
|
} else {
|
||||||
// inner arc from end back to start (counter-clockwise)
|
// straight line back to outer start (square butt)
|
||||||
pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1);
|
pathParts.push('L', ox1, oy1);
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pathParts.push('Z');
|
||||||
|
|
||||||
return pathParts.join(' ');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user