Skip to content
Open
Show file tree
Hide file tree
Changes from all 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": "Add support for donut annotations",
"packageName": "@fluentui/react-charts",
"email": "[email protected]",
"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 @@ -20,6 +20,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 @@ -353,6 +364,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 @@ -758,11 +775,13 @@ export interface DonutChartStyleProps extends CartesianChartStyleProps {

// @public
export interface DonutChartStyles {
annotationLayer?: string;
axisAnnotation?: string;
chart?: string;
chartTitle?: string;
chartWrapper?: string;
legendContainer: string;
plotContainer?: string;
root?: string;
svgTooltip?: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,26 @@ import {
} from './useChartAnnotationLayer.styles';
import { useId } from '@fluentui/react-utilities';
import { tokens } from '@fluentui/react-theme';
import {
normalizePaddingRect,
safeRectValue,
clamp,
applyMinDistanceFromAnchor,
DEFAULT_ANNOTATION_MAX_WIDTH,
DEFAULT_CONNECTOR_FALLBACK_DIRECTION,
DEFAULT_CONNECTOR_MIN_ARROW_CLEARANCE,
resolveRelativeWithPadding,
} from '../../../utilities/annotationUtils';

const DEFAULT_HORIZONTAL_ALIGN = 'center';
const DEFAULT_VERTICAL_ALIGN = 'middle';
const DEFAULT_FOREIGN_OBJECT_WIDTH = 180;
const DEFAULT_FOREIGN_OBJECT_WIDTH = DEFAULT_ANNOTATION_MAX_WIDTH;
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 @@ -254,8 +266,6 @@ const normalizeBandOffset = (
return position;
};

const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));

type AxisCoordinateType = 'data' | 'relative' | 'pixel';

const resolveDataCoordinate = (
Expand Down Expand Up @@ -308,6 +318,41 @@ 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 {
left: paddingLeft,
right: paddingRight,
top: paddingTop,
bottom: paddingBottom,
} = normalizePaddingRect(padding);

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

if (axis === 'x') {
const resolvedX = resolveRelativeWithPadding(value, svgWidth, paddingLeft, paddingRight);
return Number.isFinite(resolvedX) ? resolvedX : paddingLeft + effectiveWidth / 2;
}

const resolvedY = resolveRelativeWithPadding(value, svgHeight, paddingTop, paddingBottom);
return Number.isFinite(resolvedY) ? resolvedY : paddingTop + effectiveHeight / 2;
};

const createMeasurementSignature = (
annotationContentSignature: string,
containerStyle: React.CSSProperties,
Expand Down Expand Up @@ -368,10 +413,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 @@ -493,9 +544,7 @@ export const ChartAnnotationLayer: React.FC<ChartAnnotationLayerProps> = React.m
maxWidth: layout?.maxWidth,
...(hasCustomBackground
? {
backgroundColor: applyOpacityToColor(baseBackgroundColor, backgroundOpacity, {
preserveOriginalOpacity: annotation.style?.opacity === undefined,
}),
backgroundColor: applyOpacityToColor(baseBackgroundColor, backgroundOpacity),
}
: hideDefaultStyles
? {}
Expand Down Expand Up @@ -541,17 +590,34 @@ 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 = safeRectValue(clampRect, 'x');
const clampY = safeRectValue(clampRect, 'y');
const clampWidth = safeRectValue(clampRect, 'width');
const clampHeight = safeRectValue(clampRect, 'height');

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 All @@ -561,28 +627,26 @@ export const ChartAnnotationLayer: React.FC<ChartAnnotationLayerProps> = React.m
if (annotation.connector) {
const startPadding = annotation.connector.startPadding ?? 12;
const endPadding = annotation.connector.endPadding ?? 0;
const minArrowClearance = 6;
const minDistance = Math.max(startPadding + endPadding + minArrowClearance, startPadding);

const dx = displayPoint.x - resolved.anchor.x;
const dy = displayPoint.y - resolved.anchor.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const minDistance = Math.max(startPadding + endPadding + DEFAULT_CONNECTOR_MIN_ARROW_CLEARANCE, startPadding);

if (distance < minDistance) {
const fallbackDirection: AnnotationPoint = { x: 0, y: -1 };
const ux = distance === 0 ? fallbackDirection.x : dx / distance;
const uy = distance === 0 ? fallbackDirection.y : dy / distance;
const desiredDisplayPoint = applyMinDistanceFromAnchor(
resolved.anchor,
displayPoint,
minDistance,
DEFAULT_CONNECTOR_FALLBACK_DIRECTION,
);

const desiredDisplayX = resolved.anchor.x + ux * minDistance;
const desiredDisplayY = resolved.anchor.y + uy * minDistance;
if (desiredDisplayPoint !== displayPoint) {
const desiredDisplayX = desiredDisplayPoint.x;
const desiredDisplayY = desiredDisplayPoint.y;

let desiredTopLeftX = desiredDisplayX + offsetX;
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 @@ -683,23 +747,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
Loading