wip: using clip-path and CSS for drawing the gauge

This commit is contained in:
Paul Marbach
2025-12-15 17:38:26 -05:00
parent 0281ef1fed
commit 87521b0348
4 changed files with 183 additions and 71 deletions
@@ -1,11 +1,18 @@
import { useId } from 'react';
import { colorManipulator } from '@grafana/data';
import { RadialColorDefs } from './RadialColorDefs';
import { GaugeDimensions, toRad } from './utils';
export interface RadialArcPathPropsBase {
startAngle: number;
dimensions: GaugeDimensions;
color: string;
glowFilter?: string;
colorDef: RadialColorDefs;
arcLengthDeg: number;
color?: string;
gradient?: string;
glowFilter?: string;
roundedBars?: boolean;
showGuideDots?: boolean;
guideDotStartColor?: string;
@@ -22,17 +29,17 @@ type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDo
const MAX_DOT_RADIUS = 8;
export function RadialArcPath({
startAngle: angle,
dimensions,
color,
glowFilter,
function drawRadialArcPath({
angle,
arcLengthDeg,
dimensions,
roundedBars,
showGuideDots,
guideDotStartColor,
guideDotEndColor,
}: RadialArcPathProps) {
}: {
angle: number;
dimensions: GaugeDimensions;
arcLengthDeg: number;
roundedBars?: boolean;
}): string {
const { radius, centerX, centerY, barWidth } = dimensions;
if (arcLengthDeg === 360) {
@@ -43,29 +50,111 @@ export function RadialArcPath({
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(' ');
}
export function RadialArcPath({
startAngle: angle,
dimensions,
color,
colorDef,
gradient,
arcLengthDeg,
roundedBars,
showGuideDots,
guideDotStartColor,
guideDotEndColor,
}: RadialArcPathProps) {
const id = useId();
const { radius, centerX, centerY, barWidth } = dimensions;
const startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
const gradientList = colorDef?.getGradient();
const path = drawRadialArcPath({ angle, arcLengthDeg, dimensions, roundedBars });
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"
/>
<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>
{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 && (
<>
{arcLengthDeg > 5 && <circle cx={x1} cy={y1} r={dotRadius} fill={guideDotStartColor} />}
@@ -41,7 +41,7 @@ export function RadialBar({
dimensions={dimensions}
startAngle={startAngle}
arcLengthDeg={angle}
color={colorDefs.getMainBarColor()}
gradient={colorDefs.getGradientDef()}
roundedBars={roundedBars}
glowFilter={glowFilter}
showGuideDots={roundedBars}
@@ -1,5 +1,3 @@
import tinycolor from 'tinycolor2';
import {
colorManipulator,
DisplayProcessor,
@@ -10,6 +8,7 @@ import {
} from '@grafana/data';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { buildGradientColors } from './colors';
import { GaugeDimensions } from './utils';
export interface RadialColorDefsOptions {
@@ -124,50 +123,15 @@ export class RadialColorDefs {
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 },
];
}
return buildGradientColors(gradient, baseColor, theme, fieldDisplay.field.color?.mode, forSegment);
}
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 },
];
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(', ')})`
: 'linear-gradient(90deg, ' + colorStrings.join(', ') + ')';
}
getGuideDotColors(): [string, string] {
@@ -0,0 +1,59 @@
import tinycolor from 'tinycolor2';
import { FieldColorModeId, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import { RadialGradientMode } from './RadialGauge';
export function buildGradientColors(
gradientMode: RadialGradientMode,
baseColor: string,
theme: GrafanaTheme2,
colorModeId?: FieldColorModeId | string,
forSegment?: boolean
): Array<{ color: string; percent: number }> {
if (gradientMode === 'none') {
return [
{ color: baseColor, percent: 0 },
{ color: baseColor, percent: 1 },
];
}
const colorMode = getFieldColorMode(colorModeId);
// 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) {
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 },
];
}