GraphNG: Make the GraphNG / uPlot flow sync (#33215)

* Move data alignment to panel

* Make uPlot plugins sync, bring back alignment to GraphNG

* Update GraphNG-like panels

* Update explore graph ng

* Cleanup unnecessary tests

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Dominik Prokop 2021-04-26 13:30:04 +02:00 committed by GitHub
parent 7501a2deb6
commit a54ac510c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 739 additions and 1431 deletions

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { AlignedData } from 'uplot'; import { AlignedData } from 'uplot';
import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data'; import { DataFrame, TimeRange } from '@grafana/data';
import { VizLayout } from '../VizLayout/VizLayout'; import { VizLayout } from '../VizLayout/VizLayout';
import { Themeable } from '../../types'; import { Themeable } from '../../types';
import { UPlotChart } from '../uPlot/Plot'; import { UPlotChart } from '../uPlot/Plot';
@ -9,7 +9,7 @@ import { GraphNGLegendEvent } from '../GraphNG/types';
import { BarChartOptions } from './types'; import { BarChartOptions } from './types';
import { withTheme } from '../../themes'; import { withTheme } from '../../themes';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { preparePlotData } from '../uPlot/utils'; import { pluginLog, preparePlotData } from '../uPlot/utils';
import { LegendDisplayMode } from '../VizLegend/models.gen'; import { LegendDisplayMode } from '../VizLegend/models.gen';
import { PlotLegend } from '../uPlot/PlotLegend'; import { PlotLegend } from '../uPlot/PlotLegend';
@ -20,6 +20,7 @@ export interface BarChartProps extends Themeable, BarChartOptions {
height: number; height: number;
width: number; width: number;
data: DataFrame[]; data: DataFrame[];
structureRev?: number; // a number that will change when the data[] structure changes
onLegendClick?: (event: GraphNGLegendEvent) => void; onLegendClick?: (event: GraphNGLegendEvent) => void;
onSeriesColorChange?: (label: string, color: string) => void; onSeriesColorChange?: (label: string, color: string) => void;
} }
@ -33,40 +34,24 @@ interface BarChartState {
class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> { class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
constructor(props: BarChartProps) { constructor(props: BarChartProps) {
super(props); super(props);
this.state = {} as BarChartState; const alignedDataFrame = preparePlotFrame(props.data);
}
static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) {
const frame = preparePlotFrame(props.data);
if (!frame) {
return { ...state };
}
return {
...state,
data: preparePlotData(frame),
alignedDataFrame: frame,
};
}
componentDidMount() {
const { alignedDataFrame } = this.state;
if (!alignedDataFrame) { if (!alignedDataFrame) {
return; return;
} }
const data = preparePlotData(alignedDataFrame);
this.setState({ const config = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props);
config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), this.state = {
}); alignedDataFrame,
data,
config,
};
} }
componentDidUpdate(prevProps: BarChartProps) { componentDidUpdate(prevProps: BarChartProps) {
const { data, orientation, groupWidth, barWidth, showValue } = this.props; const { data, orientation, groupWidth, barWidth, showValue, structureRev } = this.props;
const { alignedDataFrame } = this.state; const { alignedDataFrame } = this.state;
let shouldConfigUpdate = false; let shouldConfigUpdate = false;
let hasStructureChanged = false; let stateUpdate = {} as BarChartState;
if ( if (
this.state.config === undefined || this.state.config === undefined ||
@ -78,17 +63,26 @@ class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
shouldConfigUpdate = true; shouldConfigUpdate = true;
} }
if (data !== prevProps.data) { if (data !== prevProps.data || shouldConfigUpdate) {
if (!alignedDataFrame) { const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
const alignedData = preparePlotFrame(data);
if (!alignedData) {
return; return;
} }
hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures); stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
if (shouldConfigUpdate || hasStructureChanged) {
pluginLog('BarChart', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props);
stateUpdate = { ...stateUpdate, config: builder };
}
} }
if (shouldConfigUpdate || hasStructureChanged) { if (Object.keys(stateUpdate).length > 0) {
this.setState({ this.setState(stateUpdate);
config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
});
} }
} }

View File

@ -1,21 +1,12 @@
import React from 'react'; import React from 'react';
import { AlignedData } from 'uplot'; import { AlignedData } from 'uplot';
import { import { DataFrame, FieldMatcherID, fieldMatchers, TimeRange, TimeZone } from '@grafana/data';
DataFrame,
DataFrameFieldIndex,
FieldMatcherID,
fieldMatchers,
FieldType,
TimeRange,
TimeZone,
} from '@grafana/data';
import { withTheme } from '../../themes'; import { withTheme } from '../../themes';
import { Themeable } from '../../types'; import { Themeable } from '../../types';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { GraphNGLegendEvent, XYFieldMatchers } from './types'; import { GraphNGLegendEvent, XYFieldMatchers } from './types';
import { GraphNGContext } from './hooks';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { preparePlotData } from '../uPlot/utils'; import { pluginLog, preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend'; import { PlotLegend } from '../uPlot/PlotLegend';
import { UPlotChart } from '../uPlot/Plot'; import { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen'; import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen';
@ -37,89 +28,41 @@ export interface GraphNGProps extends Themeable {
fields?: XYFieldMatchers; // default will assume timeseries data fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void; onLegendClick?: (event: GraphNGLegendEvent) => void;
onSeriesColorChange?: (label: string, color: string) => void; onSeriesColorChange?: (label: string, color: string) => void;
children?: React.ReactNode; children?: (builder: UPlotConfigBuilder, alignedDataFrame: DataFrame) => React.ReactNode;
} }
/** /**
* @internal -- not a public API * @internal -- not a public API
*/ */
export interface GraphNGState { export interface GraphNGState {
data: AlignedData;
alignedDataFrame: DataFrame; alignedDataFrame: DataFrame;
dimFields: XYFieldMatchers; data: AlignedData;
seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[];
config?: UPlotConfigBuilder; config?: UPlotConfigBuilder;
} }
class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> { class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
constructor(props: GraphNGProps) { constructor(props: GraphNGProps) {
super(props); super(props);
let dimFields = props.fields;
if (!dimFields) { pluginLog('GraphNG', false, 'constructor, data aligment');
dimFields = { const alignedData = preparePlotFrame(props.data, {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}; });
}
this.state = { dimFields } as GraphNGState;
}
/** if (!alignedData) {
* Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
* It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume
* that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32)
*
* Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle.
* If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update
*
* This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
* the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that
* often (apart from the edit mode interactions) this should be a fair performance compromise.
*/
static getDerivedStateFromProps(props: GraphNGProps, state: GraphNGState) {
let dimFields = props.fields;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
}
const frame = preparePlotFrame(props.data, dimFields);
if (!frame) {
return { ...state, dimFields };
}
return {
...state,
data: preparePlotData(frame, [FieldType.number]),
alignedDataFrame: frame,
seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
dimFields,
};
}
componentDidMount() {
const { theme } = this.props;
// alignedDataFrame is already prepared by getDerivedStateFromProps method
const { alignedDataFrame } = this.state;
if (!alignedDataFrame) {
return; return;
} }
this.setState({ this.state = {
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone), alignedDataFrame: alignedData,
}); data: preparePlotData(alignedData),
config: preparePlotConfigBuilder(alignedData, props.theme, this.getTimeRange, this.getTimeZone),
};
} }
componentDidUpdate(prevProps: GraphNGProps) { componentDidUpdate(prevProps: GraphNGProps) {
const { data, theme, structureRev } = this.props; const { theme, structureRev, data } = this.props;
const { alignedDataFrame } = this.state;
let shouldConfigUpdate = false; let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState; let stateUpdate = {} as GraphNGState;
@ -128,13 +71,31 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
} }
if (data !== prevProps.data) { if (data !== prevProps.data) {
if (!alignedDataFrame) { pluginLog('GraphNG', false, 'data changed');
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
if (hasStructureChanged) {
pluginLog('GraphNG', false, 'schema changed');
}
pluginLog('GraphNG', false, 'componentDidUpdate, data aligment');
const alignedData = preparePlotFrame(data, {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
});
if (!alignedData) {
return; return;
} }
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev; stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
if (shouldConfigUpdate || hasStructureChanged) { if (shouldConfigUpdate || hasStructureChanged) {
const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone); pluginLog('GraphNG', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone);
stateUpdate = { ...stateUpdate, config: builder }; stateUpdate = { ...stateUpdate, config: builder };
} }
} }
@ -144,10 +105,6 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
} }
} }
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
return this.state.seriesToDataFrameFieldIndexMap[i];
};
getTimeRange = () => { getTimeRange = () => {
return this.props.timeRange; return this.props.timeRange;
}; };
@ -178,35 +135,25 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
} }
render() { render() {
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props; const { width, height, children, timeRange } = this.props;
const { config, alignedDataFrame } = this.state;
if (!this.state.data || !this.state.config) { if (!config) {
return null; return null;
} }
return ( return (
<GraphNGContext.Provider
value={{
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
dimFields: this.state.dimFields,
data: this.state.alignedDataFrame,
}}
>
<VizLayout width={width} height={height} legend={this.renderLegend()}> <VizLayout width={width} height={height} legend={this.renderLegend()}>
{(vizWidth: number, vizHeight: number) => ( {(vizWidth: number, vizHeight: number) => (
<UPlotChart <UPlotChart
{...plotProps}
config={this.state.config!} config={this.state.config!}
data={this.state.data} data={this.state.data}
width={vizWidth} width={vizWidth}
height={vizHeight} height={vizHeight}
timeRange={timeRange} timeRange={timeRange}
> >
{children} {children ? children(config, alignedDataFrame) : null}
</UPlotChart> </UPlotChart>
)} )}
</VizLayout> </VizLayout>
</GraphNGContext.Provider>
); );
} }
} }

