diff --git a/change/@fluentui-react-charts-77b0899c-0605-4850-b2bc-cae106594059.json b/change/@fluentui-react-charts-77b0899c-0605-4850-b2bc-cae106594059.json new file mode 100644 index 00000000000000..98a62d468ad341 --- /dev/null +++ b/change/@fluentui-react-charts-77b0899c-0605-4850-b2bc-cae106594059.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat(react-charts): Add annotation support for GaugeChart", + "packageName": "@fluentui/react-charts", + "email": "srmukher@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/etc/react-charts.api.md b/packages/charts/react-charts/library/etc/react-charts.api.md index 3a7d51ca324bbc..b815b398e128e4 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -190,9 +190,18 @@ export const BREAKPOINTS: { fontSize: number; }[]; +// @public +export const calcAnnotationPosition: (value: number, position: "inner" | "outer" | "arc", minValue: number, maxValue: number, innerRadius: number, outerRadius: number, radialOffset?: number) => { + x: number; + y: number; +}; + // @public (undocumented) export const calcNeedleRotation: (chartValue: number, minValue: number, maxValue: number) => number; +// @public +export const calcValueToAngle: (value: number, minValue: number, maxValue: number) => number; + // @public export const CartesianChart: React_2.FunctionComponent; @@ -895,13 +904,58 @@ export interface GanttChartStyles extends CartesianChartStyles { // @public (undocumented) export const GaugeChart: React_2.FunctionComponent; +// @public +export interface GaugeChartAnnotation { + ariaLabel?: string; + arrow?: GaugeChartAnnotationArrow; + coordinates: GaugeChartAnnotationCoordinate; + id?: string; + style?: GaugeChartAnnotationStyle; + text: string; +} + +// @public +export interface GaugeChartAnnotationArrow { + color?: string; + headSize?: number; + headStyle?: number; + show?: boolean; + tailOffsetX?: number; + tailOffsetY?: number; + width?: number; +} + +// @public +export interface GaugeChartAnnotationCoordinate { + position?: GaugeChartAnnotationPosition; + radialOffset?: number; + value: number; +} + +// @public +export type GaugeChartAnnotationPosition = 'inner' | 'outer' | 'arc'; + +// @public +export interface GaugeChartAnnotationStyle { + backgroundColor?: string; + borderColor?: string; + borderRadius?: number; + borderWidth?: number; + className?: string; + fontSize?: string; + fontWeight?: React_2.CSSProperties['fontWeight']; + padding?: string; + textColor?: string; +} + // @public export interface GaugeChartProps { + annotations?: GaugeChartAnnotation[]; calloutProps?: Partial; chartTitle?: string; chartValue: number; chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string); - componentRef?: React.Ref; + componentRef?: React_2.Ref; culture?: string; enableGradient?: boolean; height?: number; @@ -912,6 +966,7 @@ export interface GaugeChartProps { legendProps?: Partial; maxValue?: number; minValue?: number; + onRenderAnnotation?: (annotation: GaugeChartAnnotation, defaultRender: (annotation: GaugeChartAnnotation) => React_2.ReactNode) => React_2.ReactNode; roundCorners?: boolean; segments: GaugeChartSegment[]; styles?: GaugeChartStyles; @@ -932,6 +987,8 @@ export interface GaugeChartSegment { // @public export interface GaugeChartStyles { + annotationContainer?: string; + annotationText?: string; calloutBlockContainer?: string; calloutContentRoot?: string; calloutContentX?: string; @@ -961,6 +1018,9 @@ export type GaugeChartVariant = 'single-segment' | 'multiple-segments'; // @public (undocumented) export type GaugeValueFormat = 'percentage' | 'fraction'; +// @public +export const getArrowHeadPath: (headStyle: number, headSize: number, width: number) => string; + // @public (undocumented) export const getChartValueLabel: (chartValue: number, minValue: number, maxValue: number, chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string), forCallout?: boolean) => string; @@ -1622,6 +1682,9 @@ export interface RefArrayData { refElement?: SVGGElement; } +// @public +export const renderAnnotationArrow: (startX: number, startY: number, endX: number, endY: number, arrow: GaugeChartAnnotationArrow, uniqueId: string) => React_2.ReactNode; + // @public (undocumented) export interface ResolvedAnnotationPosition { anchor: AnnotationPoint; diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index 9c315ec2b9108c..d0e56486eb15b5 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -37,7 +37,7 @@ import { LineChartProps } from '../LineChart/index'; import { AreaChartProps } from '../AreaChart/index'; import { HeatMapChartProps } from '../HeatMapChart/index'; import { DataVizPalette, getColorFromToken } from '../../utilities/colors'; -import { GaugeChartProps, GaugeChartSegment } from '../GaugeChart/index'; +import { GaugeChartProps, GaugeChartSegment, GaugeChartAnnotation } from '../GaugeChart/index'; import { GroupedVerticalBarChartProps } from '../GroupedVerticalBarChart/index'; import { VerticalBarChartProps } from '../VerticalBarChart/index'; import { ChartTableProps } from '../ChartTable/index'; @@ -2721,6 +2721,86 @@ export const transformPlotlyJsonToSankeyProps = ( } as SankeyChartProps; }; +/** + * Transforms Plotly annotations to GaugeChartAnnotation format. + * Plotly uses paper coordinates (0-1) where: + * - x: 0=left, 0.5=center, 1=right (maps to gauge minValue-maxValue) + * - y: 0=bottom, 1=top (used to determine inner/outer/arc positioning) + */ +const transformPlotlyAnnotationsToGaugeAnnotations = ( + annotations: Partial[] | undefined, + minValue: number, + maxValue: number, +): GaugeChartAnnotation[] => { + if (!annotations || annotations.length === 0) { + return []; + } + + return annotations.map((annotation, index): GaugeChartAnnotation => { + // Map x coordinate (0-1) to gauge value (minValue-maxValue) + const x = typeof annotation.x === 'number' ? annotation.x : 0.5; + const y = typeof annotation.y === 'number' ? annotation.y : 0.5; + const gaugeValue = minValue + x * (maxValue - minValue); + + // Determine position based on y coordinate + // y < 0.3: below gauge (outer-bottom) + // y > 0.6: above gauge arc (outer-top) + // 0.3 <= y <= 0.6: on the arc area + let position: 'inner' | 'outer' | 'arc' = 'outer'; + let radialOffset = 0; + if (y >= 0.3 && y <= 0.6) { + position = 'arc'; + } else if (y < 0.15) { + position = 'outer'; + radialOffset = 20; // Further outside for bottom annotations + } + + // Clean text content - replace
with spaces and strip HTML tags + const cleanedText = (annotation.text || '') + .replace(//gi, ' ') + .replace(/<[^>]*>/g, '') + .trim(); + + // Transform arrow properties from Plotly + const arrowConfig = + annotation.showarrow === true + ? { + show: true, + headStyle: typeof annotation.arrowhead === 'number' ? annotation.arrowhead : 2, + color: annotation.arrowcolor?.toString() || '#333333', + width: typeof annotation.arrowwidth === 'number' ? annotation.arrowwidth : 1, + headSize: typeof annotation.arrowsize === 'number' ? annotation.arrowsize : 1, + // Convert Plotly ax/ay (pixel offsets) to tailOffsetX/tailOffsetY + // In Plotly, ax/ay define offset from arrow tip to tail/text position + // Plotly y-axis goes up, SVG y-axis goes down, so we DON'T negate ay + // (Plotly ay=-40 means text is 40px below tip in visual space, same in SVG) + tailOffsetX: typeof annotation.ax === 'number' ? annotation.ax : 0, + tailOffsetY: typeof annotation.ay === 'number' ? annotation.ay : 40, + } + : undefined; + + return { + id: `plotly-annotation-${index}`, + text: cleanedText, + coordinates: { + value: gaugeValue, + position, + radialOffset, + }, + style: { + textColor: annotation.font?.color?.toString(), + fontSize: annotation.font?.size ? `${annotation.font.size}px` : undefined, + fontWeight: annotation.font?.family?.includes('Bold') ? 'bold' : undefined, + backgroundColor: annotation.bgcolor?.toString(), + borderColor: annotation.bordercolor?.toString(), + borderWidth: annotation.borderwidth, + }, + arrow: arrowConfig, + ariaLabel: cleanedText, + }; + }); +}; + export const transformPlotlyJsonToGaugeProps = ( input: PlotlySchema, isMultiPlot: boolean, @@ -2824,6 +2904,17 @@ export const transformPlotlyJsonToGaugeProps = ( const { chartTitle, titleStyles } = getTitles(input.layout); + // Get min/max values for annotation transformation + const gaugeMinValue = typeof firstData.gauge?.axis?.range?.[0] === 'number' ? firstData.gauge?.axis?.range?.[0] : 0; + const gaugeMaxValue = typeof firstData.gauge?.axis?.range?.[1] === 'number' ? firstData.gauge?.axis?.range?.[1] : 100; + + // Transform Plotly annotations to GaugeChartAnnotation format + const annotations = transformPlotlyAnnotationsToGaugeAnnotations( + input.layout?.annotations, + gaugeMinValue, + gaugeMaxValue, + ); + return { segments, chartValue: firstData.value ?? 0, @@ -2841,6 +2932,7 @@ export const transformPlotlyJsonToGaugeProps = ( styles, roundCorners: true, ...(titleStyles ? { titleStyles } : {}), + ...(annotations.length > 0 ? { annotations } : {}), } as GaugeChartProps; }; diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx index e805f12387919c..3bdbc564e72ebb 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx @@ -678,3 +678,327 @@ describe('Gauge Chart - Callout', () => { expect(getByClass(container, /calloutContentRoot/i)).toHaveLength(0); }); }); + +describe('GaugeChart - Annotations', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('should render an annotation at the specified value', () => { + const { container } = render( + , + ); + expect(screen.getByText('Target')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should render multiple annotations', () => { + const { container } = render( + , + ); + expect(screen.getByText('Low')).toBeInTheDocument(); + expect(screen.getByText('High')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should render annotations at different radial positions', () => { + const { container } = render( + , + ); + expect(screen.getByText('Inner')).toBeInTheDocument(); + expect(screen.getByText('Arc')).toBeInTheDocument(); + expect(screen.getByText('Outer')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it('should apply custom styles to annotations', () => { + render( + , + ); + const annotation = screen.getByText('Styled'); + expect(annotation).toBeInTheDocument(); + }); + + it('should use custom ariaLabel for accessibility', () => { + render( + , + ); + expect(screen.getByLabelText('Target value at 75')).toBeInTheDocument(); + }); + + it('should not render annotations when array is empty', () => { + const { container } = render(); + expect(getByClass(container, /annotationText/i)).toHaveLength(0); + }); + + it('should support custom annotation rendering', () => { + const customRenderer = jest.fn((annotation, defaultRender) => { + return ( + + {`Custom: ${annotation.text}`} + + ); + }); + + render( + , + ); + expect(customRenderer).toHaveBeenCalled(); + expect(screen.getByText('Custom: Target')).toBeInTheDocument(); + }); +}); + +describe('GaugeChart - Annotation helper functions', () => { + it('calcValueToAngle should convert value to correct angle', () => { + const { calcValueToAngle } = require('./GaugeChart'); + // At minValue (0), angle should be -90 degrees = -PI/2 radians + expect(calcValueToAngle(0, 0, 100)).toBeCloseTo(-Math.PI / 2, 5); + // At maxValue (100), angle should be +90 degrees = PI/2 radians + expect(calcValueToAngle(100, 0, 100)).toBeCloseTo(Math.PI / 2, 5); + // At midpoint (50), angle should be 0 degrees = 0 radians + expect(calcValueToAngle(50, 0, 100)).toBeCloseTo(0, 5); + }); + + it('calcAnnotationPosition should calculate correct positions', () => { + const { calcAnnotationPosition } = require('./GaugeChart'); + const innerRadius = 50; + const outerRadius = 70; + + // At value 50 (center), x should be 0 + const posCenter = calcAnnotationPosition(50, 'outer', 0, 100, innerRadius, outerRadius); + expect(posCenter.x).toBeCloseTo(0, 3); + expect(posCenter.y).toBeLessThan(0); // y should be negative (above center) + + // At value 0 (left), x should be negative + const posLeft = calcAnnotationPosition(0, 'outer', 0, 100, innerRadius, outerRadius); + expect(posLeft.x).toBeLessThan(0); + + // At value 100 (right), x should be positive + const posRight = calcAnnotationPosition(100, 'outer', 0, 100, innerRadius, outerRadius); + expect(posRight.x).toBeGreaterThan(0); + }); + + it('calcAnnotationPosition should apply radial offset correctly', () => { + const { calcAnnotationPosition } = require('./GaugeChart'); + const innerRadius = 50; + const outerRadius = 70; + + const posNoOffset = calcAnnotationPosition(50, 'outer', 0, 100, innerRadius, outerRadius, 0); + const posWithOffset = calcAnnotationPosition(50, 'outer', 0, 100, innerRadius, outerRadius, 10); + + // At value 50, both should have x = 0, but offset should move y further from center + expect(Math.abs(posWithOffset.y)).toBeGreaterThan(Math.abs(posNoOffset.y)); + }); +}); + +describe('GaugeChart - Arrow Annotations', () => { + beforeEach(() => { + jest.spyOn(global.Math, 'random').mockReturnValue(0.1); + }); + + afterEach(() => { + jest.spyOn(global.Math, 'random').mockRestore(); + }); + + it('should render an annotation with an arrow', () => { + const { container } = render( + , + ); + expect(screen.getByText('Target Zone')).toBeInTheDocument(); + // Check that a line element (arrow shaft) is rendered + const lines = container.querySelectorAll('line'); + expect(lines.length).toBeGreaterThan(0); + }); + + it('should render arrow with custom head style', () => { + const { container } = render( + , + ); + expect(screen.getByText('Custom Arrow')).toBeInTheDocument(); + // Check for polygon element (arrowhead) + const polygons = container.querySelectorAll('polygon'); + expect(polygons.length).toBeGreaterThan(0); + }); + + it('should not render arrow when show is false', () => { + const { container } = render( + , + ); + expect(screen.getByText('No Arrow')).toBeInTheDocument(); + // Should not have any line elements for arrows + const annotationLines = container.querySelectorAll('.annotation-arrow line'); + expect(annotationLines.length).toBe(0); + }); + + it('should render multiple annotations with arrows', () => { + const { container } = render( + , + ); + expect(screen.getByText('Low')).toBeInTheDocument(); + expect(screen.getByText('High')).toBeInTheDocument(); + const lines = container.querySelectorAll('.annotation-arrow line'); + expect(lines.length).toBe(2); + }); +}); + +describe('GaugeChart - Arrow helper functions', () => { + it('getArrowHeadPath should return correct path for different styles', () => { + const { getArrowHeadPath } = require('./GaugeChart'); + + // Style 0 should return empty string (no head) + expect(getArrowHeadPath(0, 1, 1)).toBe(''); + + // Style 2 (default filled arrowhead) should return a closed path + const path2 = getArrowHeadPath(2, 1, 1); + expect(path2).toContain('M 0 0'); + expect(path2).toContain('Z'); + + // All other styles should return non-empty paths + for (let style = 1; style <= 8; style++) { + const path = getArrowHeadPath(style, 1, 1); + expect(path.length).toBeGreaterThan(0); + } + }); +}); diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx index 21ab96442fc24e..c20d58e5eb4e32 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx @@ -19,7 +19,14 @@ import { import { formatToLocaleString } from '@fluentui/chart-utilities'; import { SVGTooltipText } from '../../utilities/SVGTooltipText'; import { Legend, LegendShape, Legends, Shape } from '../Legends/index'; -import { GaugeChartVariant, GaugeValueFormat, GaugeChartProps, GaugeChartSegment } from './GaugeChart.types'; +import { + GaugeChartVariant, + GaugeValueFormat, + GaugeChartProps, + GaugeChartSegment, + GaugeChartAnnotation, + GaugeChartAnnotationArrow, +} from './GaugeChart.types'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; import { ChartPopover } from '../CommonComponents/ChartPopover'; import { useImageExport } from '../../utilities/hooks'; @@ -51,6 +58,151 @@ export const calcNeedleRotation = (chartValue: number, minValue: number, maxValu return needleRotation; }; +/** + * Converts a gauge value to an angle in radians for SVG positioning. + * The gauge spans from -90° (left, minValue) to +90° (right, maxValue). + * @returns angle in radians for use with Math.cos/sin + */ +export const calcValueToAngle = (value: number, minValue: number, maxValue: number): number => { + const clampedValue = Math.max(minValue, Math.min(maxValue, value)); + const fraction = (clampedValue - minValue) / (maxValue - minValue); + // Convert to radians: gauge goes from -PI/2 (left) to PI/2 (right) + // In SVG coordinates, we need to adjust for the coordinate system + const angleDegrees = -90 + fraction * 180; // -90 to +90 degrees + return (angleDegrees * Math.PI) / 180; +}; + +/** + * Calculates the x, y position for an annotation on the gauge chart. + */ +export const calcAnnotationPosition = ( + value: number, + position: 'inner' | 'outer' | 'arc', + minValue: number, + maxValue: number, + innerRadius: number, + outerRadius: number, + radialOffset: number = 0, +): { x: number; y: number } => { + const angle = calcValueToAngle(value, minValue, maxValue); + + let radius: number; + const padding = 8; + switch (position) { + case 'inner': + radius = innerRadius - padding - radialOffset; + break; + case 'outer': + radius = outerRadius + padding + radialOffset; + break; + case 'arc': + default: + radius = (innerRadius + outerRadius) / 2 + radialOffset; + break; + } + + // In SVG coordinate system, y increases downward + // For a gauge centered at origin with 0 at top: + // x = radius * sin(angle), y = -radius * cos(angle) + return { + x: radius * Math.sin(angle), + y: -radius * Math.cos(angle), + }; +}; + +/** + * Generates SVG path data for an arrowhead based on the arrow style. + * @param headStyle - Arrow head style (0-8) + * @param headSize - Size multiplier for the arrowhead + * @param width - Arrow shaft width + * @returns SVG marker path data + */ +export const getArrowHeadPath = (headStyle: number, headSize: number, width: number): string => { + const size = headSize * width * 3; + switch (headStyle) { + case 0: // No head + return ''; + case 1: // Simple arrowhead (open) + return `M 0 0 L ${-size} ${-size / 2} M 0 0 L ${-size} ${size / 2}`; + case 2: // Filled arrowhead (default) + return `M 0 0 L ${-size} ${-size / 2} L ${-size} ${size / 2} Z`; + case 3: // Open triangle + return `M 0 0 L ${-size} ${-size / 2} L ${-size * 0.7} 0 L ${-size} ${size / 2} Z`; + case 4: // Filled triangle (larger) + return `M 0 0 L ${-size * 1.2} ${-size / 2} L ${-size * 1.2} ${size / 2} Z`; + case 5: // Thin arrow + return `M 0 0 L ${-size} ${-size / 3} L ${-size} ${size / 3} Z`; + case 6: // Wide arrow + return `M 0 0 L ${-size} ${-size} L ${-size} ${size} Z`; + case 7: // Barbed arrow + return `M 0 0 L ${-size} ${-size / 2} L ${-size * 0.6} 0 L ${-size} ${size / 2} Z`; + case 8: // Square end + return `M 0 ${-size / 2} L 0 ${size / 2} L ${-size / 2} ${size / 2} L ${-size / 2} ${-size / 2} Z`; + default: + return `M 0 0 L ${-size} ${-size / 2} L ${-size} ${size / 2} Z`; + } +}; + +/** + * Renders an arrow line with arrowhead for annotations. + * Uses direct polygon rendering for the arrowhead instead of markers for better compatibility. + */ +export const renderAnnotationArrow = ( + startX: number, + startY: number, + endX: number, + endY: number, + arrow: GaugeChartAnnotationArrow, + uniqueId: string, +): React.ReactNode => { + const color = arrow.color || '#333333'; + const width = arrow.width || 1; + const headStyle = arrow.headStyle ?? 2; + const headSize = arrow.headSize || 1; + + // Calculate arrow direction + const dx = endX - startX; + const dy = endY - startY; + const length = Math.sqrt(dx * dx + dy * dy); + + if (length === 0) { + return null; + } + + // Normalize direction vector + const nx = dx / length; + const ny = dy / length; + + // Perpendicular vector for arrow head width + const px = -ny; + const py = nx; + + // Arrow head size + const arrowLength = Math.max(8, headSize * 8); + const arrowWidth = Math.max(4, headSize * 4); + + // Calculate arrowhead points + const tipX = endX; + const tipY = endY; + const baseX = endX - nx * arrowLength; + const baseY = endY - ny * arrowLength; + const leftX = baseX + px * arrowWidth; + const leftY = baseY + py * arrowWidth; + const rightX = baseX - px * arrowWidth; + const rightY = baseY - py * arrowWidth; + + // Shorten the line so it doesn't overlap with the arrowhead + const lineEndX = endX - nx * arrowLength * 0.5; + const lineEndY = endY - ny * arrowLength * 0.5; + + return ( + + + {headStyle > 0 && } + + ); +}; + export const getSegmentLabel = ( segment: ExtendedSegment, minValue: number, @@ -279,6 +431,101 @@ export const GaugeChart: React.FunctionComponent = React.forwar ); } + function _renderDefaultAnnotation(annotation: GaugeChartAnnotation): React.ReactNode { + const { coordinates, style, ariaLabel, id, arrow } = annotation; + const position = coordinates.position || 'outer'; + const radialOffset = coordinates.radialOffset || 0; + + const { x, y } = calcAnnotationPosition( + coordinates.value, + position, + _minValue, + _maxValue, + _innerRadius, + _outerRadius, + radialOffset, + ); + + // Adjust x for RTL + const adjustedX = _isRTL ? -x : x; + + const annotationStyle: React.CSSProperties = { + fill: style?.textColor, + fontSize: style?.fontSize, + fontWeight: style?.fontWeight, + }; + + const annotationKey = id || `annotation-${coordinates.value}`; + + // Calculate text and arrow positions + // When arrow is enabled, tailOffsetX/Y define the offset from the arc position to the text + // This matches Plotly where (x,y) is the arrow tip and (ax,ay) offsets to text position + let textX = adjustedX; + let textY = y; + let arrowElement: React.ReactNode = null; + + if (arrow?.show) { + const tailOffsetX = arrow.tailOffsetX || 0; + const tailOffsetY = arrow.tailOffsetY || 0; + + // Arrow end points to the arc at the annotation value + const arcPosition = calcAnnotationPosition( + coordinates.value, + 'arc', + _minValue, + _maxValue, + _innerRadius, + _outerRadius, + 0, + ); + const arrowEndX = _isRTL ? -arcPosition.x : arcPosition.x; + const arrowEndY = arcPosition.y; + + // Text position is offset from the arc position by tailOffsetX/Y + // This matches Plotly's behavior where text is at (tip + ax, tip + ay) + textX = arrowEndX + (_isRTL ? -tailOffsetX : tailOffsetX); + textY = arrowEndY + tailOffsetY; + + // Arrow starts from text and points to arc + arrowElement = renderAnnotationArrow(textX, textY, arrowEndX, arrowEndY, arrow, annotationKey); + } + + return ( + + {arrowElement} + + + {annotation.text} + + + + ); + } + + function _renderAnnotations() { + const { annotations, onRenderAnnotation } = props; + if (!annotations || annotations.length === 0) { + return null; + } + + return annotations.map(annotation => { + if (onRenderAnnotation) { + return onRenderAnnotation(annotation, _renderDefaultAnnotation); + } + return _renderDefaultAnnotation(annotation); + }); + } + function _renderLegends() { if (props.hideLegend) { return null; @@ -695,6 +942,7 @@ export const GaugeChart: React.FunctionComponent = React.forwar wrapContent={_wrapContent} /> )} + {_renderAnnotations()} diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts index 858bd850d53ea7..7d2e227e0c213f 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts @@ -1,8 +1,158 @@ +import * as React from 'react'; import { LegendsProps } from '../Legends/index'; import { AccessibilityProps, Chart } from '../../types/index'; import { ChartPopoverProps } from '../CommonComponents/ChartPopover.types'; import type { TitleStyles } from '../../utilities/Common.styles'; +/** + * Position type for gauge annotations relative to the arc + * {@docCategory GaugeChart} + */ +export type GaugeChartAnnotationPosition = 'inner' | 'outer' | 'arc'; + +/** + * Coordinate specification for gauge annotations + * {@docCategory GaugeChart} + */ +export interface GaugeChartAnnotationCoordinate { + /** + * The value on the gauge where the annotation should be placed. + * Will be clamped between minValue and maxValue. + */ + value: number; + + /** + * Radial position relative to the gauge arc + * - 'inner': Inside the arc (towards center) + * - 'outer': Outside the arc + * - 'arc': On the arc itself + * @default 'outer' + */ + position?: GaugeChartAnnotationPosition; + + /** + * Pixel offset from the calculated position (positive = outward from center) + * @default 0 + */ + radialOffset?: number; +} + +/** + * Style properties for gauge annotations + * {@docCategory GaugeChart} + */ +export interface GaugeChartAnnotationStyle { + /** Text color */ + textColor?: string; + + /** Background color */ + backgroundColor?: string; + + /** Border color */ + borderColor?: string; + + /** Border width in pixels */ + borderWidth?: number; + + /** Border radius in pixels */ + borderRadius?: number; + + /** Font size */ + fontSize?: string; + + /** Font weight */ + fontWeight?: React.CSSProperties['fontWeight']; + + /** Padding around text */ + padding?: string; + + /** Custom CSS class */ + className?: string; +} + +/** + * Arrow configuration for gauge annotations + * {@docCategory GaugeChart} + */ +export interface GaugeChartAnnotationArrow { + /** + * Whether to show an arrow pointing from the annotation to the target + * @default false + */ + show?: boolean; + + /** + * Arrow head style (0-8, matching Plotly arrowhead values) + * - 0: No head + * - 1: Simple arrowhead + * - 2: Filled arrowhead + * - 3: Open triangle + * - 4: Filled triangle + * - 5: Thin arrow + * - 6: Wide arrow + * - 7: Barbed arrow + * - 8: Square end + * @default 2 + */ + headStyle?: number; + + /** + * Arrow color + * @default '#333333' + */ + color?: string; + + /** + * Arrow shaft width in pixels + * @default 1 + */ + width?: number; + + /** + * Arrow head size multiplier (relative to width) + * @default 1 + */ + headSize?: number; + + /** + * Horizontal offset of arrow tail from annotation (in pixels) + * Negative = left, Positive = right + * @default 0 + */ + tailOffsetX?: number; + + /** + * Vertical offset of arrow tail from annotation (in pixels) + * Negative = up, Positive = down + * @default 20 + */ + tailOffsetY?: number; +} + +/** + * Annotation configuration for GaugeChart + * {@docCategory GaugeChart} + */ +export interface GaugeChartAnnotation { + /** Unique identifier for the annotation */ + id?: string; + + /** Text content to display */ + text: string; + + /** Position specification */ + coordinates: GaugeChartAnnotationCoordinate; + + /** Visual styling */ + style?: GaugeChartAnnotationStyle; + + /** Arrow configuration */ + arrow?: GaugeChartAnnotationArrow; + + /** Accessibility label for screen readers */ + ariaLabel?: string; +} + /** * Gauge Chart segment interface. * {@docCategory GaugeChart} @@ -162,6 +312,21 @@ export interface GaugeChartProps { * the public methods and properties of the component. */ componentRef?: React.Ref; + + /** + * Annotations to display on the gauge chart. + * Annotations can be used to highlight specific values, thresholds, or targets. + */ + annotations?: GaugeChartAnnotation[]; + + /** + * Custom renderer for annotations. + * Use this to provide a custom rendering for annotations. + */ + onRenderAnnotation?: ( + annotation: GaugeChartAnnotation, + defaultRender: (annotation: GaugeChartAnnotation) => React.ReactNode, + ) => React.ReactNode; } /** @@ -273,4 +438,14 @@ export interface GaugeChartStyles { * Styles for the chart wrapper div */ chartWrapper?: string; + + /** + * Styles for the annotation container + */ + annotationContainer?: string; + + /** + * Styles for annotation text + */ + annotationText?: string; } diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap index 0ead71d3eefc04..74949ac8857e3b 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap @@ -2757,6 +2757,687 @@ exports[`Gauge chart rendering - Should render properly without chart value 1`] `; +exports[`GaugeChart - Annotations should render an annotation at the specified value 1`] = ` +
+
+
+ + + + 0 + + + 100 + + + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`GaugeChart - Annotations should render annotations at different radial positions 1`] = ` +
+
+
+ + + + 0 + + + 100 + + + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+`; + +exports[`GaugeChart - Annotations should render multiple annotations 1`] = ` +
+
+
+ + + + 0 + + + 100 + + + + + + + + + +
+
+
+
+ + + +
+
+
+
+
+`; + exports[`GaugeChart snapshot tests should not render min and max values of the gauge when the hideMinMax prop is true 1`] = ` Object { "asFragment": [Function], diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts index d08ce97a1b8713..2199f014159b8d 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts +++ b/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts @@ -28,6 +28,8 @@ export const gaugeChartClassNames: SlotClassNames = { legendsContainer: 'fui-gc__legendsContainer', chartWrapper: 'fui-gc__chartWrapper', svgTooltip: 'fui-gc__svgTooltip', + annotationContainer: 'fui-gc__annotationContainer', + annotationText: 'fui-gc__annotationText', }; const useStyles = makeStyles({ @@ -125,6 +127,17 @@ const useStyles = makeStyles({ legendsContainer: { width: '100%', }, + annotationContainer: { + ...typographyStyles.caption1, + fill: tokens.colorNeutralForeground1, + forcedColorAdjust: 'auto', + }, + annotationText: { + ...typographyStyles.caption1, + fill: tokens.colorNeutralForeground1, + forcedColorAdjust: 'auto', + pointerEvents: 'none', + }, }); export const useGaugeChartStyles = (props: GaugeChartProps): GaugeChartStyles => { const baseStyles = useStyles(); @@ -186,5 +199,15 @@ export const useGaugeChartStyles = (props: GaugeChartProps): GaugeChartStyles => baseStyles.legendsContainer, props.styles?.legendsContainer, ), + annotationContainer: mergeClasses( + gaugeChartClassNames.annotationContainer, + baseStyles.annotationContainer, + props.styles?.annotationContainer, + ), + annotationText: mergeClasses( + gaugeChartClassNames.annotationText, + baseStyles.annotationText, + props.styles?.annotationText, + ), }; };