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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 340 additions and 62 deletions

View File

@ -10,8 +10,13 @@ Use the legend to adjust how a visualization displays series. This legend functi
This topic currently applies to the following visualizations:
- [Bar chart panel]({{< relref "../visualizations/bar-chart.md">}})
- [Histogram panel]({{< relref "../visualizations/histogram.md">}})
- [Pie chart panel]({{< relref "../visualizations/pie-chart-panel.md">}})
- [State timeline panel]({{< relref "../visualizations/state-timeline.md">}})
- [Status history panel]({{< relref "../visualizations/status-history.md">}})
- [Time series panel]({{< relref "../visualizations/time-series/_index.md" >}})
- XY chart panel
## Toggle series
@ -34,3 +39,12 @@ This creates a system override that hides the other series. You can view this ov
Click on the series icon (colored line beside the series label) in the legend to change selected series color.
![Change legend series color](/static/img/docs/legend/legend-series-color-7-5.png)
## Sort series
Change legend mode to **Table** and choose [calculations]({{< relref "./calculations-list.md" >}}) to be displayed in the legend. Click the calculation name header in the legend table to sort the values in the table in ascending or descending order.
The sort order affects the positions of the bars in the Bar chart panel as well as the order of stacked series in the Time series and Bar chart panels.
> **Note:** This feature is only supported in these panels: Bar chart, Histogram, Time series, XY Chart.
![Sort legend series](/static/img/docs/legend/legend-series-sort-8-3.png)

View File

@ -258,6 +258,8 @@ export interface VizLegendOptions {
displayMode: LegendDisplayMode;
isVisible?: boolean;
placement: LegendPlacement;
sortBy?: string;
sortDesc?: boolean;
}
export enum BarGaugeDisplayMode {

View File

@ -5,9 +5,11 @@ LegendPlacement: "bottom" | "right" @cuetsy(kind="type")
LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(kind="enum")
VizLegendOptions: {
displayMode: LegendDisplayMode
placement: LegendPlacement
displayMode: LegendDisplayMode
placement: LegendPlacement
asTable?: bool
isVisible?: bool
calcs: [...string]
sortBy?: string
sortDesc?: bool
calcs: [...string]
} @cuetsy(kind="interface")

View File

@ -59,6 +59,11 @@ export interface PanelContext {
/** Update instance state, this is only supported in dashboard panel context currently */
onInstanceStateChange?: (state: any) => void;
/**
* Called when a panel is changing the sort order of the legends.
*/
onToggleLegendSort?: (sortBy: string) => void;
}
export const PanelContextRoot = React.createContext<PanelContext>({

View File

@ -8,7 +8,7 @@ import { preparePlotConfigBuilder } from './utils';
import { withTheme2 } from '../../themes/ThemeContext';
import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext';
const propsToDiff: string[] = [];
const propsToDiff: string[] = ['legend'];
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>;
@ -18,7 +18,7 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventBus, sync } = this.context;
const { theme, timeZone } = this.props;
const { theme, timeZone, legend } = this.props;
return preparePlotConfigBuilder({
frame: alignedFrame,
@ -28,6 +28,7 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
eventBus,
sync,
allFrames,
legend,
});
};

View File

@ -23,8 +23,9 @@ import {
VisibilityMode,
ScaleDirection,
ScaleOrientation,
VizLegendOptions,
} from '@grafana/schema';
import { collectStackingGroups, preparePlotData } from '../uPlot/utils';
import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils';
import uPlot from 'uplot';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
@ -35,7 +36,7 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Auto,
};
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync }> = ({
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync; legend?: VizLegendOptions }> = ({
frame,
theme,
timeZone,
@ -43,10 +44,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
eventBus,
sync,
allFrames,
legend,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
builder.setPrepData(preparePlotData);
builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend));
// X is the first field in the aligned frame
const xField = frame.fields[0];
@ -265,7 +267,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
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]],

View File

