Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a25ae72
Annotations for Donut chart in v9
srmukher Nov 28, 2025
7c3b750
Merge branch 'master' of https://github.com/srmukher/fluentui into us…
srmukher Dec 16, 2025
0acd07c
Adding styles
srmukher Dec 22, 2025
30e6f03
Donut annotations
srmukher Dec 30, 2025
316efb2
Merging master
srmukher Dec 30, 2025
43a60de
Resolving formatting errors
srmukher Dec 30, 2025
58c3b7b
Add change file
srmukher Dec 30, 2025
1877261
Resolving test errors
srmukher Jan 5, 2026
1d2bbad
Merge branch 'users/srmukher/v9_donut_anno' of https://github.com/srm…
srmukher Jan 5, 2026
f2d65f1
Resolving build errors
srmukher Jan 5, 2026
fdf7a39
Removing redundant changes
srmukher Jan 5, 2026
0a02ca0
Resolving tests error
srmukher Jan 5, 2026
1ce689b
Adding change file
srmukher Jan 5, 2026
b3afa36
Merge branch 'master' of https://github.com/srmukher/fluentui into us…
srmukher Jan 6, 2026
3b7be6e
Removing redundant changes
srmukher Jan 6, 2026
c162dd9
Refactoring code
srmukher Jan 8, 2026
0a21a13
resolving formatting issue
srmukher Jan 8, 2026
1672433
Refactoring changes to reduce bundle size
srmukher Jan 12, 2026
df8062d
Merging master
srmukher Jan 12, 2026
94fd8fd
Updating test snapshots
srmukher Jan 12, 2026
c14eaac
Resolve formatting error
srmukher Jan 13, 2026
aaea427
Refactoring code
srmukher Jan 13, 2026
213aa55
Refactor code
srmukher Jan 14, 2026
ce8732a
Merging master
srmukher Jan 14, 2026
b197a48
Merging master
srmukher Jan 14, 2026
686d261
Resolving format errors
srmukher Jan 14, 2026
abdcb2f
Resolves build error
srmukher Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "feat(react-charts): Donut annotations for v9",
"packageName": "@fluentui/react-charts",
"email": "email not defined",
"dependentChangeType": "patch"
}
19 changes: 19 additions & 0 deletions packages/charts/react-charts/library/etc/react-charts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ import { ScaleLinear } from 'd3-scale';
import { SVGProps } from 'react';
import { TimeLocaleDefinition } from 'd3-time-format';

// @internal
export const __donutChartInternals: {
computeAnnotationViewportPadding: (annotations: readonly ChartAnnotation[] | undefined, width: number | undefined, height: number | undefined, outerRadius: number) => AnnotationViewportPadding;
resolveDonutViewportLayout: (annotations: readonly ChartAnnotation[] | undefined, width: number | undefined, height: number | undefined, hideLabels: boolean | undefined) => {
padding: AnnotationViewportPadding;
svgWidth: number | undefined;
svgHeight: number | undefined;
outerRadius: number;
};
};

