diff --git a/change/@fluentui-react-charts-77cbfda3-fb06-4556-b5ea-81b4aec45330.json b/change/@fluentui-react-charts-77cbfda3-fb06-4556-b5ea-81b4aec45330.json new file mode 100644 index 00000000000000..5250f1e71bcd96 --- /dev/null +++ b/change/@fluentui-react-charts-77cbfda3-fb06-4556-b5ea-81b4aec45330.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Migrate Gauge chart to V9", + "packageName": "@fluentui/react-charts", + "email": "74965306+Anush2303@users.noreply.github.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 2cc60c13459651..9cbb4c1c7e3440 100644 --- a/packages/charts/react-charts/library/etc/react-charts.api.md +++ b/packages/charts/react-charts/library/etc/react-charts.api.md @@ -17,6 +17,9 @@ export interface AccessibilityProps { ariaLabelledBy?: string; } +// @public (undocumented) +export const ARC_PADDING = 2; + // @public (undocumented) export interface Basestate { // (undocumented) @@ -63,6 +66,16 @@ export interface Basestate { }[]; } +// @public (undocumented) +export const BREAKPOINTS: { + minRadius: number; + arcWidth: number; + fontSize: number; +}[]; + +// @public (undocumented) +export const calcNeedleRotation: (chartValue: number, minValue: number, maxValue: number) => number; + // @public export const CartesianChart: React_2.FunctionComponent; @@ -427,12 +440,91 @@ export interface EventsAnnotationProps { strokeColor?: string; } +// @public (undocumented) +export interface ExtendedSegment extends GaugeChartSegment { + // (undocumented) + end: number; + // (undocumented) + start: number; +} + +// @public (undocumented) +export const GaugeChart: React_2.FunctionComponent; + +// @public +export interface GaugeChartProps { + calloutProps?: Partial; + chartTitle?: string; + chartValue: number; + chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string); + culture?: string; + enableGradient?: boolean; + height?: number; + hideLegend?: boolean; + hideMinMax?: boolean; + hideTooltip?: boolean; + // (undocumented) + legendProps?: Partial; + maxValue?: number; + minValue?: number; + roundCorners?: boolean; + segments: GaugeChartSegment[]; + styles?: GaugeChartStyles; + sublabel?: string; + variant?: GaugeChartVariant; + width?: number; +} + +// @public +export interface GaugeChartSegment { + accessibilityData?: AccessibilityProps; + color?: string; + gradient?: [string, string]; + legend: string; + size: number; +} + +// @public +export interface GaugeChartStyles { + calloutBlockContainer?: string; + calloutContentRoot?: string; + calloutContentX?: string; + calloutContentY?: string; + calloutDateTimeContainer?: string; + calloutInfoContainer?: string; + calloutlegendText?: string; + chart?: string; + chartTitle?: string; + chartValue?: string; + descriptionMessage?: string; + gradientSegment?: string; + legendsContainer?: string; + limits?: string; + needle?: string; + root?: string; + segment?: string; + shapeStyles?: string; + sublabel?: string; +} + +// @public (undocumented) +export type GaugeChartVariant = 'single-segment' | 'multiple-segments'; + +// @public (undocumented) +export type GaugeValueFormat = 'percentage' | 'fraction'; + +// @public (undocumented) +export const getChartValueLabel: (chartValue: number, minValue: number, maxValue: number, chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string) | undefined, forCallout?: boolean) => string; + // @public (undocumented) export const getColorFromToken: (token: string, isDarkTheme?: boolean) => string; // @public (undocumented) export const getNextColor: (index: number, offset?: number, isDarkTheme?: boolean) => string; +// @public (undocumented) +export const getSegmentLabel: (segment: ExtendedSegment, minValue: number, maxValue: number, variant?: GaugeChartVariant, isAriaLabel?: boolean) => string; + // @public (undocumented) export interface GroupedVerticalBarChartData { name: string; @@ -595,6 +687,8 @@ export interface LegendsProps { onChange?: (selectedLegends: string[], event: React_2.MouseEvent, currentLegend?: Legend) => void; overflowStyles?: React_2.CSSProperties; overflowText?: string; + selectedLegend?: string; + selectedLegends?: string[]; shape?: LegendShape; styles?: LegendsStyles; } diff --git a/packages/charts/react-charts/library/src/GaugeChart.ts b/packages/charts/react-charts/library/src/GaugeChart.ts new file mode 100644 index 00000000000000..b613bf14bd406c --- /dev/null +++ b/packages/charts/react-charts/library/src/GaugeChart.ts @@ -0,0 +1 @@ +export * from './components/GaugeChart/index'; diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx new file mode 100644 index 00000000000000..c0d700c700617d --- /dev/null +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.tsx @@ -0,0 +1,712 @@ +import * as React from 'react'; +import { useGaugeChartStyles } from './useGaugeChartStyles.styles'; +import { select as d3Select } from 'd3-selection'; +import { arc as d3Arc } from 'd3-shape'; +import { YValueHover } from '../../index'; +import { + Points, + areArraysEqual, + formatValueWithSIPrefix, + getAccessibleDataObject, + getColorFromToken, + getNextColor, + pointTypes, + useRtl, +} from '../../utilities/index'; +import { convertToLocaleString } from '../../utilities/locale-util'; +import { SVGTooltipText } from '../../utilities/SVGTooltipText'; +import { Legend, LegendShape, Legends, Shape } from '../Legends/index'; +import { GaugeChartVariant, GaugeValueFormat, GaugeChartProps, GaugeChartSegment } from './GaugeChart.types'; +import { useFocusableGroup } from '@fluentui/react-tabster'; +import { ChartPopover } from '../CommonComponents/ChartPopover'; + +const GAUGE_MARGIN = 16; +const LABEL_WIDTH = 36; +const LABEL_HEIGHT = 16; +const LABEL_OFFSET = 4; +const TITLE_OFFSET = 11; +const EXTRA_NEEDLE_LENGTH = 4; +export const ARC_PADDING = 2; +export const BREAKPOINTS = [ + { minRadius: 52, arcWidth: 12, fontSize: 20 }, + { minRadius: 70, arcWidth: 16, fontSize: 24 }, + { minRadius: 88, arcWidth: 20, fontSize: 32 }, + { minRadius: 106, arcWidth: 24, fontSize: 32 }, + { minRadius: 124, arcWidth: 28, fontSize: 40 }, + { minRadius: 142, arcWidth: 32, fontSize: 40 }, +]; + +export const calcNeedleRotation = (chartValue: number, minValue: number, maxValue: number) => { + let needleRotation = ((chartValue - minValue) / (maxValue - minValue)) * 180; + if (needleRotation < 0) { + needleRotation = 0; + } else if (needleRotation > 180) { + needleRotation = 180; + } + + return needleRotation; +}; + +export const getSegmentLabel = ( + segment: ExtendedSegment, + minValue: number, + maxValue: number, + variant?: GaugeChartVariant, + isAriaLabel: boolean = false, +) => { + if (isAriaLabel) { + return minValue === 0 && variant === 'single-segment' + ? `${segment.legend}, ${segment.size} out of ${maxValue} or ${((segment.size / maxValue) * 100).toFixed()}%` + : `${segment.legend}, ${segment.start} to ${segment.end}`; + } + + return minValue === 0 && variant === 'single-segment' + ? `${segment.size} (${((segment.size / maxValue) * 100).toFixed()}%)` + : `${segment.start} - ${segment.end}`; +}; + +export const getChartValueLabel = ( + chartValue: number, + minValue: number, + maxValue: number, + 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}`; + } + + return typeof chartValueFormat === 'function' + ? chartValueFormat([chartValue - minValue, maxValue - minValue]) + : minValue !== 0 + ? chartValue.toString() + : chartValueFormat === 'fraction' + ? `${chartValue}/${maxValue}` + : `${((chartValue / maxValue) * 100).toFixed()}%`; +}; + +interface YValue extends Omit { + y?: string | number; +} +export interface ExtendedSegment extends GaugeChartSegment { + start: number; + end: number; +} + +export const GaugeChart: React.FunctionComponent = React.forwardRef( + (props, forwardedRef) => { + const _getMargins = () => { + const { hideMinMax, chartTitle, sublabel } = props; + return { + left: (!hideMinMax ? LABEL_OFFSET + LABEL_WIDTH : 0) + GAUGE_MARGIN, + right: (!hideMinMax ? LABEL_OFFSET + LABEL_WIDTH : 0) + GAUGE_MARGIN, + top: (chartTitle ? TITLE_OFFSET + LABEL_HEIGHT : EXTRA_NEEDLE_LENGTH / 2) + GAUGE_MARGIN, + bottom: (sublabel ? LABEL_OFFSET + LABEL_HEIGHT : 0) + GAUGE_MARGIN, + }; + }; + const _margins: { left: number; right: number; top: number; bottom: number } = _getMargins(); + const _legendsHeight: number = !props.hideLegend ? 24 : 0; + const _rootElem = React.useRef(null); + const _isRTL: boolean = useRtl(); + const [width, setWidth] = React.useState(140 + _getMargins().left + _getMargins().right); + const [height, setHeight] = React.useState(70 + _getMargins().top + _getMargins().bottom + _legendsHeight); + const [hoveredLegend, setHoveredLegend] = React.useState(''); + const [selectedLegends, setSelectedLegends] = React.useState(props.legendProps?.selectedLegends || []); + const [focusedElement, setFocusedElement] = React.useState(''); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [clickPosition, setClickPosition] = React.useState({ x: 0, y: 0 }); + const [isPopoverOpen, setPopoverOpen] = React.useState(false); + const [hoverXValue, setHoverXValue] = React.useState(''); + const [hoverYValues, setHoverYValues] = React.useState([]); + const prevPropsRef = React.useRef(null); + const _width = props.width || width; + const _height = props.height || height; + const _outerRadius: number = Math.min( + (_width - (_margins.left + _margins.right)) / 2, + _height - (_margins.top + _margins.bottom + _legendsHeight), + ); + const { arcWidth, chartValueSize } = _getStylesBasedOnBreakpoint(); + const _innerRadius: number = _outerRadius - arcWidth; + let _minValue!: number; + let _maxValue!: number; + let _segments!: ExtendedSegment[]; + let _calloutAnchor: string = ''; + React.useEffect(() => { + if (prevPropsRef.current) { + const prevProps = prevPropsRef.current; + if (!areArraysEqual(prevProps.legendProps?.selectedLegends, props.legendProps?.selectedLegends)) { + setSelectedLegends(props.legendProps?.selectedLegends || []); + } + if (prevProps.height !== props.height || prevProps.width !== props.width) { + setWidth(props.width!); + setHeight(props.height!); + } + } + prevPropsRef.current = props; + }, [props]); + + const classes = useGaugeChartStyles(props); + function _getStylesBasedOnBreakpoint() { + for (let index = BREAKPOINTS.length - 1; index >= 0; index -= 1) { + if (_outerRadius >= BREAKPOINTS[index].minRadius) { + return { + arcWidth: BREAKPOINTS[index].arcWidth, + chartValueSize: BREAKPOINTS[index].fontSize, + }; + } + } + return { + arcWidth: BREAKPOINTS[0].arcWidth, + chartValueSize: BREAKPOINTS[0].fontSize, + }; + } + + function _processProps() { + const { minValue = 0, maxValue, segments, roundCorners } = props; + + let total = minValue; + const processedSegments: ExtendedSegment[] = segments.map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (segment: { size: number; legend: any; color: string; accessibilityData: any }, index: number) => { + const size = Math.max(segment.size, 0); + total += size; + return { + legend: segment.legend, + size, + color: + typeof segment.color !== 'undefined' + ? getColorFromToken(segment.color, false) + : getNextColor(index, 0, false), + accessibilityData: segment.accessibilityData, + start: total - size, + end: total, + }; + }, + ); + if (typeof maxValue !== 'undefined' && total < maxValue) { + processedSegments.push({ + legend: 'Unknown', + size: maxValue - total, + color: 'neutralLight', + start: total, + end: maxValue, + }); + total = maxValue; + } + + const arcGenerator = d3Arc() + .cornerRadius(roundCorners ? 3 : 0) + .padAngle(ARC_PADDING / _outerRadius) + .padRadius(_outerRadius); + const rtlSafeSegments = _isRTL ? Array.from(processedSegments).reverse() : processedSegments; + let prevAngle = -Math.PI / 2; + // eslint-disable-next-line @typescript-eslint/no-shadow + const arcs = rtlSafeSegments.map((segment, index) => { + const endAngle = prevAngle + (segment.size / (total - minValue)) * Math.PI; + const d = arcGenerator({ + innerRadius: _innerRadius, + outerRadius: _outerRadius, + startAngle: prevAngle, + endAngle, + })!; + prevAngle = endAngle; + return { + d, + segmentIndex: _isRTL ? processedSegments.length - 1 - index : index, + startAngle: prevAngle - (segment.size / (total - minValue)) * Math.PI, + endAngle, + }; + }); + + _minValue = minValue; + _maxValue = total; + _segments = processedSegments; + + return { + arcs, + }; + } + + function _renderNeedle() { + const needleRotation = calcNeedleRotation(props.chartValue, _minValue, _maxValue); + const rtlSafeNeedleRotation = _isRTL ? 180 - needleRotation : needleRotation; + const strokeWidth = 2; + const halfStrokeWidth = strokeWidth / 2; + const needleLength = _outerRadius - _innerRadius + EXTRA_NEEDLE_LENGTH; + + return ( + + _handleFocus(e, 'Needle')} + onBlur={_handleBlur} + onMouseEnter={e => _handleMouseOver(e, 'Needle')} + onMouseMove={e => _handleMouseOver(e, 'Needle')} + role="img" + aria-label={ + 'Current value: ' + getChartValueLabel(props.chartValue, _minValue, _maxValue, props.chartValueFormat) + } + /> + + ); + } + + function _renderLegends() { + if (props.hideLegend) { + return null; + } + + const legends: Legend[] = _segments.map((segment, index) => { + const color: string = segment.color || getNextColor(index, 0, false); + + return { + title: segment.legend, + color, + hoverAction: () => { + setHoveredLegend(segment.legend); + }, + onMouseOutAction: () => { + setHoveredLegend(''); + }, + }; + }); + + return ( +
+ +
+ ); + } + + function _onLegendSelectionChange( + // eslint-disable-next-line @typescript-eslint/no-shadow + selectedLegends: string[], + event: React.MouseEvent, + currentLegend?: Legend, + ): void { + if (props.legendProps?.canSelectMultipleLegends) { + setSelectedLegends(selectedLegends); + } else { + setSelectedLegends(selectedLegends.slice(-1)); + } + if (props.legendProps?.onChange) { + props.legendProps.onChange(selectedLegends, event, currentLegend); + } + } + + /** + * This function checks if the given legend is highlighted or not. + * A legend can be highlighted in 2 ways: + * 1. selection: if the user clicks on it + * 2. hovering: if there is no selected legend and the user hovers over it + */ + function _legendHighlighted(legend: string) { + return _getHighlightedLegend().includes(legend!); + } + + /** + * This function checks if none of the legends is selected or hovered. + */ + function _noLegendHighlighted() { + return _getHighlightedLegend().length === 0; + } + + function _getHighlightedLegend() { + return selectedLegends.length > 0 ? selectedLegends : hoveredLegend ? [hoveredLegend] : []; + } + + // eslint-disable-next-line @typescript-eslint/no-shadow + function _handleFocus(focusEvent: React.FocusEvent, focusedElement: string) { + _showCallout(focusEvent, focusedElement, true); + } + + function _handleBlur() { + _hideCallout(true); + } + + function _handleMouseOver(mouseEvent: React.MouseEvent, hoveredElement: string) { + _showCallout(mouseEvent, hoveredElement, false); + } + + function _handleMouseOut() { + _hideCallout(false); + } + + function _handleCalloutDismiss() { + _hideCallout(false); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function _showCallout( + event: React.MouseEvent | React.FocusEvent, + legend: string, + isFocusEvent: boolean, + ) { + if (_calloutAnchor === legend) { + return; + } + let clientX = 0; + let clientY = 0; + if ('clientX' in event) { + clientX = event.clientX; + clientY = event.clientY; + } else { + // eslint-disable-next-line @typescript-eslint/no-shadow + const target = event.currentTarget as HTMLElement | SVGElement; + if (target && 'getBoundingClientRect' in target) { + const boundingRect = target.getBoundingClientRect(); + clientX = boundingRect.left + boundingRect.width / 2; + clientY = boundingRect.top + boundingRect.height / 2; + } + } + _calloutAnchor = legend; + // eslint-disable-next-line @typescript-eslint/no-shadow + const hoverXValue: string = + 'Current value is ' + getChartValueLabel(props.chartValue, _minValue, _maxValue, props.chartValueFormat, true); + // eslint-disable-next-line @typescript-eslint/no-shadow + const hoverYValues: YValue[] = _segments.map(segment => { + const yValue: YValue = { + legend: segment.legend, + y: getSegmentLabel(segment, _minValue, _maxValue, props.variant), + color: segment.color, + }; + return yValue; + }); + _updatePosition(clientX, clientY); + setPopoverOpen( + ['Needle', 'Chart value'].includes(legend) || _noLegendHighlighted() || _legendHighlighted(legend), + ); + setHoverXValue(hoverXValue); + setHoverYValues(hoverYValues); + if (isFocusEvent) { + setFocusedElement(legend); + } + } + + function _hideCallout(isBlurEvent?: boolean) { + _calloutAnchor = ''; + setPopoverOpen(false); + setHoverXValue(''); + setHoverYValues([]); + if (isBlurEvent) { + setFocusedElement(''); + } + } + + function _wrapContent(content: string, id: string, maxWidth: number) { + const textElement = d3Select(`#${id}`); + textElement.text(content); + if (!textElement.node()) { + return false; + } + + let isOverflowing = false; + let textLength = textElement.node()!.getComputedTextLength(); + while (textLength > maxWidth && content.length > 0) { + content = content.slice(0, -1); + textElement.text(content + '...'); + isOverflowing = true; + textLength = textElement.node()!.getComputedTextLength(); + } + return isOverflowing; + } + + // TO DO: Write a common functional component for Multi value callout and divide sub count method + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function _multiValueCallout(calloutProps: any) { + const yValueHoverSubCountsExists: boolean = _yValueHoverSubCountsExists(calloutProps.YValueHover); + return ( +
+
+
+ {convertToLocaleString(calloutProps!.hoverXValue, props.culture)} +
+
+
+ {calloutProps!.YValueHover && + calloutProps!.YValueHover.map((yValue: YValueHover, index: number, yValues: YValueHover[]) => { + const isLast: boolean = index + 1 === yValues.length; + const { shouldDrawBorderBottom = false } = yValue; + return ( +
+ {_getCalloutContent(yValue, index, yValueHoverSubCountsExists, isLast)} +
+ ); + })} + {!!calloutProps.descriptionMessage && ( +
{calloutProps.descriptionMessage}
+ )} +
+
+ ); + } + + function _yValueHoverSubCountsExists(yValueHover?: YValueHover[]) { + if (yValueHover) { + return yValueHover.some( + (yValue: { + legend?: string; + y?: number; + color?: string; + yAxisCalloutData?: string | { [id: string]: number }; + }) => yValue.yAxisCalloutData && typeof yValue.yAxisCalloutData !== 'string', + ); + } + return false; + } + + function _getCalloutContent( + xValue: YValueHover, + index: number, + yValueHoverSubCountsExists: boolean, + isLast: boolean, + ): React.ReactNode { + const marginStyle: React.CSSProperties = isLast ? {} : { marginRight: '16px' }; + const toDrawShape = xValue.index !== undefined && xValue.index !== -1; + const { culture } = props; + const yValue = convertToLocaleString(xValue.y, culture); + if (!xValue.yAxisCalloutData || typeof xValue.yAxisCalloutData === 'string') { + return ( +
+ {yValueHoverSubCountsExists && ( +
+ {xValue.legend!} ({yValue}) +
+ )} +
+ {toDrawShape && ( + + )} +
+
{xValue.legend}
+
+ {convertToLocaleString( + xValue.yAxisCalloutData ? xValue.yAxisCalloutData : xValue.y || xValue.data, + culture, + )} +
+
+
+
+ ); + } else { + const subcounts: { [id: string]: number } = xValue.yAxisCalloutData as { [id: string]: number }; + return ( +
+
+ {xValue.legend!} ({yValue}) +
+ {Object.keys(subcounts).map((subcountName: string) => { + return ( +
+
{convertToLocaleString(subcountName, culture)}
+
+ {convertToLocaleString(subcounts[subcountName], culture)} +
+
+ ); + })} +
+ ); + } + } + + function _updatePosition(newX: number, newY: number) { + const threshold = 1; // Set a threshold for movement + const { x, y } = clickPosition; + // Calculate the distance moved + const distance = Math.sqrt(Math.pow(newX - x, 2) + Math.pow(newY - y, 2)); + // Update the position only if the distance moved is greater than the threshold + if (distance > threshold) { + setClickPosition({ x: newX, y: newY }); + setPopoverOpen(true); + } + } + + function _getChartTitle(): string { + const { chartTitle } = props; + return (chartTitle ? `${chartTitle}. ` : '') + `Gauge chart with ${_segments.length} segments. `; + } + const { arcs } = _processProps(); + const focusAttributes = useFocusableGroup(); + return ( +
(_rootElem.current = el)} {...focusAttributes}> + + + {props.chartTitle && ( + + {props.chartTitle} + + )} + {!props.hideMinMax && ( + <> + + {formatValueWithSIPrefix(_minValue)} + + + {formatValueWithSIPrefix(_maxValue)} + + + )} + {arcs.map((arc, index) => { + const segment = _segments[arc.segmentIndex]; + return ( + + _handleFocus(e, segment.legend)} + onBlur={_handleBlur} + onMouseEnter={e => _handleMouseOver(e, segment.legend)} + onMouseLeave={e => _handleCalloutDismiss()} + onMouseMove={e => _handleMouseOver(e, segment.legend)} + data-is-focusable={_legendHighlighted(segment.legend) || _noLegendHighlighted()} + tabIndex={segment.legend !== '' ? 0 : undefined} + /> + + ); + })} + {_renderNeedle()} + _handleMouseOver(e, 'Chart value')} + onMouseMove={e => _handleMouseOver(e, 'Chart value')} + > + + + {props.sublabel && ( + + )} + + + {_renderLegends()} + {!props.hideTooltip && isPopoverOpen && ( + + )} +
+ ); + }, +); +GaugeChart.displayName = 'GaugeChart'; 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 new file mode 100644 index 00000000000000..7174cd027d0138 --- /dev/null +++ b/packages/charts/react-charts/library/src/components/GaugeChart/GaugeChart.types.ts @@ -0,0 +1,254 @@ +import { LegendsProps } from '../Legends/index'; +import { AccessibilityProps } from '../../types/index'; +import { ChartPopoverProps } from '../CommonComponents/ChartPopover.types'; + +/** + * Gauge Chart segment interface. + * {@docCategory GaugeChart} + */ +export interface GaugeChartSegment { + /** + * Legend text for a segment + */ + legend: string; + + /** + * Size of the segment + */ + size: number; + + /** + * Color of the segment + */ + color?: string; + + /** + * Gradient color of the segment + */ + gradient?: [string, string]; + + /** + * Accessibility data for the segment + */ + accessibilityData?: AccessibilityProps; +} + +/** + * {@docCategory GaugeChart} + */ +export type GaugeValueFormat = 'percentage' | 'fraction'; + +/** + * {@docCategory GaugeChart} + */ +export type GaugeChartVariant = 'single-segment' | 'multiple-segments'; + +/** + * Gauge Chart properties + * {@docCategory GaugeChart} + */ +export interface GaugeChartProps { + /** + * Width of the chart + */ + width?: number; + + /** + * Height of the chart + */ + height?: number; + + /** + * Title of the chart + */ + chartTitle?: string; + + /** + * Current value of the gauge + */ + chartValue: number; + + /** + * Sections of the gauge + */ + segments: GaugeChartSegment[]; + + /** + * Minimum value of the gauge + * @defaultvalue 0 + */ + minValue?: number; + + /** + * Maximum value of the gauge + */ + maxValue?: number; + + /** + * Additional text to display below the chart value + */ + sublabel?: string; + + /** + * Hide the min and max values of the gauge + * @defaultvalue false + */ + hideMinMax?: boolean; + + /** + * Format of the chart value + * @defaultvalue GaugeValueFormat.Percentage + */ + chartValueFormat?: GaugeValueFormat | ((sweepFraction: [number, number]) => string); + + /** + * Decides whether to show/hide legends + * @defaultvalue false + */ + hideLegend?: boolean; + + /* + * Props for the legends in the chart + */ + legendProps?: Partial; + + /** + * Do not show tooltips in chart + * @defaultvalue false + */ + hideTooltip?: boolean; + + /** + * Call to provide customized styling that will layer on top of the variant rules + */ + styles?: GaugeChartStyles; + + /** + * Defines the culture to localize the numbers and dates + */ + culture?: string; + + /** + * Props for the callout in the chart + */ + calloutProps?: Partial; + + /** + * Specifies the variant of GaugeChart to be rendered + * @defaultvalue GaugeChartVariant.MultipleSegments + */ + variant?: GaugeChartVariant; + + /** + * Prop to enable the gradient in the chart + * @default false + */ + enableGradient?: boolean; + + /** + * Prop to enable the round corners in the chart + * @default false + */ + roundCorners?: boolean; +} + +/** + * Gauge Chart styles + * {@docCategory GaugeChart} + */ +export interface GaugeChartStyles { + /** + * Styles for the root element + */ + root?: string; + + /** + * Styles for the chart + */ + chart?: string; + + /** + * Styles for the min and max values + */ + limits?: string; + + /** + * Styles for the chart value + */ + chartValue?: string; + + /** + * Styles for the sublabel + */ + sublabel?: string; + + /** + * Styles for the needle + */ + needle?: string; + + /** + * Styles for the chart title + */ + chartTitle?: string; + + /** + * Styles for the segments + */ + segment?: string; + + /** + * Styles for gradient segments + */ + gradientSegment?: string; + + /** + * Styles for the legends container + */ + legendsContainer?: string; + + /** + * Styles for callout root-content + */ + calloutContentRoot?: string; + + /** + * Styles for callout x-content + */ + calloutContentX?: string; + + /** + * Styles for callout y-content + */ + calloutContentY?: string; + + /** + * Styles for description message + */ + descriptionMessage?: string; + + /** + * Styles for callout Date time container + */ + calloutDateTimeContainer?: string; + + /** + * Styles for callout info container + */ + calloutInfoContainer?: string; + + /** + * Styles for callout block container + */ + calloutBlockContainer?: string; + + /** + * Styles for callout legend text + */ + calloutlegendText?: string; + + /** + * Styles for the shape object in the callout + */ + shapeStyles?: string; +} diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/index.ts b/packages/charts/react-charts/library/src/components/GaugeChart/index.ts new file mode 100644 index 00000000000000..87c705a3976c7b --- /dev/null +++ b/packages/charts/react-charts/library/src/components/GaugeChart/index.ts @@ -0,0 +1,3 @@ +export * from './GaugeChart'; +export * from './GaugeChart.types'; +export * from '../../types/index'; diff --git a/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts b/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts new file mode 100644 index 00000000000000..cd5b8459f45fbe --- /dev/null +++ b/packages/charts/react-charts/library/src/components/GaugeChart/useGaugeChartStyles.styles.ts @@ -0,0 +1,138 @@ +import { tokens, typographyStyles } from '@fluentui/react-theme'; +import { SlotClassNames } from '@fluentui/react-utilities/src/index'; +import { makeStyles, mergeClasses, shorthands } from '@griffel/react'; +import { GaugeChartProps, GaugeChartStyles } from './GaugeChart.types'; + +export const gaugeChartClassNames: SlotClassNames = { + root: 'fui-gc__root', + chart: 'fui-gc__chart', + limits: 'fui-gc__limits', + chartValue: 'fui-gc__chartValue', + sublabel: 'fui-gc__sublabel', + needle: 'fui-gc__needle', + chartTitle: 'fui-gc__chartTitle', + segment: 'fui-gc__segment', + gradientSegment: 'fui-gc__gradientSegment', + calloutContentRoot: 'fui-gc__calloutContentRoot', + calloutDateTimeContainer: 'fui-gc__calloutDateTimeContainer', + calloutContentX: 'fui-gc__calloutContentX', + calloutBlockContainer: 'fui-gc__calloutBlockContainer', + shapeStyles: 'fui-gc__shapeStyles', + calloutlegendText: 'fui-gc__calloutlegendText', + calloutContentY: 'fui-gc__calloutContentY', + descriptionMessage: 'fui-gc__descriptionMessage', + calloutInfoContainer: '', + legendsContainer: '', +}; + +const useStyles = makeStyles({ + root: { + ...typographyStyles.body1, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + width: '100%', + height: '100%', + }, + chart: { + display: 'block', + }, + limits: { + ...typographyStyles.caption1Strong, + fill: tokens.colorNeutralForeground1, + }, + chartValue: { + fontWeight: tokens.fontWeightSemibold, + fill: tokens.colorNeutralForeground1, + }, + sublabel: { + ...typographyStyles.caption1Strong, + fill: tokens.colorNeutralForeground1, + }, + needle: { + fill: tokens.colorNeutralForeground1, + stroke: tokens.colorNeutralBackground1, + }, + chartTitle: { + ...typographyStyles.caption1, + fill: tokens.colorNeutralForeground1, + }, + segment: { + outline: 'none', + stroke: tokens.colorNeutralStroke1, + }, + gradientSegment: { + width: '100%', + height: '100%', + }, + calloutContentRoot: { + display: 'grid', + overflow: 'hidden', + ...shorthands.padding('11px', '16px', '10px', '16px'), + backgroundColor: tokens.colorNeutralBackground1, + backgroundBlendMode: 'normal, luminosity', + }, + calloutDateTimeContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + }, + calloutContentX: { + ...typographyStyles.caption1, + lineHeight: '16px', + opacity: '0.85', + color: tokens.colorNeutralForeground2, + }, + calloutBlockContainer: { + ...typographyStyles.body1Strong, + marginTop: '13px', + color: tokens.colorNeutralForeground1, + paddingLeft: '8px', + display: 'block', + }, + shapeStyles: { + marginRight: '8px', + }, + calloutlegendText: { + ...typographyStyles.caption1, + lineHeight: '16px', + color: tokens.colorNeutralForeground2, + }, + calloutContentY: { + ...typographyStyles.body1Strong, + lineHeight: '22px', + }, + descriptionMessage: { + ...typographyStyles.caption1, + color: tokens.colorNeutralForeground1, + marginTop: '10px', + paddingTop: '10px', + borderTop: `1px solid ${tokens.colorNeutralStroke1}`, + }, +}); +export const useGaugeChartStyles = (props: GaugeChartProps): GaugeChartStyles => { + const baseStyles = useStyles(); + + return { + root: mergeClasses(gaugeChartClassNames.root, baseStyles.root), + chart: mergeClasses(gaugeChartClassNames.chart, baseStyles.chart), + limits: mergeClasses(gaugeChartClassNames.limits, baseStyles.limits), + chartValue: mergeClasses(gaugeChartClassNames.chartValue, baseStyles.chartValue), + sublabel: mergeClasses(gaugeChartClassNames.sublabel, baseStyles.sublabel), + needle: mergeClasses(gaugeChartClassNames.needle, baseStyles.needle), + chartTitle: mergeClasses(gaugeChartClassNames.chartTitle, baseStyles.chartTitle), + segment: mergeClasses(gaugeChartClassNames.segment, baseStyles.segment), + gradientSegment: mergeClasses(gaugeChartClassNames.gradientSegment, baseStyles.gradientSegment), + calloutContentRoot: mergeClasses(gaugeChartClassNames.calloutContentRoot, baseStyles.calloutContentRoot), + calloutDateTimeContainer: mergeClasses( + gaugeChartClassNames.calloutDateTimeContainer, + baseStyles.calloutDateTimeContainer, + ), + calloutContentX: mergeClasses(gaugeChartClassNames.calloutContentX, baseStyles.calloutContentX), + calloutBlockContainer: mergeClasses(gaugeChartClassNames.calloutBlockContainer, baseStyles.calloutBlockContainer), + shapeStyles: mergeClasses(gaugeChartClassNames.shapeStyles, baseStyles.shapeStyles), + calloutlegendText: mergeClasses(gaugeChartClassNames.calloutlegendText, baseStyles.calloutlegendText), + calloutContentY: mergeClasses(gaugeChartClassNames.calloutContentY, baseStyles.calloutContentY), + descriptionMessage: mergeClasses(gaugeChartClassNames.descriptionMessage, baseStyles.descriptionMessage), + }; +}; diff --git a/packages/charts/react-charts/library/src/components/Legends/Legends.types.ts b/packages/charts/react-charts/library/src/components/Legends/Legends.types.ts index 94f6cbf356395f..3912a5207baff3 100644 --- a/packages/charts/react-charts/library/src/components/Legends/Legends.types.ts +++ b/packages/charts/react-charts/library/src/components/Legends/Legends.types.ts @@ -191,6 +191,32 @@ export interface LegendsProps { */ defaultSelectedLegend?: string; + /** + * Keys (title) that will be used to set selected items in multi-select scenarios when canSelectMultipleLegends is + * true. For single-select, use selectedLegend. + * + * When this prop is provided, the component is controlled and does not automatically update the selection based on + * user interactions; the parent component must update the value passed to this property by handling the onChange + * event. + * + * @see defaultSelectedLegends for setting the initially-selected legends in uncontrolled mode. + * @see selectedLegends for setting the selected legends when `canSelectMultipleLegends` is `true`. + */ + selectedLegends?: string[]; + + /** + * Key (title) that will be used to set the selected item in single-select scenarios when canSelectMultipleLegends is + * false or unspecified. For multi-select, use selectedLegends. + * + * When this prop is provided, the component is controlled and does not automatically update the selection based on + * user interactions; the parent component must update the value passed to this property by handling the onChange + * event. + * + * @see defaultSelectedLegend for setting the initially-selected legend in uncontrolled mode. + * @see selectedLegend for setting the selected legend when `canSelectMultipleLegends` is `false`. + */ + selectedLegend?: string; + /** * The shape for the legend. */ diff --git a/packages/charts/react-charts/library/src/index.ts b/packages/charts/react-charts/library/src/index.ts index 213585a56ade25..a5eb4b4255b98e 100644 --- a/packages/charts/react-charts/library/src/index.ts +++ b/packages/charts/react-charts/library/src/index.ts @@ -6,6 +6,7 @@ export * from './VerticalBarChart'; export * from './CartesianChart'; export * from './types/index'; export * from './Sparkline'; +export * from './GaugeChart'; export * from './utilities/colors'; export * from './Popover'; export * from './ResponsiveContainer'; diff --git a/packages/charts/react-charts/library/src/utilities/utilities.ts b/packages/charts/react-charts/library/src/utilities/utilities.ts index 4fc20a5ca06904..7cb68168e4e0d3 100644 --- a/packages/charts/react-charts/library/src/utilities/utilities.ts +++ b/packages/charts/react-charts/library/src/utilities/utilities.ts @@ -1708,3 +1708,18 @@ export function resolveCSSVariables(chartContainer: HTMLElement, styleRules: str return containerStyles.getPropertyValue(group1); }); } + +export function areArraysEqual(arr1?: string[], arr2?: string[]): boolean { + if (arr1 === arr2 || (!arr1 && !arr2)) { + return true; + } + if (!arr1 || !arr2 || arr1.length !== arr2.length) { + return false; + } + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] !== arr2[i]) { + return false; + } + } + return true; +} diff --git a/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartBestPractices.md b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartBestPractices.md new file mode 100644 index 00000000000000..e936fee3fd8fab --- /dev/null +++ b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartBestPractices.md @@ -0,0 +1,49 @@ +### Layout + +The library recommends a few size width and height options for charts. Product teams must consider the complexity of the data to decide what size should be used in implementation. There are 6 size options for the gauge, ranging from very small to very large. The default size is medium, with a diameter of 140px and default bar width of 16px. All have a margin of 16px on all sides. + +### Content + +- **Bar** This is the arc representing the semi-circle. +- **Min and max values** Used to represent minimum and maximum values for the data being measured. These can either be an absolute value or a percentage. +- **Data segment** This represents the current value as a part of the whole scale. For rating meter, it shows the relative scale of each segment. +- **Current value indicator / needle** Used to show user’s position on the semi-circular graph. +- **Chart value** This can be a number out of another (part to whole) or represented as a percentage. + +### Accessibility + +- Users 'Enter' into the graph and can use both arrowing and tabbing to navigate through. +- The first tab stop will stop on the graph and give a description of what type of graph it is. +- Each section of the graph is readable via a screen reader. + +### Customizing the chart + +- `width` and `height`: These props determine the diameter of the gauge. If not provided, a default diameter of 140px is used. + chartTitle: Use this prop to render a title above the gauge. +- `chartValue`: This required prop controls the rotation of the needle. If the chart value is less than the minimum, the needle points to the min value. Similarly, if it exceeds the maximum, the needle points to the max value. +- `segments`: Use this required prop to divide the gauge into colored sections. The segments can have fixed sizes or vary with the chart value to create a sweeping effect. Negative segment sizes are treated as 0. +- `minValue`: Use this prop if the minimum value of the gauge is different from 0. +- `maxValue`: Use this prop to render a placeholder segment when the desired range for the gauge is more than the sum of all segments. If the maxValue is less than the sum of all segments, this property is ignored. +- `sublabel`: Use this prop to render additional text below the chart value. +- `hideMinMax`: Set this prop to true to hide the min and max labels of the gauge. +- `chartValueFormat`: This prop controls how the chart value is displayed. Set it to one of the following options: + +- A custom formatter function that returns a string representing the chart value. + + - `GaugeValueFormat.Fraction`: Renders the chart value as a fraction. + - `GaugeValueFormat.Percentage`: Renders the chart value as a percentage. This is the default format. + Note: If the min value is non-zero and no formatter function is provided, the chart value will be rendered as a number. + +- `variant`: This prop determines the presentation style of the gauge chart. Set it to one of the following options: + + - `GaugeChartVariant.SingleSegment`: This variant helps represent a single metric or key performance indicator (KPI) within a predefined range or target. In this variant, the segment sizes are rendered as percentages. + + - `GaugeChartVariant.MultipleSegments`: This is the default variant that helps display the distribution of a single variable across different thresholds or categories. In this variant, the segment sizes are rendered as ranges. + +### Do's + +- Display min and max values to the left and the right if you’re showing a percentage within the gauge. + +### Don'ts + +- Don’t add min and mix if you’re already representing the part to whole ratio within the gauge because it’s redundant. diff --git a/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx new file mode 100644 index 00000000000000..97dc0b6e72348e --- /dev/null +++ b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDefault.stories.tsx @@ -0,0 +1,141 @@ +import * as React from 'react'; +import { DataVizPalette, GaugeChart, getColorFromToken } from '@fluentui/react-charts'; +import { Checkbox, CheckboxOnChangeData, Switch } from '@fluentui/react-components'; + +export const GaugeChartBasic = () => { + const [width, setWidth] = React.useState(252); + const [height, setHeight] = React.useState(128); + const [chartValue, setChartValue] = React.useState(50); + const [hideMinMax, setHideMinMax] = React.useState(false); + const [enableGradient, setEnableGradient] = React.useState(false); + const [roundedCorners, setRoundedCorners] = React.useState(false); + const [legendMultiSelect, setLegendMultiSelect] = React.useState(false); + + const _onWidthChange = (e: React.ChangeEvent) => { + setWidth(parseInt(e.target.value, 10)); + }; + const _onHeightChange = (e: React.ChangeEvent) => { + setHeight(parseInt(e.target.value, 10)); + }; + const _onValueChange = (e: React.ChangeEvent) => { + setChartValue(parseInt(e.target.value, 10)); + }; + const _onHideMinMaxCheckChange = (ev: React.ChangeEvent, checked: CheckboxOnChangeData) => { + setHideMinMax(checked.checked as boolean); + }; + + const _onSwitchGradient = React.useCallback(ev => { + setEnableGradient(ev.currentTarget.checked); + }, []); + + const _onSwitchRoundedCorners = React.useCallback(ev => { + setRoundedCorners(ev.currentTarget.checked); + }, []); + + const _onSwitchLegendMultiSelect = React.useCallback(ev => { + setLegendMultiSelect(ev.currentTarget.checked); + }, []); + + return ( + <> +
+
+ + + {width} +
+
+ + + {height} +
+
+ + + {chartValue} +
+
+
+ +
+
+ +    + +    + +
+ + + + ); +}; +GaugeChartBasic.parameters = { + docs: { + description: {}, + }, +}; diff --git a/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDescription.md b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDescription.md new file mode 100644 index 00000000000000..e53a7cd3b1fc05 --- /dev/null +++ b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartDescription.md @@ -0,0 +1,9 @@ +A radial gauge chart uses a circular arc to show how a single value progresses toward a goal or a Key Performance Indicator (KPI). The gauge line (or needle) represents the goal or target value. The shading represents progress toward the goal. The value inside the arc represents the progress value. + +There are two types of gauge charts: Speedometer and rating meter. + +The speedometer measures a numerical value against a whole, like storage capacity. The needle is an optional component. The color of the segment representing the value being measured can be customized by product teams to suit certain scenarios or to align with branding colors. + +The rating meter shows status of the current value within a few predefined ranges or segments. The needle is a required component here. + +The segment sizes and colors can be customized by the product team to suit their needs. diff --git a/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartSingleSegment.stories.tsx b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartSingleSegment.stories.tsx new file mode 100644 index 00000000000000..e1187a899b6478 --- /dev/null +++ b/packages/charts/react-charts/stories/src/GaugeChart/GaugeChartSingleSegment.stories.tsx @@ -0,0 +1,112 @@ +import * as React from 'react'; +import { DataVizPalette, GaugeChart, getColorFromToken } from '@fluentui/react-charts'; +import { Switch } from '@fluentui/react-components'; + +export const GaugeChartSingleSegment = () => { + const [width, setWidth] = React.useState(252); + const [height, setHeight] = React.useState(173); + const [chartValue, setChartValue] = React.useState(50); + const [enableGradient, setEnableGradient] = React.useState(false); + const [roundedCorners, setRoundedCorners] = React.useState(false); + + const _onWidthChange = (e: React.ChangeEvent) => { + setWidth(parseInt(e.target.value, 10)); + }; + const _onHeightChange = (e: React.ChangeEvent) => { + setHeight(parseInt(e.target.value, 10)); + }; + const _onValueChange = (e: React.ChangeEvent) => { + setChartValue(parseInt(e.target.value, 10)); + }; + const _onSwitchGradient = React.useCallback(ev => { + setEnableGradient(ev.currentTarget.checked); + }, []); + + const _onSwitchRoundedCorners = React.useCallback(ev => { + setRoundedCorners(ev.currentTarget.checked); + }, []); + + return ( + <> +
+
+ + + {width} +
+
+ + + {height} +
+
+ + + {chartValue} +
+
+
+ +    + +
+ + + + ); +}; +GaugeChartSingleSegment.parameters = { + docs: { + description: {}, + }, +}; diff --git a/packages/charts/react-charts/stories/src/GaugeChart/index.stories.tsx b/packages/charts/react-charts/stories/src/GaugeChart/index.stories.tsx new file mode 100644 index 00000000000000..9bbfcdebbf7318 --- /dev/null +++ b/packages/charts/react-charts/stories/src/GaugeChart/index.stories.tsx @@ -0,0 +1,19 @@ +import { GaugeChart } from '@fluentui/react-charts'; + +import descriptionMd from './GaugeChartDescription.md'; +import bestPracticesMd from './GaugeChartBestPractices.md'; + +export { GaugeChartBasic } from './GaugeChartDefault.stories'; +export { GaugeChartSingleSegment } from './GaugeChartSingleSegment.stories'; + +export default { + title: 'Charts/GaugeChart', + component: GaugeChart, + parameters: { + docs: { + description: { + component: [descriptionMd, bestPracticesMd].join('\n'), + }, + }, + }, +};