From 25a058f7ecbe2b3bad3229c8dd16d588f2244ba1 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Thu, 21 Mar 2024 05:57:10 -0600 Subject: [PATCH] Histogram: Add support for stacking mode (#84693) --- .betterer.results | 4 +- .../panelcfg/x/HistogramPanelCfg_types.gen.ts | 2 +- .../app/plugins/panel/histogram/Histogram.tsx | 76 +++++++++---------- public/app/plugins/panel/histogram/config.ts | 10 +++ .../panel/histogram/migrations.test.ts | 38 +++++++++- .../app/plugins/panel/histogram/migrations.ts | 69 ++++++++++++++++- public/app/plugins/panel/histogram/module.tsx | 25 +++++- .../app/plugins/panel/histogram/panelcfg.cue | 1 + .../plugins/panel/histogram/panelcfg.gen.ts | 2 +- 9 files changed, 175 insertions(+), 52 deletions(-) create mode 100644 public/app/plugins/panel/histogram/config.ts diff --git a/.betterer.results b/.betterer.results index a07a2d03417..64fe3978eff 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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.", "16"] ], - "public/app/plugins/panel/histogram/Histogram.tsx:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] + "public/app/plugins/panel/histogram/migrations.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], "public/app/plugins/panel/live/LiveChannelEditor.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts index 3d1b420d49b..7c1b74aaf00 100644 --- a/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/histogram/panelcfg/x/HistogramPanelCfg_types.gen.ts @@ -36,7 +36,7 @@ export const defaultOptions: Partial = { 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. */ diff --git a/public/app/plugins/panel/histogram/Histogram.tsx b/public/app/plugins/panel/histogram/Histogram.tsx index 160db11fe36..0a042b1afa7 100644 --- a/public/app/plugins/panel/histogram/Histogram.tsx +++ b/public/app/plugins/panel/histogram/Histogram.tsx @@ -24,6 +24,7 @@ import { measureText, UPLOT_AXIS_FONT_SIZE, } from '@grafana/ui'; +import { getStackingGroups, preparePlotData2 } from '@grafana/ui/src/components/uPlot/utils'; 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 seriesIndex = 0; @@ -252,31 +256,32 @@ const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { return builder; }; -const preparePlotData = (frame: DataFrame) => { - let data = []; - - for (const field of frame.fields) { - if (field.name !== histogramFrameBucketMaxFieldName) { - data.push(field.values); - } - } +// since we're reusing timeseries prep for stacking, we need to make a tmp frame where fields match the uplot data +// by removing the x bucket max field to make sure stacking group series idxs match up +const xMinOnlyFrame = (frame: DataFrame) => ({ + ...frame, + fields: frame.fields.filter((f) => f.name !== histogramFrameBucketMaxFieldName), +}); +const preparePlotData = (builder: UPlotConfigBuilder, xMinOnlyFrame: DataFrame) => { // 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 - for (let i = 1; i < data.length; i++) { - let counts = data[i]; + for (let i = 1; i < xMinOnlyFrame.fields.length; i++) { + let counts = xMinOnlyFrame.fields[i].values; + for (let j = 0; j < counts.length; j++) { if (counts[j] === 0) { - counts[j] = null; + counts[j] = null; // mutates! } } } - return data as AlignedData; + return preparePlotData2(xMinOnlyFrame, builder.getStackingGroups()); }; interface State { alignedData: AlignedData; + alignedFrame: DataFrame; config?: UPlotConfigBuilder; } @@ -286,23 +291,17 @@ export class Histogram extends React.Component { this.state = this.prepState(props); } - prepState(props: HistogramProps, withConfig = true) { - let state: State = { - alignedData: [], - }; - + prepState(props: HistogramProps, withConfig = true): State { const { alignedFrame } = props; - if (alignedFrame) { - state = { - alignedData: preparePlotData(alignedFrame), - }; - if (withConfig) { - state.config = prepConfig(alignedFrame, this.props.theme); - } - } + const config = withConfig ? prepConfig(alignedFrame, this.props.theme) : this.state.config!; + const alignedData = preparePlotData(config, xMinOnlyFrame(alignedFrame)); - return state; + return { + alignedFrame, + alignedData, + config, + }; } renderLegend(config: UPlotConfigBuilder) { @@ -321,23 +320,18 @@ export class Histogram extends React.Component { const { structureRev, alignedFrame, bucketSize, bucketCount } = this.props; 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 shouldReconfig = - bucketCount !== prevProps.bucketCount || - bucketSize !== prevProps.bucketSize || - this.props.options !== prevProps.options || - this.state.config === undefined || - structureRev !== prevProps.structureRev || - !structureRev; + const newState = this.prepState(this.props, shouldReconfig); - if (shouldReconfig) { - newState.config = prepConfig(alignedFrame, this.props.theme); - } - } - - newState && this.setState(newState); + this.setState(newState); } } diff --git a/public/app/plugins/panel/histogram/config.ts b/public/app/plugins/panel/histogram/config.ts new file mode 100644 index 00000000000..88ff8f9089a --- /dev/null +++ b/public/app/plugins/panel/histogram/config.ts @@ -0,0 +1,10 @@ +import { StackingMode } from '@grafana/schema'; + +import { FieldConfig } from './panelcfg.gen'; + +export const defaultHistogramConfig: FieldConfig = { + stacking: { + mode: StackingMode.None, + group: 'A', + }, +}; diff --git a/public/app/plugins/panel/histogram/migrations.test.ts b/public/app/plugins/panel/histogram/migrations.test.ts index a0d2c5517cc..62b31f4fa1f 100644 --- a/public/app/plugins/panel/histogram/migrations.test.ts +++ b/public/app/plugins/panel/histogram/migrations.test.ts @@ -1,4 +1,5 @@ import { FieldConfigSource, PanelModel } from '@grafana/data'; +import { StackingMode } from '@grafana/ui'; import { changeToHistogramPanelMigrationHandler } from './migrations'; @@ -12,7 +13,7 @@ describe('Histogram migrations', () => { }; }); - it('From old graph', () => { + it('Should migrate from old graph', () => { const old = { angular: { xaxis: { @@ -23,6 +24,39 @@ describe('Histogram migrations', () => { const panel = {} as PanelModel; 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); }); }); diff --git a/public/app/plugins/panel/histogram/migrations.ts b/public/app/plugins/panel/histogram/migrations.ts index 215d85a9f56..983aaedc8a6 100644 --- a/public/app/plugins/panel/histogram/migrations.ts +++ b/public/app/plugins/panel/histogram/migrations.ts @@ -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 @@ -13,16 +24,66 @@ export const changeToHistogramPanelMigrationHandler: PanelTypeChangedHandler = ( const graphOptions: GraphOptions = prevOptions.angular; if (graphOptions.xaxis?.mode === 'histogram') { - return { - combine: true, - }; + const { fieldConfig, options } = graphToHistogramOptions({ + ...prevOptions.angular, + fieldConfig: prevFieldConfig, + }); + + panel.fieldConfig = fieldConfig; // Mutates the incoming panel + + return options; } } 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 { + stack?: boolean; + percentage?: boolean; xaxis: { mode: 'series' | 'time' | 'histogram'; values?: string[]; diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx index c2b26f6283f..7bea3f036aa 100644 --- a/public/app/plugins/panel/histogram/module.tsx +++ b/public/app/plugins/panel/histogram/module.tsx @@ -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 { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui'; +import { StackingEditor } from '@grafana/ui/src/options/builder'; import { HistogramPanel } from './HistogramPanel'; +import { defaultHistogramConfig } from './config'; import { changeToHistogramPanelMigrationHandler } from './migrations'; import { FieldConfig, Options, defaultFieldConfig, defaultOptions } from './panelcfg.gen'; import { originalDataHasHistogram } from './utils'; @@ -78,6 +86,21 @@ export const plugin = new PanelPlugin(HistogramPanel) const cfg = defaultFieldConfig; 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({ path: 'lineWidth', name: 'Line width', diff --git a/public/app/plugins/panel/histogram/panelcfg.cue b/public/app/plugins/panel/histogram/panelcfg.cue index c4fe9ce156d..019ce7d8c83 100644 --- a/public/app/plugins/panel/histogram/panelcfg.cue +++ b/public/app/plugins/panel/histogram/panelcfg.cue @@ -42,6 +42,7 @@ composableKinds: PanelCfg: { FieldConfig: { common.AxisConfig common.HideableFieldConfig + common.StackableFieldConfig // Controls line width of the bars. lineWidth?: uint32 & <=10 | *1 diff --git a/public/app/plugins/panel/histogram/panelcfg.gen.ts b/public/app/plugins/panel/histogram/panelcfg.gen.ts index 68e5497753b..8eff826f474 100644 --- a/public/app/plugins/panel/histogram/panelcfg.gen.ts +++ b/public/app/plugins/panel/histogram/panelcfg.gen.ts @@ -34,7 +34,7 @@ export const defaultOptions: Partial = { 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. */