View File

@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import { compareArrayValues, compareDataFrameStructures, FieldMatcherID, fieldMatchers } from '@grafana/data'; import { FieldMatcherID, fieldMatchers } from '@grafana/data';
import { withTheme } from '../../themes'; import { withTheme } from '../../themes';
import { GraphNGContext } from '../GraphNG/hooks';
import { GraphNGState } from '../GraphNG/GraphNG'; import { GraphNGState } from '../GraphNG/GraphNG';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG
import { preparePlotData } from '../uPlot/utils'; import { pluginLog, preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend'; import { PlotLegend } from '../uPlot/PlotLegend';
import { UPlotChart } from '../uPlot/Plot'; import { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode } from '../VizLegend/models.gen'; import { LegendDisplayMode } from '../VizLegend/models.gen';
@ -14,77 +13,35 @@ import { TimelineProps } from './types';
class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState> { class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState> {
constructor(props: TimelineProps) { constructor(props: TimelineProps) {
super(props); super(props);
let dimFields = props.fields; const { theme, mode, rowHeight, colWidth, showValue } = props;
if (!dimFields) { pluginLog('TimelineChart', false, 'constructor, data aligment');
dimFields = { const alignedData = preparePlotFrame(
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), props.data,
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), // this may be either numeric or strings, (or bools?) props.fields || {
};
}
this.state = { dimFields } as GraphNGState;
}
/**
* Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
* It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume
* that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32)
*
* Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle.
* If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update
*
* This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
* the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that
* often (apart from the edit mode interactions) this should be a fair performance compromise.
*/
static getDerivedStateFromProps(props: TimelineProps, state: GraphNGState) {
let dimFields = props.fields;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
} }
);
const frame = preparePlotFrame(props.data, dimFields); if (!alignedData) {
if (!frame) {
return { ...state, dimFields };
}
return {
...state,
data: preparePlotData(frame),
alignedDataFrame: frame,
seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
dimFields,
};
}
componentDidMount() {
const { theme, mode, rowHeight, colWidth, showValue } = this.props;
// alignedDataFrame is already prepared by getDerivedStateFromProps method
const { alignedDataFrame } = this.state;
if (!alignedDataFrame) {
return; return;
} }
this.setState({ this.state = {
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, { alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
config: preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
mode, mode,
rowHeight, rowHeight,
colWidth, colWidth,
showValue, showValue,
}), }),
}); };
} }
componentDidUpdate(prevProps: TimelineProps) { componentDidUpdate(prevProps: TimelineProps) {
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue } = this.props; const { data, theme, timeZone, mode, rowHeight, colWidth, showValue, structureRev } = this.props;
const { alignedDataFrame } = this.state;
let shouldConfigUpdate = false; let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState; let stateUpdate = {} as GraphNGState;
@ -99,18 +56,27 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
shouldConfigUpdate = true; shouldConfigUpdate = true;
} }
if (data !== prevProps.data) { if (data !== prevProps.data || shouldConfigUpdate) {
if (!alignedDataFrame) { const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
const alignedData = preparePlotFrame(
data,
this.props.fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}
);
if (!alignedData) {
return; return;
} }
if (!compareArrayValues(data, prevProps.data, compareDataFrameStructures)) { stateUpdate = {
shouldConfigUpdate = true; alignedDataFrame: alignedData,
} data: preparePlotData(alignedData),
} };
if (shouldConfigUpdate || hasStructureChanged) {
if (shouldConfigUpdate) { pluginLog('TimelineChart', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, { const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
mode, mode,
rowHeight, rowHeight,
colWidth, colWidth,
@ -118,16 +84,13 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
}); });
stateUpdate = { ...stateUpdate, config: builder }; stateUpdate = { ...stateUpdate, config: builder };
} }
}
if (Object.keys(stateUpdate).length > 0) { if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate); this.setState(stateUpdate);
} }
} }
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
return this.state.seriesToDataFrameFieldIndexMap[i];
};
getTimeRange = () => { getTimeRange = () => {
return this.props.timeRange; return this.props.timeRange;
}; };
@ -158,35 +121,27 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
} }
render() { render() {
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props; const { width, height, children, timeRange } = this.props;
const { config, alignedDataFrame } = this.state;
if (!this.state.data || !this.state.config) { if (!config) {
return null; return null;
} }
return ( return (
<GraphNGContext.Provider
value={{
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
dimFields: this.state.dimFields,
data: this.state.alignedDataFrame,
}}
>
<VizLayout width={width} height={height}> <VizLayout width={width} height={height}>
{(vizWidth: number, vizHeight: number) => ( {(vizWidth: number, vizHeight: number) => (
<UPlotChart <UPlotChart
{...plotProps}
config={this.state.config!} config={this.state.config!}
data={this.state.data} data={this.state.data}
width={vizWidth} width={vizWidth}
height={vizHeight} height={vizHeight}
timeRange={timeRange} timeRange={timeRange}
> >
{children} {children ? children(config, alignedDataFrame) : null}
</UPlotChart> </UPlotChart>
)} )}
</VizLayout> </VizLayout>
</GraphNGContext.Provider>
); );
} }
} }

View File

@ -224,13 +224,14 @@ export { LegacyForms, LegacyInputStatus };
// WIP, need renames and exports cleanup // WIP, need renames and exports cleanup
export * from './uPlot/config'; export * from './uPlot/config';
export { UPlotConfigBuilder } from './uPlot/config/UPlotConfigBuilder';
export { UPlotChart } from './uPlot/Plot'; export { UPlotChart } from './uPlot/Plot';
export * from './uPlot/geometries'; export * from './uPlot/geometries';
export * from './uPlot/plugins'; export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks'; export { usePlotContext } from './uPlot/context';
export { usePlotContext, usePlotPluginContext } from './uPlot/context';
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
export { useGraphNGContext } from './GraphNG/hooks'; export { useGraphNGContext } from './GraphNG/hooks';
export { preparePlotFrame } from './GraphNG/utils';
export { BarChart } from './BarChart/BarChart'; export { BarChart } from './BarChart/BarChart';
export { TimelineChart } from './Timeline/TimelineChart'; export { TimelineChart } from './Timeline/TimelineChart';
export { BarChartOptions, BarValueVisibility, BarChartFieldConfig } from './BarChart/types'; export { BarChartOptions, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { UPlotChart } from './Plot'; import { UPlotChart } from './Plot';
import { act, render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data'; import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { GraphFieldConfig, DrawStyle } from '../uPlot/config'; import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
import uPlot from 'uplot'; import uPlot from 'uplot';
@ -83,11 +83,6 @@ describe('UPlotChart', () => {
/> />
); );
// we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
expect(uPlot).toBeCalledTimes(1); expect(uPlot).toBeCalledTimes(1);
unmount(); unmount();
expect(destroyMock).toBeCalledTimes(1); expect(destroyMock).toBeCalledTimes(1);
@ -107,11 +102,6 @@ describe('UPlotChart', () => {
/> />
); );
// we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
expect(uPlot).toBeCalledTimes(1); expect(uPlot).toBeCalledTimes(1);
data.fields[1].values.set(0, 1); data.fields[1].values.set(0, 1);
@ -154,11 +144,6 @@ describe('UPlotChart', () => {
/> />
); );
// we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
expect(uPlot).toBeCalledTimes(1); expect(uPlot).toBeCalledTimes(1);
const nextConfig = new UPlotConfigBuilder(); const nextConfig = new UPlotConfigBuilder();
@ -186,16 +171,10 @@ describe('UPlotChart', () => {
); );
// we wait 1 frame for plugins initialisation logic to finish // we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
const nextConfig = new UPlotConfigBuilder();
nextConfig.addSeries({} as SeriesProps);
rerender( rerender(
<UPlotChart <UPlotChart
data={preparePlotData(data)} // frame data={preparePlotData(data)} // frame
config={nextConfig} config={config}
timeRange={timeRange} timeRange={timeRange}
width={200} width={200}
height={200} height={200}
@ -206,68 +185,5 @@ describe('UPlotChart', () => {
expect(uPlot).toBeCalledTimes(1); expect(uPlot).toBeCalledTimes(1);
expect(setSizeMock).toBeCalledTimes(1); expect(setSizeMock).toBeCalledTimes(1);
}); });
it('does not initialize plot when config and data are not in sync', () => {
const { data, timeRange, config } = mockData();
// 1 series in data, 2 series in config
config.addSeries({} as SeriesProps);
render(
<UPlotChart
data={preparePlotData(data)} // frame
config={config}
timeRange={timeRange}
width={100}
height={100}
/>
);
// we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
expect(destroyMock).toBeCalledTimes(0);
expect(uPlot).toBeCalledTimes(0);
});
it('does not reinitialize plot when config and data are not in sync', () => {
const { data, timeRange, config } = mockData();
// 1 series in data, 1 series in config
const { rerender } = render(
<UPlotChart
data={preparePlotData(data)} // frame
config={config}
timeRange={timeRange}
width={100}
height={100}
/>
);
// we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
const nextConfig = new UPlotConfigBuilder();
nextConfig.addSeries({} as SeriesProps);
nextConfig.addSeries({} as SeriesProps);
// 1 series in data, 2 series in config
rerender(
<UPlotChart
data={preparePlotData(data)} // frame
config={nextConfig}
timeRange={timeRange}
width={200}
height={200}
/>
);
expect(destroyMock).toBeCalledTimes(0);
expect(uPlot).toBeCalledTimes(1);
});
}); });
}); });

