From 1c977281c8a17a63aada8dc2adbbf49fa06059e7 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Fri, 22 Apr 2022 19:33:25 -0500 Subject: [PATCH] TimeSeries: sync minimum bar width across all bar series (#48030) --- .../src/components/GraphNG/utils.test.ts | 299 ++++++++++++++++++ .../src/components/GraphNG/utils.ts | 86 +++-- 2 files changed, 367 insertions(+), 18 deletions(-) diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/components/GraphNG/utils.test.ts index 7395c859226..31a4bb00209 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.test.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.test.ts @@ -1,6 +1,8 @@ import { + ArrayVector, createTheme, DashboardCursorSync, + DataFrame, DefaultTimeZone, EventBusSrv, FieldConfig, @@ -204,4 +206,301 @@ describe('GraphNG utils', () => { }).getConfig(); expect(result).toMatchSnapshot(); }); + + test('preparePlotFrame appends min bar spaced nulls when > 1 bar series', () => { + const df1: DataFrame = { + name: 'A', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: new ArrayVector([1, 2, 4, 6, 100]), // should find smallest delta === 1 from here + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: new ArrayVector([1, 1, 1, 1, 1]), + }, + ], + }; + + const df2: DataFrame = { + name: 'B', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: new ArrayVector([30, 40, 50, 90, 100]), // should be appended with two smallest-delta increments + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: new ArrayVector([2, 2, 2, 2, 2]), // bar series should be appended with nulls + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: new ArrayVector([3, 3, 3, 3, 3]), // line series should be appended with undefineds + }, + ], + }; + + const df3: DataFrame = { + name: 'C', + length: 2, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: new ArrayVector([1, 1.1]), // should not trip up on smaller deltas of non-bars + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: new ArrayVector([4, 4]), + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + hideFrom: { + viz: true, // should ignore hidden bar series + }, + }, + }, + values: new ArrayVector([4, 4]), + }, + ], + }; + + let aligndFrame = preparePlotFrame([df1, df2, df3], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); + + expect(aligndFrame).toMatchInlineSnapshot(` + Object { + "fields": Array [ + Object { + "config": Object {}, + "name": "time", + "state": Object { + "origin": Object { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + "type": "time", + "values": Array [ + 1, + 1.1, + 2, + 4, + 6, + 30, + 40, + 50, + 90, + 100, + 101, + 102, + ], + }, + Object { + "config": Object { + "custom": Object { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": Object { + "name": "A", + }, + "name": "value", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + "type": "number", + "values": Array [ + 1, + undefined, + 1, + 1, + 1, + undefined, + undefined, + undefined, + undefined, + 1, + null, + null, + ], + }, + Object { + "config": Object { + "custom": Object { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": Object { + "name": "B", + }, + "name": "value", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 1, + }, + }, + "type": "number", + "values": Array [ + undefined, + undefined, + undefined, + undefined, + undefined, + 2, + 2, + 2, + 2, + 2, + null, + null, + ], + }, + Object { + "config": Object { + "custom": Object { + "drawStyle": "line", + }, + }, + "labels": Object { + "name": "B", + }, + "name": "value", + "state": Object { + "origin": Object { + "fieldIndex": 2, + "frameIndex": 1, + }, + }, + "type": "number", + "values": Array [ + undefined, + undefined, + undefined, + undefined, + undefined, + 3, + 3, + 3, + 3, + 3, + undefined, + undefined, + ], + }, + Object { + "config": Object { + "custom": Object { + "drawStyle": "line", + }, + }, + "labels": Object { + "name": "C", + }, + "name": "value", + "state": Object { + "origin": Object { + "fieldIndex": 1, + "frameIndex": 2, + }, + }, + "type": "number", + "values": Array [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + Object { + "config": Object { + "custom": Object { + "drawStyle": "bars", + "hideFrom": Object { + "viz": true, + }, + }, + }, + "labels": Object { + "name": "C", + }, + "name": "value", + "state": Object { + "origin": Object { + "fieldIndex": 2, + "frameIndex": 2, + }, + }, + "type": "number", + "values": Array [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + ], + "length": 10, + } + `); + }); }); diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts index 41558d9bd3c..3abea36bb97 100644 --- a/packages/grafana-ui/src/components/GraphNG/utils.ts +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -1,4 +1,4 @@ -import { ArrayVector, DataFrame, FieldConfig, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; +import { ArrayVector, DataFrame, Field, FieldConfig, FieldType, outerJoinDataFrames, TimeRange } from '@grafana/data'; import { AxisPlacement, GraphDrawStyle, @@ -12,6 +12,10 @@ import { applyNullInsertThreshold } from './nullInsertThreshold'; import { nullToUndefThreshold } from './nullToUndefThreshold'; import { XYFieldMatchers } from './types'; +function isVisibleBarField(f: Field) { + return f.config.custom?.drawStyle === GraphDrawStyle.Bars && !f.config.custom?.hideFrom?.viz; +} + // will mutate the DataFrame's fields' values function applySpanNullsThresholds(frame: DataFrame) { let refField = frame.fields.find((field) => field.type === FieldType.time); // this doesnt need to be time, just any numeric/asc join field @@ -20,7 +24,7 @@ function applySpanNullsThresholds(frame: DataFrame) { for (let i = 0; i < frame.fields.length; i++) { let field = frame.fields[i]; - if (field === refField) { + if (field === refField || isVisibleBarField(field)) { continue; } @@ -37,30 +41,76 @@ function applySpanNullsThresholds(frame: DataFrame) { } export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers, timeRange?: TimeRange | null) { - let alignedFrame = outerJoinDataFrames({ - frames: frames.map((frame) => { - let fr = applyNullInsertThreshold(frame, null, timeRange?.to.valueOf()); + // apply null insertions at interval + frames = frames.map((frame) => applyNullInsertThreshold(frame, null, timeRange?.to.valueOf())); - // prevent minesweeper-expansion of nulls (gaps) when joining bars - // since bar width is determined from the minimum distance between non-undefined values - // (this strategy will still retain any original pre-join nulls, though) - fr.fields.forEach((f) => { - if (f.type === FieldType.number && f.config.custom?.drawStyle === GraphDrawStyle.Bars) { - f.config.custom = { - ...f.config.custom, - spanNulls: -1, - }; + let numBarSeries = 0; + + frames.forEach((frame) => { + frame.fields.forEach((f) => { + if (isVisibleBarField(f)) { + // prevent minesweeper-expansion of nulls (gaps) when joining bars + // since bar width is determined from the minimum distance between non-undefined values + // (this strategy will still retain any original pre-join nulls, though) + f.config.custom = { + ...f.config.custom, + spanNulls: -1, + }; + + numBarSeries++; + } + }); + }); + + // to make bar widths of all series uniform (equal to narrowest bar series), find smallest distance between x points + let minXDelta = Infinity; + + if (numBarSeries > 1) { + frames.forEach((frame) => { + if (!frame.fields.some(isVisibleBarField)) { + return; + } + + const xVals = frame.fields[0].values.toArray(); + + for (let i = 0; i < xVals.length; i++) { + if (i > 0) { + minXDelta = Math.min(minXDelta, xVals[i] - xVals[i - 1]); } - }); + } + }); + } - return fr; - }), + let alignedFrame = outerJoinDataFrames({ + frames, joinBy: dimFields.x, keep: dimFields.y, keepOriginIndices: true, }); - return alignedFrame && applySpanNullsThresholds(alignedFrame); + if (alignedFrame) { + alignedFrame = applySpanNullsThresholds(alignedFrame); + + // append 2 null vals at minXDelta to bar series + if (minXDelta !== Infinity) { + alignedFrame.fields.forEach((f, fi) => { + let vals = f.values.toArray(); + + if (fi === 0) { + let lastVal = vals[vals.length - 1]; + vals.push(lastVal + minXDelta, lastVal + 2 * minXDelta); + } else if (isVisibleBarField(f)) { + vals.push(null, null); + } else { + vals.push(undefined, undefined); + } + }); + } + + return alignedFrame; + } + + return null; } export function buildScaleKey(config: FieldConfig) {