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

View File

@ -1,21 +1,12 @@
import React from 'react';
import { AlignedData } from 'uplot';
import {
DataFrame,
DataFrameFieldIndex,
FieldMatcherID,
fieldMatchers,
FieldType,
TimeRange,
TimeZone,
} from '@grafana/data';
import { DataFrame, FieldMatcherID, fieldMatchers, TimeRange, TimeZone } from '@grafana/data';
import { withTheme } from '../../themes';
import { Themeable } from '../../types';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
import { GraphNGContext } from './hooks';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { preparePlotData } from '../uPlot/utils';
import { pluginLog, preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend';
import { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen';
@ -37,89 +28,41 @@ export interface GraphNGProps extends Themeable {
fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void;
onSeriesColorChange?: (label: string, color: string) => void;
children?: React.ReactNode;
children?: (builder: UPlotConfigBuilder, alignedDataFrame: DataFrame) => React.ReactNode;
}
/**
* @internal -- not a public API
*/
export interface GraphNGState {
data: AlignedData;
alignedDataFrame: DataFrame;
dimFields: XYFieldMatchers;
seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[];
data: AlignedData;
config?: UPlotConfigBuilder;
}
class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
constructor(props: GraphNGProps) {
super(props);
let dimFields = props.fields;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
}
this.state = { dimFields } as GraphNGState;
}
pluginLog('GraphNG', false, 'constructor, data aligment');
const alignedData = preparePlotFrame(props.data, {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
});
/**
* 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) {
if (!alignedData) {
return;
}
this.setState({
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone),
});
this.state = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
config: preparePlotConfigBuilder(alignedData, props.theme, this.getTimeRange, this.getTimeZone),
};
}
componentDidUpdate(prevProps: GraphNGProps) {
const { data, theme, structureRev } = this.props;
const { alignedDataFrame } = this.state;
const { theme, structureRev, data } = this.props;
let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState;
@ -128,13 +71,31 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
}
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;
}
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
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 };
}
}
@ -144,10 +105,6 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
}
}
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
return this.state.seriesToDataFrameFieldIndexMap[i];
};
getTimeRange = () => {
return this.props.timeRange;
};
@ -178,35 +135,25 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
}
render() {
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props;
if (!this.state.data || !this.state.config) {
const { width, height, children, timeRange } = this.props;
const { config, alignedDataFrame } = this.state;
if (!config) {
return null;
}
return (
<GraphNGContext.Provider
value={{
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
dimFields: this.state.dimFields,
data: this.state.alignedDataFrame,
}}
>
<VizLayout width={width} height={height} legend={this.renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
{...plotProps}
config={this.state.config!}
data={this.state.data}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children}
</UPlotChart>
)}
</VizLayout>
</GraphNGContext.Provider>
<VizLayout width={width} height={height} legend={this.renderLegend()}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
config={this.state.config!}
data={this.state.data}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children ? children(config, alignedDataFrame) : null}
</UPlotChart>
)}
</VizLayout>
);
}
}

View File

@ -1,10 +1,9 @@
import React from 'react';
import { compareArrayValues, compareDataFrameStructures, FieldMatcherID, fieldMatchers } from '@grafana/data';
import { FieldMatcherID, fieldMatchers } from '@grafana/data';
import { withTheme } from '../../themes';
import { GraphNGContext } from '../GraphNG/hooks';
import { GraphNGState } from '../GraphNG/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 { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode } from '../VizLegend/models.gen';
@ -14,77 +13,35 @@ import { TimelineProps } from './types';
class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState> {
constructor(props: TimelineProps) {
super(props);
let dimFields = props.fields;
const { theme, mode, rowHeight, colWidth, showValue } = props;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), // this may be either numeric or strings, (or bools?)
};
}
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 = {
pluginLog('TimelineChart', false, 'constructor, data aligment');
const alignedData = preparePlotFrame(
props.data,
props.fields || {
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),
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) {
if (!alignedData) {
return;
}
this.setState({
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, {
this.state = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
config: preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
}),
});
};
}
componentDidUpdate(prevProps: TimelineProps) {
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue } = this.props;
const { alignedDataFrame } = this.state;
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue, structureRev } = this.props;
let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState;
@ -99,35 +56,41 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
shouldConfigUpdate = true;
}
if (data !== prevProps.data) {
if (!alignedDataFrame) {
if (data !== prevProps.data || shouldConfigUpdate) {
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;
}
if (!compareArrayValues(data, prevProps.data, compareDataFrameStructures)) {
shouldConfigUpdate = true;
stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
if (shouldConfigUpdate || hasStructureChanged) {
pluginLog('TimelineChart', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
});
stateUpdate = { ...stateUpdate, config: builder };
}
}
if (shouldConfigUpdate) {
const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
});
stateUpdate = { ...stateUpdate, config: builder };
}
if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate);
}
}
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
return this.state.seriesToDataFrameFieldIndexMap[i];
};
getTimeRange = () => {
return this.props.timeRange;
};
@ -158,35 +121,27 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
}
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 (
<GraphNGContext.Provider
value={{
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
dimFields: this.state.dimFields,
data: this.state.alignedDataFrame,
}}
>
<VizLayout width={width} height={height}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
{...plotProps}
config={this.state.config!}
data={this.state.data}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children}
</UPlotChart>
)}
</VizLayout>
</GraphNGContext.Provider>
<VizLayout width={width} height={height}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
config={this.state.config!}
data={this.state.data}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children ? children(config, alignedDataFrame) : null}
</UPlotChart>
)}
</VizLayout>
);
}
}

View File

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

View File

@ -1,6 +1,6 @@
import React from 'react';
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 { GraphFieldConfig, DrawStyle } from '../uPlot/config';
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);
unmount();
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);
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);
const nextConfig = new UPlotConfigBuilder();
@ -186,16 +171,10 @@ describe('UPlotChart', () => {
);
// we wait 1 frame for plugins initialisation logic to finish
act(() => {
mockRaf.step({ count: 1 });
});
const nextConfig = new UPlotConfigBuilder();
nextConfig.addSeries({} as SeriesProps);
rerender(
<UPlotChart
data={preparePlotData(data)} // frame
config={nextConfig}
config={config}
timeRange={timeRange}
width={200}
height={200}
@ -206,68 +185,5 @@ describe('UPlotChart', () => {
expect(uPlot).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 { buildPlotContext, PlotContext } from './context';
import { pluginLog } from './utils';
import { usePlotConfig } from './hooks';
import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils';
import { PlotProps } from './types';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
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) => {
const canvasRef = useRef<HTMLDivElement>(null);
const plotInstance = useRef<uPlot>();
const [isPlotReady, setIsPlotReady] = useState(false);
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(() => {
return plotInstance.current;
}, []);
// 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 (!currentConfig.current || !canvasRef.current || props.width === 0 || props.height === 0) {
if (!plotInstance.current || props.width === 0 || props.height === 0) {
return;
}
// 0. Exit if the data set length is different than number of series expected to render
// This may happen when GraphNG has not synced config yet with the aligned frame. Alignment happens before the render
// in the getDerivedStateFromProps, while the config creation happens in componentDidUpdate, causing one more render
// of the UPlotChart if the config needs to be updated.
if (currentConfig.current.series.length !== props.data.length) {
pluginLog('uPlot core', false, 'updating size');
plotInstance.current!.setSize({
width: props.width,
height: props.height,
});
}, [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;
}
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
if (isConfigReady && !plotInstance.current) {
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
setIsPlotReady(true);
if (!plotInstance.current || !prevProps) {
plotInstance.current = initializePlot(props.data, config, canvasRef.current);
return;
}
// 2. When dimensions have changed, update uPlot size and return
if (currentConfig.current.width !== prevProps?.width || currentConfig.current.height !== prevProps?.height) {
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) {
// 2. Reinitialize uPlot if config changed
if (props.config !== prevProps.config) {
if (plotInstance.current) {
pluginLog('uPlot core', false, 'destroying instance');
plotInstance.current.destroy();
}
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
plotInstance.current = initializePlot(props.data, config, canvasRef.current);
return;
}
// 4. Otherwise, assume only data has changed and update uPlot data
updateData(props.config, props.data, plotInstance.current);
}, [props, isConfigReady]);
// 3. Otherwise, assume only data has changed and update uPlot data
if (props.data !== prevProps.data) {
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
useEffect(() => () => plotInstance.current?.destroy(), []);
// Memoize plot context
const plotCtx = useMemo(() => {
return buildPlotContext(isPlotReady, canvasRef, props.data, registerPlugin, getPlotInstance);
}, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance, isPlotReady]);
return buildPlotContext(canvasRef, props.data, getPlotInstance);
}, [plotInstance, canvasRef, props.data, getPlotInstance]);
return (
<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);
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 { defaultsDeep } from 'lodash';
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
type valueof<T> = T[keyof T];
import { pluginLog } from '../utils';
export class UPlotConfigBuilder {
private series: UPlotSeriesBuilder[] = [];
@ -27,7 +26,9 @@ export class UPlotConfigBuilder {
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]) {
this.hooks[type] = [];
}

View File

@ -1,6 +1,5 @@
import React, { useContext } from 'react';
import uPlot, { AlignedData, Series } from 'uplot';
import { PlotPlugin } from './types';
/**
* @alpha
@ -18,15 +17,7 @@ interface PlotCanvasContextType {
};
}
/**
* @alpha
*/
interface PlotPluginsContextType {
registerPlugin: (plugin: PlotPlugin) => () => void;
}
interface PlotContextType extends PlotPluginsContextType {
isPlotReady: boolean;
interface PlotContextType {
getPlotInstance: () => uPlot | undefined;
getSeries: () => Series[];
getCanvas: () => PlotCanvasContextType;
@ -44,40 +35,17 @@ export const usePlotContext = (): PlotContextType => {
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
*/
export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any,
data: AlignedData,
registerPlugin: any,
getPlotInstance: () => uPlot | undefined
): PlotContextType => {
return {
isPlotReady,
canvasRef,
data,
registerPlugin,
getPlotInstance,
getSeries: () => getPlotInstance()!.series,
getCanvas: () => {

View File

@ -1,20 +1,29 @@
import { DataFrame } from '@grafana/data';
import React, { useMemo } from 'react';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import { usePlotContext } from '../context';
import { useRefreshAfterGraphRendered } from '../hooks';
import { Marker } from './Marker';
import { XYCanvas } from './XYCanvas';
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
interface EventsCanvasProps {
id: string;
config: UPlotConfigBuilder;
events: DataFrame[];
renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode;
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 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 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 { SelectionPlugin } from './SelectionPlugin';
import { css } from '@emotion/css';
import { Button } from '../../Button';
import useClickAway from 'react-use/lib/useClickAway';
interface AnnotationsEditorPluginProps {
onAnnotationCreate: () => void;
}
/**
* @alpha
*/
export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
const pluginId = 'AnnotationsEditorPlugin';
return (
<SelectionPlugin
id={pluginId}
onSelect={(selection) => {
console.log(selection);
}}
lazy
>
{({ selection, clearSelection }) => {
return <AnnotationEditor selection={selection} onClose={clearSelection} />;
}}
</SelectionPlugin>
);
};
const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
const ref = useRef(null);
useClickAway(ref, () => {
if (onClose) {
onClose();
}
});
return (
<div>
<div
ref={ref}
className={css`
position: absolute;
background: purple;
top: ${selection.bbox.top}px;
left: ${selection.bbox.left}px;
width: ${selection.bbox.width}px;
height: ${selection.bbox.height}px;
`}
>
Annotations editor maybe?
<Button onClick={() => {}}>Create annotation</Button>
</div>
</div>
);
};
// import React, { useRef } from 'react';
// import { SelectionPlugin } from './SelectionPlugin';
// import { css } from '@emotion/css';
// import { Button } from '../../Button';
// import useClickAway from 'react-use/lib/useClickAway';
//
// interface AnnotationsEditorPluginProps {
// onAnnotationCreate: () => void;
// }
//
// /**
// * @alpha
// */
// export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
// const pluginId = 'AnnotationsEditorPlugin';
//
// return (
// <SelectionPlugin
// id={pluginId}
// onSelect={(selection) => {
// console.log(selection);
// }}
// lazy
// >
// {({ selection, clearSelection }) => {
// return <AnnotationEditor selection={selection} onClose={clearSelection} />;
// }}
// </SelectionPlugin>
// );
// };
//
// const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
// const ref = useRef(null);
//
// useClickAway(ref, () => {
// if (onClose) {
// onClose();
// }
// });
//
// return (
// <div>
// <div
// ref={ref}
// className={css`
// position: absolute;
// background: purple;
// top: ${selection.bbox.top}px;
// left: ${selection.bbox.left}px;
// width: ${selection.bbox.width}px;
// height: ${selection.bbox.height}px;
// `}
// >
// Annotations editor maybe?
// <Button onClick={() => {}}>Create annotation</Button>
// </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 { usePlotContext } from '../context';
import { CursorPlugin } from './CursorPlugin';
import { VizTooltipContainer, SeriesTable, SeriesTableRowProps, TooltipDisplayMode } from '../../VizTooltip';
import {
CartesianCoords2D,
DataFrame,
FieldType,
formattedValueToString,
@ -11,120 +10,144 @@ import {
getFieldDisplayName,
TimeZone,
} 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 {
mode?: TooltipDisplayMode;
timeZone: TimeZone;
data: DataFrame[];
data: DataFrame;
config: UPlotConfigBuilder;
}
/**
* @alpha
*/
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone, ...otherProps }) => {
const pluginId = 'PlotTooltip';
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
mode = TooltipDisplayMode.Single,
timeZone,
config,
...otherProps
}) => {
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();
if (!xField) {
// 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) {
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
let tooltip = null;
const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text;
// when interacting with a point in single mode
if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) {
const field = otherProps.data.fields[focusedSeriesIdx];
const plotSeries = plotContext.getSeries();
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
const value = fieldFmt(plotContext.data[focusedSeriesIdx!][focusedPointIdx]);
tooltip = (
<SeriesTable
series={[
{
// TODO: align with uPlot typings
color: (plotSeries[focusedSeriesIdx!].stroke as any)(),
label: getFieldDisplayName(field, otherProps.data),
value: value ? formattedValueToString(value) : null,
},
]}
timestamp={xVal}
/>
);
}
if (mode === TooltipDisplayMode.Multi) {
let series: SeriesTableRowProps[] = [];
const plotSeries = plotContext.getSeries();
for (let i = 0; i < plotSeries.length; i++) {
const frame = otherProps.data;
const field = frame.fields[i];
if (
field === xField ||
field.type === FieldType.time ||
field.type !== FieldType.number ||
field.config.custom?.hideFrom?.tooltip
) {
continue;
}
const value = field.display!(plotContext.data[i][focusedPointIdx]);
series.push({
// TODO: align with uPlot typings
color: (plotSeries[i].stroke as any)!(),
label: getFieldDisplayName(field, frame),
value: value ? formattedValueToString(value) : null,
isActive: focusedSeriesIdx === i,
});
}
tooltip = <SeriesTable series={series} timestamp={xVal} />;
}
if (!tooltip || !coords) {
return null;
}
return (
<CursorPlugin id={pluginId}>
{({ focusedSeriesIdx, focusedPointIdx, coords }) => {
if (!plotContext.getPlotInstance()) {
return null;
}
let tooltip = null;
// when no no cursor interaction
if (focusedPointIdx === null) {
return null;
}
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
if (mode === 'single' && originFieldIndex !== null) {
const field = graphContext.alignedData.fields[focusedSeriesIdx!];
const plotSeries = plotContext.getSeries();
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
const value = fieldFmt(field.values.get(focusedPointIdx));
tooltip = (
<SeriesTable
series={[
{
// TODO: align with uPlot typings
color: (plotSeries[focusedSeriesIdx!].stroke as any)(),
label: getFieldDisplayName(field, otherProps.data[originFieldIndex.frameIndex]),
value: value ? formattedValueToString(value) : null,
},
]}
timestamp={xVal}
/>
);
}
if (mode === 'multi') {
let series: SeriesTableRowProps[] = [];
const plotSeries = plotContext.getSeries();
for (let i = 0; i < plotSeries.length; i++) {
const dataFrameFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(i);
const frame = otherProps.data[dataFrameFieldIndex.frameIndex];
const field = otherProps.data[dataFrameFieldIndex.frameIndex].fields[dataFrameFieldIndex.fieldIndex];
if (
field === xField ||
field.type === FieldType.time ||
field.type !== FieldType.number ||
field.config.custom?.hideFrom?.tooltip
) {
continue;
}
// using aligned data value field here as it's indexes are in line with Plot data
const valueField = graphContext.alignedData.fields[i];
const value = valueField.display!(valueField.values.get(focusedPointIdx));
series.push({
// TODO: align with uPlot typings
color: (plotSeries[i].stroke as any)!(),
label: getFieldDisplayName(field, frame),
value: value ? formattedValueToString(value) : null,
isActive: originFieldIndex
? dataFrameFieldIndex.frameIndex === originFieldIndex.frameIndex &&
dataFrameFieldIndex.fieldIndex === originFieldIndex.fieldIndex
: false,
});
}
tooltip = <SeriesTable series={series} timestamp={xVal} />;
}
if (!tooltip) {
return null;
}
return (
<Portal>
<VizTooltipContainer position={{ x: coords.viewport.x, y: coords.viewport.y }} offset={{ x: 10, y: 10 }}>
{tooltip}
</VizTooltipContainer>
</Portal>
);
}}
</CursorPlugin>
<Portal>
<VizTooltipContainer position={{ x: coords.viewport.x, y: coords.viewport.y }} offset={{ x: 10, y: 10 }}>
{tooltip}
</VizTooltipContainer>
</Portal>
);
};

View File

@ -1,8 +1,23 @@
import React from 'react';
import { SelectionPlugin } from './SelectionPlugin';
import React, { useEffect, useLayoutEffect, useState } from 'react';
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 {
onZoom: (range: { from: number; to: number }) => void;
config: UPlotConfigBuilder;
}
// min px width that triggers zoom
@ -11,17 +26,40 @@ const MIN_ZOOM_DIST = 5;
/**
* @alpha
*/
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom }) => {
return (
<SelectionPlugin
id="Zoom"
/* very time series oriented for now */
onSelect={(selection) => {
if (selection.bbox.width < MIN_ZOOM_DIST) {
return;
}
onZoom({ from: selection.min, to: selection.max });
}}
/>
);
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom, config }) => {
const [selection, setSelection] = useState<Selection | null>(null);
useEffect(() => {
if (selection) {
pluginLog('ZoomPlugin', false, 'selected', selection);
if (selection.bbox.width < MIN_ZOOM_DIST) {
return;
}
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 { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
export { TooltipPlugin } from './TooltipPlugin';

View File

@ -1,5 +1,5 @@
import React from 'react';
import uPlot, { Options, Hooks, AlignedData } from 'uplot';
import { Options, AlignedData } from 'uplot';
import { TimeRange } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
@ -8,13 +8,6 @@ export type PlotConfig = Pick<
'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 {
id: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,10 @@
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { EventsCanvas, usePlotContext, useTheme } from '@grafana/ui';
import React, { useCallback, useEffect, useRef } from 'react';
import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui';
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import { AnnotationMarker } from './AnnotationMarker';
interface AnnotationsPluginProps {
config: UPlotConfigBuilder;
annotations: DataFrame[];
timeZone: TimeZone;
}
@ -14,11 +15,10 @@ interface AnnotationsDataFrameViewDTO {
tags: string[];
}
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => {
const pluginId = 'AnnotationsPlugin';
const { isPlotReady, registerPlugin, getPlotInstance } = usePlotContext();
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone, config }) => {
const theme = useTheme();
const { getPlotInstance } = usePlotContext();
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
const timeFormatter = useCallback(
@ -31,65 +31,54 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
[timeZone]
);
// Update annotations views when new annotations came
useEffect(() => {
if (isPlotReady) {
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
for (const frame of annotations) {
views.push(new DataFrameView(frame));
for (const frame of annotations) {
views.push(new DataFrameView(frame));
}
annotationsRef.current = views;
}, [annotations]);
useLayoutEffect(() => {
config.addHook('draw', (u) => {
// Render annotation lines on the canvas
/**
* We cannot rely on state value here, as it would require this effect to be dependent on the state value.
*/
if (!annotationsRef.current) {
return null;
}
annotationsRef.current = views;
}
}, [isPlotReady, annotations]);
const ctx = u.ctx;
if (!ctx) {
return;
}
for (let i = 0; i < annotationsRef.current.length; i++) {
const annotationsView = annotationsRef.current[i];
for (let j = 0; j < annotationsView.length; j++) {
const annotation = annotationsView.get(j);
useEffect(() => {
const unregister = registerPlugin({
id: pluginId,
hooks: {
// 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.
* This would make the plugin re-register making the entire plot to reinitialise. ref is the way to go :)
*/
if (!annotationsRef.current) {
return null;
if (!annotation.time) {
continue;
}
const ctx = u.ctx;
if (!ctx) {
return;
}
for (let i = 0; i < annotationsRef.current.length; i++) {
const annotationsView = annotationsRef.current[i];
for (let j = 0; j < annotationsView.length; j++) {
const annotation = annotationsView.get(j);
if (!annotation.time) {
continue;
}
const xpos = u.valToPos(annotation.time, 'x', true);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = theme.palette.red;
ctx.setLineDash([5, 5]);
ctx.moveTo(xpos, u.bbox.top);
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
ctx.stroke();
ctx.closePath();
}
}
return;
},
},
const xpos = u.valToPos(annotation.time, 'x', true);
ctx.beginPath();
ctx.lineWidth = 2;
ctx.strokeStyle = theme.palette.red;
ctx.setLineDash([5, 5]);
ctx.moveTo(xpos, u.bbox.top);
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
ctx.stroke();
ctx.closePath();
}
}
return;
});
return () => {
unregister();
};
}, [registerPlugin, theme.palette.red]);
}, [config, theme]);
const mapAnnotationToXYCoords = useCallback(
(frame: DataFrame, index: number) => {
@ -120,6 +109,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
return (
<EventsCanvas
id="annotations"
config={config}
events={annotations}
renderEventMarker={renderMarker}
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 {
ClickPlugin,
ContextMenu,
GraphContextMenuHeader,
IconName,
@ -8,10 +8,10 @@ import {
MenuItemsGroup,
MenuGroup,
MenuItem,
Portal,
useGraphNGContext,
UPlotConfigBuilder,
} from '@grafana/ui';
import {
CartesianCoords2D,
DataFrame,
DataFrameView,
getDisplayProcessor,
@ -21,9 +21,11 @@ import {
} from '@grafana/data';
import { useClickAway } from 'react-use';
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
import { pluginLog } from '@grafana/ui/src/components/uPlot/utils';
interface ContextMenuPluginProps {
data: DataFrame[];
data: DataFrame;
config: UPlotConfigBuilder;
defaultItems?: MenuItemsGroup[];
timeZone: TimeZone;
onOpen?: () => void;
@ -33,50 +35,135 @@ interface ContextMenuPluginProps {
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
data,
config,
defaultItems,
onClose,
timeZone,
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 onClick = useCallback(() => {
setIsOpen(!isOpen);
}, [isOpen]);
const openMenu = useCallback(() => {
setIsOpen(true);
}, [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 (
<ClickPlugin id="ContextMenu" onClick={onClick}>
{({ point, coords, clearSelection }) => {
return (
<Portal>
<ContextMenuView
data={data}
defaultItems={defaultItems}
timeZone={timeZone}
selection={{ point, coords }}
replaceVariables={replaceVariables}
onClose={() => {
clearSelection();
if (onClose) {
onClose();
}
}}
/>
</Portal>
);
}}
</ClickPlugin>
<>
<Global
styles={cssCore`
.uplot .u-cursor-pt {
pointer-events: auto !important;
}
`}
/>
{isOpen && coords && (
<ContextMenuView
data={data}
defaultItems={defaultItems}
timeZone={timeZone}
selection={{ point, coords }}
replaceVariables={replaceVariables}
onClose={() => {
clearSelection();
closeMenu();
if (onClose) {
onClose();
}
}}
/>
)}
</>
);
};
interface ContextMenuProps {
data: DataFrame[];
data: DataFrame;
defaultItems?: MenuItemsGroup[];
timeZone: TimeZone;
onClose?: () => void;
selection: {
point: { seriesIdx: number | null; dataIdx: number | null };
coords: { plotCanvas: { x: number; y: number }; viewport: { x: number; y: number } };
point?: { seriesIdx: number | null; dataIdx: number | null } | null;
coords: { plotCanvas: CartesianCoords2D; viewport: CartesianCoords2D };
};
replaceVariables?: InterpolateFunction;
}
@ -90,7 +177,6 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
...otherProps
}) => {
const ref = useRef(null);
const graphContext = useGraphNGContext();
const onClose = () => {
if (otherProps.onClose) {
@ -102,7 +188,7 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
onClose();
});
const xField = graphContext.getXAxisField();
const xField = data.fields[0];
if (!xField) {
return null;
@ -110,55 +196,54 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
const items = defaultItems ? [...defaultItems] : [];
let renderHeader: () => JSX.Element | null = () => null;
const { seriesIdx, dataIdx } = selection.point;
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
if (selection.point) {
const { seriesIdx, dataIdx } = selection.point;
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
if (seriesIdx && dataIdx) {
// origin field/frame indexes for inspecting the data
const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx);
const frame = data[originFieldIndex.frameIndex];
const field = frame.fields[originFieldIndex.fieldIndex];
if (seriesIdx && dataIdx) {
const field = data.fields[seriesIdx];
const displayValue = field.display!(field.values.get(dataIdx));
const displayValue = field.display!(field.values.get(dataIdx));
const hasLinks = field.config.links && field.config.links.length > 0;
const hasLinks = field.config.links && field.config.links.length > 0;
if (hasLinks) {
const linksSupplier = getFieldLinksSupplier({
display: displayValue,
name: field.name,
view: new DataFrameView(frame),
rowIndex: dataIdx,
colIndex: originFieldIndex.fieldIndex,
field: field.config,
hasLinks,
});
if (linksSupplier) {
items.push({
items: linksSupplier.getLinks(replaceVariables).map<MenuItemProps>((link) => {
return {
label: link.title,
ariaLabel: link.title,
url: link.href,
target: link.target,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
onClick: link.onClick,
};
}),
if (hasLinks) {
const linksSupplier = getFieldLinksSupplier({
display: displayValue,
name: field.name,
view: new DataFrameView(data),
rowIndex: dataIdx,
colIndex: seriesIdx,
field: field.config,
hasLinks,
});
}
}
// eslint-disable-next-line react/display-name
renderHeader = () => (
<GraphContextMenuHeader
timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
displayValue={displayValue}
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, frame)}
/>
);
if (linksSupplier) {
items.push({
items: linksSupplier.getLinks(replaceVariables).map<MenuItemProps>((link) => {
return {
label: link.title,
ariaLabel: link.title,
url: link.href,
target: link.target,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
onClick: link.onClick,
};
}),
});
}
}
// eslint-disable-next-line react/display-name
renderHeader = () => (
<GraphContextMenuHeader
timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
displayValue={displayValue}
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, data)}
/>
);
}
}
const renderMenuGroupItems = () => {

View File

@ -6,26 +6,26 @@ import {
TIME_SERIES_TIME_FIELD_NAME,
TIME_SERIES_VALUE_FIELD_NAME,
} 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 { ExemplarMarker } from './ExemplarMarker';
interface ExemplarsPluginProps {
config: UPlotConfigBuilder;
exemplars: DataFrame[];
timeZone: TimeZone;
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 mapExemplarToXYCoords = useCallback(
(dataFrame: DataFrame, index: number) => {
const plotInstance = plotCtx.getPlotInstance();
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);
if (!time || !value || !plotCtx.isPlotReady || !plotInstance) {
if (!time || !value || !plotInstance) {
return undefined;
}
@ -63,6 +63,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
return (
<EventsCanvas
config={config}
id="exemplars"
events={exemplars}
renderEventMarker={renderMarker}

View File

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