Merge branch 'fastfrwrd/gauge-clip-path' into fastfrwrd/gauge-rounded-bars-accuracy
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -59,3 +59,9 @@ export interface ThemeRichColor {
|
||||
export type DeepPartial<T> = {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
};
|
||||
|
||||
/** @alpha */
|
||||
export interface GradientStop {
|
||||
color: string;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<clipPath id={id}>
|
||||
<path d={path} />
|
||||
</clipPath>
|
||||
<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={bgDivStyle} />
|
||||
</foreignObject>
|
||||
</g>
|
||||
|
||||
{showGuideDots && (
|
||||
<>
|
||||
{endpointColors && <circle cx={x1} cy={y1} r={barWidth / 2} fill={endpointColors[0]} />}
|
||||
{endpointColors && <circle cx={x2} cy={y2} r={barWidth / 2} fill={endpointColors[1]} />}
|
||||
{guideDotColors && arcLengthDeg > 5 && <circle cx={x1} cy={y1} r={dotRadius} fill={guideDotColors[0]} />}
|
||||
{guideDotColors && <circle cx={x2} cy={y2} r={dotRadius} fill={guideDotColors[1]} />}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
fillOpacity="1"
|
||||
stroke={color}
|
||||
strokeOpacity="1"
|
||||
strokeWidth={barWidth}
|
||||
filter={glowFilter}
|
||||
strokeLinecap={roundedBars ? 'round' : 'butt'}
|
||||
className="radial-arc-path"
|
||||
/>
|
||||
{showGuideDots && (
|
||||
<>
|
||||
{arcLengthDeg > 5 && <circle cx={x1} cy={y1} r={dotRadius} fill={guideDotStartColor} />}
|
||||
<circle cx={x2} cy={y2} r={dotRadius} fill={guideDotEndColor} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
RadialArcPath.displayName = 'RadialArcPath';
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<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}
|
||||
showGuideDots={roundedBars}
|
||||
guideDotStartColor={startDotColor}
|
||||
guideDotEndColor={endDotColor}
|
||||
/>
|
||||
</g>
|
||||
<defs>{colorDefs.getDefs()}</defs>
|
||||
{/** Track */}
|
||||
<RadialArcPath
|
||||
arcLengthDeg={angleRange - angle}
|
||||
color={theme.colors.action.hover}
|
||||
dimensions={dimensions}
|
||||
displayProcessor={displayProcessor}
|
||||
fieldDisplay={fieldDisplay}
|
||||
gradientMode="none"
|
||||
roundedBars={roundedBars}
|
||||
shape={shape}
|
||||
startAngle={startAngle + angle}
|
||||
/>
|
||||
{/** The colored bar */}
|
||||
<RadialArcPath
|
||||
arcLengthDeg={angle}
|
||||
color={gradientMode === 'none' ? fieldDisplay.display.color : undefined}
|
||||
dimensions={dimensions}
|
||||
displayProcessor={displayProcessor}
|
||||
fieldDisplay={fieldDisplay}
|
||||
glowFilter={glowFilter}
|
||||
gradientMode={gradientMode}
|
||||
roundedBars={roundedBars}
|
||||
shape={shape}
|
||||
showGuideDots={roundedBars}
|
||||
startAngle={startAngle}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<RadialArcPath
|
||||
@@ -48,18 +56,17 @@ export function RadialBarSegmented({
|
||||
startAngle={segmentAngle}
|
||||
dimensions={dimensions}
|
||||
color={segmentColor}
|
||||
shape={shape}
|
||||
glowFilter={glowFilter}
|
||||
arcLengthDeg={segmentArcLengthDeg}
|
||||
gradientMode={gradientMode}
|
||||
fieldDisplay={fieldDisplay}
|
||||
displayProcessor={displayProcessor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<g>{segments}</g>
|
||||
<defs>{colorDefs.getDefs()}</defs>
|
||||
</>
|
||||
);
|
||||
return <g>{segments}</g>;
|
||||
}
|
||||
|
||||
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 (
|
||||
// <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}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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) => (
|
||||
<stop key={i} offset={`${(stop.percent * 100).toFixed(2)}%`} stopColor={stop.color} stopOpacity={1} />
|
||||
));
|
||||
|
||||
// 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(
|
||||
<linearGradient key={id} id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
{stops}
|
||||
</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 x2 = shape === 'circle' ? 0 : 1 / valuePercent;
|
||||
const y2 = shape === 'circle' ? 1 : 0;
|
||||
this.defs.push(
|
||||
<radialGradient key={id} id={id} x1="0" y1="0" x2={x2} y2={y2}>
|
||||
{stops}
|
||||
</radialGradient>
|
||||
);
|
||||
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(
|
||||
<linearGradient key={id} id={id} x1="0" y1="0" x2={x2} y2={y2} gradientUnits="userSpaceOnUse">
|
||||
{stops}
|
||||
</linearGradient>
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
<RadialBar
|
||||
key={`radial-bar-${barIndex}-${gaugeId}`}
|
||||
dimensions={dimensions}
|
||||
colorDefs={colorDefs}
|
||||
angle={angle}
|
||||
angleRange={angleRange}
|
||||
startAngle={startAngle}
|
||||
roundedBars={roundedBars}
|
||||
glowFilter={`url(#${glowFilterId})`}
|
||||
shape={shape}
|
||||
gradientMode={gradient}
|
||||
displayProcessor={displayProcessor}
|
||||
fieldDisplay={displayValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -227,7 +222,9 @@ export function RadialGauge(props: RadialGaugeProps) {
|
||||
angleRange={angleRange}
|
||||
roundedBars={roundedBars}
|
||||
glowFilter={`url(#${glowFilterId})`}
|
||||
colorDefs={colorDefs}
|
||||
shape={shape}
|
||||
gradientMode={gradient}
|
||||
displayProcessor={displayProcessor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<g>{paths}</g>
|
||||
<defs>{colorDefs.getDefs()}</defs>
|
||||
</>
|
||||
);
|
||||
return <g>{paths}</g>;
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user