BarChart: support stacking options (#37083)

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Ryan McKinley 2021-07-21 23:07:50 -07:00 committed by GitHub
parent 40a87a7851
commit 8d06aaaf09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 98 additions and 15 deletions

View File

@ -20,7 +20,7 @@ export interface BarChartProps
extends BarChartOptions,
Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend' | 'theme'> {}
const propsToDiff: string[] = ['orientation', 'barWidth', 'groupWidth', 'showValue', 'text'];
const propsToDiff: string[] = ['orientation', 'barWidth', 'groupWidth', 'stacking', 'showValue', 'text'];
export const BarChart: React.FC<BarChartProps> = (props) => {
const theme = useTheme2();

View File

@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import { PanelProps, TimeRange, VizOrientation } from '@grafana/data';
import { TooltipPlugin } from '@grafana/ui';
import { StackingMode, TooltipDisplayMode, TooltipPlugin } from '@grafana/ui';
import { BarChartOptions } from './types';
import { BarChart } from './BarChart';
import { prepareGraphableFrames } from './utils';
@ -11,7 +11,10 @@ interface Props extends PanelProps<BarChartOptions> {}
* @alpha
*/
export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, width, height, timeZone }) => {
const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series), [data]);
const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, options.stacking), [
data,
options.stacking,
]);
const orientation = useMemo(() => {
if (!options.orientation || options.orientation === VizOrientation.Auto) {
return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
@ -20,6 +23,14 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, w
return options.orientation;
}, [width, height, options.orientation]);
// Force 'multi' tooltip setting or stacking mode
const tooltip = useMemo(() => {
if (options.stacking === StackingMode.Normal || options.stacking === StackingMode.Percent) {
return { ...options.tooltip, mode: TooltipDisplayMode.Multi };
}
return options.tooltip;
}, [options.tooltip, options.stacking]);
if (!frames || warn) {
return (
<div className="panel-empty">
@ -40,7 +51,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, w
orientation={orientation}
>
{(config, alignedFrame) => {
return <TooltipPlugin data={alignedFrame} config={config} mode={options.tooltip.mode} timeZone={timeZone} />;
return <TooltipPlugin data={alignedFrame} config={config} mode={tooltip.mode} timeZone={timeZone} />;
}}
</BarChart>
);

View File

@ -1,9 +1,16 @@
import uPlot, { Axis } from 'uplot';
import { pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
import { BarValueVisibility, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config';
import { GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, PlotTooltipInterpolator, VizTextDisplayOptions } from '@grafana/ui';
import {
calculateFontSize,
PlotTooltipInterpolator,
VizTextDisplayOptions,
StackingMode,
BarValueVisibility,
ScaleDirection,
ScaleOrientation,
} from '@grafana/ui';
const groupDistr = SPACE_BETWEEN;
const barDistr = SPACE_BETWEEN;
@ -33,6 +40,8 @@ export interface BarsOptions {
groupWidth: number;
barWidth: number;
showValue: BarValueVisibility;
stacking: StackingMode;
rawValue: (seriesIdx: number, valueIdx: number) => number | null;
formatValue: (seriesIdx: number, value: any) => string;
text?: VizTextDisplayOptions;
onHover?: (seriesIdx: number, valueIdx: number) => void;
@ -43,9 +52,10 @@ export interface BarsOptions {
* @internal
*/
export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
const { xOri, xDir: dir, groupWidth, barWidth, formatValue, showValue } = opts;
const { xOri, xDir: dir, groupWidth, barWidth, rawValue, formatValue, showValue } = opts;
const isXHorizontal = xOri === ScaleOrientation.Horizontal;
const hasAutoValueSize = !Boolean(opts.text?.valueSize);
const isStacked = opts.stacking !== StackingMode.None;
let qt: Quadtree;
let hovered: Rect | undefined = undefined;
@ -91,6 +101,22 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
return out;
};
let distrOne = (groupCount: number, barCount: number) => {
let out = Array.from({ length: barCount }, () => ({
offs: Array(groupCount).fill(0),
size: Array(groupCount).fill(0),
}));
distribute(groupCount, groupWidth, groupDistr, null, (groupIdx, groupOffPct, groupDimPct) => {
distribute(barCount, barWidth, barDistr, null, (barIdx, barOffPct, barDimPct) => {
out[barIdx].offs[groupIdx] = groupOffPct;
out[barIdx].size[groupIdx] = groupDimPct;
});
});
return out;
};
let barsPctLayout: Array<null | { offs: number[]; size: number[] }> = [];
let barRects: Rect[] = [];
@ -151,7 +177,12 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
s._paths = null;
});
barsPctLayout = ([null] as any).concat(distrTwo(u.data[0].length, u.data.length - 1));
if (isStacked) {
//barsPctLayout = [null as any].concat(distrOne(u.data.length - 1, u.data[0].length));
barsPctLayout = [null as any].concat(distrOne(u.data[0].length, u.data.length - 1));
} else {
barsPctLayout = [null as any].concat(distrTwo(u.data[0].length, u.data.length - 1));
}
barRects.length = 0;
vSpace = hSpace = Infinity;
};
@ -166,7 +197,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
let labelOffset = LABEL_OFFSET_MAX;
barRects.forEach((r, i) => {
texts[i] = formatValue(r.sidx, u.data[r.sidx][r.didx]);
texts[i] = formatValue(r.sidx, rawValue(r.sidx, r.didx));
labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? r.w : r.h)));
});
@ -201,7 +232,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
let curAlign: CanvasTextAlign, curBaseline: CanvasTextBaseline;
barRects.forEach((r, i) => {
let value = u.data[r.sidx][r.didx];
let value = rawValue(r.sidx, r.didx);
let text = texts[i];
if (value != null) {

View File

@ -86,6 +86,14 @@ export const plugin = new PanelPlugin<BarChartOptions, BarChartFieldConfig>(BarC
},
defaultValue: BarValueVisibility.Auto,
})
.addRadio({
path: 'stacking',
name: 'Stacking',
settings: {
options: graphFieldOptions.stacking,
},
defaultValue: StackingMode.None,
})
.addSliderInput({
path: 'groupWidth',
name: 'Group width',

View File

@ -143,7 +143,7 @@ describe('BarChart utils', () => {
describe('prepareGraphableFrames', () => {
it('will warn when there is no data in the response', () => {
const result = prepareGraphableFrames([]);
const result = prepareGraphableFrames([], StackingMode.None);
expect(result.warn).toEqual('No data in response');
});
@ -154,7 +154,7 @@ describe('BarChart utils', () => {
{ name: 'value', values: [1, 2, 3, 4, 5] },
],
});
const result = prepareGraphableFrames([df]);
const result = prepareGraphableFrames([df], StackingMode.None);
expect(result.warn).toEqual('Bar charts requires a string field');
expect(result.frames).toBeUndefined();
});
@ -166,7 +166,7 @@ describe('BarChart utils', () => {
{ name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] },
],
});
const result = prepareGraphableFrames([df]);
const result = prepareGraphableFrames([df], StackingMode.None);
expect(result.warn).toEqual('No numeric fields found');
expect(result.frames).toBeUndefined();
});
@ -178,7 +178,7 @@ describe('BarChart utils', () => {
{ name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] },
],
});
const result = prepareGraphableFrames([df]);
const result = prepareGraphableFrames([df], StackingMode.None);
const field = result.frames![0].fields[1];
expect(field!.values.toArray()).toMatchInlineSnapshot(`

View File

@ -17,9 +17,11 @@ import {
ScaleDirection,
ScaleDistribution,
ScaleOrientation,
StackingMode,
UPlotConfigBuilder,
UPlotConfigPrepFn,
} from '@grafana/ui';
import { collectStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
/** @alpha */
function getBarCharScaleOrientation(orientation: VizOrientation) {
@ -47,6 +49,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
showValue,
groupWidth,
barWidth,
stacking,
text,
}) => {
const builder = new UPlotConfigBuilder();
@ -69,6 +72,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
xDir: vizOrientation.xDir,
groupWidth,
barWidth,
stacking,
rawValue: (seriesIdx: number, valueIdx: number) => frame.fields[seriesIdx].values.get(valueIdx),
formatValue,
text,
showValue,
@ -106,6 +111,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
let seriesIndex = 0;
const stackingGroups: Map<string, number[]> = new Map();
// iterate the y values
for (let i = 1; i < frame.fields.length; i++) {
const field = frame.fields[i];
@ -173,6 +180,19 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
theme,
});
}
collectStackingGroups(field, stackingGroups, seriesIndex);
}
if (stackingGroups.size !== 0) {
builder.setStacking(true);
for (const [_, seriesIdxs] of stackingGroups.entries()) {
for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]],
});
}
}
}
return builder;
@ -200,7 +220,10 @@ export function preparePlotFrame(data: DataFrame[]) {
}
/** @internal */
export function prepareGraphableFrames(series: DataFrame[]): { frames?: DataFrame[]; warn?: string } {
export function prepareGraphableFrames(
series: DataFrame[],
stacking: StackingMode
): { frames?: DataFrame[]; warn?: string } {
if (!series?.length) {
return { warn: 'No data in response' };
}
@ -226,6 +249,16 @@ export function prepareGraphableFrames(series: DataFrame[]): { frames?: DataFram
if (field.type === FieldType.number) {
let copy = {
...field,
config: {
...field.config,
custom: {
...field.config.custom,
stacking: {
group: '_',
mode: stacking,
},
},
},
values: new ArrayVector(
field.values.toArray().map((v) => {
if (!(Number.isFinite(v) || v == null)) {