mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
7501a2deb6
commit
a54ac510c4
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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] = [];
|
||||
}
|
||||
|
@ -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: () => {
|
||||
|
@ -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[] = [];
|
||||
|
@ -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,
|
||||
// }
|
||||
// `);
|
||||
// });
|
||||
// });
|
||||
// });
|
@ -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;
|
||||
}
|
@ -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>
|
||||
// );
|
||||
// };
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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;
|
||||
};
|
@ -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,
|
||||
});
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -1,5 +1,2 @@
|
||||
export { ClickPlugin } from './ClickPlugin';
|
||||
export { SelectionPlugin } from './SelectionPlugin';
|
||||
export { ZoomPlugin } from './ZoomPlugin';
|
||||
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
||||
export { TooltipPlugin } from './TooltipPlugin';
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
</>
|
||||
|
@ -66,6 +66,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
return (
|
||||
<BarChart
|
||||
data={data.series}
|
||||
structureRev={data.structureRev}
|
||||
width={width}
|
||||
height={height}
|
||||
onLegendClick={onLegendClick}
|
||||
|
@ -44,6 +44,7 @@ export const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
return (
|
||||
<TimelineChart
|
||||
data={data.series}
|
||||
structureRev={data.structureRev}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
width={width}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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 = () => {
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user