Histogram: Add support for stacking mode (#84693)

This commit is contained in:
Adela Almasan 2024-03-21 05:57:10 -06:00 committed by GitHub
parent b525de07cd
commit 25a058f7ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 175 additions and 52 deletions

View File

@ -5716,8 +5716,8 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "15"], [0, 0, 0, "Do not use any type assertions.", "15"],
[0, 0, 0, "Do not use any type assertions.", "16"] [0, 0, 0, "Do not use any type assertions.", "16"]
], ],
"public/app/plugins/panel/histogram/Histogram.tsx:5381": [ "public/app/plugins/panel/histogram/migrations.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Unexpected any. Specify a different type.", "0"]
], ],
"public/app/plugins/panel/live/LiveChannelEditor.tsx:5381": [ "public/app/plugins/panel/live/LiveChannelEditor.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "0"],

View File

@ -36,7 +36,7 @@ export const defaultOptions: Partial<Options> = {
bucketOffset: 0, bucketOffset: 0,
}; };
export interface FieldConfig extends common.AxisConfig, common.HideableFieldConfig { export interface FieldConfig extends common.AxisConfig, common.HideableFieldConfig, common.StackableFieldConfig {
/** /**
* Controls the fill opacity of the bars. * Controls the fill opacity of the bars.
*/ */

View File

@ -24,6 +24,7 @@ import {
measureText, measureText,
UPLOT_AXIS_FONT_SIZE, UPLOT_AXIS_FONT_SIZE,
} from '@grafana/ui'; } from '@grafana/ui';
import { getStackingGroups, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils';
import { defaultFieldConfig, FieldConfig, Options } from './panelcfg.gen'; import { defaultFieldConfig, FieldConfig, Options } from './panelcfg.gen';
@ -207,6 +208,9 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => {
}, },
}); });
let stackingGroups = getStackingGroups(xMinOnlyFrame(frame));
builder.setStackingGroups(stackingGroups);
let pathBuilder = uPlot.paths.bars!({ align: 1, size: [1, Infinity] }); let pathBuilder = uPlot.paths.bars!({ align: 1, size: [1, Infinity] });
let seriesIndex = 0; let seriesIndex = 0;
@ -252,31 +256,32 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => {
return builder; return builder;
}; };
const preparePlotData = (frame: DataFrame) => { // since we're reusing timeseries prep for stacking, we need to make a tmp frame where fields match the uplot data
let data = []; // by removing the x bucket max field to make sure stacking group series idxs match up
const xMinOnlyFrame = (frame: DataFrame) => ({
for (const field of frame.fields) { ...frame,
if (field.name !== histogramFrameBucketMaxFieldName) { fields: frame.fields.filter((f) => f.name !== histogramFrameBucketMaxFieldName),
data.push(field.values); });
}
}
const preparePlotData = (builder: UPlotConfigBuilder, xMinOnlyFrame: DataFrame) => {
// uPlot's bars pathBuilder will draw rects even if 0 (to distinguish them from nulls) // uPlot's bars pathBuilder will draw rects even if 0 (to distinguish them from nulls)
// but for histograms we want to omit them, so remap 0s -> nulls // but for histograms we want to omit them, so remap 0s -> nulls
for (let i = 1; i < data.length; i++) { for (let i = 1; i < xMinOnlyFrame.fields.length; i++) {
let counts = data[i]; let counts = xMinOnlyFrame.fields[i].values;
for (let j = 0; j < counts.length; j++) { for (let j = 0; j < counts.length; j++) {
if (counts[j] === 0) { if (counts[j] === 0) {
counts[j] = null; counts[j] = null; // mutates!
} }
} }
} }
return data as AlignedData; return preparePlotData2(xMinOnlyFrame, builder.getStackingGroups());
}; };
interface State { interface State {
alignedData: AlignedData; alignedData: AlignedData;
alignedFrame: DataFrame;
config?: UPlotConfigBuilder; config?: UPlotConfigBuilder;
} }
@ -286,23 +291,17 @@ export class Histogram extends React.Component<HistogramProps, State> {
this.state = this.prepState(props); this.state = this.prepState(props);
} }
prepState(props: HistogramProps, withConfig = true) { prepState(props: HistogramProps, withConfig = true): State {
let state: State = {
alignedData: [],
};
const { alignedFrame } = props; const { alignedFrame } = props;
if (alignedFrame) {
state = {
alignedData: preparePlotData(alignedFrame),
};
if (withConfig) { const config = withConfig ? prepConfig(alignedFrame, this.props.theme) : this.state.config!;
state.config = prepConfig(alignedFrame, this.props.theme); const alignedData = preparePlotData(config, xMinOnlyFrame(alignedFrame));
}
}
return state; return {
alignedFrame,
alignedData,
config,
};
} }
renderLegend(config: UPlotConfigBuilder) { renderLegend(config: UPlotConfigBuilder) {
@ -321,23 +320,18 @@ export class Histogram extends React.Component<HistogramProps, State> {
const { structureRev, alignedFrame, bucketSize, bucketCount } = this.props; const { structureRev, alignedFrame, bucketSize, bucketCount } = this.props;
if (alignedFrame !== prevProps.alignedFrame) { if (alignedFrame !== prevProps.alignedFrame) {
let newState = this.prepState(this.props, false); const shouldReconfig =
this.state.config == null ||
bucketCount !== prevProps.bucketCount ||
bucketSize !== prevProps.bucketSize ||
this.props.options !== prevProps.options ||
this.state.config === undefined ||
structureRev !== prevProps.structureRev ||
!structureRev;
if (newState) { const newState = this.prepState(this.props, shouldReconfig);
const shouldReconfig =
bucketCount !== prevProps.bucketCount ||
bucketSize !== prevProps.bucketSize ||
this.props.options !== prevProps.options ||
this.state.config === undefined ||
structureRev !== prevProps.structureRev ||
!structureRev;
if (shouldReconfig) { this.setState(newState);
newState.config = prepConfig(alignedFrame, this.props.theme);
}
}
newState && this.setState(newState);
} }
} }

View File

@ -0,0 +1,10 @@
import { StackingMode } from '@grafana/schema';
import { FieldConfig } from './panelcfg.gen';
export const defaultHistogramConfig: FieldConfig = {
stacking: {
mode: StackingMode.None,
group: 'A',
},
};

View File

@ -1,4 +1,5 @@
import { FieldConfigSource, PanelModel } from '@grafana/data'; import { FieldConfigSource, PanelModel } from '@grafana/data';
import { StackingMode } from '@grafana/ui';
import { changeToHistogramPanelMigrationHandler } from './migrations'; import { changeToHistogramPanelMigrationHandler } from './migrations';
@ -12,7 +13,7 @@ describe('Histogram migrations', () => {
}; };
}); });
it('From old graph', () => { it('Should migrate from old graph', () => {
const old = { const old = {
angular: { angular: {
xaxis: { xaxis: {
@ -23,6 +24,39 @@ describe('Histogram migrations', () => {
const panel = {} as PanelModel; const panel = {} as PanelModel;
panel.options = changeToHistogramPanelMigrationHandler(panel, 'graph', old, prevFieldConfig); panel.options = changeToHistogramPanelMigrationHandler(panel, 'graph', old, prevFieldConfig);
expect(panel.options.combine).toBe(true); expect(panel.options.combine).toBe(false);
});
it('Should migrate from old graph with percent stacking', () => {
const old = {
angular: {
xaxis: {
mode: 'histogram',
},
stack: true,
percentage: true,
},
};
const panel = {} as PanelModel;
panel.options = changeToHistogramPanelMigrationHandler(panel, 'graph', old, prevFieldConfig);
expect(panel.fieldConfig.defaults.custom.stacking.mode).toBe(StackingMode.Percent);
expect(panel.options.combine).toBe(false);
});
it('Should migrate from old graph with normal stacking', () => {
const old = {
angular: {
xaxis: {
mode: 'histogram',
},
stack: true,
},
};
const panel = {} as PanelModel;
panel.options = changeToHistogramPanelMigrationHandler(panel, 'graph', old, prevFieldConfig);
expect(panel.fieldConfig.defaults.custom.stacking.mode).toBe(StackingMode.Normal);
expect(panel.options.combine).toBe(false);
}); });
}); });

View File

@ -1,4 +1,15 @@
import { PanelTypeChangedHandler } from '@grafana/data'; import { isNil, omitBy } from 'lodash';
import { FieldConfigSource, PanelTypeChangedHandler } from '@grafana/data';
import {
LegendDisplayMode,
SortOrder,
StackingMode,
TooltipDisplayMode,
} from '@grafana/schema/dist/esm/common/common.gen';
import { defaultHistogramConfig } from './config';
import { FieldConfig as HistogramFieldConfig, Options } from './panelcfg.gen';
/* /*
* This is called when the panel changes from another panel * This is called when the panel changes from another panel
@ -13,16 +24,66 @@ export const changeToHistogramPanelMigrationHandler: PanelTypeChangedHandler = (
const graphOptions: GraphOptions = prevOptions.angular; const graphOptions: GraphOptions = prevOptions.angular;
if (graphOptions.xaxis?.mode === 'histogram') { if (graphOptions.xaxis?.mode === 'histogram') {
return { const { fieldConfig, options } = graphToHistogramOptions({
combine: true, ...prevOptions.angular,
}; fieldConfig: prevFieldConfig,
});
panel.fieldConfig = fieldConfig; // Mutates the incoming panel
return options;
} }
} }
return {}; return {};
}; };
function graphToHistogramOptions(angular: any): {
fieldConfig: FieldConfigSource;
options: Options;
} {
const graphOptions: GraphOptions = angular;
let histogramFieldConfig: HistogramFieldConfig = {};
const options: Options = {
legend: {
displayMode: LegendDisplayMode.List,
showLegend: true,
placement: 'bottom',
calcs: [],
},
tooltip: {
mode: TooltipDisplayMode.Single,
sort: SortOrder.None,
},
combine: false,
};
if (graphOptions.stack) {
histogramFieldConfig.stacking = {
mode: graphOptions.percentage ? StackingMode.Percent : StackingMode.Normal,
group: defaultHistogramConfig.stacking!.group,
};
options.combine = false;
}
return {
fieldConfig: {
defaults: omitBy(
{
custom: histogramFieldConfig,
},
isNil
),
overrides: [],
},
options,
};
}
interface GraphOptions { interface GraphOptions {
stack?: boolean;
percentage?: boolean;
xaxis: { xaxis: {
mode: 'series' | 'time' | 'histogram'; mode: 'series' | 'time' | 'histogram';
values?: string[]; values?: string[];

View File

@ -1,8 +1,16 @@
import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data'; import {
FieldColorModeId,
FieldConfigProperty,
FieldType,
identityOverrideProcessor,
PanelPlugin,
} from '@grafana/data';
import { histogramFieldInfo } from '@grafana/data/src/transformations/transformers/histogram'; import { histogramFieldInfo } from '@grafana/data/src/transformations/transformers/histogram';
import { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui'; import { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui';
import { StackingEditor } from '@grafana/ui/src/options/builder';
import { HistogramPanel } from './HistogramPanel'; import { HistogramPanel } from './HistogramPanel';
import { defaultHistogramConfig } from './config';
import { changeToHistogramPanelMigrationHandler } from './migrations'; import { changeToHistogramPanelMigrationHandler } from './migrations';
import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen'; import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen';
import { originalDataHasHistogram } from './utils'; import { originalDataHasHistogram } from './utils';
@ -78,6 +86,21 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(HistogramPanel)
const cfg = defaultFieldConfig; const cfg = defaultFieldConfig;
builder builder
.addCustomEditor({
id: 'stacking',
path: 'stacking',
name: 'Stacking',
category: ['Histogram'],
defaultValue: defaultHistogramConfig.stacking,
editor: StackingEditor,
override: StackingEditor,
settings: {
options: graphFieldOptions.stacking,
},
process: identityOverrideProcessor,
shouldApply: (f) => f.type === FieldType.number,
showIf: (opts, data) => !originalDataHasHistogram(data),
})
.addSliderInput({ .addSliderInput({
path: 'lineWidth', path: 'lineWidth',
name: 'Line width', name: 'Line width',

View File

@ -42,6 +42,7 @@ composableKinds: PanelCfg: {
FieldConfig: { FieldConfig: {
common.AxisConfig common.AxisConfig
common.HideableFieldConfig common.HideableFieldConfig
common.StackableFieldConfig
// Controls line width of the bars. // Controls line width of the bars.
lineWidth?: uint32 & <=10 | *1 lineWidth?: uint32 & <=10 | *1

View File

@ -34,7 +34,7 @@ export const defaultOptions: Partial<Options> = {
bucketOffset: 0, bucketOffset: 0,
}; };
export interface FieldConfig extends common.AxisConfig, common.HideableFieldConfig { export interface FieldConfig extends common.AxisConfig, common.HideableFieldConfig, common.StackableFieldConfig {
/** /**
* Controls the fill opacity of the bars. * Controls the fill opacity of the bars.
*/ */