Skip to content

Commit 29998d3

Browse files
Anush2303Anush
andauthored
fix(react-charting): fix percentage label issue in donut, vbc, vsbc and heatmap chart (#35597)
Co-authored-by: Anush <[email protected]>
1 parent 41605eb commit 29998d3

File tree

9 files changed

+262
-20
lines changed

9 files changed

+262
-20
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 percentage label issue in donut, vbc, vsbc and heatmap chart",
4+
"packageName": "@fluentui/react-charting",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}
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 barlabel issue for stacked bars",
4+
"packageName": "@fluentui/react-charts",
5+
"email": "[email protected]",
6+
"dependentChangeType": "patch"
7+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -970,6 +970,7 @@ export interface IGroupedVerticalBarChartStyles extends ICartesianChartStyles {
970970

971971
// @public (undocumented)
972972
export interface IGVBarChartSeriesPoint {
973+
barLabel?: string;
973974
callOutAccessibilityData?: IAccessibilityProps;
974975
color?: string;
975976
data: number;
@@ -1831,6 +1832,7 @@ export interface ITreeStyles {
18311832

18321833
// @public (undocumented)
18331834
export interface IVerticalBarChartDataPoint {
1835+
barLabel?: string;
18341836
callOutAccessibilityData?: IAccessibilityProps;
18351837
color?: string;
18361838
gradient?: [string, string];
@@ -1961,6 +1963,7 @@ export interface IVerticalStackedChartProps {
19611963

19621964
// @public (undocumented)
19631965
export interface IVSChartDataPoint {
1966+
barLabel?: string;
19641967
callOutAccessibilityData?: IAccessibilityProps;
19651968
color?: string;
19661969
data: number | string;

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

Lines changed: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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 = /%\{text(?::([^}]+))?\}(.*)$/;
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') {

packages/charts/react-charting/src/components/GroupedVerticalBarChart/GroupedVerticalBarChart.base.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,22 @@ export class GroupedVerticalBarChartBase
522522
/>,
523523
);
524524

525+
// Render individual bar label if provided
526+
if (pointData.barLabel && isLegendActive) {
527+
barLabelsForGroup.push(
528+
<text
529+
key={`${singleSet.indexNum}-${legendIndex}-${pointIndex}-label`}
530+
x={xPoint + this._barWidth / 2}
531+
y={pointData.data >= this.Y_ORIGIN ? yPoint - 6 : yPoint + height + 12}
532+
textAnchor="middle"
533+
className={this._classNames.barLabel}
534+
aria-hidden={true}
535+
>
536+
{pointData.barLabel}
537+
</text>,
538+
);
539+
}
540+
525541
barTotalValue += pointData.data;
526542
});
527543
if (barTotalValue !== null && !this.props.hideLabels && this._barWidth >= 16 && isLegendActive) {

packages/charts/react-charting/src/components/VerticalBarChart/VerticalBarChart.base.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -867,7 +867,7 @@ export class VerticalBarChartBase
867867
fill={this.props.enableGradient ? `url(#${gradientId})` : startColor}
868868
rx={this.props.roundCorners ? 3 : 0}
869869
/>
870-
{this._renderBarLabel(xPoint, yPoint, point.y, point.legend!, isHeightNegative)}
870+
{this._renderBarLabel(xPoint, yPoint, point.y, point.legend!, isHeightNegative, point.barLabel)}
871871
</g>
872872
);
873873
});
@@ -981,7 +981,7 @@ export class VerticalBarChartBase
981981
fill={this.props.enableGradient ? `url(#${gradientId})` : startColor}
982982
rx={this.props.roundCorners ? 3 : 0}
983983
/>
984-
{this._renderBarLabel(xPoint, yPoint, point.y, point.legend!, isHeightNegative)}
984+
{this._renderBarLabel(xPoint, yPoint, point.y, point.legend!, isHeightNegative, point.barLabel)}
985985
</g>
986986
);
987987
});
@@ -1093,7 +1093,7 @@ export class VerticalBarChartBase
10931093
fill={this.props.enableGradient ? `url(#${gradientId})` : startColor}
10941094
rx={this.props.roundCorners ? 3 : 0}
10951095
/>
1096-
{this._renderBarLabel(xPoint, yPoint, point.y, point.legend!, isHeightNegative)}
1096+
{this._renderBarLabel(xPoint, yPoint, point.y, point.legend!, isHeightNegative, point.barLabel)}
10971097
</g>
10981098
);
10991099
});
@@ -1269,7 +1269,14 @@ export class VerticalBarChartBase
12691269
);
12701270
};
12711271

1272-
private _renderBarLabel(xPoint: number, yPoint: number, barValue: number, legend: string, isNegativeBar: boolean) {
1272+
private _renderBarLabel(
1273+
xPoint: number,
1274+
yPoint: number,
1275+
barValue: number,
1276+
legend: string,
1277+
isNegativeBar: boolean,
1278+
customBarLabel?: string,
1279+
) {
12731280
if (
12741281
this.props.hideLabels ||
12751282
this._barWidth < 16 ||
@@ -1278,6 +1285,14 @@ export class VerticalBarChartBase
12781285
return null;
12791286
}
12801287

1288+
// Use custom barLabel if provided, otherwise use the formatted barValue
1289+
const displayLabel =
1290+
customBarLabel !== undefined
1291+
? customBarLabel
1292+
: typeof this.props.yAxisTickFormat === 'function'
1293+
? this.props.yAxisTickFormat(barValue)
1294+
: formatScientificLimitWidth(barValue);
1295+
12811296
return (
12821297
<text
12831298
x={xPoint + this._barWidth / 2}
@@ -1286,9 +1301,7 @@ export class VerticalBarChartBase
12861301
className={this._classNames.barLabel}
12871302
aria-hidden={true}
12881303
>
1289-
{typeof this.props.yAxisTickFormat === 'function'
1290-
? this.props.yAxisTickFormat(barValue)
1291-
: formatScientificLimitWidth(barValue)}
1304+
{displayLabel}
12921305
</text>
12931306
);
12941307
}

0 commit comments

Comments
 (0)