Gauge: Fix Safari issues in SVG rendering (#115756)
* Sparkline: Restore to a function component * fix whitespace lint issue * swap from clipPath to mask to help Safari * Gauge: Fix SVG issues in Safari * more steps in the right direction * don't set filters which don't exist * fix a couple other text and baseline stuff * fix tests after changes * clean up effects as follow-up to other PR * fix issue with threshold bars, and also simplify non-gradient case
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useId, memo, HTMLAttributes, ReactNode } from 'react';
|
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
|
||||||
|
|
||||||
import { FieldDisplay } from '@grafana/data';
|
import { FieldDisplay } from '@grafana/data';
|
||||||
|
|
||||||
@@ -50,14 +50,13 @@ export const RadialArcPath = memo(
|
|||||||
}: RadialArcPathProps) => {
|
}: RadialArcPathProps) => {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
|
||||||
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
|
const isGradient = 'gradient' in rest;
|
||||||
if ('color' in rest) {
|
|
||||||
bgDivStyle.backgroundColor = rest.color;
|
|
||||||
} else {
|
|
||||||
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
const { vizWidth, vizHeight, radius, centerX, centerY, barWidth } = dimensions;
|
||||||
|
const pad = Math.ceil(Math.max(2, barWidth / 2)); // pad to cover stroke caps and glow in Safari
|
||||||
|
const boxX = Math.round(centerX - radius - barWidth - pad);
|
||||||
|
const boxY = Math.round(centerY - radius - barWidth - pad);
|
||||||
|
const boxSize = Math.round((radius + barWidth) * 2 + pad * 2);
|
||||||
|
|
||||||
const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars);
|
const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars);
|
||||||
|
|
||||||
@@ -72,9 +71,14 @@ export const RadialArcPath = memo(
|
|||||||
const dotRadius =
|
const dotRadius =
|
||||||
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2;
|
||||||
|
|
||||||
|
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: boxSize, height: vizHeight, marginLeft: boxX };
|
||||||
|
|
||||||
|
const pathProps: SVGProps<SVGPathElement> = {};
|
||||||
let barEndcapColors: [string, string] | undefined;
|
let barEndcapColors: [string, string] | undefined;
|
||||||
let endpointMarks: ReactNode = null;
|
let endpointMarks: ReactNode = null;
|
||||||
if ('gradient' in rest) {
|
if (isGradient) {
|
||||||
|
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
|
||||||
|
|
||||||
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
|
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
|
||||||
switch (endpointMarker) {
|
switch (endpointMarker) {
|
||||||
case 'point':
|
case 'point':
|
||||||
@@ -115,25 +119,39 @@ export const RadialArcPath = memo(
|
|||||||
if (barEndcaps) {
|
if (barEndcaps) {
|
||||||
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
|
barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pathProps.fill = 'none';
|
||||||
|
pathProps.stroke = 'white';
|
||||||
|
} else {
|
||||||
|
bgDivStyle.backgroundColor = rest.color;
|
||||||
|
|
||||||
|
pathProps.fill = 'none';
|
||||||
|
pathProps.stroke = rest.color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pathEl = (
|
||||||
|
<path d={path} strokeWidth={barWidth} strokeLinecap={roundedBars ? 'round' : 'butt'} {...pathProps} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* FIXME: optimize this by only using clippath + foreign obj for gradients */}
|
{isGradient && (
|
||||||
<clipPath id={id}>
|
<defs>
|
||||||
<path d={path} />
|
<mask id={id} maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse">
|
||||||
</clipPath>
|
<rect x={boxX} y={boxY} width={boxSize} height={boxSize} fill="black" />
|
||||||
|
{pathEl}
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
)}
|
||||||
|
|
||||||
<g filter={glowFilter}>
|
<g filter={glowFilter}>
|
||||||
<foreignObject
|
{isGradient ? (
|
||||||
x={centerX - radius - barWidth}
|
<foreignObject x={0} y={0} width={vizWidth} height={vizHeight} mask={`url(#${id})`}>
|
||||||
y={centerY - radius - barWidth}
|
<div style={bgDivStyle} />
|
||||||
width={(radius + barWidth) * 2}
|
</foreignObject>
|
||||||
height={(radius + barWidth) * 2}
|
) : (
|
||||||
clipPath={`url(#${id})`}
|
pathEl
|
||||||
>
|
)}
|
||||||
<div style={bgDivStyle} />
|
|
||||||
</foreignObject>
|
|
||||||
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
|
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
|
||||||
{barEndcapColors?.[1] && (
|
{barEndcapColors?.[1] && (
|
||||||
<circle cx={xEnd} cy={yEnd} r={barWidth / 2} fill={barEndcapColors[1]} opacity={0.5} />
|
<circle cx={xEnd} cy={yEnd} r={barWidth / 2} fill={barEndcapColors[1]} opacity={0.5} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { useId } from 'react';
|
import { useId, ReactNode } from 'react';
|
||||||
|
|
||||||
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
@@ -107,14 +107,14 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
const startAngle = shape === 'gauge' ? 250 : 0;
|
const startAngle = shape === 'gauge' ? 250 : 0;
|
||||||
const endAngle = shape === 'gauge' ? 110 : 360;
|
const endAngle = shape === 'gauge' ? 110 : 360;
|
||||||
|
|
||||||
const defs: React.ReactNode[] = [];
|
const defs: ReactNode[] = [];
|
||||||
const graphics: React.ReactNode[] = [];
|
const graphics: ReactNode[] = [];
|
||||||
let sparklineElement: React.ReactNode | null = null;
|
let sparklineElement: ReactNode | null = null;
|
||||||
|
|
||||||
for (let barIndex = 0; barIndex < values.length; barIndex++) {
|
for (let barIndex = 0; barIndex < values.length; barIndex++) {
|
||||||
const displayValue = values[barIndex];
|
const displayValue = values[barIndex];
|
||||||
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
|
const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle);
|
||||||
const gradientStops = buildGradientColors(gradient, theme, displayValue);
|
const gradientStops = gradient ? buildGradientColors(theme, displayValue) : undefined;
|
||||||
const color = displayValue.display.color ?? FALLBACK_COLOR;
|
const color = displayValue.display.color ?? FALLBACK_COLOR;
|
||||||
const dimensions = calculateDimensions(
|
const dimensions = calculateDimensions(
|
||||||
width,
|
width,
|
||||||
@@ -131,7 +131,9 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
// FIXME: I want to move the ids for these filters into a context which the children
|
// FIXME: I want to move the ids for these filters into a context which the children
|
||||||
// can reference via a hook, rather than passing them down as props
|
// can reference via a hook, rather than passing them down as props
|
||||||
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
|
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
|
||||||
|
const spotlightGradientRef = endpointMarker === 'glow' ? `url(#${spotlightGradientId})` : undefined;
|
||||||
const glowFilterId = `glow-${gaugeId}`;
|
const glowFilterId = `glow-${gaugeId}`;
|
||||||
|
const glowFilterRef = glowBar ? `url(#${glowFilterId})` : undefined;
|
||||||
|
|
||||||
if (endpointMarker === 'glow') {
|
if (endpointMarker === 'glow') {
|
||||||
defs.push(
|
defs.push(
|
||||||
@@ -154,7 +156,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
fieldDisplay={displayValue}
|
fieldDisplay={displayValue}
|
||||||
angleRange={angleRange}
|
angleRange={angleRange}
|
||||||
startAngle={startAngle}
|
startAngle={startAngle}
|
||||||
glowFilter={`url(#${glowFilterId})`}
|
glowFilter={glowFilterRef}
|
||||||
segmentCount={segmentCount}
|
segmentCount={segmentCount}
|
||||||
segmentSpacing={segmentSpacing}
|
segmentSpacing={segmentSpacing}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
@@ -170,8 +172,8 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
angleRange={angleRange}
|
angleRange={angleRange}
|
||||||
startAngle={startAngle}
|
startAngle={startAngle}
|
||||||
roundedBars={roundedBars}
|
roundedBars={roundedBars}
|
||||||
glowFilter={`url(#${glowFilterId})`}
|
glowFilter={glowFilterRef}
|
||||||
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
|
endpointMarkerGlowFilter={spotlightGradientRef}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
gradient={gradientStops}
|
gradient={gradientStops}
|
||||||
fieldDisplay={displayValue}
|
fieldDisplay={displayValue}
|
||||||
@@ -183,7 +185,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
// These elements are only added for first value / bar
|
// These elements are only added for first value / bar
|
||||||
if (barIndex === 0) {
|
if (barIndex === 0) {
|
||||||
if (glowBar) {
|
if (glowBar) {
|
||||||
defs.push(<GlowGradient key="glow-filter" id={glowFilterId} barWidth={dimensions.barWidth} />);
|
defs.push(<GlowGradient key={glowFilterId} id={glowFilterId} barWidth={dimensions.barWidth} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (glowCenter) {
|
if (glowCenter) {
|
||||||
@@ -234,7 +236,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
endAngle={endAngle}
|
endAngle={endAngle}
|
||||||
angleRange={angleRange}
|
angleRange={angleRange}
|
||||||
roundedBars={roundedBars}
|
roundedBars={roundedBars}
|
||||||
glowFilter={`url(#${glowFilterId})`}
|
glowFilter={glowFilterRef}
|
||||||
shape={shape}
|
shape={shape}
|
||||||
gradient={gradientStops}
|
gradient={gradientStops}
|
||||||
/>
|
/>
|
||||||
@@ -260,7 +262,7 @@ export function RadialGauge(props: RadialGaugeProps) {
|
|||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<svg width={width} height={height} role="img" aria-label={t('gauge.category-gauge', 'Gauge')}>
|
<svg width={width} height={height} role="img" aria-label={t('gauge.category-gauge', 'Gauge')}>
|
||||||
<defs>{defs}</defs>
|
{defs.length > 0 && <defs>{defs}</defs>}
|
||||||
{graphics}
|
{graphics}
|
||||||
</svg>
|
</svg>
|
||||||
{sparklineElement}
|
{sparklineElement}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { css } from '@emotion/css';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
import { useStyles2 } from '../../themes/ThemeContext';
|
|
||||||
import { calculateFontSize } from '../../utils/measureText';
|
import { calculateFontSize } from '../../utils/measureText';
|
||||||
|
|
||||||
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
|
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
|
||||||
@@ -50,7 +48,6 @@ export const RadialText = memo(
|
|||||||
valueManualFontSize,
|
valueManualFontSize,
|
||||||
nameManualFontSize,
|
nameManualFontSize,
|
||||||
}: RadialTextProps) => {
|
}: RadialTextProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
const { centerX, centerY, radius, barWidth } = dimensions;
|
const { centerX, centerY, radius, barWidth } = dimensions;
|
||||||
|
|
||||||
if (textMode === 'none') {
|
if (textMode === 'none') {
|
||||||
@@ -106,10 +103,9 @@ export const RadialText = memo(
|
|||||||
const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY;
|
const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY;
|
||||||
const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY;
|
const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY;
|
||||||
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
|
const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary;
|
||||||
const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2;
|
|
||||||
|
|
||||||
// adjust the text up on gauges and when sparklines are present
|
// adjust the text up on gauges and when sparklines are present
|
||||||
let yOffset = 0;
|
let yOffset = valueFontSize / 4;
|
||||||
if (shape === 'gauge') {
|
if (shape === 'gauge') {
|
||||||
// we render from the center of the gauge, so move up by half of half of the total height
|
// we render from the center of the gauge, so move up by half of half of the total height
|
||||||
yOffset -= (valueHeight + nameHeight) / 4;
|
yOffset -= (valueHeight + nameHeight) / 4;
|
||||||
@@ -126,15 +122,12 @@ export const RadialText = memo(
|
|||||||
y={valueY}
|
y={valueY}
|
||||||
fontSize={valueFontSize}
|
fontSize={valueFontSize}
|
||||||
fill={theme.colors.text.primary}
|
fill={theme.colors.text.primary}
|
||||||
className={styles.text}
|
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="text-bottom"
|
||||||
>
|
>
|
||||||
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
|
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
|
||||||
<tspan>{displayValue.text}</tspan>
|
<tspan>{displayValue.text}</tspan>
|
||||||
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
|
<tspan fontSize={unitFontSize}>{displayValue.suffix ?? ''}</tspan>
|
||||||
{displayValue.suffix ?? ''}
|
|
||||||
</tspan>
|
|
||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{showName && (
|
{showName && (
|
||||||
@@ -143,7 +136,7 @@ export const RadialText = memo(
|
|||||||
x={centerX}
|
x={centerX}
|
||||||
y={nameY}
|
y={nameY}
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="middle"
|
dominantBaseline="text-bottom"
|
||||||
fill={nameColor}
|
fill={nameColor}
|
||||||
>
|
>
|
||||||
{displayValue.title}
|
{displayValue.title}
|
||||||
@@ -155,9 +148,3 @@ export const RadialText = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
RadialText.displayName = 'RadialText';
|
RadialText.displayName = 'RadialText';
|
||||||
|
|
||||||
const getStyles = (_theme: GrafanaTheme2) => ({
|
|
||||||
text: css({
|
|
||||||
verticalAlign: 'bottom',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 120 A 80 80 0 1 1 149.98603736605492 120.00000121846968"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 50 A 50 50 0 0 1 100 150"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 20 A 80 80 0 0 1 180 100"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 20 A 80 80 0 1 1 20 100.00000000000001"`;
|
||||||
|
|
||||||
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`;
|
exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 20 A 80 80 0 0 1 100 180"`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defaultsDeep } from 'lodash';
|
import { defaultsDeep } from 'lodash';
|
||||||
|
|
||||||
import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data';
|
import { createTheme, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data';
|
||||||
import { FieldColorModeId } from '@grafana/schema';
|
import { FieldColorModeId } from '@grafana/schema';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -50,35 +50,9 @@ describe('RadialGauge color utils', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the baseColor if gradient is false-y', () => {
|
|
||||||
expect(
|
|
||||||
buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
|
|
||||||
).toEqual([
|
|
||||||
{ color: '#FF0000', percent: 0 },
|
|
||||||
{ color: '#FF0000', percent: 1 },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000')
|
|
||||||
).toEqual([
|
|
||||||
{ color: '#FF0000', percent: 0 },
|
|
||||||
{ color: '#FF0000', percent: 1 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the fallback color if no baseColor is set', () => {
|
|
||||||
expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual(
|
|
||||||
[
|
|
||||||
{ color: FALLBACK_COLOR, percent: 0 },
|
|
||||||
{ color: FALLBACK_COLOR, percent: 1 },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
|
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
|
||||||
expect(
|
expect(
|
||||||
buildGradientColors(
|
buildGradientColors(
|
||||||
true,
|
|
||||||
createTheme(),
|
createTheme(),
|
||||||
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
|
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
|
||||||
view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) },
|
view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) },
|
||||||
@@ -89,14 +63,13 @@ describe('RadialGauge color utils', () => {
|
|||||||
|
|
||||||
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
|
it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => {
|
||||||
expect(
|
expect(
|
||||||
buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
|
buildGradientColors(createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
|
||||||
).toMatchSnapshot();
|
).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return gradient colors for continuous color modes', () => {
|
it('should return gradient colors for continuous color modes', () => {
|
||||||
expect(
|
expect(
|
||||||
buildGradientColors(
|
buildGradientColors(
|
||||||
true,
|
|
||||||
createTheme(),
|
createTheme(),
|
||||||
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
|
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
|
||||||
'#00FF00'
|
'#00FF00'
|
||||||
@@ -107,7 +80,6 @@ describe('RadialGauge color utils', () => {
|
|||||||
it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => {
|
it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => {
|
||||||
expect(
|
expect(
|
||||||
buildGradientColors(
|
buildGradientColors(
|
||||||
true,
|
|
||||||
createTheme({ colors: { mode } }),
|
createTheme({ colors: { mode } }),
|
||||||
buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues))
|
buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues))
|
||||||
)
|
)
|
||||||
@@ -117,7 +89,6 @@ describe('RadialGauge color utils', () => {
|
|||||||
it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => {
|
it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => {
|
||||||
expect(
|
expect(
|
||||||
buildGradientColors(
|
buildGradientColors(
|
||||||
true,
|
|
||||||
createTheme({ colors: { mode } }),
|
createTheme({ colors: { mode } }),
|
||||||
buildFieldDisplay(createField(FieldColorModeId.Fixed)),
|
buildFieldDisplay(createField(FieldColorModeId.Fixed)),
|
||||||
'#442299'
|
'#442299'
|
||||||
|
|||||||
@@ -7,18 +7,10 @@ import { GradientStop, RadialShape } from './types';
|
|||||||
import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils';
|
import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils';
|
||||||
|
|
||||||
export function buildGradientColors(
|
export function buildGradientColors(
|
||||||
gradient = false,
|
|
||||||
theme: GrafanaTheme2,
|
theme: GrafanaTheme2,
|
||||||
fieldDisplay: FieldDisplay,
|
fieldDisplay: FieldDisplay,
|
||||||
baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR
|
baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR
|
||||||
): GradientStop[] {
|
): GradientStop[] {
|
||||||
if (!gradient) {
|
|
||||||
return [
|
|
||||||
{ color: baseColor, percent: 0 },
|
|
||||||
{ color: baseColor, percent: 1 },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
|
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
|
||||||
|
|
||||||
// thresholds get special handling
|
// thresholds get special handling
|
||||||
|
|||||||
@@ -2,14 +2,20 @@ import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
|
|||||||
|
|
||||||
import { RadialGaugeDimensions } from './types';
|
import { RadialGaugeDimensions } from './types';
|
||||||
|
|
||||||
|
// some utility transparent white colors for gradients
|
||||||
|
const TRANSPARENT_WHITE = '#ffffff00';
|
||||||
|
const MOSTLY_TRANSPARENT_WHITE = '#ffffff88';
|
||||||
|
const MOSTLY_OPAQUE_WHITE = '#ffffffbb';
|
||||||
|
const OPAQUE_WHITE = '#ffffff';
|
||||||
|
|
||||||
|
const MIN_GLOW_SIZE = 0.75;
|
||||||
|
const GLOW_FACTOR = 0.08;
|
||||||
|
|
||||||
export interface GlowGradientProps {
|
export interface GlowGradientProps {
|
||||||
id: string;
|
id: string;
|
||||||
barWidth: number;
|
barWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_GLOW_SIZE = 0.75;
|
|
||||||
const GLOW_FACTOR = 0.08;
|
|
||||||
|
|
||||||
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
||||||
// 0.75 is the minimum glow size, and it scales with bar width
|
// 0.75 is the minimum glow size, and it scales with bar width
|
||||||
const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR;
|
const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR;
|
||||||
@@ -27,16 +33,6 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
|
|||||||
|
|
||||||
const CENTER_GLOW_OPACITY = 0.25;
|
const CENTER_GLOW_OPACITY = 0.25;
|
||||||
|
|
||||||
export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) {
|
|
||||||
const transparentColor = colorManipulator.alpha(color, CENTER_GLOW_OPACITY);
|
|
||||||
return (
|
|
||||||
<radialGradient id={`circle-glow-${gaugeId}`} r="50%" fr="0%">
|
|
||||||
<stop offset="0%" stopColor={transparentColor} />
|
|
||||||
<stop offset="90%" stopColor={'#ffffff00'} />
|
|
||||||
</radialGradient>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CenterGlowProps {
|
export interface CenterGlowProps {
|
||||||
dimensions: RadialGaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
gaugeId: string;
|
gaugeId: string;
|
||||||
@@ -52,7 +48,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
|
|||||||
<defs>
|
<defs>
|
||||||
<radialGradient id={gradientId} r="50%" fr="0%">
|
<radialGradient id={gradientId} r="50%" fr="0%">
|
||||||
<stop offset="0%" stopColor={transparentColor} />
|
<stop offset="0%" stopColor={transparentColor} />
|
||||||
<stop offset="90%" stopColor="#ffffff00" />
|
<stop offset="90%" stopColor={TRANSPARENT_WHITE} />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<g>
|
<g>
|
||||||
@@ -62,19 +58,15 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SpotlightGradient({
|
interface SpotlightGradientProps {
|
||||||
id,
|
|
||||||
dimensions,
|
|
||||||
roundedBars,
|
|
||||||
angle,
|
|
||||||
theme,
|
|
||||||
}: {
|
|
||||||
id: string;
|
id: string;
|
||||||
dimensions: RadialGaugeDimensions;
|
dimensions: RadialGaugeDimensions;
|
||||||
angle: number;
|
angle: number;
|
||||||
roundedBars: boolean;
|
roundedBars: boolean;
|
||||||
theme: GrafanaTheme2;
|
theme: GrafanaTheme2;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function SpotlightGradient({ id, dimensions, roundedBars, angle, theme }: SpotlightGradientProps) {
|
||||||
if (theme.isLight) {
|
if (theme.isLight) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -88,9 +80,9 @@ export function SpotlightGradient({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" stopColor="#ffffff00" />
|
<stop offset="0%" stopColor={TRANSPARENT_WHITE} />
|
||||||
<stop offset="95%" stopColor="#ffffff88" />
|
<stop offset="95%" stopColor={MOSTLY_TRANSPARENT_WHITE} />
|
||||||
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
|
{roundedBars && <stop offset="100%" stopColor={roundedBars ? MOSTLY_OPAQUE_WHITE : OPAQUE_WHITE} />}
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'non
|
|||||||
export type RadialShape = 'circle' | 'gauge';
|
export type RadialShape = 'circle' | 'gauge';
|
||||||
|
|
||||||
export interface RadialGaugeDimensions {
|
export interface RadialGaugeDimensions {
|
||||||
|
vizHeight: number;
|
||||||
|
vizWidth: number;
|
||||||
margin: number;
|
margin: number;
|
||||||
radius: number;
|
radius: number;
|
||||||
centerX: number;
|
centerX: number;
|
||||||
|
|||||||
@@ -283,7 +283,9 @@ describe('RadialGauge utils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('drawRadialArcPath', () => {
|
describe('drawRadialArcPath', () => {
|
||||||
const defaultDims: RadialGaugeDimensions = Object.freeze({
|
const defaultDims = Object.freeze({
|
||||||
|
vizHeight: 220,
|
||||||
|
vizWidth: 220,
|
||||||
centerX: 100,
|
centerX: 100,
|
||||||
centerY: 100,
|
centerY: 100,
|
||||||
radius: 80,
|
radius: 80,
|
||||||
@@ -297,7 +299,7 @@ describe('RadialGauge utils', () => {
|
|||||||
scaleLabelsSpacing: 0,
|
scaleLabelsSpacing: 0,
|
||||||
scaleLabelsRadius: 0,
|
scaleLabelsRadius: 0,
|
||||||
gaugeBottomY: 0,
|
gaugeBottomY: 0,
|
||||||
});
|
}) satisfies RadialGaugeDimensions;
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ description: 'quarter arc', startAngle: 0, endAngle: 90 },
|
{ description: 'quarter arc', startAngle: 0, endAngle: 90 },
|
||||||
@@ -324,11 +326,6 @@ describe('RadialGauge utils', () => {
|
|||||||
expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims));
|
expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims));
|
||||||
expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims));
|
expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty string if inner radius collapses to zero or below', () => {
|
|
||||||
const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 };
|
|
||||||
expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe('');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +338,9 @@ describe('RadialGauge utils', () => {
|
|||||||
|
|
||||||
describe('getOptimalSegmentCount', () => {
|
describe('getOptimalSegmentCount', () => {
|
||||||
it('should adjust segment count based on dimensions and spacing', () => {
|
it('should adjust segment count based on dimensions and spacing', () => {
|
||||||
const dimensions: RadialGaugeDimensions = {
|
const dimensions = {
|
||||||
|
vizHeight: 220,
|
||||||
|
vizWidth: 220,
|
||||||
centerX: 100,
|
centerX: 100,
|
||||||
centerY: 100,
|
centerY: 100,
|
||||||
radius: 80,
|
radius: 80,
|
||||||
@@ -355,7 +354,7 @@ describe('RadialGauge utils', () => {
|
|||||||
scaleLabelsSpacing: 0,
|
scaleLabelsSpacing: 0,
|
||||||
scaleLabelsRadius: 0,
|
scaleLabelsRadius: 0,
|
||||||
gaugeBottomY: 0,
|
gaugeBottomY: 0,
|
||||||
};
|
} satisfies RadialGaugeDimensions;
|
||||||
|
|
||||||
expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8);
|
expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8);
|
||||||
expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5);
|
expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5);
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export function calculateDimensions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
vizWidth: width,
|
||||||
|
vizHeight: height,
|
||||||
margin,
|
margin,
|
||||||
gaugeBottomY: centerY + belowCenterY,
|
gaugeBottomY: centerY + belowCenterY,
|
||||||
radius: innerRadius,
|
radius: innerRadius,
|
||||||
@@ -185,7 +187,7 @@ export function drawRadialArcPath(
|
|||||||
dimensions: RadialGaugeDimensions,
|
dimensions: RadialGaugeDimensions,
|
||||||
roundedBars?: boolean
|
roundedBars?: boolean
|
||||||
): string {
|
): string {
|
||||||
const { radius, centerX, centerY, barWidth } = dimensions;
|
const { radius, centerX, centerY } = dimensions;
|
||||||
|
|
||||||
// For some reason a 100% full arc cannot be rendered
|
// For some reason a 100% full arc cannot be rendered
|
||||||
if (endAngle >= 360) {
|
if (endAngle >= 360) {
|
||||||
@@ -197,66 +199,12 @@ export function drawRadialArcPath(
|
|||||||
|
|
||||||
const largeArc = endAngle > 180 ? 1 : 0;
|
const largeArc = endAngle > 180 ? 1 : 0;
|
||||||
|
|
||||||
const outerR = radius + barWidth / 2;
|
let x1 = centerX + radius * Math.cos(startRadians);
|
||||||
const innerR = Math.max(0, radius - barWidth / 2);
|
let y1 = centerY + radius * Math.sin(startRadians);
|
||||||
if (innerR <= 0) {
|
let x2 = centerX + radius * Math.cos(endRadians);
|
||||||
return ''; // cannot draw arc with 0 inner radius
|
let y2 = centerY + radius * Math.sin(endRadians);
|
||||||
}
|
|
||||||
|
|
||||||
// get points for both an inner and outer arc. we draw
|
return ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' ');
|
||||||
// the arc entirely with a path's fill instead of using stroke
|
|
||||||
// so that it can be used as a clip-path.
|
|
||||||
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);
|
|
||||||
|
|
||||||
// calculate the cap width in case we're drawing rounded bars
|
|
||||||
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 (square butt)
|
|
||||||
pathParts.push('L', ix2, iy2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (square butt)
|
|
||||||
pathParts.push('L', ox1, oy1);
|
|
||||||
}
|
|
||||||
|
|
||||||
pathParts.push('Z');
|
|
||||||
|
|
||||||
return pathParts.join(' ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
|
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {
|
||||||
|
|||||||
Reference in New Issue
Block a user