TimeSeries & BarChart: refactor stacking (#47373)

Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
Leon Sorokin 2022-04-13 09:29:03 -06:00 committed by GitHub
parent 3456793e0f
commit dfdfe3f428
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 3578 additions and 454 deletions

File diff suppressed because it is too large Load Diff

View File

@ -82,7 +82,7 @@ function sameProps(prevProps: any, nextProps: any, propsToDiff: Array<string | P
*/
export interface GraphNGState {
alignedFrame: DataFrame;
alignedData: AlignedData;
alignedData?: AlignedData;
config?: UPlotConfigBuilder;
}
@ -98,7 +98,9 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
constructor(props: GraphNGProps) {
super(props);
this.state = this.prepState(props);
let state = this.prepState(props);
state.alignedData = state.config!.prepData!([state.alignedFrame]) as AlignedData;
this.state = state;
this.plotInstance = React.createRef();
}
@ -131,7 +133,6 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
state = {
alignedFrame,
alignedData: config!.prepData!([alignedFrame]) as AlignedData,
config,
};
@ -229,12 +230,13 @@ 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]) as AlignedData;
pluginLog('GraphNG', false, 'config recreated', newState.config);
}
}
newState && this.setState(newState);
newState.alignedData = newState.config!.prepData!([newState.alignedFrame]) as AlignedData;
this.setState(newState);
}
}
}
@ -255,7 +257,7 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
config={config}
data={alignedData}
data={alignedData!}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}

View File

@ -58,22 +58,6 @@ Object {
"values": [Function],
},
],
"bands": Array [
Object {
"dir": -1,
"series": Array [
2,
1,
],
},
Object {
"dir": -1,
"series": Array [
4,
3,
],
},
],
"cursor": Object {
"dataIdx": [Function],
"drag": Object {

View File

@ -20,7 +20,7 @@ import {
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { UPlotChart } from '../uPlot/Plot';
import { Themeable2 } from '../../types';
import { preparePlotData } from '../uPlot/utils';
import { preparePlotData2, getStackingGroups } from '../uPlot/utils';
import { preparePlotFrame } from './utils';
import { isEqual } from 'lodash';
@ -51,7 +51,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
this.state = {
data: preparePlotData([alignedDataFrame]),
data: preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)),
alignedDataFrame,
configBuilder: this.prepareConfig(alignedDataFrame),
};
@ -65,7 +65,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
return {
...state,
data: preparePlotData([frame]),
data: preparePlotData2(frame, getStackingGroups(frame)),
alignedDataFrame: frame,
};
}

View File

