Skip to content

Commit 41605eb

Browse files
authored
fix(react-charts): Fixing date related annotation issue (#35577)
1 parent 85918ce commit 41605eb

File tree

3 files changed

+248
-63
lines changed

3 files changed

+248
-63
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "patch",
3+
"comment": "fix(react-charts): Fixing date related annotation issue",
4+
"packageName": "@fluentui/react-charts",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

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

Lines changed: 142 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,46 @@ const toFiniteNumber = (value: unknown): number | undefined => {
539539
return Number.isFinite(numeric) ? numeric : undefined;
540540
};
541541

542+
type AxisRefType = 'axis' | 'relative' | 'pixel' | undefined;
543+
544+
type ParsedAxisRef = {
545+
refType: AxisRefType;
546+
axisId: number;
547+
};
548+
549+
/**
550+
* Parses Plotly axis references (e.g. `x`, `x2`, `xaxis2`, `paper`, `pixel`, `x domain`) into a ref type + axis id.
551+
*/
552+
const parseAxisRef = (ref: string | undefined, axis: 'x' | 'y'): ParsedAxisRef => {
553+
if (!ref) {
554+
return { refType: 'axis', axisId: 1 };
555+
}
556+
557+
const normalized = ref.toLowerCase().trim();
558+
if (normalized === 'pixel') {
559+
return { refType: 'pixel', axisId: 1 };
560+
}
561+
if (normalized === 'paper') {
562+
return { refType: 'relative', axisId: 1 };
563+
}
564+
if (normalized.endsWith(' domain')) {
565+
return normalized.startsWith(axis) ? { refType: 'relative', axisId: 1 } : { refType: undefined, axisId: 1 };
566+
}
567+
568+
const match = normalized.match(/^([xy])(axis)?(\d*)$/);
569+
if (!match || match[1] !== axis) {
570+
return { refType: undefined, axisId: 1 };
571+
}
572+
573+
const suffix = match[3];
574+
if (!suffix || suffix === '1') {
575+
return { refType: 'axis', axisId: 1 };
576+
}
577+
578+
const parsed = Number(suffix);
579+
return { refType: 'axis', axisId: Number.isFinite(parsed) && parsed >= 1 ? parsed : 1 };
580+
};
581+
542582
/**
543583
* Converts Plotly's bottom-origin relative Y coordinate into the SVG top-origin space used by our overlay.
544584
*/
@@ -594,25 +634,17 @@ const appendPx = (value: unknown): string | undefined => {
594634
/**
595635
* Maps Plotly's axis reference string to one of our coordinate interpretation modes (axis, relative, or pixel).
596636
*/
597-
const resolveRefType = (ref: string | undefined, axis: 'x' | 'y'): 'axis' | 'relative' | 'pixel' | undefined => {
637+
const resolveRefType = (ref: string | undefined, axis: 'x' | 'y'): AxisRefType => {
598638
if (!ref) {
599639
return 'axis';
600640
}
601-
const normalized = ref.toLowerCase();
602-
if (normalized === 'pixel') {
603-
return 'pixel';
604-
}
605-
if (normalized === 'paper') {
606-
return 'relative';
607-
}
608-
if (normalized.endsWith(' domain')) {
609-
return normalized.startsWith(axis) ? 'relative' : undefined;
641+
const parsed = parseAxisRef(ref, axis);
642+
if (parsed.refType !== 'axis') {
643+
return parsed.refType;
610644
}
645+
const normalized = (ref ?? '').toLowerCase().trim();
611646
const match = normalized.match(/^([xy])(\d*)$/);
612-
if (match && match[1] === axis) {
613-
return 'axis';
614-
}
615-
return undefined;
647+
return match && match[1] === axis ? 'axis' : undefined;
616648
};
617649

618650
/**
@@ -627,68 +659,42 @@ const getAxisLayoutByRef = (
627659
return undefined;
628660
}
629661
const defaultAxisKey = `${axis}axis` as 'xaxis' | 'yaxis';
630-
if (!ref) {
631-
return layout[defaultAxisKey];
632-
}
633-
const normalized = ref.toLowerCase();
634-
if (normalized === 'paper' || normalized === 'pixel' || normalized.endsWith(' domain')) {
635-
return layout[defaultAxisKey];
636-
}
637-
const match = normalized.match(/^([xy])(\d*)$/);
638-
if (match && match[1] === axis) {
639-
const index = match[2];
640-
if (index && index !== '' && index !== '1') {
641-
const axisKey = `${axis}axis${index}` as keyof Layout;
642-
return layout[axisKey] as Partial<LayoutAxis> | undefined;
643-
}
662+
const { refType, axisId } = parseAxisRef(ref, axis);
663+
664+
if (refType !== 'axis' || axisId === 1) {
644665
return layout[defaultAxisKey];
645666
}
646-
return layout[defaultAxisKey];
667+
668+
const axisKey = `${axis}axis${axisId}` as keyof Layout;
669+
return layout[axisKey] as Partial<LayoutAxis> | undefined;
647670
};
648671

649-
/**
650-
* Normalizes raw Plotly data values into canonical number/date/string types based on axis configuration.
651-
*/
652-
const convertDataValue = (
653-
value: unknown,
654-
axisLayout: Partial<LayoutAxis> | undefined,
655-
): string | number | Date | undefined => {
672+
const convertAnnotationDataValue = (value: unknown, axisType: AxisType): string | number | Date | undefined => {
656673
if (value === undefined || value === null) {
657674
return undefined;
658675
}
659676

660-
const axisType = axisLayout?.type;
661-
662677
if (axisType === 'date') {
663678
const dateValue = value instanceof Date ? value : new Date(value as string | number);
664679
return Number.isNaN(dateValue.getTime()) ? undefined : dateValue;
665680
}
666681

667-
if (value instanceof Date) {
668-
return Number.isNaN(value.getTime()) ? undefined : value;
669-
}
670-
671-
if (typeof value === 'number') {
672-
return Number.isFinite(value) ? value : undefined;
673-
}
674-
675682
if (axisType === 'linear' || axisType === 'log') {
683+
if (typeof value === 'number') {
684+
return Number.isFinite(value) ? value : undefined;
685+
}
676686
const numeric = Number(value);
677687
return Number.isFinite(numeric) ? numeric : undefined;
678688
}
679689

680-
if (typeof value === 'string') {
681-
const shouldTryParseDate = axisType === undefined || axisType === '-' || axisType === null;
682-
if (shouldTryParseDate && isDate(value)) {
683-
const parsedDate = new Date(value);
684-
if (!Number.isNaN(parsedDate.getTime()) && parsedDate.getFullYear() >= 1900) {
685-
return parsedDate;
686-
}
687-
}
690+
// For category-like axes, preserve raw strings (and avoid date parsing heuristics).
691+
if (value instanceof Date) {
688692
return value;
689693
}
690-
691-
return value as string | number;
694+
if (typeof value === 'number' || typeof value === 'string') {
695+
return value;
696+
}
697+
return undefined;
692698
};
693699

694700
const createAnnotationId = (text: string, index: number): string => {
@@ -794,7 +800,8 @@ const getAnnotationCoordinateValue = (
794800
const axisRef = (axis === 'x' ? annotation?.xref : annotation?.yref) as string | undefined;
795801
const axisLayout = getAxisLayoutByRef(layout, axisRef, axis);
796802
const rawValue = axis === 'x' ? annotation?.x : annotation?.y;
797-
return convertDataValue(rawValue, axisLayout);
803+
const axisType = (axisLayout?.type as AxisType | undefined) ?? 'category';
804+
return convertAnnotationDataValue(rawValue, axisType);
798805
}
799806

800807
const numericValue = toFiniteNumber(axis === 'x' ? annotation?.x : annotation?.y);
@@ -1051,15 +1058,87 @@ const convertPlotlyAnnotation = (
10511058
};
10521059

10531060
const getChartAnnotationsFromLayout = (
1061+
data: Data[] | undefined,
10541062
layout: Partial<Layout> | undefined,
10551063
isMultiPlot: boolean,
10561064
): ChartAnnotation[] | undefined => {
10571065
if (isMultiPlot || !layout?.annotations) {
10581066
return undefined;
10591067
}
1068+
1069+
// Infer axis types when they are not explicitly set.
1070+
// This is needed so annotation coordinate parsing can correctly treat values as 'date' vs 'category'
1071+
// (for example, bar chart category axes with date-like strings).
1072+
const inferredLayout = (() => {
1073+
if (!data || !isArrayOrTypedArray(data) || data.length === 0) {
1074+
return layout;
1075+
}
1076+
1077+
const valuesByAxisKey = new Map<keyof Layout, Datum[]>();
1078+
const axesExpectingCategories = new Set<keyof Layout>();
1079+
1080+
data.forEach(series => {
1081+
const trace = series as Partial<PlotData>;
1082+
const axisIds = getAxisIds(trace);
1083+
1084+
if (trace.type === 'bar') {
1085+
const categoryAxisLetter = trace.orientation === 'h' ? 'y' : 'x';
1086+
axesExpectingCategories.add(getAxisKey(categoryAxisLetter, axisIds[categoryAxisLetter]));
1087+
}
1088+
1089+
(['x', 'y'] as const).forEach(axLetter => {
1090+
const coords = trace[axLetter];
1091+
if (!coords || !isArrayOrTypedArray(coords)) {
1092+
return;
1093+
}
1094+
1095+
const axisKey = getAxisKey(axLetter, axisIds[axLetter]);
1096+
const existing = valuesByAxisKey.get(axisKey) ?? [];
1097+
(coords as Datum[] | TypedArray).forEach(val => {
1098+
if (!isInvalidValue(val)) {
1099+
existing.push(val as Datum);
1100+
}
1101+
});
1102+
valuesByAxisKey.set(axisKey, existing);
1103+
});
1104+
});
1105+
1106+
let nextLayout: Partial<Layout> | undefined;
1107+
1108+
valuesByAxisKey.forEach((values, axisKey) => {
1109+
const currentAxis = layout?.[axisKey];
1110+
const currentType = currentAxis?.type;
1111+
if (['linear', 'log', 'date', 'category'].includes(currentType ?? '')) {
1112+
return;
1113+
}
1114+
1115+
let inferredType: AxisType | undefined;
1116+
if (axesExpectingCategories.has(axisKey) || isYearArray(values)) {
1117+
inferredType = 'category';
1118+
} else if (isDateArray(values)) {
1119+
inferredType = 'date';
1120+
}
1121+
1122+
if (!inferredType) {
1123+
return;
1124+
}
1125+
1126+
if (!nextLayout) {
1127+
nextLayout = { ...layout };
1128+
}
1129+
1130+
nextLayout[axisKey] = {
1131+
...(currentAxis ?? {}),
1132+
type: inferredType,
1133+
};
1134+
});
1135+
1136+
return nextLayout ?? layout;
1137+
})();
1138+
10601139
const annotationsArray = Array.isArray(layout.annotations) ? layout.annotations : [layout.annotations];
10611140
const converted = annotationsArray
1062-
.map((annotation, index) => convertPlotlyAnnotation(annotation as PlotlyAnnotation, layout, index))
1141+
.map((annotation, index) => convertPlotlyAnnotation(annotation as PlotlyAnnotation, inferredLayout, index))
10631142
.filter((annotation): annotation is ChartAnnotation => annotation !== undefined);
10641143

10651144
return converted.length > 0 ? converted : undefined;
@@ -1154,7 +1233,7 @@ export const transformPlotlyJsonToAnnotationChartProps = (
11541233
_colorwayType: ColorwayType,
11551234
_isDarkTheme?: boolean,
11561235
): AnnotationOnlyChartProps => {
1157-
const annotations = getChartAnnotationsFromLayout(input.layout, isMultiPlot) ?? [];
1236+
const annotations = getChartAnnotationsFromLayout(input.data, input.layout, isMultiPlot) ?? [];
11581237
const titles = getTitles(input.layout);
11591238
const layoutTitle = titles.chartTitle || undefined;
11601239

@@ -1480,7 +1559,7 @@ export const transformPlotlyJsonToVSBCProps = (
14801559
});
14811560

14821561
const vsbcData = Object.values(mapXToDataPoints);
1483-
const annotations = getChartAnnotationsFromLayout(input.layout, isMultiPlot);
1562+
const annotations = getChartAnnotationsFromLayout(input.data, input.layout, isMultiPlot);
14841563

14851564
return {
14861565
data: vsbcData,
@@ -1668,7 +1747,7 @@ export const transformPlotlyJsonToGVBCProps = (
16681747
}
16691748
});
16701749

1671-
const annotations = getChartAnnotationsFromLayout(processedInput.layout, isMultiPlot);
1750+
const annotations = getChartAnnotationsFromLayout(processedInput.data, processedInput.layout, isMultiPlot);
16721751

16731752
return {
16741753
dataV2: gvbcDataV2,
@@ -1789,7 +1868,7 @@ export const transformPlotlyJsonToVBCProps = (
17891868
});
17901869
});
17911870

1792-
const annotations = getChartAnnotationsFromLayout(input.layout, isMultiPlot);
1871+
const annotations = getChartAnnotationsFromLayout(input.data, input.layout, isMultiPlot);
17931872
return {
17941873
data: vbcData,
17951874
width: input.layout?.width,
@@ -2067,7 +2146,7 @@ const transformPlotlyJsonToScatterTraceProps = (
20672146
scatterChartData: [...chartData, ...(lineShape as ScatterChartPoints[])],
20682147
};
20692148

2070-
const annotations = getChartAnnotationsFromLayout(input.layout, isMultiPlot);
2149+
const annotations = getChartAnnotationsFromLayout(input.data, input.layout, isMultiPlot);
20712150

20722151
const commonProps = {
20732152
supportNegativeData: true,

0 commit comments

Comments
 (0)