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 { 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user