View File

@ -1,10 +1,8 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import uPlot, { AlignedData, Options } from 'uplot'; import uPlot, { AlignedData, Options } from 'uplot';
import { buildPlotContext, PlotContext } from './context'; import { buildPlotContext, PlotContext } from './context';
import { pluginLog } from './utils'; import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils';
import { usePlotConfig } from './hooks';
import { PlotProps } from './types'; import { PlotProps } from './types';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import usePrevious from 'react-use/lib/usePrevious'; import usePrevious from 'react-use/lib/usePrevious';
/** /**
@ -16,67 +14,71 @@ import usePrevious from 'react-use/lib/usePrevious';
export const UPlotChart: React.FC<PlotProps> = (props) => { export const UPlotChart: React.FC<PlotProps> = (props) => {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const plotInstance = useRef<uPlot>(); const plotInstance = useRef<uPlot>();
const [isPlotReady, setIsPlotReady] = useState(false);
const prevProps = usePrevious(props); const prevProps = usePrevious(props);
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config);
const config = useMemo(() => {
return {
...DEFAULT_PLOT_CONFIG,
width: props.width,
height: props.height,
ms: 1,
...props.config.getConfig(),
} as uPlot.Options;
}, [props.config]);
const getPlotInstance = useCallback(() => { const getPlotInstance = useCallback(() => {
return plotInstance.current; return plotInstance.current;
}, []); }, []);
// Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed
useLayoutEffect(() => { useLayoutEffect(() => {
// 0. Exit early if the component is not ready to initialize uPlot if (!plotInstance.current || props.width === 0 || props.height === 0) {
if (!currentConfig.current || !canvasRef.current || props.width === 0 || props.height === 0) {
return; return;
} }
// 0. Exit if the data set length is different than number of series expected to render pluginLog('uPlot core', false, 'updating size');
// This may happen when GraphNG has not synced config yet with the aligned frame. Alignment happens before the render plotInstance.current!.setSize({
// in the getDerivedStateFromProps, while the config creation happens in componentDidUpdate, causing one more render width: props.width,
// of the UPlotChart if the config needs to be updated. height: props.height,
if (currentConfig.current.series.length !== props.data.length) { });
}, [props.width, props.height]);
// Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed
useLayoutEffect(() => {
// 0. Exit early if the component is not ready to initialize uPlot
if (!canvasRef.current || props.width === 0 || props.height === 0) {
return; return;
} }
// 1. When config is ready and there is no uPlot instance, create new uPlot and return // 1. When config is ready and there is no uPlot instance, create new uPlot and return
if (isConfigReady && !plotInstance.current) { if (!plotInstance.current || !prevProps) {
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); plotInstance.current = initializePlot(props.data, config, canvasRef.current);
setIsPlotReady(true);
return; return;
} }
// 2. When dimensions have changed, update uPlot size and return // 2. Reinitialize uPlot if config changed
if (currentConfig.current.width !== prevProps?.width || currentConfig.current.height !== prevProps?.height) { if (props.config !== prevProps.config) {
pluginLog('uPlot core', false, 'updating size');
plotInstance.current!.setSize({
width: currentConfig.current.width,
height: currentConfig.current?.height,
});
return;
}
// 3. When config has changed re-initialize plot
if (isConfigReady && props.config !== prevProps.config) {
if (plotInstance.current) { if (plotInstance.current) {
pluginLog('uPlot core', false, 'destroying instance'); pluginLog('uPlot core', false, 'destroying instance');
plotInstance.current.destroy(); plotInstance.current.destroy();
} }
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); plotInstance.current = initializePlot(props.data, config, canvasRef.current);
return; return;
} }
// 4. Otherwise, assume only data has changed and update uPlot data // 3. Otherwise, assume only data has changed and update uPlot data
updateData(props.config, props.data, plotInstance.current); if (props.data !== prevProps.data) {
}, [props, isConfigReady]); pluginLog('uPlot core', false, 'updating plot data(throttled log!)', props.data);
plotInstance.current.setData(props.data);
}
}, [props, config]);
// When component unmounts, clean the existing uPlot instance // When component unmounts, clean the existing uPlot instance
useEffect(() => () => plotInstance.current?.destroy(), []); useEffect(() => () => plotInstance.current?.destroy(), []);
// Memoize plot context // Memoize plot context
const plotCtx = useMemo(() => { const plotCtx = useMemo(() => {
return buildPlotContext(isPlotReady, canvasRef, props.data, registerPlugin, getPlotInstance); return buildPlotContext(canvasRef, props.data, getPlotInstance);
}, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance, isPlotReady]); }, [plotInstance, canvasRef, props.data, getPlotInstance]);
return ( return (
<PlotContext.Provider value={plotCtx}> <PlotContext.Provider value={plotCtx}>
@ -92,11 +94,3 @@ function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement)
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config); pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
return new uPlot(config, data, el); return new uPlot(config, data, el);
} }
function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) {
if (!plotInstance || !data) {
return;
}
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', data);
plotInstance.setData(data);
}

View File

@ -6,8 +6,7 @@ import { AxisPlacement } from '../config';
import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot'; import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
import { defaultsDeep } from 'lodash'; import { defaultsDeep } from 'lodash';
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data'; import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
import { pluginLog } from '../utils';
type valueof<T> = T[keyof T];
export class UPlotConfigBuilder { export class UPlotConfigBuilder {
private series: UPlotSeriesBuilder[] = []; private series: UPlotSeriesBuilder[] = [];
@ -27,7 +26,9 @@ export class UPlotConfigBuilder {
this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName; this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName;
} }
addHook(type: keyof Hooks.Defs, hook: valueof<Hooks.Defs>) { addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]) {
pluginLog('UPlotConfigBuilder', false, 'addHook', type);
if (!this.hooks[type]) { if (!this.hooks[type]) {
this.hooks[type] = []; this.hooks[type] = [];
} }

View File

@ -1,6 +1,5 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import uPlot, { AlignedData, Series } from 'uplot'; import uPlot, { AlignedData, Series } from 'uplot';
import { PlotPlugin } from './types';
/** /**
* @alpha * @alpha
@ -18,15 +17,7 @@ interface PlotCanvasContextType {
}; };
} }
/** interface PlotContextType {
* @alpha
*/
interface PlotPluginsContextType {
registerPlugin: (plugin: PlotPlugin) => () => void;
}
interface PlotContextType extends PlotPluginsContextType {
isPlotReady: boolean;
getPlotInstance: () => uPlot | undefined; getPlotInstance: () => uPlot | undefined;
getSeries: () => Series[]; getSeries: () => Series[];
getCanvas: () => PlotCanvasContextType; getCanvas: () => PlotCanvasContextType;
@ -44,40 +35,17 @@ export const usePlotContext = (): PlotContextType => {
return useContext<PlotContextType>(PlotContext); return useContext<PlotContextType>(PlotContext);
}; };
const throwWhenNoContext = (name: string) => {
throw new Error(`${name} must be used within PlotContext or PlotContext is not ready yet!`);
};
/**
* Exposes API for registering uPlot plugins
*
* @alpha
*/
export const usePlotPluginContext = (): PlotPluginsContextType => {
const ctx = useContext(PlotContext);
if (Object.keys(ctx).length === 0) {
throwWhenNoContext('usePlotPluginContext');
}
return {
registerPlugin: ctx!.registerPlugin,
};
};
/** /**
* @alpha * @alpha
*/ */
export const buildPlotContext = ( export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any, canvasRef: any,
data: AlignedData, data: AlignedData,
registerPlugin: any,
getPlotInstance: () => uPlot | undefined getPlotInstance: () => uPlot | undefined
): PlotContextType => { ): PlotContextType => {
return { return {
isPlotReady,
canvasRef, canvasRef,
data, data,
registerPlugin,
getPlotInstance, getPlotInstance,
getSeries: () => getPlotInstance()!.series, getSeries: () => getPlotInstance()!.series,
getCanvas: () => { getCanvas: () => {

View File

@ -1,20 +1,29 @@
import { DataFrame } from '@grafana/data'; import { DataFrame } from '@grafana/data';
import React, { useMemo } from 'react'; import React, { useLayoutEffect, useMemo, useState } from 'react';
import { usePlotContext } from '../context'; import { usePlotContext } from '../context';
import { useRefreshAfterGraphRendered } from '../hooks';
import { Marker } from './Marker'; import { Marker } from './Marker';
import { XYCanvas } from './XYCanvas'; import { XYCanvas } from './XYCanvas';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
interface EventsCanvasProps { interface EventsCanvasProps {
id: string; id: string;
config: UPlotConfigBuilder;
events: DataFrame[]; events: DataFrame[];
renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode; renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode;
mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined; mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined;
} }
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) { export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
const plotCtx = usePlotContext(); const plotCtx = usePlotContext();
const renderToken = useRefreshAfterGraphRendered(id); // render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
// so we need to force the re-render when the draw hook was performed by uPlot
const [renderToken, setRenderToken] = useState(0);
useLayoutEffect(() => {
config.addHook('draw', () => {
setRenderToken((s) => s + 1);
});
}, [config, setRenderToken]);
const eventMarkers = useMemo(() => { const eventMarkers = useMemo(() => {
const markers: React.ReactNode[] = []; const markers: React.ReactNode[] = [];

View File

@ -1,135 +0,0 @@
// TODO: Update the tests
describe('usePlotConfig', () => {
it('tmp', () => {});
});
// import { usePlotConfig } from './hooks';
// import { renderHook } from '@testing-library/react-hooks';
// import { act } from '@testing-library/react';
// import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
//
// describe('usePlotConfig', () => {
// it('returns default plot config', async () => {
// const { result } = renderHook(() => usePlotConfig(0, 0, 'browser', new UPlotConfigBuilder()));
//
// expect(result.current.currentConfig).toMatchInlineSnapshot(`
// Object {
// "axes": Array [],
// "cursor": Object {
// "focus": Object {
// "prox": 30,
// },
// },
// "focus": Object {
// "alpha": 1,
// },
// "height": 0,
// "hooks": Object {},
// "legend": Object {
// "show": false,
// },
// "plugins": Array [],
// "scales": Object {},
// "series": Array [
// Object {},
// ],
// "tzDate": [Function],
// "width": 0,
// }
// `);
// });
//
// describe('plugins config', () => {
// it('should register plugin', async () => {
// const { result } = renderHook(() => usePlotConfig(0, 0, 'browser', new UPlotConfigBuilder()));
// const registerPlugin = result.current.registerPlugin;
//
// act(() => {
// registerPlugin({
// id: 'testPlugin',
// hooks: {},
// });
// });
//
// expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
// expect(result.current.currentConfig).toMatchInlineSnapshot(`
// Object {
// "axes": Array [],
// "cursor": Object {
// "focus": Object {
// "prox": 30,
// },
// },
// "focus": Object {
// "alpha": 1,
// },
// "height": 0,
// "hooks": Object {},
// "legend": Object {
// "show": false,
// },
// "plugins": Array [
// Object {
// "hooks": Object {},
// },
// ],
// "scales": Object {},
// "series": Array [
// Object {},
// ],
// "tzDate": [Function],
// "width": 0,
// }
// `);
// });
//
// it('should unregister plugin', async () => {
// const { result } = renderHook(() => usePlotConfig(0, 0, 'browser', new UPlotConfigBuilder()));
// const registerPlugin = result.current.registerPlugin;
//
// let unregister: () => void;
// act(() => {
// unregister = registerPlugin({
// id: 'testPlugin',
// hooks: {},
// });
// });
//
// expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
//
// act(() => {
// unregister();
// });
//
// expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(0);
// expect(result.current.currentConfig).toMatchInlineSnapshot(`
// Object {
// "axes": Array [],
// "cursor": Object {
// "focus": Object {
// "prox": 30,
// },
// },
// "focus": Object {
// "alpha": 1,
// },
// "gutters": Object {
// "x": 8,
// "y": 8,
// },
// "height": 0,
// "hooks": Object {},
// "legend": Object {
// "show": false,
// },
// "plugins": Array [],
// "scales": Object {},
// "series": Array [
// Object {},
// ],
// "tzDate": [Function],
// "width": 0,
// }
// `);
// });
// });
// });

View File

@ -1,187 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PlotPlugin } from './types';
import { pluginLog } from './utils';
import { Options, PaddingSide } from 'uplot';
import { usePlotPluginContext } from './context';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import usePrevious from 'react-use/lib/usePrevious';
import useMountedState from 'react-use/lib/useMountedState';
export const usePlotPlugins = () => {
/**
* Map of registered plugins (via children)
* Used to build uPlot plugins config
*/
const [plugins, setPlugins] = useState<Record<string, PlotPlugin>>({});
// arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised
const [arePluginsReady, setPluginsReady] = useState(false);
const cancellationToken = useRef<number>();
const isMounted = useRef(false);
const checkPluginsReady = useCallback(() => {
if (cancellationToken.current) {
window.cancelAnimationFrame(cancellationToken.current);
cancellationToken.current = undefined;
}
/**
* After registering plugin let's wait for all code to complete to set arePluginsReady to true.
* If any other plugin will try to register, the previously scheduled call will be canceled
* and arePluginsReady will be deferred to next animation frame.
*/
cancellationToken.current = window.requestAnimationFrame(function () {
if (isMounted.current) {
setPluginsReady(true);
}
});
}, [cancellationToken, setPluginsReady]);
const registerPlugin = useCallback(
(plugin: PlotPlugin) => {
pluginLog(plugin.id, false, 'register');
setPlugins((plugs) => {
if (plugs.hasOwnProperty(plugin.id)) {
throw new Error(`${plugin.id} that is already registered`);
}
return {
...plugs,
[plugin.id]: plugin,
};
});
checkPluginsReady();
return () => {
setPlugins((p) => {
pluginLog(plugin.id, false, 'unregister');
delete p[plugin.id];
return {
...p,
};
});
};
},
[setPlugins]
);
// When uPlot mounts let's check if there are any plugins pending registration
useEffect(() => {
isMounted.current = true;
checkPluginsReady();
return () => {
isMounted.current = false;
if (cancellationToken.current) {
window.cancelAnimationFrame(cancellationToken.current);
}
};
}, []);
return {
arePluginsReady,
plugins: plugins || {},
registerPlugin,
};
};
const paddingSide: PaddingSide = (u, side, sidesWithAxes, cycleNum) => {
let hasCrossAxis = side % 2 ? sidesWithAxes[0] || sidesWithAxes[2] : sidesWithAxes[1] || sidesWithAxes[3];
return sidesWithAxes[side] || !hasCrossAxis ? 0 : 8;
};
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 1,
},
cursor: {
focus: {
prox: 30,
},
},
legend: {
show: false,
},
padding: [paddingSide, paddingSide, paddingSide, paddingSide],
series: [],
hooks: {},
};
export const usePlotConfig = (width: number, height: number, configBuilder: UPlotConfigBuilder) => {
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
const [isConfigReady, setIsConfigReady] = useState(false);
const currentConfig = useRef<Options>();
useLayoutEffect(() => {
if (!arePluginsReady) {
return;
}
currentConfig.current = {
...DEFAULT_PLOT_CONFIG,
width,
height,
ms: 1,
plugins: Object.entries(plugins).map((p) => ({
hooks: p[1].hooks,
})),
...configBuilder.getConfig(),
};
setIsConfigReady(true);
}, [arePluginsReady, plugins, width, height, configBuilder]);
return {
isConfigReady,
registerPlugin,
currentConfig,
};
};
/**
* Forces re-render of a component when uPlots's draw hook is fired.
* This hook is usefull in scenarios when you want to reposition XYCanvas elements when i.e. plot size changes
* @param pluginId - id under which the plugin will be registered
*/
export const useRefreshAfterGraphRendered = (pluginId: string) => {
const pluginsApi = usePlotPluginContext();
const isMounted = useMountedState();
const [renderToken, setRenderToken] = useState(0);
useEffect(() => {
const unregister = pluginsApi.registerPlugin({
id: pluginId,
hooks: {
// refresh events when uPlot draws
draw: () => {
if (isMounted()) {
setRenderToken((c) => c + 1);
}
return;
},
},
});
return () => {
unregister();
};
}, []);
return renderToken;
};
export function useRevision<T>(dep?: T | null, cmp?: (prev?: T | null, next?: T | null) => boolean) {
const [rev, setRev] = useState(0);
const prevDep = usePrevious(dep);
const comparator = cmp ? cmp : (a?: T | null, b?: T | null) => a === b;
useLayoutEffect(() => {
const hasChange = !comparator(prevDep, dep);
if (hasChange) {
setRev((r) => r + 1);
}
}, [dep]);
return rev;
}

View File

@ -1,59 +1,59 @@
import React, { useRef } from 'react'; // import React, { useRef } from 'react';
import { SelectionPlugin } from './SelectionPlugin'; // import { SelectionPlugin } from './SelectionPlugin';
import { css } from '@emotion/css'; // import { css } from '@emotion/css';
import { Button } from '../../Button'; // import { Button } from '../../Button';
import useClickAway from 'react-use/lib/useClickAway'; // import useClickAway from 'react-use/lib/useClickAway';
//
interface AnnotationsEditorPluginProps { // interface AnnotationsEditorPluginProps {
onAnnotationCreate: () => void; // onAnnotationCreate: () => void;
} // }
//
/** // /**
* @alpha // * @alpha
*/ // */
export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => { // export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
const pluginId = 'AnnotationsEditorPlugin'; // const pluginId = 'AnnotationsEditorPlugin';
//
return ( // return (
<SelectionPlugin // <SelectionPlugin
id={pluginId} // id={pluginId}
onSelect={(selection) => { // onSelect={(selection) => {
console.log(selection); // console.log(selection);
}} // }}
lazy // lazy
> // >
{({ selection, clearSelection }) => { // {({ selection, clearSelection }) => {
return <AnnotationEditor selection={selection} onClose={clearSelection} />; // return <AnnotationEditor selection={selection} onClose={clearSelection} />;
}} // }}
</SelectionPlugin> // </SelectionPlugin>
); // );
}; // };
//
const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => { // const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
const ref = useRef(null); // const ref = useRef(null);
//
useClickAway(ref, () => { // useClickAway(ref, () => {
if (onClose) { // if (onClose) {
onClose(); // onClose();
} // }
}); // });
//
return ( // return (
<div> // <div>
<div // <div
ref={ref} // ref={ref}
className={css` // className={css`
position: absolute; // position: absolute;
background: purple; // background: purple;
top: ${selection.bbox.top}px; // top: ${selection.bbox.top}px;
left: ${selection.bbox.left}px; // left: ${selection.bbox.left}px;
width: ${selection.bbox.width}px; // width: ${selection.bbox.width}px;
height: ${selection.bbox.height}px; // height: ${selection.bbox.height}px;
`} // `}
> // >
Annotations editor maybe? // Annotations editor maybe?
<Button onClick={() => {}}>Create annotation</Button> // <Button onClick={() => {}}>Create annotation</Button>
</div> // </div>
</div> // </div>
); // );
}; // };

View File

@ -1,128 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { css as cssCore, Global } from '@emotion/react';
import { CartesianCoords2D } from '@grafana/data';
import { PlotPluginProps } from '../types';
import { usePlotPluginContext } from '../context';
import { pluginLog } from '../utils';
import { CursorPlugin } from './CursorPlugin';
interface ClickPluginAPI {
point: { seriesIdx: number | null; dataIdx: number | null };
coords: {
// coords relative to plot canvas, css px
plotCanvas: CartesianCoords2D;
// coords relative to viewport , css px
viewport: CartesianCoords2D;
};
// coords relative to plot canvas, css px
clearSelection: () => void;
}
/**
* @alpha
*/
interface ClickPluginProps extends PlotPluginProps {
onClick: (e: { seriesIdx: number | null; dataIdx: number | null }) => void;
children: (api: ClickPluginAPI) => React.ReactElement | null;
}
/**
* @alpha
* Exposes API for Graph click interactions
*/
export const ClickPlugin: React.FC<ClickPluginProps> = ({ id, onClick, children }) => {
const pluginId = `ClickPlugin:${id}`;
const pluginsApi = usePlotPluginContext();
const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>(null);
const clearSelection = useCallback(() => {
pluginLog(pluginId, false, 'clearing click selection');
setPoint(null);
}, [setPoint]);
useEffect(() => {
const unregister = pluginsApi.registerPlugin({
id: pluginId,
hooks: {
init: (u) => {
pluginLog(pluginId, false, 'init');
// for naive click&drag check
let isClick = false;
// REF: https://github.com/leeoniya/uPlot/issues/239
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt'));
const plotCanvas = u.root.querySelector<HTMLDivElement>('.u-over');
plotCanvas!.addEventListener('mousedown', (e: MouseEvent) => {
isClick = true;
});
plotCanvas!.addEventListener('mousemove', (e: MouseEvent) => {
isClick = false;
});
// TODO: remove listeners on unmount
plotCanvas!.addEventListener('mouseup', (e: MouseEvent) => {
if (!isClick) {
setPoint(null);
return;
}
isClick = true;
pluginLog(pluginId, false, 'canvas click');
if (e.target) {
const target = e.target as HTMLElement;
if (!target.classList.contains('u-cursor-pt')) {
setPoint({ seriesIdx: null, dataIdx: null });
}
}
});
if (pts.length > 0) {
pts.forEach((pt, i) => {
// TODO: remove listeners on unmount
pt.addEventListener('click', (e) => {
const seriesIdx = i + 1;
const dataIdx = u.cursor.idx;
pluginLog(id, false, seriesIdx, dataIdx);
setPoint({ seriesIdx, dataIdx: dataIdx || null });
onClick({ seriesIdx, dataIdx: dataIdx || null });
});
});
}
},
},
});
return () => {
unregister();
};
}, []);
return (
<>
<Global
styles={cssCore`
.uplot .u-cursor-pt {
pointer-events: auto !important;
}
`}
/>
<CursorPlugin id={pluginId} capture="mousedown" lock>
{({ coords }) => {
if (!point) {
return null;
}
return children({
point,
coords,
clearSelection,
});
}}
</CursorPlugin>
</>
);
};

View File

@ -1,116 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { PlotPluginProps } from '../types';
import { pluginLog } from '../utils';
import { usePlotPluginContext } from '../context';
interface CursorPluginAPI {
focusedSeriesIdx: number | null;
focusedPointIdx: number | null;
coords: {
// coords relative to plot canvas, css px
plotCanvas: Coords;
// coords relative to viewport , css px
viewport: Coords;
};
}
interface CursorPluginProps extends PlotPluginProps {
onMouseMove?: () => void; // anything?
children: (api: CursorPluginAPI) => React.ReactElement | null;
// on what interaction position should be captures
capture?: 'mousemove' | 'mousedown';
// should the position be persisted when user leaves canvas area
lock?: boolean;
}
interface Coords {
x: number;
y: number;
}
/**
* Exposes API for Graph cursor position
*
* @alpha
*/
export const CursorPlugin: React.FC<CursorPluginProps> = ({ id, children, capture = 'mousemove', lock = false }) => {
const pluginId = `CursorPlugin:${id}`;
const plotCanvas = useRef<HTMLDivElement>(null);
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
const pluginsApi = usePlotPluginContext();
// state exposed to the consumers, maybe better implement as CursorPlugin?
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [coords, setCoords] = useState<{ viewport: Coords; plotCanvas: Coords } | null>(null);
const clearCoords = useCallback(() => {
setCoords(null);
}, [setCoords]);
useEffect(() => {
pluginLog(pluginId, true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`);
}, [focusedPointIdx, focusedSeriesIdx]);
useEffect(() => {
if (plotCanvas && plotCanvas.current) {
plotCanvasBBox.current = plotCanvas.current.getBoundingClientRect();
}
}, [plotCanvas.current]);
// on mount - init plugin
useEffect(() => {
const onMouseCapture = (e: MouseEvent) => {
setCoords({
plotCanvas: {
x: e.clientX - plotCanvasBBox.current.left,
y: e.clientY - plotCanvasBBox.current.top,
},
viewport: {
x: e.clientX,
y: e.clientY,
},
});
};
const unregister = pluginsApi.registerPlugin({
id: pluginId,
hooks: {
init: (u) => {
// @ts-ignore
plotCanvas.current = u.root.querySelector<HTMLDivElement>('.u-over');
// @ts-ignore
plotCanvas.current.addEventListener(capture, onMouseCapture);
if (!lock) {
// @ts-ignore
plotCanvas.current.addEventListener('mouseleave', clearCoords);
}
},
setCursor: (u) => {
setFocusedPointIdx(u.cursor.idx === undefined ? null : u.cursor.idx);
},
setSeries: (u, idx) => {
setFocusedSeriesIdx(idx);
},
},
});
return () => {
if (plotCanvas && plotCanvas.current) {
plotCanvas.current.removeEventListener(capture, onMouseCapture);
}
unregister();
};
}, []);
// only render children if we are interacting with the canvas
return coords
? children({
focusedSeriesIdx,
focusedPointIdx,
coords,
})
: null;
};

View File

@ -1,93 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { PlotPluginProps } from '../types';
import { usePlotContext } from '../context';
import { pluginLog } from '../utils';
interface Selection {
min: number;
max: number;
// selection bounding box, relative to canvas
bbox: {
top: number;
left: number;
width: number;
height: number;
};
}
interface SelectionPluginAPI {
selection: Selection;
clearSelection: () => void;
}
interface SelectionPluginProps extends PlotPluginProps {
onSelect: (selection: Selection) => void;
onDismiss?: () => void;
// when true onSelect won't be fired when selection ends
// useful for plugins that need to do sth with the selected region, i.e. annotations editor
lazy?: boolean;
children?: (api: SelectionPluginAPI) => JSX.Element;
}
/**
* @alpha
*/
export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDismiss, lazy, id, children }) => {
const pluginId = `SelectionPlugin:${id}`;
const plotCtx = usePlotContext();
const [selection, setSelection] = useState<Selection | null>(null);
useEffect(() => {
if (!lazy && selection) {
pluginLog(pluginId, false, 'selected', selection);
onSelect(selection);
}
}, [selection]);
const clearSelection = useCallback(() => {
setSelection(null);
}, [setSelection]);
useEffect(() => {
plotCtx.registerPlugin({
id: pluginId,
hooks: {
setSelect: (u) => {
const min = u.posToVal(u.select.left, 'x');
const max = u.posToVal(u.select.left + u.select.width, 'x');
setSelection({
min,
max,
bbox: {
left: u.bbox.left / window.devicePixelRatio + u.select.left,
top: u.bbox.top / window.devicePixelRatio,
height: u.bbox.height / window.devicePixelRatio,
width: u.select.width,
},
});
// manually hide selected region (since cursor.drag.setScale = false)
/* @ts-ignore */
u.setSelect({ left: 0, width: 0 }, false);
},
},
});
return () => {
if (onDismiss) {
onDismiss();
}
};
}, []);
if (!plotCtx.getPlotInstance() || !children || !selection) {
return null;
}
return children({
selection,
clearSelection,
});
};

View File

@ -1,9 +1,8 @@
import React from 'react'; import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Portal } from '../../Portal/Portal'; import { Portal } from '../../Portal/Portal';
import { usePlotContext } from '../context'; import { usePlotContext } from '../context';
import { CursorPlugin } from './CursorPlugin';
import { VizTooltipContainer, SeriesTable, SeriesTableRowProps, TooltipDisplayMode } from '../../VizTooltip';
import { import {
CartesianCoords2D,
DataFrame, DataFrame,
FieldType, FieldType,
formattedValueToString, formattedValueToString,
@ -11,56 +10,89 @@ import {
getFieldDisplayName, getFieldDisplayName,
TimeZone, TimeZone,
} from '@grafana/data'; } from '@grafana/data';
import { useGraphNGContext } from '../../GraphNG/hooks'; import { SeriesTable, SeriesTableRowProps, TooltipDisplayMode, VizTooltipContainer } from '../../VizTooltip';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { pluginLog } from '../utils';
interface TooltipPluginProps { interface TooltipPluginProps {
mode?: TooltipDisplayMode; mode?: TooltipDisplayMode;
timeZone: TimeZone; timeZone: TimeZone;
data: DataFrame[]; data: DataFrame;
config: UPlotConfigBuilder;
} }
/** /**
* @alpha * @alpha
*/ */
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone, ...otherProps }) => { export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
const pluginId = 'PlotTooltip'; mode = TooltipDisplayMode.Single,
timeZone,
config,
...otherProps
}) => {
const plotContext = usePlotContext(); const plotContext = usePlotContext();
const graphContext = useGraphNGContext(); const plotCanvas = useRef<HTMLDivElement>();
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null);
let xField = graphContext.getXAxisField(); // Debug logs
useEffect(() => {
pluginLog('TooltipPlugin', true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`);
}, [focusedPointIdx, focusedSeriesIdx]);
// Add uPlot hooks to the config, or re-add when the config changed
useLayoutEffect(() => {
const onMouseCapture = (e: MouseEvent) => {
setCoords({
plotCanvas: {
x: e.clientX - plotCanvasBBox.current.left,
y: e.clientY - plotCanvasBBox.current.top,
},
viewport: {
x: e.clientX,
y: e.clientY,
},
});
};
config.addHook('init', (u) => {
const canvas = u.root.querySelector<HTMLDivElement>('.u-over');
plotCanvas.current = canvas || undefined;
plotCanvas.current?.addEventListener('mousemove', onMouseCapture);
plotCanvas.current?.addEventListener('mouseleave', () => {});
});
config.addHook('setCursor', (u) => {
setFocusedPointIdx(u.cursor.idx === undefined ? null : u.cursor.idx);
});
config.addHook('setSeries', (_, idx) => {
setFocusedSeriesIdx(idx);
});
}, [config]);
if (!plotContext.getPlotInstance() || focusedPointIdx === null) {
return null;
}
// GraphNG expects aligned data, let's take field 0 as x field. FTW
let xField = otherProps.data.fields[0];
if (!xField) { if (!xField) {
return null; return null;
} }
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
return (
<CursorPlugin id={pluginId}>
{({ focusedSeriesIdx, focusedPointIdx, coords }) => {
if (!plotContext.getPlotInstance()) {
return null;
}
let tooltip = null; let tooltip = null;
// when no no cursor interaction
if (focusedPointIdx === null) {
return null;
}
const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text; const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text;
// origin field/frame indexes for inspecting the data
const originFieldIndex = focusedSeriesIdx
? graphContext.mapSeriesIndexToDataFrameFieldIndex(focusedSeriesIdx)
: null;
// when interacting with a point in single mode // when interacting with a point in single mode
if (mode === 'single' && originFieldIndex !== null) { if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
const field = graphContext.alignedData.fields[focusedSeriesIdx!]; const field = otherProps.data.fields[focusedSeriesIdx];
const plotSeries = plotContext.getSeries(); const plotSeries = plotContext.getSeries();
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
const value = fieldFmt(field.values.get(focusedPointIdx)); const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
const value = fieldFmt(plotContext.data[focusedSeriesIdx!][focusedPointIdx]);
tooltip = ( tooltip = (
<SeriesTable <SeriesTable
@ -68,7 +100,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
{ {
// TODO: align with uPlot typings // TODO: align with uPlot typings
color: (plotSeries[focusedSeriesIdx!].stroke as any)(), color: (plotSeries[focusedSeriesIdx!].stroke as any)(),
label: getFieldDisplayName(field, otherProps.data[originFieldIndex.frameIndex]), label: getFieldDisplayName(field, otherProps.data),
value: value ? formattedValueToString(value) : null, value: value ? formattedValueToString(value) : null,
}, },
]} ]}
@ -77,14 +109,13 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
); );
} }
if (mode === 'multi') { if (mode === TooltipDisplayMode.Multi) {
let series: SeriesTableRowProps[] = []; let series: SeriesTableRowProps[] = [];
const plotSeries = plotContext.getSeries(); const plotSeries = plotContext.getSeries();
for (let i = 0; i < plotSeries.length; i++) { for (let i = 0; i < plotSeries.length; i++) {
const dataFrameFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(i); const frame = otherProps.data;
const frame = otherProps.data[dataFrameFieldIndex.frameIndex]; const field = frame.fields[i];
const field = otherProps.data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex];
if ( if (
field === xField || field === xField ||
field.type === FieldType.time || field.type === FieldType.time ||
@ -94,26 +125,21 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
continue; continue;
} }
// using aligned data value field here as it's indexes are in line with Plot data const value = field.display!(plotContext.data[i][focusedPointIdx]);
const valueField = graphContext.alignedData.fields[i];
const value = valueField.display!(valueField.values.get(focusedPointIdx));
series.push({ series.push({
// TODO: align with uPlot typings // TODO: align with uPlot typings
color: (plotSeries[i].stroke as any)!(), color: (plotSeries[i].stroke as any)!(),
label: getFieldDisplayName(field, frame), label: getFieldDisplayName(field, frame),
value: value ? formattedValueToString(value) : null, value: value ? formattedValueToString(value) : null,
isActive: originFieldIndex isActive: focusedSeriesIdx === i,
? dataFrameFieldIndex.frameIndex === originFieldIndex.frameIndex &&
dataFrameFieldIndex.fieldIndex === originFieldIndex.fieldIndex
: false,
}); });
} }
tooltip = <SeriesTable series={series} timestamp={xVal} />; tooltip = <SeriesTable series={series} timestamp={xVal} />;
} }
if (!tooltip) { if (!tooltip || !coords) {
return null; return null;
} }
@ -124,7 +150,4 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
</VizTooltipContainer> </VizTooltipContainer>
</Portal> </Portal>
); );
}}
</CursorPlugin>
);
}; };

