From 1e41c07920eb312dcf13604aef4451e437d8a357 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Thu, 12 Jun 2025 15:13:25 -0400 Subject: [PATCH] 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 Co-authored-by: Adela Almasan --- .../timeline-thresholds-mappings.json | 331 ++++++++++++++++-- packages/grafana-data/src/types/dataFrame.ts | 4 +- .../src/utils/valueMappings.test.ts | 92 +++++ .../components/TimelineChart/timeline.test.ts | 211 +++++++++++ .../core/components/TimelineChart/timeline.ts | 31 +- .../components/TimelineChart/utils.test.ts | 49 ++- .../core/components/TimelineChart/utils.ts | 25 +- 7 files changed, 692 insertions(+), 51 deletions(-) create mode 100644 public/app/core/components/TimelineChart/timeline.test.ts diff --git a/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json b/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json index 137bbfe04f2..39cc956a7f8 100644 --- a/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json +++ b/devenv/dev-dashboards/panel-timeline/timeline-thresholds-mappings.json @@ -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 } diff --git a/packages/grafana-data/src/types/dataFrame.ts b/packages/grafana-data/src/types/dataFrame.ts index 4a0f8adbe58..e8b6ca13f7b 100644 --- a/packages/grafana-data/src/types/dataFrame.ts +++ b/packages/grafana-data/src/types/dataFrame.ts @@ -54,7 +54,7 @@ export interface FieldConfig { 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 { /** * 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[]; diff --git a/packages/grafana-data/src/utils/valueMappings.test.ts b/packages/grafana-data/src/utils/valueMappings.test.ts index 90686b52895..e03f0ad2585 100644 --- a/packages/grafana-data/src/utils/valueMappings.test.ts +++ b/packages/grafana-data/src/utils/valueMappings.test.ts @@ -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(); + }); +}); diff --git a/public/app/core/components/TimelineChart/timeline.test.ts b/public/app/core/components/TimelineChart/timeline.test.ts new file mode 100644 index 00000000000..1fd72e9bec5 --- /dev/null +++ b/public/app/core/components/TimelineChart/timeline.test.ts @@ -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 => ({ + 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> = [ + [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); + }); + }); + }); + }); +}); diff --git a/public/app/core/components/TimelineChart/timeline.ts b/public/app/core/components/TimelineChart/timeline.ts index 8fa0a8faef1..302437593e9 100644 --- a/public/app/core/components/TimelineChart/timeline.ts +++ b/public/app/core/components/TimelineChart/timeline.ts @@ -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) { diff --git a/public/app/core/components/TimelineChart/utils.test.ts b/public/app/core/components/TimelineChart/utils.test.ts index a3c181ef42b..6dc82194d0c 100644 --- a/public/app/core/components/TimelineChart/utils.test.ts +++ b/public/app/core/components/TimelineChart/utils.test.ts @@ -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); + }); +}); diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index a9af674a839..c22dfc574e3 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -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 = ({ frame, theme, @@ -94,15 +101,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( 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 = ( 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)) {