diff --git a/change/@fluentui-react-charts-d340ad00-e0cf-4529-88fd-dfeab85f9305.json b/change/@fluentui-react-charts-d340ad00-e0cf-4529-88fd-dfeab85f9305.json new file mode 100644 index 00000000000000..a70dbd500e6922 --- /dev/null +++ b/change/@fluentui-react-charts-d340ad00-e0cf-4529-88fd-dfeab85f9305.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "\u0016feat(react-charts) add a variant to horizontal bar charts with annotations", + "packageName": "@fluentui/react-charts", + "email": "aknowles@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 ef598260ed7ac8..e72180c837d62b 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -225,10 +225,11 @@ export interface Chart { } // @public -export type ChartDataMode = 'default' | 'fraction' | 'percentage'; +export type ChartDataMode = 'default' | 'fraction' | 'percentage' | 'hidden'; // @public (undocumented) export interface ChartDataPoint { + annotationInformation?: string[]; callOutAccessibilityData?: AccessibilityProps; color?: string; data?: number; @@ -721,14 +722,18 @@ export const HorizontalBarChart: React_2.FunctionComponent { + allowHoverOnSegment?: boolean; barHeight?: number; calloutProps?: ChartPopoverProps; calloutPropsPerDataPoint?: (dataPointCalloutProps: ChartDataPoint) => ChartPopoverProps; chartDataMode?: ChartDataMode; className?: string; color?: string; + containerWidth?: number; culture?: string; data?: ChartProps[]; + // (undocumented) + displayAnnotationIcon?: (segment: ChartDataPoint, index: number) => React_2.ReactNode; hideLabels?: boolean; hideRatio?: boolean[]; hideTooltip?: boolean; @@ -737,6 +742,7 @@ export interface HorizontalBarChartProps extends React_2.RefAttributes JSX.Element | undefined; + showAnnotationsInPercentage?: boolean; showTriangle?: boolean; styles?: HorizontalBarChartStyles; variant?: HorizontalBarChartVariant; @@ -749,6 +755,7 @@ export interface HorizontalBarChartStyles { barWrapper: string; benchmarkContainer: string; chart: string; + chartAnnotationText: string; chartDataTextDenominator: string; chartTitle: string; chartTitleLeft: string; @@ -1253,6 +1260,13 @@ export interface Schema { plotlySchema: any; } +// @public (undocumented) +export type segment = { + percent: number; + adjustedPercent: number; + rawValue: number; +}; + // @public (undocumented) export const Shape: React_2.FunctionComponent; diff --git a/packages/charts/react-charts/library/src/components/HeatMapChart/__snapshots__/HeatMapChart.test.tsx.snap b/packages/charts/react-charts/library/src/components/HeatMapChart/__snapshots__/HeatMapChart.test.tsx.snap index c3010d83c1a3e9..589591eb1444e5 100644 --- a/packages/charts/react-charts/library/src/components/HeatMapChart/__snapshots__/HeatMapChart.test.tsx.snap +++ b/packages/charts/react-charts/library/src/components/HeatMapChart/__snapshots__/HeatMapChart.test.tsx.snap @@ -52,7 +52,7 @@ Object { fill="currentColor" y="10" > - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -150,7 +150,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -443,7 +443,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -1436,7 +1436,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -1674,7 +1674,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -1971,7 +1971,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -2264,7 +2264,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -2616,7 +2616,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -2909,7 +2909,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -3265,7 +3265,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -3563,7 +3563,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -3919,7 +3919,7 @@ Object { - Mar/01 + Feb/29 - Mar/02 + Mar/01 @@ -4216,7 +4216,7 @@ Object { (''); const [activeLegend, setActiveLegend] = React.useState(''); + const rectRef = React.useRef(null); function _refCallback(element: SVGGElement, legendTitle: string | undefined): void { _refArray.push({ index: legendTitle, refElement: element }); @@ -142,6 +143,12 @@ export const HorizontalBarChart: React.FunctionComponent; + } + const { culture } = props; const accessibilityData = getAccessibleDataObject(data.chartDataAccessibilityData!, 'text', false); if (!isSingleBar) { @@ -156,7 +163,7 @@ export const HorizontalBarChart: React.FunctionComponent ); } - const chartDataMode = props.chartDataMode || 'default'; + const chartData: ChartDataPoint = data!.chartData![0]; const x = chartData.horizontalBarChartdata!.x; const y = chartData.horizontalBarChartdata!.total!; @@ -204,6 +211,169 @@ export const HorizontalBarChart: React.FunctionComponent { + const x = point.horizontalBarChartdata?.x; + return acc + (typeof x === 'number' ? x : 0); + }, 0); + + const totalMarginValue = (totalMarginPercent * totalBarXValue) / 100; + const MIN_WIDTH_PX = 35; // min visible width in pixels + + const containerWidth = props.containerWidth ?? 600; // fallback if unknown + const minSegmentMinWidthPercent = (MIN_WIDTH_PX / containerWidth) * 100; + let minSegmentValueDisplayed = (minSegmentMinWidthPercent / 100) * totalBarXValue; + + // Find the sum of deviations for segments below the minimum value + const underMinDeviations = chartData.reduce((acc, point) => { + const value = point.horizontalBarChartdata?.x ?? 0; + if (value < minSegmentValueDisplayed) { + return acc + (minSegmentValueDisplayed - value); + } + return acc; + }, 0); + + // Find the sum of deviations for segments above the minimum value + const overMinTotalDeviations = chartData.reduce((acc, point) => { + const value = point.horizontalBarChartdata?.x ?? 0; + if (value > minSegmentValueDisplayed) { + return acc + (value - minSegmentValueDisplayed); + } + return acc; + }, 0); + + // Scale the portion of the segments larger than the minimum to reclaim space for the segments less + // than the minimum and the space needed for the margins + let scale = (overMinTotalDeviations - underMinDeviations - totalMarginValue) / overMinTotalDeviations; + + // If the segment count at min. width overflows, fall back to shrinking below the minimum with all segments the same size + if (scale < 0) { + scale = 0; + // Don't let the segments scale below 1 margin worth of space, falling back to allowing horizontal overflow + minSegmentValueDisplayed = Math.max(totalBarXValue - totalMarginValue, totalMarginValue * 2) / noOfSegments; + } + + // Normalize your chart data such that: + // Small segments (value < minSegmentValueDisplayed) are raised to the minimum threshold. + // Large segments (value >= minSegmentValueDisplayed) are scaled down using a scale factor: + + const adjustedSegments = chartData.map(point => { + const originalValue = point.horizontalBarChartdata?.x ?? 0; + // Percent of original + const originalPercent = totalBarXValue > 0 ? (originalValue * 100) / totalBarXValue : 0; + // Adjust value + const adjustedValue = + originalValue < minSegmentValueDisplayed + ? minSegmentValueDisplayed + : (originalValue - minSegmentValueDisplayed) * scale + minSegmentValueDisplayed; + // Adjusted percent + const adjustedPercent = totalBarXValue > 0 ? (adjustedValue * 100) / totalBarXValue : 0; + return { + originalValue, + originalPercent, + adjustedValue, + adjustedPercent, + }; + }); + + let prevPosition = 0; + + const annotatedBars = adjustedSegments!.map((segment, index) => { + const point = chartData![index]; + const color = point.color ?? defaultColors[Math.floor(Math.random() * 4)]; + const xValue = point.horizontalBarChartdata!.x; + const isLegendSelected: boolean = _legendHighlighted(point.legend) || _noLegendHighlighted(); + + segmentStarts.push(prevPosition); + prevPosition += segment.adjustedPercent; + + const barX = _isRTL + ? 100 - segmentStarts[index] - segment.adjustedPercent - index * barSpacingInPercent + : segmentStarts[index] + index * barSpacingInPercent; + + const labelX = barX + segment.adjustedPercent / 2; + const labelY = _barHeight + 12; + + // We want the text offset a bit more for double digits and less for single digits if an icon to be displayed + const offsetForAnnotationValue = props.displayAnnotationIcon ? (segment.adjustedPercent <= 9 ? 1 : 2) : 0; + + const MIN_VISIBLE_WIDTH = 25; // px + // inside your render loop + const segmentWidthPx = (segment.adjustedPercent / 100) * (props?.containerWidth ?? 0); + + return ( + + _hoverOn(event, xValue, point) : undefined + } + onFocus={ + props.allowHoverOnSegment && point.legend !== '' ? event => _hoverOn(event, xValue, point) : undefined + } + role="img" + aria-label={_getAriaLabel(point)} + onBlur={_hoverOff} + onMouseLeave={_hoverOff} + className={classes.barWrapper} + opacity={isLegendSelected ? 1 : 0.1} + tabIndex={point.legend !== '' ? 0 : undefined} + /> + {/* Render the annotation text and icon if the segment is wide enough + or only the text if there is no annotation icon to be displayed*/} + {(segmentWidthPx >= MIN_VISIBLE_WIDTH || !props.displayAnnotationIcon) && ( + + + {segment.originalValue === 0 ? '0%' : `${Math.max(1, Math.round(segment.originalPercent))}%`} + + + {props.displayAnnotationIcon && ( + +
{props.displayAnnotationIcon(point, index)}
+
+ )} +
+ )} + {props.displayAnnotationIcon && segmentWidthPx < MIN_VISIBLE_WIDTH && ( + + {props.displayAnnotationIcon && ( + +
{props.displayAnnotationIcon(point, index)}
+
+ )} +
+ )} +
+ ); + }); + + return annotatedBars; + } + /** * This functions returns an array of elements, which form the bars * For each bar an x value, and a width needs to be specified @@ -230,6 +400,7 @@ export const HorizontalBarChart: React.FunctionComponent ); }); + return bars; } @@ -408,7 +580,7 @@ export const HorizontalBarChart: React.FunctionComponent ChartPopoverProps; + + /** + * prop to show annotations on the chart + */ + showAnnotationsInPercentage?: boolean; + + /** + * show the callout on hover + * @default true + */ + allowHoverOnSegment?: boolean; + + // renderAnnotationAddon?: (index: number) => JSX.Element; + displayAnnotationIcon?: (segment: ChartDataPoint, index: number) => React.ReactNode; + + /** + * Width of the container that holds the chart + * Used to calculate segment widths + */ + containerWidth?: number; } /** @@ -162,6 +182,11 @@ export interface HorizontalBarChartStyles { */ chartDataTextDenominator: string; + /** + * Style for the chart annotation text. + */ + chartAnnotationText: string; + /** * Style for the benchmark container */ @@ -193,9 +218,10 @@ export interface HorizontalBarChartStyles { * default: show the datapoint.x value * fraction: show the fraction of datapoint.x/datapoint.y * percentage: show the percentage of (datapoint.x/datapoint.y)% + * hidden: do not show the chart data text in the top right side of the bars * {@docCategory HorizontalBarChart} */ -export type ChartDataMode = 'default' | 'fraction' | 'percentage'; +export type ChartDataMode = 'default' | 'fraction' | 'percentage' | 'hidden'; /** * {@docCategory HorizontalBarChart} @@ -204,3 +230,9 @@ export enum HorizontalBarChartVariant { PartToWhole = 'part-to-whole', AbsoluteScale = 'absolute-scale', } + +export type segment = { + percent: number; + adjustedPercent: number; + rawValue: number; +}; diff --git a/packages/charts/react-charts/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts index c035dd5da7a044..d8b56b223fbe98 100644 --- a/packages/charts/react-charts/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts +++ b/packages/charts/react-charts/library/src/components/HorizontalBarChart/useHorizontalBarChartStyles.styles.ts @@ -15,6 +15,7 @@ export const hbcClassNames: SlotClassNames = { chartTitleLeft: 'fui-hbc__chartTitleLeft', chartTitleRight: 'fui-hbc__chartTitleRight', chartDataTextDenominator: 'fui-hbc__textDenom', + chartAnnotationText: 'fui-hbc__chartAnnotationText', benchmarkContainer: 'fui-hbc__benchmark', triangle: 'fui-hbc__triangle', barLabel: 'fui-hbc__barLabel', @@ -70,6 +71,10 @@ const useStyles = makeStyles({ ...typographyStyles.body1, color: tokens.colorNeutralForeground1, }, + chartAnnotationText: { + ...typographyStyles.caption2, + color: tokens.colorNeutralForeground1, + }, benchmarkContainer: { position: 'relative', height: '7px', @@ -104,14 +109,14 @@ const useStyles = makeStyles({ * Apply styling to the Carousel slots based on the state */ export const useHorizontalBarChartStyles = (props: HorizontalBarChartProps): HorizontalBarChartStyles => { - const { className, showTriangle, variant, hideLabels } = props; // ToDo - width, barHeight is non enumerable. Need to be used inline. + const { className, showTriangle, variant, hideLabels, showAnnotationsInPercentage } = props; // ToDo - width, barHeight is non enumerable. Need to be used inline. const baseStyles = useStyles(); return { root: mergeClasses(hbcClassNames.root, baseStyles.root, className, props.styles?.root), items: mergeClasses( hbcClassNames.items, - showTriangle || variant === HorizontalBarChartVariant.AbsoluteScale + showTriangle || variant === HorizontalBarChartVariant.AbsoluteScale || showAnnotationsInPercentage ? baseStyles.items16pMargin : baseStyles.items10pMargin, props.styles?.items, @@ -137,6 +142,11 @@ export const useHorizontalBarChartStyles = (props: HorizontalBarChartProps): Hor baseStyles.chartDataTextDenominator, props.styles?.chartDataTextDenominator, ), + chartAnnotationText: mergeClasses( + hbcClassNames.chartAnnotationText, + baseStyles.chartAnnotationText, + props.styles?.chartAnnotationText, + ), benchmarkContainer: mergeClasses( hbcClassNames.benchmarkContainer, baseStyles.benchmarkContainer, diff --git a/packages/charts/react-charts/library/src/types/DataPoint.ts b/packages/charts/react-charts/library/src/types/DataPoint.ts index 3c328e9d57ac8f..629c3605d96f06 100644 --- a/packages/charts/react-charts/library/src/types/DataPoint.ts +++ b/packages/charts/react-charts/library/src/types/DataPoint.ts @@ -156,6 +156,12 @@ export interface ChartDataPoint { * Accessibility data for callout */ callOutAccessibilityData?: AccessibilityProps; + + /** + * Annotation information for the data point + * This is an optional prop, If not provided, no additional information will be displayed. + */ + annotationInformation?: string[]; } /** diff --git a/packages/charts/react-charts/stories/src/HorizontalBarChart/HorizontalBarChartAnnotated.stories.tsx b/packages/charts/react-charts/stories/src/HorizontalBarChart/HorizontalBarChartAnnotated.stories.tsx new file mode 100644 index 00000000000000..5c16a7cde20150 --- /dev/null +++ b/packages/charts/react-charts/stories/src/HorizontalBarChart/HorizontalBarChartAnnotated.stories.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { HorizontalBarChart, getColorFromToken, DataVizPalette } from '@fluentui/react-charts'; + +export const HorizontalBarChartAnnotated = () => { + const chartDataEdgeCase = Array.from({ length: 20 }, (_, i) => ({ + legend: `Four.${i + 1}`, + horizontalBarChartdata: { x: 20 }, + color: getColorFromToken(DataVizPalette.color1), + annotationInformation: [`Person ${i + 1}`], + })); + + const chartSecondEdgeCase = Array.from({ length: 19 }, (_, i) => ({ + legend: `Six.${i + 1}`, + horizontalBarChartdata: { x: 1 }, + color: getColorFromToken(DataVizPalette.color2), + annotationInformation: [`Person ${i + 1}`], + })); + + const data = [ + { + chartTitle: 'one', + chartData: [ + { + legend: 'One.One', + horizontalBarChartdata: { x: 1543 }, + color: getColorFromToken(DataVizPalette.color1), + annotationInformation: ['Person 1', 'Person 2'], + }, + { + legend: 'One.Two', + horizontalBarChartdata: { x: 1 }, + color: getColorFromToken(DataVizPalette.color2), + annotationInformation: ['Person 3', 'Person 4', 'Person 5'], + }, + { + legend: 'One.Three', + horizontalBarChartdata: { x: 998 }, + color: getColorFromToken(DataVizPalette.color3), + annotationInformation: ['Person 6'], + }, + { + legend: 'One.Four', + horizontalBarChartdata: { x: 7 }, + color: getColorFromToken(DataVizPalette.color4), + annotationInformation: ['Person 7', 'Person 8'], + }, + ], + }, + { + chartTitle: 'two', + chartData: [ + { + legend: 'Two.One', + horizontalBarChartdata: { x: 987 }, + color: getColorFromToken(DataVizPalette.color5), + annotationInformation: ['Person 9', 'Person 10'], + }, + { + legend: 'Two.Two', + horizontalBarChartdata: { x: 7 }, + color: getColorFromToken(DataVizPalette.color6), + annotationInformation: ['Person 11'], + }, + { + legend: 'Two.Three', + horizontalBarChartdata: { x: 1 }, + color: getColorFromToken(DataVizPalette.color7), + annotationInformation: ['Person 12', 'Person 13'], + }, + { + legend: 'Two.Four', + horizontalBarChartdata: { x: 1985 }, + color: getColorFromToken(DataVizPalette.color8), + annotationInformation: ['Person 14'], + }, + ], + }, + { + chartTitle: 'three', + chartData: [ + { + legend: 'Three.One', + horizontalBarChartdata: { x: 18 }, + color: getColorFromToken(DataVizPalette.color9), + annotationInformation: ['Person 15', 'Person 16'], + }, + { + legend: 'Three.Two', + horizontalBarChartdata: { x: 0 }, + color: getColorFromToken(DataVizPalette.color10), + annotationInformation: ['Person 17'], + }, + { + legend: 'Three.Three', + horizontalBarChartdata: { x: 971 }, + color: getColorFromToken(DataVizPalette.color11), + annotationInformation: ['Person 18', 'Person 19'], + }, + { + legend: 'Three.Four', + horizontalBarChartdata: { x: 28 }, + color: getColorFromToken(DataVizPalette.color12), + annotationInformation: ['Person 20'], + }, + ], + }, + { + chartTitle: 'four', + chartData: chartDataEdgeCase, + }, + { + chartTitle: 'five', + chartData: [ + { + legend: 'Five.One', + horizontalBarChartdata: { x: 60 }, + color: getColorFromToken(DataVizPalette.color20), + annotationInformation: ['Person 21'], + }, + { + legend: 'Five.Two', + horizontalBarChartdata: { x: 6 }, + color: getColorFromToken(DataVizPalette.color21), + annotationInformation: ['Person 22'], + }, + { + legend: 'Five.Three', + horizontalBarChartdata: { x: 30 }, + color: getColorFromToken(DataVizPalette.color22), + annotationInformation: ['Person 23'], + }, + { + legend: 'Five.Four', + horizontalBarChartdata: { x: 4 }, + color: getColorFromToken(DataVizPalette.color12), + annotationInformation: ['Person 24'], + }, + ], + }, + { + chartTitle: 'six', + chartData: [ + { + legend: 'Six.Hundred', + horizontalBarChartdata: { x: 100 }, + color: getColorFromToken(DataVizPalette.color12), + annotationInformation: ['Person 20'], + }, + ...chartSecondEdgeCase, + ], + }, + ]; + + function useResizeObserver(targetRef: React.RefObject) { + const [size, setSize] = React.useState({ width: 0, height: 0 }); + + React.useEffect(() => { + if (!targetRef.current) return; + + const observer = new ResizeObserver(entries => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setSize({ width, height }); + } + }); + + observer.observe(targetRef.current); + + return () => observer.disconnect(); + }, [targetRef]); + + return size; + } + + const containerRef = React.useRef(null); + const dimension = useResizeObserver(containerRef); + + return ( +
+ +
+ ); +}; + +HorizontalBarChartAnnotated.parameters = { + docs: { + description: { + story: 'A horizontal bar chart with annotations for each bar.', + }, + }, +}; diff --git a/packages/charts/react-charts/stories/src/HorizontalBarChart/index.stories.tsx b/packages/charts/react-charts/stories/src/HorizontalBarChart/index.stories.tsx index a75d06c8602db8..4380260dc60a6b 100644 --- a/packages/charts/react-charts/stories/src/HorizontalBarChart/index.stories.tsx +++ b/packages/charts/react-charts/stories/src/HorizontalBarChart/index.stories.tsx @@ -9,6 +9,7 @@ export { HorizontalBarBenchmark } from './HorizontalBarChartBenchmark.stories'; export { HorizontalBarStacked } from './HorizontalBarChartStacked.stories'; export { HorizontalBarCustomAccessibility } from './HorizontalBarChartCustomAccessibility.stories'; export { HorizontalBarCustomCallout } from './HorizontalBarChartCustomCallout.stories'; +export { HorizontalBarChartAnnotated } from './HorizontalBarChartAnnotated.stories'; export default { title: 'Charts/HorizontalBarChart',