RadialGauge: (#111841)

* Radial gauge

* Update

* Update

* Update

* Updated

* Progress

* Spotlight

* Glow

* More effects

* Update

* Update

* Update

* Update

* Fix overflow

* Progress

* Progress

* Barwidth factor

* Update

* segmemnted

* Update

* Update

* Update

* Display processor

* Progress

* Updated

* Update

* rounded bars option

* added option for rounded

* Fixed gauge shape and segments

* Updated text and sparkline placmeent

* progress

* New spotlight effect is working

* refactorings

* Update

* hue working in gauge mode

* Update

* Update

* Progress

* Refactorings and sizing improvements

* Refactorings

* Progress

* Unify arc path

* Thresholdsbar

* Update

* Progress

* Update

* Close to mergable

* Unit tests

* Update

* Update

* Fix

* Update

* update

* simple test

* Fix

* Minor tweak

* added icon to shape

* Progress on color simplification

* progress on new color system

* Simplify color gradient modes around a single auto mode

* Progress on text sizing

* Fixes

* Update

* Update

* Hook up manual font size

* Restore old behavior in old panel
This commit is contained in:
Torkel Ödegaard
2025-10-16 15:53:38 +02:00
committed by GitHub
parent 16b02e86fa
commit 77e571b079
39 changed files with 6154 additions and 19 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -37,6 +37,7 @@
"filter": (import '../dev-dashboards/transforms/filter.json'),
"gauge-multi-series": (import '../dev-dashboards/panel-gauge/gauge-multi-series.json'),
"gauge_tests": (import '../dev-dashboards/panel-gauge/gauge_tests.json'),
"gauge_tests_new": (import '../dev-dashboards/panel-gauge/gauge_tests_new.json'),
"geomap-color-field": (import '../dev-dashboards/panel-geomap/geomap-color-field.json'),
"geomap-photo-layer": (import '../dev-dashboards/panel-geomap/geomap-photo-layer.json'),
"geomap-route-layer": (import '../dev-dashboards/panel-geomap/geomap-route-layer.json'),
@@ -257,6 +257,12 @@ function calculateRange(
globalRange: NumericRange | undefined,
data: DataFrame[]
): { range?: { min?: number | null; max?: number | null; delta: number }; newGlobalRange: NumericRange | undefined } {
// If range is defined with min/max, use it
if (isNumber(config.min) && isNumber(config.max)) {
const range = { min: config.min, max: config.max, delta: config.max - config.min };
return { range, newGlobalRange: globalRange ?? range };
}
// Only calculate ranges when the field is a number and one of min/max is set to auto.
if (field.type !== FieldType.number || (isNumber(config.min) && isNumber(config.max))) {
return { newGlobalRange: globalRange };
+1
View File
@@ -285,6 +285,7 @@ export const availableIconsIndex = {
'ai-sparkle': true,
bitbucket: true,
git: true,
'tachometer-fast': true,
};
export type IconName = keyof typeof availableIconsIndex;
@@ -0,0 +1,49 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as common from '@grafana/schema';
export const pluginVersion = "12.3.0-pre";
export interface GaugePanelEffects {
barGlow?: boolean;
centerGlow?: boolean;
rounded?: boolean;
spotlight?: boolean;
}
export const defaultGaugePanelEffects: Partial<GaugePanelEffects> = {
barGlow: false,
centerGlow: true,
rounded: false,
spotlight: false,
};
export interface Options extends common.SingleStatBaseOptions {
barWidthFactor: number;
effects: GaugePanelEffects;
gradient: ('none' | 'auto');
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
showThresholdMarkers: boolean;
sparkline?: boolean;
}
export const defaultOptions: Partial<Options> = {
barWidthFactor: 0.4,
effects: {},
gradient: 'none',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',
showThresholdMarkers: true,
sparkline: false,
};
@@ -10,8 +10,10 @@ import {
GAUGE_DEFAULT_MAXIMUM,
GAUGE_DEFAULT_MINIMUM,
GrafanaTheme2,
FieldColorModeId,
FALLBACK_COLOR,
} from '@grafana/data';
import { VizTextDisplayOptions, VizOrientation } from '@grafana/schema';
import { VizTextDisplayOptions, VizOrientation, Threshold } from '@grafana/schema';
import { calculateFontSize } from '../../utils/measureText';
import { clearButtonStyles } from '../Button/Button';
@@ -96,6 +98,14 @@ export class Gauge extends PureComponent<Props> {
max = +max.toFixed(decimals);
}
let thresholds: Threshold[] = [];
if (field.color?.mode === FieldColorModeId.Thresholds) {
thresholds = getFormattedThresholds(decimals, field, theme);
} else {
thresholds = [{ value: field.min ?? GAUGE_DEFAULT_MINIMUM, color: value.color ?? FALLBACK_COLOR }];
}
const options = {
series: {
gauges: {
@@ -113,13 +123,13 @@ export class Gauge extends PureComponent<Props> {
layout: { margin: 0, thresholdWidth: 0, vMargin: 0 },
cell: { border: { width: 0 } },
threshold: {
values: getFormattedThresholds(decimals, field, value, theme),
values: thresholds,
label: {
show: showThresholdLabels,
margin: thresholdMarkersWidth + 1,
font: { size: thresholdLabelFontSize },
},
show: showThresholdMarkers,
show: showThresholdMarkers && thresholds.length > 1,
width: thresholdMarkersWidth,
},
value: {
@@ -6,10 +6,6 @@ import { getTheme } from '../../themes/getTheme';
import { calculateGaugeAutoProps, getFormattedThresholds } from './utils';
describe('getFormattedThresholds', () => {
const value = {
text: '25',
numeric: 25,
};
const theme = getTheme();
let field: FieldConfig;
@@ -30,7 +26,7 @@ describe('getFormattedThresholds', () => {
it('should return first thresholds color for min and max', () => {
field.thresholds = { mode: ThresholdsMode.Absolute, steps: [{ value: -Infinity, color: '#7EB26D' }] };
expect(getFormattedThresholds(2, field, value, theme)).toEqual([
expect(getFormattedThresholds(2, field, theme)).toEqual([
{ value: 0, color: '#7EB26D' },
{ value: 100, color: '#7EB26D' },
]);
@@ -46,7 +42,7 @@ describe('getFormattedThresholds', () => {
],
};
expect(getFormattedThresholds(2, field, value, theme)).toEqual([
expect(getFormattedThresholds(2, field, theme)).toEqual([
{ value: 0, color: '#7EB26D' },
{ value: 50, color: '#7EB26D' },
{ value: 75, color: '#EAB839' },
@@ -1,7 +1,4 @@
import {
DisplayValue,
FALLBACK_COLOR,
FieldColorModeId,
FieldConfig,
GAUGE_DEFAULT_MAXIMUM,
GAUGE_DEFAULT_MINIMUM,
@@ -51,13 +48,8 @@ export function calculateGaugeAutoProps(
export function getFormattedThresholds(
decimals: number,
field: FieldConfig,
value: DisplayValue,
theme: GrafanaTheme | GrafanaTheme2
): Threshold[] {
if (field.color?.mode !== FieldColorModeId.Thresholds) {
return [{ value: field.min ?? GAUGE_DEFAULT_MINIMUM, color: value.color ?? FALLBACK_COLOR }];
}
const thresholds = field.thresholds ?? DEFAULT_THRESHOLDS;
const isPercent = thresholds.mode === ThresholdsMode.Percentage;
const steps = thresholds.steps;
@@ -0,0 +1,52 @@
import { GaugeDimensions, toRad } from './utils';
export interface RadialArcPathProps {
startAngle: number;
dimensions: GaugeDimensions;
color: string;
glowFilter?: string;
arcLengthDeg: number;
roundedBars?: boolean;
}
export function RadialArcPath({
startAngle: angle,
dimensions,
color,
glowFilter,
arcLengthDeg,
roundedBars,
}: RadialArcPathProps) {
const { radius, centerX, centerY, barWidth } = dimensions;
if (arcLengthDeg === 360) {
// For some reason a 100% full arc cannot be rendered
arcLengthDeg = 359.99;
}
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
let x1 = centerX + radius * Math.cos(startRadians);
let y1 = centerY + radius * Math.sin(startRadians);
let x2 = centerX + radius * Math.cos(endRadians);
let y2 = centerY + radius * Math.sin(endRadians);
const largeArc = arcLengthDeg > 180 ? 1 : 0;
const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' ');
return (
<path
d={path}
fill="none"
fillOpacity="1"
stroke={color}
strokeOpacity="1"
strokeWidth={barWidth}
filter={glowFilter}
strokeLinecap={roundedBars ? 'round' : 'butt'}
className="radial-arc-path"
/>
);
}
@@ -0,0 +1,97 @@
import { GrafanaTheme2 } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions, toRad } from './utils';
export interface RadialBarProps {
dimensions: GaugeDimensions;
colorDefs: RadialColorDefs;
angleRange: number;
angle: number;
startAngle: number;
roundedBars?: boolean;
spotlightStroke: string;
glowFilter?: string;
}
export function RadialBar({
dimensions,
colorDefs,
angleRange,
angle,
startAngle,
roundedBars,
spotlightStroke,
glowFilter,
}: RadialBarProps) {
const theme = useTheme2();
return (
<>
<g>
{/** Track */}
<RadialArcPath
startAngle={startAngle + angle}
dimensions={dimensions}
arcLengthDeg={angleRange - angle}
color={theme.colors.action.hover}
roundedBars={roundedBars}
/>
{/** The colored bar */}
<RadialArcPath
dimensions={dimensions}
startAngle={startAngle}
arcLengthDeg={angle}
color={colorDefs.getMainBarColor()}
roundedBars={roundedBars}
glowFilter={glowFilter}
/>
{spotlightStroke && angle > 8 && (
<SpotlightSquareEffect
dimensions={dimensions}
angle={startAngle + angle}
glowFilter={glowFilter}
spotlightStroke={spotlightStroke}
theme={theme}
roundedBars={roundedBars}
/>
)}
</g>
<defs>{colorDefs.getDefs()}</defs>
</>
);
}
interface SpotlightEffectProps {
dimensions: GaugeDimensions;
angle: number;
glowFilter?: string;
spotlightStroke: string;
theme: GrafanaTheme2;
roundedBars?: boolean;
}
function SpotlightSquareEffect({ dimensions, angle, glowFilter, spotlightStroke, roundedBars }: SpotlightEffectProps) {
const { radius, centerX, centerY, barWidth } = dimensions;
const angleRadian = toRad(angle);
const x1 = centerX + radius * Math.cos(angleRadian - 0.2);
const y1 = centerY + radius * Math.sin(angleRadian - 0.2);
const x2 = centerX + radius * Math.cos(angleRadian);
const y2 = centerY + radius * Math.sin(angleRadian);
const path = ['M', x1, y1, 'A', radius, radius, 0, 0, 1, x2, y2].join(' ');
return (
<path
d={path}
fill="none"
strokeWidth={barWidth}
stroke={spotlightStroke}
strokeLinecap={roundedBars ? 'round' : 'butt'}
filter={glowFilter}
/>
);
}
@@ -0,0 +1,126 @@
import { FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions } from './utils';
export interface RadialBarSegmentedProps {
fieldDisplay: FieldDisplay;
dimensions: GaugeDimensions;
colorDefs: RadialColorDefs;
angleRange: number;
startAngle: number;
glowFilter?: string;
segmentCount: number;
segmentSpacing: number;
}
export function RadialBarSegmented({
fieldDisplay,
dimensions,
startAngle,
angleRange,
glowFilter,
segmentCount,
segmentSpacing,
colorDefs,
}: 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;
for (let i = 0; i < segmentCountAdjusted; i++) {
const angleValue = ((max - min) / segmentCountAdjusted) * i;
const angleColor = colorDefs.getSegmentColor(angleValue);
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
const segmentColor = angleValue > value ? theme.colors.action.hover : angleColor;
segments.push(
<RadialArcPath
key={i}
startAngle={segmentAngle}
dimensions={dimensions}
color={segmentColor}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
/>
);
}
return (
<>
<g>{segments}</g>
<defs>{colorDefs.getDefs()}</defs>
</>
);
}
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 / 4 / 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);
}
// export function RadialSegmentLine({
// gaugeId,
// center,
// angle,
// size,
// color,
// barWidth,
// roundedBars,
// glow,
// margin,
// segmentWidth,
// }: RadialSegmentProps) {
// const arcSize = size - barWidth;
// const radius = arcSize / 2 - margin;
// const angleRad = (Math.PI * (angle - 90)) / 180;
// const lineLength = radius - barWidth;
// const x1 = center + radius * Math.cos(angleRad);
// const y1 = center + radius * Math.sin(angleRad);
// const x2 = center + lineLength * Math.cos(angleRad);
// const y2 = center + lineLength * Math.sin(angleRad);
// return (
// <line
// x1={x1}
// y1={y1}
// x2={x2}
// y2={y2}
// fill="none"
// fillOpacity="0.85"
// stroke={color}
// strokeOpacity="1"
// strokeLinecap={roundedBars ? 'round' : 'butt'}
// strokeWidth={segmentWidth}
// strokeDasharray="0"
// filter={glow ? `url(#glow-${gaugeId})` : undefined}
// />
// );
// }
@@ -0,0 +1,137 @@
import tinycolor from 'tinycolor2';
import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
export interface RadialColorDefsOptions {
gradient: RadialGradientMode;
fieldDisplay: FieldDisplay;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
shape: RadialShape;
gaugeId: string;
displayProcessor: DisplayProcessor;
}
export class RadialColorDefs {
private colorToIds: Record<string, string> = {};
private defs: React.ReactNode[] = [];
constructor(private options: RadialColorDefsOptions) {}
getSegmentColor(forValue: number): string {
const { displayProcessor } = this.options;
const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR;
return this.getColor(baseColor, true);
}
getColor(baseColor: string, forSegment?: boolean): string {
const { gradient, dimensions, gaugeId, fieldDisplay, shape, theme } = this.options;
const id = `value-color-${baseColor}-${gaugeId}`;
if (this.colorToIds[id]) {
return this.colorToIds[id];
}
// If no gradient, just return the base color
if (gradient === 'none') {
this.colorToIds[id] = baseColor;
return baseColor;
}
const returnColor = (this.colorToIds[id] = `url(#${id})`);
const colorModeId = fieldDisplay.field.color?.mode;
const colorMode = getFieldColorMode(colorModeId);
const valuePercent = fieldDisplay.display.percent ?? 0;
// Handle continusous color modes first
// If it's a segment color we don't want to do continuous gradients
if (colorMode.isContinuous && colorMode.getColors && !forSegment) {
const colors = colorMode.getColors(theme);
const count = colors.length;
this.defs.push(
<linearGradient x1="0" y1="0" x2={1 / valuePercent} y2="0" id={id}>
{colors.map((stopColor, i) => (
<stop key={i} offset={`${(i / (count - 1)).toFixed(2)}`} stopColor={stopColor} stopOpacity={1} />
))}
</linearGradient>
);
return returnColor;
}
// 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) {
const color1 = tinycolor(baseColor).darken(5);
this.defs.push(
<radialGradient
cx={dimensions.centerX}
cy={dimensions.centerY}
r={dimensions.radius + dimensions.barWidth / 2}
fr={dimensions.radius - dimensions.barWidth / 2}
id={id}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor={tinycolor(baseColor).spin(20).lighten(10).toString()} stopOpacity={1} />
<stop offset="60%" stopColor={color1.toString()} stopOpacity={1} />
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
</radialGradient>
);
}
// For fixed / palette based color scales we can create a more fun
// hue and light based linear gradient that we rotate/move with the value
const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius;
const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0;
const color1 = tinycolor(baseColor).spin(-20).darken(5);
const color2 = tinycolor(baseColor).saturate(20).spin(20).brighten(10);
// this makes it so the gradient is always brightest at the current value
const transform =
shape === 'circle'
? `rotate(${360 * valuePercent - 180} ${dimensions.centerX} ${dimensions.centerY})`
: `translate(-${dimensions.radius * 2 * (1 - valuePercent)}, 0)`;
this.defs.push(
<linearGradient
x1="0"
y1="0"
x2={x2}
y2={y2}
id={id}
gradientUnits="userSpaceOnUse"
gradientTransform={transform}
>
{theme.isDark ? (
<>
<stop offset="0%" stopColor={color1.darken(10).toString()} stopOpacity={1} />
<stop offset="100%" stopColor={color2.lighten(10).toString()} stopOpacity={1} />
</>
) : (
<>
<stop offset="0%" stopColor={color2.lighten(10).toString()} stopOpacity={1} />
<stop offset="100%" stopColor={color1.toString()} stopOpacity={1} />
</>
)}
</linearGradient>
);
return returnColor;
}
getMainBarColor(): string {
return this.getColor(this.options.fieldDisplay.display.color ?? FALLBACK_COLOR);
}
getDefs(): React.ReactNode[] {
return this.defs;
}
}
@@ -0,0 +1,482 @@
import { Meta, StoryFn } from '@storybook/react';
import {
applyFieldOverrides,
Field,
FieldType,
getFieldDisplayValues,
GrafanaTheme2,
toDataFrame,
} from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import { useTheme2 } from '../../themes/ThemeContext';
import { Stack } from '../Layout/Stack/Stack';
import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge';
interface StoryProps extends RadialGaugeProps {
value: number;
seriesCount: number;
sparkline: boolean;
colorScheme: FieldColorModeId;
decimals: number;
}
const meta: Meta<StoryProps> = {
title: 'Plugins/RadialGauge',
component: RadialGauge,
excludeStories: ['RadialGaugeExample'],
parameters: {
controls: {
exclude: ['theme', 'values', 'vizCount'],
},
},
args: {
barWidthFactor: 0.2,
spotlight: false,
glowBar: false,
glowCenter: false,
sparkline: false,
value: undefined,
width: 200,
height: 200,
shape: 'circle',
gradient: 'none',
seriesCount: 1,
segmentCount: 0,
segmentSpacing: 0.4,
roundedBars: false,
thresholdsBar: false,
colorScheme: FieldColorModeId.Thresholds,
decimals: 0,
},
argTypes: {
barWidthFactor: { control: { type: 'range', min: 0.1, max: 1, step: 0.01 } },
width: { control: { type: 'range', min: 50, max: 600 } },
height: { control: { type: 'range', min: 50, max: 600 } },
value: { control: { type: 'range', min: 0, max: 110 } },
spotlight: { control: 'boolean' },
roundedBars: { control: 'boolean' },
sparkline: { control: 'boolean' },
thresholdsBar: { control: 'boolean' },
gradient: { control: { type: 'radio' } },
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 } },
colorScheme: {
control: { type: 'select' },
options: [
FieldColorModeId.Thresholds,
FieldColorModeId.Fixed,
FieldColorModeId.ContinuousGrYlRd,
FieldColorModeId.ContinuousBlYlRd,
FieldColorModeId.ContinuousBlPu,
],
},
decimals: { control: { type: 'range', min: 0, max: 7 } },
},
};
export const Basic: StoryFn<StoryProps> = (args) => {
const visualizations: React.ReactNode[] = [];
const colors = ['blue', 'green', 'red', 'purple', 'orange', 'yellow', 'dark-red', 'dark-blue', 'dark-green'];
for (let i = 0; i < args.seriesCount; i++) {
const color = args.colorScheme === FieldColorModeId.Fixed ? colors[i % colors.length] : undefined;
visualizations.push(
<RadialGaugeExample {...args} key={i} color={color} seriesCount={0} vizCount={args.seriesCount} />
);
}
return (
<Stack direction={'row'} gap={3} wrap="wrap">
{visualizations}
</Stack>
);
};
export const Examples: StoryFn = (args) => {
return (
<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={60} color="blue" barWidthFactor={0.1} />
<RadialGaugeExample seriesName="0.4" value={60} color="green" barWidthFactor={0.4} />
<RadialGaugeExample seriesName="0.6" value={60} color="red" barWidthFactor={0.6} />
<RadialGaugeExample seriesName="0.8" value={60} color="purple" barWidthFactor={0.8} />
</Stack>
<div>Effects</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
<RadialGaugeExample value={30} spotlight glowBar glowCenter color="blue" gradient="auto" />
<RadialGaugeExample value={40} spotlight glowBar glowCenter color="green" gradient="auto" />
<RadialGaugeExample value={60} spotlight glowBar glowCenter color="red" gradient="auto" roundedBars />
<RadialGaugeExample value={70} spotlight glowBar glowCenter color="purple" gradient="auto" roundedBars />
</Stack>
<div>Shape: Gauge & color scale</div>
<Stack direction="row" alignItems="center" gap={3} wrap="wrap">
<RadialGaugeExample
value={40}
shape="gauge"
gradient="auto"
colorScheme={FieldColorModeId.ContinuousGrYlRd}
glowCenter={true}
barWidthFactor={0.6}
/>
<RadialGaugeExample
colorScheme={FieldColorModeId.ContinuousGrYlRd}
gradient="auto"
value={90}
barWidthFactor={0.6}
roundedBars={false}
glowBar={true}
glowCenter={true}
shape="gauge"
/>
</Stack>
<div>Sparklines</div>
<Stack direction={'row'} gap={3}>
<RadialGaugeExample
value={70}
color="blue"
shape="gauge"
{...args}
gradient="auto"
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.2}
/>
<RadialGaugeExample
value={30}
color="green"
shape="gauge"
{...args}
gradient="auto"
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.8}
/>
<RadialGaugeExample
value={50}
color="red"
{...args}
shape="gauge"
width={250}
gradient="auto"
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.2}
/>
<RadialGaugeExample
value={50}
color="red"
{...args}
width={250}
shape="gauge"
gradient="auto"
sparkline={true}
spotlight
glowBar={true}
glowCenter={true}
barWidthFactor={0.8}
/>
</Stack>
<div>Segmented</div>
<Stack direction={'row'} gap={3}>
<RadialGaugeExample
value={70}
color="green"
{...args}
gradient="auto"
glowCenter={true}
segmentCount={8}
barWidthFactor={0.4}
/>
<RadialGaugeExample
value={30}
color="green"
{...args}
gradient="auto"
segmentCount={20}
glowCenter={true}
barWidthFactor={0.5}
/>
<RadialGaugeExample
value={50}
color="red"
{...args}
gradient="auto"
segmentCount={40}
glowCenter={true}
barWidthFactor={0.7}
segmentSpacing={0.4}
/>
</Stack>
<div>Segmented color scale</div>
<Stack direction={'row'} gap={3}>
<RadialGaugeExample
value={70}
{...args}
colorScheme={FieldColorModeId.ContinuousGrYlRd}
spotlight
glowBar={true}
glowCenter={true}
segmentCount={20}
barWidthFactor={0.4}
/>
<RadialGaugeExample
value={70}
{...args}
width={250}
colorScheme={FieldColorModeId.ContinuousGrYlRd}
spotlight
shape="gauge"
glowBar={true}
glowCenter={true}
segmentCount={20}
barWidthFactor={0.4}
/>
</Stack>
<div>Thresholds</div>
<Stack direction={'row'} gap={3}>
<RadialGaugeExample
value={70}
{...args}
colorScheme={FieldColorModeId.Thresholds}
thresholdsBar={true}
roundedBars={false}
spotlight
glowCenter={true}
barWidthFactor={0.7}
/>
<RadialGaugeExample
value={70}
{...args}
width={250}
colorScheme={FieldColorModeId.Thresholds}
glowCenter={true}
thresholdsBar={true}
roundedBars={false}
shape="gauge"
barWidthFactor={0.7}
/>
<RadialGaugeExample
value={70}
{...args}
colorScheme={FieldColorModeId.Thresholds}
glowCenter={true}
thresholdsBar={true}
roundedBars={false}
segmentCount={40}
segmentSpacing={0.2}
shape="gauge"
barWidthFactor={0.7}
/>
</Stack>
</Stack>
);
};
Examples.parameters = {
controls: { include: ['barWidthFactor', 'value'] },
};
export const MultiSeries: StoryFn<StoryProps> = (args) => {
return (
<Stack direction={'column'} gap={3}>
<RadialGaugeExample color="red" {...args} />
</Stack>
);
};
MultiSeries.args = {
barWidthFactor: 0.2,
};
export const Temp: StoryFn<StoryProps> = (args) => {
return (
<Stack direction={'column'} gap={3}>
<RadialGaugeExample
{...args}
colorScheme={FieldColorModeId.ContinuousReds}
color="red"
shape="gauge"
roundedBars={false}
barWidthFactor={0.8}
spotlight
/>
</Stack>
);
};
interface ExampleProps {
gradient?: RadialGradientMode;
color?: string;
seriesName?: string;
value?: number;
shape?: RadialShape;
min?: number;
max?: number;
width?: number;
height?: number;
spotlight?: boolean;
glowBar?: boolean;
glowCenter?: boolean;
barWidthFactor?: number;
sparkline?: boolean;
seriesCount?: number;
vizCount?: number;
textMode?: RadialTextMode;
segmentCount?: number;
segmentSpacing?: number;
roundedBars?: boolean;
thresholdsBar?: boolean;
colorScheme?: FieldColorModeId;
decimals?: number;
}
export function RadialGaugeExample({
gradient = 'none',
color,
seriesName = 'Server A',
value = 70,
shape = 'circle',
min = 0,
max = 100,
width = 200,
height = 200,
spotlight = false,
glowBar = false,
glowCenter = false,
barWidthFactor = 0.4,
sparkline = false,
seriesCount = 0,
vizCount = 1,
textMode = 'auto',
segmentCount = 0,
segmentSpacing = 0.1,
roundedBars = false,
thresholdsBar = false,
colorScheme = FieldColorModeId.Thresholds,
decimals = 0,
}: ExampleProps) {
const theme = useTheme2();
if (color) {
colorScheme = FieldColorModeId.Fixed;
}
const frame = toDataFrame({
name: 'TestData',
length: 18,
fields: [
{
name: 'Time',
type: FieldType.time,
values: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17],
config: {
min: 0,
max: 4,
},
},
{
name: seriesName,
type: FieldType.number,
values: [40, 45, 20, 25, 30, 28, 27, 30, 31, 26, 50, 55, 52, 20, 25, 30, 60, value],
config: {
min: min,
max: max,
unit: 'percent',
decimals: decimals,
color: { mode: colorScheme, fixedColor: color ? theme.visualization.getColorByName(color) : undefined },
thresholds: {
mode: 'absolute',
steps: [
{ value: -Infinity, color: 'green' },
{ value: 65, color: 'orange' },
{ value: 85, color: 'red' },
],
},
},
// Add state and getLinks
state: {},
getLinks: () => [],
},
...getExtraSeries(seriesCount, colorScheme, decimals, theme),
],
});
const data = applyFieldOverrides({
data: [frame],
fieldConfig: {
defaults: {},
overrides: [],
},
replaceVariables: (value) => value,
timeZone: 'utc',
theme,
});
const values = getFieldDisplayValues({
fieldConfig: { overrides: [], defaults: {} },
reduceOptions: { calcs: ['last'] },
replaceVariables: (value) => value,
theme: theme,
data,
sparkline,
});
return (
<RadialGauge
values={values}
width={width}
height={height}
barWidthFactor={barWidthFactor}
gradient={gradient}
shape={shape}
spotlight={spotlight}
glowBar={glowBar}
glowCenter={glowCenter}
textMode={textMode}
vizCount={vizCount}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
roundedBars={roundedBars}
thresholdsBar={thresholdsBar}
/>
);
}
function getExtraSeries(seriesCount: number, colorScheme: FieldColorModeId, decimals: number, theme: GrafanaTheme2) {
const fields: Field[] = [];
const colors = ['blue', 'green', 'purple', 'orange', 'yellow'];
for (let i = 1; i < seriesCount; i++) {
fields.push({
name: `Series ${i + 1}`,
type: FieldType.number,
values: [40, 45, 20, 25, 30, 28, 27, 30, 31, 26, 50, 55, 52, 20, 25, 30, 60, 20 * (i + 1)],
config: {
min: 0,
max: 100,
decimals: decimals,
unit: 'percent',
color: { mode: colorScheme, fixedColor: theme.visualization.getColorByName(colors[i % colors.length]) },
},
// Add state and getLinks
state: {},
getLinks: () => [],
});
}
return fields;
}
export default meta;
@@ -0,0 +1,11 @@
import { render, screen } from '@testing-library/react';
import { RadialGaugeExample } from './RadialGauge.story';
describe('RadialGauge', () => {
it('should render', () => {
render(<RadialGaugeExample />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
});
@@ -0,0 +1,259 @@
import { css } from '@emotion/css';
import { isNumber } from 'lodash';
import { useId } from 'react';
import { DisplayValueAlignmentFactors, FieldDisplay, getDisplayProcessor, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { useStyles2, useTheme2 } from '../../themes/ThemeContext';
import { RadialBar } from './RadialBar';
import { RadialBarSegmented } from './RadialBarSegmented';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialSparkline } from './RadialSparkline';
import { RadialText } from './RadialText';
import { ThresholdsBar } from './ThresholdsBar';
import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects';
import { calculateDimensions, getValueAngleForValue } from './utils';
export interface RadialGaugeProps {
values: FieldDisplay[];
width: number;
height: number;
/**
* Circle or gauge (partial circle)
*/
shape?: RadialShape;
gradient?: RadialGradientMode;
/**
* Bar width is always relative to size of the gauge.
* But this gives you control over the width relative to size.
* Range 0 - 1 (1 being the thickest)
* Defaults to 0.4
**/
barWidthFactor?: number;
/** Adds a white spotlight for the end position */
spotlight?: boolean;
glowBar?: boolean;
glowCenter?: boolean;
roundedBars?: boolean;
thresholdsBar?: boolean;
/**
* Number of segments depends on size of gauge but this
* factor 1-10 gives you relative control
**/
segmentCount?: number;
/**
* Distance between segments
* Factor between 0-1
*/
segmentSpacing?: number;
/**
* If multiple is shown in a group (via VizRepeater).
* This impacts the auto textMode
*/
vizCount?: number;
/** Factors that should influence the positioning of the text */
alignmentFactors?: DisplayValueAlignmentFactors;
/** Explicit font size control */
valueManualFontSize?: number;
/** Explicit font size control */
nameManualFontSize?: number;
/** Specify which text should be visible */
textMode?: RadialTextMode;
}
export type RadialGradientMode = 'none' | 'auto';
export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none';
export type RadialShape = 'circle' | 'gauge';
export function RadialGauge(props: RadialGaugeProps) {
const {
width = 256,
height = 256,
shape = 'circle',
gradient = 'none',
barWidthFactor = 0.4,
spotlight = false,
glowBar = false,
glowCenter = false,
textMode = 'auto',
vizCount = 1,
segmentCount = 0,
segmentSpacing = 0.1,
roundedBars = true,
thresholdsBar = false,
values,
} = props;
const theme = useTheme2();
const gaugeId = useId();
const styles = useStyles2(getStyles);
const startAngle = shape === 'gauge' ? 250 : 0;
const endAngle = shape === 'gauge' ? 110 : 360;
const defs: React.ReactNode[] = [];
const graphics: React.ReactNode[] = [];
let sparklineElement: React.ReactNode | null = null;
for (let barIndex = 0; barIndex < values.length; barIndex++) {
const displayValue = values[barIndex];
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
const color = displayValue.display.color ?? 'gray';
const dimensions = calculateDimensions(
width,
height,
endAngle,
glowBar,
roundedBars,
barWidthFactor,
barIndex,
thresholdsBar
);
const displayProcessor = getFieldDisplayProcessor(displayValue);
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
const glowFilterId = `glow-${gaugeId}`;
const colorDefs = new RadialColorDefs({
gradient,
fieldDisplay: displayValue,
theme,
dimensions,
shape,
gaugeId,
displayProcessor,
});
if (spotlight) {
defs.push(
<SpotlightGradient
key={spotlightGradientId}
id={spotlightGradientId}
angle={angle + startAngle}
dimensions={dimensions}
roundedBars={roundedBars}
theme={theme}
/>
);
}
if (segmentCount > 1) {
graphics.push(
<RadialBarSegmented
key={`radial-bar-segmented-${barIndex}-${gaugeId}`}
dimensions={dimensions}
fieldDisplay={displayValue}
angleRange={angleRange}
startAngle={startAngle}
glowFilter={`url(#${glowFilterId})`}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
colorDefs={colorDefs}
/>
);
} else {
graphics.push(
<RadialBar
key={`radial-bar-${barIndex}-${gaugeId}`}
dimensions={dimensions}
colorDefs={colorDefs}
angle={angle}
angleRange={angleRange}
startAngle={startAngle}
roundedBars={roundedBars}
spotlightStroke={`url(#${spotlightGradientId})`}
glowFilter={`url(#${glowFilterId})`}
/>
);
}
// These elements are only added for first value / bar
if (barIndex === 0) {
if (glowBar) {
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} radius={dimensions.radius} />);
}
if (glowCenter) {
graphics.push(<MiddleCircleGlow key="center-glow" gaugeId={gaugeId} color={color} dimensions={dimensions} />);
}
if (thresholdsBar) {
graphics.push(
<ThresholdsBar
key="thresholds-bar"
dimensions={dimensions}
fieldDisplay={displayValue}
startAngle={startAngle}
endAngle={endAngle}
angleRange={angleRange}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
colorDefs={colorDefs}
/>
);
}
graphics.push(
<RadialText
key="radial-text"
vizCount={vizCount}
textMode={textMode}
displayValue={displayValue.display}
dimensions={dimensions}
theme={theme}
valueManualFontSize={props.valueManualFontSize}
nameManualFontSize={props.nameManualFontSize}
shape={shape}
/>
);
if (displayValue.sparkline) {
sparklineElement = (
<RadialSparkline
sparkline={displayValue.sparkline}
dimensions={dimensions}
theme={theme}
color={color}
shape={shape}
/>
);
}
}
}
return (
<div className={styles.vizWrapper} style={{ width, height }}>
<svg width={width} height={height} role="img" aria-label={t('gauge.category-gauge', 'Gauge')}>
<defs>{defs}</defs>
{graphics}
</svg>
{sparklineElement}
</div>
);
}
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({
position: 'relative',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
// Adds subtle shadow in light themes to help bar stand out
'.radial-arc-path': {
filter: theme.isLight ? `drop-shadow(0px 0px 1px #888);` : '',
},
}),
};
}
@@ -0,0 +1,52 @@
import { css } from '@emotion/css';
import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data';
import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema';
import { Sparkline } from '../Sparkline/Sparkline';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
interface RadialSparklineProps {
sparkline: FieldDisplay['sparkline'];
dimensions: GaugeDimensions;
theme: GrafanaTheme2;
color?: string;
shape?: RadialShape;
}
export function RadialSparkline({ sparkline, dimensions, theme, color, shape }: RadialSparklineProps) {
if (!sparkline) {
return null;
}
const { radius, barWidth } = dimensions;
const height = radius / 4;
const widthFactor = shape === 'gauge' ? 1.6 : 1.4;
const width = radius * widthFactor - barWidth;
const topPos = shape === 'gauge' ? `calc(50% + ${radius / 1.75}px)` : `calc(50% + ${radius / 2.8}px)`;
const styles = css({
position: 'absolute',
top: topPos,
});
const config: FieldConfig<GraphFieldConfig> = {
color: {
mode: 'fixed',
fixedColor: color ?? 'blue',
},
custom: {
gradientMode: GraphGradientMode.Opacity,
fillOpacity: 40,
lineInterpolation: LineInterpolation.Smooth,
},
};
return (
<div className={styles}>
<Sparkline height={height} width={width} sparkline={sparkline} theme={theme} config={config} />
</div>
);
}
@@ -0,0 +1,151 @@
import { css } from '@emotion/css';
import { DisplayValue, DisplayValueAlignmentFactors, formattedValueToString, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode } from './RadialGauge';
import { GaugeDimensions } from './utils';
// function toCartesian(centerX: number, centerY: number, radius: number, angleInDegrees: number) {
// let radian = ((angleInDegrees - 90) * Math.PI) / 180.0;
// return {
// x: centerX + radius * Math.cos(radian),
// y: centerY + radius * Math.sin(radian),
// };
// }
interface RadialTextProps {
displayValue: DisplayValue;
theme: GrafanaTheme2;
dimensions: GaugeDimensions;
textMode: RadialTextMode;
vizCount: number;
shape: RadialShape;
alignmentFactors?: DisplayValueAlignmentFactors;
valueManualFontSize?: number;
nameManualFontSize?: number;
}
export function RadialText({
displayValue,
theme,
dimensions,
textMode,
vizCount,
shape,
alignmentFactors,
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
if (textMode === 'none') {
return null;
}
if (textMode === 'auto') {
textMode = vizCount === 1 ? 'value' : 'value_and_name';
}
const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? '';
const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue);
const showValue = textMode === 'value' || textMode === 'value_and_name';
const showName = textMode === 'name' || textMode === 'value_and_name';
const maxTextWidth = radius * 2 - barWidth - radius / 7;
// Not sure where this comes from but svg text is not using body line-height
const lineHeight = 1.21;
const valueWidthToRadiusFactor = 0.6;
const nameToHeightFactor = 0.3;
const largeRadiusScalingDecay = 0.92;
// 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;
if (showValue && showName) {
maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay);
maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay);
}
const valueFontSize =
valueManualFontSize ??
calculateFontSize(
valueToAlignTo,
maxTextWidth,
maxValueHeight,
lineHeight,
undefined,
theme.typography.body.fontWeight
);
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 / 2 : 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;
// For gauge shape we shift text up a bit
const valueDy = shape === 'gauge' ? -valueFontSize * 0.3 : 0;
const nameDy = shape === 'gauge' ? -nameFontSize * 0.7 : 0;
return (
<g>
{showValue && (
<text
x={centerX}
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
dy={valueDy}
>
<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}
dy={nameDy}
textAnchor="middle"
dominantBaseline="middle"
fill={nameColor}
>
{displayValue.title}
</text>
)}
</g>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
text: css({
verticalAlign: 'bottom',
}),
});
@@ -0,0 +1,72 @@
import { FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { getFormattedThresholds } from '../Gauge/utils';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions } from './utils';
export interface Props {
dimensions: GaugeDimensions;
angleRange: number;
startAngle: number;
endAngle: number;
fieldDisplay: FieldDisplay;
roundedBars?: boolean;
glowFilter?: string;
colorDefs: RadialColorDefs;
}
export function ThresholdsBar({
dimensions,
fieldDisplay,
startAngle,
angleRange,
roundedBars,
glowFilter,
colorDefs,
}: Props) {
const theme = useTheme2();
const fieldConfig = fieldDisplay.field;
const decimals = fieldConfig.decimals ?? 2;
const min = fieldConfig.min ?? 0;
const max = fieldConfig.max ?? 100;
const thresholds = getFormattedThresholds(decimals, fieldConfig, theme);
const outerRadius = dimensions.radius + dimensions.barWidth / 2;
const thresholdDimensions = {
...dimensions,
barWidth: dimensions.thresholdsBarWidth,
radius: outerRadius + dimensions.thresholdsBarWidth / 2 + dimensions.thresholdsBarSpacing,
};
let currentStart = startAngle;
let paths: React.ReactNode[] = [];
for (let i = 1; i < thresholds.length; i++) {
const threshold = thresholds[i];
const valueDeg = ((threshold.value - min) / (max - min)) * angleRange;
const lengthDeg = valueDeg - currentStart + startAngle;
paths.push(
<RadialArcPath
key={i}
startAngle={currentStart}
arcLengthDeg={lengthDeg}
dimensions={thresholdDimensions}
roundedBars={roundedBars}
glowFilter={glowFilter}
color={colorDefs.getColor(threshold.color, true)}
/>
);
currentStart += lengthDeg;
}
return (
<>
<g>{paths}</g>
<defs>{colorDefs.getDefs()}</defs>
</>
);
}
@@ -0,0 +1,94 @@
import { GrafanaTheme2 } from '@grafana/data';
import { GaugeDimensions } from './utils';
export interface GlowGradientProps {
id: string;
radius: number;
}
export function GlowGradient({ id, radius }: GlowGradientProps) {
const glowSize = 0.03 * radius;
return (
<filter id={id} filterUnits="userSpaceOnUse">
<feGaussianBlur stdDeviation={glowSize} />
<feComponentTransfer>
<feFuncA type="linear" slope="1" />
</feComponentTransfer>
<feBlend in2="SourceGraphic" />
</filter>
);
}
export function SpotlightGradient({
id,
dimensions,
roundedBars,
angle,
theme,
}: {
id: string;
dimensions: GaugeDimensions;
angle: number;
roundedBars: boolean;
theme: GrafanaTheme2;
}) {
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);
if (theme.isLight) {
return (
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor={'black'} stopOpacity={0.0} />
<stop offset="90%" stopColor={'black'} stopOpacity={0.0} />
<stop offset="91%" stopColor={'black'} stopOpacity={1} />
</linearGradient>
);
}
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>
);
}
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
return (
<radialGradient id={`circle-glow-${gaugeId}`} r={'50%'} fr={'0%'}>
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
</radialGradient>
);
}
export interface CenterGlowProps {
dimensions: GaugeDimensions;
gaugeId: string;
color?: string;
}
export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps) {
const gradientId = `circle-glow-${gaugeId}`;
return (
<>
<defs>
<radialGradient id={gradientId} r={'50%'} fr={'0%'}>
<stop offset="0%" stopColor={color} stopOpacity={0.2} />
<stop offset="90%" stopColor={color} stopOpacity={0} />
</radialGradient>
</defs>
<g>
<circle cx={dimensions.centerX} cy={dimensions.centerY} r={dimensions.radius} fill={`url(#${gradientId})`} />
</g>
</>
);
}
@@ -0,0 +1,173 @@
import { FieldDisplay } from '@grafana/data';
import type { RadialGaugeProps } from './RadialGauge';
import { calculateDimensions, toRad, getValueAngleForValue } 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
);
}
describe('calculateDimensions', () => {
it('should calculate basic dimensions for a square gauge', () => {
const result = calc();
expect(result).toMatchObject({
centerX: 100, // width / 2
centerY: 100, // height / 2
barWidth: expect.closeTo(13.33, 1),
radius: expect.closeTo(93.33, 1),
margin: 0, // no glow
barIndex: 0,
thresholdsBarWidth: 0,
thresholdsBarSpacing: 2,
});
});
it('should handle different aspect ratios', () => {
const wideGauge = calc({ width: 400, height: 200 });
const tallGauge = calc({ width: 200, height: 400 });
expect(wideGauge.centerX).toBe(200);
expect(wideGauge.centerY).toBe(100);
expect(tallGauge.centerX).toBe(100);
expect(tallGauge.centerY).toBe(200);
});
it('should apply glow margin when glow is enabled', () => {
const withoutGlow = calc({ width: 200, height: 200 });
const withGlow = calc({ width: 200, height: 200, glowBar: true });
expect(withGlow.margin).toBeGreaterThan(0);
expect(withoutGlow.margin).toBe(0);
expect(withGlow.radius).toBeLessThan(withoutGlow.radius); // glow reduces available space
});
it('should adjust radius for rounded bars when endAngle < 180', () => {
const sharpBars = calc({});
const roundedBars = calc({ roundedBars: true });
const roundedGauge = calc({ roundedBars: true, shape: 'gauge' });
expect(roundedBars.radius).toEqual(sharpBars.radius);
expect(roundedGauge.radius).toBeLessThan(sharpBars.radius);
});
it('should handle threshold bars', () => {
const withoutThresholds = calc({ width: 200, height: 200 });
const withThresholds = calc({ width: 200, height: 200, thresholdsBar: true });
expect(withThresholds.thresholdsBarWidth).toBe(4);
expect(withThresholds.radius).toBeLessThan(withoutThresholds.radius);
});
it('should adjust radius for multiple bars (barIndex > 0)', () => {
const firstBar = calc({ width: 200, height: 200, barIndex: 0 });
const secondBar = calc({ width: 200, height: 200, barIndex: 1 });
const thirdBar = calc({ width: 200, height: 200, barIndex: 2 });
expect(secondBar.radius).toBeLessThan(firstBar.radius);
expect(thirdBar.radius).toBeLessThan(secondBar.radius);
expect(thirdBar.barIndex).toBe(2);
});
it('should handle different barWidthFactors', () => {
const thinBar = calc({ width: 200, height: 200, barWidthFactor: 0.2, barIndex: 0 });
const thickBar = calc({ width: 200, height: 200, barWidthFactor: 0.8, barIndex: 0 });
expect(thickBar.barWidth).toBeGreaterThan(thinBar.barWidth);
expect(thinBar.radius).toBeGreaterThan(thickBar.radius); // thinner bars leave more space
});
it('should enforce minimum bar width', () => {
const result = calc({ width: 50, height: 50, barWidthFactor: 0.01, barIndex: 0 });
expect(result.barWidth).toBeGreaterThanOrEqual(2);
});
it('should optimize space and position for gauge shape', () => {
const gauge = calc({ width: 200, height: 200, shape: 'gauge', barIndex: 0 });
// Different end angles should affect the available space differently
expect(gauge.radius).toBeCloseTo(93.33, 1);
expect(gauge.centerY).toBeCloseTo(132.89, 1); // centerY can be much lower when shape is a semi circle
});
});
describe('toRad', () => {
it('should convert degrees to radians with -90 degree offset', () => {
expect(toRad(0)).toBeCloseTo(-Math.PI / 2, 5); // 0° becomes -90° in radians
expect(toRad(90)).toBeCloseTo(0, 5); // 90° becomes 0° in radians
expect(toRad(180)).toBeCloseTo(Math.PI / 2, 5); // 180° becomes 90° in radians
expect(toRad(270)).toBeCloseTo(Math.PI, 5); // 270° becomes 180° in radians
});
it('should handle negative angles', () => {
expect(toRad(-90)).toBeCloseTo(-Math.PI, 5);
});
});
describe('getValueAngleForValue', () => {
const createFieldDisplay = (value: number, min = 0, max = 100): FieldDisplay => ({
display: {
numeric: value,
text: value.toString(),
color: 'blue',
},
field: {
min,
max,
},
view: undefined,
colIndex: 0,
rowIndex: 0,
name: 'test',
getLinks: () => [],
hasLinks: false,
});
it('should calculate angle for value in range', () => {
const fieldDisplay = createFieldDisplay(50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(180); // 50% of 360°
expect(result.angleRange).toBe(360);
});
it('should handle different start and end angles', () => {
const fieldDisplay = createFieldDisplay(50, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 90, 270);
expect(result.angle).toBe(135); // 50% of 360° range
expect(result.angleRange).toBe(270);
});
it('should clamp angle to maximum range', () => {
const fieldDisplay = createFieldDisplay(150, 0, 100); // value exceeds max
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(360); // clamped to angleRange
});
it('should handle minimum values', () => {
const fieldDisplay = createFieldDisplay(0, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(0);
});
it('should handle maximum values', () => {
const fieldDisplay = createFieldDisplay(100, 0, 100);
const result = getValueAngleForValue(fieldDisplay, 0, 360);
expect(result.angle).toBe(360);
});
});
});
@@ -0,0 +1,105 @@
import { FieldDisplay } from '@grafana/data';
export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) {
const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle;
const min = fieldDisplay.field.min ?? 0;
const max = fieldDisplay.field.max ?? 100;
let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange;
if (angle > angleRange) {
angle = angleRange;
}
return { angleRange, angle };
}
/**
* Returns the angle in radians for a given angle in degrees
* But shifted -90 degrees to make 0 degree angle point upwards
* @param angle
* @returns
*/
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;
thresholdsBarWidth: number;
thresholdsBarSpacing: number;
}
export function calculateDimensions(
width: number,
height: number,
endAngle: number,
glow: boolean,
roundedBars: boolean,
barWidthFactor: number,
barIndex: number,
thresholdBar?: boolean
): GaugeDimensions {
const yMaxAngle = endAngle > 180 ? 180 : endAngle;
let margin = 0;
// Max radius based on width
let maxRadiusH = width / 2 - margin;
// Max radius based on height
let heightRatioV = Math.sin(toRad(yMaxAngle));
let maxRadiusV = (height - margin * 2) / (1 + heightRatioV);
let maxRadius = Math.min(maxRadiusH, maxRadiusV);
const barWidth = Math.max(barWidthFactor * (maxRadius / 3), 2);
const thresholdsToBarWidth = 0.2 * Math.pow(barWidth, 0.92);
const thresholdsBarWidth = thresholdBar ? Math.min(Math.max(thresholdsToBarWidth, 4), 12) : 0;
const thresholdsBarSpacing = Math.min(Math.max(thresholdsBarWidth / 2, 2), 12);
let outerRadius = maxRadius;
// If rounded bars is enabled they need a bit more vertical space
if (yMaxAngle < 180 && roundedBars) {
outerRadius -= barWidth;
}
if (thresholdsBarWidth > 0) {
maxRadiusH -= thresholdsBarWidth + thresholdsBarSpacing;
maxRadiusV -= thresholdsBarWidth + thresholdsBarSpacing;
outerRadius = Math.min(maxRadiusH, maxRadiusV);
}
if (glow) {
margin = 0.04 * outerRadius;
outerRadius -= (margin * 2) / (1 + heightRatioV);
}
let innerRadius = outerRadius - barWidth / 2;
const maxY = maxRadius * Math.sin(toRad(yMaxAngle)) + maxRadius;
const rest = height - maxY - margin * 2;
const centerX = width / 2;
const centerY = maxRadius + margin + rest / 2;
if (barIndex > 0) {
innerRadius = innerRadius - (barWidth + 4) * barIndex;
}
return {
margin,
radius: innerRadius,
centerX,
centerY,
barWidth,
barIndex,
thresholdsBarWidth,
thresholdsBarSpacing,
};
}
@@ -139,7 +139,7 @@ export class VizRepeater<V, D = {}> extends PureComponent<PropsWithDefaults<V, D
}
}
return <div style={{ position: 'relative' }}>{items}</div>;
return <div style={{ position: 'relative', width: '100%', height: '100%' }}>{items}</div>;
}
render() {
@@ -104,3 +104,4 @@ export { useComponentInstanceId } from '../utils/useComponetInstanceId';
export { closePopover } from '../utils/closePopover';
export { flattenTokens } from '../slate-plugins/slate-prism';
export { RadialGauge } from '../components/RadialGauge/RadialGauge';
+10
View File
@@ -278,6 +278,16 @@ func GetComposableKinds() ([]ComposableKind, error) {
CueFile: piechartCue,
})
radialbarCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/radialbar/panelcfg.cue"))
if err != nil {
return nil, err
}
kinds = append(kinds, ComposableKind{
Name: "radialbar",
Filename: "panelcfg.cue",
CueFile: radialbarCue,
})
statCue, err := loadCueFileWithCommon(root, filepath.Join(root, "./public/app/plugins/panel/stat/panelcfg.cue"))
if err != nil {
return nil, err
@@ -241,6 +241,7 @@ func verifyCorePluginCatalogue(t *testing.T, ctx context.Context, ps *pluginstor
"welcome": {},
"xychart": {},
"datagrid": {},
"radialbar": {},
}
expDataSources := map[string]struct{}{
+2 -1
View File
@@ -192,5 +192,6 @@
"unicons/window-grid",
"unicons/ban",
"unicons/git",
"unicons/bitbucket"
"unicons/bitbucket",
"unicons/tachometer-fast"
]
@@ -68,6 +68,9 @@ const heatmapPanel = async () =>
const nodeGraph = async () =>
await import(/* webpackChunkName: "nodeGraphPanel" */ 'app/plugins/panel/nodeGraph/module');
const radialBar = async () =>
await import(/* webpackChunkName: "radialBarPanel" */ 'app/plugins/panel/radialbar/module');
const builtInPlugins: Record<string, System.Module | (() => Promise<System.Module>)> = {
// datasources
'core:plugin/cloudwatch': cloudwatchPlugin,
@@ -110,6 +113,7 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
'core:plugin/welcome': welcomeBanner,
'core:plugin/nodeGraph': nodeGraph,
'core:plugin/histogram': histogramPanel,
'core:plugin/radialbar': radialBar,
};
export default builtInPlugins;
@@ -0,0 +1,50 @@
import { StandardEditorProps } from '@grafana/data';
import { t } from '@grafana/i18n';
import { Stack, Switch, Label } from '@grafana/ui';
import { GaugePanelEffects } from './panelcfg.gen';
/**
* Editor for all the radial bar effects options
*/
export function EffectsEditor(props: StandardEditorProps<GaugePanelEffects>) {
return (
<Stack direction="row" alignItems={'flex-start'} gap={1} wrap>
<Stack>
<Switch
id="radialbar-rounded-bars"
value={!!props.value?.rounded}
onChange={(e) => props.onChange({ ...props.value, rounded: e.currentTarget.checked })}
/>
<Label htmlFor="radialbar-rounded-bars">{t('radialbar.config.effects.rounded-bars', 'Rounded bars')}</Label>
</Stack>
<Stack>
<Switch
id="radialbar-bar-glow"
label={t('radialbar.config.effects.bar-glow', 'Bar glow')}
value={!!props.value?.barGlow}
onChange={(e) => props.onChange({ ...props.value, barGlow: e.currentTarget.checked })}
/>
<Label htmlFor="radialbar-bar-glow">{t('radialbar.config.effects.bar-glow', 'Bar glow')}</Label>
</Stack>
<Stack>
<Switch
id="radialbar-center-glow"
label={t('radialbar.config.effects.center-glow', 'Center glow')}
value={!!props.value?.centerGlow}
onChange={(e) => props.onChange({ ...props.value, centerGlow: e.currentTarget.checked })}
/>
<Label htmlFor="radialbar-center-glow">{t('radialbar.config.effects.center-glow', 'Center glow')}</Label>
</Stack>
<Stack>
<Switch
id="radialbar-spotlight"
label={t('radialbar.config.effects.spotlight', 'Spotlight')}
value={!!props.value?.spotlight}
onChange={(e) => props.onChange({ ...props.value, spotlight: e.currentTarget.checked })}
/>
<Label htmlFor="radialbar-spotlight">{t('radialbar.config.effects.spotlight', 'Spotlight')}</Label>
</Stack>
</Stack>
);
}
@@ -0,0 +1,105 @@
import {
DisplayValueAlignmentFactors,
FieldDisplay,
getDisplayValueAlignmentFactors,
getFieldDisplayValues,
PanelProps,
} from '@grafana/data';
import { DataLinksContextMenu, Stack, VizRepeater, VizRepeaterRenderValueProps } from '@grafana/ui';
import { DataLinksContextMenuApi, RadialGauge } from '@grafana/ui/internal';
import { config } from 'app/core/config';
import { Options } from './panelcfg.gen';
export function RadialBarPanel({
height,
width,
data,
renderCounter,
options,
replaceVariables,
fieldConfig,
timeZone,
}: PanelProps<Options>) {
function renderComponent(
valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>,
menuProps: DataLinksContextMenuApi
) {
const { width, height, value } = valueProps;
return (
<RadialGauge
values={[value]}
width={width}
height={height}
barWidthFactor={options.barWidthFactor}
gradient={options.gradient}
spotlight={options.effects?.spotlight}
glowBar={options.effects?.barGlow}
glowCenter={options.effects?.centerGlow}
roundedBars={options.effects?.rounded}
vizCount={valueProps.count}
shape={options.shape}
segmentCount={options.segmentCount}
segmentSpacing={options.segmentSpacing}
thresholdsBar={options.showThresholdMarkers}
alignmentFactors={valueProps.alignmentFactors}
valueManualFontSize={options.text?.valueSize}
nameManualFontSize={options.text?.titleSize}
/>
);
}
function renderValue(
valueProps: VizRepeaterRenderValueProps<FieldDisplay, DisplayValueAlignmentFactors>
): JSX.Element {
const { value } = valueProps;
const { getLinks, hasLinks } = value;
if (hasLinks && getLinks) {
return (
<DataLinksContextMenu links={getLinks} style={{ flexGrow: 1 }}>
{(api) => {
return renderComponent(valueProps, api);
}}
</DataLinksContextMenu>
);
}
return renderComponent(valueProps, {});
}
function getValues(): FieldDisplay[] {
return getFieldDisplayValues({
fieldConfig,
reduceOptions: options.reduceOptions,
replaceVariables,
theme: config.theme2,
data: data.series,
sparkline: options.sparkline,
timeZone,
});
}
const minVizHeight = 60;
const minVizWidth = 60;
return (
<Stack direction="row" justifyContent="center" alignItems="center" height={'100%'}>
<VizRepeater
getValues={getValues}
renderValue={renderValue}
width={width}
height={height}
source={data}
autoGrid={true}
itemSpacing={16}
renderCounter={renderCounter}
orientation={options.orientation}
minVizHeight={minVizHeight}
minVizWidth={minVizWidth}
getAlignmentFactors={getDisplayValueAlignmentFactors}
/>
</Stack>
);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 80.73 58.39"><defs><style>.cls-1{fill:#84aff1;}.cls-2{fill:#3865ab;}.cls-3{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="54.11" y1="33.93" x2="72.49" y2="33.93" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M27.14,48.88l7-13.94v-.11H26v-3.4H38.54v3.43l-7.06,14Z"/><path class="cls-1" d="M47.2,31.13a8.09,8.09,0,0,1,2.74.47,6.1,6.1,0,0,1,2.32,1.48,7,7,0,0,1,1.61,2.67,12,12,0,0,1,.59,4,12.44,12.44,0,0,1-.91,5A7.32,7.32,0,0,1,51,48a6.77,6.77,0,0,1-3.87,1.11,7,7,0,0,1-3.25-.71,5.91,5.91,0,0,1-2.24-1.92,5.79,5.79,0,0,1-1-2.71h4.15a2.07,2.07,0,0,0,.83,1.31,2.77,2.77,0,0,0,3.9-1,7.76,7.76,0,0,0,.79-3.78h-.1a3.94,3.94,0,0,1-1.75,1.84,5.41,5.41,0,0,1-2.68.67A5.32,5.32,0,0,1,43,42.15a5,5,0,0,1-1.88-2,5.89,5.89,0,0,1-.68-2.88,6,6,0,0,1,.85-3.21,5.85,5.85,0,0,1,2.37-2.17A7.45,7.45,0,0,1,47.2,31.13Zm0,3.24a2.59,2.59,0,0,0-1.94.8,3,3,0,0,0,0,4,2.73,2.73,0,0,0,3.86,0,2.79,2.79,0,0,0,.78-2,2.82,2.82,0,0,0-.77-2A2.56,2.56,0,0,0,47.23,34.37Z"/><path class="cls-1" d="M30.08,18.55a24.24,24.24,0,0,1,8.28-2.22v-8a31.86,31.86,0,0,0-12.29,3.3Z"/><path class="cls-2" d="M42.36,8.31v8a24.23,24.23,0,0,1,8.29,2.22l4-6.94A31.86,31.86,0,0,0,42.36,8.31Z"/><path class="cls-2" d="M7.52,56a36.13,36.13,0,0,1-3.44-18A36.37,36.37,0,1,1,73.21,56a1,1,0,0,0,.39,1.3l1.75,1a1,1,0,0,0,1.4-.42A40.37,40.37,0,1,0,4,57.83a1,1,0,0,0,1.4.42l1.75-1A1,1,0,0,0,7.52,56Z"/><path class="cls-2" d="M17.77,51.1a1,1,0,0,0,.41-1.27,24.08,24.08,0,0,1,8.44-29.27l-4-6.94A32.06,32.06,0,0,0,11.14,53.68a1,1,0,0,0,1.41.43Z"/><path class="cls-3" d="M64.49,40.37a24.09,24.09,0,0,1-1.94,9.46A1,1,0,0,0,63,51.1l5.22,3a1,1,0,0,0,1.41-.43A32.06,32.06,0,0,0,58.12,13.62l-4,6.94A24.12,24.12,0,0,1,64.49,40.37Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,107 @@
import { PanelPlugin } from '@grafana/data';
import { t } from '@grafana/i18n';
import { commonOptionsBuilder } from '@grafana/ui';
import { addOrientationOption, addStandardDataReduceOptions } from '../stat/common';
import { EffectsEditor } from './EffectsEditor';
import { RadialBarPanel } from './RadialBarPanel';
import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen';
import { GaugeSuggestionsSupplier } from './suggestions';
export const plugin = new PanelPlugin<Options>(RadialBarPanel)
.useFieldConfig({})
.setPanelOptions((builder) => {
const category = [t('gauge.category-radial-bar', 'Gauge')];
addStandardDataReduceOptions(builder);
addOrientationOption(builder, category);
commonOptionsBuilder.addTextSizeOptions(builder, { withTitle: true, withValue: true });
builder.addRadio({
path: 'shape',
name: t('radialbar.config.shape', 'Shape'),
category,
defaultValue: defaultOptions.shape,
settings: {
options: [
{ value: 'circle', label: t('radialbar.config.shape-circle', 'Circle'), icon: 'circle' },
{ value: 'gauge', label: t('radialbar.config.shape-gauge', 'Gauge'), icon: 'tachometer-fast' },
],
},
});
builder.addRadio({
path: 'gradient',
name: t('radialbar.config.gradient', 'Gradient'),
category,
defaultValue: 'none',
settings: {
options: [
{ value: 'none', label: t('radialbar.config.gradient-none', 'None') },
{ value: 'auto', label: t('radialbar.config.gradient-auto', 'Auto') },
],
},
});
builder.addSliderInput({
path: 'barWidthFactor',
name: t('radialbar.config.bar-width', 'Bar width'),
category,
defaultValue: defaultOptions.barWidthFactor,
settings: {
min: 0.1,
max: 1,
step: 0.01,
},
});
builder.addSliderInput({
path: 'segmentCount',
name: t('radialbar.config.segment-count', 'Segments'),
category,
defaultValue: defaultOptions.segmentCount,
settings: {
min: 1,
max: 100,
step: 1,
},
});
builder.addSliderInput({
path: 'segmentSpacing',
name: t('radialbar.config.segment-spacing', 'Segment spacing'),
category,
defaultValue: defaultOptions.segmentSpacing,
showIf: (options) => options.segmentCount > 1,
settings: {
min: 0,
max: 1,
step: 0.01,
},
});
builder.addBooleanSwitch({
path: 'sparkline',
name: t('radialbar.config.sparkline', 'Show sparkline'),
category,
defaultValue: defaultOptions.sparkline,
});
builder.addBooleanSwitch({
path: 'showThresholdMarkers',
name: t('radialbar.config.threshold-markers', 'Show thresholds'),
category,
defaultValue: defaultOptions.showThresholdMarkers,
});
builder.addCustomEditor({
id: 'radialbar-effects',
path: 'effects',
name: 'Effects',
category,
editor: EffectsEditor,
settings: {},
defaultValue: defaultGaugePanelEffects,
});
})
.setSuggestionsSupplier(new GaugeSuggestionsSupplier());
@@ -0,0 +1,12 @@
* unit where name is
* dynamic font size based on text length
* text alignment factors
* segment hue gradient
* Rethink gradient / color options
* threshold & min/max labels
Gauge => new gauge migration notes
Old gauge "Show threshold markers" does nothing when color scheme != From thresholds
@@ -0,0 +1,50 @@
// Copyright 2021 Grafana Labs
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package grafanaplugin
import (
"github.com/grafana/grafana/packages/grafana-schema/src/common"
)
composableKinds: PanelCfg: {
maturity: "experimental"
lineage: {
schemas: [{
version: [0, 0]
schema: {
GaugePanelEffects: {
barGlow?: bool | *false
spotlight?: bool | *false
rounded?: bool | *false
centerGlow?: bool | *true
} @cuetsy(kind="interface")
Options: {
common.SingleStatBaseOptions
showThresholdMarkers: bool | *true
segmentCount: number | *1
segmentSpacing: number | *0.3
sparkline?: bool | *false
shape: "circle" | *"gauge"
barWidthFactor: number | *0.4
gradient: *"none" | "auto"
effects: GaugePanelEffects | *{}
} @cuetsy(kind="interface")
}
}]
lenses: []
}
}
@@ -0,0 +1,47 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
//
// Generated by:
// public/app/plugins/gen.go
// Using jennies:
// TSTypesJenny
// PluginTsTypesJenny
//
// Run 'make gen-cue' from repository root to regenerate.
import * as common from '@grafana/schema';
export interface GaugePanelEffects {
barGlow?: boolean;
centerGlow?: boolean;
rounded?: boolean;
spotlight?: boolean;
}
export const defaultGaugePanelEffects: Partial<GaugePanelEffects> = {
barGlow: false,
centerGlow: true,
rounded: false,
spotlight: false,
};
export interface Options extends common.SingleStatBaseOptions {
barWidthFactor: number;
effects: GaugePanelEffects;
gradient: ('none' | 'auto');
segmentCount: number;
segmentSpacing: number;
shape: ('circle' | 'gauge');
showThresholdMarkers: boolean;
sparkline?: boolean;
}
export const defaultOptions: Partial<Options> = {
barWidthFactor: 0.4,
effects: {},
gradient: 'none',
segmentCount: 1,
segmentSpacing: 0.3,
shape: 'gauge',
showThresholdMarkers: true,
sparkline: false,
};
@@ -0,0 +1,24 @@
{
"type": "panel",
"name": "New Gauge",
"id": "radialbar",
"state": "alpha",
"info": {
"description": "Standard gauge visualization",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "img/icon_gauge.svg",
"large": "img/icon_gauge.svg"
},
"links": [
{ "name": "Raise issue", "url": "https://github.com/grafana/grafana/issues/new" },
{
"name": "Documentation",
"url": "https://grafana.com/docs/grafana/latest/panels-visualizations/visualizations/gauge/"
}
]
}
}
@@ -0,0 +1,58 @@
import { VisualizationSuggestionsBuilder } from '@grafana/data';
import { SuggestionName } from 'app/types/suggestions';
import { Options } from './panelcfg.gen';
export class GaugeSuggestionsSupplier {
getSuggestionsForData(builder: VisualizationSuggestionsBuilder) {
const { dataSummary } = builder;
if (!dataSummary.hasData || !dataSummary.hasNumberField) {
return;
}
// for many fields / series this is probably not a good fit
if (dataSummary.numberFieldCount >= 50) {
return;
}
const list = builder.getListAppender<Options, {}>({
name: SuggestionName.Gauge,
pluginId: 'gauge',
options: {},
fieldConfig: {
defaults: {},
overrides: [],
},
cardOptions: {
previewModifier: (s) => {
if (s.options!.reduceOptions.values) {
s.options!.reduceOptions.limit = 2;
}
},
},
});
if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) {
list.append({
name: SuggestionName.RadialBar,
options: {
reduceOptions: {
values: true,
calcs: [],
},
},
});
} else {
list.append({
name: SuggestionName.RadialBar,
options: {
reduceOptions: {
values: false,
calcs: ['lastNotNull'],
},
},
});
}
}
}
+1
View File
@@ -19,6 +19,7 @@ export enum SuggestionName {
StatColoredBackground = 'Stat colored background',
Gauge = 'Gauge',
GaugeNoThresholds = 'Gauge no thresholds',
RadialBar = 'Radial bar',
BarGaugeBasic = 'Bar gauge basic',
BarGaugeLCD = 'Bar gauge LCD',
Table = 'Table',
+22
View File
@@ -7678,6 +7678,7 @@
},
"gauge": {
"category-gauge": "Gauge",
"category-radial-bar": "Gauge",
"description-min-height": "Minimum row height (horizontal orientation)",
"description-min-width": "Minimum column width (vertical orientation)",
"description-neutral": "Leave empty to use Min as neutral point",
@@ -12051,6 +12052,27 @@
},
"query-editor-not-exported": "Data source plugin does not export any Query Editor component"
},
"radialbar": {
"config": {
"bar-width": "Bar width",
"effects": {
"bar-glow": "Bar glow",
"center-glow": "Center glow",
"rounded-bars": "Rounded bars",
"spotlight": "Spotlight"
},
"gradient": "Gradient",
"gradient-auto": "Auto",
"gradient-none": "None",
"segment-count": "Segments",
"segment-spacing": "Segment spacing",
"shape": "Shape",
"shape-circle": "Circle",
"shape-gauge": "Gauge",
"sparkline": "Show sparkline",
"threshold-markers": "Show thresholds"
}
},
"recently-deleted": {
"buttons": {
"restore": "Restore"