GraphNG: stack by % (#37127)

This commit is contained in:
Leon Sorokin 2021-07-28 20:31:07 -05:00 committed by GitHub
parent 4fa17c8040
commit 8b80d2256d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 121 additions and 30 deletions

View File

@ -4,6 +4,7 @@ import { Vector, QueryResultMeta } from '../types';
import { guessFieldTypeFromNameAndValue, toDataFrameDTO } from './processDataFrame';
import { FunctionalVector } from '../vector/FunctionalVector';
/** @public */
export type ValueConverter<T = any> = (val: any) => T;
const NOOP: ValueConverter = (v) => v;

View File

@ -1,6 +1,7 @@
import { vectorToArray } from './vectorToArray';
import { Vector } from '../types';
/** @public */
export abstract class FunctionalVector<T = any> implements Vector<T>, Iterable<T> {
abstract get length(): number;

View File

@ -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<GraphNGProps, GraphNGState> {
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<GraphNGProps, GraphNGState> {
.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<GraphNGProps, GraphNGState> {
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);
}
}

View File

@ -46,10 +46,9 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
super(props);
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
const data = preparePlotData(alignedDataFrame);
this.state = {
data,
data: preparePlotData(alignedDataFrame),
alignedDataFrame,
configBuilder: this.prepareConfig(alignedDataFrame),
};

View File

@ -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) {

View File

@ -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<SelectableValue<StackingMode>>,
thresholdsDisplayModes: [

View File

@ -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<string, UPlotAxisBuilder> = {};
@ -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;
}

View File

@ -34,7 +34,12 @@ export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
};
/** @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<string, number[]> = 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;

View File

@ -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<BarChartOptions> {}
* @alpha
*/
export const BarChartPanel: React.FunctionComponent<Props> = ({ 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(() => {

View File

@ -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,
};
}

View File

@ -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(`

View File

@ -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<BarChartOptions> = ({
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 });

View File

@ -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<TimelineOptions> = ({
builder.setTooltipInterpolator(interpolateTooltip);
builder.setPrepData(preparePlotData);
builder.setCursor(coreConfig.cursor);
builder.addScale({

View File

@ -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: