Skip to content

Commit 22c2506

Browse files
committed
Add support for Gantt Chart Annotations
1 parent 8ba3571 commit 22c2506

File tree

5 files changed

+123
-13
lines changed

5 files changed

+123
-13
lines changed

change/@fluentui-react-charts-0898a648-499d-4054-b387-769cf8f423d0.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
{
22
"type": "minor",
3-
"comment": {
4-
"title": "",
5-
"value": ""
6-
},
3+
"comment": "feat(react-charts): Add annotation support for GanttChart",
74
"packageName": "@fluentui/react-charts",
85
"email": "srmukher@microsoft.com",
96
"dependentChangeType": "patch"

packages/charts/react-charts/library/etc/react-charts.api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,7 @@ export interface GanttChartAnnotation {
864864
position?: GanttChartAnnotationPosition;
865865
style?: GanttChartAnnotationStyle;
866866
taskIndex: number;
867+
taskLabel?: string;
867868
text: string;
868869
}
869870

@@ -874,6 +875,7 @@ export interface GanttChartAnnotationArrow {
874875
headSize?: number;
875876
headStyle?: 'triangle' | 'none';
876877
offsetX?: number;
878+
offsetY?: number;
877879
show?: boolean;
878880
width?: number;
879881
}

packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2306,6 +2306,7 @@ export const transformPlotlyJsonToHorizontalBarWithAxisProps = (
23062306
const transformPlotlyAnnotationsToGanttAnnotations = (
23072307
input: PlotlySchema,
23082308
yAxisLabels: string[],
2309+
xRange?: { min: number; max: number },
23092310
): GanttChartAnnotation[] => {
23102311
const rawAnnotations = input.layout?.annotations;
23112312
if (!rawAnnotations) {
@@ -2331,9 +2332,14 @@ const transformPlotlyAnnotationsToGanttAnnotations = (
23312332
// Handle x position
23322333
if (xref === 'paper' && typeof annotation.x === 'number') {
23332334
// Paper coordinates: x is a fraction of the plot width (0-1)
2334-
// We'll need to map this to an actual date from the data range
2335-
// For now, skip paper-based x coordinates as they require domain knowledge
2336-
return;
2335+
// Map to actual data value using the x-axis range
2336+
if (xRange) {
2337+
const xValue = xRange.min + annotation.x * (xRange.max - xRange.min);
2338+
date = xValue;
2339+
} else {
2340+
// No range available, skip this annotation
2341+
return;
2342+
}
23372343
} else if (typeof annotation.x === 'string') {
23382344
// Date string
23392345
date = new Date(annotation.x);
@@ -2400,6 +2406,7 @@ const transformPlotlyAnnotationsToGanttAnnotations = (
24002406
const ganttAnnotation: GanttChartAnnotation = {
24012407
text: cleanedText,
24022408
taskIndex,
2409+
taskLabel: typeof annotation.y === 'string' ? annotation.y : undefined,
24032410
date: typeof date === 'number' ? date : date,
24042411
position,
24052412
id: `annotation-${index}`,
@@ -2436,6 +2443,7 @@ const transformPlotlyAnnotationsToGanttAnnotations = (
24362443
headStyle: annotation.arrowhead === 0 ? 'none' : 'triangle',
24372444
direction,
24382445
offsetX: Math.abs(ax),
2446+
offsetY: ay,
24392447
};
24402448
}
24412449

@@ -2535,8 +2543,36 @@ export const transformPlotlyJsonToGanttChartProps = (
25352543
});
25362544
const yAxisLabels = Array.from(yAxisLabelsSet).reverse();
25372545

2546+
// Compute x-axis range for paper coordinate conversion
2547+
let xRange: { min: number; max: number } | undefined;
2548+
if (ganttData.length > 0) {
2549+
let minX = Infinity;
2550+
let maxX = -Infinity;
2551+
ganttData.forEach(point => {
2552+
const startVal = point.x.start instanceof Date ? point.x.start.getTime() : (point.x.start as number);
2553+
const endVal = point.x.end instanceof Date ? point.x.end.getTime() : (point.x.end as number);
2554+
minX = Math.min(minX, startVal);
2555+
maxX = Math.max(maxX, endVal);
2556+
});
2557+
// Also check layout xaxis range if specified
2558+
const layoutRange = input.layout?.xaxis?.range;
2559+
if (layoutRange && layoutRange.length === 2) {
2560+
const rangeMin =
2561+
typeof layoutRange[0] === 'number' ? layoutRange[0] : new Date(layoutRange[0] as string).getTime();
2562+
const rangeMax =
2563+
typeof layoutRange[1] === 'number' ? layoutRange[1] : new Date(layoutRange[1] as string).getTime();
2564+
if (!isNaN(rangeMin) && !isNaN(rangeMax)) {
2565+
minX = rangeMin;
2566+
maxX = rangeMax;
2567+
}
2568+
}
2569+
if (minX !== Infinity && maxX !== -Infinity) {
2570+
xRange = { min: minX, max: maxX };
2571+
}
2572+
}
2573+
25382574
// Transform Plotly annotations to GanttChart annotations
2539-
const ganttAnnotations = transformPlotlyAnnotationsToGanttAnnotations(input, yAxisLabels);
2575+
const ganttAnnotations = transformPlotlyAnnotationsToGanttAnnotations(input, yAxisLabels, xRange);
25402576

25412577
return {
25422578
data: ganttData,

packages/charts/react-charts/library/src/components/GanttChart/GanttChart.tsx

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ export const GanttChart: React.FunctionComponent<GanttChartProps> = React.forwar
485485
const borderColor = annotation.style?.borderColor;
486486
const arrowDirection = annotation.arrow?.direction || 'vertical';
487487
const arrowOffsetX = annotation.arrow?.offsetX || 40;
488+
const arrowOffsetY = annotation.arrow?.offsetY || 0;
488489

489490
// Calculate text position
490491
let textX = xPos;
@@ -494,16 +495,31 @@ export const GanttChart: React.FunctionComponent<GanttChartProps> = React.forwar
494495

495496
if (position === 'header') {
496497
// Header annotations are positioned above the chart area
497-
textY = -10; // Above the first row
498+
// Get the first visible row's Y position and place header above it
499+
const firstRowLabel = _yAxisLabels[_yAxisLabels.length - 1];
500+
if (firstRowLabel) {
501+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
502+
const firstRowY = yScale(firstRowLabel as any)!;
503+
textY = firstRowY - fontSize - 10; // Position above first row
504+
} else {
505+
textY = 10; // Fallback position
506+
}
498507
} else {
499-
const yLabel = _yAxisLabels[_yAxisLabels.length - 1 - annotation.taskIndex];
508+
// Use taskLabel if available, otherwise fall back to taskIndex
509+
let yLabel: string | undefined;
510+
if (annotation.taskLabel && _yAxisLabels.includes(annotation.taskLabel)) {
511+
yLabel = annotation.taskLabel;
512+
} else {
513+
yLabel = _yAxisLabels[_yAxisLabels.length - 1 - annotation.taskIndex];
514+
}
500515
if (yLabel === undefined) {
501516
return;
502517
}
503518
// eslint-disable-next-line @typescript-eslint/no-explicit-any
504519
baseY = yScale(yLabel as any)! + scaleBandwidth / 2;
505520
textY = baseY;
506521

522+
// Apply horizontal offset for arrows with ax value
507523
if (arrowDirection === 'left') {
508524
// Text is to the right of the arrow point (ax > 0 in Plotly)
509525
textX = xPos + arrowOffsetX;
@@ -512,6 +528,12 @@ export const GanttChart: React.FunctionComponent<GanttChartProps> = React.forwar
512528
// Text is to the left of the arrow point (ax < 0 in Plotly)
513529
textX = xPos - arrowOffsetX;
514530
textAnchor = 'end';
531+
}
532+
533+
// Apply vertical offset (ay value from Plotly)
534+
// In Plotly, negative ay means text is above the arrow point
535+
if (arrowOffsetY !== 0) {
536+
textY = baseY + arrowOffsetY; // ay is negative for above, positive for below
515537
} else if (position === 'above') {
516538
textY = baseY - _barHeight.current / 2 - 8;
517539
} else if (position === 'below') {
@@ -532,15 +554,16 @@ export const GanttChart: React.FunctionComponent<GanttChartProps> = React.forwar
532554
const arrowLength = 20;
533555

534556
if (arrowDirection === 'left' || arrowDirection === 'right') {
535-
// Horizontal arrow - short connector from text toward bar
557+
// Horizontal/diagonal arrow from text position toward the target row
536558
if (arrowDirection === 'left') {
537559
arrowStartX = textX - 5;
538560
arrowEndX = textX - 5 - arrowLength;
539561
} else {
540562
arrowStartX = textX + 5;
541563
arrowEndX = textX + 5 + arrowLength;
542564
}
543-
arrowStartY = baseY;
565+
// Arrow goes from text Y position toward the target row
566+
arrowStartY = textY;
544567
arrowEndY = baseY;
545568
} else {
546569
// Vertical arrows
@@ -605,8 +628,46 @@ export const GanttChart: React.FunctionComponent<GanttChartProps> = React.forwar
605628
</text>
606629
</g>,
607630
);
631+
} else if (backgroundColor || borderColor) {
632+
// Render text with background for non-header annotations
633+
const textWidth = annotation.text.length * fontSize * 0.6;
634+
const padding = 4;
635+
// Adjust rect position based on text anchor
636+
let rectX = textX - padding;
637+
if (textAnchor === 'middle') {
638+
rectX = textX - textWidth / 2 - padding;
639+
} else if (textAnchor === 'end') {
640+
rectX = textX - textWidth - padding;
641+
}
642+
annotations.push(
643+
<g key={annotationKey}>
644+
<rect
645+
x={rectX}
646+
y={textY - fontSize}
647+
width={textWidth + padding * 2}
648+
height={fontSize + padding}
649+
fill={backgroundColor || 'transparent'}
650+
stroke={borderColor}
651+
strokeWidth={borderColor ? 1 : 0}
652+
rx={2}
653+
ry={2}
654+
/>
655+
<text
656+
x={textX}
657+
y={textY - padding / 2}
658+
textAnchor={textAnchor}
659+
fontSize={fontSize}
660+
fontWeight={fontWeight}
661+
fill={textColor}
662+
role="img"
663+
aria-label={annotation.ariaLabel || annotation.text}
664+
>
665+
{annotation.text}
666+
</text>
667+
</g>,
668+
);
608669
} else {
609-
// Render text
670+
// Render plain text
610671
annotations.push(
611672
<text
612673
key={annotationKey}

packages/charts/react-charts/library/src/components/GanttChart/GanttChart.types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,13 @@ export interface GanttChartAnnotationArrow {
9191
* @default 0
9292
*/
9393
offsetX?: number;
94+
95+
/**
96+
* Offset in pixels for vertical position (ay value from Plotly).
97+
* Negative values mean text is above the arrow point.
98+
* @default 0
99+
*/
100+
offsetY?: number;
94101
}
95102

96103
/**
@@ -105,9 +112,16 @@ export interface GanttChartAnnotation {
105112

106113
/**
107114
* The index of the task row (0-based) where the annotation should appear.
115+
* Used when taskLabel is not provided.
108116
*/
109117
taskIndex: number;
110118

119+
/**
120+
* The label of the task row where the annotation should appear.
121+
* If provided, this takes precedence over taskIndex.
122+
*/
123+
taskLabel?: string;
124+
111125
/**
112126
* The date position of the annotation on the x-axis.
113127
*/

0 commit comments

Comments
 (0)