StateTimeline: Display false and empty string values (#107059)

* Support boolean false value mappings

* Support empty string value mapping
This commit is contained in:
Jesse David Peterson
2025-06-24 12:07:13 -04:00
committed by GitHub
parent 53cb80e58c
commit dcfb079856
3 changed files with 199 additions and 10 deletions
@@ -986,6 +986,101 @@
],
"title": "special null + NaN value mapping from data",
"type": "state-timeline"
},
{
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
"fieldMinMax": false,
"mappings": [
{
"options": {
"match": "false",
"result": {
"color": "red",
"index": 0
}
},
"type": "special"
},
{
"options": {
"match": "null+nan",
"result": {
"color": "blue",
"index": 1,
"text": "null + NaN"
}
},
"type": "special"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 6,
"x": 12,
"y": 18
},
"id": 14,
"options": {
"alignValue": "center",
"legend": {
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"mergeValues": true,
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
"type": "testdata",
"uid": "PD8C576611E62080A"
},
"rawFrameContent": "[\n {\n \"schema\": {\n \"refId\": \"A\",\n \"fields\": [\n {\n \"name\": \"time\",\n \"type\": \"time\",\n \"typeInfo\": {\n \"frame\": \"time\",\n \"nullable\": true\n },\n \"config\": {}\n },\n {\n \"name\": \"value\",\n \"type\": \"number\",\n \"typeInfo\": {\n \"frame\": \"int64\",\n \"nullable\": true\n },\n \"config\": {\n \"thresholds\": {\n \"mode\": \"absolute\",\n \"steps\": [\n {\n \"color\": \"green\",\n \"value\": null\n }\n ]\n }\n }\n }\n ]\n },\n \"data\": {\n \"values\": [\n [\n 1674732835000,\n 1674736435000,\n 1674740035000,\n 1674743635000,\n 1674747235000,\n 1674750835000,\n 1674754235000,\n 1674757835000\n ],\n [\n null,\n null,\n false,\n true,\n true,\n false,\n true,\n null\n ]\n ],\n \"entities\": [null, { \"NaN\": [0], \"Undefined\": [1]}]\n }\n }\n]",
"refId": "A",
"scenarioId": "raw_frame"
}
],
"title": "boolean values from data",
"type": "state-timeline"
}
],
"preload": false,
@@ -1008,5 +1103,5 @@
"timezone": "utc",
"title": "StateTimeline - Thresholds & Mappings",
"uid": "Kce7z9TVz",
"version": 13
}
"version": 18
}
@@ -3,7 +3,7 @@ import uPlot from 'uplot';
import { getDefaultTimeRange, createTheme } from '@grafana/data';
import { VisibilityMode } from '@grafana/schema';
import { getConfig, TimelineCoreOptions } from './timeline';
import { getConfig, TimelineCoreOptions, shouldDrawYValue } from './timeline';
import { TimelineMode } from './utils';
jest.mock('uplot');
@@ -208,4 +208,80 @@ describe('StateTimeline uPlot integration', () => {
});
});
});
describe('#shouldDrawYValue', () => {
describe.each([
[true, undefined, undefined, true, 'boolean true returns true'],
[false, undefined, undefined, true, 'boolean false returns true'],
])('boolean values', (yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
});
describe.each([
[0, undefined, undefined, true, 'zero returns true'],
[1, undefined, undefined, true, 'positive integer returns true'],
[-2.71, undefined, undefined, true, 'negative float returns true'],
[Number.MAX_VALUE, undefined, undefined, true, 'max value returns true'],
[Number.MIN_VALUE, undefined, undefined, true, 'min value returns true'],
])('finite numeric values', (yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
});
describe.each([
[Infinity, undefined, undefined, true, 'positive infinity returns true'],
[-Infinity, undefined, undefined, true, 'negative infinity returns true'],
[Number.POSITIVE_INFINITY, undefined, undefined, true, 'Number.POSITIVE_INFINITY returns true'],
[Number.NEGATIVE_INFINITY, undefined, undefined, true, 'Number.NEGATIVE_INFINITY returns true'],
])('non-finite numeric values', (yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
});
describe.each([
[null, undefined, undefined, false, 'null without mappings returns false'],
[null, false, true, false, 'null with false mappedNull returns false'],
[null, true, undefined, true, 'null with ture mappedNull returns true'],
])('null values', (yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
});
describe.each([
[NaN, undefined, undefined, false, 'NaN without mappings returns false'],
[NaN, true, undefined, false, 'NaN with false mappedNaN returns false'],
[NaN, undefined, true, true, 'NaN with true mappedNaN returns true'],
])('NaN values', (yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
});
describe.each([
['', undefined, undefined, true, 'empty string returns true'],
['to be or not to be', undefined, undefined, true, 'non-empty string returns true'],
])('string values', (yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
});
describe.each([[undefined, undefined, undefined, false, 'undefined returns false']])(
'falsy values',
(yValue, mappedNull, mappedNaN, expected, testName) => {
it(testName, () => {
expect(shouldDrawYValue(yValue, mappedNull, mappedNaN)).toBe(expected);
});
}
);
describe('non-supported values', () => {
it.todo(`TODO: this helper currently returns true for many non-supported truthy values, but should not`);
});
});
});
@@ -56,6 +56,28 @@ export interface TimelineCoreOptions {
hoverMulti: boolean;
}
/**
* @internal
*/
export function shouldDrawYValue(yValue: unknown, mappedNull?: boolean, mappedNaN?: boolean): boolean {
if (typeof yValue === 'boolean') {
return true;
}
if (typeof yValue === 'string') {
return true;
}
if (typeof yValue === 'number' && !Number.isNaN(yValue)) {
return true;
}
if (yValue === null && mappedNull) {
return true;
}
if (Number.isNaN(yValue) && mappedNaN) {
return true;
}
return !!yValue;
}
/**
* @internal
*/
@@ -210,9 +232,7 @@ export function getConfig(opts: TimelineCoreOptions) {
if (mode === TimelineMode.Changes) {
for (let ix = 0; ix < dataY.length; ix++) {
let yVal = dataY[ix];
const shouldDrawY =
!!yVal || yVal === 0 || (yVal === null && mappedNull) || (Number.isNaN(yVal) && mappedNaN);
const shouldDrawY = shouldDrawYValue(yVal, mappedNull, mappedNaN);
if (shouldDrawY) {
let left = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff));
@@ -257,8 +277,7 @@ export function getConfig(opts: TimelineCoreOptions) {
for (let ix = idx0; ix <= idx1; ix++) {
let yVal = dataY[ix];
const shouldDrawY =
!!yVal || yVal === 0 || (yVal === null && mappedNull) || (Number.isNaN(yVal) && mappedNaN);
const shouldDrawY = shouldDrawYValue(yVal, mappedNull, mappedNaN);
if (shouldDrawY) {
// TODO: all xPos can be pre-computed once for all series in aligned set
@@ -321,8 +340,7 @@ export function getConfig(opts: TimelineCoreOptions) {
for (let ix = 0; ix < dataY.length; ix++) {
const yVal = dataY[ix];
const shouldDrawY =
!!yVal || yVal === 0 || (yVal == null && mappedNull) || (Number.isNaN(yVal) && mappedNaN);
const shouldDrawY = shouldDrawYValue(yVal, mappedNull, mappedNaN);
if (shouldDrawY) {
const boxRect = boxRectsBySeries[sidx - 1][ix];