diff --git a/change/@fluentui-react-charts-98de7c61-2a77-4e43-96ad-2e5d639cbf59.json b/change/@fluentui-react-charts-98de7c61-2a77-4e43-96ad-2e5d639cbf59.json new file mode 100644 index 00000000000000..7c2853c5b6cd9a --- /dev/null +++ b/change/@fluentui-react-charts-98de7c61-2a77-4e43-96ad-2e5d639cbf59.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add support for donut annotations", + "packageName": "@fluentui/react-charts", + "email": "chartingdev@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/charts/react-charts/library/etc/react-charts.api.md b/packages/charts/react-charts/library/etc/react-charts.api.md index 3a7d51ca324bbc..7411d515182d63 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -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; @@ -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; @@ -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; } diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.tsx b/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.tsx index eb9f628b2f5640..fd53e13f76d2dd 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.tsx +++ b/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.tsx @@ -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)!; @@ -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 = ( @@ -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, @@ -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; @@ -493,9 +544,7 @@ export const ChartAnnotationLayer: React.FC = React.m maxWidth: layout?.maxWidth, ...(hasCustomBackground ? { - backgroundColor: applyOpacityToColor(baseBackgroundColor, backgroundOpacity, { - preserveOriginalOpacity: annotation.style?.opacity === undefined, - }), + backgroundColor: applyOpacityToColor(baseBackgroundColor, backgroundOpacity), } : hideDefaultStyles ? {} @@ -541,17 +590,34 @@ export const ChartAnnotationLayer: React.FC = 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, @@ -561,28 +627,26 @@ export const ChartAnnotationLayer: React.FC = 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; @@ -683,16 +747,15 @@ export const ChartAnnotationLayer: React.FC = 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 = { @@ -700,6 +763,27 @@ export const ChartAnnotationLayer: React.FC = React.m 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, diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.types.ts b/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.types.ts index aa62370b8b1ab7..4e12b00a206128 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.types.ts +++ b/packages/charts/react-charts/library/src/components/CommonComponents/Annotations/ChartAnnotationLayer.types.ts @@ -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 */ diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts index b2dc75e77db0ed..36d95e351a16ac 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapter.ts @@ -654,12 +654,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 = 'axis', +): AxisRefType => { if (!ref) { - return 'axis'; + return defaultRef; } const parsed = parseAxisRef(ref, axis); if (parsed.refType !== 'axis') { @@ -747,8 +762,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; @@ -757,6 +774,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'; } @@ -846,14 +873,15 @@ const getAnnotationCoordinateValue = ( const convertPlotlyAnnotation = ( annotation: PlotlyAnnotation, layout: Partial | undefined, + defaultRefType: Exclude, 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; @@ -922,12 +950,13 @@ const convertPlotlyAnnotation = ( const layoutProps: Partial = {}; const styleProps: Partial = {}; 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); @@ -997,6 +1026,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; @@ -1126,7 +1163,8 @@ const getChartAnnotationsFromLayout = ( }); }); - let nextLayout: Partial | undefined; + let nextLayout: Partial = layout ? { ...layout } : {}; + let didChange = false; valuesByAxisKey.forEach((values, axisKey) => { const currentAxis = layout?.[axisKey]; @@ -1146,22 +1184,26 @@ const getChartAnnotationsFromLayout = ( return; } - if (!nextLayout) { - nextLayout = { ...layout }; - } - nextLayout[axisKey] = { ...(currentAxis ?? {}), type: inferredType, }; + + didChange = true; }); - return nextLayout ?? layout; + return didChange ? nextLayout : layout; })(); + const defaultRefType: Exclude = 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; @@ -1295,6 +1337,7 @@ export const transformPlotlyJsonToDonutProps = ( ): DonutChartProps => { const firstData = input.data[0] as Partial; + 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 @@ -1380,6 +1423,7 @@ export const transformPlotlyJsonToDonutProps = ( chartTitle, chartData: reorderedEntries.map(([, v]) => v as ChartDataPoint), }, + annotations, hideLegend: isMultiPlot || input.layout?.showlegend === false, width: input.layout?.width, height, diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapterUT.test.tsx b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapterUT.test.tsx index 7da43e56578d4b..b2a267ef6f0e61 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapterUT.test.tsx +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/PlotlySchemaAdapterUT.test.tsx @@ -145,6 +145,17 @@ describe('isMonthArray', () => { }); describe('correctYearMonth', () => { + beforeAll(() => { + jest.useFakeTimers(); + // `correctYearMonth` derives year information from the current date. + // Freeze time to keep these expectations stable across calendar years. + jest.setSystemTime(new Date('2026-01-05T00:00:00.000Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + test('Should return dates array when input array contains months data', () => { expect(correctYearMonth([10, 11, 1])).toStrictEqual(['10 01, 2025', '11 01, 2025', '1 01, 2026']); }); @@ -206,6 +217,79 @@ describe('transform Plotly Json To chart Props', () => { ).toMatchSnapshot(); }); + test('transformPlotlyJsonToDonutProps - infers vertical alignment from arrow offsets', () => { + const plotlySchema = { + visualizer: 'plotly', + data: [ + { + type: 'pie' as const, + values: [10, 20], + labels: ['A', 'B'], + }, + ], + layout: { + annotations: [ + { + text: 'Above', + xref: 'paper' as const, + yref: 'paper' as const, + x: 0.5, + y: 1, + showarrow: true, + ax: 0, + ay: -40, + }, + { + text: 'Below', + xref: 'paper' as const, + yref: 'paper' as const, + x: 0.5, + y: 0, + showarrow: true, + ax: 0, + ay: 40, + }, + ], + }, + frames: [] as never[], + }; + + const result = transformPlotlyJsonToDonutProps(plotlySchema, false, { current: new Map() }, 'default', false); + expect(result.annotations).toHaveLength(2); + expect(result.annotations?.[0].layout?.verticalAlign).toBe('bottom'); + expect(result.annotations?.[1].layout?.verticalAlign).toBe('top'); + }); + + test('transformPlotlyJsonToDonutProps - defaults to end arrow when showarrow is true', () => { + const plotlySchema = { + visualizer: 'plotly', + data: [ + { + type: 'pie' as const, + values: [100], + labels: ['Only'], + }, + ], + layout: { + annotations: [ + { + text: 'Default arrow', + xref: 'paper' as const, + yref: 'paper' as const, + x: 0.5, + y: 0.5, + showarrow: true, + }, + ], + }, + frames: [] as never[], + }; + + const result = transformPlotlyJsonToDonutProps(plotlySchema, false, { current: new Map() }, 'default', false); + expect(result.annotations).toHaveLength(1); + expect(result.annotations?.[0].connector?.arrow).toBe('end'); + }); + test('transformPlotlyJsonToVSBCProps - Should return VSBC props', () => { const plotlySchema = require('./tests/schema/fluent_verticalstackedbarchart_test.json'); expect( @@ -315,7 +399,9 @@ describe('transform Plotly Json To chart Props', () => { textColor: '#ffffff', fontSize: '12px', }); - expect(relative?.layout).toBeUndefined(); + expect(relative?.layout).toEqual({ + clipToBounds: false, + }); expect(relative?.connector).toBeUndefined(); expect(pixel).toBeDefined(); @@ -325,8 +411,10 @@ describe('transform Plotly Json To chart Props', () => { y: 40, }); expect(pixel?.layout).toEqual({ + clipToBounds: false, offsetX: 15, offsetY: 12, + verticalAlign: 'top', }); expect(pixel?.style).toEqual({ textColor: '#111111', @@ -351,7 +439,9 @@ describe('transform Plotly Json To chart Props', () => { textColor: '#222222', fontSize: '12px', }); - expect(domain?.layout).toBeUndefined(); + expect(domain?.layout).toEqual({ + clipToBounds: false, + }); expect(domain?.connector).toBeUndefined(); }); diff --git a/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap b/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap index d7ee0661ff7740..fe753a05d74c3f 100644 --- a/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/DeclarativeChart/__snapshots__/PlotlySchemaAdapterUT.test.tsx.snap @@ -83,6 +83,7 @@ Object { exports[`transform Plotly Json To chart Props transformPlotlyJsonToDonutProps - Should return donut chart props 1`] = ` Object { + "annotations": Array [], "data": Object { "chartData": Array [ Object { @@ -211,6 +212,7 @@ Object { exports[`transform Plotly Json To chart Props transformPlotlyJsonToDonutProps - Should return pie chart props 1`] = ` Object { + "annotations": Array [], "data": Object { "chartData": Array [ Object { diff --git a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.test.tsx b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.test.tsx index 8b72aece919654..8c280d609a3013 100644 --- a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.test.tsx @@ -1,11 +1,13 @@ import { render, screen, queryAllByAttribute, fireEvent, act } from '@testing-library/react'; import '@testing-library/jest-dom'; import { ChartDataPoint, ChartProps, DonutChart } from './index'; +import { __donutChartInternals } from './DonutChart'; import * as React from 'react'; import { FluentProvider } from '@fluentui/react-provider'; import * as utils from '../../utilities/utilities'; import { axe, toHaveNoViolations } from 'jest-axe'; import { chartPointsDC, chartPointsDCElevateMinimums, pointsDC } from '../../utilities/test-data'; +import type { ChartAnnotation } from '../../types/ChartAnnotation'; expect.extend(toHaveNoViolations); @@ -180,6 +182,178 @@ describe('Donut chart interactions', () => { }); }); + describe('Donut chart viewport layout', () => { + const { resolveDonutViewportLayout } = __donutChartInternals; + const baseWidth = 400; + const baseHeight = 320; + + it('adds padding when viewport annotations overflow due to offsets', () => { + const annotations: ChartAnnotation[] = [ + { + text: 'Offset annotation outside viewport', + coordinates: { type: 'relative', x: 0.5, y: 0.5 }, + layout: { + align: 'center', + verticalAlign: 'middle', + offsetX: -220, + offsetY: -140, + clipToBounds: false, + }, + }, + ]; + + const layout = resolveDonutViewportLayout(annotations, baseWidth, baseHeight, true); + + expect(layout.padding.left).toBeGreaterThan(12); + expect(layout.padding.top).toBeGreaterThan(12); + expect(layout.svgWidth).toBeLessThan(baseWidth); + expect(layout.svgHeight).toBeLessThan(baseHeight); + + const defaultLayout = resolveDonutViewportLayout(undefined, baseWidth, baseHeight, true); + expect(layout.outerRadius).toBeLessThan(defaultLayout.outerRadius); + }); + + it('keeps original layout when viewport annotations remain inside bounds', () => { + const annotations: ChartAnnotation[] = [ + { + text: 'Centered annotation', + coordinates: { type: 'relative', x: 0.5, y: 0.5 }, + layout: { + align: 'center', + verticalAlign: 'middle', + clipToBounds: false, + }, + }, + ]; + + const layout = resolveDonutViewportLayout(annotations, baseWidth, baseHeight, true); + + expect(layout.padding.top).toBe(0); + expect(layout.padding.right).toBe(0); + expect(layout.padding.bottom).toBe(0); + expect(layout.padding.left).toBe(0); + expect(layout.svgWidth).toBe(baseWidth); + expect(layout.svgHeight).toBe(baseHeight); + }); + + it('keeps viewport annotations within the viewport bounds after layout adjustments', () => { + const annotations: ChartAnnotation[] = [ + { + text: 'Left edge', + coordinates: { type: 'relative', x: -0.2, y: 0.4 }, + layout: { + align: 'end', + verticalAlign: 'middle', + clipToBounds: false, + }, + }, + { + text: 'Right edge', + coordinates: { type: 'relative', x: 1.25, y: 0.6 }, + layout: { + align: 'start', + verticalAlign: 'middle', + clipToBounds: false, + }, + }, + { + text: 'Top edge', + coordinates: { type: 'relative', x: 0.5, y: -0.1 }, + layout: { + align: 'center', + verticalAlign: 'bottom', + clipToBounds: false, + }, + }, + { + text: 'Bottom edge', + coordinates: { type: 'relative', x: 0.5, y: 1.2 }, + layout: { + align: 'center', + verticalAlign: 'top', + clipToBounds: false, + }, + }, + ]; + + const { container } = render( + , + ); + + const annotationSvg = container.querySelector('[data-chart-annotation-svg="true"]') as SVGElement; + expect(annotationSvg).toBeTruthy(); + + const viewBox = annotationSvg.getAttribute('viewBox'); + expect(viewBox).toBeTruthy(); + const viewBoxParts = viewBox!.split(' '); + expect(viewBoxParts).toHaveLength(4); + const viewportWidth = Number(viewBoxParts[2]); + const viewportHeight = Number(viewBoxParts[3]); + expect(Number.isFinite(viewportWidth)).toBe(true); + expect(Number.isFinite(viewportHeight)).toBe(true); + + const foreignObjects = annotationSvg.querySelectorAll('foreignObject[data-annotation-key]'); + expect(foreignObjects.length).toBe(annotations.length); + + foreignObjects.forEach(foreignObject => { + const x = Number(foreignObject.getAttribute('x')); + const y = Number(foreignObject.getAttribute('y')); + const widthAttr = Number(foreignObject.getAttribute('width')); + const heightAttr = Number(foreignObject.getAttribute('height')); + + expect(Number.isFinite(x)).toBe(true); + expect(Number.isFinite(y)).toBe(true); + expect(Number.isFinite(widthAttr)).toBe(true); + expect(Number.isFinite(heightAttr)).toBe(true); + expect(x).toBeGreaterThanOrEqual(-0.5); + expect(y).toBeGreaterThanOrEqual(-0.5); + expect(x + widthAttr).toBeLessThanOrEqual(viewportWidth + 0.5); + expect(y + heightAttr).toBeLessThanOrEqual(viewportHeight + 0.5); + }); + }); + }); + + test('does not expand annotation viewport when viewport-relative annotations are provided', () => { + const annotations: ChartAnnotation[] = [ + { + text: 'Annotation', + coordinates: { type: 'relative', x: 0.5, y: 0.1 }, + layout: { + verticalAlign: 'bottom', + clipToBounds: false, + }, + }, + ]; + + const { container } = render(); + + const plotContainer = container.querySelector('.fui-donut__plotContainer') as HTMLDivElement; + expect(plotContainer).toBeTruthy(); + + const containerHeight = parseFloat(plotContainer.style.height); + expect(containerHeight).not.toBeGreaterThan(200); + + const chartSvg = container.querySelector('.fui-donut__chart') as SVGElement; + expect(chartSvg).toBeTruthy(); + expect(parseFloat(chartSvg.style.top)).toBeGreaterThan(0); + + const annotationSvg = container.querySelector('[data-chart-annotation-svg="true"]') as SVGElement; + expect(annotationSvg).toBeTruthy(); + const viewBox = annotationSvg.getAttribute('viewBox'); + expect(viewBox).toBeTruthy(); + const viewBoxParts = viewBox!.split(' '); + expect(viewBoxParts).toHaveLength(4); + const viewBoxHeight = Number(viewBoxParts[3]); + expect(viewBoxHeight).not.toBeGreaterThan(200); + }); + test('Should change value inside donut with the legend value on mouseOver legend ', () => { // Mock the implementation of wrapTextInsideDonut as it internally calls a Browser Function like // getComputedTextLength() which will otherwise lead to a crash if mounted diff --git a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx index 1e064b7c69e02b..8dd51de9d3de64 100644 --- a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx +++ b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.tsx @@ -12,15 +12,21 @@ import { getColorFromToken, getNextColor, MIN_DONUT_RADIUS, + useRtl, ChartTitle, - CHART_TITLE_PADDING, } from '../../utilities/index'; import { Legend, Legends } from '../../index'; -import { useId } from '@fluentui/react-utilities'; +import type { LegendContainer } from '../../index'; +import { useId, useMergedRefs } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; -import { ChartPopover } from '../CommonComponents/ChartPopover'; +import { ChartAnnotationLayer, ChartPopover } from '../CommonComponents'; import { useImageExport } from '../../utilities/hooks'; +import { + useDonutAnnotationLayout, + computeAnnotationViewportPadding, + resolveDonutViewportLayout, +} from './useDonutAnnotationLayout'; const MIN_LEGEND_CONTAINER_HEIGHT = 40; @@ -37,10 +43,11 @@ export const DonutChart: React.FunctionComponent = React.forwar props.hideLegend, false, ); + const rootRef = useMergedRefs(_rootElem, forwardedRef); const _uniqText: string = useId('_Pie_'); + const _emptyChartId: string = useId('_DonutChart_empty'); /* eslint-disable @typescript-eslint/no-explicit-any */ let _calloutAnchorPoint: ChartDataPoint | null; - let _emptyChartId: string | null; const legendContainer = React.useRef(null); const prevSize = React.useRef<{ width?: number; height?: number }>({}); @@ -58,6 +65,7 @@ export const DonutChart: React.FunctionComponent = React.forwar const [refSelected, setRefSelected] = React.useState(null); const [isPopoverOpen, setPopoverOpen] = React.useState(false); const prevPropsRef = React.useRef(null); + const _isRTL: boolean = useRtl(); React.useEffect(() => { _fitParentContainer(); @@ -81,50 +89,33 @@ export const DonutChart: React.FunctionComponent = React.forwar prevSize.current.width = props.width; }, [props.width, props.height]); - function _elevateToMinimums(data: ChartDataPoint[]) { - let sumOfData = 0; - const minPercent = 0.01; - const elevatedData: ChartDataPoint[] = []; - data.forEach(item => { - sumOfData += item.data!; - }); - data.forEach(item => { - elevatedData.push( - minPercent * sumOfData > item.data! && item.data! > 0 - ? { - ...item, - data: minPercent * sumOfData, - yAxisCalloutData: - item.yAxisCalloutData === undefined ? item.data!.toLocaleString() : item.yAxisCalloutData, - } - : item, - ); - }); - return elevatedData; - } - function _createLegends(chartData: ChartDataPoint[]): JSXElement { - if (props.order === 'sorted') { - chartData.sort((a: ChartDataPoint, b: ChartDataPoint) => { - return b.data! - a.data!; - }); + function _createLegends(chartData: ChartDataPoint[]): JSXElement | undefined { + if (!chartData || chartData.length === 0) { + return undefined; } - const legendDataItems = chartData.map((point: ChartDataPoint, index: number) => { - const color: string = point.color!; - // mapping data to the format Legends component needs - const legend: Legend = { - title: point.legend!, - color, + + const dataForLegend = + props.order === 'sorted' ? [...chartData].sort((a, b) => (b.data ?? 0) - (a.data ?? 0)) : chartData; + + const legendDataItems: Legend[] = dataForLegend.map((point, index) => { + const legendTitle = point.legend ?? ''; + const resolvedColor = + typeof point.color === 'string' && point.color.length > 0 ? point.color : getNextColor(index, 0); + + return { + title: legendTitle, + color: resolvedColor, hoverAction: () => { _handleChartMouseLeave(); - setActiveLegend(point.legend!); + setActiveLegend(legendTitle); }, onMouseOutAction: () => { setActiveLegend(undefined); }, }; - return legend; }); - const legends = ( + + return ( = React.forwar {...props.legendProps} // eslint-disable-next-line react/jsx-no-bind onChange={_onLegendSelectionChange} - legendRef={_legendsRef} + legendRef={_legendsRef as React.RefObject} /> ); - return legends; } function _onLegendSelectionChange( selectedLegends: string[], @@ -255,17 +245,47 @@ export const DonutChart: React.FunctionComponent = React.forwar } function _addDefaultColors(donutChartDataPoint?: ChartDataPoint[]): ChartDataPoint[] { - return donutChartDataPoint - ? donutChartDataPoint.map((item, index) => { - let defaultColor: string; - if (typeof item.color === 'undefined') { - defaultColor = getNextColor(index, 0); - } else { - defaultColor = getColorFromToken(item.color); - } - return { ...item, defaultColor }; - }) - : []; + if (!donutChartDataPoint || donutChartDataPoint.length === 0) { + return []; + } + + return donutChartDataPoint.map((item, index) => { + const resolvedColor = + typeof item.color === 'string' && item.color.length > 0 + ? getColorFromToken(item.color) + : getNextColor(index, 0); + + return { ...item, color: resolvedColor }; + }); + } + + function _elevateToMinimums(data: ChartDataPoint[]): ChartDataPoint[] { + if (!data || data.length === 0) { + return []; + } + + const minPercent = 0.01; + const sumOfData = data.reduce((sum, point) => sum + (point.data ?? 0), 0); + + if (sumOfData <= 0) { + return data; + } + + const minimumValue = minPercent * sumOfData; + + return data.map(point => { + const value = point.data ?? 0; + + if (value > 0 && value < minimumValue) { + return { + ...point, + data: minimumValue, + yAxisCalloutData: point.yAxisCalloutData === undefined ? value.toLocaleString() : point.yAxisCalloutData, + }; + } + + return point; + }); } /** @@ -288,16 +308,24 @@ export const DonutChart: React.FunctionComponent = React.forwar } if (props.parentRef || _rootElem.current) { const container = props.parentRef ? props.parentRef : _rootElem.current!; - const currentContainerWidth = container.getBoundingClientRect().width; - const currentContainerHeight = - container.getBoundingClientRect().height > legendContainerHeight - ? container.getBoundingClientRect().height - : 200; - const shouldResize = - _width !== currentContainerWidth || _height !== currentContainerHeight - legendContainerHeight; - if (shouldResize) { - setWidth(currentContainerWidth); - setHeight(currentContainerHeight - legendContainerHeight); + const containerRect = container.getBoundingClientRect(); + const measuredWidth = containerRect.width; + const measuredHeight = containerRect.height; + + const nextWidth = measuredWidth > 0 ? measuredWidth : _width ?? 200; + + const measuredAvailableHeight = measuredHeight - legendContainerHeight; + const nextHeightCandidate = measuredAvailableHeight > 0 ? measuredAvailableHeight : undefined; + const fallbackHeight = + _height ?? Math.max((_width ?? 200) - legendContainerHeight, 200 - legendContainerHeight); + const nextHeight = nextHeightCandidate ?? fallbackHeight; + + if (typeof nextWidth === 'number' && nextWidth !== _width) { + setWidth(nextWidth); + } + + if (typeof nextHeight === 'number' && nextHeight !== _height) { + setHeight(nextHeight); } } //}); @@ -305,54 +333,61 @@ export const DonutChart: React.FunctionComponent = React.forwar const { data, hideLegend = false } = props; const points = _addDefaultColors(data?.chartData); + const annotations = props.annotations ?? []; + const hasAnnotations = annotations.length > 0; const classes = useDonutChartStyles(props); const legendBars = _createLegends(points.filter(d => d.data! >= 0)); - const donutMarginHorizontal = props.hideLabels ? 0 : 80; - const donutMarginVertical = props.hideLabels ? 0 : 40; - const titleHeight = data?.chartTitle - ? Math.max( - (typeof props.titleStyles?.titleFont?.size === 'number' ? props.titleStyles.titleFont.size : 13) + - CHART_TITLE_PADDING, - 36, - ) - : 0; - const outerRadius = Math.min(_width! - donutMarginHorizontal, _height! - donutMarginVertical - titleHeight) / 2; + + const { + annotationContext, + plotContainerStyle, + outerRadius: resolvedOuterRadius, + resolvedSvgWidth, + resolvedSvgHeight, + svgStyle: svgPositionStyle, + } = useDonutAnnotationLayout({ + annotations, + width: _width, + height: _height, + hideLabels: props.hideLabels, + isRtl: _isRTL, + }); const chartData = _elevateToMinimums(points); const valueInsideDonut = props.innerRadius! > MIN_DONUT_RADIUS ? _valueInsideDonut(props.valueInsideDonut!, chartData!) : ''; const arrowAttributes = useArrowNavigationGroup({ circular: true, axis: 'horizontal' }); return !_isChartEmpty() ? ( -
{ - _rootElem.current = rootElem; - }} - onMouseLeave={_handleChartMouseLeave} - > +
{props.xAxisAnnotation && ( {props.xAxisAnnotation} )}
- - {!hideLegend && data?.chartTitle && ( - - )} - +
+ + {!hideLegend && data?.chartTitle && ( + + )} = React.forwar showLabelsInPercent={props.showLabelsInPercent} hideLabels={props.hideLabels} /> - - + + {hasAnnotations && annotationContext ? ( + + ) : null} +
= React.forwar )}
) : ( -
+
); }, ); +/** @internal Testing utilities for verifying donut layout behaviour */ +export const __donutChartInternals = { + computeAnnotationViewportPadding, + resolveDonutViewportLayout, +}; + DonutChart.displayName = 'DonutChart'; diff --git a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.types.ts b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.types.ts index 6e847dfc918541..84fadd0b458a12 100644 --- a/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.types.ts +++ b/packages/charts/react-charts/library/src/components/DonutChart/DonutChart.types.ts @@ -157,6 +157,11 @@ export interface DonutChartStyles { * Style for the chart. */ chart?: string; + + /** + * Styles for the element wrapping the svg and overlays for annotation + */ + plotContainer?: string; /** * Style for the legend container. */ @@ -177,6 +182,11 @@ export interface DonutChartStyles { */ chartWrapper?: string; + /** + * Styles applied to the annotation layer root element + */ + annotationLayer?: string; + /** * Style for SVG tooltip text */ diff --git a/packages/charts/react-charts/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap index 883f391aadb1c0..b5e7553096ab1e 100644 --- a/packages/charts/react-charts/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/DonutChart/__snapshots__/DonutChart.test.tsx.snap @@ -9,44 +9,45 @@ exports[`Donut chart interactions Should hide callout on mouse leave 1`] = ` class="fui-donut__chartWrapper" data-tabster="{\\"mover\\":{\\"cyclic\\":true,\\"direction\\":2,\\"memorizeCurrent\\":true}}" > - - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - -
- - - - + +
`; @@ -2418,45 +2431,46 @@ exports[`DonutChart snapShot testing renders hideTooltip correctly 1`] = ` class="fui-donut__chartWrapper" data-tabster="{\\"mover\\":{\\"cyclic\\":true,\\"direction\\":2,\\"memorizeCurrent\\":true}}" > - - - -
- - - -
{ + const fontSize = parseCssSizeToPixels(annotation.style?.fontSize) ?? DEFAULT_VIEWPORT_FONT_SIZE; + const maxWidth = isFiniteNumber(annotation.layout?.maxWidth) + ? Math.max(annotation.layout.maxWidth, fontSize * 2) + : DEFAULT_VIEWPORT_MAX_WIDTH; + + const paddingSides = resolvePaddingSides(annotation.style?.padding ?? DEFAULT_ANNOTATION_PADDING); + const totalHorizontalPadding = paddingSides.left + paddingSides.right; + const totalVerticalPadding = paddingSides.top + paddingSides.bottom; + + const charWidth = Math.max(fontSize * APPROX_CHAR_WIDTH_RATIO, 4); + const approxCharsPerLine = Math.max(Math.floor(maxWidth / charWidth), 1); + + const text = typeof annotation.text === 'string' ? annotation.text : String(annotation.text ?? ''); + const segments = splitTextIntoSegments(text); + let lineCount = 0; + let longestSegmentLength = 0; + + segments.forEach(segment => { + const length = segment.length; + longestSegmentLength = Math.max(longestSegmentLength, length); + if (length === 0) { + lineCount += 1; + return; + } + lineCount += Math.max(1, Math.ceil(length / approxCharsPerLine)); + }); + + if (lineCount === 0) { + lineCount = 1; + } + + const lineHeight = Math.max(fontSize * DEFAULT_LINE_HEIGHT_RATIO, fontSize); + const estimatedContentWidth = Math.min( + maxWidth, + Math.max(charWidth * Math.min(longestSegmentLength || approxCharsPerLine, approxCharsPerLine), fontSize * 2), + ); + + return { + width: Math.max(estimatedContentWidth + totalHorizontalPadding, fontSize * 2 + totalHorizontalPadding), + height: Math.max(lineCount * lineHeight + totalVerticalPadding, fontSize + totalVerticalPadding), + }; +}; + +const isViewportRelativeAnnotation = (annotation: ChartAnnotation): boolean => { + const layout = annotation.layout; + const coordinates = annotation.coordinates; + return !!layout && layout.clipToBounds === false && coordinates?.type === 'relative'; +}; + +const estimateViewportAnnotationOverflow = ( + annotation: ChartAnnotation, + containerWidth: number, + containerHeight: number, + padding: AnnotationViewportPadding, +): AnnotationOverflow | undefined => { + const layout = annotation.layout; + const coordinates = annotation.coordinates; + + if (!layout || layout.clipToBounds !== false || coordinates?.type !== 'relative') { + return undefined; + } + + if ( + !isFiniteNumber(containerWidth) || + containerWidth <= 0 || + !isFiniteNumber(containerHeight) || + containerHeight <= 0 + ) { + return undefined; + } + + if (!isFiniteNumber(coordinates.x) || !isFiniteNumber(coordinates.y)) { + return undefined; + } + + const { width, height } = estimateAnnotationSize(annotation); + + const offsetX = isFiniteNumber(layout.offsetX) ? layout.offsetX : 0; + const offsetY = isFiniteNumber(layout.offsetY) ? layout.offsetY : 0; + + const { + left: paddingLeft, + right: paddingRight, + top: paddingTop, + bottom: paddingBottom, + } = normalizePaddingRect(padding); + + const anchorX = resolveViewportRelativePosition(coordinates.x, containerWidth, paddingLeft, paddingRight); + const anchorY = resolveViewportRelativePosition(coordinates.y, containerHeight, paddingTop, paddingBottom); + + const pointX = anchorX + offsetX; + const pointY = anchorY + offsetY; + + const horizontalAlign = layout.align ?? 'center'; + const verticalAlign = layout.verticalAlign ?? 'middle'; + + const alignOffsetX = horizontalAlign === 'center' ? -width / 2 : horizontalAlign === 'end' ? -width : 0; + const alignOffsetY = verticalAlign === 'middle' ? -height / 2 : verticalAlign === 'bottom' ? -height : 0; + + let topLeftX = pointX + alignOffsetX; + let topLeftY = pointY + alignOffsetY; + + let displayX = topLeftX - alignOffsetX; + let displayY = topLeftY - alignOffsetY; + + if (annotation.connector) { + const startPadding = isFiniteNumber(annotation.connector.startPadding) + ? annotation.connector.startPadding + : DEFAULT_CONNECTOR_START_PADDING; + const endPadding = isFiniteNumber(annotation.connector.endPadding) + ? annotation.connector.endPadding + : DEFAULT_CONNECTOR_END_PADDING; + + const adjustedDisplay = enforceConnectorMinDistance( + { x: anchorX, y: anchorY }, + { x: displayX, y: displayY }, + startPadding, + endPadding, + ); + displayX = adjustedDisplay.x; + displayY = adjustedDisplay.y; + topLeftX = displayX + alignOffsetX; + topLeftY = displayY + alignOffsetY; + } + + const left = topLeftX; + const right = topLeftX + width; + const top = topLeftY; + const bottom = topLeftY + height; + + return { + left: left < 0 ? -left : 0, + right: right > containerWidth ? right - containerWidth : 0, + top: top < 0 ? -top : 0, + bottom: bottom > containerHeight ? bottom - containerHeight : 0, + }; +}; + +const hasViewportAnnotation = (annotations: readonly ChartAnnotation[] | undefined): boolean => { + if (!annotations || annotations.length === 0) { + return false; + } + + return annotations.some(annotation => isViewportRelativeAnnotation(annotation)); +}; + +const computeAnnotationViewportPadding = ( + annotations: readonly ChartAnnotation[] | undefined, + width: number | undefined, + height: number | undefined, + outerRadius: number, +): AnnotationViewportPadding => { + if (!hasViewportAnnotation(annotations)) { + return ZERO_ANNOTATION_VIEWPORT_PADDING; + } + + if ( + !isFiniteNumber(width) || + width <= 0 || + !isFiniteNumber(height) || + height <= 0 || + !isFiniteNumber(outerRadius) || + outerRadius <= 0 + ) { + return ZERO_ANNOTATION_VIEWPORT_PADDING; + } + + let minRelativeX = Number.POSITIVE_INFINITY; + let maxRelativeX = Number.NEGATIVE_INFINITY; + let minRelativeY = Number.POSITIVE_INFINITY; + let maxRelativeY = Number.NEGATIVE_INFINITY; + + annotations?.forEach(annotation => { + const layout = annotation.layout; + const coordinates = annotation.coordinates; + + if (!layout || layout.clipToBounds !== false || coordinates?.type !== 'relative') { + return; + } + + const { x, y } = coordinates; + if (isFiniteNumber(x)) { + minRelativeX = Math.min(minRelativeX, x); + maxRelativeX = Math.max(maxRelativeX, x); + } + if (isFiniteNumber(y)) { + minRelativeY = Math.min(minRelativeY, y); + maxRelativeY = Math.max(maxRelativeY, y); + } + }); + + const hasViewportAnchors = Number.isFinite(minRelativeX) && Number.isFinite(maxRelativeX); + if (!hasViewportAnchors) { + return ZERO_ANNOTATION_VIEWPORT_PADDING; + } + + const clampedDiameter = Math.min(outerRadius * 2, Math.min(width, height)); + const currentVerticalMargin = Math.max((height - clampedDiameter) / 2, 0); + const currentHorizontalMargin = Math.max((width - clampedDiameter) / 2, 0); + + const desiredTopMargin = Math.max(outerRadius * 0.75, 48); + const desiredBottomMargin = Math.max(outerRadius * 0.5, 32); + const desiredHorizontalMargin = Math.max(outerRadius * 0.85, 56); + + const needsTop = minRelativeY < 0; + const needsBottom = maxRelativeY > 1; + const needsLeft = minRelativeX < 0.25; + const needsRight = maxRelativeX > 0.75; + + const extraTop = needsTop ? Math.max(0, desiredTopMargin - currentVerticalMargin) : 0; + const extraBottom = needsBottom ? Math.max(0, desiredBottomMargin - currentVerticalMargin) : 0; + const extraHorizontal = Math.max(0, desiredHorizontalMargin - currentHorizontalMargin); + + const basePadding: AnnotationViewportPadding = { + top: extraTop, + right: needsRight ? extraHorizontal : 0, + bottom: extraBottom, + left: needsLeft ? extraHorizontal : 0, + }; + + let padding: AnnotationViewportPadding = { ...basePadding }; + + for (let iteration = 0; iteration < 4; iteration++) { + const overflows: OverflowRect[] = []; + + annotations?.forEach(annotation => { + const overflow = estimateViewportAnnotationOverflow(annotation, width, height, padding); + if (overflow) { + overflows.push(overflow); + } + }); + + const aggregatedOverflow = aggregateMaxOverflow(overflows); + const overflowPadding = addMarginToOverflow(aggregatedOverflow, ADDITIONAL_MARGIN_SAFETY); + + const nextPadding: AnnotationViewportPadding = maxSides(basePadding, padding, overflowPadding); + + if (hasPaddingConverged(padding, nextPadding)) { + padding = nextPadding; + break; + } + + padding = nextPadding; + } + + if (padding.top < 0.5 && padding.right < 0.5 && padding.bottom < 0.5 && padding.left < 0.5) { + return ZERO_ANNOTATION_VIEWPORT_PADDING; + } + + return applyToAllSides(side => Math.max(padding[side], 0)) as AnnotationViewportPadding; +}; + +const resolveOuterRadius = (width: number, height: number, hideLabels: boolean | undefined): number => { + const donutMarginHorizontal = hideLabels ? 0 : 80; + const donutMarginVertical = hideLabels ? 0 : 40; + const usableWidth = Math.max(width - donutMarginHorizontal, 0); + const usableHeight = Math.max(height - donutMarginVertical, 0); + return Math.max(Math.min(usableWidth, usableHeight) / 2, 0); +}; + +const resolveDonutViewportLayout = ( + annotations: readonly ChartAnnotation[] | undefined, + width: number | undefined, + height: number | undefined, + hideLabels: boolean | undefined, +): DonutViewportLayout => { + if (!isFiniteNumber(width) || width <= 0 || !isFiniteNumber(height) || height <= 0) { + return { + padding: ZERO_ANNOTATION_VIEWPORT_PADDING, + svgWidth: isFiniteNumber(width) ? Math.max(width, 0) : undefined, + svgHeight: isFiniteNumber(height) ? Math.max(height, 0) : undefined, + outerRadius: 0, + }; + } + + const safeWidth = Math.max(width, 0); + const safeHeight = Math.max(height, 0); + let outerRadius = resolveOuterRadius(safeWidth, safeHeight, hideLabels); + + if (!hasViewportAnnotation(annotations)) { + return { + padding: ZERO_ANNOTATION_VIEWPORT_PADDING, + svgWidth: safeWidth, + svgHeight: safeHeight, + outerRadius, + }; + } + + for (let iteration = 0; iteration < 3; iteration++) { + const nextPadding = computeAnnotationViewportPadding(annotations, safeWidth, safeHeight, outerRadius); + const innerWidth = Math.max(safeWidth - nextPadding.left - nextPadding.right, 0); + const innerHeight = Math.max(safeHeight - nextPadding.top - nextPadding.bottom, 0); + const nextOuterRadius = resolveOuterRadius(innerWidth, innerHeight, hideLabels); + + if (Math.abs(nextOuterRadius - outerRadius) < 0.5) { + outerRadius = nextOuterRadius; + break; + } + + outerRadius = nextOuterRadius; + } + + const finalPadding = computeAnnotationViewportPadding(annotations, safeWidth, safeHeight, outerRadius); + const finalSvgWidth = Math.max(safeWidth - finalPadding.left - finalPadding.right, 0); + const finalSvgHeight = Math.max(safeHeight - finalPadding.top - finalPadding.bottom, 0); + const finalOuterRadius = resolveOuterRadius(finalSvgWidth, finalSvgHeight, hideLabels); + + return { + padding: finalPadding, + svgWidth: finalSvgWidth, + svgHeight: finalSvgHeight, + outerRadius: finalOuterRadius, + }; +}; + +const createAnnotationContext = ( + svgWidth: number | undefined, + svgHeight: number | undefined, + outerRadiusValue: number, + padding: AnnotationViewportPadding, + isRtl: boolean, +): ChartAnnotationContext => { + const safeWidth = typeof svgWidth === 'number' && svgWidth > 0 ? svgWidth : 1; + const safeHeight = typeof svgHeight === 'number' && svgHeight > 0 ? svgHeight : 1; + + const { + left: paddingLeft, + right: paddingRight, + top: paddingTop, + bottom: paddingBottom, + } = normalizePaddingRect(padding); + + const fallbackDiameter = Math.max(Math.min(safeWidth, safeHeight), 1); + const safeOuterRadius = typeof outerRadiusValue === 'number' && outerRadiusValue > 0 ? outerRadiusValue : 0; + const desiredDiameter = safeOuterRadius > 0 ? safeOuterRadius * 2 : 0; + const diameter = desiredDiameter > 0 ? Math.min(desiredDiameter, fallbackDiameter) : fallbackDiameter; + const plotWidth = diameter; + const plotHeight = diameter; + const plotX = paddingLeft + (safeWidth - plotWidth) / 2; + const plotY = paddingTop + (safeHeight - plotHeight) / 2; + const svgViewportWidth = safeWidth + paddingLeft + paddingRight; + const svgViewportHeight = safeHeight + paddingTop + paddingBottom; + + return { + plotRect: { + x: plotX, + y: plotY, + width: plotWidth, + height: plotHeight, + }, + svgRect: { + width: svgViewportWidth, + height: svgViewportHeight, + }, + viewportPadding: { + top: paddingTop, + right: paddingRight, + bottom: paddingBottom, + left: paddingLeft, + }, + isRtl, + }; +}; + +const createPlotContainerStyle = ( + width: number | undefined, + height: number | undefined, + resolvedSvgWidth: number, + resolvedSvgHeight: number, + padding: AnnotationViewportPadding, +): React.CSSProperties => { + const style: React.CSSProperties = {}; + const normalizedPadding = normalizePaddingRect(padding); + const totalHorizontalPadding = normalizedPadding.left + normalizedPadding.right; + const totalVerticalPadding = normalizedPadding.top + normalizedPadding.bottom; + + const desiredWidth = Math.max(resolvedSvgWidth + totalHorizontalPadding, 0); + const desiredHeight = Math.max(resolvedSvgHeight + totalVerticalPadding, 0); + + if (isFiniteNumber(width)) { + style.width = Math.max(width, desiredWidth); + } else if (desiredWidth > 0) { + style.width = desiredWidth; + } + + if (isFiniteNumber(height)) { + style.height = Math.max(height, desiredHeight); + } else if (desiredHeight > 0) { + style.height = desiredHeight; + } + + return style; +}; + +export const useDonutAnnotationLayout = ({ + annotations, + width, + height, + hideLabels, + isRtl, +}: UseDonutAnnotationLayoutOptions): UseDonutAnnotationLayoutResult => { + return React.useMemo(() => { + const annotationList = annotations ?? EMPTY_ANNOTATIONS; + const layout = resolveDonutViewportLayout(annotationList, width, height, hideLabels); + const fallbackSvgWidth = isFiniteNumber(width) ? Math.max(width, 0) : 0; + const fallbackSvgHeight = isFiniteNumber(height) ? Math.max(height, 0) : 0; + + const resolvedSvgWidth = isFiniteNumber(layout.svgWidth) ? Math.max(layout.svgWidth, 0) : fallbackSvgWidth; + + const resolvedSvgHeight = isFiniteNumber(layout.svgHeight) ? Math.max(layout.svgHeight, 0) : fallbackSvgHeight; + + const annotationViewportPadding = layout.padding; + const hasAnnotationViewportPadding = + annotationViewportPadding.top > 0 || + annotationViewportPadding.right > 0 || + annotationViewportPadding.bottom > 0 || + annotationViewportPadding.left > 0; + + const normalizedPaddingTop = normalizeViewportPadding(annotationViewportPadding.top); + const normalizedPaddingLeft = normalizeViewportPadding(annotationViewportPadding.left); + + const svgStyle: React.CSSProperties | undefined = hasAnnotationViewportPadding + ? { + position: 'absolute', + top: normalizedPaddingTop, + left: normalizedPaddingLeft, + } + : undefined; + + const annotationContext = + annotationList.length > 0 + ? createAnnotationContext( + resolvedSvgWidth, + resolvedSvgHeight, + layout.outerRadius, + annotationViewportPadding, + isRtl, + ) + : undefined; + + return { + annotationContext, + annotationViewportPadding, + hasAnnotationViewportPadding, + outerRadius: layout.outerRadius, + plotContainerStyle: createPlotContainerStyle( + width, + height, + resolvedSvgWidth, + resolvedSvgHeight, + annotationViewportPadding, + ), + resolvedSvgWidth, + resolvedSvgHeight, + svgStyle, + }; + }, [annotations, width, height, hideLabels, isRtl]); +}; + +export { computeAnnotationViewportPadding, resolveDonutViewportLayout }; diff --git a/packages/charts/react-charts/library/src/components/DonutChart/useDonutChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/DonutChart/useDonutChartStyles.styles.ts index 74c8e663d8b411..a82d6088b2b759 100644 --- a/packages/charts/react-charts/library/src/components/DonutChart/useDonutChartStyles.styles.ts +++ b/packages/charts/react-charts/library/src/components/DonutChart/useDonutChartStyles.styles.ts @@ -12,9 +12,11 @@ import { getAxisTitleStyle, getChartTitleStyles, HighContrastSelector } from '.. export const donutClassNames: SlotClassNames = { root: 'fui-donut__root', chart: 'fui-donut__chart', + plotContainer: 'fui-donut__plotContainer', legendContainer: 'fui-donut__legendContainer', chartWrapper: 'fui-donut__chartWrapper', axisAnnotation: 'fui-donut__axisAnnotation', + annotationLayer: 'fui-donut__annotationLayer', chartTitle: 'fui-donut__chartTitle', svgTooltip: 'fui-donut__svgTooltip', }; @@ -38,10 +40,18 @@ const useStyles = makeStyles({ display: 'block', overflow: 'visible', }, + plotContainer: { + position: 'relative', + width: '100%', + height: '100%', + }, legendContainer: { paddingTop: tokens.spacingVerticalL, width: '100%', }, + annotationLayer: { + pointerEvents: 'none', + }, axisAnnotation: getAxisTitleStyle() as GriffelStyle, chartTitle: getChartTitleStyles() as GriffelStyle, svgTooltip: { @@ -62,12 +72,18 @@ export const useDonutChartStyles = (props: DonutChartProps): DonutChartStyles => return { root: mergeClasses(donutClassNames.root, baseStyles.root, className, props.styles?.root), chart: mergeClasses(donutClassNames.chart, baseStyles.chart, props.styles?.chart), + plotContainer: mergeClasses(donutClassNames.plotContainer, baseStyles.plotContainer, props.styles?.plotContainer), legendContainer: mergeClasses( donutClassNames.legendContainer, baseStyles.legendContainer, props.styles?.legendContainer, ), chartWrapper: mergeClasses(donutClassNames.chartWrapper, props.styles?.chartWrapper), + annotationLayer: mergeClasses( + donutClassNames.annotationLayer, + baseStyles.annotationLayer, + props.styles?.annotationLayer, + ), axisAnnotation: mergeClasses( donutClassNames.axisAnnotation, baseStyles.axisAnnotation, diff --git a/packages/charts/react-charts/library/src/utilities/annotationUtils.ts b/packages/charts/react-charts/library/src/utilities/annotationUtils.ts new file mode 100644 index 00000000000000..6a082f3c1ecc63 --- /dev/null +++ b/packages/charts/react-charts/library/src/utilities/annotationUtils.ts @@ -0,0 +1,363 @@ +/** + * Shared utilities for chart annotations to reduce code duplication + * across ChartAnnotationLayer and useDonutAnnotationLayout + */ + +/** + * Normalizes a single padding value to ensure it's a valid, positive, finite number. + * Returns 0 for any invalid input (undefined, null, negative, NaN, Infinity, etc.) + * + * @param value - The padding value to normalize + * @returns The normalized padding value (>= 0) + */ +export const normalizeViewportPadding = (value: number | undefined): number => + typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; + +/** + * Normalizes all four sides of a padding rect, ensuring each value is valid and positive. + * + * @param padding - Optional padding object with top, right, bottom, left properties + * @returns Normalized padding rect with all four sides as valid numbers + */ +export const normalizePaddingRect = (padding?: { + top?: number; + right?: number; + bottom?: number; + left?: number; +}): { top: number; right: number; bottom: number; left: number } => { + return { + top: normalizeViewportPadding(padding?.top), + right: normalizeViewportPadding(padding?.right), + bottom: normalizeViewportPadding(padding?.bottom), + left: normalizeViewportPadding(padding?.left), + }; +}; + +/** + * Safely extracts a numeric value from a rectangle object, validating it's a finite number. + * Returns the fallback value if the rect is undefined, the property doesn't exist, or is invalid. + * + * @param rect - The rectangle object to extract from + * @param key - The property key to extract ('x', 'y', 'width', or 'height') + * @param fallback - The fallback value if extraction fails (defaults to 0) + * @returns The extracted and validated number, or the fallback value + */ +export const safeRectValue = (rect: any, key: 'x' | 'y' | 'width' | 'height', fallback: number = 0): number => { + return rect && typeof rect[key] === 'number' && Number.isFinite(rect[key]) ? rect[key] : fallback; +}; + +/** + * Clamps a value between min and max bounds + */ +export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); + +/** + * Type guard to check if a value is a finite number + */ +export const isFiniteNumber = (value: number | undefined): value is number => + typeof value === 'number' && Number.isFinite(value); + +/** + * Helper to apply a callback to all four sides of a rect + * Reduces repetitive code for top/right/bottom/left operations + */ +export const applyToAllSides = ( + callback: (side: 'top' | 'right' | 'bottom' | 'left') => T, +): { top: T; right: T; bottom: T; left: T } => ({ + top: callback('top'), + right: callback('right'), + bottom: callback('bottom'), + left: callback('left'), +}); + +// CSS parsing utilities +export const CSS_SIZE_REGEX = /^(-?\d*\.?\d+)(px|em|rem)?$/i; +export const DEFAULT_PADDING_SIDES = Object.freeze({ top: 4, right: 8, bottom: 4, left: 8 }); + +/** + * Parses a CSS size value to pixels + * Supports px, em, rem units (em/rem assumed 16px base) + */ +export const parseCssSizeToPixels = (value: string | undefined): number | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const match = CSS_SIZE_REGEX.exec(value.trim()); + if (!match) { + return undefined; + } + + const numeric = Number.parseFloat(match[1]); + if (!Number.isFinite(numeric)) { + return undefined; + } + + const unit = match[2]?.toLowerCase(); + if (!unit || unit === 'px') { + return numeric; + } + if (unit === 'em' || unit === 'rem') { + return numeric * 16; + } + return undefined; +}; + +/** + * Resolves CSS padding shorthand to individual sides + * Supports 1-4 value syntax like CSS padding + */ +export const resolvePaddingSides = ( + padding: string | undefined, +): { top: number; right: number; bottom: number; left: number } => { + if (typeof padding !== 'string' || padding.trim().length === 0) { + return { ...DEFAULT_PADDING_SIDES }; + } + + const tokens = padding.trim().split(/\s+/); + if (tokens.length === 0 || tokens.length > 4) { + return { ...DEFAULT_PADDING_SIDES }; + } + + const values = tokens.map(token => parseCssSizeToPixels(token)); + if (values.some(value => value === undefined)) { + return { ...DEFAULT_PADDING_SIDES }; + } + + switch (values.length) { + case 1: { + const v = values[0]!; + return { top: v, right: v, bottom: v, left: v }; + } + case 2: { + const [vertical, horizontal] = values as number[]; + return { top: vertical, right: horizontal, bottom: vertical, left: horizontal }; + } + case 3: { + const [top, horizontal, bottom] = values as number[]; + return { top, right: horizontal, bottom, left: horizontal }; + } + case 4: { + const [top, right, bottom, left] = values as number[]; + return { top, right, bottom, left }; + } + default: + return { ...DEFAULT_PADDING_SIDES }; + } +}; + +/** + * Represents overflow/padding on all four sides + */ +export type OverflowRect = { + top: number; + right: number; + bottom: number; + left: number; +}; + +/** + * Aggregates multiple overflow rects by taking maximum for each side + */ +export const aggregateMaxOverflow = (overflows: OverflowRect[]): OverflowRect => { + return overflows.reduce( + (acc, overflow) => ({ + top: Math.max(acc.top, overflow.top || 0), + right: Math.max(acc.right, overflow.right || 0), + bottom: Math.max(acc.bottom, overflow.bottom || 0), + left: Math.max(acc.left, overflow.left || 0), + }), + { top: 0, right: 0, bottom: 0, left: 0 }, + ); +}; + +/** + * Adds margin to non-zero overflow sides + */ +export const addMarginToOverflow = (overflow: OverflowRect, margin: number): OverflowRect => ({ + top: overflow.top > 0 ? overflow.top + margin : 0, + right: overflow.right > 0 ? overflow.right + margin : 0, + bottom: overflow.bottom > 0 ? overflow.bottom + margin : 0, + left: overflow.left > 0 ? overflow.left + margin : 0, +}); + +/** + * Checks if padding has converged between iterations + */ +export const hasPaddingConverged = (prev: OverflowRect, next: OverflowRect, threshold: number = 0.5): boolean => + Math.abs(next.top - prev.top) < threshold && + Math.abs(next.right - prev.right) < threshold && + Math.abs(next.bottom - prev.bottom) < threshold && + Math.abs(next.left - prev.left) < threshold; + +// Text processing utilities +const TEXT_LINE_BREAK_REGEX = /[\r\n]+/; + +/** + * Sanitizes a text segment by removing HTML tags, collapsing whitespace, and trimming + */ +export const sanitizeTextSegment = (segment: string): string => + segment + .replace(/<[^>]+>/g, ' ') // Remove HTML tags + .replace(/\s+/g, ' ') // Collapse whitespace + .trim(); + +/** + * Splits text into segments by line breaks and sanitizes each segment + * Supports custom line break regex and sanitizer function + */ +export const splitTextIntoSegments = ( + text: string, + lineBreakRegex: RegExp = TEXT_LINE_BREAK_REGEX, + sanitizer: (segment: string) => string = sanitizeTextSegment, +): string[] => { + const rawSegments = text.split(lineBreakRegex); + + if (rawSegments.length === 0) { + return ['']; + } + + const sanitized = rawSegments.map(sanitizer); + return sanitized.length > 0 ? sanitized : ['']; +}; + +// Connector utilities +export type Point2D = { x: number; y: number }; +export type Point = Point2D; + +export const CONNECTOR_MIN_ARROW_CLEARANCE = 6; +export const DEFAULT_CONNECTOR_MIN_ARROW_CLEARANCE = 6; +export const CONNECTOR_FALLBACK_DIRECTION: Point2D = Object.freeze({ x: 0, y: -1 }); +export const DEFAULT_CONNECTOR_FALLBACK_DIRECTION = CONNECTOR_FALLBACK_DIRECTION; + +/** + * Enforces minimum distance between anchor and display points for connector rendering + * Moves the display point away from the anchor if they are too close + */ +export const enforceConnectorMinDistance = ( + anchorPoint: Point2D, + displayPoint: Point2D, + startPadding: number, + endPadding: number, + minArrowClearance: number = CONNECTOR_MIN_ARROW_CLEARANCE, + fallbackDirection: Point2D = CONNECTOR_FALLBACK_DIRECTION, +): Point2D => { + const minDistance = Math.max(startPadding + endPadding + minArrowClearance, startPadding); + const dx = displayPoint.x - anchorPoint.x; + const dy = displayPoint.y - anchorPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < minDistance) { + const unitX = distance === 0 ? fallbackDirection.x : dx / distance; + const unitY = distance === 0 ? fallbackDirection.y : dy / distance; + + return { + x: anchorPoint.x + unitX * minDistance, + y: anchorPoint.y + unitY * minDistance, + }; + } + + return displayPoint; +}; + +/** + * Ensures a connector has enough clearance between an anchor point and a display point. + * If the current distance is less than minDistance, returns an adjusted display point. + */ +export const applyMinDistanceFromAnchor = ( + anchor: Point, + displayPoint: Point, + minDistance: number, + fallbackDirection: Point = DEFAULT_CONNECTOR_FALLBACK_DIRECTION, +): Point => { + const dx = displayPoint.x - anchor.x; + const dy = displayPoint.y - anchor.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (!Number.isFinite(distance) || distance >= minDistance) { + return displayPoint; + } + + const ux = distance === 0 ? fallbackDirection.x : dx / distance; + const uy = distance === 0 ? fallbackDirection.y : dy / distance; + + return { + x: anchor.x + ux * minDistance, + y: anchor.y + uy * minDistance, + }; +}; + +// Viewport resolution utilities +/** + * Resolves a relative position value within a padded container + * Returns the absolute position given a relative coordinate (0-1 range) + */ +export const resolveViewportRelativePosition = ( + relativeValue: number, + containerSize: number, + paddingStart: number, + paddingEnd: number, +): number => { + const effectiveSize = Math.max(containerSize - paddingStart - paddingEnd, 0); + + if (!Number.isFinite(relativeValue)) { + return paddingStart + effectiveSize / 2; + } + + if (effectiveSize === 0) { + return paddingStart; + } + + return paddingStart + relativeValue * effectiveSize; +}; + +/** + * Resolves a relative coordinate (0..1) to a pixel coordinate within a padded container. + * If the relative value is invalid, returns the padded center. + */ +export const resolveRelativeWithPadding = ( + relative: number, + totalSize: number, + paddingStart: number, + paddingEnd: number, +): number => { + return resolveViewportRelativePosition(relative, totalSize, paddingStart, paddingEnd); +}; + +/** + * Resolves both x and y relative coordinates within a padded container + */ +export const resolveViewportRelativePoint = ( + relativeX: number, + relativeY: number, + containerWidth: number, + containerHeight: number, + padding: { top: number; right: number; bottom: number; left: number }, +): Point2D => { + return { + x: resolveViewportRelativePosition(relativeX, containerWidth, padding.left, padding.right), + y: resolveViewportRelativePosition(relativeY, containerHeight, padding.top, padding.bottom), + }; +}; + +/** + * Shared constants for annotation layout behavior. + * Keeping these in one place reduces duplication across chart implementations. + */ +export const DEFAULT_ANNOTATION_MAX_WIDTH = 180; + +/** + * Takes the per-side max across multiple rects. + */ +export const maxSides = (...rects: Array | undefined>): OverflowRect => { + return applyToAllSides(side => { + let maxValue = 0; + for (const rect of rects) { + const candidate = rect?.[side]; + if (typeof candidate === 'number' && Number.isFinite(candidate)) { + maxValue = Math.max(maxValue, candidate); + } + } + return maxValue; + }); +}; diff --git a/packages/charts/react-charts/library/src/utilities/index.ts b/packages/charts/react-charts/library/src/utilities/index.ts index 29b1f8e98933a1..d17a0be9fe3a66 100644 --- a/packages/charts/react-charts/library/src/utilities/index.ts +++ b/packages/charts/react-charts/library/src/utilities/index.ts @@ -2,4 +2,5 @@ export * from './utilities'; export * from './colors'; export * from './vbc-utils'; export * from './Common.styles'; +export * from './annotationUtils'; export * from './ChartTitle';