@ -23,7 +23,7 @@ export function VizLegend<T>({
itemRenderer,
readonly,
}: LegendProps<T>) {
const { eventBus, onToggleSeriesVisibility } = usePanelContext();
const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext();
const onMouseEnter = useCallback(
(item: VizLegendItem, event: React.MouseEvent<HTMLElement, MouseEvent>) => {
@ -82,7 +82,7 @@ export function VizLegend<T>({
sortBy={sortKey}
sortDesc={sortDesc}
onLabelClick={onLegendLabelClick}
onToggleSort={onToggleSort}
onToggleSort={onToggleSort || onToggleLegendSort}
onLabelMouseEnter={onMouseEnter}
onLabelMouseOut={onMouseOut}
itemRenderer={itemRenderer}

View File

@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css';
import { VizLegendTableProps } from './types';
import { Icon } from '../Icon/Icon';
import { useStyles2 } from '../../themes/ThemeContext';
import { sortBy } from 'lodash';
import { orderBy } from 'lodash';
import { LegendTableItem } from './VizLegendTableItem';
import { DisplayValue, GrafanaTheme2 } from '@grafana/data';
@ -34,13 +34,17 @@ export const VizLegendTable = <T extends unknown>({
}
const sortedItems = sortKey
? sortBy(items, (item) => {
if (item.getDisplayValues) {
const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0];
return stat && stat.numeric;
}
return undefined;
})
? orderBy(
items,
(item) => {
if (item.getDisplayValues) {
const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0];
return stat && stat.numeric;
}
return undefined;
},
sortDesc ? 'desc' : 'asc'
)
: items;
if (!itemRenderer) {
@ -68,7 +72,9 @@ export const VizLegendTable = <T extends unknown>({
<th
title={displayValue.description}
key={columnTitle}
className={cx(styles.header, onToggleSort && styles.headerSortable)}
className={cx(styles.header, onToggleSort && styles.headerSortable, {
[styles.withIcon]: sortKey === columnTitle,
})}
onClick={() => {
if (onToggleSort) {
onToggleSort(columnTitle);
@ -76,9 +82,7 @@ export const VizLegendTable = <T extends unknown>({
}}
>
{columnTitle}
{sortKey === columnTitle && (
<Icon className={styles.sortIcon} name={sortDesc ? 'angle-down' : 'angle-up'} />
)}
{sortKey === columnTitle && <Icon size="xs" name={sortDesc ? 'angle-down' : 'angle-up'} />}
</th>
);
})}
@ -94,21 +98,23 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: 100%;
th:first-child {
width: 100%;
border-bottom: 1px solid ${theme.colors.border.weak};
}
`,
header: css`
color: ${theme.colors.primary.text};
font-weight: ${theme.typography.fontWeightMedium};
border-bottom: 1px solid ${theme.colors.border.weak};
padding: ${theme.spacing(0.25, 1)};
padding: ${theme.spacing(0.25, 2, 0.25, 1)};
font-size: ${theme.typography.bodySmall.fontSize};
text-align: right;
text-align: left;
white-space: nowrap;
`,
// This needs to be padding-right - icon size(xs==12) to avoid jumping
withIcon: css`
padding-right: 4px;
`,
headerSortable: css`
cursor: pointer;
`,
sortIcon: css`
margin-left: ${theme.spacing(1)};
`,
});

View File

@ -120,7 +120,7 @@ const getStyles = (theme: GrafanaTheme2) => {
align-items: center;
`,
value: css`
text-align: right;
text-align: left;
`,
yAxisLabel: css`
color: ${theme.colors.text.secondary};

View File

@ -85,7 +85,13 @@ export const PlotLegend: React.FC<PlotLegendProps> = ({
return (
<VizLayout.Legend placement={placement} {...vizLayoutLegendProps}>
<VizLegend placement={placement} items={legendItems} displayMode={displayMode} />
<VizLegend
placement={placement}
items={legendItems}
displayMode={displayMode}
sortBy={vizLayoutLegendProps.sortBy}
sortDesc={vizLayoutLegendProps.sortDesc}
/>
</VizLayout.Legend>
);
};

View File

@ -1,4 +1,4 @@
import { preparePlotData, timeFormatToTemplate } from './utils';
import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils';
import { FieldType, MutableDataFrame } from '@grafana/data';
import { StackingMode } from '@grafana/schema';
@ -295,5 +295,113 @@ describe('preparePlotData', () => {
]
`);
});
describe('with legend sorted', () => {
it('should affect when single group', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{
name: 'a',
values: [-10, 20, 10],
state: { calcs: { max: 20 } },
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'b',
values: [10, 10, 10],
state: { calcs: { max: 10 } },
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'c',
values: [20, 20, 20],
state: { calcs: { max: 20 } },
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
],
});
expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: false } as any)).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
],
Array [
0,
30,
20,
],
Array [
10,
10,
10,
],
Array [
20,
50,
40,
],
]
`);
expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: true } as any)).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
],
Array [
-10,
20,
10,
],
Array [
20,
50,
40,
],
Array [
10,
40,
30,
],
]
`);
});
});
});
});
describe('orderIdsByCalcs', () => {
const ids = [1, 2, 3, 4];
const frame = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{ 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 } } },
{ name: 'd', values: [30, 30, 30] },
],
});
it.each([
{ legend: undefined },
{ legend: { sortBy: 'Min' } },
{ legend: { sortDesc: false } },
{ legend: {} },
{ sortBy: 'Mik', sortDesc: true },
])('should return without ordering if legend option is %o', (legend: any) => {
const result = orderIdsByCalcs({ ids, frame, legend });
expect(result).toEqual([1, 2, 3, 4]);
});
it('should order the ids based on the frame stat', () => {
const resultDesc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: true } as any });
expect(resultDesc).toEqual([4, 2, 3, 1]);
const resultAsc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: false } as any });
expect(resultAsc).toEqual([1, 3, 2, 4]);
});
});