@ -19,7 +19,7 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
const { eventBus, sync } = this.context as PanelContext;
const { theme, timeZone, legend, renderers, tweakAxis, tweakScale } = this.props;
const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props;
return preparePlotConfigBuilder({
frame: alignedFrame,
@ -29,7 +29,6 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
eventBus,
sync,
allFrames,
legend,
renderers,
tweakScale,
tweakAxis,

View File

@ -23,10 +23,9 @@ import {
VisibilityMode,
ScaleDirection,
ScaleOrientation,
VizLegendOptions,
StackingMode,
} from '@grafana/schema';
import { collectStackingGroups, INTERNAL_NEGATIVE_Y_PREFIX, orderIdsByCalcs, preparePlotData } from '../uPlot/utils';
import { getStackingGroups, preparePlotData2 } from '../uPlot/utils';
import uPlot from 'uplot';
import { buildScaleKey } from '../GraphNG/utils';
@ -40,7 +39,6 @@ const defaultConfig: GraphFieldConfig = {
export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
sync?: () => DashboardCursorSync;
legend?: VizLegendOptions;
}> = ({
frame,
theme,
@ -50,13 +48,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
sync,
allFrames,
renderers,
legend,
tweakScale = (opts) => opts,
tweakAxis = (opts) => opts,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
builder.setPrepData((prepData) => preparePlotData(prepData, undefined, legend));
builder.setPrepData((frames) => preparePlotData2(frames[0], builder.getStackingGroups()));
// X is the first field in the aligned frame
const xField = frame.fields[0];
@ -122,8 +119,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
let customRenderedFields =
renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? [];
const stackingGroups: Map<string, number[]> = new Map();
let indexByName: Map<string, number> | undefined;
for (let i = 1; i < frame.fields.length; i++) {
@ -328,20 +323,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{
});
}
}
collectStackingGroups(field, stackingGroups, seriesIndex);
}
if (stackingGroups.size !== 0) {
for (const [group, seriesIds] of stackingGroups.entries()) {
const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]],
dir: group.startsWith(INTERNAL_NEGATIVE_Y_PREFIX) ? 1 : -1,
});
}
}
}
let stackingGroups = getStackingGroups(frame);
builder.setStackingGroups(stackingGroups);
// hook up custom/composite renderers
renderers?.forEach((r) => {

View File

@ -6,7 +6,7 @@ import { GraphFieldConfig, GraphDrawStyle } from '@grafana/schema';
import uPlot from 'uplot';
import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import { preparePlotData } from './utils';
import { preparePlotData2, getStackingGroups } from './utils';
import { SeriesProps } from './config/UPlotSeriesBuilder';
const mockRaf = createMockRaf();
@ -55,7 +55,7 @@ const mockData = () => {
const config = new UPlotConfigBuilder();
config.addSeries({} as SeriesProps);
return { data: [data], timeRange, config };
return { data: data, timeRange, config };
};
describe('UPlotChart', () => {
@ -75,7 +75,7 @@ describe('UPlotChart', () => {
const { unmount } = render(
<UPlotChart
data={preparePlotData(data)} // mock
data={preparePlotData2(data, getStackingGroups(data))} // mock
config={config}
timeRange={timeRange}
width={100}
@ -94,7 +94,7 @@ describe('UPlotChart', () => {
const { rerender } = render(
<UPlotChart
data={preparePlotData(data)} // mock
data={preparePlotData2(data, getStackingGroups(data))} // mock
config={config}
timeRange={timeRange}
width={100}
@ -104,11 +104,11 @@ describe('UPlotChart', () => {
expect(uPlot).toBeCalledTimes(1);
data[0].fields[1].values.set(0, 1);
data.fields[1].values.set(0, 1);
rerender(
<UPlotChart
data={preparePlotData(data)} // changed
data={preparePlotData2(data, getStackingGroups(data))} // changed
config={config}
timeRange={timeRange}
width={100}
@ -124,7 +124,13 @@ describe('UPlotChart', () => {
it('skips uPlot intialization for width and height equal 0', async () => {
const { data, timeRange, config } = mockData();
const { queryAllByTestId } = render(
<UPlotChart data={preparePlotData(data)} config={config} timeRange={timeRange} width={0} height={0} />
<UPlotChart
data={preparePlotData2(data, getStackingGroups(data))}
config={config}
timeRange={timeRange}
width={0}
height={0}
/>
);
expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
@ -136,7 +142,7 @@ describe('UPlotChart', () => {
const { rerender } = render(
<UPlotChart
data={preparePlotData(data)} // frame
data={preparePlotData2(data, getStackingGroups(data))} // frame
config={config}
timeRange={timeRange}
width={100}
@ -150,7 +156,13 @@ describe('UPlotChart', () => {
nextConfig.addSeries({} as SeriesProps);
rerender(
<UPlotChart data={preparePlotData(data)} config={nextConfig} timeRange={timeRange} width={100} height={100} />
<UPlotChart
data={preparePlotData2(data, getStackingGroups(data))}
config={nextConfig}
timeRange={timeRange}
width={100}
height={100}
/>
);
expect(destroyMock).toBeCalledTimes(1);
@ -162,7 +174,7 @@ describe('UPlotChart', () => {
const { rerender } = render(
<UPlotChart
data={preparePlotData(data)} // frame
data={preparePlotData2(data, getStackingGroups(data))} // frame
config={config}
timeRange={timeRange}
width={100}
@ -173,7 +185,7 @@ describe('UPlotChart', () => {
// we wait 1 frame for plugins initialisation logic to finish
rerender(
<UPlotChart
data={preparePlotData(data)} // frame
data={preparePlotData2(data, getStackingGroups(data))} // frame
config={config}
timeRange={timeRange}
width={200}

View File

@ -15,7 +15,7 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { AxisPlacement } from '@grafana/schema';
import { pluginLog } from '../utils';
import { getStackingBands, pluginLog, StackingGroup } from '../utils';
import { getThresholdsDrawHook, UPlotThresholdOptions } from './UPlotThresholds';
const cursorDefaults: Cursor = {
@ -33,12 +33,14 @@ const cursorDefaults: Cursor = {
};
type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
type PreDataStacked = (frames: DataFrame[], stackingGroups: StackingGroup[]) => AlignedData | FacetedData;
export class UPlotConfigBuilder {
private series: UPlotSeriesBuilder[] = [];
private axes: Record<string, UPlotAxisBuilder> = {};
private scales: UPlotScaleBuilder[] = [];
private bands: Band[] = [];
private stackingGroups: StackingGroup[] = [];
private cursor: Cursor | undefined;
private select: uPlot.Select | undefined;
private hasLeftAxis = false;
@ -143,6 +145,14 @@ export class UPlotConfigBuilder {
this.bands.push(band);
}
setStackingGroups(groups: StackingGroup[]) {
this.stackingGroups = groups;
}
getStackingGroups() {
return this.stackingGroups;
}
setTooltipInterpolator(interpolator: PlotTooltipInterpolator) {
this.tooltipInterpolator = interpolator;
}
@ -151,10 +161,10 @@ export class UPlotConfigBuilder {
return this.tooltipInterpolator;
}
setPrepData(prepData: PrepData) {
setPrepData(prepData: PreDataStacked) {
this.prepData = (frames) => {
this.frames = frames;
return prepData(frames);
return prepData(frames, this.getStackingGroups());
};
}
@ -221,6 +231,14 @@ export class UPlotConfigBuilder {
config.tzDate = this.tzDate;
config.padding = this.padding;
if (this.stackingGroups.length) {
this.stackingGroups.forEach((group) => {
getStackingBands(group).forEach((band) => {
this.addBand(band);
});
});
}
if (this.bands.length) {
config.bands = this.bands;
}

View File

@ -1,6 +1,7 @@
import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils';
import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils';
import { FieldType, MutableDataFrame } from '@grafana/data';
import { GraphTransform, StackingMode } from '@grafana/schema';
import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema';
import Units from 'ol/proj/Units';
describe('timeFormatToTemplate', () => {
it.each`
@ -16,7 +17,7 @@ describe('timeFormatToTemplate', () => {
});
});
describe('preparePlotData', () => {
describe('preparePlotData2', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
@ -27,7 +28,7 @@ describe('preparePlotData', () => {
});
it('creates array from DataFrame', () => {
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -63,7 +64,7 @@ describe('preparePlotData', () => {
{ name: 'c', values: [20, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -104,7 +105,7 @@ describe('preparePlotData', () => {
{ name: 'i', values: [20, undefined, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } },
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -178,7 +179,7 @@ describe('preparePlotData', () => {
{ name: 'c', values: [20, 20, 20] },
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -226,7 +227,7 @@ describe('preparePlotData', () => {
},
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -273,7 +274,7 @@ describe('preparePlotData', () => {
},
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -329,7 +330,7 @@ describe('preparePlotData', () => {
},
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -397,7 +398,7 @@ describe('preparePlotData', () => {
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -472,7 +473,7 @@ describe('preparePlotData', () => {
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
expect(preparePlotData2(df, getStackingGroups(df))).toMatchInlineSnapshot(`
Array [
Array [
9997,
@ -507,239 +508,330 @@ describe('preparePlotData', () => {
]
`);
});
describe('ignores nullish-only stacks', () => {
test('single stacking group', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{
name: 'a',
values: [-10, null, 10],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'b',
values: [10, null, null],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'c',
values: [20, undefined, 20],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
],
Array [
-10,
null,
10,
],
Array [
0,
null,
10,
],
Array [
20,
null,
30,
],
]
`);
});
test('multiple stacking groups', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{
name: 'a',
values: [-10, undefined, 10],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'b',
values: [10, undefined, 10],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'c',
values: [20, undefined, 20],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'd',
values: [1, 2, null],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } },
},
{
name: 'e',
values: [1, 2, null],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } },
},
{
name: 'f',
values: [1, 2, null],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackB' } } },
},
],
});
expect(preparePlotData([df])).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
],
Array [
-10,
null,
10,
],
Array [
0,
null,
20,
],
Array [
20,
null,
40,
],
Array [
1,
2,
null,
],
Array [
2,
4,
null,
],
Array [
3,
6,
null,
],
]
`);
});
});
describe('with legend sorted', () => {
it('should affect when single group', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{
name: 'a',
values: [-10, 20, 10],
state: { calcs: { max: 20 } },
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'b',
values: [10, 10, 10],
state: { calcs: { max: 10 } },
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
{
name: 'c',
values: [20, 20, 20],
state: { calcs: { max: 20 } },
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } },
},
],
});
expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: false } as any)).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
],
Array [
0,
30,
20,
],
Array [
10,
10,
10,
],
Array [
20,
50,
40,
],
]
`);
expect(preparePlotData([df], undefined, { sortBy: 'Max', sortDesc: true } as any)).toMatchInlineSnapshot(`
Array [
Array [
9997,
9998,
9999,
],
Array [
-10,
20,
10,
],
Array [
20,
50,
40,
],
Array [
10,
40,
30,
],
]
`);
});
});
});
});
describe('orderIdsByCalcs', () => {
const ids = [1, 2, 3, 4];
const frame = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [9997, 9998, 9999] },
{ name: 'a', values: [-10, 20, 10], state: { calcs: { min: -10 } } },
{ name: 'b', values: [20, 20, 20], state: { calcs: { min: 20 } } },
{ name: 'c', values: [10, 10, 10], state: { calcs: { min: 10 } } },
{ name: 'd', values: [30, 30, 30] },
],
describe('auto stacking groups', () => {
test('split on stacking mode', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [0, 1, 2] },
{
name: 'b',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Percent } } },
},
{
name: 'c',
values: [4, 5, 6],
config: { custom: { stacking: { mode: StackingMode.Normal } } },
},
],
});
expect(getStackingGroups(df)).toMatchInlineSnapshot(`
Array [
Object {
"dir": 1,
"series": Array [
1,
],
},
Object {
"dir": 1,
"series": Array [
2,
],
},
]
`);
});
it.each([
{ legend: undefined },
{ legend: { sortBy: 'Min' } },
{ legend: { sortDesc: false } },
{ legend: {} },
{ sortBy: 'Mik', sortDesc: true },
])('should return without ordering if legend option is %o', (legend: any) => {
const result = orderIdsByCalcs({ ids, frame, legend });
expect(result).toEqual([1, 2, 3, 4]);
test('split pos/neg', () => {
// since we expect most series to be Pos, we try to bail early when scanning all values
// as soon as we find a value >= 0, it's assumed Pos, else Neg
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [0, 1, 2] },
{
name: 'a',
values: [-1, null, -3],
config: { custom: { stacking: { mode: StackingMode.Normal } } },
},
{
name: 'b',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal } } },
},
{
name: 'c',
values: [0, 0, 0],
config: { custom: { stacking: { mode: StackingMode.Normal } } },
},
],
});
expect(getStackingGroups(df)).toMatchInlineSnapshot(`
Array [
Object {
"dir": -1,
"series": Array [
1,
3,
],
},
Object {
"dir": 1,
"series": Array [
2,
],
},
]
`);
});
it('should order the ids based on the frame stat', () => {
const resultDesc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: true } as any });
expect(resultDesc).toEqual([4, 2, 3, 1]);
const resultAsc = orderIdsByCalcs({ ids, frame, legend: { sortBy: 'Min', sortDesc: false } as any });
expect(resultAsc).toEqual([1, 3, 2, 4]);
test('split pos/neg with NegY', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [0, 1, 2] },
{
name: 'a',
values: [-1, null, -3],
config: { custom: { stacking: { mode: StackingMode.Normal }, transform: GraphTransform.NegativeY } },
},
{
name: 'b',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal } } },
},
{
name: 'c',
values: [0, 0, 0],
config: { custom: { stacking: { mode: StackingMode.Normal } } },
},
],
});
expect(getStackingGroups(df)).toMatchInlineSnapshot(`
Array [
Object {
"dir": 1,
"series": Array [
1,
2,
],
},
Object {
"dir": -1,
"series": Array [
3,
],
},
]
`);
});
test('split on drawStyle, lineInterpolation, barAlignment', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [0, 1, 2] },
{
name: 'a',
values: [1, 2, 3],
config: {
custom: {
drawStyle: GraphDrawStyle.Bars,
barAlignment: BarAlignment.After,
stacking: { mode: StackingMode.Normal },
},
},
},
{
name: 'b',
values: [1, 2, 3],
config: {
custom: {
drawStyle: GraphDrawStyle.Bars,
barAlignment: BarAlignment.Before,
stacking: { mode: StackingMode.Normal },
},
},
},
{
name: 'c',
values: [1, 2, 3],
config: {
custom: {
drawStyle: GraphDrawStyle.Line,
lineInterpolation: LineInterpolation.Linear,
stacking: { mode: StackingMode.Normal },
},
},
},
{
name: 'd',
values: [1, 2, 3],
config: {
custom: {
drawStyle: GraphDrawStyle.Line,
lineInterpolation: LineInterpolation.Smooth,
stacking: { mode: StackingMode.Normal },
},
},
},
{
name: 'e',
values: [1, 2, 3],
config: { custom: { drawStyle: GraphDrawStyle.Points, stacking: { mode: StackingMode.Normal } } },
},
],
});
expect(getStackingGroups(df)).toMatchInlineSnapshot(`
Array [
Object {
"dir": 1,
"series": Array [
1,
],
},
Object {
"dir": 1,
"series": Array [
2,
],
},
Object {
"dir": 1,
"series": Array [
3,
],
},
Object {
"dir": 1,
"series": Array [
4,
],
},
Object {
"dir": 1,
"series": Array [
5,
],
},
]
`);
});
test('split on axis & units (scaleKey)', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [0, 1, 2] },
{
name: 'a',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal } }, unit: Units.FEET },
},
{
name: 'b',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal } }, unit: Units.DEGREES },
},
],
});
expect(getStackingGroups(df)).toMatchInlineSnapshot(`
Array [
Object {
"dir": 1,
"series": Array [
1,
],
},
Object {
"dir": 1,
"series": Array [
2,
],
},
]
`);
});
test('split on explicit stacking group & mode & pos/neg w/NegY', () => {
const df = new MutableDataFrame({
fields: [
{ name: 'time', type: FieldType.time, values: [0, 1, 2] },
{
name: 'a',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'A' } } },
},
{
name: 'b',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'A' } } },
},
{
name: 'c',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Percent, group: 'A' } } },
},
{
name: 'd',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Normal, group: 'B' } } },
},
{
name: 'e',
values: [1, 2, 3],
config: { custom: { stacking: { mode: StackingMode.Percent, group: 'B' } } },
},
{
name: 'e',
values: [1, 2, 3],
config: {
custom: { stacking: { mode: StackingMode.Percent, group: 'B' }, transform: GraphTransform.NegativeY },
},
},
],
});
expect(getStackingGroups(df)).toMatchInlineSnapshot(`
Array [
Object {
"dir": 1,
"series": Array [
1,
2,
],
},
Object {
"dir": 1,
"series": Array [
3,
],
},
Object {
"dir": 1,
"series": Array [
4,
],
},
Object {
"dir": 1,
"series": Array [
5,
],
},
Object {
"dir": -1,
"series": Array [
6,
],
},
]
`);
});
});

