@@ -363,6 +363,75 @@ export const resolveXAxisPoint = (
363363 return x ;
364364} ;
365365
366+ /**
367+ * Formats text values according to the texttemplate specification
368+ * Supports D3 format specifiers within %{text:format} patterns
369+ * @param textValue The raw text value to format
370+ * @param textTemplate The template string (e.g., "%{text:.1f}%", "%{text:.2%}", "%{text:,.0f}")
371+ * @param index Optional index for array-based templates
372+ * @returns Formatted text string
373+ *
374+ * Examples:
375+ * - "%{text:.1f}%" → Formats number with 1 decimal place and adds % suffix
376+ * - "%{text:.2%}" → Formats as percentage with 2 decimal places
377+ * - "%{text:,.0f}" → Formats with thousands separator and no decimals
378+ * - "%{text:$,.2f}" → Formats as currency with thousands separator and 2 decimals
379+ */
380+ const formatTextWithTemplate = (
381+ textValue : string | number ,
382+ textTemplate ?: string | string [ ] ,
383+ index ?: number ,
384+ ) : string => {
385+ if ( ! textTemplate ) {
386+ return String ( textValue ) ;
387+ }
388+ const numVal = typeof textValue === 'number' ? textValue : parseFloat ( String ( textValue ) ) ;
389+ if ( isNaN ( numVal ) ) {
390+ return String ( textValue ) ;
391+ }
392+ const template = typeof textTemplate === 'string' ? textTemplate : textTemplate [ index || 0 ] || '' ;
393+
394+ // Match Plotly's texttemplate pattern: %{text:format} or %{text}
395+ // Can be followed by any literal text like %, $, etc.
396+ const plotlyPattern = / % \{ t e x t (?: : ( [ ^ } ] + ) ) ? \} ( .* ) $ / ;
397+ const match = template . match ( plotlyPattern ) ;
398+
399+ if ( match ) {
400+ const formatSpec = match [ 1 ] ; // The format specifier (e.g., ".1f", ".2%", ",.0f") or undefined
401+ const suffix = match [ 2 ] ; // Any text after the closing brace (e.g., "%", " units")
402+
403+ // If no format specifier is provided (e.g., %{text}%), try to infer from suffix
404+ if ( ! formatSpec ) {
405+ // Check if suffix starts with % - assume simple percentage with 1 decimal
406+ if ( suffix . startsWith ( '%' ) ) {
407+ return `${ numVal . toFixed ( 1 ) } ${ suffix } ` ;
408+ }
409+ // No format specifier, just return the number with the suffix
410+ return `${ numVal } ${ suffix } ` ;
411+ }
412+
413+ try {
414+ // Use D3 format function to apply the format specifier
415+ const formatter = d3Format ( formatSpec ) ;
416+ const formattedValue = formatter ( numVal ) ;
417+ return `${ formattedValue } ${ suffix } ` ;
418+ } catch ( error ) {
419+ // Try to extract precision for basic fallback
420+ const precisionMatch = formatSpec . match ( / \. ( \d + ) [ f % ] / ) ;
421+ const precision = precisionMatch ? parseInt ( precisionMatch [ 1 ] , 10 ) : 2 ;
422+
423+ // Check if it's a percentage format
424+ if ( formatSpec . includes ( '%' ) ) {
425+ return `${ ( numVal * 100 ) . toFixed ( precision ) } %${ suffix } ` ;
426+ }
427+
428+ return `${ numVal . toFixed ( precision ) } ${ suffix } ` ;
429+ }
430+ }
431+
432+ return String ( textValue ) ;
433+ } ;
434+
366435/**
367436 * Extracts unique X-axis categories from Plotly data traces
368437 * @param data Array of Plotly data traces
@@ -1284,7 +1353,9 @@ export const transformPlotlyJsonToDonutProps = (
12841353 height,
12851354 innerRadius,
12861355 hideLabels,
1287- showLabelsInPercent : firstData . textinfo ? [ 'percent' , 'label+percent' ] . includes ( firstData . textinfo ) : true ,
1356+ showLabelsInPercent : firstData . textinfo
1357+ ? [ 'percent' , 'label+percent' , 'percent+label' ] . includes ( firstData . textinfo )
1358+ : true ,
12881359 roundCorners : true ,
12891360 order : 'sorted' ,
12901361 } ;
@@ -1331,6 +1402,11 @@ export const transformPlotlyJsonToVSBCProps = (
13311402 validXYRanges . forEach ( ( [ rangeStart , rangeEnd ] , rangeIdx ) => {
13321403 const rangeXValues = series . x ! . slice ( rangeStart , rangeEnd ) ;
13331404 const rangeYValues = series . y ! . slice ( rangeStart , rangeEnd ) ;
1405+ const textValues = Array . isArray ( series . text )
1406+ ? series . text . slice ( rangeStart , rangeEnd )
1407+ : typeof series . text === 'string'
1408+ ? series . text
1409+ : undefined ;
13341410
13351411 ( rangeXValues as Datum [ ] ) . forEach ( ( x : string | number , index2 : number ) => {
13361412 if ( ! mapXToDataPoints [ x ] ) {
@@ -1359,12 +1435,19 @@ export const transformPlotlyJsonToVSBCProps = (
13591435 const opacity = getOpacity ( series , index2 ) ;
13601436 const yVal : number | string = rangeYValues [ index2 ] as number | string ;
13611437 const yAxisCalloutData = getFormattedCalloutYData ( yVal , yAxisTickFormat ) ;
1438+ let barLabel = Array . isArray ( textValues ) ? textValues [ index2 ] : textValues ;
1439+
1440+ // Apply texttemplate formatting if specified
1441+ if ( barLabel && series . texttemplate ) {
1442+ barLabel = formatTextWithTemplate ( barLabel , series . texttemplate , index2 ) ;
1443+ }
13621444 if ( series . type === 'bar' ) {
13631445 mapXToDataPoints [ x ] . chartData . push ( {
13641446 legend,
13651447 data : yVal ,
13661448 color : rgb ( color ) . copy ( { opacity } ) . formatHex8 ( ) ?? color ,
13671449 yAxisCalloutData,
1450+ ...( barLabel ? { barLabel : String ( barLabel ) } : { } ) ,
13681451 } ) ;
13691452 if ( typeof yVal === 'number' ) {
13701453 yMaxValue = Math . max ( yMaxValue , yVal ) ;
@@ -1583,12 +1666,20 @@ export const transformPlotlyJsonToGVBCProps = (
15831666 ) ;
15841667 const opacity = getOpacity ( series , xIndex ) ;
15851668 const yVal = series . y ! [ xIndex ] as number ;
1669+ // Extract text value for barLabel
1670+ let barLabel = Array . isArray ( series . text ) ? series . text [ xIndex ] : series . text ;
1671+
1672+ // Apply texttemplate formatting if specified
1673+ if ( barLabel && series . texttemplate ) {
1674+ barLabel = formatTextWithTemplate ( barLabel , series . texttemplate , xIndex ) ;
1675+ }
15861676
15871677 return {
15881678 x : x ! . toString ( ) ,
15891679 y : yVal ,
15901680 yAxisCalloutData : getFormattedCalloutYData ( yVal , yAxisTickFormat ) ,
15911681 color : rgb ( color ) . copy ( { opacity } ) . formatHex8 ( ) ?? color ,
1682+ ...( barLabel ? { barLabel : String ( barLabel ) } : { } ) ,
15921683 } ;
15931684 } )
15941685 . filter ( item => typeof item !== 'undefined' ) ,
@@ -1745,6 +1836,12 @@ export const transformPlotlyJsonToVBCProps = (
17451836 isXString ? bin . length : getBinSize ( bin as Bin < number , number > ) ,
17461837 ) ;
17471838
1839+ // Handle text values and texttemplate formatting for histogram bins
1840+ let barLabel = Array . isArray ( series . text ) ? series . text [ index ] : series . text ;
1841+ if ( barLabel && series . texttemplate ) {
1842+ barLabel = formatTextWithTemplate ( barLabel , series . texttemplate , index ) ;
1843+ }
1844+
17481845 vbcData . push ( {
17491846 x : isXString ? bin . join ( ', ' ) : getBinCenter ( bin as Bin < number , number > ) ,
17501847 y : yVal ,
@@ -1753,6 +1850,7 @@ export const transformPlotlyJsonToVBCProps = (
17531850 ...( isXString
17541851 ? { }
17551852 : { xAxisCalloutData : `[${ ( bin as Bin < number , number > ) . x0 } - ${ ( bin as Bin < number , number > ) . x1 } )` } ) ,
1853+ ...( barLabel ? { barLabel : String ( barLabel ) } : { } ) ,
17561854 } ) ;
17571855 } ) ;
17581856 } ) ;
@@ -2289,6 +2387,53 @@ export const transformPlotlyJsonToHeatmapProps = (
22892387 let zMin = Number . POSITIVE_INFINITY ;
22902388 let zMax = Number . NEGATIVE_INFINITY ;
22912389
2390+ // Build a 2D array of annotations based on their grid position
2391+ const annotationGrid : ( string | undefined ) [ ] [ ] = [ ] ;
2392+ const rawAnnotations = input . layout ?. annotations ;
2393+
2394+ if ( rawAnnotations ) {
2395+ const annotationsArray = Array . isArray ( rawAnnotations ) ? rawAnnotations : [ rawAnnotations ] ;
2396+
2397+ // Collect all unique x and y values from valid annotations
2398+ const xSet = new Set < number > ( ) ;
2399+ const ySet = new Set < number > ( ) ;
2400+ const validAnnotations : Array < { x : number ; y : number ; text : string } > = [ ] ;
2401+
2402+ annotationsArray . forEach ( ( a : PlotlyAnnotation ) => {
2403+ if (
2404+ a &&
2405+ typeof a . x === 'number' &&
2406+ typeof a . y === 'number' &&
2407+ typeof a . text === 'string' &&
2408+ ( a . xref === 'x' || a . xref === undefined ) &&
2409+ ( a . yref === 'y' || a . yref === undefined )
2410+ ) {
2411+ xSet . add ( a . x ) ;
2412+ ySet . add ( a . y ) ;
2413+ validAnnotations . push ( { x : a . x , y : a . y , text : cleanText ( a . text ) } ) ;
2414+ }
2415+ } ) ;
2416+
2417+ if ( validAnnotations . length > 0 ) {
2418+ // Get sorted unique x and y values
2419+ const xValues = Array . from ( xSet ) . sort ( ( a , b ) => a - b ) ;
2420+ const yValues = Array . from ( ySet ) . sort ( ( a , b ) => a - b ) ;
2421+
2422+ // Initialize 2D grid and populate
2423+ validAnnotations . forEach ( annotation => {
2424+ const xIdx = xValues . indexOf ( annotation . x ) ;
2425+ const yIdx = yValues . indexOf ( annotation . y ) ;
2426+ if ( ! annotationGrid [ yIdx ] ) {
2427+ annotationGrid [ yIdx ] = [ ] ;
2428+ }
2429+ annotationGrid [ yIdx ] [ xIdx ] = annotation . text ;
2430+ } ) ;
2431+ }
2432+ }
2433+
2434+ // Helper function to get annotation from 2D grid by index
2435+ const getAnnotationByIndex = ( xIdx : number , yIdx : number ) : string | undefined => annotationGrid [ yIdx ] ?. [ xIdx ] ;
2436+
22922437 if ( firstData . type === 'histogram2d' ) {
22932438 const xValues : ( string | number ) [ ] = [ ] ;
22942439 const yValues : ( string | number ) [ ] = [ ] ;
@@ -2337,11 +2482,13 @@ export const transformPlotlyJsonToHeatmapProps = (
23372482 isYString ? yBin . length : getBinSize ( yBin as Bin < number , number > ) ,
23382483 ) ;
23392484
2485+ const annotationText = getAnnotationByIndex ( xIdx , yIdx ) ;
2486+
23402487 heatmapDataPoints . push ( {
23412488 x : isXString ? xBin . join ( ', ' ) : getBinCenter ( xBin as Bin < number , number > ) ,
23422489 y : isYString ? yBin . join ( ', ' ) : getBinCenter ( yBin as Bin < number , number > ) ,
23432490 value : zVal ,
2344- rectText : zVal ,
2491+ rectText : annotationText || zVal ,
23452492 } ) ;
23462493
23472494 if ( typeof zVal === 'number' ) {
@@ -2351,16 +2498,31 @@ export const transformPlotlyJsonToHeatmapProps = (
23512498 } ) ;
23522499 } ) ;
23532500 } else {
2354- ( firstData . x as Datum [ ] ) ?. forEach ( ( xVal , xIdx : number ) => {
2501+ // If x and y are not provided, generate indices based on z dimensions
2502+ const zArray = firstData . z as number [ ] [ ] ;
2503+ const xValues = firstData . x as Datum [ ] | undefined ;
2504+ const yValues = firstData . y as Datum [ ] | undefined ;
2505+
2506+ // Determine the dimensions from z array
2507+ const yLength = zArray ?. length ?? 0 ;
2508+ const xLength = zArray ?. [ 0 ] ?. length ?? 0 ;
2509+
2510+ // Use provided x/y values or generate indices
2511+ const xData = xValues ?? Array . from ( { length : xLength } , ( _ , i ) => i ) ;
2512+ const yData = yValues ?? Array . from ( { length : yLength } , ( _ , i ) => yLength - 1 - i ) ;
2513+
2514+ xData . forEach ( ( xVal , xIdx : number ) => {
23552515 // eslint-disable-next-line @typescript-eslint/no-explicit-any
2356- firstData . y ?. forEach ( ( yVal : any , yIdx : number ) => {
2357- const zVal = ( firstData . z as number [ ] [ ] ) ?. [ yIdx ] ?. [ xIdx ] ;
2516+ yData . forEach ( ( yVal : any , yIdx : number ) => {
2517+ const zVal = zArray ?. [ yIdx ] ?. [ xIdx ] ;
2518+
2519+ const annotationText = getAnnotationByIndex ( xIdx , yIdx ) ;
23582520
23592521 heatmapDataPoints . push ( {
23602522 x : input . layout ?. xaxis ?. type === 'date' ? ( xVal as Date ) : xVal ?? 0 ,
23612523 y : input . layout ?. yaxis ?. type === 'date' ? ( yVal as Date ) : yVal ,
23622524 value : zVal ,
2363- rectText : zVal ,
2525+ rectText : annotationText || zVal ,
23642526 } ) ;
23652527
23662528 if ( typeof zVal === 'number' ) {
0 commit comments