refactoring defs into utils

This commit is contained in:
Paul Marbach
2025-12-15 23:13:08 -05:00
parent 59ec3cc8a9
commit b300bd8b85
11 changed files with 255 additions and 307 deletions
+1 -1
View File
@@ -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,21 +1,25 @@
import { useId, useEffect } from 'react';
import { useId, useMemo } from 'react';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialShape } from './RadialGauge';
import { GaugeDimensions, toRad } from './utils';
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;
colorDefs: RadialColorDefs;
arcLengthDeg: number;
shape: RadialShape;
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 {
@@ -28,110 +32,60 @@ type RadialArcPathProps = RadialArcPathPropsBase | RadialArcPathPropsWithGuideDo
const MAX_DOT_RADIUS = 8;
function drawRadialArcPath({
angle,
arcLengthDeg,
dimensions,
roundedBars,
}: {
angle: number;
dimensions: GaugeDimensions;
arcLengthDeg: number;
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(' ');
}
export function RadialArcPath({
startAngle: angle,
dimensions,
color,
colorDefs,
shape,
arcLengthDeg,
color,
dimensions,
displayProcessor,
fieldDisplay,
glowFilter,
gradientMode,
roundedBars,
shape,
showGuideDots,
guideDotStartColor,
guideDotEndColor,
startAngle: angle,
}: RadialArcPathProps) {
const theme = useTheme2();
const id = useId();
const { radius, centerX, centerY, barWidth } = dimensions;
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 || shape !== 'circle' || gradientStops.length === 0) {
return {
guideDotStartColor: undefined,
guideDotEndColor: undefined,
};
}
return {
guideDotColors: getGuideDotColors(gradientStops, fieldDisplay.display.percent ?? 0),
endpointColors: getEndpointColors(gradientStops, fieldDisplay.display.percent ?? 0),
};
}, [showGuideDots, shape, fieldDisplay, gradientStops]);
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 startRadians = toRad(angle);
const endRadians = toRad(angle + arcLengthDeg);
const [startColor, endColor] = colorDefs.getEndpointColors();
const path = drawRadialArcPath({ angle, arcLengthDeg, dimensions, roundedBars });
const path = useMemo(
() => drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars),
[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);
@@ -152,28 +106,16 @@ export function RadialArcPath({
height={(radius + barWidth) * 2}
clipPath={`url(#${id})`}
>
<div
style={{
width: '100%',
height: '100%',
backgroundImage: color ? undefined : colorDefs.getGradientDef(),
backgroundColor: color,
}}
/>
<div style={bgDivStyle} />
</foreignObject>
</g>
{showGuideDots && (
<>
{shape === 'circle' && (
<>
<circle cx={x1} cy={y1} r={barWidth / 2} fill={startColor} />
<circle cx={x2} cy={y2} r={barWidth / 2} fill={endColor} />
</>
)}
{arcLengthDeg > 5 && <circle cx={x1} cy={y1} r={dotRadius} fill={guideDotStartColor} />}
<circle cx={x2} cy={y2} r={dotRadius} fill={guideDotEndColor} />
{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]} />}
</>
)}
</>
@@ -1,60 +1,64 @@
import { DisplayProcessor, FieldDisplay } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialShape } from './RadialGauge';
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}
colorDefs={colorDefs}
color={theme.colors.action.hover}
roundedBars={roundedBars}
shape={shape}
/>
{/** The colored bar */}
<RadialArcPath
dimensions={dimensions}
startAngle={startAngle}
arcLengthDeg={angle}
colorDefs={colorDefs}
roundedBars={roundedBars}
glowFilter={glowFilter}
showGuideDots={roundedBars}
guideDotStartColor={startDotColor}
guideDotEndColor={endDotColor}
shape={shape}
/>
</g>
{/** 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,16 +1,15 @@
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;
@@ -21,13 +20,13 @@ export interface RadialBarSegmentedProps {
}
export function RadialBarSegmented({
fieldDisplay,
displayProcessor,
dimensions,
startAngle,
angleRange,
glowFilter,
segmentCount,
segmentSpacing,
colorDefs,
shape,
gradientMode,
}: RadialBarSegmentedProps) {
@@ -48,7 +47,7 @@ export function RadialBarSegmented({
if (angleValue >= value) {
segmentColor = theme.colors.action.hover;
} else if (gradientMode === 'none') {
segmentColor = colorDefs.getSegmentColor(angleValue);
segmentColor = displayProcessor(angleValue).color ?? FALLBACK_COLOR;
}
segments.push(
@@ -58,9 +57,11 @@ export function RadialBarSegmented({
dimensions={dimensions}
color={segmentColor}
shape={shape}
colorDefs={colorDefs}
glowFilter={glowFilter}
arcLengthDeg={segmentArcLengthDeg}
gradientMode={gradientMode}
fieldDisplay={fieldDisplay}
displayProcessor={displayProcessor}
/>
);
}
@@ -89,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,72 +0,0 @@
import { colorManipulator, DisplayProcessor, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2 } from '@grafana/data';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { buildGradientColors } from './colors';
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 {
constructor(private options: RadialColorDefsOptions) {}
getSegmentColor(forValue: number): string {
const { displayProcessor } = this.options;
return displayProcessor(forValue).color ?? FALLBACK_COLOR;
}
getFieldBaseColor(): string {
return this.options.fieldDisplay.display.color ?? FALLBACK_COLOR;
}
getGradient(baseColor = this.getFieldBaseColor(), forSegment?: boolean): Array<{ color: string; percent: number }> {
const { displayProcessor, gradient, fieldDisplay, theme } = this.options;
return buildGradientColors(gradient, baseColor, theme, displayProcessor, fieldDisplay, forSegment);
}
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 0deg, ${colorStrings.join(', ')})`
: `linear-gradient(90deg, ${colorStrings.join(', ')})`;
}
getEndpointColors(): [string, string] {
const { fieldDisplay } = this.options;
const gradient = this.getGradient();
const valuePercent = fieldDisplay.display.percent ?? 0;
const 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 [startColor, endColor];
}
getGuideDotColors(): [string, string] {
const [startColor, endColor] = this.getEndpointColors();
return [getGuideDotColor(startColor), getGuideDotColor(endColor)];
}
}
@@ -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,9 +144,9 @@ export function RadialGauge(props: RadialGaugeProps) {
glowFilter={`url(#${glowFilterId})`}
segmentCount={segmentCount}
segmentSpacing={segmentSpacing}
colorDefs={colorDefs}
shape={shape}
gradientMode={gradient}
displayProcessor={displayProcessor}
/>
);
} else {
@@ -164,13 +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}
/>
);
}
@@ -230,9 +222,9 @@ export function RadialGauge(props: RadialGaugeProps) {
angleRange={angleRange}
roundedBars={roundedBars}
glowFilter={`url(#${glowFilterId})`}
colorDefs={colorDefs}
shape={shape}
gradientMode={gradient}
displayProcessor={displayProcessor}
/>
);
}
@@ -1,9 +1,6 @@
import { FieldDisplay, Threshold } from '@grafana/data';
import { useTheme2 } from '../../themes/ThemeContext';
import { DisplayProcessor, FieldDisplay, Threshold } from '@grafana/data';
import { RadialArcPath } from './RadialArcPath';
import { RadialColorDefs } from './RadialColorDefs';
import { RadialGradientMode, RadialShape } from './RadialGauge';
import { GaugeDimensions } from './utils';
@@ -16,9 +13,9 @@ export interface Props {
fieldDisplay: FieldDisplay;
roundedBars?: boolean;
glowFilter?: string;
colorDefs: RadialColorDefs;
thresholds: Threshold[];
gradientMode: RadialGradientMode;
displayProcessor: DisplayProcessor;
}
export function ThresholdsBar({
dimensions,
@@ -27,12 +24,11 @@ export function ThresholdsBar({
angleRange,
roundedBars,
glowFilter,
colorDefs,
thresholds,
shape,
gradientMode,
displayProcessor,
}: Props) {
const theme = useTheme2();
const fieldConfig = fieldDisplay.field;
const min = fieldConfig.min ?? 0;
const max = fieldConfig.max ?? 100;
@@ -63,12 +59,14 @@ export function ThresholdsBar({
key={i}
startAngle={currentStart}
arcLengthDeg={lengthDeg}
colorDefs={colorDefs}
color={gradientMode === 'none' ? threshold.color : undefined}
shape={shape}
dimensions={thresholdDimensions}
roundedBars={roundedBars}
glowFilter={glowFilter}
gradientMode={gradientMode}
displayProcessor={displayProcessor}
fieldDisplay={fieldDisplay}
/>
);
@@ -1,18 +1,26 @@
import tinycolor from 'tinycolor2';
import { DisplayProcessor, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data';
import {
colorManipulator,
DisplayProcessor,
FALLBACK_COLOR,
FieldDisplay,
getFieldColorMode,
GradientStop,
GrafanaTheme2,
} from '@grafana/data';
import { FieldColorModeId } from '@grafana/schema';
import { RadialGradientMode } from './RadialGauge';
import { RadialGradientMode, RadialShape } from './RadialGauge';
export function buildGradientColors(
gradientMode: RadialGradientMode,
baseColor: string,
theme: GrafanaTheme2,
displayProcessor: DisplayProcessor,
fieldDisplay: FieldDisplay,
baseColor = FALLBACK_COLOR,
forSegment?: boolean
): Array<{ color: string; percent: number }> {
): GradientStop[] {
if (gradientMode === 'none') {
return [
{ color: baseColor, percent: 0 },
@@ -80,3 +88,35 @@ export function buildGradientColors(
{ 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(' ');
}