// @public (undocumented)
export interface AccessibilityProps {
ariaDescribedBy?: string;
Expand Down Expand Up @@ -344,6 +355,12 @@ export interface ChartAnnotationContext {
width: number;
height: number;
};
viewportPadding?: {
top: number;
right: number;
bottom: number;
left: number;
};
xScale?: (value: any) => number;
yScalePrimary?: (value: any) => number;
yScaleSecondary?: (value: any) => number;
Expand Down Expand Up @@ -741,10 +758,12 @@ export interface DonutChartStyleProps extends CartesianChartStyleProps {

// @public
export interface DonutChartStyles {
annotationLayer?: string;
axisAnnotation?: string;
chart?: string;
chartWrapper?: string;
legendContainer: string;
plotContainer?: string;
root?: string;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const DEFAULT_FOREIGN_OBJECT_HEIGHT = 60;
const MIN_ARROW_SIZE = 6;
const MAX_ARROW_SIZE = 24;
const ARROW_SIZE_SCALE = 0.35;
const MIN_CONNECTOR_ARROW_LENGTH = 8;
const CONNECTOR_START_RATIO = 0.4;
const MAX_SIMPLE_MARKUP_DEPTH = 5;
const CHAR_CODE_LESS_THAN = '<'.codePointAt(0)!;
const CHAR_CODE_GREATER_THAN = '>'.codePointAt(0)!;
Expand Down Expand Up @@ -308,6 +310,43 @@ const resolveAxisCoordinate = (
}
};

const resolveViewportRelative = (
axis: 'x' | 'y',
value: number,
context: ChartAnnotationContext,
): number | undefined => {
if (!Number.isFinite(value)) {
return undefined;
}

const svgWidth = context.svgRect.width;
const svgHeight = context.svgRect.height;
if (!Number.isFinite(svgWidth) || !Number.isFinite(svgHeight)) {
return undefined;
}

const padding = context.viewportPadding;
const paddingLeft =
typeof padding?.left === 'number' && Number.isFinite(padding.left) && padding.left > 0 ? padding.left : 0;
const paddingRight =
typeof padding?.right === 'number' && Number.isFinite(padding.right) && padding.right > 0 ? padding.right : 0;
const paddingTop =
typeof padding?.top === 'number' && Number.isFinite(padding.top) && padding.top > 0 ? padding.top : 0;
const paddingBottom =
typeof padding?.bottom === 'number' && Number.isFinite(padding.bottom) && padding.bottom > 0 ? padding.bottom : 0;

const effectiveWidth = Math.max(svgWidth - paddingLeft - paddingRight, 0);
const effectiveHeight = Math.max(svgHeight - paddingTop - paddingBottom, 0);

if (axis === 'x') {
const resolvedX = paddingLeft + value * (effectiveWidth > 0 ? effectiveWidth : 0);
return Number.isFinite(resolvedX) ? resolvedX : paddingLeft + effectiveWidth / 2;
}

const resolvedY = paddingTop + value * (effectiveHeight > 0 ? effectiveHeight : 0);
return Number.isFinite(resolvedY) ? resolvedY : paddingTop + effectiveHeight / 2;
};

const createMeasurementSignature = (
annotationContentSignature: string,
containerStyle: React.CSSProperties,
Expand Down Expand Up @@ -368,10 +407,16 @@ const resolveCoordinates = (
const offsetX = layout?.offsetX ?? 0;
const offsetY = layout?.offsetY ?? 0;

const anchorX = resolveAxisCoordinate('x', descriptor.xType, coordinates.x, context);
const anchorY = resolveAxisCoordinate('y', descriptor.yType, coordinates.y, context, {
yAxis: descriptor.yAxis,
});
const useViewportSpace = layout?.clipToBounds === false;

const anchorX =
useViewportSpace && descriptor.xType === 'relative' && typeof coordinates.x === 'number'
? resolveViewportRelative('x', coordinates.x, context)
: resolveAxisCoordinate('x', descriptor.xType, coordinates.x, context);
const anchorY =
useViewportSpace && descriptor.yType === 'relative' && typeof coordinates.y === 'number'
? resolveViewportRelative('y', coordinates.y, context)
: resolveAxisCoordinate('y', descriptor.yType, coordinates.y, context, { yAxis: descriptor.yAxis });

if (anchorX === undefined || anchorY === undefined) {
return undefined;
Expand Down Expand Up @@ -539,17 +584,36 @@ export const ChartAnnotationLayer: React.FC<ChartAnnotationLayerProps> = React.m
const baseTopLeftX = resolved.point.x + offsetX;
const baseTopLeftY = resolved.point.y + offsetY;

const usePlotBounds = layout?.clipToBounds !== false;
const viewportX = usePlotBounds ? context.plotRect.x : 0;
const viewportY = usePlotBounds ? context.plotRect.y : 0;
const viewportWidth = usePlotBounds ? context.plotRect.width : context.svgRect.width ?? 0;
const viewportHeight = usePlotBounds ? context.plotRect.height : context.svgRect.height ?? 0;
const usesViewportSpace = annotation.coordinates?.type === 'relative' && layout?.clipToBounds === false;
const clampRect = usesViewportSpace
? {
x: 0,
y: 0,
width:
typeof context.svgRect.width === 'number' && Number.isFinite(context.svgRect.width)
? context.svgRect.width
: 0,
height:
typeof context.svgRect.height === 'number' && Number.isFinite(context.svgRect.height)
? context.svgRect.height
: 0,
}
: layout?.clipToBounds !== false
? context.plotRect
: undefined;

const clampX = clampRect && typeof clampRect.x === 'number' && Number.isFinite(clampRect.x) ? clampRect.x : 0;
const clampY = clampRect && typeof clampRect.y === 'number' && Number.isFinite(clampRect.y) ? clampRect.y : 0;
const clampWidth =
clampRect && typeof clampRect.width === 'number' && Number.isFinite(clampRect.width) ? clampRect.width : 0;
const clampHeight =
clampRect && typeof clampRect.height === 'number' && Number.isFinite(clampRect.height) ? clampRect.height : 0;

const maxTopLeftX = viewportWidth > 0 ? viewportX + viewportWidth - width : baseTopLeftX;
const maxTopLeftY = viewportHeight > 0 ? viewportY + viewportHeight - height : baseTopLeftY;
const maxTopLeftX = clampWidth > 0 ? clampX + clampWidth - width : baseTopLeftX;
const maxTopLeftY = clampHeight > 0 ? clampY + clampHeight - height : baseTopLeftY;

let topLeftX = viewportWidth > 0 ? clamp(baseTopLeftX, viewportX, Math.max(viewportX, maxTopLeftX)) : baseTopLeftX;
let topLeftY = viewportHeight > 0 ? clamp(baseTopLeftY, viewportY, Math.max(viewportY, maxTopLeftY)) : baseTopLeftY;
let topLeftX = clampWidth > 0 ? clamp(baseTopLeftX, clampX, Math.max(clampX, maxTopLeftX)) : baseTopLeftX;
let topLeftY = clampHeight > 0 ? clamp(baseTopLeftY, clampY, Math.max(clampY, maxTopLeftY)) : baseTopLeftY;

let displayPoint = {
x: topLeftX - offsetX,
Expand Down Expand Up @@ -578,9 +642,9 @@ export const ChartAnnotationLayer: React.FC<ChartAnnotationLayerProps> = React.m
let desiredTopLeftY = desiredDisplayY + offsetY;

desiredTopLeftX =
viewportWidth > 0 ? clamp(desiredTopLeftX, viewportX, Math.max(viewportX, maxTopLeftX)) : desiredTopLeftX;
clampWidth > 0 ? clamp(desiredTopLeftX, clampX, Math.max(clampX, maxTopLeftX)) : desiredTopLeftX;
desiredTopLeftY =
viewportHeight > 0 ? clamp(desiredTopLeftY, viewportY, Math.max(viewportY, maxTopLeftY)) : desiredTopLeftY;
clampHeight > 0 ? clamp(desiredTopLeftY, clampY, Math.max(clampY, maxTopLeftY)) : desiredTopLeftY;

topLeftX = desiredTopLeftX;
topLeftY = desiredTopLeftY;
Expand Down Expand Up @@ -679,23 +743,43 @@ export const ChartAnnotationLayer: React.FC<ChartAnnotationLayerProps> = React.m
const ux = dx / distance;
const uy = dy / distance;

const sizeBasis = Math.max(1, Math.min(width, height));
const proportionalSize = sizeBasis * ARROW_SIZE_SCALE;
const maxByPadding = startPadding > 0 ? startPadding * 1.25 : MAX_ARROW_SIZE;
const maxByDistance = distance * 0.6;
const markerSize = clamp(proportionalSize, MIN_ARROW_SIZE, Math.min(MAX_ARROW_SIZE, maxByPadding, maxByDistance));
const markerStrokeWidth = clamp(strokeWidth, 1, markerSize / 2);
const availableDistance = Math.max(distance - endPadding, 0);
const startLimitByArrow = availableDistance - MIN_CONNECTOR_ARROW_LENGTH;
const startLimitByRatio = availableDistance * CONNECTOR_START_RATIO;
const startLimit = Math.min(availableDistance, Math.max(startLimitByRatio, startLimitByArrow));
const effectiveStartPadding = availableDistance > 0 ? Math.max(0, Math.min(startPadding, startLimit)) : 0;

const start: AnnotationPoint = {
x: displayPoint.x + ux * startPadding,
y: displayPoint.y + uy * startPadding,
x: displayPoint.x + ux * effectiveStartPadding,
y: displayPoint.y + uy * effectiveStartPadding,
};

const end: AnnotationPoint = {
x: resolved.anchor.x - ux * endPadding,
y: resolved.anchor.y - uy * endPadding,
};

const arrowLength = Math.max(distance - effectiveStartPadding - endPadding, 0);

const sizeBasis = Math.max(1, Math.min(width, height));
const proportionalSize = sizeBasis * ARROW_SIZE_SCALE;
const maxByPadding = startPadding > 0 ? startPadding * 1.25 : MAX_ARROW_SIZE;
const maxByDistance = distance * 0.6;
let markerSize = clamp(proportionalSize, MIN_ARROW_SIZE, Math.min(MAX_ARROW_SIZE, maxByPadding, maxByDistance));

if (arrowLength > 0) {
const markerMinByLength = Math.max(1, Math.min(MIN_ARROW_SIZE, arrowLength));
const markerMaxByLength = Math.max(
markerMinByLength,
Math.min(MAX_ARROW_SIZE, maxByPadding, maxByDistance, arrowLength),
);
markerSize = clamp(markerSize, markerMinByLength, markerMaxByLength);
} else {
markerSize = Math.min(markerSize, MIN_ARROW_SIZE);
}

const markerStrokeWidth = clamp(strokeWidth, 1, markerSize / 2);

connectors.push({
key: `${key}-connector`,
start,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ChartAnnotationContext {
plotRect: AnnotationPlotRect;
/** Size of the owning SVG element */
svgRect: { width: number; height: number };
/** Padding reserved around the viewport (used for donut layout adjustments) */
viewportPadding?: { top: number; right: number; bottom: number; left: number };
/** Indicates if layout should be mirrored */
isRtl?: boolean;
/** Primary x scale mapping data domain to pixels */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -631,12 +631,27 @@ const appendPx = (value: unknown): string | undefined => {
return undefined;
};

const shouldDefaultToRelativeCoordinates = (data: Data[] | undefined): boolean => {
if (!data || data.length === 0) {
return false;
}

return data.every(trace => {
const traceType = typeof trace?.type === 'string' ? trace.type.toLowerCase() : undefined;
return traceType !== undefined && isNonPlotType(traceType);
});
};

/**
* Maps Plotly's axis reference string to one of our coordinate interpretation modes (axis, relative, or pixel).
*/
const resolveRefType = (ref: string | undefined, axis: 'x' | 'y'): AxisRefType => {
const resolveRefType = (
ref: string | undefined,
axis: 'x' | 'y',
defaultRef: Exclude<AxisRefType, undefined> = 'axis',
): AxisRefType => {
if (!ref) {
return 'axis';
return defaultRef;
}
const parsed = parseAxisRef(ref, axis);
if (parsed.refType !== 'axis') {
Expand Down Expand Up @@ -724,8 +739,10 @@ const mapArrowsideToArrow = (annotation: PlotlyAnnotation): ChartAnnotationArrow
includeEnd = arrowSide.includes('end');
}

const endHead = toFiniteNumber(annotation?.arrowhead);
const startHead = toFiniteNumber((annotation as { startarrowhead?: number }).startarrowhead);
const rawEndHead = (annotation as { arrowhead?: number }).arrowhead;
const rawStartHead = (annotation as { startarrowhead?: number }).startarrowhead;
const endHead = toFiniteNumber(rawEndHead);
const startHead = toFiniteNumber(rawStartHead);

if (endHead !== undefined && endHead > 0) {
includeEnd = true;
Expand All @@ -734,6 +751,16 @@ const mapArrowsideToArrow = (annotation: PlotlyAnnotation): ChartAnnotationArrow
includeStart = true;
}

if (!includeStart && !includeEnd) {
const hasExplicitArrowSide = arrowSide !== undefined;
const hasExplicitEndHead = rawEndHead !== undefined;
const hasExplicitStartHead = rawStartHead !== undefined;

if (!hasExplicitArrowSide && !hasExplicitEndHead && !hasExplicitStartHead) {
includeEnd = true;
}
}

if (includeStart && includeEnd) {
return 'both';
}
Expand Down Expand Up @@ -823,14 +850,15 @@ const getAnnotationCoordinateValue = (
const convertPlotlyAnnotation = (
annotation: PlotlyAnnotation,
layout: Partial<Layout> | undefined,
defaultRefType: Exclude<AxisRefType, undefined>,
index: number,
): ChartAnnotation | undefined => {
if (!annotation || (annotation as { visible?: boolean }).visible === false) {
return undefined;
}

const xRefType = resolveRefType(annotation.xref as string | undefined, 'x');
const yRefType = resolveRefType(annotation.yref as string | undefined, 'y');
const xRefType = resolveRefType(annotation.xref as string | undefined, 'x', defaultRefType);
const yRefType = resolveRefType(annotation.yref as string | undefined, 'y', defaultRefType);

if (!xRefType || !yRefType) {
return undefined;
Expand Down Expand Up @@ -899,12 +927,13 @@ const convertPlotlyAnnotation = (
const layoutProps: Partial<ChartAnnotationLayoutProps> = {};
const styleProps: Partial<ChartAnnotationStyleProps> = {};
const showArrow = annotation.showarrow === undefined ? false : !!annotation.showarrow;

const clipOnAxis = (annotation as { cliponaxis?: boolean }).cliponaxis;
if (clipOnAxis !== undefined) {
layoutProps.clipToBounds = !!clipOnAxis;
} else if (coordinates.type === 'data') {
layoutProps.clipToBounds = true;
} else {
layoutProps.clipToBounds = false;
}

const horizontalAlign = mapHorizontalAlign(annotation.xanchor as string | undefined);
Expand Down Expand Up @@ -974,6 +1003,14 @@ const convertPlotlyAnnotation = (
layoutProps.offsetY = DEFAULT_ARROW_OFFSET;
}

if (!layoutProps.verticalAlign && showArrow && ay !== undefined && (ayRef === undefined || ayRef === 'pixel')) {
if (ay < 0) {
layoutProps.verticalAlign = 'bottom';
} else if (ay > 0) {
layoutProps.verticalAlign = 'top';
}
}

const maxWidth = toFiniteNumber(annotation.width);
if (maxWidth !== undefined) {
layoutProps.maxWidth = maxWidth;
Expand Down Expand Up @@ -1136,9 +1173,15 @@ const getChartAnnotationsFromLayout = (
return nextLayout ?? layout;
})();

const defaultRefType: Exclude<AxisRefType, undefined> = shouldDefaultToRelativeCoordinates(data)
? 'relative'
: 'axis';

const annotationsArray = Array.isArray(layout.annotations) ? layout.annotations : [layout.annotations];
const converted = annotationsArray
.map((annotation, index) => convertPlotlyAnnotation(annotation as PlotlyAnnotation, inferredLayout, index))
.map((annotation, index) =>
convertPlotlyAnnotation(annotation as PlotlyAnnotation, inferredLayout, defaultRefType, index),
)
.filter((annotation): annotation is ChartAnnotation => annotation !== undefined);

return converted.length > 0 ? converted : undefined;
Expand Down Expand Up @@ -1272,6 +1315,7 @@ export const transformPlotlyJsonToDonutProps = (
): DonutChartProps => {
const firstData = input.data[0] as Partial<PieData>;

const annotations = getChartAnnotationsFromLayout(input.data, input.layout, isMultiPlot) ?? [];
// extract colors for each series only once
// use piecolorway if available
// otherwise, default to colorway from template
Expand Down Expand Up @@ -1357,6 +1401,7 @@ export const transformPlotlyJsonToDonutProps = (
chartTitle,
chartData: reorderedEntries.map(([, v]) => v as ChartDataPoint),
},
annotations,
hideLegend: isMultiPlot || input.layout?.showlegend === false,
width: input.layout?.width,
height,
Expand Down
Loading
Loading