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:
Vendored
+1909
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
|
||||
@@ -285,6 +285,7 @@ export const availableIconsIndex = {
|
||||
'ai-sparkle': true,
|
||||
bitbucket: true,
|
||||
git: true,
|
||||
'tachometer-fast': true,
|
||||
};
|
||||
|
||||
export type IconName = keyof typeof availableIconsIndex;
|
||||
|
||||
+49
@@ -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';
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user