View File

@ -1,8 +1,23 @@
import React from 'react'; import React, { useEffect, useLayoutEffect, useState } from 'react';
import { SelectionPlugin } from './SelectionPlugin'; import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
import { pluginLog } from '../utils';
interface Selection {
min: number;
max: number;
// selection bounding box, relative to canvas
bbox: {
top: number;
left: number;
width: number;
height: number;
};
}
interface ZoomPluginProps { interface ZoomPluginProps {
onZoom: (range: { from: number; to: number }) => void; onZoom: (range: { from: number; to: number }) => void;
config: UPlotConfigBuilder;
} }
// min px width that triggers zoom // min px width that triggers zoom
@ -11,17 +26,40 @@ const MIN_ZOOM_DIST = 5;
/** /**
* @alpha * @alpha
*/ */
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom }) => { export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom, config }) => {
return ( const [selection, setSelection] = useState<Selection | null>(null);
<SelectionPlugin
id="Zoom" useEffect(() => {
/* very time series oriented for now */ if (selection) {
onSelect={(selection) => { pluginLog('ZoomPlugin', false, 'selected', selection);
if (selection.bbox.width < MIN_ZOOM_DIST) { if (selection.bbox.width < MIN_ZOOM_DIST) {
return; return;
} }
onZoom({ from: selection.min, to: selection.max }); onZoom({ from: selection.min, to: selection.max });
}} }
/> }, [selection]);
);
useLayoutEffect(() => {
config.addHook('setSelect', (u) => {
const min = u.posToVal(u.select.left, 'x');
const max = u.posToVal(u.select.left + u.select.width, 'x');
setSelection({
min,
max,
bbox: {
left: u.bbox.left / window.devicePixelRatio + u.select.left,
top: u.bbox.top / window.devicePixelRatio,
height: u.bbox.height / window.devicePixelRatio,
width: u.select.width,
},
});
// manually hide selected region (since cursor.drag.setScale = false)
/* @ts-ignore */
u.setSelect({ left: 0, width: 0 }, false);
});
}, [config]);
return null;
}; };

