DataFrame: cache frame/field index in field state (#30529)

This commit is contained in:
Ryan McKinley 2021-01-22 10:18:46 -08:00 committed by GitHub
parent f97348ff5b
commit f2327baf66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 254 additions and 188 deletions

View File

@ -162,6 +162,13 @@ export interface FieldState {
* Useful for assigning color to series by looking up a color in a palette using this index
*/
seriesIndex?: number;
/**
* Location of this field within the context frames results
*
* @internal -- we will try to make this unnecessary
*/
origin?: DataFrameFieldIndex;
}
export interface NumericRange {
@ -206,7 +213,8 @@ export const TIME_SERIES_METRIC_FIELD_NAME = 'Metric';
/**
* Describes where a specific data frame field is located within a
* dataset of type DataFrame[]
* @public
*
* @internal -- we will try to make this unnecessary
*/
export interface DataFrameFieldIndex {
frameIndex: number;

View File

@ -12,7 +12,7 @@ import {
reduceField,
TimeRange,
} from '@grafana/data';
import { alignDataFrames } from './utils';
import { joinDataFrames } from './utils';
import { useTheme } from '../../themes';
import { UPlotChart } from '../uPlot/Plot';
import { PlotProps } from '../uPlot/types';
@ -64,9 +64,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
const theme = useTheme();
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const alignedFrameWithGapTest = useMemo(() => alignDataFrames(data, fields), [data, fields]);
const alignedFrame = alignedFrameWithGapTest?.frame;
const getDataFrameFieldIndex = alignedFrameWithGapTest?.getDataFrameFieldIndex;
const frame = useMemo(() => joinDataFrames(data, fields), [data, fields]);
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
if (a && b) {
@ -98,17 +96,17 @@ export const GraphNG: React.FC<GraphNGProps> = ({
currentTimeRange.current = timeRange;
}, [timeRange]);
const configRev = useRevision(alignedFrame, compareFrames);
const configRev = useRevision(frame, compareFrames);
const configBuilder = useMemo(() => {
const builder = new UPlotConfigBuilder();
if (!alignedFrame) {
if (!frame) {
return builder;
}
// X is the first field in the aligned frame
const xField = alignedFrame.fields[0];
const xField = frame.fields[0];
if (xField.type === FieldType.time) {
builder.addScale({
@ -141,8 +139,8 @@ export const GraphNG: React.FC<GraphNGProps> = ({
}
let indexByName: Map<string, number> | undefined = undefined;
for (let i = 0; i < alignedFrame.fields.length; i++) {
const field = alignedFrame.fields[i];
for (let i = 0; i < frame.fields.length; i++) {
const field = frame.fields[i];
const config = field.config as FieldConfig<GraphFieldConfig>;
const customConfig: GraphFieldConfig = {
...defaultConfig,
@ -182,14 +180,13 @@ export const GraphNG: React.FC<GraphNGProps> = ({
}
const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints;
const dataFrameFieldIndex = getDataFrameFieldIndex ? getDataFrameFieldIndex(i) : undefined;
let { fillOpacity } = customConfig;
if (customConfig.fillBelowTo) {
if (!indexByName) {
indexByName = getNamesToFieldIndex(alignedFrame);
indexByName = getNamesToFieldIndex(frame);
}
const t = indexByName.get(getFieldDisplayName(field, alignedFrame));
const t = indexByName.get(getFieldDisplayName(field, frame));
const b = indexByName.get(customConfig.fillBelowTo);
if (isNumber(b) && isNumber(t)) {
builder.addBand({
@ -221,15 +218,15 @@ export const GraphNG: React.FC<GraphNGProps> = ({
thresholds: config.thresholds,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
dataFrameFieldIndex,
fieldName: getFieldDisplayName(field, alignedFrame),
dataFrameFieldIndex: field.state?.origin,
fieldName: getFieldDisplayName(field, frame),
hideInLegend: customConfig.hideFrom?.legend,
});
}
return builder;
}, [configRev, timeZone]);
if (alignedFrameWithGapTest == null) {
if (!frame) {
return (
<div className="panel-empty">
<p>No data found in response</p>
@ -299,7 +296,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
<VizLayout width={width} height={height} legend={legendElement}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
data={alignedFrameWithGapTest}
data={frame}
config={configBuilder}
width={vizWidth}
height={vizHeight}

View File

@ -1,9 +1,8 @@
import { ArrayVector, DataFrame, FieldType, toDataFrame } from '@grafana/data';
import { AlignedFrameWithGapTest } from '../uPlot/types';
import { alignDataFrames, isLikelyAscendingVector } from './utils';
import { joinDataFrames, isLikelyAscendingVector } from './utils';
describe('alignDataFrames', () => {
describe('aligned frame', () => {
describe('joinDataFrames', () => {
describe('joined frame', () => {
it('should align multiple data frames into one data frame', () => {
const data: DataFrame[] = [
toDataFrame({
@ -20,37 +19,64 @@ describe('alignDataFrames', () => {
}),
];
const aligned = alignDataFrames(data);
const joined = joinDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature A',
seriesIndex: 0,
expect(joined?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
name: 'temperature A',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
{
config: {},
state: {
displayName: 'temperature B',
seriesIndex: 1,
Object {
"config": Object {},
"name": "temperature A",
"state": Object {
"displayName": "temperature A",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
name: 'temperature B',
type: FieldType.number,
values: new ArrayVector([0, 2, 6, 7]),
},
]);
Object {
"config": Object {},
"name": "temperature B",
"state": Object {
"displayName": "temperature B",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 1,
},
"seriesIndex": 1,
},
"type": "number",
"values": Array [
0,
2,
6,
7,
],
},
]
`);
});
it('should align multiple data frames into one data frame but only keep first time field', () => {
@ -69,37 +95,64 @@ describe('alignDataFrames', () => {
}),
];
const aligned = alignDataFrames(data);
const aligned = joinDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature',
seriesIndex: 0,
expect(aligned?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
{
config: {},
state: {
displayName: 'temperature B',
seriesIndex: 1,
Object {
"config": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
name: 'temperature B',
type: FieldType.number,
values: new ArrayVector([0, 2, 6, 7]),
},
]);
Object {
"config": Object {},
"name": "temperature B",
"state": Object {
"displayName": "temperature B",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 1,
},
"seriesIndex": 1,
},
"type": "number",
"values": Array [
0,
2,
6,
7,
],
},
]
`);
});
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
@ -113,27 +166,45 @@ describe('alignDataFrames', () => {
}),
];
const aligned = alignDataFrames(data);
const aligned = joinDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature',
seriesIndex: 0,
expect(aligned?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
]);
Object {
"config": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
]
`);
});
it('should align multiple data frames into one data frame and skip non-numeric fields', () => {
@ -147,32 +218,50 @@ describe('alignDataFrames', () => {
}),
];
const aligned = alignDataFrames(data);
const aligned = joinDataFrames(data);
expect(aligned?.frame.fields).toEqual([
{
config: {},
state: {},
name: 'time',
type: FieldType.time,
values: new ArrayVector([1000, 2000, 3000, 4000]),
},
{
config: {},
state: {
displayName: 'temperature',
seriesIndex: 0,
expect(aligned?.fields).toMatchInlineSnapshot(`
Array [
Object {
"config": Object {},
"name": "time",
"state": Object {
"origin": undefined,
},
"type": "time",
"values": Array [
1000,
2000,
3000,
4000,
],
},
name: 'temperature',
type: FieldType.number,
values: new ArrayVector([1, 3, 5, 7]),
},
]);
Object {
"config": Object {},
"name": "temperature",
"state": Object {
"displayName": "temperature",
"origin": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"seriesIndex": 0,
},
"type": "number",
"values": Array [
1,
3,
5,
7,
],
},
]
`);
});
});
describe('getDataFrameFieldIndex', () => {
let aligned: AlignedFrameWithGapTest | null;
let aligned: DataFrame | null;
beforeAll(() => {
const data: DataFrame[] = [
@ -197,7 +286,7 @@ describe('alignDataFrames', () => {
}),
];
aligned = alignDataFrames(data);
aligned = joinDataFrames(data);
});
it.each`
@ -209,7 +298,7 @@ describe('alignDataFrames', () => {
`('should return correct index for yDim', ({ yDim, index }) => {
const [frameIndex, fieldIndex] = index;
expect(aligned?.getDataFrameFieldIndex(yDim)).toEqual({
expect(aligned?.fields[yDim].state?.origin).toEqual({
frameIndex,
fieldIndex,
});

View File

@ -12,7 +12,6 @@ import {
sortDataFrame,
Vector,
} from '@grafana/data';
import { AlignedFrameWithGapTest } from '../uPlot/types';
import uPlot, { AlignedData, JoinNullMode } from 'uplot';
import { XYFieldMatchers } from './GraphNG';
@ -44,7 +43,7 @@ export function mapDimesions(match: XYFieldMatchers, frame: DataFrame, frames?:
*
* @alpha
*/
export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): AlignedFrameWithGapTest | null {
export function joinDataFrames(frames: DataFrame[], fields?: XYFieldMatchers): DataFrame | null {
const valuesFromFrames: AlignedData[] = [];
const sourceFields: Field[] = [];
const sourceFieldsRefs: Record<number, DataFrameFieldIndex> = {};
@ -118,39 +117,34 @@ export function alignDataFrames(frames: DataFrame[], fields?: XYFieldMatchers):
}
// do the actual alignment (outerJoin on the first arrays)
let alignedData = uPlot.join(valuesFromFrames, nullModes);
let joinedData = uPlot.join(valuesFromFrames, nullModes);
if (alignedData!.length !== sourceFields.length) {
if (joinedData!.length !== sourceFields.length) {
throw new Error('outerJoinValues lost a field?');
}
let seriesIdx = 0;
// Replace the values from the outer-join field
return {
frame: {
length: alignedData![0].length,
fields: alignedData!.map((vals, idx) => {
let state: FieldState = { ...sourceFields[idx].state };
...frames[0],
length: joinedData![0].length,
fields: joinedData!.map((vals, idx) => {
let state: FieldState = {
...sourceFields[idx].state,
origin: sourceFieldsRefs[idx],
};
if (sourceFields[idx].type !== FieldType.time) {
state.seriesIndex = seriesIdx;
seriesIdx++;
}
return {
...sourceFields[idx],
state,
values: new ArrayVector(vals),
};
}),
},
getDataFrameFieldIndex: (alignedFieldIndex: number) => {
const index = sourceFieldsRefs[alignedFieldIndex];
if (!index) {
throw new Error(`Could not find index for ${alignedFieldIndex}`);
if (sourceFields[idx].type !== FieldType.time) {
state.seriesIndex = seriesIdx;
seriesIdx++;
}
return index;
},
return {
...sourceFields[idx],
state,
values: new ArrayVector(vals),
};
}),
};
}

View File

@ -160,10 +160,7 @@ export class Sparkline extends PureComponent<Props, State> {
return (
<UPlotChart
data={{
frame: data,
getDataFrameFieldIndex: () => undefined,
}}
data={data}
config={configBuilder}
width={width}
height={height}

View File

@ -1,12 +1,11 @@
import React from 'react';
import { UPlotChart } from './Plot';
import { act, render } from '@testing-library/react';
import { ArrayVector, DataFrame, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
import uPlot from 'uplot';
import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import { AlignedFrameWithGapTest } from './types';
const mockRaf = createMockRaf();
const setDataMock = jest.fn();
@ -69,11 +68,10 @@ describe('UPlotChart', () => {
it('destroys uPlot instance when component unmounts', () => {
const { data, timeRange, config } = mockData();
const uPlotData = createPlotData(data);
const { unmount } = render(
<UPlotChart
data={uPlotData}
data={data} // mock
config={config}
timeRange={timeRange}
timeZone={'browser'}
@ -95,11 +93,10 @@ describe('UPlotChart', () => {
describe('data update', () => {
it('skips uPlot reinitialization when there are no field config changes', () => {
const { data, timeRange, config } = mockData();
const uPlotData = createPlotData(data);
const { rerender } = render(
<UPlotChart
data={uPlotData}
data={data} // mock
config={config}
timeRange={timeRange}
timeZone={'browser'}
@ -116,11 +113,10 @@ describe('UPlotChart', () => {
expect(uPlot).toBeCalledTimes(1);
data.fields[1].values.set(0, 1);
uPlotData.frame = data;
rerender(
<UPlotChart
data={uPlotData}
data={data} // changed
config={config}
timeRange={timeRange}
timeZone={'browser'}
@ -136,10 +132,9 @@ describe('UPlotChart', () => {
describe('config update', () => {
it('skips uPlot intialization for width and height equal 0', async () => {
const { data, timeRange, config } = mockData();
const uPlotData = createPlotData(data);
const { queryAllByTestId } = render(
<UPlotChart data={uPlotData} config={config} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
<UPlotChart data={data} config={config} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
);
expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
@ -148,11 +143,10 @@ describe('UPlotChart', () => {
it('reinitializes uPlot when config changes', () => {
const { data, timeRange, config } = mockData();
const uPlotData = createPlotData(data);
const { rerender } = render(
<UPlotChart
data={uPlotData}
data={data} // frame
config={config}
timeRange={timeRange}
timeZone={'browser'}
@ -170,7 +164,7 @@ describe('UPlotChart', () => {
rerender(
<UPlotChart
data={uPlotData}
data={data}
config={new UPlotConfigBuilder()}
timeRange={timeRange}
timeZone={'browser'}
@ -185,11 +179,10 @@ describe('UPlotChart', () => {
it('skips uPlot reinitialization when only dimensions change', () => {
const { data, timeRange, config } = mockData();
const uPlotData = createPlotData(data);
const { rerender } = render(
<UPlotChart
data={uPlotData}
data={data} // frame
config={config}
timeRange={timeRange}
timeZone={'browser'}
@ -205,7 +198,7 @@ describe('UPlotChart', () => {
rerender(
<UPlotChart
data={uPlotData}
data={data} // frame
config={new UPlotConfigBuilder()}
timeRange={timeRange}
timeZone={'browser'}
@ -220,10 +213,3 @@ describe('UPlotChart', () => {
});
});
});
const createPlotData = (frame: DataFrame): AlignedFrameWithGapTest => {
return {
frame,
getDataFrameFieldIndex: () => undefined,
};
};

View File

@ -39,7 +39,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
if (isConfigReady && !plotInstance.current) {
plotInstance.current = initializePlot(prepareData(props.data.frame), currentConfig.current, canvasRef.current);
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current);
setIsPlotReady(true);
return;
}
@ -60,12 +60,12 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
pluginLog('uPlot core', false, 'destroying instance');
plotInstance.current.destroy();
}
plotInstance.current = initializePlot(prepareData(props.data.frame), currentConfig.current, canvasRef.current);
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current);
return;
}
// 4. Otherwise, assume only data has changed and update uPlot data
updateData(props.data.frame, props.config, plotInstance.current, prepareData(props.data.frame));
updateData(props.data, props.config, plotInstance.current, prepareData(props.data));
}, [props, isConfigReady]);
// When component unmounts, clean the existing uPlot instance
@ -86,7 +86,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
);
};
function prepareData(frame: DataFrame) {
function prepareData(frame: DataFrame): AlignedData {
return frame.fields.map((f) => f.values.toArray()) as AlignedData;
}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useContext } from 'react';
import uPlot, { Series } from 'uplot';
import { PlotPlugin, AlignedFrameWithGapTest } from './types';
import { PlotPlugin } from './types';
import { DataFrame, Field, FieldConfig } from '@grafana/data';
interface PlotCanvasContextType {
@ -26,7 +26,7 @@ interface PlotContextType extends PlotPluginsContextType {
getSeries: () => Series[];
getCanvas: () => PlotCanvasContextType;
canvasRef: any;
data: AlignedFrameWithGapTest;
data: DataFrame;
}
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
@ -76,7 +76,7 @@ export const usePlotData = (): PlotDataAPI => {
if (!ctx) {
throwWhenNoContext('usePlotData');
}
return ctx!.data.frame.fields[idx];
return ctx!.data.fields[idx];
},
[ctx]
);
@ -109,7 +109,7 @@ export const usePlotData = (): PlotDataAPI => {
}
// by uPlot convention x-axis is always first field
// this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html)
return ctx!.data.frame.fields.slice(1);
return ctx!.data.fields.slice(1);
}, [ctx]);
if (!ctx) {
@ -117,7 +117,7 @@ export const usePlotData = (): PlotDataAPI => {
}
return {
data: ctx.data.frame,
data: ctx.data,
getField,
getFieldValue,
getFieldConfig,
@ -129,7 +129,7 @@ export const usePlotData = (): PlotDataAPI => {
export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any,
data: AlignedFrameWithGapTest,
data: DataFrame,
registerPlugin: any,
getPlotInstance: () => uPlot | undefined
): PlotContextType => {

View File

@ -1,6 +1,6 @@
import React from 'react';
import uPlot, { Options, Hooks } from 'uplot';
import { DataFrame, DataFrameFieldIndex, TimeRange, TimeZone } from '@grafana/data';
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
export type PlotSeriesConfig = Pick<Options, 'series' | 'scales' | 'axes' | 'cursor' | 'bands'>;
@ -16,7 +16,7 @@ export interface PlotPluginProps {
}
export interface PlotProps {
data: AlignedFrameWithGapTest;
data: DataFrame;
timeRange: TimeRange;
timeZone: TimeZone;
width: number;
@ -29,8 +29,3 @@ export abstract class PlotConfigBuilder<P, T> {
constructor(public props: P) {}
abstract getConfig(): T;
}
export interface AlignedFrameWithGapTest {
frame: DataFrame;
getDataFrameFieldIndex: (alignedFieldIndex: number) => DataFrameFieldIndex | undefined;
}