diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 63c36639e25..0c6ff34517a 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -319,7 +319,7 @@ export { type MonacoLanguageRegistryItem, monacoLanguageRegistry } from './monac export { createTheme } from './themes/createTheme'; export { getThemeById, getBuiltInThemes, type ThemeRegistryItem } from './themes/registry'; export type { NewThemeOptions } from './themes/createTheme'; -export type { ThemeRichColor, GrafanaTheme2 } from './themes/types'; +export type { ThemeRichColor, GrafanaTheme2, GradientStop } from './themes/types'; export type { ThemeColors } from './themes/createColors'; export type { ThemeBreakpoints, ThemeBreakpointsKey } from './themes/breakpoints'; export type { ThemeShadows } from './themes/createShadows'; diff --git a/packages/grafana-data/src/themes/colorManipulator.ts b/packages/grafana-data/src/themes/colorManipulator.ts index 42bbf454b1a..844671d50da 100644 --- a/packages/grafana-data/src/themes/colorManipulator.ts +++ b/packages/grafana-data/src/themes/colorManipulator.ts @@ -4,6 +4,8 @@ import tinycolor from 'tinycolor2'; +import { GradientStop } from './types'; + /** * Returns a number whose value is limited to the given range. * @param value The value to be clamped @@ -393,16 +395,14 @@ export const onBackground = ( }; /** + * @alpha * Given color stops (each with a color and percentage 0..1) returns the color at a given percentage. * Uses tinycolor.mix for interpolation. * @params stops - array of color stops (percentages 0..1) * @params percent - percentage 0..1 * @returns color at the given percentage */ -export function colorAtGradientPercent( - stops: Array<{ color: string; percent: number }>, - percent: number -): tinycolor.Instance { +export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { if (!stops || stops.length < 2) { throw new Error('colorAtGradientPercent requires at least two color stops'); } diff --git a/packages/grafana-data/src/themes/types.ts b/packages/grafana-data/src/themes/types.ts index f586937cf3c..93f34705522 100644 --- a/packages/grafana-data/src/themes/types.ts +++ b/packages/grafana-data/src/themes/types.ts @@ -59,3 +59,9 @@ export interface ThemeRichColor { export type DeepPartial = { [P in keyof T]?: DeepPartial; }; + +/** @alpha */ +export interface GradientStop { + color: string; + percent: number; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index a6278294029..f105b821577 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,15 +1,25 @@ -import { GaugeDimensions, toRad } from './utils'; +import { useId, useMemo, memo } from 'react'; + +import { DisplayProcessor, FieldDisplay } from '@grafana/data'; + +import { useTheme2 } from '../../themes/ThemeContext'; + +import { RadialGradientMode, RadialShape } from './RadialGauge'; +import { buildGradientColors, getEndpointColors, getGradientCss, getGuideDotColors } from './colors'; +import { drawRadialArcPath, GaugeDimensions, toRad } from './utils'; export interface RadialArcPathPropsBase { - startAngle: number; - dimensions: GaugeDimensions; - color: string; - glowFilter?: string; arcLengthDeg: number; + color?: string; + dimensions: GaugeDimensions; + displayProcessor: DisplayProcessor; + fieldDisplay: FieldDisplay; + glowFilter?: string; + gradientMode: RadialGradientMode; roundedBars?: boolean; + shape: RadialShape; showGuideDots?: boolean; - guideDotStartColor?: string; - guideDotEndColor?: string; + startAngle: number; } interface RadialArcPathPropsWithGuideDot extends RadialArcPathPropsBase { @@ -20,58 +30,105 @@ interface RadialArcPathPropsWithGuideDot extends RadialArcPathPropsBase { type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDot; +const DOT_RADIUS_FACTOR = 0.4; const MAX_DOT_RADIUS = 8; -export function RadialArcPath({ - startAngle: angle, - dimensions, - color, - glowFilter, - arcLengthDeg, - roundedBars, - showGuideDots, - guideDotStartColor, - guideDotEndColor, -}: RadialArcPathProps) { - const { radius, centerX, centerY, barWidth } = dimensions; +export const RadialArcPath = memo( + ({ + arcLengthDeg, + color, + dimensions, + displayProcessor, + fieldDisplay, + glowFilter, + gradientMode, + roundedBars, + shape, + showGuideDots, + startAngle: angle, + }: RadialArcPathProps) => { + const theme = useTheme2(); + const id = useId(); - if (arcLengthDeg === 360) { - // For some reason a 100% full arc cannot be rendered - arcLengthDeg = 359.99; + const gradientStops = useMemo(() => { + if (gradientMode === 'none') { + return []; + } + return buildGradientColors(gradientMode, theme, displayProcessor, fieldDisplay, fieldDisplay.display.color); + }, [gradientMode, fieldDisplay, theme, displayProcessor]); + + const { guideDotColors, endpointColors } = useMemo(() => { + if (!showGuideDots || gradientStops.length === 0) { + return { + guideDotStartColor: undefined, + guideDotEndColor: undefined, + }; + } + return { + guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0), + endpointColors: + shape === 'circle' ? getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0) : undefined, + }; + }, [showGuideDots, fieldDisplay, gradientStops, shape]); + + const bgDivStyle = useMemo(() => { + const baseStyles = { width: '100%', height: '100%' }; + if (color) { + return { backgroundColor: color, ...baseStyles }; + } + const gradientCss = getGradientCss(gradientStops, shape); + return { backgroundImage: gradientCss, ...baseStyles }; + }, [color, gradientStops, shape]); + + const { radius, centerX, centerY, barWidth } = dimensions; + + const path = useMemo( + () => drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars), + [angle, arcLengthDeg, dimensions, roundedBars] + ); + + const { x1, x2, y1, y2 } = useMemo(() => { + 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); + return { x1, y1, x2, y2 }; + }, [angle, arcLengthDeg, centerX, centerY, radius]); + + const dotRadius = Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS); + + return ( + <> + {/* FIXME: optimize this by only using clippath + foreign obj for gradients */} + + + + + +
+ + + + {showGuideDots && ( + <> + {endpointColors && } + {endpointColors && } + {guideDotColors && arcLengthDeg > 5 && } + {guideDotColors && } + + )} + + ); } +); - 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(' '); - const dotRadius = Math.min((barWidth / 2) * 0.4, MAX_DOT_RADIUS); - - return ( - <> - - {showGuideDots && ( - <> - {arcLengthDeg > 5 && } - - - )} - - ); -} +RadialArcPath.displayName = 'RadialArcPath'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index c5552ad5bab..786c506af00 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,55 +1,64 @@ +import { DisplayProcessor, FieldDisplay } from '@grafana/data'; + import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarProps { - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; - angleRange: number; angle: number; - startAngle: number; - roundedBars?: boolean; + angleRange: number; + dimensions: GaugeDimensions; + displayProcessor: DisplayProcessor; + fieldDisplay: FieldDisplay; glowFilter?: string; + gradientMode: RadialGradientMode; + roundedBars?: boolean; + shape: RadialShape; + startAngle: number; } export function RadialBar({ - dimensions, - colorDefs, - angleRange, angle, - startAngle, - roundedBars, + angleRange, + dimensions, + displayProcessor, + fieldDisplay, glowFilter, + gradientMode, + roundedBars, + shape, + startAngle, }: RadialBarProps) { const theme = useTheme2(); - const [startDotColor, endDotColor] = colorDefs.getGuideDotColors(); - return ( <> - - {/** Track */} - - {/** The colored bar */} - - - {colorDefs.getDefs()} + {/** Track */} + + {/** The colored bar */} + ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index dd2ec0ac9bf..bff45475181 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -1,30 +1,34 @@ -import { FieldDisplay } from '@grafana/data'; +import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface RadialBarSegmentedProps { fieldDisplay: FieldDisplay; + displayProcessor: DisplayProcessor; dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; angleRange: number; startAngle: number; glowFilter?: string; segmentCount: number; segmentSpacing: number; + shape: RadialShape; + gradientMode: RadialGradientMode; } export function RadialBarSegmented({ fieldDisplay, + displayProcessor, dimensions, startAngle, angleRange, glowFilter, segmentCount, segmentSpacing, - colorDefs, + shape, + gradientMode, }: RadialBarSegmentedProps) { const segments: React.ReactNode[] = []; const theme = useTheme2(); @@ -38,9 +42,13 @@ export function RadialBarSegmented({ for (let i = 0; i < segmentCountAdjusted; i++) { const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const angleColor = colorDefs.getSegmentColor(angleValue, i); const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor; + let segmentColor: string | undefined; + if (angleValue >= value) { + segmentColor = theme.colors.action.hover; + } else if (gradientMode === 'none') { + segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR; + } segments.push( ); } - return ( - <> - {segments} - {colorDefs.getDefs()} - - ); + return {segments}; } export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { @@ -83,44 +90,3 @@ function getOptimalSegmentCount( 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 ( -// -// ); -// } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx deleted file mode 100644 index 9e42d7b1bc2..00000000000 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import tinycolor from 'tinycolor2'; - -import { - colorManipulator, - 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; -} - -const CONTRAST_THRESHOLD_MAX = 4.5; -const getGuideDotColor = (color: string): string => { - const darkColor = '#111217'; // gray05 - const lightColor = '#fbfbfb'; // gray90 - return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; -}; - -export class RadialColorDefs { - private colorToIds: Record = {}; - private defs: React.ReactNode[] = []; - - constructor(private options: RadialColorDefsOptions) {} - - getSegmentColor(forValue: number, segmentIdx: number): string { - const { displayProcessor } = this.options; - const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR; - return this.getColor(baseColor, segmentIdx); - } - - getColor(baseColor: string, segmentIdx?: number): string { - const { gradient, dimensions, gaugeId, fieldDisplay, shape } = this.options; - - let id = `value-color-${baseColor}-${gaugeId}`; - const forSegment = segmentIdx !== undefined; - if (forSegment) { - id += `-segment-${segmentIdx}`; - } - - 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; - - const gradientStops = this.getGradient(baseColor, forSegment); - const stops = gradientStops.map((stop, i) => ( - - )); - - // circular gradients are a little awkward today. we don't exactly have the result we - // want for continuous color modes, which would be to have the radial bar fill from the top - // around the circle. But SVG doesn't support that kind of gradient on stroke paths out-of-the-box, - // we'd need to implement something like https://gist.github.com/mbostock/4163057 - - // 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) { - this.defs.push( - - {stops} - - ); - - 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 x2 = shape === 'circle' ? 0 : 1 / valuePercent; - const y2 = shape === 'circle' ? 1 : 0; - this.defs.push( - - {stops} - - ); - return returnColor; - } - - // 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; - - this.defs.push( - - {stops} - - ); - - return returnColor; - } - - getFieldBaseColor(): string { - return this.options.fieldDisplay.display.color ?? FALLBACK_COLOR; - } - - getMainBarColor(): string { - return this.getColor(this.getFieldBaseColor()); - } - - getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> { - const { gradient, fieldDisplay, theme } = this.options; - if (gradient === 'none') { - return [ - { color: baseColor, percent: 0 }, - { color: baseColor, percent: 1 }, - ]; - } - - const colorModeId = fieldDisplay.field.color?.mode; - const colorMode = getFieldColorMode(colorModeId); - - // Handle continusous color modes first - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - const colors = colorMode.getColors(theme); - return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); - } else if (colorMode.isByValue) { - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best - const darkerColor = tinycolor(baseColor).darken(5); - const lighterColor = tinycolor(baseColor).spin(20).lighten(10); - - const color1 = theme.isDark ? lighterColor : darkerColor; - const color2 = theme.isDark ? darkerColor : lighterColor; - - return [ - { color: color1.toString(), percent: 0 }, - { color: color2.toString(), percent: 0.6 }, - { color: color2.toString(), percent: 1 }, - ]; - } - - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best - // we set the highest contrast color second based on the theme. - const darkerColor = tinycolor(baseColor).spin(-20).darken(5); - const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10); - return theme.isDark - ? [ - { color: darkerColor.darken(10).toString(), percent: 0 }, - { color: lighterColor.lighten(10).toString(), percent: 1 }, - ] - : [ - { color: lighterColor.lighten(10).toString(), percent: 0 }, - { color: darkerColor.toString(), percent: 1 }, - ]; - } - - getGuideDotColors(): [string, string] { - const { dimensions, fieldDisplay, shape } = 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; - 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 - if (gradient.length >= 2) { - const endColorByPercentage = colorManipulator.colorAtGradientPercent(gradient, valuePercent); - endColor = - endColorByPercentage.getAlpha() === 1 - ? endColorByPercentage.toHexString() - : endColorByPercentage.toHex8String(); - } - - return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; - } - - getDefs(): React.ReactNode[] { - return this.defs; - } -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index d20665872c7..0771a52f9d1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -16,7 +16,6 @@ import { getFormattedThresholds } from '../Gauge/utils'; import { RadialBar } from './RadialBar'; import { RadialBarSegmented } from './RadialBarSegmented'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialScaleLabels } from './RadialScaleLabels'; import { RadialSparkline } from './RadialSparkline'; import { RadialText } from './RadialText'; @@ -133,15 +132,6 @@ export function RadialGauge(props: RadialGaugeProps) { const displayProcessor = getFieldDisplayProcessor(displayValue); const glowFilterId = `glow-${gaugeId}`; - const colorDefs = new RadialColorDefs({ - gradient, - fieldDisplay: displayValue, - theme, - dimensions, - shape, - gaugeId, - displayProcessor, - }); if (segmentCount > 1) { graphics.push( @@ -154,7 +144,9 @@ export function RadialGauge(props: RadialGaugeProps) { glowFilter={`url(#${glowFilterId})`} segmentCount={segmentCount} segmentSpacing={segmentSpacing} - colorDefs={colorDefs} + shape={shape} + gradientMode={gradient} + displayProcessor={displayProcessor} /> ); } else { @@ -162,12 +154,15 @@ export function RadialGauge(props: RadialGaugeProps) { ); } @@ -227,7 +222,9 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} - colorDefs={colorDefs} + shape={shape} + gradientMode={gradient} + displayProcessor={displayProcessor} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 8977bbb6642..784e14aa397 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,7 +1,7 @@ -import { FieldDisplay, Threshold } from '@grafana/data'; +import { DisplayProcessor, FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; +import { RadialGradientMode, RadialShape } from './RadialGauge'; import { GaugeDimensions } from './utils'; export interface Props { @@ -9,11 +9,13 @@ export interface Props { angleRange: number; startAngle: number; endAngle: number; + shape: RadialShape; fieldDisplay: FieldDisplay; roundedBars?: boolean; glowFilter?: string; - colorDefs: RadialColorDefs; thresholds: Threshold[]; + gradientMode: RadialGradientMode; + displayProcessor: DisplayProcessor; } export function ThresholdsBar({ dimensions, @@ -22,8 +24,10 @@ export function ThresholdsBar({ angleRange, roundedBars, glowFilter, - colorDefs, thresholds, + shape, + gradientMode, + displayProcessor, }: Props) { const fieldConfig = fieldDisplay.field; const min = fieldConfig.min ?? 0; @@ -55,20 +59,19 @@ export function ThresholdsBar({ key={i} startAngle={currentStart} arcLengthDeg={lengthDeg} + color={gradientMode === 'none' ? threshold.color : undefined} + shape={shape} dimensions={thresholdDimensions} roundedBars={roundedBars} glowFilter={glowFilter} - color={colorDefs.getColor(threshold.color, i)} + gradientMode={gradientMode} + displayProcessor={displayProcessor} + fieldDisplay={fieldDisplay} /> ); currentStart += lengthDeg; } - return ( - <> - {paths} - {colorDefs.getDefs()} - - ); + return {paths}; } diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts new file mode 100644 index 00000000000..e1d323b88a5 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -0,0 +1,122 @@ +import tinycolor from 'tinycolor2'; + +import { + colorManipulator, + DisplayProcessor, + FALLBACK_COLOR, + FieldDisplay, + getFieldColorMode, + GradientStop, + GrafanaTheme2, +} from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { RadialGradientMode, RadialShape } from './RadialGauge'; + +export function buildGradientColors( + gradientMode: RadialGradientMode, + theme: GrafanaTheme2, + displayProcessor: DisplayProcessor, + fieldDisplay: FieldDisplay, + baseColor = FALLBACK_COLOR, + forSegment?: boolean +): GradientStop[] { + if (gradientMode === 'none') { + return [ + { color: baseColor, percent: 0 }, + { color: baseColor, percent: 1 }, + ]; + } + + 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 ?? FALLBACK_COLOR, percent: 0 }, + ]; + + for (const threshold of thresholds) { + if (threshold.value > min && threshold.value < max) { + const percent = (threshold.value - min) / (max - min); + result.push({ color: theme.visualization.getColorByName(threshold.color), percent }); + } + } + + result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 }); + + return result; + } + + 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) })); + } + + if (colorMode.isByValue) { + // For value based colors we want to stay more true to the specific color + // So a radial gradient that adds a bit of light and shade works best + const darkerColor = tinycolor(baseColor).darken(5); + const lighterColor = tinycolor(baseColor).spin(20).lighten(10); + + const color1 = theme.isDark ? lighterColor : darkerColor; + const color2 = theme.isDark ? darkerColor : lighterColor; + + return [ + { color: color1.toString(), percent: 0 }, + { color: color2.toString(), percent: 0.6 }, + { color: color2.toString(), percent: 1 }, + ]; + } + + // For value based colors we want to stay more true to the specific color + // So a radial gradient that adds a bit of light and shade works best + // we set the highest contrast color second based on the theme. + const darkerColor = tinycolor(baseColor).spin(-20).darken(5); + const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10); + return theme.isDark + ? [ + { color: darkerColor.darken(10).toString(), percent: 0 }, + { color: lighterColor.lighten(10).toString(), percent: 1 }, + ] + : [ + { color: lighterColor.lighten(10).toString(), percent: 0 }, + { color: darkerColor.toString(), percent: 1 }, + ]; +} + +export function getEndpointColors(gradientStops: GradientStop[], percent = 0): [string, string] { + const startColor = gradientStops[0].color; + let endColor = gradientStops[gradientStops.length - 1].color; + + // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates + if (gradientStops.length >= 2) { + const endColorByPercentage = colorManipulator.colorAtGradientPercent(gradientStops, percent); + endColor = + endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); + } + return [startColor, endColor]; +} + +export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape): string { + const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); + return shape === 'circle' + ? `conic-gradient(from 0deg, ${colorStrings.join(', ')})` + : `linear-gradient(90deg, ${colorStrings.join(', ')})`; +} + +const CONTRAST_THRESHOLD_MAX = 4.5; +const getGuideDotColor = (color: string): string => { + const darkColor = '#111217'; // gray05 + const lightColor = '#fbfbfb'; // gray90 + return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; +}; + +export function getGuideDotColors(gradientStops: GradientStop[], percent = 0): [string, string] { + const [startColor, endColor] = getEndpointColors(gradientStops, percent); + return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index 44f767d89b2..a0746ff54de 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -155,3 +155,81 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an y: centerY + radius * Math.sin(radian), }; } + +export function drawRadialArcPath( + angle: number, + arcLengthDeg: number, + dimensions: GaugeDimensions, + roundedBars?: boolean +): string { + const { radius, centerX, centerY, barWidth } = dimensions; + + if (arcLengthDeg === 360) { + // For some reason a 100% full arc cannot be rendered + arcLengthDeg = 359.99; + } + + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); + + const largeArc = arcLengthDeg > 180 ? 1 : 0; + + const outerR = radius + barWidth / 2; + const innerR = Math.max(0, radius - barWidth / 2); + + const ox1 = centerX + outerR * Math.cos(startRadians); + const oy1 = centerY + outerR * Math.sin(startRadians); + const ox2 = centerX + outerR * Math.cos(endRadians); + const oy2 = centerY + outerR * Math.sin(endRadians); + + const ix1 = centerX + innerR * Math.cos(startRadians); + const iy1 = centerY + innerR * Math.sin(startRadians); + const ix2 = centerX + innerR * Math.cos(endRadians); + const iy2 = centerY + innerR * Math.sin(endRadians); + + const capR = barWidth / 2; + + const pathParts = [ + // start at outer start + 'M', + ox1, + oy1, + // outer arc from start to end (clockwise) + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + ox2, + oy2, + ]; + + if (roundedBars) { + // rounded end cap: small arc connecting outer end to inner end + pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); + } else { + // straight line to inner end + pathParts.push('L', ix2, iy2); + } + + if (innerR <= 0) { + // if inner radius collapsed to center, line to center and close + pathParts.push('L', centerX, centerY, 'Z'); + } else { + // inner arc from end back to start (counter-clockwise) + pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); + + if (roundedBars) { + // rounded start cap: small arc connecting inner start back to outer start + pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); + } else { + // straight line back to outer start + pathParts.push('L', ox1, oy1); + } + + pathParts.push('Z'); + } + + return pathParts.join(' '); +}