Skip to content

Commit

Permalink
[Area Chart] Support legend multi selection (#33475)
Browse files Browse the repository at this point in the history
  • Loading branch information
srmukher authored Dec 27, 2024
1 parent af36db5 commit a2f70fd
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 37 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "[Area Chart] Support legend multi selection [Area Chart] Support legend multi selection",
"packageName": "@fluentui/react-charting",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
createStringYAxis,
formatDate,
getSecureProps,
areArraysEqual,
} from '../../utilities/index';
import { ILegend, Legends } from '../Legends/index';
import { DirectionalHint } from '@fluentui/react/lib/Callout';
Expand Down Expand Up @@ -87,6 +88,7 @@ export interface IAreaChartState extends IBasestate {
isShowCalloutPending: boolean;
/** focused point */
activePoint: string;
selectedLegends: string[];
}

export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartState> implements IChart {
Expand Down Expand Up @@ -135,8 +137,8 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt

this._createSet = memoizeFunction(this._createDataSet);
this.state = {
selectedLegend: props.legendProps?.selectedLegend ?? '',
activeLegend: '',
selectedLegends: props.legendProps?.selectedLegends || [],
activeLegend: undefined,
hoverXValue: '',
isCalloutVisible: false,
refSelected: null,
Expand All @@ -163,9 +165,9 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
}

public componentDidUpdate(prevProps: IAreaChartProps): void {
if (prevProps.legendProps?.selectedLegend !== this.props.legendProps?.selectedLegend) {
if (!areArraysEqual(prevProps.legendProps?.selectedLegends, this.props.legendProps?.selectedLegends)) {
this.setState({
selectedLegend: this.props.legendProps?.selectedLegend ?? '',
selectedLegends: this.props.legendProps?.selectedLegends || [],
});
}

Expand Down Expand Up @@ -310,6 +312,7 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
const { data } = this.props;
const { lineChartData } = data;
// This will get the value of the X when mouse is on the chart
const { selectedLegends } = this.state;
const xOffset = this._xAxisRectScale.invert(pointer(mouseEvent)[0], document.getElementById(this._rectId)!);
const i = bisect(lineChartData![0].data, xOffset);
const d0 = lineChartData![0].data[i - 1] as ILineChartDataPoint;
Expand Down Expand Up @@ -361,16 +364,20 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
const pointToHighlightUpdated = this.state.nearestCircleToHighlight !== nearestCircleToHighlight;
// if no points need to be called out then don't show vertical line and callout card
if (found && pointToHighlightUpdated && !this.state.isShowCalloutPending) {
const filteredValues =
selectedLegends.length > 0
? found.values.filter((value: { legend: string }) => selectedLegends.includes(value.legend))
: found.values;
this.setState({
nearestCircleToHighlight,
isCalloutVisible: false,
isShowCalloutPending: true,
lineXValue: this._xAxisRectScale(pointToHighlight),
displayOfLine: InterceptVisibility.show,
isCircleClicked: false,
stackCalloutProps: found!,
YValueHover: found.values,
dataPointCalloutProps: found!,
stackCalloutProps: { ...found, values: filteredValues },
YValueHover: filteredValues,
dataPointCalloutProps: { ...found, values: filteredValues },
hoverXValue: xAxisCalloutData ? xAxisCalloutData : formattedDate,
xAxisCalloutAccessibilityData,
activePoint: '',
Expand Down Expand Up @@ -583,18 +590,6 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
this._chart = this._drawGraph(containerHeight, xAxis, yAxis, xElement!);
};

private _onLegendClick(legend: string): void {
if (this.state.selectedLegend === legend) {
this.setState({
selectedLegend: '',
});
} else {
this.setState({
selectedLegend: legend,
});
}
}

private _onLegendHover(legend: string): void {
this.setState({
activeLegend: legend,
Expand All @@ -603,7 +598,7 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt

private _onLegendLeave(): void {
this.setState({
activeLegend: '',
activeLegend: undefined,
});
}

Expand All @@ -623,9 +618,6 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
const legend: ILegend = {
title: singleChartData.legend,
color,
action: () => {
this._onLegendClick(singleChartData.legend);
},
hoverAction: () => {
this._handleChartMouseLeave();
this._onLegendHover(singleChartData.legend);
Expand All @@ -644,10 +636,26 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
enabledWrapLines={this.props.enabledLegendsWrapLines}
focusZonePropsInHoverCard={this.props.focusZonePropsForLegendsInHoverCard}
{...this.props.legendProps}
onChange={this._onLegendSelectionChange.bind(this)}
/>
);
};

private _onLegendSelectionChange(
selectedLegends: string[],
event: React.MouseEvent<HTMLButtonElement>,
currentLegend?: ILegend,
): void {
if (this.props.legendProps?.canSelectMultipleLegends) {
this.setState({ selectedLegends });
} else {
this.setState({ selectedLegends: selectedLegends.slice(-1) });
}
if (this.props.legendProps?.onChange) {
this.props.legendProps.onChange(selectedLegends, event, currentLegend);
}
}

private _onDataPointClick = (func: (() => void) | undefined) => {
if (func) {
func();
Expand Down Expand Up @@ -799,6 +807,7 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
const circleId = `${this._circleId}_${index * this._stackedData[0].length + pointIndex}`;
const xDataPoint = singlePoint.xVal instanceof Date ? singlePoint.xVal.getTime() : singlePoint.xVal;
lineColor = points[index]!.color!;
const legend = points[index]!.legend;
return (
<circle
key={circleId}
Expand All @@ -815,7 +824,7 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
onFocus={() => this._handleFocus(index, pointIndex, circleId)}
onBlur={this._handleBlur}
{...getSecureProps(pointOptions)}
r={this._getCircleRadius(xDataPoint, circleRadius, circleId)}
r={this._getCircleRadius(xDataPoint, circleRadius, circleId, legend)}
role="img"
aria-label={this._getAriaLabel(index, pointIndex)}
/>
Expand All @@ -830,6 +839,7 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
if (this.state.nearestCircleToHighlight === xDataPoint) {
const circleId = `${this._circleId}_${index * this._stackedData[0].length + pointIndex}`;
lineColor = points[index]!.color!;
const legend = points[index]!.legend;
graph.push(
<circle
key={circleId}
Expand All @@ -843,7 +853,7 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
onMouseOver={this._onRectMouseMove}
onClick={this._onDataPointClick.bind(this, points[index]!.data[pointIndex].onDataPointClick!)}
{...getSecureProps(pointOptions)}
r={this._getCircleRadius(xDataPoint, circleRadius, circleId)}
r={this._getCircleRadius(xDataPoint, circleRadius, circleId, legend)}
/>,
);
}
Expand Down Expand Up @@ -893,8 +903,14 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
return graph;
};

private _getCircleRadius = (xDataPoint: number, circleRadius: number, circleId: string): number => {
private _getCircleRadius = (xDataPoint: number, circleRadius: number, circleId: string, legend: string): number => {
const { isCircleClicked, nearestCircleToHighlight, activePoint } = this.state;

// Show the circle if no legends are selected or if the point's legend is in the selected legends
if (!this._legendHighlighted(legend)) {
return 0;
}

if (isCircleClicked && nearestCircleToHighlight === xDataPoint) {
return 1;
} else if (nearestCircleToHighlight === xDataPoint || activePoint === circleId) {
Expand All @@ -917,18 +933,24 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
* 2. hovering: if there is no selected legend and the user hovers over it
*/
private _legendHighlighted = (legend: string) => {
return (
this.state.selectedLegend === legend || (this.state.selectedLegend === '' && this.state.activeLegend === legend)
);
return this._getHighlightedLegend().includes(legend!);
};

/**
* This function checks if none of the legends is selected or hovered.
*/
private _noLegendHighlighted = () => {
return this.state.selectedLegend === '' && this.state.activeLegend === '';
return this._getHighlightedLegend().length === 0;
};

private _getHighlightedLegend() {
return this.state.selectedLegends.length > 0
? this.state.selectedLegends
: this.state.activeLegend
? [this.state.activeLegend]
: [];
}

private _addDefaultColors = (lineChartData?: ILineChartPoints[]): ILineChartPoints[] => {
return lineChartData
? lineChartData.map((item, index) => {
Expand All @@ -953,18 +975,26 @@ export class AreaChartBase extends React.Component<IAreaChartProps, IAreaChartSt
const found: any = this._calloutPoints.find((e: { x: string | number }) => e.x === modifiedXVal);
// Show details in the callout for the focused point only
found.values = found.values.filter((e: { y: number }) => e.y === y);
const filteredValues = this._getFilteredLegendValues(found.values);

this.setState({
refSelected: `#${circleId}`,
isCalloutVisible: true,
hoverXValue: xAxisCalloutData ? xAxisCalloutData : formattedDate,
YValueHover: found.values,
stackCalloutProps: found,
dataPointCalloutProps: found,
YValueHover: filteredValues!,
stackCalloutProps: { ...found, values: filteredValues },
dataPointCalloutProps: { ...found, values: filteredValues },
activePoint: circleId,
});
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
private _getFilteredLegendValues = (values: any) => {
!this._noLegendHighlighted()
? values.filter((value: { legend: string }) => this._legendHighlighted(value.legend))
: values;
};

private _handleBlur = () => {
this.setState({
refSelected: null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,28 @@ describe('Area chart - Subcomponent legend', () => {
expect(firstLegend).toHaveAttribute('aria-selected', 'false');
},
);

testWithoutWait(
'Should select multiple legends on single mouse click on legends',
AreaChart,
{ data: chartData, legendProps: { canSelectMultipleLegends: true } },
container => {
const legend1 = screen.queryByText('legend1')?.closest('button');
expect(legend1).toBeDefined();
const legend2 = screen.queryByText('legend2')?.closest('button');
expect(legend2).toBeDefined();

fireEvent.click(legend1!);
fireEvent.click(legend2!);

// Assert
expect(legend1).toHaveAttribute('aria-selected', 'true');
expect(legend2).toHaveAttribute('aria-selected', 'true');
expect(getById(container, /graph-areaChart/i)[0]).toHaveAttribute('fill-opacity', '0.7');
expect(getById(container, /graph-areaChart/i)[1]).toHaveAttribute('fill-opacity', '0.7');
expect(getById(container, /graph-areaChart/i)[2]).toHaveAttribute('fill-opacity', '0.1');
},
);
});

describe('Area chart - Subcomponent callout', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ exports[`AreaChart - mouse events Should render callout correctly on mouseover 1
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
r={8}
r={0}
role="img"
stroke="red"
strokeWidth={3}
Expand Down Expand Up @@ -747,7 +747,7 @@ exports[`AreaChart - mouse events Should render customized callout on mouseover
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
r={8}
r={0}
role="img"
stroke="red"
strokeWidth={3}
Expand Down Expand Up @@ -1215,7 +1215,7 @@ exports[`AreaChart - mouse events Should render customized callout per stack on
onFocus={[Function]}
onMouseOut={[Function]}
onMouseOver={[Function]}
r={8}
r={0}
role="img"
stroke="red"
strokeWidth={3}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,12 @@ export const DeclarativeChart: React.FunctionComponent<DeclarativeChartProps> =
const isAreaChart = data.some((series: any) => series.fill === 'tonexty' || series.fill === 'tozeroy');
const renderChart = (chartProps: any) => {
if (isAreaChart) {
return <AreaChart {...chartProps} />;
return (
<AreaChart
{...chartProps}
legendProps={{ ...legendProps, canSelectMultipleLegends: true, selectedLegends: activeLegends }}
/>
);
}
return (
<LineChart
Expand Down
Loading

0 comments on commit a2f70fd

Please sign in to comment.