diff --git a/change/@fluentui-react-charts-44bddcc6-c2b6-4612-b1cc-b5872295c51a.json b/change/@fluentui-react-charts-44bddcc6-c2b6-4612-b1cc-b5872295c51a.json new file mode 100644 index 00000000000000..365cd70c8dbe85 --- /dev/null +++ b/change/@fluentui-react-charts-44bddcc6-c2b6-4612-b1cc-b5872295c51a.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix: improve LineChart and GaugeChart callout behavior and formatting", + "packageName": "@fluentui/react-charts", + "email": "atisjai@microsoft.com", + "dependentChangeType": "none" +} \ No newline at end of file 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 c23df757987034..bd7931fd758709 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -897,14 +897,22 @@ export const GaugeChart: React_2.FunctionComponent; // @public export interface GaugeChartCalloutData { + chartTitle?: string; chartValue: number; chartValueLabel: string; legend: string; maxValue: number; minValue: number; + segments?: GaugeChartCalloutSegment[]; segmentValues: YValueHover[]; } +// @public +export interface GaugeChartCalloutSegment extends GaugeChartSegment { + end: number; + start: number; +} + // @public export interface GaugeChartProps { calloutProps?: Partial; diff --git a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx index e878b0320fbb2d..6475ab285b2e05 100644 --- a/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx +++ b/packages/charts/react-charts/library/src/components/CommonComponents/CartesianChart.tsx @@ -531,6 +531,12 @@ export const CartesianChart: React.FunctionComponent): void { + if (!event.relatedTarget || !event.currentTarget.contains(event.relatedTarget as Node)) { + _onChartLeave(); + } + } + function _calculateChartMinWidth(): number { // Adding 10px for padding on both sides const labelWidth = _calcMaxLabelWidthWithTransform(_tickLabels) + 10; @@ -746,6 +752,7 @@ export const CartesianChart: React.FunctionComponent { chartContainer.current = rootElem; }} + onBlur={_onChartBlur} onMouseLeave={_onChartLeave} >
diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx index 5b05e8d8d7368c..0c629d6813bd17 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.test.tsx @@ -392,19 +392,27 @@ describe('GaugeChart custom callout', () => { { segments, chartValue: 30, + chartTitle: 'Server tick time', minValue: 10, - maxValue: 110, - onRenderCallout: (calloutData?: GaugeChartCalloutData) => ( -
- {calloutData?.legend}: {calloutData?.chartValue} blocks ({calloutData?.minValue}-{calloutData?.maxValue}) -
- ), + maxValue: 120, + onRenderCallout: (calloutData?: GaugeChartCalloutData) => { + const lastSegment = calloutData?.segments?.at(-1); + return ( +
+ {calloutData?.chartTitle}: {calloutData?.legend}: {calloutData?.chartValue} blocks ({calloutData?.minValue}- + {calloutData?.maxValue}); last range {lastSegment?.start}-{lastSegment?.end} +
+ ); + }, }, () => { const chartSegments = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'path'); fireEvent.mouseOver(chartSegments[0]); - expect(screen.getByTestId('custom-gauge-callout')).toHaveTextContent('Low Risk: 30 blocks (10-110)'); + expect(screen.getByTestId('custom-gauge-callout')).toHaveTextContent( + 'Server tick time: Low Risk: 30 blocks (10-120); last range 77-110', + ); + expect(screen.getByTestId('custom-gauge-callout')).not.toHaveTextContent('Unknown'); }, ); @@ -414,13 +422,14 @@ describe('GaugeChart custom callout', () => { { segments, chartValue: 30, + chartValueFormat: ([value]: [number, number]) => `${value}ms`, onRenderCallout: renderDefaultCallout, }, () => { const chartSegments = screen.getAllByText((content, element) => element!.tagName.toLowerCase() === 'path'); fireEvent.mouseOver(chartSegments[0]); - expect(screen.getByTestId('wrapped-gauge-callout')).toHaveTextContent('Current value is 30/100'); + expect(screen.getByTestId('wrapped-gauge-callout')).toHaveTextContent('Current value is 30ms'); expect(screen.getByTestId('wrapped-gauge-callout')).toHaveTextContent('Low Risk'); }, ); @@ -504,30 +513,33 @@ describe('GaugeChart rendering and behavior tests', () => { it('should render the chart value correctly', () => { const customChartValue = 'Custom chart value'; + const formatMilliseconds = ([value]: [number, number]) => (value === 0 ? 'offline' : `${value}ms`); expect(getChartValueLabel(25, 0, 100)).toBe('25%'); - expect(getChartValueLabel(25, 0, 100, undefined, true)).toBe('25/100'); + expect(getChartValueLabel(25, 0, 100, undefined, true)).toBe('25%'); expect(getChartValueLabel(25, 0, 100, GaugeValueFormat.Percentage)).toBe('25%'); - expect(getChartValueLabel(25, 0, 100, GaugeValueFormat.Percentage, true)).toBe('25/100'); + expect(getChartValueLabel(25, 0, 100, GaugeValueFormat.Percentage, true)).toBe('25%'); expect(getChartValueLabel(25, 0, 100, GaugeValueFormat.Fraction)).toBe('25/100'); - expect(getChartValueLabel(25, 0, 100, GaugeValueFormat.Fraction, true)).toBe('25%'); + expect(getChartValueLabel(25, 0, 100, GaugeValueFormat.Fraction, true)).toBe('25/100'); expect(getChartValueLabel(25, 0, 100, () => customChartValue)).toBe(customChartValue); - expect(getChartValueLabel(25, 0, 100, () => customChartValue, true)).toBe('25/100'); + expect(getChartValueLabel(25, 0, 100, () => customChartValue, true)).toBe(customChartValue); + expect(getChartValueLabel(50, 0, 200, formatMilliseconds, true)).toBe('50ms'); + expect(getChartValueLabel(0, 0, 200, formatMilliseconds, true)).toBe('offline'); expect(getChartValueLabel(125, 100, 200)).toBe('125'); expect(getChartValueLabel(125, 100, 200, undefined, true)).toBe('125'); - expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Percentage)).toBe('125'); - expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Percentage, true)).toBe('125'); + expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Percentage)).toBe('25%'); + expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Percentage, true)).toBe('25%'); - expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Fraction)).toBe('125'); - expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Fraction, true)).toBe('125'); + expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Fraction)).toBe('25/100'); + expect(getChartValueLabel(125, 100, 200, GaugeValueFormat.Fraction, true)).toBe('25/100'); expect(getChartValueLabel(125, 100, 200, () => customChartValue)).toBe(customChartValue); - expect(getChartValueLabel(125, 100, 200, () => customChartValue, true)).toBe('125'); + expect(getChartValueLabel(125, 100, 200, () => customChartValue, true)).toBe(customChartValue); }); }); diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx index e4443925c2c304..ddcf6bd2242cd0 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx @@ -83,23 +83,36 @@ export const getChartValueLabel = ( chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string), forCallout: boolean = false, ): string => { - if (forCallout) { - // When displaying the chart value as a percentage, use fractions in the callout, and vice versa. - // This helps clarify the actual value and avoid repetition. - return minValue !== 0 - ? chartValue.toString() - : chartValueFormat === 'fraction' - ? `${((chartValue / maxValue) * 100).toFixed()}%` - : `${chartValue}/${maxValue}`; + if (typeof chartValueFormat === 'function') { + return chartValueFormat([chartValue - minValue, maxValue - minValue]); } - return typeof chartValueFormat === 'function' - ? chartValueFormat([chartValue - minValue, maxValue - minValue]) - : minValue !== 0 - ? chartValue.toString() - : chartValueFormat === 'fraction' - ? `${chartValue}/${maxValue}` - : `${((chartValue / maxValue) * 100).toFixed()}%`; + if (chartValueFormat === 'percentage') { + return `${(((chartValue - minValue) / (maxValue - minValue)) * 100).toFixed()}%`; + } + + if (chartValueFormat === 'fraction') { + return `${chartValue - minValue}/${maxValue - minValue}`; + } + + return minValue !== 0 ? chartValue.toString() : `${((chartValue / maxValue) * 100).toFixed()}%`; +}; + +const getCalloutSegmentLabel = ( + segment: ExtendedSegment, + minValue: number, + maxValue: number, + variant: GaugeChartVariant | undefined, + chartValueFormat: GaugeChartProps['chartValueFormat'], +): string => { + if (chartValueFormat === 'percentage' || (!chartValueFormat && minValue === 0)) { + const range = maxValue - minValue; + const startPercentage = (((segment.start - minValue) / range) * 100).toFixed(); + const endPercentage = (((segment.end - minValue) / range) * 100).toFixed(); + return `${startPercentage}% - ${endPercentage}%`; + } + + return getSegmentLabel(segment, minValue, maxValue, variant); }; interface YValue extends Omit { @@ -396,7 +409,7 @@ export const GaugeChart: React.FunctionComponent = React.forwar .map(segment => { const yValue: YValue = { legend: segment.legend, - y: getSegmentLabel(segment, _minValue, _maxValue, props.variant), + y: getCalloutSegmentLabel(segment, _minValue, _maxValue, props.variant, props.chartValueFormat), color: segment.color, }; return yValue; @@ -726,6 +739,7 @@ export const GaugeChart: React.FunctionComponent = React.forwar (() => { const calloutData: GaugeChartCalloutData = { legend: calloutLegend, + chartTitle: props.chartTitle, chartValue: props.chartValue, minValue: _minValue, maxValue: _maxValue, @@ -736,6 +750,7 @@ export const GaugeChart: React.FunctionComponent = React.forwar props.chartValueFormat, true, ), + segments: _segments.slice(0, props.segments.length).map(segment => ({ ...segment })), segmentValues: hoverYValues, }; diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts index 1b5c41c4c27ba2..9c1f5eb202ef1e 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts @@ -46,6 +46,22 @@ export type GaugeValueFormat = 'percentage' | 'fraction'; */ export type GaugeChartVariant = 'single-segment' | 'multiple-segments'; +/** + * GaugeChart segment data with its calculated range. + * {@docCategory GaugeChart} + */ +export interface GaugeChartCalloutSegment extends GaugeChartSegment { + /** + * Start of the segment range. + */ + start: number; + + /** + * End of the segment range. + */ + end: number; +} + /** * Data provided to a custom GaugeChart callout renderer. * {@docCategory GaugeChart} @@ -56,6 +72,11 @@ export interface GaugeChartCalloutData { */ legend: string; + /** + * Title of the gauge. + */ + chartTitle?: string; + /** * Current value of the gauge. */ @@ -76,6 +97,11 @@ export interface GaugeChartCalloutData { */ chartValueLabel: string; + /** + * Gauge segments with their calculated ranges. + */ + segments?: GaugeChartCalloutSegment[]; + /** * Segment values displayed in the default callout. */ @@ -141,6 +167,8 @@ export interface GaugeChartProps { /** * Format of the chart value + * A custom formatter applies to the chart value and the current value in the callout. + * Use `onRenderCallout` to customize units for segment ranges. * @defaultvalue GaugeValueFormat.Percentage */ chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string); diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap index 7a592e63f99605..35d0e9c27ea6e1 100644 --- a/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/GaugeChart/__snapshots__/GaugeChart.test.tsx.snap @@ -953,7 +953,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = ` data-is-focusable="false" role="text" > - Current value is 30/100 + Current value is 30%
@@ -964,7 +964,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
@@ -977,7 +977,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
- 0 - 33 + 0% - 33%
@@ -990,7 +990,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
@@ -1003,7 +1003,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
- 33 - 67 + 33% - 67%
@@ -1016,7 +1016,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
@@ -1029,7 +1029,7 @@ exports[`Gauge chart interactions Should hide callout on mouse leave 1`] = `
- 67 - 100 + 67% - 100%
@@ -1684,7 +1684,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = ` data-is-focusable="false" role="text" > - Current value is 30/100 + Current value is 30%
@@ -1695,7 +1695,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
@@ -1708,7 +1708,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
- 0 - 33 + 0% - 33%
@@ -1721,7 +1721,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
@@ -1734,7 +1734,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
- 33 - 67 + 33% - 67%
@@ -1747,7 +1747,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
@@ -1760,7 +1760,7 @@ exports[`Gauge chart interactions Should show callout on focus 1`] = `
- 67 - 100 + 67% - 100%
@@ -1999,7 +1999,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = ` data-is-focusable="false" role="text" > - Current value is 30/100 + Current value is 30%
@@ -2010,7 +2010,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
@@ -2023,7 +2023,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
- 0 - 33 + 0% - 33%
@@ -2036,7 +2036,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
@@ -2049,7 +2049,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
- 33 - 67 + 33% - 67%
@@ -2062,7 +2062,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
@@ -2075,7 +2075,7 @@ exports[`Gauge chart interactions Should show callout on mouse over 1`] = `
- 67 - 100 + 67% - 100%
diff --git a/packages/charts/react-charts/library/src/components/LineChart/LineChart.test.tsx b/packages/charts/react-charts/library/src/components/LineChart/LineChart.test.tsx index 717a2acaa42f6f..197299d9809cc8 100644 --- a/packages/charts/react-charts/library/src/components/LineChart/LineChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/LineChart/LineChart.test.tsx @@ -1016,4 +1016,54 @@ describe('LineChart - mouse events', () => { expect(container.querySelector('pre')).toBeDefined(); expect(container).toMatchSnapshot(); }); + + it('Should dismiss the callout when focus leaves the chart', () => { + const { container } = render( + <> + + + , + { container: root! }, + ); + const dataPoint = container.querySelector('[id^="circle"][tabindex="0"]'); + + expect(dataPoint).not.toBeNull(); + fireEvent.focus(dataPoint!); + expect(getByClass(container, /calloutContentRoot/i)).toHaveLength(1); + + fireEvent.blur(dataPoint!, { relatedTarget: screen.getByTestId('before-chart') }); + expect(getByClass(container, /calloutContentRoot/i)).toHaveLength(0); + }); + + it('Should keep the callout open when focus moves within the chart', () => { + const { container } = render(, { container: root! }); + const dataPoints = container.querySelectorAll('[id^="circle"][tabindex="0"]'); + + expect(dataPoints.length).toBeGreaterThan(1); + fireEvent.focus(dataPoints[0]); + expect(getByClass(container, /calloutContentRoot/i)).toHaveLength(1); + + fireEvent.blur(dataPoints[0], { relatedTarget: dataPoints[1] }); + expect(getByClass(container, /calloutContentRoot/i)).toHaveLength(1); + }); + + it('Should keep the callout open when focus moves into custom callout content', () => { + const onRenderCalloutPerDataPoint = () => ; + const { container } = render( + , + { container: root! }, + ); + const dataPoint = container.querySelector('[id^="circle"][tabindex="0"]'); + + expect(dataPoint).not.toBeNull(); + fireEvent.focus(dataPoint!); + const calloutAction = screen.getByTestId('callout-action'); + + fireEvent.blur(dataPoint!, { relatedTarget: calloutAction }); + expect(screen.getByTestId('callout-action')).toBeInTheDocument(); + }); }); diff --git a/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx index 5c71fb65e65f6c..faf33ab7c94614 100644 --- a/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx +++ b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx @@ -14,6 +14,13 @@ const useStyles = makeStyles({ fontSize: tokens.fontSizeBase400, fontWeight: tokens.fontWeightSemibold, }, + calloutTitle: { + fontSize: tokens.fontSizeBase500, + fontWeight: tokens.fontWeightSemibold, + }, + calloutDescription: { + color: tokens.colorNeutralForeground2, + }, }); export const GaugeChartBasic = (): JSXElement => { @@ -63,11 +70,15 @@ export const GaugeChartBasic = (): JSXElement => { return (
- {data.legend} - Risk score: {data.chartValue} - - Range: {data.minValue} to {data.maxValue} - + {data.chartTitle} + Average time required to process one server tick. + Current value: {data.chartValueLabel} + {data.segments?.map((segment, index) => ( + + {segment.legend}: {segment.start} + {index === data.segments.length - 1 ? '+' : `-${segment.end}`} ms + + ))}
); }; @@ -109,11 +120,11 @@ export const GaugeChartBasic = (): JSXElement => { type="range" value={chartValue} min={0} - max={100} + max={200} id="value-slider" onChange={_onValueChange} aria-label="Change Current Value" - aria-valuetext={`current value ${chartValue}', Minimum 0 and Maximum 100`} + aria-valuetext={`current value ${chartValue}, Minimum 0 and Maximum 200`} /> {chartValue}
@@ -152,22 +163,24 @@ export const GaugeChartBasic = (): JSXElement => { height={height} segments={[ { - size: 33, + size: 50, color: getColorFromToken(DataVizPalette.success), - legend: 'Low Risk', + legend: 'Healthy', }, { - size: 34, + size: 100, color: getColorFromToken(DataVizPalette.warning), - legend: 'Medium Risk', + legend: 'Elevated', }, { - size: 33, + size: 50, color: getColorFromToken(DataVizPalette.error), - legend: 'High Risk', + legend: 'Critical', }, ]} + chartTitle="Server tick time" chartValue={chartValue} + chartValueFormat={useCustomCallout ? ([value]) => (value === 0 ? 'offline' : `${value}ms`) : 'percentage'} hideMinMax={hideMinMax} variant={'multiple-segments'} enableGradient={enableGradient}