wip: overhaul color in gauge

This commit is contained in:
Paul Marbach
2025-12-15 20:40:38 -05:00
parent 87521b0348
commit 23e6c3301b
7 changed files with 95 additions and 44 deletions
@@ -1,15 +1,15 @@
import { useId } from 'react';
import { colorManipulator } from '@grafana/data';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions, toRad } from './utils';
export interface RadialArcPathPropsBase {
startAngle: number;
dimensions: GaugeDimensions;
colorDef: RadialColorDefs;
colorDefs: RadialColorDefs;
arcLengthDeg: number;
shape: RadialShape;
color?: string;
gradient?: string;
glowFilter?: string;
@@ -116,9 +116,10 @@ export function RadialArcPath({
startAngle: angle,
dimensions,
color,
colorDef,
gradient,
colorDefs,
shape,
arcLengthDeg,
glowFilter,
roundedBars,
showGuideDots,
guideDotStartColor,
@@ -129,7 +130,7 @@ export function RadialArcPath({
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
const gradientList = colorDef?.getGradient();
const [startColor, endColor] = colorDefs.getEndpointColors();
const path = drawRadialArcPath({ angle, arcLengthDeg, dimensions, roundedBars });
let x1 = centerX + radius * Math.cos(startRadians);
@@ -144,19 +145,34 @@ export function RadialArcPath({
<clipPath id={id}>
<path d={path} />
</clipPath>
<foreignObject x="0" y="0" width={centerX * 2} height={centerY * 2} clipPath={`url(#${id})`}>
<div style={{ width: '100%', height: '100%', backgroundImage: gradient, backgroundColor: color }} />
</foreignObject>
<g filter={glowFilter}>
<foreignObject
x={centerX - radius - barWidth}
y={centerY - radius - barWidth}
width={(radius + barWidth) * 2}
height={(radius + barWidth) * 2}
clipPath={`url(#${id})`}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: color ? undefined : colorDefs.getGradientDef(),
backgroundColor: color,
}}
/>
</foreignObject>
</g>
{roundedBars && gradientList && (
<>
<circle cx={x1} cy={y1} r={dotRadius} fill={gradientList[0].color} />
{/* this would actually need to be the color determined by the display */}
<circle cx={x2} cy={y2} r={dotRadius} fill={gradientList[gradientList.length - 1].color} />
</>
)}
{showGuideDots && (
<>
{shape === 'circle' && (
<>
<circle cx={x1} cy={y1} r={barWidth / 2} fill={startColor} />
<circle cx={x2} cy={y2} r={barWidth / 2} fill={endColor} />
</>
)}
{arcLengthDeg > 5 && <circle cx={x1} cy={y1} r={dotRadius} fill={guideDotStartColor} />}
<circle cx={x2} cy={y2} r={dotRadius} fill={guideDotEndColor} />
</>
@@ -2,6 +2,7 @@ import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
export interface RadialBarProps {
@@ -12,6 +13,7 @@ export interface RadialBarProps {
startAngle: number;
roundedBars?: boolean;
glowFilter?: string;
shape: RadialShape;
}
export function RadialBar({
dimensions,
@@ -21,6 +23,7 @@ export function RadialBar({
startAngle,
roundedBars,
glowFilter,
shape,
}: RadialBarProps) {
const theme = useTheme2();
const [startDotColor, endDotColor] = colorDefs.getGuideDotColors();
@@ -33,20 +36,24 @@ export function RadialBar({
startAngle={startAngle + angle}
dimensions={dimensions}
arcLengthDeg={angleRange - angle}
colorDefs={colorDefs}
color={theme.colors.action.hover}
roundedBars={roundedBars}
shape={shape}
/>
{/** The colored bar */}
<RadialArcPath
dimensions={dimensions}
startAngle={startAngle}
arcLengthDeg={angle}
colorDefs={colorDefs}
gradient={colorDefs.getGradientDef()}
roundedBars={roundedBars}
glowFilter={glowFilter}
showGuideDots={roundedBars}
guideDotStartColor={startDotColor}
guideDotEndColor={endDotColor}
shape={shape}
/>
</g>
<defs>{colorDefs.getDefs()}</defs>
@@ -4,6 +4,7 @@ import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
export interface RadialBarSegmentedProps {
@@ -15,6 +16,7 @@ export interface RadialBarSegmentedProps {
glowFilter?: string;
segmentCount: number;
segmentSpacing: number;
shape: RadialShape;
}
export function RadialBarSegmented({
fieldDisplay,
@@ -25,6 +27,7 @@ export function RadialBarSegmented({
segmentCount,
segmentSpacing,
colorDefs,
shape,
}: RadialBarSegmentedProps) {
const segments: React.ReactNode[] = [];
const theme = useTheme2();
@@ -38,9 +41,9 @@ export function RadialBarSegmented({
for (let i = 0; i < segmentCountAdjusted; i++) {
const angleValue = min + ((max - min) / segmentCountAdjusted) * i;
const angleColor = colorDefs.getSegmentColor(angleValue, i);
// const angleColor = colorDefs.getSegmentColor(angleValue, i);
const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01;
const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor;
const segmentColor = angleValue >= value ? theme.colors.action.hover : undefined;
segments.push(
<RadialArcPath
@@ -48,6 +51,8 @@ export function RadialBarSegmented({
startAngle={segmentAngle}
dimensions={dimensions}
color={segmentColor}
shape={shape}
colorDefs={colorDefs}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
/>
@@ -122,35 +122,24 @@ export class RadialColorDefs {
}
getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> {
const { gradient, fieldDisplay, theme } = this.options;
return buildGradientColors(gradient, baseColor, theme, fieldDisplay.field.color?.mode, forSegment);
const { displayProcessor, gradient, fieldDisplay, theme } = this.options;
return buildGradientColors(gradient, baseColor, theme, displayProcessor, fieldDisplay, forSegment);
}
getGradientDef(): string {
const gradientStops = this.getGradient();
const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`);
return this.options.shape === 'circle'
? `conic-gradient(from -10deg, ${colorStrings.join(', ')})`
? `conic-gradient(from 0deg, ${colorStrings.join(', ')})`
: 'linear-gradient(90deg, ' + colorStrings.join(', ') + ')';
}
getGuideDotColors(): [string, string] {
const { dimensions, fieldDisplay, shape } = this.options;
getEndpointColors(): [string, string] {
const { fieldDisplay } = this.options;
const gradient = this.getGradient();
let valuePercent = fieldDisplay.display.percent ?? 0;
// the linear gradient used in circular gradients means that we want to use the
// y position of the edge of the bar to determine the color. If we ever address
// that shortcoming, we could delete this block.
if (shape === 'circle') {
const angleDeg = ((valuePercent - 0.25) % 1) * 360;
const angleRad = (angleDeg * Math.PI) / 180;
const yPos = dimensions.centerY + dimensions.radius * Math.sin(angleRad);
valuePercent = yPos / (dimensions.centerY * 2);
}
let startColor = gradient[0].color;
const valuePercent = fieldDisplay.display.percent ?? 0;
const startColor = gradient[0].color;
let endColor = gradient[gradient.length - 1].color;
// if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates
@@ -161,7 +150,11 @@ export class RadialColorDefs {
? endColorByPercentage.toHexString()
: endColorByPercentage.toHex8String();
}
return [startColor, endColor];
}
getGuideDotColors(): [string, string] {
const [startColor, endColor] = this.getEndpointColors();
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
}
@@ -155,6 +155,7 @@ export function RadialGauge(props: RadialGaugeProps) {
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
colorDefs={colorDefs}
shape={shape}
/>
);
} else {
@@ -168,6 +169,7 @@ export function RadialGauge(props: RadialGaugeProps) {
startAngle={startAngle}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
shape={shape}
/>
);
}
@@ -228,6 +230,7 @@ export function RadialGauge(props: RadialGaugeProps) {
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
colorDefs={colorDefs}
shape={shape}
/>
);
}
@@ -2,6 +2,7 @@ import { FieldDisplay, Threshold } from '@grafana/data';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
export interface Props {
@@ -9,6 +10,7 @@ export interface Props {
angleRange: number;
startAngle: number;
endAngle: number;
shape: RadialShape;
fieldDisplay: FieldDisplay;
roundedBars?: boolean;
glowFilter?: string;
@@ -24,6 +26,7 @@ export function ThresholdsBar({
glowFilter,
colorDefs,
thresholds,
shape,
}: Props) {
const fieldConfig = fieldDisplay.field;
const min = fieldConfig.min ?? 0;
@@ -55,10 +58,11 @@ export function ThresholdsBar({
key={i}
startAngle={currentStart}
arcLengthDeg={lengthDeg}
colorDefs={colorDefs}
shape={shape}
dimensions={thresholdDimensions}
roundedBars={roundedBars}
glowFilter={glowFilter}
color={colorDefs.getColor(threshold.color, i)}
/>
);
@@ -1,6 +1,7 @@
import tinycolor from 'tinycolor2';
import { FieldColorModeId, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { DisplayProcessor, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import { RadialGradientMode } from './RadialGauge';
@@ -8,7 +9,8 @@ export function buildGradientColors(
gradientMode: RadialGradientMode,
baseColor: string,
theme: GrafanaTheme2,
colorModeId?: FieldColorModeId | string,
displayProcessor: DisplayProcessor,
fieldDisplay: FieldDisplay,
forSegment?: boolean
): Array<{ color: string; percent: number }> {
if (gradientMode === 'none') {
@@ -18,15 +20,36 @@ export function buildGradientColors(
];
}
const colorMode = getFieldColorMode(colorModeId);
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
if (colorMode.id === FieldColorModeId.Thresholds) {
const thresholds = fieldDisplay.field.thresholds?.steps ?? [];
const min = fieldDisplay.field.min ?? 0;
const max = fieldDisplay.field.max ?? 100;
const result: Array<{ color: string; percent: number }> = [
{ color: displayProcessor(min).color ?? baseColor, percent: 0 },
];
for (const threshold of thresholds) {
if (threshold.value > min && threshold.value < max) {
const percent = (threshold.value - min) / (max - min);
result.push({ color: threshold.color, percent });
}
}
result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 });
return result;
}
// TODO we need to return thresholded values here. those will have breakpoints
// which map to exact percentages, and we should show that correctly.
// Handle continuous color modes first
if (colorMode.isContinuous && colorMode.getColors && !forSegment) {
// Handle continuous color modes first
const colors = colorMode.getColors(theme);
return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) }));
} else if (colorMode.isByValue) {
}
if (colorMode.isByValue) {
// For value based colors we want to stay more true to the specific color
// So a radial gradient that adds a bit of light and shade works best
const darkerColor = tinycolor(baseColor).darken(5);