@@ -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 ( / ^ ( [ x y ] ) ( a x i s ) ? ( \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 ( / ^ ( [ x y ] ) ( \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 ( / ^ ( [ x y ] ) ( \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
694700const 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
10531060const 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