View File

@ -1,8 +1,9 @@
import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data';
import { StackingMode } from '@grafana/schema';
import { createLogger } from '../../utils/logger';
import { attachDebugger } from '../../utils';
import { StackingMode, VizLegendOptions } from '@grafana/schema';
import { orderBy } from 'lodash';
import { AlignedData, Options, PaddingSide } from 'uplot';
import { attachDebugger } from '../../utils';
import { createLogger } from '../../utils/logger';
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
@ -39,7 +40,11 @@ interface StackMeta {
}
/** @internal */
export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackMeta) => void): AlignedData {
export function preparePlotData(
frames: DataFrame[],
onStackMeta?: (meta: StackMeta) => void,
legend?: VizLegendOptions
): AlignedData {
const frame = frames[0];
const result: any[] = [];
const stackingGroups: Map<string, number[]> = new Map();
@ -67,7 +72,9 @@ export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackM
alignedTotals[0] = null;
// array or stacking groups
for (const [_, seriesIdxs] of stackingGroups.entries()) {
for (const [_, seriesIds] of stackingGroups.entries()) {
const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
const groupTotals = byPct ? Array(dataLength).fill(0) : null;
if (byPct) {
@ -184,3 +191,23 @@ export const pluginLogger = createLogger('uPlot');
export const pluginLog = pluginLogger.logger;
// pluginLogger.enable();
attachDebugger('graphng', undefined, pluginLogger);
type OrderIdsByCalcsOptions = {
legend?: VizLegendOptions;
ids: number[];
frame: DataFrame;
};
export function orderIdsByCalcs({ legend, ids, frame }: OrderIdsByCalcsOptions) {
if (!legend?.sortBy || legend.sortDesc == null) {
return ids;
}
const orderedIds = orderBy<number>(
ids,
(id) => {
return frame.fields[id].state?.calcs?.[legend.sortBy!.toLowerCase()];
},
legend.sortDesc ? 'desc' : 'asc'
);
return orderedIds;
}

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);