From 9a47dd2a7ecf27ecc22c2a0de74391a2f9e866e9 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 14 Aug 2025 17:38:54 -0400 Subject: [PATCH] Sparkline: Properly handle flat data (min === max) (#109702) --- .../src/components/Sparkline/Sparkline.tsx | 23 +---- .../src/components/Sparkline/utils.test.ts | 90 ++++++++++++++++++- .../src/components/Sparkline/utils.ts | 37 ++++++++ .../src/components/Table/TableNG/TableNG.tsx | 3 + 4 files changed, 130 insertions(+), 23 deletions(-) diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index 6d4547e3fbe..8de8ed1b893 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -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 { } 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) { diff --git a/packages/grafana-ui/src/components/Sparkline/utils.test.ts b/packages/grafana-ui/src/components/Sparkline/utils.test.ts index d563d83f334..9eb914af438 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.test.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.test.ts @@ -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]!); + }); +}); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index aa77fd64dc4..5c2afd8a1cb 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -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]; +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index a3e532f7cad..b0f720fbe10 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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,