From 8b80d2256dabbb29bb2beb3e97773789af61b22f Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Wed, 28 Jul 2021 20:31:07 -0500 Subject: [PATCH] GraphNG: stack by % (#37127) --- .../src/dataframe/ArrayDataFrame.ts | 1 + .../src/vector/FunctionalVector.ts | 1 + .../src/components/GraphNG/GraphNG.tsx | 23 ++++++---- .../src/components/Sparkline/Sparkline.tsx | 3 +- .../src/components/TimeSeries/utils.ts | 4 +- .../grafana-ui/src/components/uPlot/config.ts | 1 + .../uPlot/config/UPlotConfigBuilder.ts | 10 ++++- .../grafana-ui/src/components/uPlot/utils.ts | 44 ++++++++++++++++--- .../plugins/panel/barchart/BarChartPanel.tsx | 7 ++- public/app/plugins/panel/barchart/bars.ts | 27 ++++++++++-- .../app/plugins/panel/barchart/utils.test.ts | 8 ++-- public/app/plugins/panel/barchart/utils.ts | 11 +++++ .../app/plugins/panel/state-timeline/utils.ts | 3 ++ public/app/plugins/panel/timeseries/utils.ts | 8 +++- 14 files changed, 121 insertions(+), 30 deletions(-) diff --git a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts index ed055966ea2..ff69a9efbb0 100644 --- a/packages/grafana-data/src/dataframe/ArrayDataFrame.ts +++ b/packages/grafana-data/src/dataframe/ArrayDataFrame.ts @@ -4,6 +4,7 @@ import { Vector, QueryResultMeta } from '../types'; import { guessFieldTypeFromNameAndValue, toDataFrameDTO } from './processDataFrame'; import { FunctionalVector } from '../vector/FunctionalVector'; +/** @public */ export type ValueConverter = (val: any) => T; const NOOP: ValueConverter = (v) => v; diff --git a/packages/grafana-data/src/vector/FunctionalVector.ts b/packages/grafana-data/src/vector/FunctionalVector.ts index 6569881ec52..2e999704da2 100644 --- a/packages/grafana-data/src/vector/FunctionalVector.ts +++ b/packages/grafana-data/src/vector/FunctionalVector.ts @@ -1,6 +1,7 @@ import { vectorToArray } from './vectorToArray'; import { Vector } from '../types'; +/** @public */ export abstract class FunctionalVector implements Vector, Iterable { abstract get length(): number; diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 763954e70fc..304e61a8e67 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { AlignedData } from 'uplot'; import { Themeable2 } from '../../types'; -import { findMidPointYPosition, pluginLog, preparePlotData } from '../uPlot/utils'; +import { findMidPointYPosition, pluginLog } from '../uPlot/utils'; import { DataFrame, FieldMatcherID, @@ -98,16 +98,20 @@ export class GraphNG extends React.Component { pluginLog('GraphNG', false, 'data aligned', alignedFrame); if (alignedFrame) { - state = { - alignedFrame, - alignedData: preparePlotData(alignedFrame), - }; - pluginLog('GraphNG', false, 'data prepared', state.alignedData); + let config = this.state?.config; if (withConfig) { - state.config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); - pluginLog('GraphNG', false, 'config prepared', state.config); + config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config prepared', config); } + + state = { + alignedFrame, + alignedData: config!.prepData!(alignedFrame), + config, + }; + + pluginLog('GraphNG', false, 'data prepared', state.alignedData); } return state; @@ -123,7 +127,7 @@ export class GraphNG extends React.Component { .pipe(throttleTime(50)) .subscribe({ next: (evt) => { - const u = this.plotInstance?.current; + const u = this.plotInstance.current; if (u) { // Try finding left position on time axis const left = u.valToPos(evt.payload.point.time, 'time'); @@ -183,6 +187,7 @@ export class GraphNG extends React.Component { if (shouldReconfig) { newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); + newState.alignedData = newState.config.prepData!(newState.alignedFrame); pluginLog('GraphNG', false, 'config recreated', newState.config); } } diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index c212dc080b2..ae91df18434 100755 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -46,10 +46,9 @@ export class Sparkline extends PureComponent { super(props); const alignedDataFrame = preparePlotFrame(props.sparkline, props.config); - const data = preparePlotData(alignedDataFrame); this.state = { - data, + data: preparePlotData(alignedDataFrame), alignedDataFrame, configBuilder: this.prepareConfig(alignedDataFrame), }; diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index f6c77877d3d..4e3c62e8385 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -24,7 +24,7 @@ import { ScaleDirection, ScaleOrientation, } from '../uPlot/config'; -import { collectStackingGroups } from '../uPlot/utils'; +import { collectStackingGroups, preparePlotData } from '../uPlot/utils'; import uPlot from 'uplot'; const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); @@ -46,6 +46,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor }) => { const builder = new UPlotConfigBuilder(timeZone); + builder.setPrepData(preparePlotData); + // X is the first field in the aligned frame const xField = frame.fields[0]; if (!xField) { diff --git a/packages/grafana-ui/src/components/uPlot/config.ts b/packages/grafana-ui/src/components/uPlot/config.ts index 2837c310142..bbf685c6d1c 100644 --- a/packages/grafana-ui/src/components/uPlot/config.ts +++ b/packages/grafana-ui/src/components/uPlot/config.ts @@ -285,6 +285,7 @@ export const graphFieldOptions = { stacking: [ { label: 'Off', value: StackingMode.None }, { label: 'Normal', value: StackingMode.Normal }, + { label: '100%', value: StackingMode.Percent }, ] as Array>, thresholdsDisplayModes: [ diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 6f5e34d5146..edf97904230 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -1,4 +1,4 @@ -import uPlot, { Cursor, Band, Hooks, Select } from 'uplot'; +import uPlot, { Cursor, Band, Hooks, Select, AlignedData } from 'uplot'; import { merge } from 'lodash'; import { DataFrame, @@ -35,6 +35,8 @@ const cursorDefaults: Cursor = { }, }; +type PrepData = (frame: DataFrame) => AlignedData; + export class UPlotConfigBuilder { private series: UPlotSeriesBuilder[] = []; private axes: Record = {}; @@ -56,6 +58,8 @@ export class UPlotConfigBuilder { */ tooltipInterpolator: PlotTooltipInterpolator | undefined = undefined; + prepData: PrepData | undefined = undefined; + constructor(timeZone: TimeZone = DefaultTimeZone) { this.tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName; } @@ -153,6 +157,10 @@ export class UPlotConfigBuilder { this.tooltipInterpolator = interpolator; } + setPrepData(prepData: PrepData) { + this.prepData = prepData; + } + setSync() { this.sync = true; } diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index a738de8dfcd..1be74629d3b 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -34,7 +34,12 @@ export const DEFAULT_PLOT_CONFIG: Partial = { }; /** @internal */ -export function preparePlotData(frame: DataFrame): AlignedData { +interface StackMeta { + totals: AlignedData; +} + +/** @internal */ +export function preparePlotData(frame: DataFrame, onStackMeta?: (meta: StackMeta) => void): AlignedData { const result: any[] = []; const stackingGroups: Map = new Map(); let seriesIndex = 0; @@ -64,21 +69,48 @@ export function preparePlotData(frame: DataFrame): AlignedData { // Stacking if (stackingGroups.size !== 0) { + const byPct = frame.fields[1].config.custom?.stacking?.mode === StackingMode.Percent; + const dataLength = result[0].length; + const alignedTotals = Array(stackingGroups.size); + alignedTotals[0] = null; + // array or stacking groups for (const [_, seriesIdxs] of stackingGroups.entries()) { - const acc = Array(result[0].length).fill(0); + const groupTotals = byPct ? Array(dataLength).fill(0) : null; + + if (byPct) { + for (let j = 0; j < seriesIdxs.length; j++) { + const currentlyStacking = result[seriesIdxs[j]]; + + for (let k = 0; k < dataLength; k++) { + const v = currentlyStacking[k]; + groupTotals![k] += v == null ? 0 : +v; + } + } + } + + const acc = Array(dataLength).fill(0); for (let j = 0; j < seriesIdxs.length; j++) { - const currentlyStacking = result[seriesIdxs[j]]; + let seriesIdx = seriesIdxs[j]; - for (let k = 0; k < result[0].length; k++) { + alignedTotals[seriesIdx] = groupTotals; + + const currentlyStacking = result[seriesIdx]; + + for (let k = 0; k < dataLength; k++) { const v = currentlyStacking[k]; - acc[k] += v == null ? 0 : +v; + acc[k] += v == null ? 0 : v / (byPct ? groupTotals![k] : 1); } - result[seriesIdxs[j]] = acc.slice(); + result[seriesIdx] = acc.slice(); } } + + onStackMeta && + onStackMeta({ + totals: alignedTotals as AlignedData, + }); } return result as AlignedData; diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index 5e489cec2a0..09cf1159fbc 100755 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react'; import { PanelProps, TimeRange, VizOrientation } from '@grafana/data'; -import { StackingMode, TooltipDisplayMode, TooltipPlugin } from '@grafana/ui'; +import { StackingMode, TooltipDisplayMode, TooltipPlugin, useTheme2 } from '@grafana/ui'; import { BarChartOptions } from './types'; import { BarChart } from './BarChart'; import { prepareGraphableFrames } from './utils'; @@ -11,8 +11,11 @@ interface Props extends PanelProps {} * @alpha */ export const BarChartPanel: React.FunctionComponent = ({ data, options, width, height, timeZone }) => { - const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, options.stacking), [ + const theme = useTheme2(); + + const { frames, warn } = useMemo(() => prepareGraphableFrames(data?.series, theme, options.stacking), [ data, + theme, options.stacking, ]); const orientation = useMemo(() => { diff --git a/public/app/plugins/panel/barchart/bars.ts b/public/app/plugins/panel/barchart/bars.ts index 40f693aabc3..6a7ac200c26 100644 --- a/public/app/plugins/panel/barchart/bars.ts +++ b/public/app/plugins/panel/barchart/bars.ts @@ -1,7 +1,7 @@ -import uPlot, { Axis } from 'uplot'; +import uPlot, { Axis, AlignedData } from 'uplot'; import { pointWithin, Quadtree, Rect } from './quadtree'; import { distribute, SPACE_BETWEEN } from './distribute'; -import { GrafanaTheme2 } from '@grafana/data'; +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { calculateFontSize, PlotTooltipInterpolator, @@ -11,6 +11,7 @@ import { ScaleDirection, ScaleOrientation, } from '@grafana/ui'; +import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; const groupDistr = SPACE_BETWEEN; const barDistr = SPACE_BETWEEN; @@ -52,10 +53,17 @@ export interface BarsOptions { * @internal */ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { - const { xOri, xDir: dir, groupWidth, barWidth, rawValue, formatValue, showValue } = opts; + const { xOri, xDir: dir, rawValue, formatValue, showValue } = opts; const isXHorizontal = xOri === ScaleOrientation.Horizontal; const hasAutoValueSize = !Boolean(opts.text?.valueSize); const isStacked = opts.stacking !== StackingMode.None; + const pctStacked = opts.stacking === StackingMode.Percent; + + let { groupWidth, barWidth } = opts; + + if (isStacked) { + [groupWidth, barWidth] = [barWidth, groupWidth]; + } let qt: Quadtree; let hovered: Rect | undefined = undefined; @@ -200,7 +208,7 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { let labelOffset = LABEL_OFFSET_MAX; barRects.forEach((r, i) => { - texts[i] = formatValue(r.sidx, rawValue(r.sidx, r.didx)); + texts[i] = formatValue(r.sidx, rawValue(r.sidx, r.didx)! / (pctStacked ? alignedTotals![r.sidx][r.didx]! : 1)); labelOffset = Math.min(labelOffset, Math.round(LABEL_OFFSET_FACTOR * (isXHorizontal ? r.w : r.h))); }); @@ -302,6 +310,16 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { }; }; + let alignedTotals: AlignedData | null = null; + + function prepData(alignedFrame: DataFrame) { + alignedTotals = null; + + return preparePlotData(alignedFrame, ({ totals }) => { + alignedTotals = totals; + }); + } + return { cursor: { x: false, @@ -318,5 +336,6 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) { drawClear, draw, interpolateTooltip, + prepData, }; } diff --git a/public/app/plugins/panel/barchart/utils.test.ts b/public/app/plugins/panel/barchart/utils.test.ts index b1d1096e7fb..3309e0bd767 100644 --- a/public/app/plugins/panel/barchart/utils.test.ts +++ b/public/app/plugins/panel/barchart/utils.test.ts @@ -143,7 +143,7 @@ describe('BarChart utils', () => { describe('prepareGraphableFrames', () => { it('will warn when there is no data in the response', () => { - const result = prepareGraphableFrames([], StackingMode.None); + const result = prepareGraphableFrames([], createTheme(), StackingMode.None); expect(result.warn).toEqual('No data in response'); }); @@ -154,7 +154,7 @@ describe('BarChart utils', () => { { name: 'value', values: [1, 2, 3, 4, 5] }, ], }); - const result = prepareGraphableFrames([df], StackingMode.None); + const result = prepareGraphableFrames([df], createTheme(), StackingMode.None); expect(result.warn).toEqual('Bar charts requires a string field'); expect(result.frames).toBeUndefined(); }); @@ -166,7 +166,7 @@ describe('BarChart utils', () => { { name: 'value', type: FieldType.boolean, values: [true, true, true, true, true] }, ], }); - const result = prepareGraphableFrames([df], StackingMode.None); + const result = prepareGraphableFrames([df], createTheme(), StackingMode.None); expect(result.warn).toEqual('No numeric fields found'); expect(result.frames).toBeUndefined(); }); @@ -178,7 +178,7 @@ describe('BarChart utils', () => { { name: 'value', values: [-10, NaN, 10, -Infinity, +Infinity] }, ], }); - const result = prepareGraphableFrames([df], StackingMode.None); + const result = prepareGraphableFrames([df], createTheme(), StackingMode.None); const field = result.frames![0].fields[1]; expect(field!.values.toArray()).toMatchInlineSnapshot(` diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index af07f1eac1f..b35c3b2b5d0 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -4,8 +4,10 @@ import { Field, FieldType, formattedValueToString, + getDisplayProcessor, getFieldColorModeForField, getFieldSeriesColor, + GrafanaTheme2, MutableDataFrame, VizOrientation, } from '@grafana/data'; @@ -89,6 +91,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ builder.setTooltipInterpolator(config.interpolateTooltip); + builder.setPrepData(config.prepData); + builder.addScale({ scaleKey: 'x', isTime: false, @@ -222,6 +226,7 @@ export function preparePlotFrame(data: DataFrame[]) { /** @internal */ export function prepareGraphableFrames( series: DataFrame[], + theme: GrafanaTheme2, stacking: StackingMode ): { frames?: DataFrame[]; warn?: string } { if (!series?.length) { @@ -268,6 +273,12 @@ export function prepareGraphableFrames( }) ), }; + + if (stacking === StackingMode.Percent) { + copy.config.unit = 'percentunit'; + copy.display = getDisplayProcessor({ field: copy, theme }); + } + fields.push(copy); } else { fields.push({ ...field }); diff --git a/public/app/plugins/panel/state-timeline/utils.ts b/public/app/plugins/panel/state-timeline/utils.ts index c6af380bb61..e28e39f8034 100644 --- a/public/app/plugins/panel/state-timeline/utils.ts +++ b/public/app/plugins/panel/state-timeline/utils.ts @@ -27,6 +27,7 @@ import { getConfig, TimelineCoreOptions } from './timeline'; import { AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/ui/src/components/uPlot/config'; import { TimelineFieldConfig, TimelineOptions } from './types'; import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types'; +import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils'; const defaultConfig: TimelineFieldConfig = { lineWidth: 0, @@ -140,6 +141,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ builder.setTooltipInterpolator(interpolateTooltip); + builder.setPrepData(preparePlotData); + builder.setCursor(coreConfig.cursor); builder.addScale({ diff --git a/public/app/plugins/panel/timeseries/utils.ts b/public/app/plugins/panel/timeseries/utils.ts index 702f1227be3..6bcc2db2dec 100644 --- a/public/app/plugins/panel/timeseries/utils.ts +++ b/public/app/plugins/panel/timeseries/utils.ts @@ -7,7 +7,7 @@ import { GrafanaTheme2, isBooleanUnit, } from '@grafana/data'; -import { GraphFieldConfig, LineInterpolation } from '@grafana/ui'; +import { GraphFieldConfig, LineInterpolation, StackingMode } from '@grafana/ui'; // This will return a set of frames with only graphable values included export function prepareGraphableFields( @@ -46,6 +46,12 @@ export function prepareGraphableFields( }) ), }; + + if (copy.config.custom?.stacking?.mode === StackingMode.Percent) { + copy.config.unit = 'percentunit'; + copy.display = getDisplayProcessor({ field: copy, theme }); + } + fields.push(copy); break; // ok case FieldType.boolean: