From 608f066ca5d6dc09c021cfb5fb89ecb3af3ec4da Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Wed, 11 Oct 2023 10:50:11 +0200 Subject: [PATCH] PanelConfig: Add option to calculate min/max per field instead of using the global min/max in the data frame. (#75952) * Add option to calculate min max per field * Fix eslint warnings * Add back hideFromDefaults that went missing * whitespace * Add tests * Refactor range calculation * Rename localMinMax -> fieldMinMax * Remove the lint exceptions Removing these as to not hide these once we get around to fixing the underlying typing issue. * Update docs --- .betterer.results | 16 ++- .../configure-standard-options/index.md | 8 +- .../src/field/fieldOverrides.test.ts | 100 ++++++++++++++++++ .../grafana-data/src/field/fieldOverrides.ts | 38 +++++-- packages/grafana-data/src/types/dataFrame.ts | 3 + .../src/utils/tests/mockStandardProperties.ts | 19 +++- .../core/components/OptionsUI/registry.tsx | 21 +++- .../PanelEditor/getVisualizationOptions.tsx | 14 +-- 8 files changed, 195 insertions(+), 24 deletions(-) diff --git a/.betterer.results b/.betterer.results index 0f4e3f64193..6d5ba9d6a08 100644 --- a/.betterer.results +++ b/.betterer.results @@ -173,7 +173,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], "packages/grafana-data/src/field/fieldOverrides.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], "packages/grafana-data/src/field/overrides/processors.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -1390,10 +1391,15 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "77"], [0, 0, 0, "Do not use any type assertions.", "78"], [0, 0, 0, "Unexpected any. Specify a different type.", "79"], - [0, 0, 0, "Do not use any type assertions.", "80"], - [0, 0, 0, "Unexpected any. Specify a different type.", "81"], - [0, 0, 0, "Do not use any type assertions.", "82"], - [0, 0, 0, "Unexpected any. Specify a different type.", "83"] + [0, 0, 0, "Unexpected any. Specify a different type.", "80"], + [0, 0, 0, "Do not use any type assertions.", "81"], + [0, 0, 0, "Unexpected any. Specify a different type.", "82"], + [0, 0, 0, "Do not use any type assertions.", "83"], + [0, 0, 0, "Unexpected any. Specify a different type.", "84"], + [0, 0, 0, "Do not use any type assertions.", "85"], + [0, 0, 0, "Unexpected any. Specify a different type.", "86"], + [0, 0, 0, "Do not use any type assertions.", "87"], + [0, 0, 0, "Unexpected any. Specify a different type.", "88"] ], "public/app/core/components/OptionsUI/slider.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"] diff --git a/docs/sources/panels-visualizations/configure-standard-options/index.md b/docs/sources/panels-visualizations/configure-standard-options/index.md index cfa0c6ca9b7..860f5238f7f 100644 --- a/docs/sources/panels-visualizations/configure-standard-options/index.md +++ b/docs/sources/panels-visualizations/configure-standard-options/index.md @@ -82,11 +82,15 @@ Grafana can sometimes be too aggressive in parsing strings and displaying them a ### Min -Lets you set the minimum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. +Lets you set the minimum value used in percentage threshold calculations. Leave blank to automatically calculate the minimum. ### Max -Lets you set the maximum value used in percentage threshold calculations. Leave blank for auto calculation based on all series and fields. +Lets you set the maximum value used in percentage threshold calculations. Leave blank to automatically calculate the maximum. + +### Field min/max + +By default the calculated min and max will be based on the minimum and maximum, in all series and fields. Turning field min/max on, will calculate the min or max on each field individually, based on the minimum or maximum value of the field. ### Decimals diff --git a/packages/grafana-data/src/field/fieldOverrides.test.ts b/packages/grafana-data/src/field/fieldOverrides.test.ts index fe241167923..8d358a80320 100644 --- a/packages/grafana-data/src/field/fieldOverrides.test.ts +++ b/packages/grafana-data/src/field/fieldOverrides.test.ts @@ -330,6 +330,106 @@ describe('applyFieldOverrides', () => { expect(range.min).toEqual(-20); }); + it('should calculate min/max per field when fieldMinMax is set', () => { + const df = toDataFrame([ + { title: 'AAA', value: 100, value2: 1234 }, + { title: 'BBB', value: -20, value2: null }, + { title: 'CCC', value: 200, value2: 1000 }, + ]); + + const fieldCfgSource: FieldConfigSource = { + defaults: { + fieldMinMax: true, + }, + overrides: [], + }; + const data = applyFieldOverrides({ + data: [df], // the frame + fieldConfig: fieldCfgSource, + replaceVariables: undefined as unknown as InterpolateFunction, + theme: createTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + + const valueColumn1 = data.fields[1]; + const range1 = valueColumn1.state!.range!; + expect(range1.max).toEqual(200); + expect(range1.min).toEqual(-20); + + const valueColumn2 = data.fields[2]; + const range2 = valueColumn2.state!.range!; + expect(range2.max).toEqual(1234); + expect(range2.min).toEqual(1000); + }); + + it('should calculate min/max locally for fields with fieldMinMax and globally for other fields', () => { + const df = toDataFrame({ + fields: [ + { name: 'first', type: FieldType.number, values: [-1, -2] }, + { name: 'second', type: FieldType.number, values: [1, 2] }, + { name: 'third', type: FieldType.number, values: [1000, 2000] }, + ], + }); + + const fieldCfgSource: FieldConfigSource = { + defaults: {}, + overrides: [ + { + matcher: { id: FieldMatcherID.byName, options: 'second' }, + properties: [{ id: 'fieldMinMax', value: true }], + }, + ], + }; + const data = applyFieldOverrides({ + data: [df], // the frame + fieldConfig: fieldCfgSource, + replaceVariables: undefined as unknown as InterpolateFunction, + theme: createTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + + const valueColumn0 = data.fields[0]; + const range0 = valueColumn0.state!.range!; + expect(range0.max).toEqual(2000); + expect(range0.min).toEqual(-2); + + const valueColumn1 = data.fields[1]; + const range1 = valueColumn1.state!.range!; + expect(range1.max).toEqual(2); + expect(range1.min).toEqual(1); + + const valueColumn2 = data.fields[2]; + const range2 = valueColumn2.state!.range!; + expect(range2.max).toEqual(2000); + expect(range2.min).toEqual(-2); + }); + + it('should not calculate min if min is set', () => { + const df = toDataFrame({ + fields: [{ name: 'first', type: FieldType.number, values: [-1, -2] }], + }); + + const fieldCfgSource: FieldConfigSource = { + defaults: { + min: 1, + fieldMinMax: true, + }, + overrides: [], + }; + const data = applyFieldOverrides({ + data: [df], // the frame + fieldConfig: fieldCfgSource, + replaceVariables: undefined as unknown as InterpolateFunction, + theme: createTheme(), + fieldConfigRegistry: customFieldRegistry, + })[0]; + + const valueColumn0 = data.fields[0]; + const range0 = valueColumn0.state!.range!; + expect(range0.max).toEqual(-1); + expect(range0.min).toEqual(1); + }); + it('getLinks should use applied field config', () => { const replaceVariablesCalls: ScopedVars[] = []; diff --git a/packages/grafana-data/src/field/fieldOverrides.ts b/packages/grafana-data/src/field/fieldOverrides.ts index 6fc79b771fd..8316e1a7350 100644 --- a/packages/grafana-data/src/field/fieldOverrides.ts +++ b/packages/grafana-data/src/field/fieldOverrides.ts @@ -40,6 +40,7 @@ import { mapInternalLinkToExplore } from '../utils/dataLinks'; import { FieldConfigOptionsRegistry } from './FieldConfigOptionsRegistry'; import { getDisplayProcessor, getRawDisplayProcessor } from './displayProcessor'; +import { getMinMaxAndDelta } from './scale'; import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; interface OverrideProps { @@ -166,15 +167,8 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra } // Set the Min/Max value automatically - let range: NumericRange | undefined = undefined; - if (field.type === FieldType.number) { - if (!globalRange && (!isNumber(config.min) || !isNumber(config.max))) { - globalRange = findNumericFieldMinMax(options.data!); - } - const min = config.min ?? globalRange!.min; - const max = config.max ?? globalRange!.max; - range = { min, max, delta: max! - min! }; - } + const { range, newGlobalRange } = calculateRange(config, field, globalRange, options.data!); + globalRange = newGlobalRange; field.state!.seriesIndex = seriesIndex; field.state!.range = range; @@ -243,6 +237,32 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra }); } +function calculateRange( + config: FieldConfig, + field: Field, + globalRange: NumericRange | undefined, + data: DataFrame[] +): { range?: { min?: number | null; max?: number | null; delta: number }; newGlobalRange: NumericRange | undefined } { + // Only calculate ranges when the field is a number and one of min/max is set to auto. + if (field.type !== FieldType.number || (isNumber(config.min) && isNumber(config.max))) { + return { newGlobalRange: globalRange }; + } + + // Calculate the min/max from the field. + if (config.fieldMinMax) { + const localRange = getMinMaxAndDelta(field); + const min = config.min ?? localRange.min; + const max = config.max ?? localRange.max; + return { range: { min, max, delta: max! - min! }, newGlobalRange: globalRange }; + } + + // We use the global range if supplied, otherwise we calculate it. + const newGlobalRange = globalRange ?? findNumericFieldMinMax(data); + const min = config.min ?? newGlobalRange!.min; + const max = config.max ?? newGlobalRange!.max; + return { range: { min, max, delta: max! - min! }, newGlobalRange }; +} + // this is a significant optimization for streaming, where we currently re-process all values in the buffer on ech update // via field.display(value). this can potentially be removed once we... // 1. process data packets incrementally and/if cache the results in the streaming datafame (maybe by buffer index) diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index cb971ab9c56..0154d8014c9 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -101,6 +101,9 @@ export interface FieldConfig { // Panel Specific Values custom?: TOptions; + + // Calculate min max per field + fieldMinMax?: boolean; } export interface FieldTypeConfig { diff --git a/packages/grafana-data/src/utils/tests/mockStandardProperties.ts b/packages/grafana-data/src/utils/tests/mockStandardProperties.ts index 85ad66e660f..5858cd0a51f 100644 --- a/packages/grafana-data/src/utils/tests/mockStandardProperties.ts +++ b/packages/grafana-data/src/utils/tests/mockStandardProperties.ts @@ -67,6 +67,23 @@ export const mockStandardProperties = () => { shouldApply: () => true, }; + const fieldMinMax = { + id: 'fieldMinMax', + path: 'fieldMinMax', + name: 'localminmax', + description: 'Calculate min/max per field ', + + editor: () => null, + override: () => null, + process: identityOverrideProcessor, + + settings: { + placeholder: 'auto', + }, + + shouldApply: () => true, + }; + const decimals = { id: 'decimals', path: 'decimals', @@ -166,5 +183,5 @@ export const mockStandardProperties = () => { shouldApply: () => true, }; - return [unit, min, max, decimals, title, noValue, thresholds, mappings, links, color]; + return [unit, min, max, fieldMinMax, decimals, title, noValue, thresholds, mappings, links, color]; }; diff --git a/public/app/core/components/OptionsUI/registry.tsx b/public/app/core/components/OptionsUI/registry.tsx index 7f5681609dd..18c1816c8ac 100644 --- a/public/app/core/components/OptionsUI/registry.tsx +++ b/public/app/core/components/OptionsUI/registry.tsx @@ -1,3 +1,4 @@ +import { BooleanFieldSettings } from '@react-awesome-query-builder/ui'; import React from 'react'; import { @@ -27,6 +28,7 @@ import { FieldNamePickerConfigSettings, booleanOverrideProcessor, } from '@grafana/data'; +import { FieldConfig } from '@grafana/schema'; import { RadioButtonGroup, TimeZonePicker, Switch } from '@grafana/ui'; import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker'; import { ThresholdsValueEditor } from 'app/features/dimensions/editors/ThresholdsEditor/thresholds'; @@ -245,6 +247,23 @@ export const getAllStandardFieldConfigs = () => { category, }; + const fieldMinMax: FieldConfigPropertyItem = { + id: 'fieldMinMax', + path: 'fieldMinMax', + name: 'Field min/max', + description: 'Calculate min max per field', + + editor: standardEditorsRegistry.get('boolean').editor as any, + override: standardEditorsRegistry.get('boolean').editor as any, + process: booleanOverrideProcessor, + + shouldApply: (field) => field.type === FieldType.number, + showIf: (options: FieldConfig) => { + return options.min === undefined || options.max === undefined; + }, + category, + }; + const min: FieldConfigPropertyItem = { id: 'min', path: 'min', @@ -397,5 +416,5 @@ export const getAllStandardFieldConfigs = () => { category, }; - return [unit, min, max, decimals, displayName, color, noValue, links, mappings, thresholds, filterable]; + return [unit, min, max, fieldMinMax, decimals, displayName, color, noValue, links, mappings, thresholds, filterable]; }; diff --git a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx index 099a5461f6b..5a33695a3e6 100644 --- a/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx +++ b/public/app/features/dashboard/components/PanelEditor/getVisualizationOptions.tsx @@ -97,12 +97,14 @@ export function getVisualizationOptions(props: OptionPaneRenderProps): OptionsPa * Field options */ for (const fieldOption of plugin.fieldConfigRegistry.list()) { - if ( - fieldOption.isCustom && - fieldOption.showIf && - !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series) - ) { - continue; + if (fieldOption.isCustom) { + if (fieldOption.showIf && !fieldOption.showIf(currentFieldConfig.defaults.custom, data?.series)) { + continue; + } + } else { + if (fieldOption.showIf && !fieldOption.showIf(currentFieldConfig.defaults, data?.series)) { + continue; + } } if (fieldOption.hideFromDefaults) {