From 8ba357108ca9d4cb797126c11c726aedc026cd84 Mon Sep 17 00:00:00 2001 From: srmukher Date: Mon, 2 Feb 2026 16:45:01 +0530 Subject: [PATCH 1/2] feat(react-charts): Add annotation support for GanttChart --- ...-0898a648-499d-4054-b387-769cf8f423d0.json | 10 + .../library/etc/react-charts.api.md | 36 ++ .../DeclarativeChart/PlotlySchemaAdapter.ts | 158 ++++++- .../components/GanttChart/GanttChart.test.tsx | 114 +++++ .../src/components/GanttChart/GanttChart.tsx | 203 +++++++- .../components/GanttChart/GanttChart.types.ts | 144 ++++++ .../__snapshots__/GanttChart.test.tsx.snap | 433 ++++++++++++++++++ 7 files changed, 1096 insertions(+), 2 deletions(-) create mode 100644 change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json diff --git a/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json b/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json new file mode 100644 index 00000000000000..68474bf76b5b80 --- /dev/null +++ b/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json @@ -0,0 +1,10 @@ +{ + "type": "minor", + "comment": { + "title": "", + "value": "" + }, + "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..7aeccc32993150 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -855,6 +855,41 @@ export interface FunnelChartStyles { // @public (undocumented) export const GanttChart: React_2.FunctionComponent; +// @public +export interface GanttChartAnnotation { + ariaLabel?: string; + arrow?: GanttChartAnnotationArrow; + date: Date | number; + id?: string; + position?: GanttChartAnnotationPosition; + style?: GanttChartAnnotationStyle; + taskIndex: number; + text: string; +} + +// @public +export interface GanttChartAnnotationArrow { + color?: string; + direction?: 'left' | 'right' | 'vertical'; + headSize?: number; + headStyle?: 'triangle' | 'none'; + offsetX?: number; + show?: boolean; + width?: number; +} + +// @public +export type GanttChartAnnotationPosition = 'above' | 'below' | 'on' | 'header'; + +// @public +export interface GanttChartAnnotationStyle { + backgroundColor?: string; + borderColor?: string; + fontSize?: number; + fontWeight?: string | number; + textColor?: string; +} + // @public (undocumented) export interface GanttChartDataPoint { callOutAccessibilityData?: AccessibilityProps; @@ -878,6 +913,7 @@ export interface GanttChartProps extends CartesianChartProps { culture?: string; data?: GanttChartDataPoint[]; enableGradient?: boolean; + ganttAnnotations?: GanttChartAnnotation[]; maxBarHeight?: number; onRenderCalloutPerDataPoint?: RenderFunction; roundCorners?: boolean; 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..7035df3fbedf98 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -41,7 +41,7 @@ import { GaugeChartProps, GaugeChartSegment } from '../GaugeChart/index'; import { GroupedVerticalBarChartProps } from '../GroupedVerticalBarChart/index'; import { VerticalBarChartProps } from '../VerticalBarChart/index'; import { ChartTableProps } from '../ChartTable/index'; -import { GanttChartProps } from '../GanttChart/index'; +import { GanttChartProps, GanttChartAnnotation } from '../GanttChart/index'; import type { AnnotationOnlyChartProps } from '../AnnotationOnlyChart/AnnotationOnlyChart.types'; import { DEFAULT_DATE_STRING, @@ -2300,6 +2300,151 @@ export const transformPlotlyJsonToHorizontalBarWithAxisProps = ( }; }; +/** + * Transform Plotly annotations to GanttChart annotations + */ +const transformPlotlyAnnotationsToGanttAnnotations = ( + input: PlotlySchema, + yAxisLabels: string[], +): GanttChartAnnotation[] => { + const rawAnnotations = input.layout?.annotations; + if (!rawAnnotations) { + return []; + } + + const annotationsArray = Array.isArray(rawAnnotations) ? rawAnnotations : [rawAnnotations]; + const numRows = yAxisLabels.length; + + const ganttAnnotations: GanttChartAnnotation[] = []; + + annotationsArray.forEach((annotation: PlotlyAnnotation, index: number) => { + if (!annotation || annotation.text === undefined) { + return; + } + + const xref = annotation.xref || 'x'; + const yref = annotation.yref || 'y'; + + let date: Date | number | undefined; + let taskIndex: number | undefined; + + // Handle x position + if (xref === 'paper' && typeof annotation.x === 'number') { + // Paper coordinates: x is a fraction of the plot width (0-1) + // We'll need to map this to an actual date from the data range + // For now, skip paper-based x coordinates as they require domain knowledge + return; + } else if (typeof annotation.x === 'string') { + // Date string + date = new Date(annotation.x); + } else if (typeof annotation.x === 'number') { + // Numeric value - could be a timestamp or number + date = annotation.x; + } + + // Handle y position + let isHeaderAnnotation = false; + if (yref === 'paper' && typeof annotation.y === 'number') { + if (annotation.y >= 1) { + // Paper coordinates y >= 1 means above the chart area - treat as header + isHeaderAnnotation = true; + taskIndex = 0; // Will be overridden by header position logic + } else { + // Paper coordinates: y is a fraction of the plot height (0-1) + // Map to task index (inverted because y=0 is bottom, y=1 is top) + taskIndex = Math.round((1 - annotation.y) * (numRows - 1)); + } + } else if (typeof annotation.y === 'string') { + // String label - find the matching row + const foundIndex = yAxisLabels.indexOf(annotation.y); + if (foundIndex !== -1) { + // Use the index directly - GanttChart.tsx handles the y-axis inversion + taskIndex = foundIndex; + } + } else if (typeof annotation.y === 'number') { + // Numeric y value - use as-is or find closest row + taskIndex = Math.round(annotation.y); + } + + if (date === undefined || taskIndex === undefined || taskIndex < 0 || taskIndex >= numRows) { + return; + } + + // Clean text - strip HTML tags like , ,
and decode HTML entities + const cleanedText = (annotation.text || '') + .replace(//gi, ' ') + .replace(/<[^>]*>/g, '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .trim(); + + // Determine position based on ay offset (negative = above, positive = below) + let position: 'above' | 'below' | 'on' | 'header' = 'above'; + if (isHeaderAnnotation) { + position = 'header'; + } else { + const ay = typeof annotation.ay === 'number' ? annotation.ay : 0; + if (ay > 10) { + position = 'below'; + } else if (ay < -10) { + position = 'above'; + } else if (ay === 0 && typeof annotation.ax === 'number' && annotation.ax !== 0) { + // Horizontal arrow (ay=0, ax!=0) - position 'on' + position = 'on'; + } + } + + const ganttAnnotation: GanttChartAnnotation = { + text: cleanedText, + taskIndex, + date: typeof date === 'number' ? date : date, + position, + id: `annotation-${index}`, + ariaLabel: cleanedText, + }; + + // Add style if present + if (annotation.font?.color || annotation.font?.size || annotation.bgcolor || annotation.bordercolor) { + ganttAnnotation.style = { + textColor: annotation.font?.color as string | undefined, + fontSize: annotation.font?.size as number | undefined, + backgroundColor: annotation.bgcolor as string | undefined, + borderColor: annotation.bordercolor as string | undefined, + }; + } + + // Add arrow if present + if (annotation.showarrow) { + const ax = typeof annotation.ax === 'number' ? annotation.ax : 0; + const ay = typeof annotation.ay === 'number' ? annotation.ay : 0; + + // Determine arrow direction + let direction: 'left' | 'right' | 'vertical' = 'vertical'; + if (Math.abs(ax) > Math.abs(ay) && ax !== 0) { + // Horizontal arrow - if ax > 0, text is to the right of arrow point, so arrow points left + direction = ax > 0 ? 'left' : 'right'; + } + + ganttAnnotation.arrow = { + show: true, + color: (annotation.arrowcolor as string) || (annotation.font?.color as string | undefined) || '#000000', + width: annotation.arrowwidth || 1, + headSize: annotation.arrowsize ? annotation.arrowsize * 6 : 6, + headStyle: annotation.arrowhead === 0 ? 'none' : 'triangle', + direction, + offsetX: Math.abs(ax), + }; + } + + ganttAnnotations.push(ganttAnnotation); + }); + + return ganttAnnotations; +}; + export const transformPlotlyJsonToGanttChartProps = ( input: PlotlySchema, isMultiPlot: boolean, @@ -2383,6 +2528,16 @@ export const transformPlotlyJsonToGanttChartProps = ( } }); + // Extract unique y-axis labels from the data for annotation mapping + const yAxisLabelsSet = new Set(); + ganttData.forEach(point => { + yAxisLabelsSet.add(String(point.y)); + }); + const yAxisLabels = Array.from(yAxisLabelsSet).reverse(); + + // Transform Plotly annotations to GanttChart annotations + const ganttAnnotations = transformPlotlyAnnotationsToGanttAnnotations(input, yAxisLabels); + return { data: ganttData, showYAxisLables: true, @@ -2394,6 +2549,7 @@ export const transformPlotlyJsonToGanttChartProps = ( showYAxisLablesTooltip: true, roundCorners: true, useUTC: false, + ...(ganttAnnotations.length > 0 ? { ganttAnnotations } : {}), ...getTitles(input.layout), ...getAxisCategoryOrderProps(data, input.layout), ...getBarProps(data, input.layout, true), diff --git a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.test.tsx b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.test.tsx index 1d1864517fb04b..4690b971cc1d29 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.test.tsx @@ -8,6 +8,7 @@ import { axe, toHaveNoViolations } from 'jest-axe'; import { GanttChart } from './index'; import { ganttData, ganttDataWithLongY, ganttDataWithNumericY } from '../../utilities/test-data'; import { GanttChartDataPoint } from '../../types/index'; +import { GanttChartAnnotation } from './GanttChart.types'; expect.extend(toHaveNoViolations); @@ -284,3 +285,116 @@ describe('GanttChart interaction and accessibility tests', () => { expect(results).toHaveNoViolations(); }); }); + +describe('GanttChart annotation tests', () => { + const sampleAnnotations: GanttChartAnnotation[] = [ + { + text: 'Milestone 1', + taskIndex: 0, + date: new Date('2024-01-15'), + position: 'above', + id: 'ann-1', + ariaLabel: 'First milestone marker', + }, + { + text: 'Deadline', + taskIndex: 1, + date: new Date('2024-01-20'), + position: 'below', + style: { + textColor: 'red', + fontSize: 14, + }, + id: 'ann-2', + }, + ]; + + const annotationWithArrow: GanttChartAnnotation[] = [ + { + text: 'Critical Point', + taskIndex: 0, + date: new Date('2024-01-10'), + position: 'above', + arrow: { + show: true, + color: '#FF0000', + width: 2, + headSize: 8, + headStyle: 'triangle', + }, + id: 'ann-arrow', + }, + ]; + + it('should render annotation with text', () => { + const { container } = render(); + const textElements = container.querySelectorAll('text'); + const annotationTexts = Array.from(textElements).filter( + el => el.textContent === 'Milestone 1' || el.textContent === 'Deadline', + ); + expect(annotationTexts.length).toBeGreaterThanOrEqual(1); + }); + + it('should render annotation with arrow', () => { + const { container } = render(); + // Check for arrow elements (line and polygon) + const lines = container.querySelectorAll('line'); + const polygons = container.querySelectorAll('polygon'); + expect(lines.length).toBeGreaterThanOrEqual(1); + expect(polygons.length).toBeGreaterThanOrEqual(1); + }); + + it('should render annotation with custom styling', () => { + const styledAnnotations: GanttChartAnnotation[] = [ + { + text: 'Styled Note', + taskIndex: 0, + date: new Date('2024-01-12'), + position: 'on', + style: { + textColor: 'blue', + fontSize: 16, + fontWeight: 'bold', + }, + id: 'styled-ann', + }, + ]; + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it('should render multiple annotations', () => { + const multipleAnnotations: GanttChartAnnotation[] = [ + { + text: 'Note 1', + taskIndex: 0, + date: new Date('2024-01-05'), + id: 'multi-1', + }, + { + text: 'Note 2', + taskIndex: 1, + date: new Date('2024-01-10'), + id: 'multi-2', + }, + { + text: 'Note 3', + taskIndex: 2, + date: new Date('2024-01-15'), + id: 'multi-3', + }, + ]; + const { container } = render(); + const textElements = container.querySelectorAll('text'); + const annotationTexts = Array.from(textElements).filter(el => + ['Note 1', 'Note 2', 'Note 3'].includes(el.textContent || ''), + ); + expect(annotationTexts.length).toBeGreaterThanOrEqual(1); + }); + + it('should apply aria-label to annotations for accessibility', () => { + const { container } = render(); + const annotationWithAria = container.querySelector('[aria-label="First milestone marker"]'); + expect(annotationWithAria).not.toBeNull(); + }); +}); diff --git a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx index fa2ff292edc3a4..d504f938238886 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx +++ b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx @@ -8,7 +8,7 @@ import type { JSXElement } from '@fluentui/react-utilities'; import { Legend, Legends } from '../Legends/index'; import { Margins, GanttChartDataPoint } from '../../types/DataPoint'; import { CartesianChart, ModifiedCartesianChartProps } from '../CommonComponents/index'; -import { GanttChartProps } from './GanttChart.types'; +import { GanttChartProps, GanttChartAnnotation, GanttChartAnnotationArrow } from './GanttChart.types'; import { ChartPopover } from '../CommonComponents/ChartPopover'; import { ChartPopoverProps } from '../../index'; import { @@ -39,6 +39,49 @@ type DateScale = ScaleTime; const DEFAULT_BAR_HEIGHT = 24; const MIN_BAR_HEIGHT = 1; +/** + * Renders an arrow for an annotation pointing to the target position. + */ +const renderAnnotationArrow = ( + arrowProps: GanttChartAnnotationArrow, + startX: number, + startY: number, + endX: number, + endY: number, + index: number, +): React.ReactElement | null => { + if (!arrowProps.show) { + return null; + } + + const color = arrowProps.color || '#000000'; + const width = arrowProps.width || 1; + const headSize = arrowProps.headSize || 6; + const headStyle = arrowProps.headStyle || 'triangle'; + + // Calculate the angle of the line + const angle = Math.atan2(endY - startY, endX - startX); + + // Calculate the arrow head points + const arrowHeadPoints = + headStyle === 'triangle' + ? [ + [endX, endY], + [endX - headSize * Math.cos(angle - Math.PI / 6), endY - headSize * Math.sin(angle - Math.PI / 6)], + [endX - headSize * Math.cos(angle + Math.PI / 6), endY - headSize * Math.sin(angle + Math.PI / 6)], + ] + .map(p => p.join(',')) + .join(' ') + : ''; + + return ( + + + {headStyle === 'triangle' && } + + ); +}; + export const GanttChart: React.FunctionComponent = React.forwardRef( ({ useUTC = true, yAxisCategoryOrder = 'default', maxBarHeight = 24, ...props }, forwardedRef) => { const _barHeight = React.useRef(DEFAULT_BAR_HEIGHT); @@ -428,10 +471,166 @@ export const GanttChart: React.FunctionComponent = React.forwar /> ); }); + + // Render annotations + const annotations: JSXElement[] = []; + if (props.ganttAnnotations && props.ganttAnnotations.length > 0) { + props.ganttAnnotations.forEach((annotation: GanttChartAnnotation, index: number) => { + const xPos = xScale(annotation.date); + const position = annotation.position || 'above'; + const fontSize = annotation.style?.fontSize || 12; + const textColor = annotation.style?.textColor || '#000000'; + const fontWeight = annotation.style?.fontWeight || 'normal'; + const backgroundColor = annotation.style?.backgroundColor; + const borderColor = annotation.style?.borderColor; + const arrowDirection = annotation.arrow?.direction || 'vertical'; + const arrowOffsetX = annotation.arrow?.offsetX || 40; + + // Calculate text position + let textX = xPos; + let textY: number; + let baseY: number | undefined; + let textAnchor: 'start' | 'middle' | 'end' = 'middle'; + + if (position === 'header') { + // Header annotations are positioned above the chart area + textY = -10; // Above the first row + } else { + const yLabel = _yAxisLabels[_yAxisLabels.length - 1 - annotation.taskIndex]; + if (yLabel === undefined) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + baseY = yScale(yLabel as any)! + scaleBandwidth / 2; + textY = baseY; + + if (arrowDirection === 'left') { + // Text is to the right of the arrow point (ax > 0 in Plotly) + textX = xPos + arrowOffsetX; + textAnchor = 'start'; + } else if (arrowDirection === 'right') { + // Text is to the left of the arrow point (ax < 0 in Plotly) + textX = xPos - arrowOffsetX; + textAnchor = 'end'; + } else if (position === 'above') { + textY = baseY - _barHeight.current / 2 - 8; + } else if (position === 'below') { + textY = baseY + _barHeight.current / 2 + fontSize + 4; + } + } + + const annotationKey = annotation.id || `annotation-${index}`; + + // Render arrow if configured (skip for header annotations) + if (annotation.arrow?.show && position !== 'header' && baseY !== undefined) { + let arrowStartX: number; + let arrowStartY: number; + let arrowEndX: number; + let arrowEndY: number; + + // Arrow length - keep it short like Plotly (20-25 pixels) + const arrowLength = 20; + + if (arrowDirection === 'left' || arrowDirection === 'right') { + // Horizontal arrow - short connector from text toward bar + if (arrowDirection === 'left') { + arrowStartX = textX - 5; + arrowEndX = textX - 5 - arrowLength; + } else { + arrowStartX = textX + 5; + arrowEndX = textX + 5 + arrowLength; + } + arrowStartY = baseY; + arrowEndY = baseY; + } else { + // Vertical arrows + arrowEndX = xPos; + if (position === 'above') { + arrowStartX = xPos; + arrowStartY = textY + 8; // Start below the text + arrowEndY = baseY - _barHeight.current / 2; // End at top of bar + } else if (position === 'below') { + arrowStartX = xPos; + arrowStartY = textY - 4; // Start above the text + arrowEndY = baseY + _barHeight.current / 2; // End at bottom of bar + } else { + // For 'on' position with vertical arrow + arrowStartX = xPos; + arrowStartY = baseY - 10; + arrowEndY = baseY; + } + } + + annotations.push( + renderAnnotationArrow( + annotation.arrow, + arrowStartX, + arrowStartY, + arrowEndX, + arrowEndY, + index, + ) as JSXElement, + ); + } + + // Render text with optional background for header annotations + if (position === 'header' && (backgroundColor || borderColor)) { + // Estimate text width (rough approximation) + const textWidth = annotation.text.length * fontSize * 0.6; + const padding = 6; + annotations.push( + + + + {annotation.text} + + , + ); + } else { + // Render text + annotations.push( + + {annotation.text} + , + ); + } + }); + } + return ( {gradientDefs.length > 0 ? {gradientDefs} : null} {bars} + {annotations} ); }, @@ -444,8 +643,10 @@ export const GanttChart: React.FunctionComponent = React.forwar _onBarFocus, _onBarHover, _onBarLeave, + _yAxisLabels, _yAxisType, props.enableGradient, + props.ganttAnnotations, props.roundCorners, ], ); diff --git a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.types.ts b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.types.ts index 3a1e1597ce6d82..e31cfc4839b3cb 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.types.ts +++ b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.types.ts @@ -1,6 +1,145 @@ import { RenderFunction } from '../../utilities/index'; import { CartesianChartProps, CartesianChartStyleProps, CartesianChartStyles, GanttChartDataPoint } from '../../index'; +/** + * Position of an annotation relative to the task bar. + * - 'above': Annotation appears above the task bar + * - 'below': Annotation appears below the task bar + * - 'on': Annotation appears on the task bar + * - 'header': Annotation appears above the chart area (for chart-level annotations) + * {@docCategory GanttChart} + */ +export type GanttChartAnnotationPosition = 'above' | 'below' | 'on' | 'header'; + +/** + * Style properties for a Gantt Chart annotation. + * {@docCategory GanttChart} + */ +export interface GanttChartAnnotationStyle { + /** + * Color of the annotation text. + */ + textColor?: string; + + /** + * Font size of the annotation text in pixels. + */ + fontSize?: number; + + /** + * Font weight of the annotation text. + */ + fontWeight?: string | number; + + /** + * Background color of the annotation. + */ + backgroundColor?: string; + + /** + * Border color of the annotation. + */ + borderColor?: string; +} + +/** + * Arrow properties for a Gantt Chart annotation. + * {@docCategory GanttChart} + */ +export interface GanttChartAnnotationArrow { + /** + * Whether to show the arrow. + * @default false + */ + show?: boolean; + + /** + * Style of the arrow head. + * @default 'triangle' + */ + headStyle?: 'triangle' | 'none'; + + /** + * Color of the arrow. + */ + color?: string; + + /** + * Width of the arrow line in pixels. + * @default 1 + */ + width?: number; + + /** + * Size of the arrow head in pixels. + * @default 6 + */ + headSize?: number; + + /** + * Direction of the arrow relative to the annotation text. + * - 'left': Arrow points left from the text toward the bar + * - 'right': Arrow points right from the text toward the bar + * - 'vertical': Arrow points vertically (up or down depending on position) + * @default 'vertical' + */ + direction?: 'left' | 'right' | 'vertical'; + + /** + * Offset in pixels for horizontal arrows (ax value from Plotly). + * Positive values mean text is to the right of the arrow point. + * @default 0 + */ + offsetX?: number; +} + +/** + * Represents an annotation on the Gantt Chart. + * {@docCategory GanttChart} + */ +export interface GanttChartAnnotation { + /** + * The text content of the annotation. + */ + text: string; + + /** + * The index of the task row (0-based) where the annotation should appear. + */ + taskIndex: number; + + /** + * The date position of the annotation on the x-axis. + */ + date: Date | number; + + /** + * Position of the annotation relative to the task bar. + * @default 'above' + */ + position?: GanttChartAnnotationPosition; + + /** + * Style properties for the annotation. + */ + style?: GanttChartAnnotationStyle; + + /** + * Arrow properties for the annotation. + */ + arrow?: GanttChartAnnotationArrow; + + /** + * Unique identifier for the annotation. + */ + id?: string; + + /** + * Accessible label for the annotation. + */ + ariaLabel?: string; +} + /** * Gantt Chart properties * {@docCategory GanttChart} @@ -56,6 +195,11 @@ export interface GanttChartProps extends CartesianChartProps { * @default 24 */ maxBarHeight?: number; + + /** + * An array of annotations to be rendered on the chart. + */ + ganttAnnotations?: GanttChartAnnotation[]; } /** diff --git a/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap index 6cdabc93cfd572..810c6fe83489fc 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/GanttChart/__snapshots__/GanttChart.test.tsx.snap @@ -1,5 +1,438 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`GanttChart annotation tests should render annotation with custom styling 1`] = ` +
+ +
+`; + exports[`GanttChart interaction and accessibility tests should render custom callout content using onRenderCalloutPerDataPoint when a bar is hovered 1`] = `
Date: Tue, 3 Feb 2026 19:52:51 +0530 Subject: [PATCH 2/2] Add support for Gantt Chart Annotations --- ...-0898a648-499d-4054-b387-769cf8f423d0.json | 5 +- .../library/etc/react-charts.api.md | 2 + .../DeclarativeChart/PlotlySchemaAdapter.ts | 44 ++++++++++-- .../src/components/GanttChart/GanttChart.tsx | 71 +++++++++++++++++-- .../components/GanttChart/GanttChart.types.ts | 14 ++++ 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json b/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json index 68474bf76b5b80..fed66c4ee938e4 100644 --- a/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json +++ b/change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json @@ -1,9 +1,6 @@ { "type": "minor", - "comment": { - "title": "", - "value": "" - }, + "comment": "feat(react-charts): Add annotation support for GanttChart", "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 7aeccc32993150..2e2e3ae5352946 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -864,6 +864,7 @@ export interface GanttChartAnnotation { position?: GanttChartAnnotationPosition; style?: GanttChartAnnotationStyle; taskIndex: number; + taskLabel?: string; text: string; } @@ -874,6 +875,7 @@ export interface GanttChartAnnotationArrow { headSize?: number; headStyle?: 'triangle' | 'none'; offsetX?: number; + offsetY?: number; show?: boolean; width?: number; } 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 7035df3fbedf98..2838ddcec6e111 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -2306,6 +2306,7 @@ export const transformPlotlyJsonToHorizontalBarWithAxisProps = ( const transformPlotlyAnnotationsToGanttAnnotations = ( input: PlotlySchema, yAxisLabels: string[], + xRange?: { min: number; max: number }, ): GanttChartAnnotation[] => { const rawAnnotations = input.layout?.annotations; if (!rawAnnotations) { @@ -2331,9 +2332,14 @@ const transformPlotlyAnnotationsToGanttAnnotations = ( // Handle x position if (xref === 'paper' && typeof annotation.x === 'number') { // Paper coordinates: x is a fraction of the plot width (0-1) - // We'll need to map this to an actual date from the data range - // For now, skip paper-based x coordinates as they require domain knowledge - return; + // Map to actual data value using the x-axis range + if (xRange) { + const xValue = xRange.min + annotation.x * (xRange.max - xRange.min); + date = xValue; + } else { + // No range available, skip this annotation + return; + } } else if (typeof annotation.x === 'string') { // Date string date = new Date(annotation.x); @@ -2400,6 +2406,7 @@ const transformPlotlyAnnotationsToGanttAnnotations = ( const ganttAnnotation: GanttChartAnnotation = { text: cleanedText, taskIndex, + taskLabel: typeof annotation.y === 'string' ? annotation.y : undefined, date: typeof date === 'number' ? date : date, position, id: `annotation-${index}`, @@ -2436,6 +2443,7 @@ const transformPlotlyAnnotationsToGanttAnnotations = ( headStyle: annotation.arrowhead === 0 ? 'none' : 'triangle', direction, offsetX: Math.abs(ax), + offsetY: ay, }; } @@ -2535,8 +2543,36 @@ export const transformPlotlyJsonToGanttChartProps = ( }); const yAxisLabels = Array.from(yAxisLabelsSet).reverse(); + // Compute x-axis range for paper coordinate conversion + let xRange: { min: number; max: number } | undefined; + if (ganttData.length > 0) { + let minX = Infinity; + let maxX = -Infinity; + ganttData.forEach(point => { + const startVal = point.x.start instanceof Date ? point.x.start.getTime() : (point.x.start as number); + const endVal = point.x.end instanceof Date ? point.x.end.getTime() : (point.x.end as number); + minX = Math.min(minX, startVal); + maxX = Math.max(maxX, endVal); + }); + // Also check layout xaxis range if specified + const layoutRange = input.layout?.xaxis?.range; + if (layoutRange && layoutRange.length === 2) { + const rangeMin = + typeof layoutRange[0] === 'number' ? layoutRange[0] : new Date(layoutRange[0] as string).getTime(); + const rangeMax = + typeof layoutRange[1] === 'number' ? layoutRange[1] : new Date(layoutRange[1] as string).getTime(); + if (!isNaN(rangeMin) && !isNaN(rangeMax)) { + minX = rangeMin; + maxX = rangeMax; + } + } + if (minX !== Infinity && maxX !== -Infinity) { + xRange = { min: minX, max: maxX }; + } + } + // Transform Plotly annotations to GanttChart annotations - const ganttAnnotations = transformPlotlyAnnotationsToGanttAnnotations(input, yAxisLabels); + const ganttAnnotations = transformPlotlyAnnotationsToGanttAnnotations(input, yAxisLabels, xRange); return { data: ganttData, diff --git a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx index d504f938238886..a49a261e7d1359 100644 --- a/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx +++ b/packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx @@ -485,6 +485,7 @@ export const GanttChart: React.FunctionComponent = React.forwar const borderColor = annotation.style?.borderColor; const arrowDirection = annotation.arrow?.direction || 'vertical'; const arrowOffsetX = annotation.arrow?.offsetX || 40; + const arrowOffsetY = annotation.arrow?.offsetY || 0; // Calculate text position let textX = xPos; @@ -494,9 +495,23 @@ export const GanttChart: React.FunctionComponent = React.forwar if (position === 'header') { // Header annotations are positioned above the chart area - textY = -10; // Above the first row + // Get the first visible row's Y position and place header above it + const firstRowLabel = _yAxisLabels[_yAxisLabels.length - 1]; + if (firstRowLabel) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstRowY = yScale(firstRowLabel as any)!; + textY = firstRowY - fontSize - 10; // Position above first row + } else { + textY = 10; // Fallback position + } } else { - const yLabel = _yAxisLabels[_yAxisLabels.length - 1 - annotation.taskIndex]; + // Use taskLabel if available, otherwise fall back to taskIndex + let yLabel: string | undefined; + if (annotation.taskLabel && _yAxisLabels.includes(annotation.taskLabel)) { + yLabel = annotation.taskLabel; + } else { + yLabel = _yAxisLabels[_yAxisLabels.length - 1 - annotation.taskIndex]; + } if (yLabel === undefined) { return; } @@ -504,6 +519,7 @@ export const GanttChart: React.FunctionComponent = React.forwar baseY = yScale(yLabel as any)! + scaleBandwidth / 2; textY = baseY; + // Apply horizontal offset for arrows with ax value if (arrowDirection === 'left') { // Text is to the right of the arrow point (ax > 0 in Plotly) textX = xPos + arrowOffsetX; @@ -512,6 +528,12 @@ export const GanttChart: React.FunctionComponent = React.forwar // Text is to the left of the arrow point (ax < 0 in Plotly) textX = xPos - arrowOffsetX; textAnchor = 'end'; + } + + // Apply vertical offset (ay value from Plotly) + // In Plotly, negative ay means text is above the arrow point + if (arrowOffsetY !== 0) { + textY = baseY + arrowOffsetY; // ay is negative for above, positive for below } else if (position === 'above') { textY = baseY - _barHeight.current / 2 - 8; } else if (position === 'below') { @@ -532,7 +554,7 @@ export const GanttChart: React.FunctionComponent = React.forwar const arrowLength = 20; if (arrowDirection === 'left' || arrowDirection === 'right') { - // Horizontal arrow - short connector from text toward bar + // Horizontal/diagonal arrow from text position toward the target row if (arrowDirection === 'left') { arrowStartX = textX - 5; arrowEndX = textX - 5 - arrowLength; @@ -540,7 +562,8 @@ export const GanttChart: React.FunctionComponent = React.forwar arrowStartX = textX + 5; arrowEndX = textX + 5 + arrowLength; } - arrowStartY = baseY; + // Arrow goes from text Y position toward the target row + arrowStartY = textY; arrowEndY = baseY; } else { // Vertical arrows @@ -605,8 +628,46 @@ export const GanttChart: React.FunctionComponent = React.forwar , ); + } else if (backgroundColor || borderColor) { + // Render text with background for non-header annotations + const textWidth = annotation.text.length * fontSize * 0.6; + const padding = 4; + // Adjust rect position based on text anchor + let rectX = textX - padding; + if (textAnchor === 'middle') { + rectX = textX - textWidth / 2 - padding; + } else if (textAnchor === 'end') { + rectX = textX - textWidth - padding; + } + annotations.push( + + + + {annotation.text} + + , + ); } else { - // Render text + // Render plain text annotations.push(