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 10:29:03 -05:00
committed by GitHub
co-authored by Zoltán Bedi Dominik Prokop Ryan McKinley
parent 3456793e0f
commit dfdfe3f428
13 changed files with 3578 additions and 454 deletions
File diff suppressed because it is too large Load Diff
@@ -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}
@@ -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 {
@@ -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,
};
}
@@ -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,
@@ -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) => {
@@ -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}
@@ -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;
}
@@ -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,
],
},
]
`);
});
});
+217 -128
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;
}
+5 -10
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 {
+4 -14
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;
};
@@ -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);