Skip to content

Commit

Permalink
Charting: Adding support for bar gaps and rounded corners in Vertical…
Browse files Browse the repository at this point in the history
…StackedBarChart (#15704)

Cherry-pick of #15606.
  • Loading branch information
khmakoto authored Oct 26, 2020
1 parent 0fe2bfa commit 8fa8f36
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 49 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"type": "prerelease",
"comment": "Charting: Adding support for bar gaps and rounded corners in VerticalStackedBarChart.",
"packageName": "@fluentui/react-charting",
"email": "[email protected]",
"dependentChangeType": "patch",
"date": "2020-10-26T23:15:45.984Z"
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { max as d3Max } from 'd3-array';
import { Axis as D3Axis } from 'd3-axis';
import { scaleLinear as d3ScaleLinear, ScaleLinear as D3ScaleLinear } from 'd3-scale';
import { classNamesFunction, getId, getRTL, find, warnDeprecations } from '@fluentui/react/lib/Utilities';
import { IProcessedStyleSet, IPalette } from '@fluentui/react/lib/Styling';
import { IPalette } from '@fluentui/react/lib/Styling';
import { DirectionalHint } from '@fluentui/react/lib/Callout';
import { ILegend, Legends } from '../Legends/index';
import {
Expand All @@ -28,6 +28,12 @@ type NumericScale = D3ScaleLinear<number, number>;
type StringScale = D3ScaleLinear<string, string>;
const COMPONENT_NAME = 'VERTICAL STACKED BAR CHART';

// When displaying gaps between bars, the max height of the gap is given in the
// props. The actual gap is calculated with this multiplier, with a minimum gap
// of 1 pixel. (If these values are changed, update the comment for barGapMax.)
const barGapMultiplier = 0.2;
const barGapMin = 1;

interface IRefArrayData {
refElement?: SVGGElement | null;
}
Expand All @@ -48,7 +54,6 @@ export class VerticalStackedBarChartBase extends React.Component<
private _isNumeric: boolean;
private _barWidth: number;
private _calloutId: string;
private _classNames: IProcessedStyleSet<IVerticalStackedBarChartStyles>;
private _colors: string[];
private margins: IMargins;
private _isRtl: boolean = getRTL();
Expand Down Expand Up @@ -96,11 +101,6 @@ export class VerticalStackedBarChartBase extends React.Component<
this._isNumeric = this._dataset.length > 0 && typeof this._dataset[0].x === 'number';
const legendBars: JSX.Element = this._getLegendData(this._points, this.props.theme!.palette);

this._classNames = getClassNames(this.props.styles!, {
href: this.props.href!,
theme: this.props.theme!,
});

const calloutProps = {
isCalloutVisible: this.state.isCalloutVisible,
directionalHint: DirectionalHint.topRightEdge,
Expand Down Expand Up @@ -366,28 +366,37 @@ export class VerticalStackedBarChartBase extends React.Component<
yBarScale: NumericScale,
containerHeight: number,
): JSX.Element[] => {
const { barGapMax = 0, barCornerRadius = 0 } = this.props;

const bars = this._points.map((singleChartData: IVerticalStackedChartProps, indexNumber: number) => {
let startingPointOfY = 0;
let yPoint = containerHeight - this.margins.bottom!;
const isCalloutForStack = this.props.isCalloutForStack || false;
const xPoint = xBarScale(this._isNumeric ? (singleChartData.xAxisPoint as number) : indexNumber);

let xPoint: number | string;
if (this._isNumeric) {
xPoint = xBarScale(singleChartData.xAxisPoint as number);
} else {
xPoint = xBarScale(indexNumber);
}
// Removing datapoints with zero data
const nonZeroBars = singleChartData.chartData.filter(point => point.data > 0);

// When displaying gaps between the bars, the height of each bar is
// adjusted so that the total of all bars is not changed by the gaps
const totalData = nonZeroBars.reduce((iter, value) => iter + value.data, 0);
const totalHeight = yBarScale(totalData);
const spaces = barGapMax && nonZeroBars.length - 1;
const spaceHeight = spaces && Math.max(barGapMin, Math.min(barGapMax, (totalHeight * barGapMultiplier) / spaces));
const heightValueRatio = (totalHeight - spaceHeight * spaces) / totalData;

if (heightValueRatio < 0) {
return undefined;
}

const singleBar = nonZeroBars.map((point: IVSChartDataPoint, index: number) => {
startingPointOfY = startingPointOfY + point.data;
const color = point.color ? point.color : this._colors[index];
const ref: IRefArrayData = {};

let shouldHighlight = true;
if (this.state.isLegendHovered || this.state.isLegendSelected) {
shouldHighlight = this.state.selectedLegendTitle === point.legend;
}
this._classNames = getClassNames(this.props.styles!, {
const classNames = getClassNames(this.props.styles!, {
theme: this.props.theme!,
shouldHighlight: shouldHighlight,
href: this.props.href,
Expand All @@ -402,14 +411,40 @@ export class VerticalStackedBarChartBase extends React.Component<
onBlur: this._handleMouseOut,
onClick: this._redirectToUrl,
};

const barHeight = heightValueRatio * point.data;
yPoint = yPoint - barHeight - (index ? spaceHeight : 0);

// If set, apply the corner radius to the top of the final bar
if (barCornerRadius && barHeight > barCornerRadius && index === nonZeroBars.length - 1) {
return (
<path
key={index + indexNumber}
className={classNames.opacityChangeOnHover}
d={`
M ${xPoint} ${yPoint + barCornerRadius}
a ${barCornerRadius} ${barCornerRadius} 0 0 1 ${barCornerRadius} ${-barCornerRadius}
h ${this._barWidth - 2 * barCornerRadius}
a ${barCornerRadius} ${barCornerRadius} 0 0 1 ${barCornerRadius} ${barCornerRadius}
v ${barHeight - barCornerRadius}
h ${-this._barWidth}
z
`}
fill={color}
ref={e => (ref.refElement = e)}
{...rectFocusProps}
/>
);
}

return (
<rect
key={index + indexNumber}
className={this._classNames.opacityChangeOnHover}
className={classNames.opacityChangeOnHover}
x={xPoint}
y={containerHeight - this.margins.bottom! - yBarScale(startingPointOfY)}
y={yPoint}
width={this._barWidth}
height={Math.max(yBarScale(point.data), 0)}
height={barHeight}
fill={color}
ref={e => (ref.refElement = e)}
{...rectFocusProps}
Expand All @@ -427,18 +462,13 @@ export class VerticalStackedBarChartBase extends React.Component<
onClick: this._redirectToUrl,
};
return (
<g
key={indexNumber}
id={`${indexNumber}-singleBar`}
data-is-focusable={this.props.isCalloutForStack}
ref={e => (groupRef.refElement = e)}
{...stackFocusProps}
>
<g key={indexNumber} id={`${indexNumber}-singleBar`} ref={e => (groupRef.refElement = e)} {...stackFocusProps}>
{singleBar}
</g>
);
});
return bars;

return bars.filter((bar): bar is JSX.Element => !!bar);
};

private _createNumericBars = (containerHeight: number, containerWidth: number): JSX.Element[] => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ export interface IVerticalStackedBarChartProps extends ICartesianChartProps {
*/
barWidth?: number;

/**
* Gap (max) between bars in a stack. When non-zero, the bars in a stack will
* be separated by gaps. The actual size of each gap is calculated as 20% of
* the height of that stack, with a minimum size of 1px and a maximum given by
* this prop.
* @default 0
*/
barGapMax?: number;

/**
* Corner radius of the bars
* @default 0
*/
barCornerRadius?: number;

/**
* Colors from which to select the color of each bar.
* @deprecated Not using this prop. DIrectly taking color from given data.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ import {
IVerticalStackedBarChartProps,
} from '@fluentui/react-charting';
import { DefaultPalette, IStyle, DefaultFontStyles } from '@fluentui/react/lib/Styling';
import { DirectionalHint } from '@fluentui/react';
import { ChoiceGroup, DirectionalHint, IChoiceGroupOption } from '@fluentui/react';

const options: IChoiceGroupOption[] = [
{ key: 'singleCallout', text: 'Single callout' },
{ key: 'MultiCallout', text: 'Stack callout' },
];

interface IVerticalStackedBarState {
width: number;
height: number;
barGapMax: number;
barCornerRadius: number;
selectedCallout: string;
}

export class VerticalStackedBarChartStyledExample extends React.Component<{}, IVerticalStackedBarState> {
Expand All @@ -20,23 +28,19 @@ export class VerticalStackedBarChartStyledExample extends React.Component<{}, IV
this.state = {
width: 650,
height: 350,
barGapMax: 2,
barCornerRadius: 2,
selectedCallout: 'MultiCallout',
};
}
public render(): JSX.Element {
return <div>{this._basicExample()}</div>;
}

private _onWidthChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ width: parseInt(e.target.value, 10) });
};
private _onHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
this.setState({ height: parseInt(e.target.value, 10) });
};

private _basicExample(): JSX.Element {
const firstChartPoints: IVSChartDataPoint[] = [
{ legend: 'Metadata1', data: 40, color: DefaultPalette.accent },
{ legend: 'Metadata2', data: 5, color: DefaultPalette.blueMid },
{ legend: 'Metadata1', data: 2, color: DefaultPalette.accent },
{ legend: 'Metadata2', data: 1, color: DefaultPalette.blueMid },
{ legend: 'Metadata3', data: 0, color: DefaultPalette.blueLight },
];

Expand Down Expand Up @@ -76,7 +80,7 @@ export class VerticalStackedBarChartStyledExample extends React.Component<{}, IV
return {
xAxis: {
selectors: {
text: { fill: 'black', fontSize: '8px' },
text: { fill: 'black', fontSize: '10px' },
},
},
chart: {
Expand All @@ -94,31 +98,70 @@ export class VerticalStackedBarChartStyledExample extends React.Component<{}, IV

return (
<>
<label>change Width:</label>
<input type="range" value={this.state.width} min={200} max={1000} onChange={this._onWidthChange} />
<label>change Height:</label>
<input type="range" value={this.state.height} min={200} max={1000} onChange={this._onHeightChange} />
<div>
<label>Width:</label>
<input
type="range"
value={this.state.width}
min={200}
max={1000}
onChange={e => this.setState({ width: +e.target.value })}
/>
<label>Height:</label>
<input
type="range"
value={this.state.height}
min={200}
max={1000}
onChange={e => this.setState({ height: +e.target.value })}
/>
</div>
<div>
<label>BarGapMax:</label>
<input
type="range"
value={this.state.barGapMax}
min={0}
max={10}
onChange={e => this.setState({ barGapMax: +e.target.value })}
/>
<label>BarCornerRadius:</label>
<input
type="range"
value={this.state.barCornerRadius}
min={0}
max={10}
onChange={e => this.setState({ barCornerRadius: +e.target.value })}
/>
<ChoiceGroup
options={options}
defaultSelectedKey="MultiCallout"
// eslint-disable-next-line react/jsx-no-bind
onChange={(_ev, option) => option && this.setState({ selectedCallout: option.key })}
label="Pick one"
/>
</div>
<div style={rootStyle}>
<VerticalStackedBarChart
data={data}
height={this.state.height}
width={this.state.width}
{...this.state}
yAxisTickCount={10}
// Just test link
href={'www.google.com'}
// eslint-disable-next-line react/jsx-no-bind
styles={customStyles}
yMaxValue={120}
yMinValue={10}
isCalloutForStack={this.state.selectedCallout === 'MultiCallout'}
calloutProps={{
directionalHint: DirectionalHint.topCenter,
}}
// eslint-disable-next-line react/jsx-no-bind
yAxisTickFormat={(x: number | string) => `${x} h`}
margins={{
bottom: 0,
top: 0,
left: 0,
bottom: 35,
top: 10,
left: 35,
right: 0,
}}
legendProps={{
Expand All @@ -129,8 +172,8 @@ export class VerticalStackedBarChartStyledExample extends React.Component<{}, IV
},
},
}}
// eslint-disable-next-line react/jsx-no-bind, @typescript-eslint/no-explicit-any
onRenderCalloutPerDataPoint={(props: any) =>
// eslint-disable-next-line react/jsx-no-bind
onRenderCalloutPerDataPoint={props =>
props ? (
<ChartHoverCard
XValue={props.xAxisCalloutData}
Expand Down

0 comments on commit 8fa8f36

Please sign in to comment.