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:
Paul Marbach
2026-01-06 11:25:40 -05:00
committed by GitHub
parent 8ff88036e7
commit 2cf485f6bf
10 changed files with 102 additions and 191 deletions
@@ -1,4 +1,4 @@
import { useId, memo, HTMLAttributes, ReactNode } from 'react';
import { useId, memo, HTMLAttributes, ReactNode, SVGProps } from 'react';
import { FieldDisplay } from '@grafana/data';
@@ -50,14 +50,13 @@ export const RadialArcPath = memo(
}: RadialArcPathProps) => {
const id = useId();
const bgDivStyle: HTMLAttributes<HTMLDivElement>['style'] = { width: '100%', height: '100%' };
if ('color' in rest) {
bgDivStyle.backgroundColor = rest.color;
} else {
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
}
const isGradient = 'gradient' in rest;
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);
@@ -72,9 +71,14 @@ export const RadialArcPath = memo(
const dotRadius =
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 endpointMarks: ReactNode = null;
if ('gradient' in rest) {
if (isGradient) {
bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape);
if (endpointMarker && (rest.gradient?.length ?? 0) > 0) {
switch (endpointMarker) {
case 'point':
@@ -115,25 +119,39 @@ export const RadialArcPath = memo(
if (barEndcaps) {
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 (
<>
{/* FIXME: optimize this by only using clippath + foreign obj for gradients */}
<clipPath id={id}>
<path d={path} />
</clipPath>
{isGradient && (
<defs>
<mask id={id} maskUnits="userSpaceOnUse" maskContentUnits="userSpaceOnUse">
<rect x={boxX} y={boxY} width={boxSize} height={boxSize} fill="black" />
{pathEl}
</mask>
</defs>
)}
<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>
{isGradient ? (
<foreignObject x={0} y={0} width={vizWidth} height={vizHeight} mask={`url(#${id})`}>
<div style={bgDivStyle} />
</foreignObject>
) : (
pathEl
)}
{barEndcapColors?.[0] && <circle cx={xStart} cy={yStart} r={barWidth / 2} fill={barEndcapColors[0]} />}
{barEndcapColors?.[1] && (
<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 { useId } from 'react';
import { useId, ReactNode } from 'react';
import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -107,14 +107,14 @@ export function RadialGauge(props: RadialGaugeProps) {
const startAngle = shape === 'gauge' ? 250 : 0;
const endAngle = shape === 'gauge' ? 110 : 360;
const defs: React.ReactNode[] = [];
const graphics: React.ReactNode[] = [];
let sparklineElement: React.ReactNode | null = null;
const defs: ReactNode[] = [];
const graphics: ReactNode[] = [];
let sparklineElement: ReactNode | null = null;
for (let barIndex = 0; barIndex < values.length; barIndex++) {
const displayValue = values[barIndex];
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 dimensions = calculateDimensions(
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
// can reference via a hook, rather than passing them down as props
const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`;
const spotlightGradientRef = endpointMarker === 'glow' ? `url(#${spotlightGradientId})` : undefined;
const glowFilterId = `glow-${gaugeId}`;
const glowFilterRef = glowBar ? `url(#${glowFilterId})` : undefined;
if (endpointMarker === 'glow') {
defs.push(
@@ -154,7 +156,7 @@ export function RadialGauge(props: RadialGaugeProps) {
fieldDisplay={displayValue}
angleRange={angleRange}
startAngle={startAngle}
glowFilter={`url(#${glowFilterId})`}
glowFilter={glowFilterRef}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
shape={shape}
@@ -170,8 +172,8 @@ export function RadialGauge(props: RadialGaugeProps) {
angleRange={angleRange}
startAngle={startAngle}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
endpointMarkerGlowFilter={`url(#${spotlightGradientId})`}
glowFilter={glowFilterRef}
endpointMarkerGlowFilter={spotlightGradientRef}
shape={shape}
gradient={gradientStops}
fieldDisplay={displayValue}
@@ -183,7 +185,7 @@ export function RadialGauge(props: RadialGaugeProps) {
// These elements are only added for first value / bar
if (barIndex === 0) {
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) {
@@ -234,7 +236,7 @@ export function RadialGauge(props: RadialGaugeProps) {
endAngle={endAngle}
angleRange={angleRange}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
glowFilter={glowFilterRef}
shape={shape}
gradient={gradientStops}
/>
@@ -260,7 +262,7 @@ export function RadialGauge(props: RadialGaugeProps) {
const body = (
<>
<svg width={width} height={height} role="img" aria-label={t('gauge.category-gauge', 'Gauge')}>
<defs>{defs}</defs>
{defs.length > 0 && <defs>{defs}</defs>}
{graphics}
</svg>
{sparklineElement}
@@ -1,4 +1,3 @@
import { css } from '@emotion/css';
import { memo } from 'react';
import {
@@ -9,7 +8,6 @@ import {
GrafanaTheme2,
} from '@grafana/data';
import { useStyles2 } from '../../themes/ThemeContext';
import { calculateFontSize } from '../../utils/measureText';
import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types';
@@ -50,7 +48,6 @@ export const RadialText = memo(
valueManualFontSize,
nameManualFontSize,
}: RadialTextProps) => {
const styles = useStyles2(getStyles);
const { centerX, centerY, radius, barWidth } = dimensions;
if (textMode === 'none') {
@@ -106,10 +103,9 @@ export const RadialText = memo(
const valueY = showName ? centerY - nameHeight * (1 - 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 suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2;
// adjust the text up on gauges and when sparklines are present
let yOffset = 0;
let yOffset = valueFontSize / 4;
if (shape === 'gauge') {
// we render from the center of the gauge, so move up by half of half of the total height
yOffset -= (valueHeight + nameHeight) / 4;
@@ -126,15 +122,12 @@ export const RadialText = memo(
y={valueY}
fontSize={valueFontSize}
fill={theme.colors.text.primary}
className={styles.text}
textAnchor="middle"
dominantBaseline="middle"
dominantBaseline="text-bottom"
>
<tspan fontSize={unitFontSize}>{displayValue.prefix ?? ''}</tspan>
<tspan>{displayValue.text}</tspan>
<tspan className={styles.text} fontSize={unitFontSize} dy={suffixShift}>
{displayValue.suffix ?? ''}
</tspan>
<tspan fontSize={unitFontSize}>{displayValue.suffix ?? ''}</tspan>
</text>
)}
{showName && (
@@ -143,7 +136,7 @@ export const RadialText = memo(
x={centerX}
y={nameY}
textAnchor="middle"
dominantBaseline="middle"
dominantBaseline="text-bottom"
fill={nameColor}
>
{displayValue.title}
@@ -155,9 +148,3 @@ export const RadialText = memo(
);
RadialText.displayName = 'RadialText';
const getStyles = (_theme: GrafanaTheme2) => ({
text: css({
verticalAlign: 'bottom',
}),
});
@@ -1,17 +1,17 @@
// 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 { 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 {
@@ -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)', () => {
expect(
buildGradientColors(
true,
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.Thresholds), {
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)', () => {
expect(
buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
buildGradientColors(createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000')
).toMatchSnapshot();
});
it('should return gradient colors for continuous color modes', () => {
expect(
buildGradientColors(
true,
createTheme(),
buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)),
'#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) => {
expect(
buildGradientColors(
true,
createTheme({ colors: { mode } }),
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) => {
expect(
buildGradientColors(
true,
createTheme({ colors: { mode } }),
buildFieldDisplay(createField(FieldColorModeId.Fixed)),
'#442299'
@@ -7,18 +7,10 @@ import { GradientStop, RadialShape } from './types';
import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils';
export function buildGradientColors(
gradient = false,
theme: GrafanaTheme2,
fieldDisplay: FieldDisplay,
baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR
): GradientStop[] {
if (!gradient) {
return [
{ color: baseColor, percent: 0 },
{ color: baseColor, percent: 1 },
];
}
const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode);
// thresholds get special handling
@@ -2,14 +2,20 @@ import { colorManipulator, GrafanaTheme2 } from '@grafana/data';
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 {
id: string;
barWidth: number;
}
const MIN_GLOW_SIZE = 0.75;
const GLOW_FACTOR = 0.08;
export function GlowGradient({ id, barWidth }: GlowGradientProps) {
// 0.75 is the minimum glow size, and it scales with bar width
const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR;
@@ -27,16 +33,6 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) {
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 {
dimensions: RadialGaugeDimensions;
gaugeId: string;
@@ -52,7 +48,7 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
<defs>
<radialGradient id={gradientId} r="50%" fr="0%">
<stop offset="0%" stopColor={transparentColor} />
<stop offset="90%" stopColor="#ffffff00" />
<stop offset="90%" stopColor={TRANSPARENT_WHITE} />
</radialGradient>
</defs>
<g>
@@ -62,19 +58,15 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps
);
}
export function SpotlightGradient({
id,
dimensions,
roundedBars,
angle,
theme,
}: {
interface SpotlightGradientProps {
id: string;
dimensions: RadialGaugeDimensions;
angle: number;
roundedBars: boolean;
theme: GrafanaTheme2;
}) {
}
export function SpotlightGradient({ id, dimensions, roundedBars, angle, theme }: SpotlightGradientProps) {
if (theme.isLight) {
return null;
}
@@ -88,9 +80,9 @@ export function SpotlightGradient({
return (
<linearGradient x1={x1} y1={y1} x2={x2} y2={y2} id={id} gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#ffffff00" />
<stop offset="95%" stopColor="#ffffff88" />
{roundedBars && <stop offset="100%" stopColor={roundedBars ? '#ffffffbb' : 'white'} />}
<stop offset="0%" stopColor={TRANSPARENT_WHITE} />
<stop offset="95%" stopColor={MOSTLY_TRANSPARENT_WHITE} />
{roundedBars && <stop offset="100%" stopColor={roundedBars ? MOSTLY_OPAQUE_WHITE : OPAQUE_WHITE} />}
</linearGradient>
);
}
@@ -2,6 +2,8 @@ export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'non
export type RadialShape = 'circle' | 'gauge';
export interface RadialGaugeDimensions {
vizHeight: number;
vizWidth: number;
margin: number;
radius: number;
centerX: number;
@@ -283,7 +283,9 @@ describe('RadialGauge utils', () => {
});
describe('drawRadialArcPath', () => {
const defaultDims: RadialGaugeDimensions = Object.freeze({
const defaultDims = Object.freeze({
vizHeight: 220,
vizWidth: 220,
centerX: 100,
centerY: 100,
radius: 80,
@@ -297,7 +299,7 @@ describe('RadialGauge utils', () => {
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
});
}) satisfies RadialGaugeDimensions;
it.each([
{ 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, 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', () => {
it('should adjust segment count based on dimensions and spacing', () => {
const dimensions: RadialGaugeDimensions = {
const dimensions = {
vizHeight: 220,
vizWidth: 220,
centerX: 100,
centerY: 100,
radius: 80,
@@ -355,7 +354,7 @@ describe('RadialGauge utils', () => {
scaleLabelsSpacing: 0,
scaleLabelsRadius: 0,
gaugeBottomY: 0,
};
} satisfies RadialGaugeDimensions;
expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8);
expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5);
@@ -155,6 +155,8 @@ export function calculateDimensions(
}
return {
vizWidth: width,
vizHeight: height,
margin,
gaugeBottomY: centerY + belowCenterY,
radius: innerRadius,
@@ -185,7 +187,7 @@ export function drawRadialArcPath(
dimensions: RadialGaugeDimensions,
roundedBars?: boolean
): string {
const { radius, centerX, centerY, barWidth } = dimensions;
const { radius, centerX, centerY } = dimensions;
// For some reason a 100% full arc cannot be rendered
if (endAngle >= 360) {
@@ -197,66 +199,12 @@ export function drawRadialArcPath(
const largeArc = endAngle > 180 ? 1 : 0;
const outerR = radius + barWidth / 2;
const innerR = Math.max(0, radius - barWidth / 2);
if (innerR <= 0) {
return ''; // cannot draw arc with 0 inner radius
}
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);
// get points for both an inner and outer arc. we draw
// 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(' ');
return ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' ');
}
export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) {