Sparkline: Properly handle flat data (min === max) (#109702)

This commit is contained in:
Paul Marbach
2025-08-14 21:38:54 +00:00
committed by GitHub
parent cc651e2e6e
commit 9a47dd2a7e
4 changed files with 130 additions and 23 deletions
@@ -26,7 +26,7 @@ import { UPlotChart } from '../uPlot/Plot';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { preparePlotData2, getStackingGroups } from '../uPlot/utils';
import { preparePlotFrame } from './utils';
import { getYRange, preparePlotFrame } from './utils';
export interface SparklineProps extends Themeable2 {
width: number;
@@ -102,26 +102,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
}
getYRange(field: Field): Range.MinMax {
let { min, max } = this.state.alignedDataFrame.fields[1].state?.range!;
const noValue = +this.state.alignedDataFrame.fields[1].config?.noValue!;
if (!Number.isNaN(noValue)) {
min = Math.min(min!, +noValue);
max = Math.max(max!, +noValue);
}
if (min === max) {
if (min === 0) {
max = 100;
} else {
min = 0;
max! *= 2;
}
return [min, max!];
}
return [Math.max(min!, field.config.min ?? -Infinity), Math.min(max!, field.config.max ?? Infinity)];
return getYRange(field, this.state.alignedDataFrame);
}
prepareConfig(data: DataFrame) {
@@ -1,6 +1,6 @@
import { FieldSparkline, FieldType } from '@grafana/data';
import { Field, FieldSparkline, FieldType } from '@grafana/data';
import { preparePlotFrame } from './utils';
import { getYRange, preparePlotFrame } from './utils';
describe('Prepare Sparkline plot frame', () => {
it('should return sorted array if x-axis numeric', () => {
@@ -91,3 +91,89 @@ describe('Prepare Sparkline plot frame', () => {
expect(frame.fields[1].values).toEqual([2, null, 3, null, null, 1]);
});
});
describe('Get y range', () => {
const defaultYField: Field = {
name: 'y',
values: [1, 2, 3, 4, 5],
type: FieldType.number,
config: {},
state: { range: { min: 1, max: 5, delta: 4 } },
};
const straightLineYField: Field = {
name: 'y',
values: [2, 2, 2, 2, 2],
type: FieldType.number,
config: {},
state: { range: { min: 2, max: 2, delta: 0 } },
};
const straightLineNegYField: Field = {
name: 'y',
values: [-2, -2, -2, -2, -2],
type: FieldType.number,
config: {},
state: { range: { min: -2, max: -2, delta: 0 } },
};
const xField: Field = {
name: 'x',
values: [1000, 2000, 3000, 4000, 5000],
type: FieldType.time,
config: {},
};
const getAlignedFrame = (yField: Field) => ({
refId: 'sparkline',
fields: [xField, yField],
length: yField.values.length,
});
it.each([
{
description: 'inferred min and max',
field: defaultYField,
expected: [1, 5],
},
{
description: 'min from config',
field: { ...defaultYField, config: { min: 3 }, state: { range: { min: 3, max: 5, delta: 2 } } },
expected: [3, 5],
},
{
description: 'max from config',
field: { ...defaultYField, config: { max: 30 }, state: { range: { min: 1, max: 30, delta: 29 } } },
expected: [1, 30],
},
{
description: 'no value is set',
field: { ...defaultYField, config: { noValue: '0' } },
expected: [0, 5],
},
{
description: 'NaN no value is set',
field: { ...defaultYField, config: { noValue: 'foo' } },
expected: [1, 5],
},
{
description: 'straight line',
field: straightLineYField,
expected: [0, 4],
},
{
description: 'straight line, negative values',
field: straightLineNegYField,
expected: [-4, 0],
},
{
description: 'straight line with config min and max',
field: { ...straightLineYField, config: { min: 1, max: 3 }, state: { range: { min: 1, max: 3, delta: 2 } } },
expected: [1, 3],
},
{
description: 'straight line with config no value',
field: { ...straightLineYField, config: { noValue: '0' } },
expected: [0, 2],
},
])(`should return correct range for $description`, ({ field, expected }) => {
const actual = getYRange(field, getAlignedFrame(field));
expect(actual).toEqual(expected);
expect(actual[0]).toBeLessThan(actual[1]!);
});
});
@@ -1,3 +1,5 @@
import { Range } from 'uplot';
import {
DataFrame,
FieldConfig,
@@ -6,6 +8,7 @@ import {
isLikelyAscendingVector,
sortDataFrame,
applyNullInsertThreshold,
Field,
} from '@grafana/data';
import { GraphFieldConfig } from '@grafana/schema';
@@ -48,3 +51,37 @@ export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig
refFieldPseudoMax: sparkline.timeRange?.to.valueOf(),
});
}
/**
* apply configuration defaults and ensure that the range is never two equal values.
*/
export function getYRange(field: Field, alignedFrame: DataFrame): Range.MinMax {
let { min, max } = alignedFrame.fields[1].state?.range!;
// enure that the min/max from the field config are respected
min = Math.max(min!, field.config.min ?? -Infinity);
max = Math.min(max!, field.config.max ?? Infinity);
// if noValue is set, ensure that it is included in the range as well
const noValue = +alignedFrame.fields[1].config?.noValue!;
if (!Number.isNaN(noValue)) {
min = Math.min(min, noValue);
max = Math.max(max, noValue);
}
// if min and max are equal after all of that, create a range
// that allows the sparkline to be visible in the center of the viz
if (min === max) {
if (min === 0) {
max = 100;
} else if (min < 0) {
max = 0;
min *= 2;
} else {
min = 0;
max *= 2;
}
}
return [min, max];
}
@@ -97,6 +97,7 @@ export function TableNG(props: TableNGProps) {
onSortByChange,
showTypeIcons,
structureRev,
timeRange,
transparent,
width,
} = props;
@@ -461,6 +462,7 @@ export function TableNG(props: TableNGProps) {
theme,
value,
width,
timeRange,
cellInspect,
showFilters,
getActions: getCellActions,
@@ -646,6 +648,7 @@ export function TableNG(props: TableNGProps) {
showTypeIcons,
sortColumns,
styles,
timeRange,
theme,
visibleFields,
widths,