View File

@ -1,5 +1,2 @@
export { ClickPlugin } from './ClickPlugin';
export { SelectionPlugin } from './SelectionPlugin';
export { ZoomPlugin } from './ZoomPlugin'; export { ZoomPlugin } from './ZoomPlugin';
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
export { TooltipPlugin } from './TooltipPlugin'; export { TooltipPlugin } from './TooltipPlugin';

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import uPlot, { Options, Hooks, AlignedData } from 'uplot'; import { Options, AlignedData } from 'uplot';
import { TimeRange } from '@grafana/data'; import { TimeRange } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
@ -8,13 +8,6 @@ export type PlotConfig = Pick<
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
>; >;
export type PlotPlugin = {
id: string;
/** can mutate provided opts as necessary */
opts?: (self: uPlot, opts: Options) => void;
hooks: Hooks.ArraysOrFuncs;
};
export interface PlotPluginProps { export interface PlotPluginProps {
id: string; id: string;
} }

View File

@ -1,9 +1,8 @@
import { DataFrame, dateTime, Field, FieldType } from '@grafana/data'; import { DataFrame, dateTime, Field, FieldType } from '@grafana/data';
import { AlignedData, Options } from 'uplot';
import { PlotPlugin, PlotProps } from './types';
import { StackingMode } from './config'; import { StackingMode } from './config';
import { createLogger } from '../../utils/logger'; import { createLogger } from '../../utils/logger';
import { attachDebugger } from '../../utils'; import { attachDebugger } from '../../utils';
import { AlignedData, Options, PaddingSide } from 'uplot';
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; 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;
@ -11,10 +10,13 @@ export function timeFormatToTemplate(f: string) {
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`); return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
} }
export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPlugin>): Options { const paddingSide: PaddingSide = (u, side, sidesWithAxes) => {
return { let hasCrossAxis = side % 2 ? sidesWithAxes[0] || sidesWithAxes[2] : sidesWithAxes[1] || sidesWithAxes[3];
width: props.width,
height: props.height, return sidesWithAxes[side] || !hasCrossAxis ? 0 : 8;
};
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: { focus: {
alpha: 1, alpha: 1,
}, },
@ -26,12 +28,10 @@ export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPl
legend: { legend: {
show: false, show: false,
}, },
plugins: Object.entries(plugins).map((p) => ({ padding: [paddingSide, paddingSide, paddingSide, paddingSide],
hooks: p[1].hooks, series: [],
})),
hooks: {}, hooks: {},
} as Options; };
}
/** @internal */ /** @internal */
export function preparePlotData(frame: DataFrame, keepFieldTypes?: FieldType[]): AlignedData { export function preparePlotData(frame: DataFrame, keepFieldTypes?: FieldType[]): AlignedData {
@ -111,5 +111,5 @@ export function collectStackingGroups(f: Field, groups: Map<string, number[]>, s
/** @internal */ /** @internal */
export const pluginLogger = createLogger('uPlot Plugin'); export const pluginLogger = createLogger('uPlot Plugin');
export const pluginLog = pluginLogger.logger; export const pluginLog = pluginLogger.logger;
// pluginLogger.enable();
attachDebugger('graphng', undefined, pluginLogger); attachDebugger('graphng', undefined, pluginLogger);

View File

@ -57,6 +57,7 @@ export function ExploreGraphNGPanel({
}: Props) { }: Props) {
const theme = useTheme(); const theme = useTheme();
const [showAllTimeSeries, setShowAllTimeSeries] = useState(false); const [showAllTimeSeries, setShowAllTimeSeries] = useState(false);
const [structureRev, setStructureRev] = useState(1);
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>({ const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>({
defaults: { defaults: {
color: { color: {
@ -95,6 +96,7 @@ export function ExploreGraphNGPanel({
const onLegendClick = useCallback( const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => { (event: GraphNGLegendEvent) => {
setStructureRev((r) => r + 1);
setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data)); setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data));
}, },
[fieldConfig, data] [fieldConfig, data]
@ -122,6 +124,7 @@ export function ExploreGraphNGPanel({
<Collapse label="Graph" loading={isLoading} isOpen> <Collapse label="Graph" loading={isLoading} isOpen>
<GraphNG <GraphNG
data={seriesToShow} data={seriesToShow}
structureRev={structureRev}
width={width} width={width}
height={400} height={400}
timeRange={timeRange} timeRange={timeRange}
@ -129,10 +132,28 @@ export function ExploreGraphNGPanel({
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }} legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeZone={timeZone} timeZone={timeZone}
> >
<ZoomPlugin onZoom={onUpdateTimeRange} /> {(config, alignedDataFrame) => {
<TooltipPlugin data={data} mode={TooltipDisplayMode.Single} timeZone={timeZone} /> return (
<ContextMenuPlugin data={data} timeZone={timeZone} /> <>
{annotations && <ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />} <ZoomPlugin config={config} onZoom={onUpdateTimeRange} />
<TooltipPlugin
config={config}
data={alignedDataFrame}
mode={TooltipDisplayMode.Single}
timeZone={timeZone}
/>
<ContextMenuPlugin config={config} data={alignedDataFrame} timeZone={timeZone} />
{annotations && (
<ExemplarsPlugin
config={config}
exemplars={annotations}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
/>
)}
</>
);
}}
</GraphNG> </GraphNG>
</Collapse> </Collapse>
</> </>

View File

@ -66,6 +66,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
return ( return (
<BarChart <BarChart
data={data.series} data={data.series}
structureRev={data.structureRev}
width={width} width={width}
height={height} height={height}
onLegendClick={onLegendClick} onLegendClick={onLegendClick}

View File

@ -44,6 +44,7 @@ export const TimelinePanel: React.FC<TimelinePanelProps> = ({
return ( return (
<TimelineChart <TimelineChart
data={data.series} data={data.series}
structureRev={data.structureRev}
timeRange={timeRange} timeRange={timeRange}
timeZone={timeZone} timeZone={timeZone}
width={width} width={width}

View File

@ -61,13 +61,37 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
> >
<ZoomPlugin onZoom={onChangeTimeRange} /> {(config, alignedDataFrame) => {
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode} timeZone={timeZone} /> return (
<ContextMenuPlugin data={data.series} timeZone={timeZone} replaceVariables={replaceVariables} /> <>
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
<TooltipPlugin
data={alignedDataFrame}
config={config}
mode={options.tooltipOptions.mode}
timeZone={timeZone}
/>
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
/>
{data.annotations && ( {data.annotations && (
<ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} /> <AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
)} )}
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
{data.annotations && (
<ExemplarsPlugin
config={config}
exemplars={data.annotations}
timeZone={timeZone}
getFieldLinks={getFieldLinks}
/>
)}
</>
);
}}
</GraphNG> </GraphNG>
); );
}; };

View File

@ -1,9 +1,10 @@
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data'; import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { EventsCanvas, usePlotContext, useTheme } from '@grafana/ui'; import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui';
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { AnnotationMarker } from './AnnotationMarker'; import { AnnotationMarker } from './AnnotationMarker';
interface AnnotationsPluginProps { interface AnnotationsPluginProps {
config: UPlotConfigBuilder;
annotations: DataFrame[]; annotations: DataFrame[];
timeZone: TimeZone; timeZone: TimeZone;
} }
@ -14,11 +15,10 @@ interface AnnotationsDataFrameViewDTO {
tags: string[]; tags: string[];
} }
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => { export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone, config }) => {
const pluginId = 'AnnotationsPlugin';
const { isPlotReady, registerPlugin, getPlotInstance } = usePlotContext();
const theme = useTheme(); const theme = useTheme();
const { getPlotInstance } = usePlotContext();
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>(); const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
const timeFormatter = useCallback( const timeFormatter = useCallback(
@ -31,8 +31,8 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
[timeZone] [timeZone]
); );
// Update annotations views when new annotations came
useEffect(() => { useEffect(() => {
if (isPlotReady) {
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = []; const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
for (const frame of annotations) { for (const frame of annotations) {
@ -40,18 +40,13 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
} }
annotationsRef.current = views; annotationsRef.current = views;
} }, [annotations]);
}, [isPlotReady, annotations]);
useEffect(() => { useLayoutEffect(() => {
const unregister = registerPlugin({ config.addHook('draw', (u) => {
id: pluginId,
hooks: {
// Render annotation lines on the canvas // Render annotation lines on the canvas
draw: (u) => {
/** /**
* We cannot rely on state value here, as it would require this effect to be dependent on the state value. * We cannot rely on state value here, as it would require this effect to be dependent on the state value.
* This would make the plugin re-register making the entire plot to reinitialise. ref is the way to go :)
*/ */
if (!annotationsRef.current) { if (!annotationsRef.current) {
return null; return null;
@ -82,14 +77,8 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
} }
} }
return; return;
},
},
}); });
}, [config, theme]);
return () => {
unregister();
};
}, [registerPlugin, theme.palette.red]);
const mapAnnotationToXYCoords = useCallback( const mapAnnotationToXYCoords = useCallback(
(frame: DataFrame, index: number) => { (frame: DataFrame, index: number) => {
@ -120,6 +109,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
return ( return (
<EventsCanvas <EventsCanvas
id="annotations" id="annotations"
config={config}
events={annotations} events={annotations}
renderEventMarker={renderMarker} renderEventMarker={renderMarker}
mapEventToXYCoords={mapAnnotationToXYCoords} mapEventToXYCoords={mapAnnotationToXYCoords}

View File

@ -1,6 +1,6 @@
import React, { useCallback, useRef, useState } from 'react'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { css as cssCore, Global } from '@emotion/react';
import { import {
ClickPlugin,
ContextMenu, ContextMenu,
GraphContextMenuHeader, GraphContextMenuHeader,
IconName, IconName,
@ -8,10 +8,10 @@ import {
MenuItemsGroup, MenuItemsGroup,
MenuGroup, MenuGroup,
MenuItem, MenuItem,
Portal, UPlotConfigBuilder,
useGraphNGContext,
} from '@grafana/ui'; } from '@grafana/ui';
import { import {
CartesianCoords2D,
DataFrame, DataFrame,
DataFrameView, DataFrameView,
getDisplayProcessor, getDisplayProcessor,
@ -21,9 +21,11 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { useClickAway } from 'react-use'; import { useClickAway } from 'react-use';
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers'; import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
import { pluginLog } from '@grafana/ui/src/components/uPlot/utils';
interface ContextMenuPluginProps { interface ContextMenuPluginProps {
data: DataFrame[]; data: DataFrame;
config: UPlotConfigBuilder;
defaultItems?: MenuItemsGroup[]; defaultItems?: MenuItemsGroup[];
timeZone: TimeZone; timeZone: TimeZone;
onOpen?: () => void; onOpen?: () => void;
@ -33,22 +35,108 @@ interface ContextMenuPluginProps {
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
data, data,
config,
defaultItems, defaultItems,
onClose, onClose,
timeZone, timeZone,
replaceVariables, replaceVariables,
}) => { }) => {
const plotCanvas = useRef<HTMLDivElement>();
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null);
const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const onClick = useCallback(() => { const openMenu = useCallback(() => {
setIsOpen(!isOpen); setIsOpen(true);
}, [isOpen]); }, [setIsOpen]);
const closeMenu = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
const clearSelection = useCallback(() => {
pluginLog('ContextMenuPlugin', false, 'clearing click selection');
setPoint(null);
}, [setPoint]);
// Add uPlot hooks to the config, or re-add when the config changed
useLayoutEffect(() => {
const onMouseCapture = (e: MouseEvent) => {
setCoords({
plotCanvas: {
x: e.clientX - plotCanvasBBox.current.left,
y: e.clientY - plotCanvasBBox.current.top,
},
viewport: {
x: e.clientX,
y: e.clientY,
},
});
};
config.addHook('init', (u) => {
const canvas = u.root.querySelector<HTMLDivElement>('.u-over');
plotCanvas.current = canvas || undefined;
plotCanvas.current?.addEventListener('mousedown', onMouseCapture);
plotCanvas.current?.addEventListener('mouseleave', () => {});
pluginLog('ContextMenuPlugin', false, 'init');
// for naive click&drag check
let isClick = false;
// REF: https://github.com/leeoniya/uPlot/issues/239
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt'));
plotCanvas.current?.addEventListener('mousedown', (e: MouseEvent) => {
isClick = true;
});
plotCanvas.current?.addEventListener('mousemove', (e: MouseEvent) => {
isClick = false;
});
// TODO: remove listeners on unmount
plotCanvas.current?.addEventListener('mouseup', (e: MouseEvent) => {
if (!isClick) {
setPoint(null);
return;
}
isClick = true;
if (e.target) {
const target = e.target as HTMLElement;
if (!target.classList.contains('u-cursor-pt')) {
pluginLog('ContextMenuPlugin', false, 'canvas click');
setPoint({ seriesIdx: null, dataIdx: null });
}
}
});
if (pts.length > 0) {
pts.forEach((pt, i) => {
// TODO: remove listeners on unmount
pt.addEventListener('click', (e) => {
const seriesIdx = i + 1;
const dataIdx = u.cursor.idx;
pluginLog('ContextMenuPlugin', false, seriesIdx, dataIdx);
openMenu();
setPoint({ seriesIdx, dataIdx: dataIdx || null });
});
});
}
});
}, [config, openMenu]);
return ( return (
<ClickPlugin id="ContextMenu" onClick={onClick}> <>
{({ point, coords, clearSelection }) => { <Global
return ( styles={cssCore`
<Portal> .uplot .u-cursor-pt {
pointer-events: auto !important;
}
`}
/>
{isOpen && coords && (
<ContextMenuView <ContextMenuView
data={data} data={data}
defaultItems={defaultItems} defaultItems={defaultItems}
@ -57,26 +145,25 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
replaceVariables={replaceVariables} replaceVariables={replaceVariables}
onClose={() => { onClose={() => {
clearSelection(); clearSelection();
closeMenu();
if (onClose) { if (onClose) {
onClose(); onClose();
} }
}} }}
/> />
</Portal> )}
); </>
}}
</ClickPlugin>
); );
}; };
interface ContextMenuProps { interface ContextMenuProps {
data: DataFrame[]; data: DataFrame;
defaultItems?: MenuItemsGroup[]; defaultItems?: MenuItemsGroup[];
timeZone: TimeZone; timeZone: TimeZone;
onClose?: () => void; onClose?: () => void;
selection: { selection: {
point: { seriesIdx: number | null; dataIdx: number | null }; point?: { seriesIdx: number | null; dataIdx: number | null } | null;
coords: { plotCanvas: { x: number; y: number }; viewport: { x: number; y: number } }; coords: { plotCanvas: CartesianCoords2D; viewport: CartesianCoords2D };
}; };
replaceVariables?: InterpolateFunction; replaceVariables?: InterpolateFunction;
} }
@ -90,7 +177,6 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
...otherProps ...otherProps
}) => { }) => {
const ref = useRef(null); const ref = useRef(null);
const graphContext = useGraphNGContext();
const onClose = () => { const onClose = () => {
if (otherProps.onClose) { if (otherProps.onClose) {
@ -102,7 +188,7 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
onClose(); onClose();
}); });
const xField = graphContext.getXAxisField(); const xField = data.fields[0];
if (!xField) { if (!xField) {
return null; return null;
@ -110,14 +196,12 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
const items = defaultItems ? [...defaultItems] : []; const items = defaultItems ? [...defaultItems] : [];
let renderHeader: () => JSX.Element | null = () => null; let renderHeader: () => JSX.Element | null = () => null;
if (selection.point) {
const { seriesIdx, dataIdx } = selection.point; const { seriesIdx, dataIdx } = selection.point;
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
if (seriesIdx && dataIdx) { if (seriesIdx && dataIdx) {
// origin field/frame indexes for inspecting the data const field = data.fields[seriesIdx];
const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx);
const frame = data[originFieldIndex.frameIndex];
const field = frame.fields[originFieldIndex.fieldIndex];
const displayValue = field.display!(field.values.get(dataIdx)); const displayValue = field.display!(field.values.get(dataIdx));
@ -127,9 +211,9 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
const linksSupplier = getFieldLinksSupplier({ const linksSupplier = getFieldLinksSupplier({
display: displayValue, display: displayValue,
name: field.name, name: field.name,
view: new DataFrameView(frame), view: new DataFrameView(data),
rowIndex: dataIdx, rowIndex: dataIdx,
colIndex: originFieldIndex.fieldIndex, colIndex: seriesIdx,
field: field.config, field: field.config,
hasLinks, hasLinks,
}); });
@ -156,10 +240,11 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
timestamp={xFieldFmt(xField.values.get(dataIdx)).text} timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
displayValue={displayValue} displayValue={displayValue}
seriesColor={displayValue.color!} seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, frame)} displayName={getFieldDisplayName(field, data)}
/> />
); );
} }
}
const renderMenuGroupItems = () => { const renderMenuGroupItems = () => {
return items?.map((group, index) => ( return items?.map((group, index) => (

View File

@ -6,26 +6,26 @@ import {
TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME,
} from '@grafana/data'; } from '@grafana/data';
import { EventsCanvas, FIXED_UNIT, usePlotContext } from '@grafana/ui'; import { EventsCanvas, FIXED_UNIT, UPlotConfigBuilder, usePlotContext } from '@grafana/ui';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { ExemplarMarker } from './ExemplarMarker'; import { ExemplarMarker } from './ExemplarMarker';
interface ExemplarsPluginProps { interface ExemplarsPluginProps {
config: UPlotConfigBuilder;
exemplars: DataFrame[]; exemplars: DataFrame[];
timeZone: TimeZone; timeZone: TimeZone;
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>; getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
} }
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone, getFieldLinks }) => { export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone, getFieldLinks, config }) => {
const plotCtx = usePlotContext(); const plotCtx = usePlotContext();
const mapExemplarToXYCoords = useCallback( const mapExemplarToXYCoords = useCallback(
(dataFrame: DataFrame, index: number) => { (dataFrame: DataFrame, index: number) => {
const plotInstance = plotCtx.getPlotInstance(); const plotInstance = plotCtx.getPlotInstance();
const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME); const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME); const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
if (!time || !value || !plotCtx.isPlotReady || !plotInstance) { if (!time || !value || !plotInstance) {
return undefined; return undefined;
} }
@ -63,6 +63,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
return ( return (
<EventsCanvas <EventsCanvas
config={config}
id="exemplars" id="exemplars"
events={exemplars} events={exemplars}
renderEventMarker={renderMarker} renderEventMarker={renderMarker}

View File

@ -63,8 +63,16 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
> >
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode as any} timeZone={timeZone} /> {(config, alignedDataFrame) => {
<>{/* needs to be an array */}</> return (
<TooltipPlugin
config={config}
data={alignedDataFrame}
mode={options.tooltipOptions.mode as any}
timeZone={timeZone}
/>
);
}}
</GraphNG> </GraphNG>
); );
}; };