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:
co-authored by
Jesse David Peterson
Adela Almasan
parent
7b70271ce6
commit
1e41c07920
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user