mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user