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
co-authored by Dominik Prokop Ursula Kallio
parent d5de885633
commit e6d2324516
18 changed files with 340 additions and 62 deletions
+14
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: 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">}}) - [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" >}}) - [Time series panel]({{< relref "../visualizations/time-series/_index.md" >}})
- XY chart panel
## Toggle series ## 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. 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) ![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)
@@ -258,6 +258,8 @@ export interface VizLegendOptions {
displayMode: LegendDisplayMode; displayMode: LegendDisplayMode;
isVisible?: boolean; isVisible?: boolean;
placement: LegendPlacement; placement: LegendPlacement;
sortBy?: string;
sortDesc?: boolean;
} }
export enum BarGaugeDisplayMode { export enum BarGaugeDisplayMode {
@@ -5,9 +5,11 @@ LegendPlacement: "bottom" | "right" @cuetsy(kind="type")
LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(kind="enum") LegendDisplayMode: "list" | "table" | "hidden" @cuetsy(kind="enum")
VizLegendOptions: { VizLegendOptions: {
displayMode: LegendDisplayMode displayMode: LegendDisplayMode
placement: LegendPlacement placement: LegendPlacement
asTable?: bool asTable?: bool
isVisible?: bool isVisible?: bool
calcs: [...string] sortBy?: string
sortDesc?: bool
calcs: [...string]
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
@@ -59,6 +59,11 @@ export interface PanelContext {
/** Update instance state, this is only supported in dashboard panel context currently */ /** Update instance state, this is only supported in dashboard panel context currently */
onInstanceStateChange?: (state: any) => void; 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>({ export const PanelContextRoot = React.createContext<PanelContext>({
@@ -8,7 +8,7 @@ import { preparePlotConfigBuilder } from './utils';
import { withTheme2 } from '../../themes/ThemeContext'; import { withTheme2 } from '../../themes/ThemeContext';
import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext'; import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext';
const propsToDiff: string[] = []; const propsToDiff: string[] = ['legend'];
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>; 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) => { prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventBus, sync } = this.context; const { eventBus, sync } = this.context;
const { theme, timeZone } = this.props; const { theme, timeZone, legend } = this.props;
return preparePlotConfigBuilder({ return preparePlotConfigBuilder({
frame: alignedFrame, frame: alignedFrame,
@@ -28,6 +28,7 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
eventBus, eventBus,
sync, sync,
allFrames, allFrames,
legend,
}); });
}; };
@@ -23,8 +23,9 @@ import {
VisibilityMode, VisibilityMode,
ScaleDirection, ScaleDirection,
ScaleOrientation, ScaleOrientation,
VizLegendOptions,
} from '@grafana/schema'; } from '@grafana/schema';
import { collectStackingGroups, preparePlotData } from '../uPlot/utils'; import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils';
import uPlot from 'uplot'; import uPlot from 'uplot';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
@@ -35,7 +36,7 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Auto, axisPlacement: AxisPlacement.Auto,
}; };
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync }> = ({ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursorSync; legend?: VizLegendOptions }> = ({
frame, frame,
theme, theme,
timeZone, timeZone,
@@ -43,10 +44,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
eventBus, eventBus,
sync, sync,
allFrames, allFrames,
legend,
}) => { }) => {
const builder = new UPlotConfigBuilder(timeZone); const builder = new UPlotConfigBuilder(timeZone);
builder.setPrepData(preparePlotData); builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend));
// X is the first field in the aligned frame // X is the first field in the aligned frame
const xField = frame.fields[0]; const xField = frame.fields[0];
@@ -265,7 +267,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
if (stackingGroups.size !== 0) { if (stackingGroups.size !== 0) {
builder.setStacking(true); 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--) { for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({ builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]], series: [seriesIdxs[j], seriesIdxs[j - 1]],
@@ -23,7 +23,7 @@ export function VizLegend<T>({
itemRenderer, itemRenderer,
readonly, readonly,
}: LegendProps<T>) { }: LegendProps<T>) {
const { eventBus, onToggleSeriesVisibility } = usePanelContext(); const { eventBus, onToggleSeriesVisibility, onToggleLegendSort } = usePanelContext();
const onMouseEnter = useCallback( const onMouseEnter = useCallback(
(item: VizLegendItem, event: React.MouseEvent<HTMLElement, MouseEvent>) => { (item: VizLegendItem, event: React.MouseEvent<HTMLElement, MouseEvent>) => {
@@ -82,7 +82,7 @@ export function VizLegend<T>({
sortBy={sortKey} sortBy={sortKey}
sortDesc={sortDesc} sortDesc={sortDesc}
onLabelClick={onLegendLabelClick} onLabelClick={onLegendLabelClick}
onToggleSort={onToggleSort} onToggleSort={onToggleSort || onToggleLegendSort}
onLabelMouseEnter={onMouseEnter} onLabelMouseEnter={onMouseEnter}
onLabelMouseOut={onMouseOut} onLabelMouseOut={onMouseOut}
itemRenderer={itemRenderer} itemRenderer={itemRenderer}
@@ -3,7 +3,7 @@ import { css, cx } from '@emotion/css';
import { VizLegendTableProps } from './types'; import { VizLegendTableProps } from './types';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { useStyles2 } from '../../themes/ThemeContext'; import { useStyles2 } from '../../themes/ThemeContext';
import { sortBy } from 'lodash'; import { orderBy } from 'lodash';
import { LegendTableItem } from './VizLegendTableItem'; import { LegendTableItem } from './VizLegendTableItem';
import { DisplayValue, GrafanaTheme2 } from '@grafana/data'; import { DisplayValue, GrafanaTheme2 } from '@grafana/data';
@@ -34,13 +34,17 @@ export const VizLegendTable = <T extends unknown>({
} }
const sortedItems = sortKey const sortedItems = sortKey
? sortBy(items, (item) => { ? orderBy(
if (item.getDisplayValues) { items,
const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0]; (item) => {
return stat && stat.numeric; if (item.getDisplayValues) {
} const stat = item.getDisplayValues().filter((stat) => stat.title === sortKey)[0];
return undefined; return stat && stat.numeric;
}) }
return undefined;
},
sortDesc ? 'desc' : 'asc'
)
: items; : items;
if (!itemRenderer) { if (!itemRenderer) {
@@ -68,7 +72,9 @@ export const VizLegendTable = <T extends unknown>({
<th <th
title={displayValue.description} title={displayValue.description}
key={columnTitle} key={columnTitle}
className={cx(styles.header, onToggleSort && styles.headerSortable)} className={cx(styles.header, onToggleSort && styles.headerSortable, {
[styles.withIcon]: sortKey === columnTitle,
})}
onClick={() => { onClick={() => {
if (onToggleSort) { if (onToggleSort) {
onToggleSort(columnTitle); onToggleSort(columnTitle);
@@ -76,9 +82,7 @@ export const VizLegendTable = <T extends unknown>({
}} }}
> >
{columnTitle} {columnTitle}
{sortKey === columnTitle && ( {sortKey === columnTitle && <Icon size="xs" name={sortDesc ? 'angle-down' : 'angle-up'} />}
<Icon className={styles.sortIcon} name={sortDesc ? 'angle-down' : 'angle-up'} />
)}
</th> </th>
); );
})} })}
@@ -94,21 +98,23 @@ const getStyles = (theme: GrafanaTheme2) => ({
width: 100%; width: 100%;
th:first-child { th:first-child {
width: 100%; width: 100%;
border-bottom: 1px solid ${theme.colors.border.weak};
} }
`, `,
header: css` header: css`
color: ${theme.colors.primary.text}; color: ${theme.colors.primary.text};
font-weight: ${theme.typography.fontWeightMedium}; font-weight: ${theme.typography.fontWeightMedium};
border-bottom: 1px solid ${theme.colors.border.weak}; 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}; font-size: ${theme.typography.bodySmall.fontSize};
text-align: right; text-align: left;
white-space: nowrap; white-space: nowrap;
`, `,
// This needs to be padding-right - icon size(xs==12) to avoid jumping
withIcon: css`
padding-right: 4px;
`,
headerSortable: css` headerSortable: css`
cursor: pointer; cursor: pointer;
`, `,
sortIcon: css`
margin-left: ${theme.spacing(1)};
`,
}); });
@@ -120,7 +120,7 @@ const getStyles = (theme: GrafanaTheme2) => {
align-items: center; align-items: center;
`, `,
value: css` value: css`
text-align: right; text-align: left;
`, `,
yAxisLabel: css` yAxisLabel: css`
color: ${theme.colors.text.secondary}; color: ${theme.colors.text.secondary};
@@ -85,7 +85,13 @@ export const PlotLegend: React.FC<PlotLegendProps> = ({
return ( return (
<VizLayout.Legend placement={placement} {...vizLayoutLegendProps}> <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> </VizLayout.Legend>
); );
}; };
@@ -1,4 +1,4 @@
import { preparePlotData, timeFormatToTemplate } from './utils'; import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils';
import { FieldType, MutableDataFrame } from '@grafana/data'; import { FieldType, MutableDataFrame } from '@grafana/data';
import { StackingMode } from '@grafana/schema'; 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]);
}); });
}); });
@@ -1,8 +1,9 @@
import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data'; import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data';
import { StackingMode } from '@grafana/schema'; import { StackingMode, VizLegendOptions } from '@grafana/schema';
import { createLogger } from '../../utils/logger'; import { orderBy } from 'lodash';
import { attachDebugger } from '../../utils';
import { AlignedData, Options, PaddingSide } from 'uplot'; 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; 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 */ /** @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 frame = frames[0];
const result: any[] = []; const result: any[] = [];
const stackingGroups: Map<string, number[]> = new Map(); const stackingGroups: Map<string, number[]> = new Map();
@@ -67,7 +72,9 @@ export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackM
alignedTotals[0] = null; alignedTotals[0] = null;
// array or stacking groups // 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; const groupTotals = byPct ? Array(dataLength).fill(0) : null;
if (byPct) { if (byPct) {
@@ -184,3 +191,23 @@ export const pluginLogger = createLogger('uPlot');
export const pluginLog = pluginLogger.logger; export const pluginLog = pluginLogger.logger;
// pluginLogger.enable(); // pluginLogger.enable();
attachDebugger('graphng', undefined, pluginLogger); 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;
}
@@ -20,6 +20,7 @@ import {
toUtc, toUtc,
} from '@grafana/data'; } from '@grafana/data';
import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui'; import { ErrorBoundary, PanelContext, PanelContextProvider, SeriesVisibilityChangeMode } from '@grafana/ui';
import { VizLegendOptions } from '@grafana/schema';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { PanelHeader } from './PanelHeader/PanelHeader'; import { PanelHeader } from './PanelHeader/PanelHeader';
@@ -89,6 +90,7 @@ export class PanelChrome extends PureComponent<Props, State> {
onAnnotationDelete: this.onAnnotationDelete, onAnnotationDelete: this.onAnnotationDelete,
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable), canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
onInstanceStateChange: this.onInstanceStateChange, onInstanceStateChange: this.onInstanceStateChange,
onToggleLegendSort: this.onToggleLegendSort,
}, },
data: this.getInitialPanelDataState(), 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 { getInitialPanelDataState(): PanelData {
return { return {
state: LoadingState.NotStarted, 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 { GraphNG, GraphNGProps, PlotLegend, UPlotConfigBuilder, usePanelContext, useTheme2 } from '@grafana/ui';
import { LegendDisplayMode } from '@grafana/schema'; import { LegendDisplayMode } from '@grafana/schema';
import { BarChartOptions } from './types'; 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'; import { PropDiffFn } from '../../../../../packages/grafana-ui/src/components/GraphNG/GraphNG';
/** /**
@@ -20,6 +20,7 @@ const propsToDiff: Array<string | PropDiffFn> = [
'groupWidth', 'groupWidth',
'stacking', 'stacking',
'showValue', 'showValue',
'legend',
(prev: BarChartProps, next: BarChartProps) => next.text?.valueSize === prev.text?.valueSize, (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) => { 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( let field = frame0Ref.current!.fields.find(
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIdx - 1 (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 }) => { export const BarChartPanel: React.FunctionComponent<Props> = ({ data, options, width, height, timeZone }) => {
const theme = useTheme2(); const theme = useTheme2();
const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options.stacking), [ const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options), [data, theme, options]);
data,
theme,
options.stacking,
]);
const orientation = useMemo(() => { const orientation = useMemo(() => {
if (!options.orientation || options.orientation === VizOrientation.Auto) { if (!options.orientation || options.orientation === VizOrientation.Auto) {
return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
+16 -4
View File
@@ -3,7 +3,14 @@ import { pointWithin, Quadtree, Rect } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute'; import { distribute, SPACE_BETWEEN } from './distribute';
import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { calculateFontSize, PlotTooltipInterpolator } from '@grafana/ui'; 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'; import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
const groupDistr = SPACE_BETWEEN; const groupDistr = SPACE_BETWEEN;
@@ -40,6 +47,7 @@ export interface BarsOptions {
text?: VizTextDisplayOptions; text?: VizTextDisplayOptions;
onHover?: (seriesIdx: number, valueIdx: number) => void; onHover?: (seriesIdx: number, valueIdx: number) => void;
onLeave?: (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[]) { function prepData(frames: DataFrame[]) {
alignedTotals = null; alignedTotals = null;
return preparePlotData(frames, ({ totals }) => { return preparePlotData(
alignedTotals = totals; frames,
}); ({ totals }) => {
alignedTotals = totals;
},
opts.legend
);
} }
return { return {
@@ -144,7 +144,7 @@ describe('BarChart utils', () => {
describe('prepareGraphableFrames', () => { describe('prepareGraphableFrames', () => {
it('will warn when there is no data in the response', () => { 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'); expect(result.warn).toEqual('No data in response');
}); });
@@ -155,7 +155,7 @@ describe('BarChart utils', () => {
{ name: 'value', values: [1, 2, 3, 4, 5] }, { 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.warn).toEqual('Bar charts requires a string field');
expect(result.frames).toBeUndefined(); expect(result.frames).toBeUndefined();
}); });
@@ -167,7 +167,7 @@ describe('BarChart utils', () => {
{ name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] }, { 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.warn).toEqual('No numeric fields found');
expect(result.frames).toBeUndefined(); expect(result.frames).toBeUndefined();
}); });
@@ -179,7 +179,7 @@ describe('BarChart utils', () => {
{ name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] }, { 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]; const field = result.frames![0].fields[1];
expect(field!.values.toArray()).toMatchInlineSnapshot(` 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');
});
}); });
}); });
+44 -12
View File
@@ -13,9 +13,17 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from './types'; import { BarChartFieldConfig, BarChartOptions, defaultBarChartFieldConfig } from './types';
import { BarsOptions, getConfig } from './bars'; 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 { 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 */ /** @alpha */
function getBarCharScaleOrientation(orientation: VizOrientation) { function getBarCharScaleOrientation(orientation: VizOrientation) {
@@ -47,6 +55,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
text, text,
rawValue, rawValue,
allFrames, allFrames,
legend,
}) => { }) => {
const builder = new UPlotConfigBuilder(); const builder = new UPlotConfigBuilder();
const defaultValueFormatter = (seriesIdx: number, value: any) => const defaultValueFormatter = (seriesIdx: number, value: any) =>
@@ -73,6 +82,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
formatValue, formatValue,
text, text,
showValue, showValue,
legend,
}; };
const config = getConfig(opts, theme); const config = getConfig(opts, theme);
@@ -108,14 +118,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
}); });
let seriesIndex = 0; let seriesIndex = 0;
const legendOrdered = isLegendOrdered(legend);
const stackingGroups: Map<string, number[]> = new Map(); const stackingGroups: Map<string, number[]> = new Map();
// iterate the y values // iterate the y values
for (let i = 1; i < frame.fields.length; i++) { for (let i = 1; i < frame.fields.length; i++) {
const field = frame.fields[i]; const field = frame.fields[i];
field.state!.seriesIndex = seriesIndex++; seriesIndex++;
const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; 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 // 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 // PlotLegend currently gets unfiltered DataFrame[], so index must be into that field array, not the prepped frame's which we're iterating here
dataFrameFieldIndex: { dataFrameFieldIndex: {
fieldIndex: allFrames[0].fields.findIndex( fieldIndex: legendOrdered
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1 ? i
), : allFrames[0].fields.findIndex(
(f) => f.type === FieldType.number && f.state?.seriesIndex === seriesIndex - 1
),
frameIndex: 0, frameIndex: 0,
}, },
}); });
@@ -192,7 +204,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
if (stackingGroups.size !== 0) { if (stackingGroups.size !== 0) {
builder.setStacking(true); 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--) { for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({ builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]], series: [seriesIdxs[j], seriesIdxs[j - 1]],
@@ -229,7 +242,7 @@ export function preparePlotFrame(data: DataFrame[]) {
export function prepareGraphableFrames( export function prepareGraphableFrames(
series: DataFrame[], series: DataFrame[],
theme: GrafanaTheme2, theme: GrafanaTheme2,
stacking: StackingMode options: BarChartOptions
): { frames?: DataFrame[]; warn?: string } { ): { frames?: DataFrame[]; warn?: string } {
if (!series?.length) { if (!series?.length) {
return { warn: 'No data in response' }; return { warn: 'No data in response' };
@@ -250,6 +263,7 @@ export function prepareGraphableFrames(
}; };
} }
const legendOrdered = isLegendOrdered(options.legend);
let seriesIndex = 0; let seriesIndex = 0;
for (let frame of series) { for (let frame of series) {
@@ -268,7 +282,7 @@ export function prepareGraphableFrames(
...field.config.custom, ...field.config.custom,
stacking: { stacking: {
group: '_', 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.config.unit = 'percentunit';
copy.display = getDisplayProcessor({ field: copy, theme }); 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({ frames.push({
...frame, ...frame,
fields, fields: orderedFields || fields,
}); });
} }
return { frames }; return { frames };
} }
export const isLegendOrdered = (options: VizLegendOptions) => Boolean(options?.sortBy && options.sortDesc !== null);