Sparkline: Improve min/max logic to avoid issues for very narrow deltas (#115030)
* Sparkline: Prevent infinite loop when rendering a sparkline with a single value * some tests for this case * refactor out utils, experiment with getting highlightIndex working * add comments throughout for #112977 * remove unused import * Update Sparkline.test.tsx * fix points mode rendering * Sparkline: Improve min/max logic to avoid issues for very narrow deltas * spread all config * defaults deep * delete unused import * remove go.work.sum delta * line break at end of file
This commit is contained in:
@@ -253,7 +253,7 @@ describe('applyFieldOverrides', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
it('will apply field overrides to the fields within the frame', () => {
|
it('will apply default field overrides to the fields within the frame', () => {
|
||||||
const f0 = createDataFrame({
|
const f0 = createDataFrame({
|
||||||
name: 'A',
|
name: 'A',
|
||||||
fields: [
|
fields: [
|
||||||
@@ -285,6 +285,41 @@ describe('applyFieldOverrides', () => {
|
|||||||
expect(withOverrides[0].fields[1].values[0].fields[1].state.range.max).toBe(30);
|
expect(withOverrides[0].fields[1].values[0].fields[1].state.range.max).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('will apply targeted field overrides to the fields within the frame', () => {
|
||||||
|
const f0 = createDataFrame({
|
||||||
|
name: 'A',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'message',
|
||||||
|
type: FieldType.string,
|
||||||
|
values: ['foo'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'frame',
|
||||||
|
type: FieldType.frame,
|
||||||
|
values: [f0Internal],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const withOverrides = applyFieldOverrides({
|
||||||
|
data: [f0],
|
||||||
|
fieldConfig: {
|
||||||
|
defaults: {},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
matcher: { id: FieldMatcherID.byName, options: 'frame' },
|
||||||
|
properties: [{ id: 'max', value: 30 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
replaceVariables: (value) => value,
|
||||||
|
theme: createTheme(),
|
||||||
|
fieldConfigRegistry: customFieldRegistry,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withOverrides[0].fields[1].values[0].fields[1].config.max).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
it('will not crash when some of the nested frames are undefined', () => {
|
it('will not crash when some of the nested frames are undefined', () => {
|
||||||
const f0 = createDataFrame({
|
const f0 = createDataFrame({
|
||||||
name: 'A',
|
name: 'A',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isNumber, set, unset, get, cloneDeep } from 'lodash';
|
import { isNumber, set, unset, get, cloneDeep, defaultsDeep } from 'lodash';
|
||||||
import { createContext, useContext, useMemo, useRef } from 'react';
|
import { createContext, useContext, useMemo, useRef } from 'react';
|
||||||
import { usePrevious } from 'react-use';
|
import { usePrevious } from 'react-use';
|
||||||
|
|
||||||
@@ -240,9 +240,14 @@ export function applyFieldOverrides(options: ApplyFieldOverrideOptions): DataFra
|
|||||||
...options,
|
...options,
|
||||||
// nested frames can be `undefined` in certain situations, like after `merge` transform due to padding the value array.
|
// nested frames can be `undefined` in certain situations, like after `merge` transform due to padding the value array.
|
||||||
// let's replace them with empty frames to avoid errors applying overrides
|
// let's replace them with empty frames to avoid errors applying overrides
|
||||||
data: field.values.map(
|
data: field.values.map((nestedFrame: DataFrame | undefined): DataFrame => {
|
||||||
(nestedFrame: DataFrame | undefined): DataFrame => nestedFrame ?? createDataFrame({ fields: [] })
|
const result = nestedFrame ?? createDataFrame({ fields: [] });
|
||||||
),
|
result.fields = result.fields.map((newField) => {
|
||||||
|
newField.config = defaultsDeep(newField.config || {}, config);
|
||||||
|
return newField;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,7 +261,7 @@ function calculateRange(
|
|||||||
field: Field,
|
field: Field,
|
||||||
globalRange: NumericRange | undefined,
|
globalRange: NumericRange | undefined,
|
||||||
data: DataFrame[]
|
data: DataFrame[]
|
||||||
): { range?: { min?: number | null; max?: number | null; delta: number }; newGlobalRange: NumericRange | undefined } {
|
): { range?: NumericRange; newGlobalRange?: NumericRange } {
|
||||||
// If range is defined with min/max, use it
|
// If range is defined with min/max, use it
|
||||||
if (isNumber(config.min) && isNumber(config.max)) {
|
if (isNumber(config.min) && isNumber(config.max)) {
|
||||||
const range = { min: config.min, max: config.max, delta: config.max - config.min };
|
const range = { min: config.min, max: config.max, delta: config.max - config.min };
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ describe('Get y range', () => {
|
|||||||
config: {},
|
config: {},
|
||||||
state: { range: { min: -2, max: -2, delta: 0 } },
|
state: { range: { min: -2, max: -2, delta: 0 } },
|
||||||
};
|
};
|
||||||
|
const decimalsCloseYField: Field = {
|
||||||
|
name: 'y',
|
||||||
|
values: [2, 1.999999999999999, 2.000000000000001, 2, 2],
|
||||||
|
type: FieldType.number,
|
||||||
|
config: {},
|
||||||
|
state: { range: { min: 1.999999999999999, max: 2.000000000000001, delta: 0 } },
|
||||||
|
};
|
||||||
const xField: Field = {
|
const xField: Field = {
|
||||||
name: 'x',
|
name: 'x',
|
||||||
values: [1000, 2000, 3000, 4000, 5000],
|
values: [1000, 2000, 3000, 4000, 5000],
|
||||||
@@ -154,12 +161,12 @@ describe('Get y range', () => {
|
|||||||
{
|
{
|
||||||
description: 'straight line',
|
description: 'straight line',
|
||||||
field: straightLineYField,
|
field: straightLineYField,
|
||||||
expected: [0, 4],
|
expected: [2, 4],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'straight line, negative values',
|
description: 'straight line, negative values',
|
||||||
field: straightLineNegYField,
|
field: straightLineNegYField,
|
||||||
expected: [-4, 0],
|
expected: [-4, -2],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'straight line with config min and max',
|
description: 'straight line with config min and max',
|
||||||
@@ -171,8 +178,13 @@ describe('Get y range', () => {
|
|||||||
field: { ...straightLineYField, config: { noValue: '0' } },
|
field: { ...straightLineYField, config: { noValue: '0' } },
|
||||||
expected: [0, 2],
|
expected: [0, 2],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: 'long decimals which are nearly equal and result in a functional delta of 0',
|
||||||
|
field: decimalsCloseYField,
|
||||||
|
expected: [2, 4],
|
||||||
|
},
|
||||||
])(`should return correct range for $description`, ({ field, expected }) => {
|
])(`should return correct range for $description`, ({ field, expected }) => {
|
||||||
const actual = getYRange(field, getAlignedFrame(field));
|
const actual = getYRange(getAlignedFrame(field));
|
||||||
expect(actual).toEqual(expected);
|
expect(actual).toEqual(expected);
|
||||||
expect(actual[0]).toBeLessThan(actual[1]!);
|
expect(actual[0]).toBeLessThan(actual[1]!);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Range } from 'uplot';
|
|||||||
import {
|
import {
|
||||||
applyNullInsertThreshold,
|
applyNullInsertThreshold,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
Field,
|
|
||||||
FieldConfig,
|
FieldConfig,
|
||||||
FieldSparkline,
|
FieldSparkline,
|
||||||
FieldType,
|
FieldType,
|
||||||
@@ -11,6 +10,7 @@ import {
|
|||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
isLikelyAscendingVector,
|
isLikelyAscendingVector,
|
||||||
nullToValue,
|
nullToValue,
|
||||||
|
roundDecimals,
|
||||||
sortDataFrame,
|
sortDataFrame,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { t } from '@grafana/i18n';
|
import { t } from '@grafana/i18n';
|
||||||
@@ -68,35 +68,49 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig
|
|||||||
/**
|
/**
|
||||||
* apply configuration defaults and ensure that the range is never two equal values.
|
* apply configuration defaults and ensure that the range is never two equal values.
|
||||||
*/
|
*/
|
||||||
export function getYRange(field: Field, alignedFrame: DataFrame): Range.MinMax {
|
export function getYRange(alignedFrame: DataFrame): Range.MinMax {
|
||||||
let { min, max } = alignedFrame.fields[1].state?.range!;
|
const field = alignedFrame.fields[1];
|
||||||
|
let { min, max } = field.state?.range!;
|
||||||
|
|
||||||
// enure that the min/max from the field config are respected
|
// enure that the min/max from the field config are respected.
|
||||||
min = Math.max(min!, field.config.min ?? -Infinity);
|
min = Math.min(min!, field.config.min ?? Infinity);
|
||||||
max = Math.min(max!, field.config.max ?? Infinity);
|
max = Math.max(max!, field.config.max ?? -Infinity);
|
||||||
|
|
||||||
|
// console.log({ min, max });
|
||||||
|
|
||||||
// if noValue is set, ensure that it is included in the range as well
|
// if noValue is set, ensure that it is included in the range as well
|
||||||
const noValue = +alignedFrame.fields[1].config?.noValue!;
|
const noValue = +field.config?.noValue!;
|
||||||
if (!Number.isNaN(noValue)) {
|
if (!Number.isNaN(noValue)) {
|
||||||
min = Math.min(min, noValue);
|
min = Math.min(min, noValue);
|
||||||
max = Math.max(max, noValue);
|
max = Math.max(max, noValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
// if min and max are equal after all of that, create a range
|
// call roundDecimals to mirror what is going to eventually happen in uplot
|
||||||
// that allows the sparkline to be visible in the center of the viz
|
let roundedMin = roundDecimals(min, field.config.decimals ?? 0);
|
||||||
if (min === max) {
|
let roundedMax = roundDecimals(max, field.config.decimals ?? 0);
|
||||||
if (min === 0) {
|
|
||||||
max = 100;
|
// if the rounded min and max are different,
|
||||||
} else if (min < 0) {
|
// we can return the real min and max.
|
||||||
max = 0;
|
if (roundedMin !== roundedMax) {
|
||||||
min *= 2;
|
return [min, max];
|
||||||
} else {
|
|
||||||
min = 0;
|
|
||||||
max *= 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [min, max];
|
// we are forced to tweak the min and max since they
|
||||||
|
// will be treated as equal after rounding by uPlot.
|
||||||
|
if (roundedMin === 0) {
|
||||||
|
// both are zero
|
||||||
|
roundedMax = 1;
|
||||||
|
} else if (roundedMin < 0) {
|
||||||
|
// both are negative
|
||||||
|
// max = 0;
|
||||||
|
roundedMin *= 2;
|
||||||
|
} else {
|
||||||
|
// both are positive
|
||||||
|
// min = 0;
|
||||||
|
roundedMax *= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [roundedMin, roundedMax];
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: #112977 enable highlight index
|
// TODO: #112977 enable highlight index
|
||||||
@@ -182,7 +196,7 @@ export const prepareConfig = (
|
|||||||
scaleKey,
|
scaleKey,
|
||||||
orientation: ScaleOrientation.Vertical,
|
orientation: ScaleOrientation.Vertical,
|
||||||
direction: ScaleDirection.Up,
|
direction: ScaleDirection.Up,
|
||||||
range: () => getYRange(field, dataFrame),
|
range: () => getYRange(dataFrame),
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.addAxis({
|
builder.addAxis({
|
||||||
|
|||||||
Reference in New Issue
Block a user