change endpoint marks to be configurable

This commit is contained in:
Paul Marbach
2025-12-17 14:36:01 -05:00
parent 2d72990c17
commit 50fa37af53
16 changed files with 264 additions and 169 deletions
@@ -28,6 +28,7 @@ export interface Options extends common.SingleStatBaseOptions {
barShape: ('flat' | 'rounded');
barWidthFactor: number;
effects: GaugePanelEffects;
endpointMarker?: ('point' | 'glow' | 'none');
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
@@ -40,6 +41,7 @@ export const defaultOptions: Partial<Options> = {
barShape: 'flat',
barWidthFactor: 0.5,
effects: {},
endpointMarker: 'point',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',
@@ -1,11 +1,11 @@
import { useId, memo, HTMLAttributes } from 'react';
import { useId, memo, HTMLAttributes, ReactElement } from 'react';
import { FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { buildGradientColors, getEndpointColors, getGradientCss, getGuideDotColors } from './colors';
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
import { buildGradientColors, getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors';
import { RadialShape, RadialGaugeDimensions } from './types';
import { drawRadialArcPath, toRad } from './utils';
export interface RadialArcPathPropsBase {
@@ -13,12 +13,13 @@ export interface RadialArcPathPropsBase {
color?: string;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
glowFilter?: string;
gradientMode: RadialGradientMode;
gradient?: boolean;
roundedBars?: boolean;
shape: RadialShape;
showGuideDots?: boolean;
endpointMarker?: 'point' | 'glow';
startAngle: number;
glowFilter?: string;
endpointMarkerGlowFilter?: string;
}
interface RadialArcPathPropsWithGuideDot extends RadialArcPathPropsBase {
@@ -40,27 +41,18 @@ export const RadialArcPath = memo(
color,
dimensions,
fieldDisplay,
glowFilter,
gradientMode,
gradient,
roundedBars,
shape,
showGuideDots,
endpointMarker,
startAngle: angle,
glowFilter,
endpointMarkerGlowFilter,
}: RadialArcPathProps) => {
const theme = useTheme2();
const id = useId();
const gradientStops = buildGradientColors(gradientMode, theme, fieldDisplay, fieldDisplay.display.color);
let guideDotColors: [string, string] | undefined;
let endpointColors: [string, string] | undefined;
if (showGuideDots && gradientStops.length > 0) {
guideDotColors = getGuideDotColors(gradientStops, fieldDisplay.display.percent);
if (shape === 'circle') {
endpointColors = getEndpointColors(gradientStops, fieldDisplay.display.percent);
}
}
const gradientStops = buildGradientColors(gradient, theme, fieldDisplay, fieldDisplay.display.color);
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
if (color) {
@@ -76,12 +68,65 @@ export const RadialArcPath = memo(
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
const x1 = centerX + radius * Math.cos(startRadians);
const y1 = centerY + radius * Math.sin(startRadians);
const x2 = centerX + radius * Math.cos(endRadians);
const y2 = centerY + radius * Math.sin(endRadians);
const xStart = centerX + radius * Math.cos(startRadians);
const yStart = centerY + radius * Math.sin(startRadians);
const xEnd = centerX + radius * Math.cos(endRadians);
const yEnd = centerY + radius * Math.sin(endRadians);
const dotRadius = Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS);
const dotRadius =
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
let barEndcapColors: [string | undefined, string | undefined] | undefined;
const endpointMarks: ReactElement[] = [];
if (endpointMarker && gradientStops.length > 0) {
switch (endpointMarker) {
case 'point':
const [pointColorStart, pointColorEnd] = getEndpointMarkerColors(gradientStops, fieldDisplay.display.percent);
if (arcLengthDeg > DOT_START_MIN_ANGLE_DEG) {
endpointMarks.push(
<circle
key="endpoint-marker-start"
cx={xStart}
cy={yStart}
r={dotRadius}
fill={pointColorStart}
opacity={DOT_OPACITY}
/>
);
}
endpointMarks.push(
<circle
key="endpoint-marker-end"
cx={xEnd}
cy={yEnd}
r={dotRadius}
fill={pointColorEnd}
opacity={DOT_OPACITY}
/>
);
break;
case 'glow':
const xStartMark = centerX + radius * Math.cos(endRadians - 0.2);
const yStartMark = centerY + radius * Math.sin(endRadians - 0.2);
endpointMarks.push(
<path
d={['M', xStartMark, yStartMark, 'A', radius, radius, 0, 0, 1, xEnd, yEnd].join(' ')}
fill="none"
strokeWidth={barWidth}
stroke={endpointMarkerGlowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
);
break;
default:
break;
}
if (shape === 'circle') {
barEndcapColors = getBarEndcapColors(gradientStops, fieldDisplay.display.percent);
}
}
return (
<>
@@ -100,14 +145,13 @@ export const RadialArcPath = memo(
>
<div style={bgDivStyle} />
</foreignObject>
{endpointColors && <circle cx={x1} cy={y1} r={barWidth / 2} fill={endpointColors[0]} />}
{endpointColors && <circle cx={x2} cy={y2} r={barWidth / 2} fill={endpointColors[1]} />}
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
{barEndcapColors?.[1] && (
<circle cx={xEnd} cy={yEnd} r={barWidth / 2} fill={barEndcapColors[1]} opacity={0.5} />
)}
</g>
{guideDotColors && arcLengthDeg > DOT_START_MIN_ANGLE_DEG && (
<circle cx={x1} cy={y1} r={dotRadius} fill={guideDotColors[0]} opacity={DOT_OPACITY} />
)}
{guideDotColors && <circle cx={x2} cy={y2} r={dotRadius} fill={guideDotColors[1]} opacity={DOT_OPACITY} />}
{endpointMarks}
</>
);
}
@@ -3,29 +3,33 @@ import { FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
import { RadialShape, RadialGaugeDimensions } from './types';
export interface RadialBarProps {
angle: number;
angleRange: number;
dimensions: RadialGaugeDimensions;
fieldDisplay: FieldDisplay;
glowFilter?: string;
gradientMode: RadialGradientMode;
gradient?: boolean;
roundedBars?: boolean;
endpointMarker?: 'point' | 'glow';
shape: RadialShape;
startAngle: number;
glowFilter?: string;
endpointMarkerGlowFilter?: string;
}
export function RadialBar({
angle,
angleRange,
dimensions,
fieldDisplay,
glowFilter,
gradientMode,
gradient,
roundedBars,
endpointMarker,
shape,
startAngle,
glowFilter,
endpointMarkerGlowFilter,
}: RadialBarProps) {
const theme = useTheme2();
return (
@@ -36,7 +40,6 @@ export function RadialBar({
fieldDisplay={fieldDisplay}
color={theme.colors.action.hover}
dimensions={dimensions}
gradientMode="none"
roundedBars={roundedBars}
shape={shape}
startAngle={startAngle + angle}
@@ -44,15 +47,16 @@ export function RadialBar({
{/** The colored bar */}
<RadialArcPath
arcLengthDeg={angle}
color={gradientMode === 'none' ? fieldDisplay.display.color : undefined}
color={gradient ? undefined : fieldDisplay.display.color}
dimensions={dimensions}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
gradientMode={gradientMode}
gradient={gradient}
roundedBars={roundedBars}
shape={shape}
showGuideDots={roundedBars}
endpointMarker={roundedBars ? endpointMarker : undefined}
startAngle={startAngle}
endpointMarkerGlowFilter={endpointMarkerGlowFilter}
glowFilter={glowFilter}
/>
</>
);
@@ -5,7 +5,7 @@ import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialGradientMode, RadialShape, RadialGaugeDimensions } from './types';
import { RadialShape, RadialGaugeDimensions } from './types';
import {
getAngleBetweenSegments,
getFieldConfigMinMax,
@@ -22,7 +22,7 @@ export interface RadialBarSegmentedProps {
segmentCount: number;
segmentSpacing: number;
shape: RadialShape;
gradientMode: RadialGradientMode;
gradient?: boolean;
}
export const RadialBarSegmented = memo(
@@ -32,10 +32,10 @@ export const RadialBarSegmented = memo(
startAngle,
angleRange,
glowFilter,
gradient,
segmentCount,
segmentSpacing,
shape,
gradientMode,
}: RadialBarSegmentedProps) => {
const theme = useTheme2();
@@ -53,21 +53,21 @@ export const RadialBarSegmented = memo(
let segmentColor: string | undefined;
if (angleValue >= value) {
segmentColor = theme.colors.action.hover;
} else if (gradientMode === 'none') {
} else if (!gradient) {
segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR;
}
segments.push(
<RadialArcPath
key={i}
startAngle={segmentAngle}
arcLengthDeg={segmentArcLengthDeg}
color={segmentColor}
dimensions={dimensions}
fieldDisplay={fieldDisplay}
color={segmentColor}
shape={shape}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
gradientMode={gradientMode}
gradient={gradient}
shape={shape}
startAngle={segmentAngle}
/>
);
}
@@ -14,7 +14,7 @@ import { useTheme2 } from '../../themes/ThemeContext';
import { Stack } from '../Layout/Stack/Stack';
import { RadialGauge, RadialGaugeProps } from './RadialGauge';
import { RadialGradientMode, RadialShape, RadialTextMode } from './types';
import { RadialShape, RadialTextMode } from './types';
interface StoryProps extends RadialGaugeProps {
value: number;
@@ -60,7 +60,7 @@ const meta: Meta<StoryProps> = {
width: 200,
height: 200,
shape: 'circle',
gradient: 'none',
gradient: false,
seriesCount: 1,
segmentCount: 0,
segmentSpacing: 0.2,
@@ -77,7 +77,7 @@ const meta: Meta<StoryProps> = {
roundedBars: { control: 'boolean' },
sparkline: { control: 'boolean' },
thresholdsBar: { control: 'boolean' },
gradient: { control: { type: 'radio' } },
gradient: { control: { type: 'boolean' } },
seriesCount: { control: { type: 'range', min: 1, max: 20 } },
segmentCount: { control: { type: 'range', min: 0, max: 100 } },
segmentSpacing: { control: { type: 'range', min: 0, max: 1, step: 0.01 } },
@@ -119,41 +119,17 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<Stack direction={'column'} gap={3} wrap="wrap">
<div>Bar width</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
<RadialGaugeExample
seriesName="0.1"
value={args.value ?? 30}
color="blue"
gradient="auto"
barWidthFactor={0.1}
/>
<RadialGaugeExample
seriesName="0.4"
value={args.value ?? 40}
color="green"
gradient="auto"
barWidthFactor={0.4}
/>
<RadialGaugeExample
seriesName="0.6"
value={args.value ?? 60}
color="red"
gradient="auto"
barWidthFactor={0.6}
/>
<RadialGaugeExample
seriesName="0.8"
value={args.value ?? 70}
color="purple"
gradient="auto"
barWidthFactor={0.8}
/>
<RadialGaugeExample seriesName="0.1" value={args.value ?? 30} color="blue" gradient barWidthFactor={0.1} />
<RadialGaugeExample seriesName="0.4" value={args.value ?? 40} color="green" gradient barWidthFactor={0.4} />
<RadialGaugeExample seriesName="0.6" value={args.value ?? 60} color="red" gradient barWidthFactor={0.6} />
<RadialGaugeExample seriesName="0.8" value={args.value ?? 70} color="purple" gradient barWidthFactor={0.8} />
</Stack>
<div>Effects</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
<RadialGaugeExample value={args.value ?? 30} glowBar glowCenter color="blue" gradient="auto" />
<RadialGaugeExample value={args.value ?? 40} glowBar glowCenter color="green" gradient="auto" />
<RadialGaugeExample value={args.value ?? 60} glowBar glowCenter color="red" gradient="auto" roundedBars />
<RadialGaugeExample value={args.value ?? 70} glowBar glowCenter color="purple" gradient="auto" roundedBars />
<RadialGaugeExample value={args.value ?? 30} glowBar glowCenter color="blue" gradient />
<RadialGaugeExample value={args.value ?? 40} glowBar glowCenter color="green" gradient />
<RadialGaugeExample value={args.value ?? 60} glowBar glowCenter color="red" gradient roundedBars />
<RadialGaugeExample value={args.value ?? 70} glowBar glowCenter color="purple" gradient roundedBars />
</Stack>
<div>Shape: Gauge & color scale</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
@@ -161,14 +137,14 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={40}
shape="gauge"
width={250}
gradient="auto"
gradient
colorScheme={FieldColorModeId.ContinuousGrYlRd}
glowCenter={true}
barWidthFactor={0.6}
/>
<RadialGaugeExample
colorScheme={FieldColorModeId.ContinuousGrYlRd}
gradient="auto"
gradient
width={250}
value={90}
barWidthFactor={0.6}
@@ -184,7 +160,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 70}
color="blue"
shape="gauge"
gradient="auto"
gradient
sparkline={true}
glowBar={true}
glowCenter={true}
@@ -194,7 +170,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 30}
color="green"
shape="gauge"
gradient="auto"
gradient
sparkline={true}
glowBar={true}
glowCenter={true}
@@ -205,7 +181,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
color="red"
shape="gauge"
width={250}
gradient="auto"
gradient
sparkline={true}
glowBar={true}
glowCenter={true}
@@ -216,7 +192,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
color="red"
width={250}
shape="gauge"
gradient="auto"
gradient
sparkline={true}
glowBar={true}
glowCenter={true}
@@ -228,7 +204,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 70}
color="green"
gradient="auto"
gradient
glowCenter={true}
segmentCount={8}
segmentSpacing={0.1}
@@ -237,7 +213,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 30}
color="purple"
gradient="auto"
gradient
segmentCount={30}
glowCenter={true}
barWidthFactor={0.6}
@@ -245,7 +221,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 50}
color="red"
gradient="auto"
gradient
segmentCount={40}
glowCenter={true}
barWidthFactor={1}
@@ -267,7 +243,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
width={250}
colorScheme={FieldColorModeId.ContinuousGrYlRd}
shape="gauge"
gradient="auto"
gradient
glowBar={true}
glowCenter={true}
segmentCount={40}
@@ -280,7 +256,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
<RadialGaugeExample
value={args.value ?? 70}
colorScheme={FieldColorModeId.Thresholds}
gradient="auto"
gradient
thresholdsBar={true}
roundedBars={false}
glowCenter={true}
@@ -290,7 +266,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 70}
width={250}
colorScheme={FieldColorModeId.Thresholds}
gradient="auto"
gradient
glowCenter={true}
thresholdsBar={true}
roundedBars={false}
@@ -301,7 +277,7 @@ export const Examples: StoryFn<StoryProps> = (args) => {
value={args.value ?? 70}
width={250}
colorScheme={FieldColorModeId.Thresholds}
gradient="auto"
gradient
glowCenter={true}
thresholdsBar={true}
roundedBars={false}
@@ -347,7 +323,6 @@ export const Temp: StoryFn<StoryProps> = (args) => {
};
interface ExampleProps {
gradient?: RadialGradientMode;
color?: string;
seriesName?: string;
value?: number;
@@ -356,6 +331,7 @@ interface ExampleProps {
max?: number;
width?: number;
height?: number;
gradient?: boolean;
glowBar?: boolean;
glowCenter?: boolean;
barWidthFactor?: number;
@@ -373,7 +349,6 @@ interface ExampleProps {
}
export function RadialGaugeExample({
gradient = 'none',
color,
seriesName = 'Server A',
value = 70,
@@ -382,6 +357,7 @@ export function RadialGaugeExample({
max = 100,
width = 200,
height = 200,
gradient = false,
glowBar = false,
glowCenter = false,
barWidthFactor = 0.4,
@@ -13,8 +13,8 @@ import { RadialScaleLabels } from './RadialScaleLabels';
import { RadialSparkline } from './RadialSparkline';
import { RadialText } from './RadialText';
import { ThresholdsBar } from './ThresholdsBar';
import { GlowGradient, MiddleCircleGlow } from './effects';
import { RadialGradientMode, RadialShape, RadialTextMode } from './types';
import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects';
import { RadialShape, RadialTextMode } from './types';
import { calculateDimensions, getValueAngleForValue } from './utils';
export interface RadialGaugeProps {
@@ -25,7 +25,7 @@ export interface RadialGaugeProps {
* Circle or gauge (partial circle)
*/
shape?: RadialShape;
gradient?: RadialGradientMode;
gradient?: boolean;
/**
* Bar width is always relative to size of the gauge.
* But this gives you control over the width relative to size.
@@ -37,6 +37,10 @@ export interface RadialGaugeProps {
glowCenter?: boolean;
roundedBars?: boolean;
thresholdsBar?: boolean;
/**
* Specify if an endpoint marker should be shown at the end of the bar
*/
endpointMarker?: 'point' | 'glow';
/**
* Number of segments depends on size of gauge but this
* factor 1-10 gives you relative control
@@ -74,7 +78,7 @@ export function RadialGauge(props: RadialGaugeProps) {
width = 256,
height = 256,
shape = 'circle',
gradient = 'none',
gradient = false,
barWidthFactor = 0.4,
glowBar = false,
glowCenter = false,
@@ -85,6 +89,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars = true,
thresholdsBar = false,
showScaleLabels = false,
endpointMarker,
onClick,
values,
} = props;
@@ -120,8 +125,24 @@ export function RadialGauge(props: RadialGaugeProps) {
showScaleLabels
);
// FIXME: I want to move the ids for these filters into a context which the children
// can reference via a hook, rather than passing them down as props
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
const glowFilterId = `glow-${gaugeId}`;
if (endpointMarker === 'glow') {
defs.push(
<SpotlightGradient
key={spotlightGradientId}
id={spotlightGradientId}
angle={angle + startAngle}
dimensions={dimensions}
roundedBars={roundedBars}
theme={theme}
/>
);
}
if (segmentCount > 1) {
graphics.push(
<RadialBarSegmented
@@ -134,7 +155,7 @@ export function RadialGauge(props: RadialGaugeProps) {
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
shape={shape}
gradientMode={gradient}
gradient={gradient}
/>
);
} else {
@@ -147,9 +168,11 @@ export function RadialGauge(props: RadialGaugeProps) {
startAngle={startAngle}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
shape={shape}
gradientMode={gradient}
gradient={gradient}
fieldDisplay={displayValue}
endpointMarker={endpointMarker}
/>
);
}
@@ -210,7 +233,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
shape={shape}
gradientMode={gradient}
gradient={gradient}
/>
);
}
@@ -1,7 +1,7 @@
import { FieldDisplay, Threshold } from '@grafana/data';
import { RadialArcPath } from './RadialArcPath';
import { RadialGaugeDimensions, RadialGradientMode, RadialShape } from './types';
import { RadialGaugeDimensions, RadialShape } from './types';
import { getFieldConfigMinMax } from './utils';
interface ThresholdsBarProps {
@@ -14,7 +14,7 @@ interface ThresholdsBarProps {
roundedBars?: boolean;
glowFilter?: string;
thresholds: Threshold[];
gradientMode: RadialGradientMode;
gradient?: boolean;
}
export function ThresholdsBar({
@@ -26,7 +26,7 @@ export function ThresholdsBar({
glowFilter,
thresholds,
shape,
gradientMode,
gradient,
}: ThresholdsBarProps) {
const thresholdDimensions = {
...dimensions,
@@ -50,7 +50,7 @@ export function ThresholdsBar({
}
const lengthDeg = valueDeg - currentStart + startAngle;
const color = gradientMode === 'none' ? threshold.color : undefined;
const color = gradient ? undefined : threshold.color;
paths.push(
<RadialArcPath
@@ -60,7 +60,7 @@ export function ThresholdsBar({
dimensions={thresholdDimensions}
fieldDisplay={fieldDisplay}
glowFilter={glowFilter}
gradientMode={gradientMode}
gradient={gradient}
roundedBars={roundedBars}
shape={shape}
startAngle={currentStart}
@@ -112,16 +112,12 @@ exports[`RadialGauge color utils buildGradientColors should return gradient colo
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = `
[
{
"color": "#030108",
"color": "#210550",
"percent": 0,
},
{
"color": "#442299",
"percent": 0.45,
},
{
"color": "#442299",
"percent": 0.55,
"percent": 0.33,
},
{
"color": "#ffffff",
@@ -133,19 +129,15 @@ exports[`RadialGauge color utils buildGradientColors should return gradient colo
exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = `
[
{
"color": "#9689ca",
"color": "#9181d3",
"percent": 0,
},
{
"color": "#442299",
"percent": 0.45,
"percent": 0.33,
},
{
"color": "#442299",
"percent": 0.55,
},
{
"color": "#030108",
"color": "#210550",
"percent": 1,
},
]
@@ -6,9 +6,9 @@ import { FieldColorModeId } from '@grafana/schema';
import {
buildGradientColors,
colorAtGradientPercent,
getEndpointColors,
getBarEndcapColors,
getEndpointMarkerColors,
getGradientCss,
getGuideDotColors,
} from './colors';
export type DeepPartial<T> = {
@@ -49,9 +49,16 @@ describe('RadialGauge color utils', () => {
},
});
it('should return the baseColor if gradientMode is none', () => {
it('should return the baseColor if gradient is false-y', () => {
expect(
buildGradientColors('none', createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
).toEqual([
{ color: '#FF0000', percent: 0 },
{ color: '#FF0000', percent: 1 },
]);
expect(
buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
).toEqual([
{ color: '#FF0000', percent: 0 },
{ color: '#FF0000', percent: 1 },
@@ -59,18 +66,18 @@ describe('RadialGauge color utils', () => {
});
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 },
]);
expect(buildGradientColors(false, 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',
true,
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) },
@@ -81,19 +88,14 @@ describe('RadialGauge color utils', () => {
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
expect(
buildGradientColors(
'auto',
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.Thresholds)),
'#FF0000'
)
buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
).toMatchSnapshot();
});
it('should return gradient colors for continuous color modes', () => {
expect(
buildGradientColors(
'auto',
true,
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
'#00FF00'
@@ -104,7 +106,7 @@ describe('RadialGauge color utils', () => {
it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => {
expect(
buildGradientColors(
'auto',
true,
createTheme({ colors: { mode } }),
buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues))
)
@@ -114,7 +116,7 @@ describe('RadialGauge color utils', () => {
it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => {
expect(
buildGradientColors(
'auto',
true,
createTheme({ colors: { mode } }),
buildFieldDisplay(createField(FieldColorModeId.Fixed)),
'#442299'
@@ -168,14 +170,14 @@ describe('RadialGauge color utils', () => {
});
});
describe('getEndpointColors', () => {
describe('getBarEndcapColors', () => {
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);
const [startColor, endColor] = getBarEndcapColors(gradient);
expect(startColor).toBe('#ff0000');
expect(endColor).toBe('#0000ff');
});
@@ -186,22 +188,22 @@ describe('RadialGauge color utils', () => {
{ color: '#00ff00', percent: 0.5 },
{ color: '#0000ff', percent: 1 },
];
const [startColor, endColor] = getEndpointColors(gradient, 0.25);
const [startColor, endColor] = getBarEndcapColors(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);
const [startColor, endColor] = getBarEndcapColors(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');
getBarEndcapColors([]);
}).toThrow('getBarEndcapColors requires at least one color stop');
});
});
@@ -227,14 +229,14 @@ describe('RadialGauge color utils', () => {
});
});
describe('getGuideDotColors', () => {
describe('getEndpointMarkerColors', () => {
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);
const [startDotColor, endDotColor] = getEndpointMarkerColors(gradient, 0.35);
expect(startDotColor).toBe('#fbfbfb');
expect(endDotColor).toBe('#111217');
});
@@ -3,16 +3,16 @@ import tinycolor from 'tinycolor2';
import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import { GradientStop, RadialGradientMode, RadialShape } from './types';
import { GradientStop, RadialShape } from './types';
import { getFieldConfigMinMax, getFieldDisplayProcessor } from './utils';
export function buildGradientColors(
gradientMode: RadialGradientMode,
gradient = false,
theme: GrafanaTheme2,
fieldDisplay: FieldDisplay,
baseColor = FALLBACK_COLOR
): GradientStop[] {
if (gradientMode === 'none') {
if (!gradient) {
return [
{ color: baseColor, percent: 0 },
{ color: baseColor, percent: 1 },
@@ -67,16 +67,14 @@ export function buildGradientColors(
// for fixed colors and other modes, we create a simple two-color gradient
// we set the highest contrast color second based on the theme.
const darkerColor = tinycolor(baseColor).spin(5).darken(35).saturate(20);
const darkerColor = tinycolor(baseColor).spin(5).darken(20).saturate(25);
const lighterColor = tinycolor(baseColor)
.spin(-5)
.brighten(theme.isDark ? 30 : 15)
.lighten(theme.isDark ? 35 : 15)
.desaturate(10);
.lighten(theme.isDark ? 35 : 15);
return [
{ color: theme.isDark ? darkerColor.toString() : lighterColor.toString(), percent: 0 },
{ color: baseColor, percent: 0.45 },
{ color: baseColor, percent: 0.55 },
{ color: baseColor, percent: 0.33 },
{ color: theme.isDark ? lighterColor.toString() : darkerColor.toString(), percent: 1 },
];
}
@@ -138,9 +136,9 @@ export function colorAtGradientPercent(stops: GradientStop[], percent: number):
return mixed;
}
export function getEndpointColors(gradientStops: GradientStop[], percent = 1): [string, string] {
export function getBarEndcapColors(gradientStops: GradientStop[], percent = 1): [string, string] {
if (gradientStops.length === 0) {
throw new Error('getEndpointColors requires at least one color stop');
throw new Error('getBarEndcapColors requires at least one color stop');
}
const startColor = gradientStops[0].color;
@@ -173,7 +171,7 @@ const getGuideDotColor = (color: string): string => {
return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor;
};
export function getGuideDotColors(gradientStops: GradientStop[], percent = 1): [string, string] {
const [startColor, endColor] = getEndpointColors(gradientStops, percent);
export function getEndpointMarkerColors(gradientStops: GradientStop[], percent = 1): [string, string] {
const [startColor, endColor] = getBarEndcapColors(gradientStops, percent);
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
}
@@ -1,3 +1,5 @@
import { GrafanaTheme2 } from '@grafana/data';
import { RadialGaugeDimensions } from './types';
export interface GlowGradientProps {
@@ -57,3 +59,36 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
</>
);
}
export function SpotlightGradient({
id,
dimensions,
roundedBars,
angle,
theme,
}: {
id: string;
dimensions: RadialGaugeDimensions;
angle: number;
roundedBars: boolean;
theme: GrafanaTheme2;
}) {
if (theme.isLight) {
return null;
}
const angleRadian = ((angle - 90) * Math.PI) / 180;
let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2);
let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2);
let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian);
let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian);
return (
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor={'white'} stopOpacity={0.0} />
<stop offset="95%" stopColor={'white'} stopOpacity={0.5} />
{roundedBars && <stop offset="100%" stopColor={'white'} stopOpacity={roundedBars ? 0.7 : 1} />}
</linearGradient>
);
}
@@ -1,4 +1,3 @@
export type RadialGradientMode = 'none' | 'auto';
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
export type RadialShape = 'circle' | 'gauge';
@@ -37,7 +37,7 @@ export function RadialBarPanel({
width={width}
height={height}
barWidthFactor={options.barWidthFactor}
gradient={options.effects?.gradient ? 'auto' : 'none'}
gradient={options.effects?.gradient}
glowBar={options.effects?.barGlow}
glowCenter={options.effects?.centerGlow}
roundedBars={options.barShape === 'rounded'}
@@ -50,6 +50,7 @@ export function RadialBarPanel({
alignmentFactors={valueProps.alignmentFactors}
valueManualFontSize={options.text?.valueSize}
nameManualFontSize={options.text?.titleSize}
endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined}
onClick={menuProps.openMenu}
/>
);
@@ -71,6 +71,22 @@ export const plugin = new PanelPlugin<Options>(RadialBarPanel)
showIf: (options) => options.segmentCount === 1,
});
builder.addRadio({
path: 'endpointMarker',
name: t('radialbar.config.endpoint-marker', 'Endpoint marker'),
description: t('radialbar.config.endpoint-marker-description', 'Glow is only supported in dark mode.'),
category,
defaultValue: defaultOptions.endpointMarker,
settings: {
options: [
{ value: 'point', label: t('radialbar.config.endpoint-marker-point', 'Point') },
{ value: 'glow', label: t('radialbar.config.endpoint-marker-glow', 'Glow') },
{ value: 'none', label: t('radialbar.config.endpoint-marker-none', 'None') },
],
},
showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1,
});
builder.addSliderInput({
path: 'barWidthFactor',
name: t('radialbar.config.bar-width', 'Bar width'),
@@ -28,7 +28,7 @@ composableKinds: PanelCfg: {
GaugePanelEffects: {
barGlow?: bool | *false
centerGlow?: bool | *false
gradient?: bool | *true
gradient?: bool | *true
} @cuetsy(kind="interface")
Options: {
@@ -40,7 +40,8 @@ composableKinds: PanelCfg: {
sparkline?: bool | *true
shape: "circle" | *"gauge"
barWidthFactor: number | *0.5
barShape: "flat" | "rounded" | *"flat"
barShape: "flat" | "rounded" | *"flat"
endpointMarker?: "point" | "glow" | "none" | *"point"
effects: GaugePanelEffects | *{}
} @cuetsy(kind="interface")
}
+2
View File
@@ -26,6 +26,7 @@ export interface Options extends common.SingleStatBaseOptions {
barShape: ('flat' | 'rounded');
barWidthFactor: number;
effects: GaugePanelEffects;
endpointMarker?: ('point' | 'glow' | 'none');
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
@@ -38,6 +39,7 @@ export const defaultOptions: Partial<Options> = {
barShape: 'flat',
barWidthFactor: 0.5,
effects: {},
endpointMarker: 'point',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',