Time series/Bar chart panel: Add ability to sort series via legend (#40226)

* Make legend sorting work in Time series panel

* Import from schema

Add properties to the cue schema as well

* Order stacking

* Add tests for orderIdsByCalcs

* Add check for legend options

* Fix cue schema

* UI fixes

* Order bars as well in barchart

* Use different index when ordered

* Legend sort series doc

* Fix nits

* Update docs/sources/panels/legend-options.md

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>

* Fix linting

* Apply suggestions from code review

Co-authored-by: Ursula Kallio <73951760+osg-grafana@users.noreply.github.com>

* Update docs/sources/panels/legend-options.md

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Ursula Kallio <73951760+osg-grafana@users.noreply.github.com>
This commit is contained in:
Zoltán Bedi
2021-10-25 11:21:51 +02:00
committed by GitHub
parent d5de885633
commit e6d2324516
18 changed files with 340 additions and 62 deletions

View File

@@ -20,6 +20,7 @@ import {
toUtc,
} from '@grafana/data';
import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui';
import { VizLegendOptions } from '@grafana/schema';
import { selectors } from '@grafana/e2e-selectors';
import { PanelHeader } from './PanelHeader/PanelHeader';
@@ -89,6 +90,7 @@ export class PanelChrome extends PureComponent<Props, State> {
onAnnotationDelete: this.onAnnotationDelete,
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
onInstanceStateChange: this.onInstanceStateChange,
onToggleLegendSort: this.onToggleLegendSort,
},
data: this.getInitialPanelDataState(),
};
@@ -127,6 +129,35 @@ export class PanelChrome extends PureComponent<Props, State> {
);
};
onToggleLegendSort = (sortKey: string) => {
const legendOptions: VizLegendOptions = this.props.panel.options.legend;
// We don't want to do anything when legend options are not available
if (!legendOptions) {
return;
}
let sortDesc = legendOptions.sortDesc;
let sortBy = legendOptions.sortBy;
if (sortKey !== sortBy) {
sortDesc = undefined;
}
// if already sort ascending, disable sorting
if (sortDesc === false) {
sortBy = undefined;
sortDesc = undefined;
} else {
sortDesc = !sortDesc;
sortBy = sortKey;
}
this.onOptionsChange({
...this.props.panel.options,
legend: { ...legendOptions, sortBy, sortDesc },
});
};
getInitialPanelDataState(): PanelData {
return {
state: LoadingState.NotStarted,

View File

@@ -4,7 +4,7 @@ import { DataFrame, FieldType, TimeRange } from '@grafana/data';
import { GraphNG, GraphNGProps, PlotLegend, UPlotConfigBuilder, usePanelContext, useTheme2 } from '@grafana/ui';
import { LegendDisplayMode } from '@grafana/schema';
import { BarChartOptions } from './types';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { isLegendOrdered, preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { PropDiffFn } from '../../../../../packages/grafana-ui/src/components/GraphNG/GraphNG';
/**
@@ -20,6 +20,7 @@ const propsToDiff: Array<string | PropDiffFn> = [
'groupWidth',
'stacking',
'showValue',
'legend',
(prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize,
];
@@ -39,6 +40,11 @@ export const BarChart: React.FC<BarChartProps> = (props) => {
};
const rawValue = (seriesIdx: number, valueIdx: number) => {
// When sorted by legend state.seriesIndex is not changed and is not equal to the sorted index of the field
if (isLegendOrdered(props.legend)) {
return frame0Ref.current!.fields[seriesIdx].values.get(valueIdx);
}
let field = frame0Ref.current!.fields.find(
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIdx - 1
);

View File

@@ -14,11 +14,7 @@ interface Props extends PanelProps<BarChartOptions> {}
export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, width, height, timeZone }) => {
const theme = useTheme2();
const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options.stacking), [
data,
theme,
options.stacking,
]);
const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options), [data, theme, options]);
const orientation = useMemo(() => {
if (!options.orientation || options.orientation === VizOrientation.Auto) {
return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;

View File

@@ -3,7 +3,14 @@ import { pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, PlotTooltipInterpolator } from '@grafana/ui';
import { StackingMode, VisibilityMode, ScaleDirection, ScaleOrientation, VizTextDisplayOptions } from '@grafana/schema';
import {
StackingMode,
VisibilityMode,
ScaleDirection,
ScaleOrientation,
VizTextDisplayOptions,
VizLegendOptions,
} from '@grafana/schema';
import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
const groupDistr = SPACE_BETWEEN;
@@ -40,6 +47,7 @@ export interface BarsOptions {
text?: VizTextDisplayOptions;
onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (seriesIdx: number, valueIdx: number) => void;
legend?: VizLegendOptions;
}
/**
@@ -311,9 +319,13 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
function prepData(frames: DataFrame[]) {
alignedTotals = null;
return preparePlotData(frames, ({ totals }) => {
alignedTotals = totals;
});
return preparePlotData(
frames,
({ totals }) => {
alignedTotals = totals;
},
opts.legend
);
}
return {

View File

@@ -144,7 +144,7 @@ describe('BarChart utils', () => {
describe('prepareGraphableFrames', () => {
it('will warn when there is no data in the response', () => {
const result = prepareGraphableFrames([], createTheme(), StackingMode.None);
const result = prepareGraphableFrames([], createTheme(), { stacking: StackingMode.None } as any);
expect(result.warn).toEqual('No data in response');
});
@@ -155,7 +155,7 @@ describe('BarChart utils', () => {
{ name: 'value', values: [1, 2, 3, 4, 5] },
],
});
const result = prepareGraphableFrames([df], createTheme(), StackingMode.None);
const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any);
expect(result.warn).toEqual('Bar charts requires a string field');
expect(result.frames).toBeUndefined();
});
@@ -167,7 +167,7 @@ describe('BarChart utils', () => {
{ name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] },
],
});
const result = prepareGraphableFrames([df], createTheme(), StackingMode.None);
const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any);
expect(result.warn).toEqual('No numeric fields found');
expect(result.frames).toBeUndefined();
});
@@ -179,7 +179,7 @@ describe('BarChart utils', () => {
{ name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] },
],
});
const result = prepareGraphableFrames([df], createTheme(), StackingMode.None);
const result = prepareGraphableFrames([df], createTheme(), { stacking: StackingMode.None } as any);
const field = result.frames![0].fields[1];
expect(field!.values.toArray()).toMatchInlineSnapshot(`
@@ -192,5 +192,32 @@ describe('BarChart utils', () => {
]
`);
});
it('should sort fields when legend sortBy and sortDesc are set', () => {
const frame = new MutableDataFrame({
fields: [
{ name: 'string', type: FieldType.string, values: ['a', 'b', 'c'] },
{ name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } },
{ name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } },
{ name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } },
],
});
const resultAsc = prepareGraphableFrames([frame], createTheme(), {
legend: { sortBy: 'Min', sortDesc: false },
} as any);
expect(resultAsc.frames![0].fields[0].type).toBe(FieldType.string);
expect(resultAsc.frames![0].fields[1].name).toBe('a');
expect(resultAsc.frames![0].fields[2].name).toBe('c');
expect(resultAsc.frames![0].fields[3].name).toBe('b');
const resultDesc = prepareGraphableFrames([frame], createTheme(), {
legend: { sortBy: 'Min', sortDesc: true },
} as any);
expect(resultDesc.frames![0].fields[0].type).toBe(FieldType.string);
expect(resultDesc.frames![0].fields[1].name).toBe('b');
expect(resultDesc.frames![0].fields[2].name).toBe('c');
expect(resultDesc.frames![0].fields[3].name).toBe('a');
});
});
});

View File

@@ -13,9 +13,17 @@ import {
} from '@grafana/data';
import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from './types';
import { BarsOptions, getConfig } from './bars';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation, StackingMode } from '@grafana/schema';
import {
AxisPlacement,
ScaleDirection,
ScaleDistribution,
ScaleOrientation,
StackingMode,
VizLegendOptions,
} from '@grafana/schema';
import { FIXED_UNIT, UPlotConfigBuilder, UPlotConfigPrepFn } from '@grafana/ui';
import { collectStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { collectStackingGroups, orderIdsByCalcs } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { orderBy } from 'lodash';
/** @alpha */
function getBarCharScaleOrientation(orientation: VizOrientation) {
@@ -47,6 +55,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
text,
rawValue,
allFrames,
legend,
}) => {
const builder = new UPlotConfigBuilder();
const defaultValueFormatter = (seriesIdx: number, value: any) =>
@@ -73,6 +82,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
formatValue,
text,
showValue,
legend,
};
const config = getConfig(opts, theme);
@@ -108,14 +118,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
});
let seriesIndex = 0;
const legendOrdered = isLegendOrdered(legend);
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];
field.state!.seriesIndex = seriesIndex++;
seriesIndex++;
const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom };
@@ -144,9 +154,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
// PlotLegend currently gets unfiltered DataFrame[], so index must be into that field array, not the prepped frame's which we're iterating here
dataFrameFieldIndex: {
fieldIndex: allFrames[0].fields.findIndex(
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1
),
fieldIndex: legendOrdered
? i
: allFrames[0].fields.findIndex(
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1
),
frameIndex: 0,
},
});
@@ -192,7 +204,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
if (stackingGroups.size !== 0) {
builder.setStacking(true);
for (const [_, seriesIdxs] of stackingGroups.entries()) {
for (const [_, seriesIds] of stackingGroups.entries()) {
const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]],
@@ -229,7 +242,7 @@ export function preparePlotFrame(data: DataFrame[]) {
export function prepareGraphableFrames(
series: DataFrame[],
theme: GrafanaTheme2,
stacking: StackingMode
options: BarChartOptions
): { frames?: DataFrame[]; warn?: string } {
if (!series?.length) {
return { warn: 'No data in response' };
@@ -250,6 +263,7 @@ export function prepareGraphableFrames(
};
}
const legendOrdered = isLegendOrdered(options.legend);
let seriesIndex = 0;
for (let frame of series) {
@@ -268,7 +282,7 @@ export function prepareGraphableFrames(
...field.config.custom,
stacking: {
group: '_',
mode: stacking,
mode: options.stacking,
},
},
},
@@ -282,7 +296,7 @@ export function prepareGraphableFrames(
),
};
if (stacking === StackingMode.Percent) {
if (options.stacking === StackingMode.Percent) {
copy.config.unit = 'percentunit';
copy.display = getDisplayProcessor({ field: copy, theme });
}
@@ -293,11 +307,29 @@ export function prepareGraphableFrames(
}
}
let orderedFields: Field[] | undefined;
if (legendOrdered) {
orderedFields = orderBy(
fields,
({ state }) => {
return state?.calcs?.[options.legend.sortBy!.toLowerCase()];
},
options.legend.sortDesc ? 'desc' : 'asc'
);
// The string field needs to be the first one
if (orderedFields[orderedFields.length - 1].type === FieldType.string) {
orderedFields.unshift(orderedFields.pop()!);
}
}
frames.push({
...frame,
fields,
fields: orderedFields || fields,
});
}
return { frames };
}
export const isLegendOrdered = (options: VizLegendOptions) => Boolean(options?.sortBy && options.sortDesc !== null);