View File

@ -1,12 +1,11 @@
import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data';
import { GraphFieldConfig, GraphTransform, StackingMode, VizLegendOptions } from '@grafana/schema';
import { orderBy } from 'lodash';
import { DataFrame, ensureTimeField, FieldType } from '@grafana/data';
import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema';
import uPlot, { AlignedData, Options, PaddingSide } from 'uplot';
import { attachDebugger } from '../../utils';
import { createLogger } from '../../utils/logger';
import { buildScaleKey } from '../GraphNG/utils';
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
export const INTERNAL_NEGATIVE_Y_PREFIX = '__internalNegY';
export function timeFormatToTemplate(f: string) {
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
@ -41,115 +40,225 @@ interface StackMeta {
}
/** @internal */
export function preparePlotData(
frames: DataFrame[],
onStackMeta?: (meta: StackMeta) => void,
legend?: VizLegendOptions
): AlignedData {
const frame = frames[0];
const result: any[] = [];
const stackingGroups: Map<string, number[]> = new Map();
let seriesIndex = 0;
for (let i = 0; i < frame.fields.length; i++) {
const f = frame.fields[i];
if (f.type === FieldType.time) {
result.push(ensureTimeField(f).values.toArray());
seriesIndex++;
continue;
}
collectStackingGroups(f, stackingGroups, seriesIndex);
const customConfig: GraphFieldConfig = f.config.custom || {};
const values = f.values.toArray();
if (customConfig.transform === GraphTransform.NegativeY) {
result.push(values.map((v) => (v == null ? v : v * -1)));
} else if (customConfig.transform === GraphTransform.Constant) {
result.push(new Array(values.length).fill(values[0]));
} else {
result.push(values);
}
seriesIndex++;
}
// 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 [_, seriesIds] of stackingGroups.entries()) {
const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
const noValueStack = Array(dataLength).fill(true);
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++) {
let seriesIdx = seriesIdxs[j];
alignedTotals[seriesIdx] = groupTotals;
const currentlyStacking = result[seriesIdx];
for (let k = 0; k < dataLength; k++) {
const v = currentlyStacking[k];
if (v != null && noValueStack[k]) {
noValueStack[k] = false;
}
acc[k] += v == null ? 0 : v / (byPct ? groupTotals![k] : 1);
}
result[seriesIdx] = acc.slice().map((v, i) => (noValueStack[i] ? null : v));
}
}
onStackMeta &&
onStackMeta({
totals: alignedTotals as AlignedData,
});
}
return result as AlignedData;
export interface StackingGroup {
series: number[];
dir: StackDirection;
}
export function collectStackingGroups(f: Field, groups: Map<string, number[]>, seriesIdx: number) {
const customConfig = f.config.custom;
if (!customConfig) {
return;
}
if (
customConfig.stacking?.mode !== StackingMode.None &&
customConfig.stacking?.group &&
!customConfig.hideFrom?.viz
) {
const group =
customConfig.transform === GraphTransform.NegativeY
? `${INTERNAL_NEGATIVE_Y_PREFIX}-${customConfig.stacking.group}`
: customConfig.stacking.group;
/** @internal */
const enum StackDirection {
Pos = 1,
Neg = -1,
}
if (!groups.has(group)) {
groups.set(group, [seriesIdx]);
} else {
groups.set(group, groups.get(group)!.concat(seriesIdx));
// generates bands between adjacent group series
/** @internal */
export function getStackingBands(group: StackingGroup) {
let bands: uPlot.Band[] = [];
let { series, dir } = group;
let lastIdx = series.length - 1;
let rSeries = series.slice().reverse();
rSeries.forEach((si, i) => {
if (i !== lastIdx) {
let nextIdx = rSeries[i + 1];
bands.push({
series: [si, nextIdx],
// fill direction is inverted from stack direction
dir: (-1 * dir) as 1 | -1,
});
}
});
return bands;
}
// expects an AlignedFrame
/** @internal */
export function getStackingGroups(frame: DataFrame) {
let groups: Map<string, StackingGroup> = new Map();
frame.fields.forEach(({ config, values }, i) => {
// skip x or time field
if (i === 0) {
return;
}
let { custom } = config;
if (custom == null) {
return;
}
// TODO: currently all AlignedFrame fields end up in uplot series & data, even custom.hideFrom?.viz
// ideally hideFrom.viz fields would be excluded so we can remove this
if (custom.hideFrom?.viz) {
return;
}
let { stacking } = custom;
if (stacking == null) {
return;
}
let { mode: stackingMode, group: stackingGroup } = stacking;
// not stacking
if (stackingMode === StackingMode.None) {
return;
}
// will this be stacked up or down after any transforms applied
let vals = values.toArray();
let transform = custom.transform;
let stackDir =
transform === GraphTransform.Constant
? vals[0] > 0
? StackDirection.Pos
: StackDirection.Neg
: transform === GraphTransform.NegativeY
? vals.some((v) => v > 0)
? StackDirection.Neg
: StackDirection.Pos
: vals.some((v) => v > 0)
? StackDirection.Pos
: StackDirection.Neg;
let drawStyle = custom.drawStyle as GraphDrawStyle;
let drawStyle2 =
drawStyle === GraphDrawStyle.Bars
? (custom.barAlignment as BarAlignment)
: drawStyle === GraphDrawStyle.Line
? (custom.lineInterpolation as LineInterpolation)
: null;
let stackKey = `${stackDir}|${stackingMode}|${stackingGroup}|${buildScaleKey(config)}|${drawStyle}|${drawStyle2}`;
let group = groups.get(stackKey);
if (group == null) {
group = {
series: [],
dir: stackDir,
};
groups.set(stackKey, group);
}
group.series.push(i);
});
return [...groups.values()];
}
/** @internal */
export function preparePlotData2(
frame: DataFrame,
stackingGroups: StackingGroup[],
onStackMeta?: (meta: StackMeta) => void
) {
let data = Array(frame.fields.length) as AlignedData;
let dataLen = frame.length;
let zeroArr = Array(dataLen).fill(0);
let accums = Array.from({ length: stackingGroups.length }, () => zeroArr.slice());
frame.fields.forEach((field, i) => {
let vals = field.values.toArray();
if (i === 0) {
if (field.type === FieldType.time) {
data[i] = ensureTimeField(field).values.toArray();
} else {
data[i] = vals;
}
return;
}
let { custom } = field.config;
if (!custom || custom.hideFrom?.viz) {
data[i] = vals;
return;
}
// apply transforms
if (custom.transform === GraphTransform.Constant) {
vals = Array(vals.length).fill(vals[0]);
} else {
vals = vals.slice();
if (custom.transform === GraphTransform.NegativeY) {
for (let i = 0; i < vals.length; i++) {
if (vals[i] != null) {
vals[i] *= -1;
}
}
}
}
let stackingMode = custom.stacking?.mode;
if (!stackingMode || stackingMode === StackingMode.None) {
data[i] = vals;
} else {
let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1);
let accum = accums[stackIdx];
let stacked = (data[i] = Array(dataLen));
for (let i = 0; i < dataLen; i++) {
let v = vals[i];
if (v != null) {
stacked[i] = accum[i] += v;
} else {
stacked[i] = v; // we may want to coerce to 0 here
}
}
}
});
if (onStackMeta) {
let accumsBySeriesIdx = data.map((vals, i) => {
let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1);
return stackIdx !== -1 ? accums[stackIdx] : vals;
});
onStackMeta({
totals: accumsBySeriesIdx as AlignedData,
});
}
// re-compute by percent
frame.fields.forEach((field, i) => {
if (i === 0 || field.config.custom?.hideFrom?.viz) {
return;
}
let stackingMode = field.config.custom?.stacking?.mode;
if (stackingMode === StackingMode.Percent) {
let stackIdx = stackingGroups.findIndex((group) => group.series.indexOf(i) > -1);
let accum = accums[stackIdx];
let group = stackingGroups[stackIdx];
let stacked = data[i];
for (let i = 0; i < dataLen; i++) {
let v = stacked[i];
if (v != null) {
// v / accum will always be pos, so properly (re)sign by group stacking dir
stacked[i] = group.dir * (v / accum[i]);
}
}
}
});
return data;
}
/**
@ -215,23 +324,3 @@ export const pluginLogger = createLogger('uPlot');
export const pluginLog = pluginLogger.logger;
// pluginLogger.enable();
attachDebugger('graphng', undefined, pluginLogger);
type OrderIdsByCalcsOptions = {
legend?: VizLegendOptions;
ids: number[];
frame: DataFrame;
};
export function orderIdsByCalcs({ legend, ids, frame }: OrderIdsByCalcsOptions) {
if (!legend?.sortBy || legend.sortDesc == null) {
return ids;
}
const orderedIds = orderBy<number>(
ids,
(id) => {
return frame.fields[id].state?.calcs?.[legend.sortBy!.toLowerCase()];
},
legend.sortDesc ? 'desc' : 'asc'
);
return orderedIds;
}

View File

@ -11,7 +11,7 @@ import {
VizTextDisplayOptions,
VizLegendOptions,
} from '@grafana/schema';
import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { preparePlotData2, StackingGroup } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { alpha } from '@grafana/data/src/themes/colorManipulator';
import { formatTime } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
@ -564,16 +564,11 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
let alignedTotals: AlignedData | null = null;
function prepData(frames: DataFrame[]) {
function prepData(frames: DataFrame[], stackingGroups: StackingGroup[]) {
alignedTotals = null;
return preparePlotData(
frames,
({ totals }) => {
alignedTotals = totals;
},
opts.legend
);
return preparePlotData2(frames[0], stackingGroups, ({ totals }) => {
alignedTotals = totals;
});
}
return {

View File

@ -25,9 +25,9 @@ import {
StackingMode,
VizLegendOptions,
} from '@grafana/schema';
import { collectStackingGroups, orderIdsByCalcs } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { orderBy } from 'lodash';
import { findField } from 'app/features/dimensions';
import { getStackingGroups } from '@grafana/ui/src/components/uPlot/utils';
function getBarCharScaleOrientation(orientation: VizOrientation) {
if (orientation === VizOrientation.Vertical) {
@ -156,7 +156,6 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
let seriesIndex = 0;
const legendOrdered = isLegendOrdered(legend);
const stackingGroups: Map<string, number[]> = new Map();
// iterate the y values
for (let i = 1; i < frame.fields.length; i++) {
@ -237,20 +236,11 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptionsEX> = ({
grid: { show: customConfig.axisGridShow },
});
}
collectStackingGroups(field, stackingGroups, seriesIndex);
}
if (stackingGroups.size !== 0) {
for (const [_, seriesIds] of stackingGroups.entries()) {
const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame });
for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]],
});
}
}
}
let stackingGroups = getStackingGroups(frame);
builder.setStackingGroups(stackingGroups);
return builder;
};

View File

@ -31,7 +31,7 @@ import { getConfig, TimelineCoreOptions } from './timeline';
import { VizLegendOptions, AxisPlacement, ScaleDirection, ScaleOrientation } from '@grafana/schema';
import { TimelineFieldConfig, TimelineOptions } from './types';
import { PlotTooltipInterpolator } from '@grafana/ui/src/components/uPlot/types';
import { preparePlotData } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import { preparePlotData2, getStackingGroups } from '../../../../../packages/grafana-ui/src/components/uPlot/utils';
import uPlot from 'uplot';
const defaultConfig: TimelineFieldConfig = {
@ -153,7 +153,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
builder.setTooltipInterpolator(interpolateTooltip);
builder.setPrepData(preparePlotData);
builder.setPrepData((frames) => preparePlotData2(frames[0], getStackingGroups(frames[0])));
builder.setCursor(coreConfig.cursor);