Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Copy link

@github-actions github-actions bot Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Charts-DonutChart 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Charts-DonutChart.Dynamic.default.chromium.png 5581 Changed
vr-tests-react-components/Charts-DonutChart.Dynamic - Dark Mode.default.chromium.png 7530 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 617 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 729 Changed
vr-tests-react-components/TagPicker 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.disabled input hover.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled - High Contrast.disabled input hover.chromium.png 1319 Changed

There were 2 duplicate changes discarded. Check the build logs for more information.

"type": "minor",
"comment": "feat(react-charts): Add annotation support for GanttChart",
"packageName": "@fluentui/react-charts",
"email": "srmukher@microsoft.com",
"dependentChangeType": "patch"
}
38 changes: 38 additions & 0 deletions packages/charts/react-charts/library/etc/react-charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,43 @@ export interface FunnelChartStyles {
// @public (undocumented)
export const GanttChart: React_2.FunctionComponent<GanttChartProps>;

// @public
export interface GanttChartAnnotation {
ariaLabel?: string;
arrow?: GanttChartAnnotationArrow;
date: Date | number;
id?: string;
position?: GanttChartAnnotationPosition;
style?: GanttChartAnnotationStyle;
taskIndex: number;
taskLabel?: string;
text: string;
}

// @public
export interface GanttChartAnnotationArrow {
color?: string;
direction?: 'left' | 'right' | 'vertical';
headSize?: number;
headStyle?: 'triangle' | 'none';
offsetX?: number;
offsetY?: 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;
Expand All @@ -878,6 +915,7 @@ export interface GanttChartProps extends CartesianChartProps {
culture?: string;
data?: GanttChartDataPoint[];
enableGradient?: boolean;
ganttAnnotations?: GanttChartAnnotation[];
maxBarHeight?: number;
onRenderCalloutPerDataPoint?: RenderFunction<GanttChartDataPoint>;
roundCorners?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2300,6 +2300,159 @@ export const transformPlotlyJsonToHorizontalBarWithAxisProps = (
};
};

/**
* Transform Plotly annotations to GanttChart annotations
*/
const transformPlotlyAnnotationsToGanttAnnotations = (
input: PlotlySchema,
yAxisLabels: string[],
xRange?: { min: number; max: number },
): 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)
// 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);
} 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 <b>, <i>, <br> and decode HTML entities
const cleanedText = (annotation.text || '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]*>/g, '')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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,
taskLabel: typeof annotation.y === 'string' ? annotation.y : undefined,
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),
offsetY: ay,
};
}

ganttAnnotations.push(ganttAnnotation);
});

return ganttAnnotations;
};

export const transformPlotlyJsonToGanttChartProps = (
input: PlotlySchema,
isMultiPlot: boolean,
Expand Down Expand Up @@ -2383,6 +2536,44 @@ export const transformPlotlyJsonToGanttChartProps = (
}
});

// Extract unique y-axis labels from the data for annotation mapping
const yAxisLabelsSet = new Set<string>();
ganttData.forEach(point => {
yAxisLabelsSet.add(String(point.y));
});
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, xRange);

return {
data: ganttData,
showYAxisLables: true,
Expand All @@ -2394,6 +2585,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),
Expand Down
Loading