mirror of
https://github.com/grafana/grafana.git
synced 2025-02-13 17:15:40 -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:
parent
d5de885633
commit
e6d2324516
@ -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)
|
||||
|
@ -258,6 +258,8 @@ export interface VizLegendOptions {
|
||||
displayMode: LegendDisplayMode;
|
||||
isVisible?: boolean;
|
||||
placement: LegendPlacement;
|
||||
sortBy?: string;
|
||||
sortDesc?: boolean;
|
||||
}
|
||||
|
||||
export enum BarGaugeDisplayMode {
|
||||
|
@ -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")
|
||||
|
@ -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>({
|
||||
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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]],
|
||||
|
@ -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}
|
||||
|
@ -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)};
|
||||
`,
|
||||
});
|
||||
|
@ -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};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
Loading…
Reference in New Issue
Block a user