StateTimeline: Support NaN and null value mappings (#105638)

* fix(#92944): add StateTimeline Null+NaN handling

* chore: remove console.warns from debugging

* test: initialize a couple of simple tests

* test: more tests for hasMappedNaN and hasMappedNull

* chore: revert some of the let-const syntax cleanup for a later PR

* chore: rename should draw method

* chore: fix comment typo

* refactor(timeline-chart-utils): un-nest hasSpecialMappedValue() helper

* test(timeline-chart-utils): unit test hasSpecialMappedValue() helper

* chore: fix code comment typos in changed files

* refactor(timeline-chart): reduce helper DRY-ness for better performance

* fix(timeline-chart): check Y value for truthiness, not if it is finite

* test(state-timeline): additional gdev test panels with null + NN values

* fix(timeline-chart): allow Y value of zero in checks

---------

Co-authored-by: Jesse David Peterson <jesdavpet@users.noreply.github.com>
Co-authored-by: Adela Almasan <adela.almasan@grafana.com>
This commit is contained in:
Paul Marbach
2025-06-12 15:13:25 -04:00
committed by GitHub
co-authored by Jesse David Peterson Adela Almasan
parent 7b70271ce6
commit 1e41c07920
7 changed files with 692 additions and 51 deletions
@@ -24,19 +24,27 @@
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1263,
"id": 15116,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
@@ -45,7 +53,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -74,10 +83,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -93,14 +104,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
@@ -109,7 +129,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -146,10 +167,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -165,14 +188,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
@@ -181,7 +213,8 @@
"mode": "percentage",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -218,10 +251,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -237,14 +272,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
@@ -253,7 +297,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -275,7 +320,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -314,10 +360,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -333,14 +381,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"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
},
@@ -394,7 +451,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
}
]
}
@@ -416,13 +474,15 @@
"showLegend": true
},
"mergeValues": true,
"rowHeight": 0.9,
"rowHeight": 0.47,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -438,14 +498,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"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
},
@@ -454,7 +523,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
}
]
}
@@ -537,10 +607,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -556,14 +628,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
@@ -572,7 +653,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -601,10 +683,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -620,14 +704,23 @@
"type": "state-timeline"
},
{
"datasource": { "type": "testdata" },
"datasource": {
"type": "testdata"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"custom": {
"axisPlacement": "auto",
"fillOpacity": 70,
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineWidth": 0,
"spanNulls": false
},
@@ -636,7 +729,8 @@
"mode": "absolute",
"steps": [
{
"color": "green"
"color": "green",
"value": 0
},
{
"color": "red",
@@ -665,10 +759,12 @@
"rowHeight": 0.9,
"showValue": "auto",
"tooltip": {
"hideZeros": false,
"mode": "single",
"sort": "none"
}
},
"pluginVersion": "12.1.0-pre",
"targets": [
{
"datasource": {
@@ -709,10 +805,192 @@
}
],
"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": "null",
"result": {
"color": "purple",
"index": 0,
"text": "null"
}
},
"type": "special"
},
{
"options": {
"match": "nan",
"result": {
"color": "red",
"index": 1,
"text": "NaN"
}
},
"type": "special"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 6,
"x": 0,
"y": 18
},
"id": 12,
"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 ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [3]}]\n }\n }\n]",
"refId": "A",
"scenarioId": "raw_frame"
}
],
"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": "null+nan",
"result": {
"color": "super-light-red",
"index": 0,
"text": "null + NaN"
}
},
"type": "special"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": 0
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 9,
"w": 6,
"x": 6,
"y": 18
},
"id": 13,
"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 ],\n [\n 5,\n null,\n 20,\n null,\n 40\n ]\n ],\n \"entities\": [null, { \"NaN\": [1]}]\n }\n }\n]",
"refId": "A",
"scenarioId": "raw_frame"
}
],
"title": "special null + NaN value mapping from data",
"type": "state-timeline"
}
],
"refresh": false,
"schemaVersion": 38,
"preload": false,
"refresh": "",
"schemaVersion": 41,
"tags": [
"gdev",
"panel-tests",
@@ -723,13 +1001,12 @@
"list": []
},
"time": {
"from": "2023-01-26T11:33:55.000Z",
"to": "2023-01-26T14:33:55.000Z"
"from": "2023-01-26T11:29:47.180Z",
"to": "2023-01-26T16:29:39.205Z"
},
"timepicker": {},
"timezone": "",
"timezone": "utc",
"title": "StateTimeline - Thresholds & Mappings",
"uid": "Kce7z9TVz",
"version": 14,
"weekStart": ""
"version": 13
}
+2 -2
View File
@@ -54,7 +54,7 @@ export interface FieldConfig<TOptions = any> {
description?: string;
/**
* An explict path to the field in the datasource. When the frame meta includes a path,
* An explicit path to the field in the datasource. When the frame meta includes a path,
* This will default to `${frame.meta.path}/${field.name}
*
* When defined, this value can be used as an identifier within the datasource scope, and
@@ -158,7 +158,7 @@ export interface Field<T = any> {
/**
* When type === FieldType.Time, this can optionally store
* the nanosecond-precison fractions as integers between
* the nanosecond-precision fractions as integers between
* 0 and 999999.
*/
nanos?: number[];
@@ -281,3 +281,95 @@ describe('isNumeric', () => {
expect(isNumeric(value)).toEqual(expected);
});
});
describe('null and NaN special mapping', () => {
it('should return null for NaN', () => {
const value = Number.NaN;
expect(
getValueMappingResult(
[
{
type: MappingType.SpecialValue,
options: {
match: SpecialValueMatch.NullAndNaN,
result: { text: 'it is null or nan' },
},
},
],
value
)
).toEqual({ text: 'it is null or nan' });
});
it('should return null for null', () => {
const value = null;
expect(
getValueMappingResult(
[
{
type: MappingType.SpecialValue,
options: {
match: SpecialValueMatch.NullAndNaN,
result: { text: 'it is null or nan' },
},
},
],
value
)
).toEqual({ text: 'it is null or nan' });
});
it('should return null for undefined', () => {
const value = undefined;
expect(
getValueMappingResult(
[
{
type: MappingType.SpecialValue,
options: {
match: SpecialValueMatch.NullAndNaN,
result: { text: 'it is null or nan' },
},
},
],
value
)
).toEqual({ text: 'it is null or nan' });
});
it('should return null for numeric non-NaN', () => {
const value = 42;
expect(
getValueMappingResult(
[
{
type: MappingType.SpecialValue,
options: {
match: SpecialValueMatch.NullAndNaN,
result: { text: 'it is null or nan' },
},
},
],
value
)
).toBeNull();
});
it('should return null for string', () => {
const value = 'foo';
expect(
getValueMappingResult(
[
{
type: MappingType.SpecialValue,
options: {
match: SpecialValueMatch.NullAndNaN,
result: { text: 'it is null or nan' },
},
},
],
value
)
).toBeNull();
});
});
@@ -0,0 +1,211 @@
import uPlot from 'uplot';
import { getDefaultTimeRange, createTheme } from '@grafana/data';
import { VisibilityMode } from '@grafana/schema';
import { getConfig, TimelineCoreOptions } from './timeline';
import { TimelineMode } from './utils';
jest.mock('uplot');
describe('StateTimeline uPlot integration', () => {
const buildTestCoreOptions = (opts: Partial<TimelineCoreOptions> = {}): TimelineCoreOptions => ({
mode: TimelineMode.Changes,
numSeries: 1,
theme: createTheme(),
showValue: VisibilityMode.Always,
isDiscrete: jest.fn(() => true),
hasMappedNull: jest.fn(() => false),
hasMappedNaN: jest.fn(() => false),
getValueColor: jest.fn(() => '#fff'),
label: jest.fn(() => 'foo'),
getTimeRange: jest.fn(() => getDefaultTimeRange()),
getFieldConfig: jest.fn(() => ({})),
hoverMulti: false,
...opts,
});
const buildMockUplotInstance = (
data: Array<Array<null | number>> = [
[0, 0, 0],
[0, 1, 2],
]
) =>
({
ctx: {
save: jest.fn(),
restore: jest.fn(),
rect: jest.fn(),
clip: jest.fn(),
font: '',
fill: jest.fn(),
fillStyle: '',
fillText: jest.fn(),
measureText: jest.fn(() => ({ width: 0 })),
beginPath: jest.fn(),
},
root: document.createElement('div'),
bbox: { left: 0, top: 0, width: 100, height: 100 },
data,
cursor: { left: 0, top: 0 },
series: [{}],
scales: {},
opts: {},
pxRatio: 1,
posToVal: jest.fn(),
valToPos: jest.fn(),
}) as unknown as uPlot;
const callOrientCallback = (mockUplot: uPlot) => {
const orientCallback = jest.mocked(uPlot.orient).mock.calls[jest.mocked(uPlot.orient).mock.calls.length - 1][2];
const methods = {
moveTo: jest.fn(() => {}),
lineTo: jest.fn(() => {}),
rect: jest.fn(() => {}),
arc: jest.fn(() => {}),
bezierCurveTo: jest.fn(() => {}),
};
orientCallback(
mockUplot.series[0],
mockUplot.data[0] as number[],
mockUplot.data[1] as number[],
mockUplot.scales.x,
mockUplot.scales.y,
jest.fn(() => 1),
jest.fn(() => 1),
0,
0,
100,
100,
methods.moveTo,
methods.lineTo,
methods.rect,
methods.arc,
methods.bezierCurveTo
);
return methods;
};
describe('#drawPoints', () => {
it('returns a `drawPoints` method when a `formatValue` function is provided', () => {
const config = getConfig(buildTestCoreOptions({ formatValue: () => 'foo' }));
expect(typeof config.drawPoints).toBe('function');
});
it('returns false for `drawPoints` when no `formatValue` function is provided', () => {
const config = getConfig(buildTestCoreOptions());
expect(config.drawPoints).toBe(false);
});
it('returns false for `drawPoints` if the visibility mode is `never`', () => {
const config = getConfig(buildTestCoreOptions({ formatValue: () => 'foo', showValue: VisibilityMode.Never }));
expect(config.drawPoints).toBe(false);
});
it('returns a function for `drawPoints` if the conditions are met', () => {
const { drawPoints } = getConfig(buildTestCoreOptions({ formatValue: () => 'foo' }));
if (!drawPoints) {
throw new Error('drawPoints is not defined');
}
const mockUplot = buildMockUplotInstance();
expect(drawPoints(mockUplot, 1, 2, 3, null)).toBe(false);
expect(uPlot.orient).toHaveBeenCalledWith(mockUplot, 1, expect.any(Function));
});
describe('#drawPaths', () => {
describe('null and NaN values', () => {
// these tests are attempting to determine whether `shouldDrawYVal` is returning false
// and preventing a draw for a given value. this is being done by checking the number of
// calls to `rect` by the `orient` callback created by a `drawPaths` call.
it('should draw boxes for null values when hasMappedNull returns true and isDiscrete returns true', () => {
const { drawClear, drawPaths } = getConfig(
buildTestCoreOptions({
hasMappedNull: jest.fn(() => true),
isDiscrete: jest.fn(() => true),
formatValue: () => 'foo',
})
);
const mockUplot = buildMockUplotInstance([[0], [null]]);
drawClear(mockUplot);
drawPaths(mockUplot, 1, 0, 1);
const { rect } = callOrientCallback(mockUplot);
expect(rect).toHaveBeenCalledTimes(2);
});
it('should not draw boxes for null values when hasMappedNull returns false and isDiscrete returns true', () => {
const { drawClear, drawPaths } = getConfig(
buildTestCoreOptions({
hasMappedNull: jest.fn(() => false),
isDiscrete: jest.fn(() => true),
formatValue: () => 'foo',
})
);
const mockUplot = buildMockUplotInstance([[0], [null]]);
drawClear(mockUplot);
drawPaths(mockUplot, 1, 0, 1);
const { rect } = callOrientCallback(mockUplot);
expect(rect).toHaveBeenCalledTimes(1);
});
it('should draw boxes for NaN values when hasMappedNaN returns true and isDiscrete returns true', () => {
const { drawClear, drawPaths } = getConfig(
buildTestCoreOptions({
hasMappedNaN: jest.fn(() => true),
isDiscrete: jest.fn(() => true),
formatValue: () => 'foo',
})
);
const mockUplot = buildMockUplotInstance([[0], [NaN]]);
drawClear(mockUplot);
drawPaths(mockUplot, 1, 0, 1);
const { rect } = callOrientCallback(mockUplot);
expect(rect).toHaveBeenCalledTimes(2);
});
it('should not draw boxes for NaN values when hasMappedNaN returns false and isDiscrete returns true', () => {
const { drawClear, drawPaths } = getConfig(
buildTestCoreOptions({
hasMappedNaN: jest.fn(() => false),
isDiscrete: jest.fn(() => true),
formatValue: () => 'foo',
})
);
const mockUplot = buildMockUplotInstance([[0], [NaN]]);
drawClear(mockUplot);
drawPaths(mockUplot, 1, 0, 1);
const { rect } = callOrientCallback(mockUplot);
expect(rect).toHaveBeenCalledTimes(1);
});
it('should not draw boxes for NaN or null values when isDiscrete returns false', () => {
const { drawClear, drawPaths } = getConfig(
buildTestCoreOptions({
hasMappedNaN: jest.fn(() => true),
hasMappedNull: jest.fn(() => true),
isDiscrete: jest.fn(() => false),
formatValue: () => 'foo',
})
);
const mockUplot = buildMockUplotInstance([[0], [NaN, null]]);
drawClear(mockUplot);
drawPaths(mockUplot, 1, 0, 1);
const { rect } = callOrientCallback(mockUplot);
expect(rect).toHaveBeenCalledTimes(1);
});
});
});
});
});
@@ -47,6 +47,7 @@ export interface TimelineCoreOptions {
mergeValues?: boolean;
isDiscrete: (seriesIdx: number) => boolean;
hasMappedNull: (seriesIdx: number) => boolean;
hasMappedNaN: (seriesIdx: number) => boolean;
getValueColor: (seriesIdx: number, value: unknown) => string;
label: (seriesIdx: number) => string;
getTimeRange: () => TimeRange;
@@ -64,6 +65,7 @@ export function getConfig(opts: TimelineCoreOptions) {
numSeries,
isDiscrete,
hasMappedNull,
hasMappedNaN,
rowHeight = 0,
colWidth = 0,
showValue,
@@ -196,9 +198,9 @@ export function getConfig(opts: TimelineCoreOptions) {
sidx,
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => {
let strokeWidth = round((series.width || 0) * uPlot.pxRatio);
let discrete = isDiscrete(sidx);
let mappedNull = discrete && hasMappedNull(sidx);
const discrete = isDiscrete(sidx);
const mappedNull = discrete && hasMappedNull(sidx);
const mappedNaN = discrete && hasMappedNaN(sidx);
u.ctx.save();
rect(u.ctx, u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
@@ -209,7 +211,10 @@ export function getConfig(opts: TimelineCoreOptions) {
for (let ix = 0; ix < dataY.length; ix++) {
let yVal = dataY[ix];
if (yVal != null || mappedNull) {
const shouldDrawY =
!!yVal || yVal === 0 || (yVal === null && mappedNull) || (Number.isNaN(yVal) && mappedNaN);
if (shouldDrawY) {
let left = Math.round(valToPosX(dataX[ix], scaleX, xDim, xOff));
let nextIx = ix;
@@ -252,8 +257,10 @@ 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);
if (yVal != null || mappedNull) {
if (shouldDrawY) {
// TODO: all xPos can be pre-computed once for all series in aligned set
let left = valToPosX(dataX[ix], scaleX, xDim, xOff);
@@ -306,14 +313,18 @@ export function getConfig(opts: TimelineCoreOptions) {
sidx,
(series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim) => {
let strokeWidth = round((series.width || 0) * uPlot.pxRatio);
let discrete = isDiscrete(sidx);
let mappedNull = discrete && hasMappedNull(sidx);
let y = round(valToPosY(ySplits[sidx - 1], scaleY, yDim, yOff));
const discrete = isDiscrete(sidx);
const mappedNull = discrete && hasMappedNull(sidx);
const mappedNaN = discrete && hasMappedNaN(sidx);
for (let ix = 0; ix < dataY.length; ix++) {
if (dataY[ix] != null || mappedNull) {
const yVal = dataY[ix];
const shouldDrawY =
!!yVal || yVal === 0 || (yVal == null && mappedNull) || (Number.isNaN(yVal) && mappedNaN);
if (shouldDrawY) {
const boxRect = boxRectsBySeries[sidx - 1][ix];
if (!boxRect || boxRect.x >= xDim) {
@@ -8,8 +8,10 @@ import {
DataFrame,
fieldMatchers,
FieldMatcherID,
Field,
SpecialValueMatch,
} from '@grafana/data';
import { LegendDisplayMode, VizLegendOptions } from '@grafana/schema';
import { LegendDisplayMode, MappingType, VizLegendOptions } from '@grafana/schema';
import { preparePlotFrame } from '../GraphNG/utils';
@@ -17,6 +19,7 @@ import {
findNextStateIndex,
fmtDuration,
getThresholdItems,
hasSpecialMappedValue,
makeFramePerSeries,
prepareTimelineFields,
prepareTimelineLegendItems,
@@ -504,3 +507,47 @@ describe('duration', () => {
expect(result).toEqual(expected);
});
});
describe('hasSpecialMappedValue', () => {
const makeField = (mappingsType: MappingType | SpecialValueMatch, optionsMatch: MappingType | SpecialValueMatch) =>
({
name: 'Field',
type: FieldType.frame,
config: {
mappings: [
{
type: mappingsType,
options: { match: optionsMatch, result: {} },
},
],
},
values: [],
}) as Field;
it.each([
[[MappingType.SpecialValue, SpecialValueMatch.Null], SpecialValueMatch.Null, true, 'should match Null with Null'],
[[MappingType.SpecialValue, SpecialValueMatch.NaN], SpecialValueMatch.NaN, true, 'should match NaN with NaN'],
[
[MappingType.SpecialValue, SpecialValueMatch.NullAndNaN],
SpecialValueMatch.NullAndNaN,
true,
'should match Null and NaN with Null and NaN',
],
[
[MappingType.SpecialValue, SpecialValueMatch.NullAndNaN],
SpecialValueMatch.Empty,
false,
'should NOT match Null and NaN with Empty',
],
[
[MappingType.ValueToText, SpecialValueMatch.Null],
SpecialValueMatch.Null,
false,
'should NOT match non-special value',
],
])('%s', ([mappingsType, optionsMatch], valueMatch, expected, _) => {
const field = makeField(mappingsType, optionsMatch);
expect(hasSpecialMappedValue(field, valueMatch)).toEqual(expected);
});
});
@@ -20,6 +20,7 @@ import {
ThresholdsConfig,
applyNullInsertThreshold,
nullToValue,
SpecialValueMatch,
} from '@grafana/data';
import { maybeSortFrame, NULL_RETAIN } from '@grafana/data/internal';
import {
@@ -72,6 +73,12 @@ const defaultConfig: PanelFieldConfig = {
fillOpacity: 80,
};
/** Checks if a mapped value of the specified type exists for the given field */
export const hasSpecialMappedValue = (field: Field, match: SpecialValueMatch): boolean =>
field.config.mappings?.some(
(mapping: ValueMapping): boolean => mapping.type === MappingType.SpecialValue && mapping.options.match === match
) || false;
export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = ({
frame,
theme,
@@ -94,15 +101,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
const mode = field.config?.color?.mode;
return !(mode && field.display && mode.startsWith('continuous-'));
};
const hasMappedNull = (field: Field) => {
return (
field.config.mappings?.some(
(mapping) => mapping.type === MappingType.SpecialValue && mapping.options.match === 'null'
) || false
);
};
const getValueColorFn = (seriesIdx: number, value: unknown) => {
const field = frame.fields[seriesIdx];
@@ -121,7 +119,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<UPlotConfigOptions> = (
mode: mode!,
numSeries: frame.fields.length - 1,
isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
hasMappedNull: (seriesIdx) => hasMappedNull(frame.fields[seriesIdx]),
hasMappedNull: (seriesIdx) =>
hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.Null) ||
hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.NullAndNaN),
hasMappedNaN: (seriesIdx) =>
hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.NaN) ||
hasSpecialMappedValue(frame.fields[seriesIdx], SpecialValueMatch.NullAndNaN),
mergeValues,
rowHeight: rowHeight,
colWidth: colWidth,
@@ -664,7 +667,7 @@ export function findNextStateIndex(field: Field, datapointIdx: number) {
* This function calculates with 30 days month and 365 days year.
* adapted from https://gist.github.com/remino/1563878
* @param milliSeconds The duration in milliseconds
* @returns A formated string of the duration
* @returns A formatted string of the duration
*/
export function fmtDuration(milliSeconds: number): string {
if (milliSeconds < 0 || Number.isNaN(milliSeconds)) {