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 React from 'react';
|
||||||
import { AlignedData } from 'uplot';
|
import { AlignedData } from 'uplot';
|
||||||
import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data';
|
import { DataFrame, TimeRange } from '@grafana/data';
|
||||||
import { VizLayout } from '../VizLayout/VizLayout';
|
import { VizLayout } from '../VizLayout/VizLayout';
|
||||||
import { Themeable } from '../../types';
|
import { Themeable } from '../../types';
|
||||||
import { UPlotChart } from '../uPlot/Plot';
|
import { UPlotChart } from '../uPlot/Plot';
|
||||||
@ -9,7 +9,7 @@ import { GraphNGLegendEvent } from '../GraphNG/types';
|
|||||||
import { BarChartOptions } from './types';
|
import { BarChartOptions } from './types';
|
||||||
import { withTheme } from '../../themes';
|
import { withTheme } from '../../themes';
|
||||||
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
|
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
|
||||||
import { preparePlotData } from '../uPlot/utils';
|
import { pluginLog, preparePlotData } from '../uPlot/utils';
|
||||||
import { LegendDisplayMode } from '../VizLegend/models.gen';
|
import { LegendDisplayMode } from '../VizLegend/models.gen';
|
||||||
import { PlotLegend } from '../uPlot/PlotLegend';
|
import { PlotLegend } from '../uPlot/PlotLegend';
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ export interface BarChartProps extends Themeable, BarChartOptions {
|
|||||||
height: number;
|
height: number;
|
||||||
width: number;
|
width: number;
|
||||||
data: DataFrame[];
|
data: DataFrame[];
|
||||||
|
structureRev?: number; // a number that will change when the data[] structure changes
|
||||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||||
onSeriesColorChange?: (label: string, color: string) => void;
|
onSeriesColorChange?: (label: string, color: string) => void;
|
||||||
}
|
}
|
||||||
@ -33,40 +34,24 @@ interface BarChartState {
|
|||||||
class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
|
class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
|
||||||
constructor(props: BarChartProps) {
|
constructor(props: BarChartProps) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = {} as BarChartState;
|
const alignedDataFrame = preparePlotFrame(props.data);
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) {
|
|
||||||
const frame = preparePlotFrame(props.data);
|
|
||||||
|
|
||||||
if (!frame) {
|
|
||||||
return { ...state };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
data: preparePlotData(frame),
|
|
||||||
alignedDataFrame: frame,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { alignedDataFrame } = this.state;
|
|
||||||
|
|
||||||
if (!alignedDataFrame) {
|
if (!alignedDataFrame) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const data = preparePlotData(alignedDataFrame);
|
||||||
this.setState({
|
const config = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props);
|
||||||
config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
|
this.state = {
|
||||||
});
|
alignedDataFrame,
|
||||||
|
data,
|
||||||
|
config,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: BarChartProps) {
|
componentDidUpdate(prevProps: BarChartProps) {
|
||||||
const { data, orientation, groupWidth, barWidth, showValue } = this.props;
|
const { data, orientation, groupWidth, barWidth, showValue, structureRev } = this.props;
|
||||||
const { alignedDataFrame } = this.state;
|
const { alignedDataFrame } = this.state;
|
||||||
let shouldConfigUpdate = false;
|
let shouldConfigUpdate = false;
|
||||||
let hasStructureChanged = false;
|
let stateUpdate = {} as BarChartState;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.state.config === undefined ||
|
this.state.config === undefined ||
|
||||||
@ -78,17 +63,26 @@ class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
|
|||||||
shouldConfigUpdate = true;
|
shouldConfigUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data !== prevProps.data) {
|
if (data !== prevProps.data || shouldConfigUpdate) {
|
||||||
if (!alignedDataFrame) {
|
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
|
||||||
|
const alignedData = preparePlotFrame(data);
|
||||||
|
|
||||||
|
if (!alignedData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures);
|
stateUpdate = {
|
||||||
|
alignedDataFrame: alignedData,
|
||||||
|
data: preparePlotData(alignedData),
|
||||||
|
};
|
||||||
|
if (shouldConfigUpdate || hasStructureChanged) {
|
||||||
|
pluginLog('BarChart', false, 'updating config');
|
||||||
|
const builder = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props);
|
||||||
|
stateUpdate = { ...stateUpdate, config: builder };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldConfigUpdate || hasStructureChanged) {
|
if (Object.keys(stateUpdate).length > 0) {
|
||||||
this.setState({
|
this.setState(stateUpdate);
|
||||||
config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,21 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { AlignedData } from 'uplot';
|
import { AlignedData } from 'uplot';
|
||||||
import {
|
import { DataFrame, FieldMatcherID, fieldMatchers, TimeRange, TimeZone } from '@grafana/data';
|
||||||
DataFrame,
|
|
||||||
DataFrameFieldIndex,
|
|
||||||
FieldMatcherID,
|
|
||||||
fieldMatchers,
|
|
||||||
FieldType,
|
|
||||||
TimeRange,
|
|
||||||
TimeZone,
|
|
||||||
} from '@grafana/data';
|
|
||||||
import { withTheme } from '../../themes';
|
import { withTheme } from '../../themes';
|
||||||
import { Themeable } from '../../types';
|
import { Themeable } from '../../types';
|
||||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||||
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
|
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
|
||||||
import { GraphNGContext } from './hooks';
|
|
||||||
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
|
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
|
||||||
import { preparePlotData } from '../uPlot/utils';
|
import { pluginLog, preparePlotData } from '../uPlot/utils';
|
||||||
import { PlotLegend } from '../uPlot/PlotLegend';
|
import { PlotLegend } from '../uPlot/PlotLegend';
|
||||||
import { UPlotChart } from '../uPlot/Plot';
|
import { UPlotChart } from '../uPlot/Plot';
|
||||||
import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen';
|
import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen';
|
||||||
@ -37,89 +28,41 @@ export interface GraphNGProps extends Themeable {
|
|||||||
fields?: XYFieldMatchers; // default will assume timeseries data
|
fields?: XYFieldMatchers; // default will assume timeseries data
|
||||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||||
onSeriesColorChange?: (label: string, color: string) => void;
|
onSeriesColorChange?: (label: string, color: string) => void;
|
||||||
children?: React.ReactNode;
|
children?: (builder: UPlotConfigBuilder, alignedDataFrame: DataFrame) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal -- not a public API
|
* @internal -- not a public API
|
||||||
*/
|
*/
|
||||||
export interface GraphNGState {
|
export interface GraphNGState {
|
||||||
data: AlignedData;
|
|
||||||
alignedDataFrame: DataFrame;
|
alignedDataFrame: DataFrame;
|
||||||
dimFields: XYFieldMatchers;
|
data: AlignedData;
|
||||||
seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[];
|
|
||||||
config?: UPlotConfigBuilder;
|
config?: UPlotConfigBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
||||||
constructor(props: GraphNGProps) {
|
constructor(props: GraphNGProps) {
|
||||||
super(props);
|
super(props);
|
||||||
let dimFields = props.fields;
|
|
||||||
|
|
||||||
if (!dimFields) {
|
pluginLog('GraphNG', false, 'constructor, data aligment');
|
||||||
dimFields = {
|
const alignedData = preparePlotFrame(props.data, {
|
||||||
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
||||||
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
||||||
};
|
});
|
||||||
}
|
|
||||||
this.state = { dimFields } as GraphNGState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
if (!alignedData) {
|
||||||
* Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
|
|
||||||
* It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume
|
|
||||||
* that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32)
|
|
||||||
*
|
|
||||||
* Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle.
|
|
||||||
* If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update
|
|
||||||
*
|
|
||||||
* This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
|
|
||||||
* the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that
|
|
||||||
* often (apart from the edit mode interactions) this should be a fair performance compromise.
|
|
||||||
*/
|
|
||||||
static getDerivedStateFromProps(props: GraphNGProps, state: GraphNGState) {
|
|
||||||
let dimFields = props.fields;
|
|
||||||
|
|
||||||
if (!dimFields) {
|
|
||||||
dimFields = {
|
|
||||||
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
|
||||||
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const frame = preparePlotFrame(props.data, dimFields);
|
|
||||||
|
|
||||||
if (!frame) {
|
|
||||||
return { ...state, dimFields };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
data: preparePlotData(frame, [FieldType.number]),
|
|
||||||
alignedDataFrame: frame,
|
|
||||||
seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
|
|
||||||
dimFields,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { theme } = this.props;
|
|
||||||
|
|
||||||
// alignedDataFrame is already prepared by getDerivedStateFromProps method
|
|
||||||
const { alignedDataFrame } = this.state;
|
|
||||||
|
|
||||||
if (!alignedDataFrame) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.state = {
|
||||||
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone),
|
alignedDataFrame: alignedData,
|
||||||
});
|
data: preparePlotData(alignedData),
|
||||||
|
config: preparePlotConfigBuilder(alignedData, props.theme, this.getTimeRange, this.getTimeZone),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: GraphNGProps) {
|
componentDidUpdate(prevProps: GraphNGProps) {
|
||||||
const { data, theme, structureRev } = this.props;
|
const { theme, structureRev, data } = this.props;
|
||||||
const { alignedDataFrame } = this.state;
|
|
||||||
let shouldConfigUpdate = false;
|
let shouldConfigUpdate = false;
|
||||||
let stateUpdate = {} as GraphNGState;
|
let stateUpdate = {} as GraphNGState;
|
||||||
|
|
||||||
@ -128,13 +71,31 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data !== prevProps.data) {
|
if (data !== prevProps.data) {
|
||||||
if (!alignedDataFrame) {
|
pluginLog('GraphNG', false, 'data changed');
|
||||||
|
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
|
||||||
|
|
||||||
|
if (hasStructureChanged) {
|
||||||
|
pluginLog('GraphNG', false, 'schema changed');
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginLog('GraphNG', false, 'componentDidUpdate, data aligment');
|
||||||
|
const alignedData = preparePlotFrame(data, {
|
||||||
|
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
||||||
|
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!alignedData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
|
stateUpdate = {
|
||||||
|
alignedDataFrame: alignedData,
|
||||||
|
data: preparePlotData(alignedData),
|
||||||
|
};
|
||||||
|
|
||||||
if (shouldConfigUpdate || hasStructureChanged) {
|
if (shouldConfigUpdate || hasStructureChanged) {
|
||||||
const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone);
|
pluginLog('GraphNG', false, 'updating config');
|
||||||
|
const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone);
|
||||||
stateUpdate = { ...stateUpdate, config: builder };
|
stateUpdate = { ...stateUpdate, config: builder };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,10 +105,6 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
|
|
||||||
return this.state.seriesToDataFrameFieldIndexMap[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
getTimeRange = () => {
|
getTimeRange = () => {
|
||||||
return this.props.timeRange;
|
return this.props.timeRange;
|
||||||
};
|
};
|
||||||
@ -178,35 +135,25 @@ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props;
|
const { width, height, children, timeRange } = this.props;
|
||||||
|
const { config, alignedDataFrame } = this.state;
|
||||||
if (!this.state.data || !this.state.config) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GraphNGContext.Provider
|
<VizLayout width={width} height={height} legend={this.renderLegend()}>
|
||||||
value={{
|
{(vizWidth: number, vizHeight: number) => (
|
||||||
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
|
<UPlotChart
|
||||||
dimFields: this.state.dimFields,
|
config={this.state.config!}
|
||||||
data: this.state.alignedDataFrame,
|
data={this.state.data}
|
||||||
}}
|
width={vizWidth}
|
||||||
>
|
height={vizHeight}
|
||||||
<VizLayout width={width} height={height} legend={this.renderLegend()}>
|
timeRange={timeRange}
|
||||||
{(vizWidth: number, vizHeight: number) => (
|
>
|
||||||
<UPlotChart
|
{children ? children(config, alignedDataFrame) : null}
|
||||||
{...plotProps}
|
</UPlotChart>
|
||||||
config={this.state.config!}
|
)}
|
||||||
data={this.state.data}
|
</VizLayout>
|
||||||
width={vizWidth}
|
|
||||||
height={vizHeight}
|
|
||||||
timeRange={timeRange}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</UPlotChart>
|
|
||||||
)}
|
|
||||||
</VizLayout>
|
|
||||||
</GraphNGContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { compareArrayValues, compareDataFrameStructures, FieldMatcherID, fieldMatchers } from '@grafana/data';
|
import { FieldMatcherID, fieldMatchers } from '@grafana/data';
|
||||||
import { withTheme } from '../../themes';
|
import { withTheme } from '../../themes';
|
||||||
import { GraphNGContext } from '../GraphNG/hooks';
|
|
||||||
import { GraphNGState } from '../GraphNG/GraphNG';
|
import { GraphNGState } from '../GraphNG/GraphNG';
|
||||||
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG
|
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG
|
||||||
import { preparePlotData } from '../uPlot/utils';
|
import { pluginLog, preparePlotData } from '../uPlot/utils';
|
||||||
import { PlotLegend } from '../uPlot/PlotLegend';
|
import { PlotLegend } from '../uPlot/PlotLegend';
|
||||||
import { UPlotChart } from '../uPlot/Plot';
|
import { UPlotChart } from '../uPlot/Plot';
|
||||||
import { LegendDisplayMode } from '../VizLegend/models.gen';
|
import { LegendDisplayMode } from '../VizLegend/models.gen';
|
||||||
@ -14,77 +13,35 @@ import { TimelineProps } from './types';
|
|||||||
class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState> {
|
class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState> {
|
||||||
constructor(props: TimelineProps) {
|
constructor(props: TimelineProps) {
|
||||||
super(props);
|
super(props);
|
||||||
let dimFields = props.fields;
|
const { theme, mode, rowHeight, colWidth, showValue } = props;
|
||||||
|
|
||||||
if (!dimFields) {
|
pluginLog('TimelineChart', false, 'constructor, data aligment');
|
||||||
dimFields = {
|
const alignedData = preparePlotFrame(
|
||||||
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
props.data,
|
||||||
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), // this may be either numeric or strings, (or bools?)
|
props.fields || {
|
||||||
};
|
|
||||||
}
|
|
||||||
this.state = { dimFields } as GraphNGState;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
|
|
||||||
* It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume
|
|
||||||
* that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32)
|
|
||||||
*
|
|
||||||
* Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle.
|
|
||||||
* If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update
|
|
||||||
*
|
|
||||||
* This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
|
|
||||||
* the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that
|
|
||||||
* often (apart from the edit mode interactions) this should be a fair performance compromise.
|
|
||||||
*/
|
|
||||||
static getDerivedStateFromProps(props: TimelineProps, state: GraphNGState) {
|
|
||||||
let dimFields = props.fields;
|
|
||||||
|
|
||||||
if (!dimFields) {
|
|
||||||
dimFields = {
|
|
||||||
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
||||||
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
||||||
};
|
}
|
||||||
}
|
);
|
||||||
|
|
||||||
const frame = preparePlotFrame(props.data, dimFields);
|
if (!alignedData) {
|
||||||
|
|
||||||
if (!frame) {
|
|
||||||
return { ...state, dimFields };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
data: preparePlotData(frame),
|
|
||||||
alignedDataFrame: frame,
|
|
||||||
seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
|
|
||||||
dimFields,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const { theme, mode, rowHeight, colWidth, showValue } = this.props;
|
|
||||||
|
|
||||||
// alignedDataFrame is already prepared by getDerivedStateFromProps method
|
|
||||||
const { alignedDataFrame } = this.state;
|
|
||||||
|
|
||||||
if (!alignedDataFrame) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({
|
this.state = {
|
||||||
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, {
|
alignedDataFrame: alignedData,
|
||||||
|
data: preparePlotData(alignedData),
|
||||||
|
config: preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
|
||||||
mode,
|
mode,
|
||||||
rowHeight,
|
rowHeight,
|
||||||
colWidth,
|
colWidth,
|
||||||
showValue,
|
showValue,
|
||||||
}),
|
}),
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps: TimelineProps) {
|
componentDidUpdate(prevProps: TimelineProps) {
|
||||||
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue } = this.props;
|
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue, structureRev } = this.props;
|
||||||
const { alignedDataFrame } = this.state;
|
|
||||||
let shouldConfigUpdate = false;
|
let shouldConfigUpdate = false;
|
||||||
let stateUpdate = {} as GraphNGState;
|
let stateUpdate = {} as GraphNGState;
|
||||||
|
|
||||||
@ -99,35 +56,41 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
|
|||||||
shouldConfigUpdate = true;
|
shouldConfigUpdate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data !== prevProps.data) {
|
if (data !== prevProps.data || shouldConfigUpdate) {
|
||||||
if (!alignedDataFrame) {
|
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
|
||||||
|
|
||||||
|
const alignedData = preparePlotFrame(
|
||||||
|
data,
|
||||||
|
this.props.fields || {
|
||||||
|
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
||||||
|
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!alignedData) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!compareArrayValues(data, prevProps.data, compareDataFrameStructures)) {
|
stateUpdate = {
|
||||||
shouldConfigUpdate = true;
|
alignedDataFrame: alignedData,
|
||||||
|
data: preparePlotData(alignedData),
|
||||||
|
};
|
||||||
|
if (shouldConfigUpdate || hasStructureChanged) {
|
||||||
|
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) {
|
if (Object.keys(stateUpdate).length > 0) {
|
||||||
this.setState(stateUpdate);
|
this.setState(stateUpdate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
|
|
||||||
return this.state.seriesToDataFrameFieldIndexMap[i];
|
|
||||||
};
|
|
||||||
|
|
||||||
getTimeRange = () => {
|
getTimeRange = () => {
|
||||||
return this.props.timeRange;
|
return this.props.timeRange;
|
||||||
};
|
};
|
||||||
@ -158,35 +121,27 @@ class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState>
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props;
|
const { width, height, children, timeRange } = this.props;
|
||||||
|
const { config, alignedDataFrame } = this.state;
|
||||||
|
|
||||||
if (!this.state.data || !this.state.config) {
|
if (!config) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GraphNGContext.Provider
|
<VizLayout width={width} height={height}>
|
||||||
value={{
|
{(vizWidth: number, vizHeight: number) => (
|
||||||
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
|
<UPlotChart
|
||||||
dimFields: this.state.dimFields,
|
config={this.state.config!}
|
||||||
data: this.state.alignedDataFrame,
|
data={this.state.data}
|
||||||
}}
|
width={vizWidth}
|
||||||
>
|
height={vizHeight}
|
||||||
<VizLayout width={width} height={height}>
|
timeRange={timeRange}
|
||||||
{(vizWidth: number, vizHeight: number) => (
|
>
|
||||||
<UPlotChart
|
{children ? children(config, alignedDataFrame) : null}
|
||||||
{...plotProps}
|
</UPlotChart>
|
||||||
config={this.state.config!}
|
)}
|
||||||
data={this.state.data}
|
</VizLayout>
|
||||||
width={vizWidth}
|
|
||||||
height={vizHeight}
|
|
||||||
timeRange={timeRange}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</UPlotChart>
|
|
||||||
)}
|
|
||||||
</VizLayout>
|
|
||||||
</GraphNGContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -224,13 +224,14 @@ export { LegacyForms, LegacyInputStatus };
|
|||||||
|
|
||||||
// WIP, need renames and exports cleanup
|
// WIP, need renames and exports cleanup
|
||||||
export * from './uPlot/config';
|
export * from './uPlot/config';
|
||||||
|
export { UPlotConfigBuilder } from './uPlot/config/UPlotConfigBuilder';
|
||||||
export { UPlotChart } from './uPlot/Plot';
|
export { UPlotChart } from './uPlot/Plot';
|
||||||
export * from './uPlot/geometries';
|
export * from './uPlot/geometries';
|
||||||
export * from './uPlot/plugins';
|
export * from './uPlot/plugins';
|
||||||
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
|
export { usePlotContext } from './uPlot/context';
|
||||||
export { usePlotContext, usePlotPluginContext } from './uPlot/context';
|
|
||||||
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
|
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
|
||||||
export { useGraphNGContext } from './GraphNG/hooks';
|
export { useGraphNGContext } from './GraphNG/hooks';
|
||||||
|
export { preparePlotFrame } from './GraphNG/utils';
|
||||||
export { BarChart } from './BarChart/BarChart';
|
export { BarChart } from './BarChart/BarChart';
|
||||||
export { TimelineChart } from './Timeline/TimelineChart';
|
export { TimelineChart } from './Timeline/TimelineChart';
|
||||||
export { BarChartOptions, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
|
export { BarChartOptions, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { UPlotChart } from './Plot';
|
import { UPlotChart } from './Plot';
|
||||||
import { act, render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
|
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
|
||||||
import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
|
import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
|
||||||
import uPlot from 'uplot';
|
import uPlot from 'uplot';
|
||||||
@ -83,11 +83,6 @@ describe('UPlotChart', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// we wait 1 frame for plugins initialisation logic to finish
|
|
||||||
act(() => {
|
|
||||||
mockRaf.step({ count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uPlot).toBeCalledTimes(1);
|
expect(uPlot).toBeCalledTimes(1);
|
||||||
unmount();
|
unmount();
|
||||||
expect(destroyMock).toBeCalledTimes(1);
|
expect(destroyMock).toBeCalledTimes(1);
|
||||||
@ -107,11 +102,6 @@ describe('UPlotChart', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// we wait 1 frame for plugins initialisation logic to finish
|
|
||||||
act(() => {
|
|
||||||
mockRaf.step({ count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uPlot).toBeCalledTimes(1);
|
expect(uPlot).toBeCalledTimes(1);
|
||||||
|
|
||||||
data.fields[1].values.set(0, 1);
|
data.fields[1].values.set(0, 1);
|
||||||
@ -154,11 +144,6 @@ describe('UPlotChart', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// we wait 1 frame for plugins initialisation logic to finish
|
|
||||||
act(() => {
|
|
||||||
mockRaf.step({ count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(uPlot).toBeCalledTimes(1);
|
expect(uPlot).toBeCalledTimes(1);
|
||||||
|
|
||||||
const nextConfig = new UPlotConfigBuilder();
|
const nextConfig = new UPlotConfigBuilder();
|
||||||
@ -186,16 +171,10 @@ describe('UPlotChart', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// we wait 1 frame for plugins initialisation logic to finish
|
// we wait 1 frame for plugins initialisation logic to finish
|
||||||
act(() => {
|
|
||||||
mockRaf.step({ count: 1 });
|
|
||||||
});
|
|
||||||
const nextConfig = new UPlotConfigBuilder();
|
|
||||||
nextConfig.addSeries({} as SeriesProps);
|
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<UPlotChart
|
<UPlotChart
|
||||||
data={preparePlotData(data)} // frame
|
data={preparePlotData(data)} // frame
|
||||||
config={nextConfig}
|
config={config}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
width={200}
|
width={200}
|
||||||
height={200}
|
height={200}
|
||||||
@ -206,68 +185,5 @@ describe('UPlotChart', () => {
|
|||||||
expect(uPlot).toBeCalledTimes(1);
|
expect(uPlot).toBeCalledTimes(1);
|
||||||
expect(setSizeMock).toBeCalledTimes(1);
|
expect(setSizeMock).toBeCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not initialize plot when config and data are not in sync', () => {
|
|
||||||
const { data, timeRange, config } = mockData();
|
|
||||||
|
|
||||||
// 1 series in data, 2 series in config
|
|
||||||
config.addSeries({} as SeriesProps);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<UPlotChart
|
|
||||||
data={preparePlotData(data)} // frame
|
|
||||||
config={config}
|
|
||||||
timeRange={timeRange}
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// we wait 1 frame for plugins initialisation logic to finish
|
|
||||||
act(() => {
|
|
||||||
mockRaf.step({ count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(destroyMock).toBeCalledTimes(0);
|
|
||||||
expect(uPlot).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reinitialize plot when config and data are not in sync', () => {
|
|
||||||
const { data, timeRange, config } = mockData();
|
|
||||||
|
|
||||||
// 1 series in data, 1 series in config
|
|
||||||
const { rerender } = render(
|
|
||||||
<UPlotChart
|
|
||||||
data={preparePlotData(data)} // frame
|
|
||||||
config={config}
|
|
||||||
timeRange={timeRange}
|
|
||||||
width={100}
|
|
||||||
height={100}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// we wait 1 frame for plugins initialisation logic to finish
|
|
||||||
act(() => {
|
|
||||||
mockRaf.step({ count: 1 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextConfig = new UPlotConfigBuilder();
|
|
||||||
nextConfig.addSeries({} as SeriesProps);
|
|
||||||
nextConfig.addSeries({} as SeriesProps);
|
|
||||||
|
|
||||||
// 1 series in data, 2 series in config
|
|
||||||
rerender(
|
|
||||||
<UPlotChart
|
|
||||||
data={preparePlotData(data)} // frame
|
|
||||||
config={nextConfig}
|
|
||||||
timeRange={timeRange}
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(destroyMock).toBeCalledTimes(0);
|
|
||||||
expect(uPlot).toBeCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
||||||
import uPlot, { AlignedData, Options } from 'uplot';
|
import uPlot, { AlignedData, Options } from 'uplot';
|
||||||
import { buildPlotContext, PlotContext } from './context';
|
import { buildPlotContext, PlotContext } from './context';
|
||||||
import { pluginLog } from './utils';
|
import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils';
|
||||||
import { usePlotConfig } from './hooks';
|
|
||||||
import { PlotProps } from './types';
|
import { PlotProps } from './types';
|
||||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
|
||||||
import usePrevious from 'react-use/lib/usePrevious';
|
import usePrevious from 'react-use/lib/usePrevious';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -16,67 +14,71 @@ import usePrevious from 'react-use/lib/usePrevious';
|
|||||||
export const UPlotChart: React.FC<PlotProps> = (props) => {
|
export const UPlotChart: React.FC<PlotProps> = (props) => {
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const plotInstance = useRef<uPlot>();
|
const plotInstance = useRef<uPlot>();
|
||||||
const [isPlotReady, setIsPlotReady] = useState(false);
|
|
||||||
const prevProps = usePrevious(props);
|
const prevProps = usePrevious(props);
|
||||||
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config);
|
|
||||||
|
const config = useMemo(() => {
|
||||||
|
return {
|
||||||
|
...DEFAULT_PLOT_CONFIG,
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
ms: 1,
|
||||||
|
...props.config.getConfig(),
|
||||||
|
} as uPlot.Options;
|
||||||
|
}, [props.config]);
|
||||||
|
|
||||||
const getPlotInstance = useCallback(() => {
|
const getPlotInstance = useCallback(() => {
|
||||||
return plotInstance.current;
|
return plotInstance.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
// 0. Exit early if the component is not ready to initialize uPlot
|
if (!plotInstance.current || props.width === 0 || props.height === 0) {
|
||||||
if (!currentConfig.current || !canvasRef.current || props.width === 0 || props.height === 0) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0. Exit if the data set length is different than number of series expected to render
|
pluginLog('uPlot core', false, 'updating size');
|
||||||
// This may happen when GraphNG has not synced config yet with the aligned frame. Alignment happens before the render
|
plotInstance.current!.setSize({
|
||||||
// in the getDerivedStateFromProps, while the config creation happens in componentDidUpdate, causing one more render
|
width: props.width,
|
||||||
// of the UPlotChart if the config needs to be updated.
|
height: props.height,
|
||||||
if (currentConfig.current.series.length !== props.data.length) {
|
});
|
||||||
|
}, [props.width, props.height]);
|
||||||
|
|
||||||
|
// Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
// 0. Exit early if the component is not ready to initialize uPlot
|
||||||
|
if (!canvasRef.current || props.width === 0 || props.height === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
|
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
|
||||||
if (isConfigReady && !plotInstance.current) {
|
if (!plotInstance.current || !prevProps) {
|
||||||
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
|
plotInstance.current = initializePlot(props.data, config, canvasRef.current);
|
||||||
setIsPlotReady(true);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. When dimensions have changed, update uPlot size and return
|
// 2. Reinitialize uPlot if config changed
|
||||||
if (currentConfig.current.width !== prevProps?.width || currentConfig.current.height !== prevProps?.height) {
|
if (props.config !== prevProps.config) {
|
||||||
pluginLog('uPlot core', false, 'updating size');
|
|
||||||
plotInstance.current!.setSize({
|
|
||||||
width: currentConfig.current.width,
|
|
||||||
height: currentConfig.current?.height,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. When config has changed re-initialize plot
|
|
||||||
if (isConfigReady && props.config !== prevProps.config) {
|
|
||||||
if (plotInstance.current) {
|
if (plotInstance.current) {
|
||||||
pluginLog('uPlot core', false, 'destroying instance');
|
pluginLog('uPlot core', false, 'destroying instance');
|
||||||
plotInstance.current.destroy();
|
plotInstance.current.destroy();
|
||||||
}
|
}
|
||||||
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
|
plotInstance.current = initializePlot(props.data, config, canvasRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Otherwise, assume only data has changed and update uPlot data
|
// 3. Otherwise, assume only data has changed and update uPlot data
|
||||||
updateData(props.config, props.data, plotInstance.current);
|
if (props.data !== prevProps.data) {
|
||||||
}, [props, isConfigReady]);
|
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', props.data);
|
||||||
|
plotInstance.current.setData(props.data);
|
||||||
|
}
|
||||||
|
}, [props, config]);
|
||||||
|
|
||||||
// When component unmounts, clean the existing uPlot instance
|
// When component unmounts, clean the existing uPlot instance
|
||||||
useEffect(() => () => plotInstance.current?.destroy(), []);
|
useEffect(() => () => plotInstance.current?.destroy(), []);
|
||||||
|
|
||||||
// Memoize plot context
|
// Memoize plot context
|
||||||
const plotCtx = useMemo(() => {
|
const plotCtx = useMemo(() => {
|
||||||
return buildPlotContext(isPlotReady, canvasRef, props.data, registerPlugin, getPlotInstance);
|
return buildPlotContext(canvasRef, props.data, getPlotInstance);
|
||||||
}, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance, isPlotReady]);
|
}, [plotInstance, canvasRef, props.data, getPlotInstance]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PlotContext.Provider value={plotCtx}>
|
<PlotContext.Provider value={plotCtx}>
|
||||||
@ -92,11 +94,3 @@ function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement)
|
|||||||
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
|
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
|
||||||
return new uPlot(config, data, el);
|
return new uPlot(config, data, el);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) {
|
|
||||||
if (!plotInstance || !data) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', data);
|
|
||||||
plotInstance.setData(data);
|
|
||||||
}
|
|
||||||
|
@ -6,8 +6,7 @@ import { AxisPlacement } from '../config';
|
|||||||
import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
|
import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
|
||||||
import { defaultsDeep } from 'lodash';
|
import { defaultsDeep } from 'lodash';
|
||||||
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
|
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
|
||||||
|
import { pluginLog } from '../utils';
|
||||||
type valueof<T> = T[keyof T];
|
|
||||||
|
|
||||||
export class UPlotConfigBuilder {
|
export class UPlotConfigBuilder {
|
||||||
private series: UPlotSeriesBuilder[] = [];
|
private series: UPlotSeriesBuilder[] = [];
|
||||||
@ -27,7 +26,9 @@ export class UPlotConfigBuilder {
|
|||||||
this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName;
|
this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName;
|
||||||
}
|
}
|
||||||
|
|
||||||
addHook(type: keyof Hooks.Defs, hook: valueof<Hooks.Defs>) {
|
addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]) {
|
||||||
|
pluginLog('UPlotConfigBuilder', false, 'addHook', type);
|
||||||
|
|
||||||
if (!this.hooks[type]) {
|
if (!this.hooks[type]) {
|
||||||
this.hooks[type] = [];
|
this.hooks[type] = [];
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
import uPlot, { AlignedData, Series } from 'uplot';
|
import uPlot, { AlignedData, Series } from 'uplot';
|
||||||
import { PlotPlugin } from './types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @alpha
|
* @alpha
|
||||||
@ -18,15 +17,7 @@ interface PlotCanvasContextType {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface PlotContextType {
|
||||||
* @alpha
|
|
||||||
*/
|
|
||||||
interface PlotPluginsContextType {
|
|
||||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlotContextType extends PlotPluginsContextType {
|
|
||||||
isPlotReady: boolean;
|
|
||||||
getPlotInstance: () => uPlot | undefined;
|
getPlotInstance: () => uPlot | undefined;
|
||||||
getSeries: () => Series[];
|
getSeries: () => Series[];
|
||||||
getCanvas: () => PlotCanvasContextType;
|
getCanvas: () => PlotCanvasContextType;
|
||||||
@ -44,40 +35,17 @@ export const usePlotContext = (): PlotContextType => {
|
|||||||
return useContext<PlotContextType>(PlotContext);
|
return useContext<PlotContextType>(PlotContext);
|
||||||
};
|
};
|
||||||
|
|
||||||
const throwWhenNoContext = (name: string) => {
|
|
||||||
throw new Error(`${name} must be used within PlotContext or PlotContext is not ready yet!`);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes API for registering uPlot plugins
|
|
||||||
*
|
|
||||||
* @alpha
|
|
||||||
*/
|
|
||||||
export const usePlotPluginContext = (): PlotPluginsContextType => {
|
|
||||||
const ctx = useContext(PlotContext);
|
|
||||||
if (Object.keys(ctx).length === 0) {
|
|
||||||
throwWhenNoContext('usePlotPluginContext');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
registerPlugin: ctx!.registerPlugin,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @alpha
|
* @alpha
|
||||||
*/
|
*/
|
||||||
export const buildPlotContext = (
|
export const buildPlotContext = (
|
||||||
isPlotReady: boolean,
|
|
||||||
canvasRef: any,
|
canvasRef: any,
|
||||||
data: AlignedData,
|
data: AlignedData,
|
||||||
registerPlugin: any,
|
|
||||||
getPlotInstance: () => uPlot | undefined
|
getPlotInstance: () => uPlot | undefined
|
||||||
): PlotContextType => {
|
): PlotContextType => {
|
||||||
return {
|
return {
|
||||||
isPlotReady,
|
|
||||||
canvasRef,
|
canvasRef,
|
||||||
data,
|
data,
|
||||||
registerPlugin,
|
|
||||||
getPlotInstance,
|
getPlotInstance,
|
||||||
getSeries: () => getPlotInstance()!.series,
|
getSeries: () => getPlotInstance()!.series,
|
||||||
getCanvas: () => {
|
getCanvas: () => {
|
||||||
|
@ -1,20 +1,29 @@
|
|||||||
import { DataFrame } from '@grafana/data';
|
import { DataFrame } from '@grafana/data';
|
||||||
import React, { useMemo } from 'react';
|
import React, { useLayoutEffect, useMemo, useState } from 'react';
|
||||||
import { usePlotContext } from '../context';
|
import { usePlotContext } from '../context';
|
||||||
import { useRefreshAfterGraphRendered } from '../hooks';
|
|
||||||
import { Marker } from './Marker';
|
import { Marker } from './Marker';
|
||||||
import { XYCanvas } from './XYCanvas';
|
import { XYCanvas } from './XYCanvas';
|
||||||
|
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||||
|
|
||||||
interface EventsCanvasProps {
|
interface EventsCanvasProps {
|
||||||
id: string;
|
id: string;
|
||||||
|
config: UPlotConfigBuilder;
|
||||||
events: DataFrame[];
|
events: DataFrame[];
|
||||||
renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode;
|
renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode;
|
||||||
mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined;
|
mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) {
|
export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) {
|
||||||
const plotCtx = usePlotContext();
|
const plotCtx = usePlotContext();
|
||||||
const renderToken = useRefreshAfterGraphRendered(id);
|
// render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change
|
||||||
|
// so we need to force the re-render when the draw hook was performed by uPlot
|
||||||
|
const [renderToken, setRenderToken] = useState(0);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
config.addHook('draw', () => {
|
||||||
|
setRenderToken((s) => s + 1);
|
||||||
|
});
|
||||||
|
}, [config, setRenderToken]);
|
||||||
|
|
||||||
const eventMarkers = useMemo(() => {
|
const eventMarkers = useMemo(() => {
|
||||||
const markers: React.ReactNode[] = [];
|
const markers: React.ReactNode[] = [];
|
||||||
|
@ -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 React, { useRef } from 'react';
|
||||||
import { SelectionPlugin } from './SelectionPlugin';
|
// import { SelectionPlugin } from './SelectionPlugin';
|
||||||
import { css } from '@emotion/css';
|
// import { css } from '@emotion/css';
|
||||||
import { Button } from '../../Button';
|
// import { Button } from '../../Button';
|
||||||
import useClickAway from 'react-use/lib/useClickAway';
|
// import useClickAway from 'react-use/lib/useClickAway';
|
||||||
|
//
|
||||||
interface AnnotationsEditorPluginProps {
|
// interface AnnotationsEditorPluginProps {
|
||||||
onAnnotationCreate: () => void;
|
// onAnnotationCreate: () => void;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
/**
|
// /**
|
||||||
* @alpha
|
// * @alpha
|
||||||
*/
|
// */
|
||||||
export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
|
// export const AnnotationsEditorPlugin: React.FC<AnnotationsEditorPluginProps> = ({ onAnnotationCreate }) => {
|
||||||
const pluginId = 'AnnotationsEditorPlugin';
|
// const pluginId = 'AnnotationsEditorPlugin';
|
||||||
|
//
|
||||||
return (
|
// return (
|
||||||
<SelectionPlugin
|
// <SelectionPlugin
|
||||||
id={pluginId}
|
// id={pluginId}
|
||||||
onSelect={(selection) => {
|
// onSelect={(selection) => {
|
||||||
console.log(selection);
|
// console.log(selection);
|
||||||
}}
|
// }}
|
||||||
lazy
|
// lazy
|
||||||
>
|
// >
|
||||||
{({ selection, clearSelection }) => {
|
// {({ selection, clearSelection }) => {
|
||||||
return <AnnotationEditor selection={selection} onClose={clearSelection} />;
|
// return <AnnotationEditor selection={selection} onClose={clearSelection} />;
|
||||||
}}
|
// }}
|
||||||
</SelectionPlugin>
|
// </SelectionPlugin>
|
||||||
);
|
// );
|
||||||
};
|
// };
|
||||||
|
//
|
||||||
const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
|
// const AnnotationEditor: React.FC<any> = ({ onClose, selection }) => {
|
||||||
const ref = useRef(null);
|
// const ref = useRef(null);
|
||||||
|
//
|
||||||
useClickAway(ref, () => {
|
// useClickAway(ref, () => {
|
||||||
if (onClose) {
|
// if (onClose) {
|
||||||
onClose();
|
// onClose();
|
||||||
}
|
// }
|
||||||
});
|
// });
|
||||||
|
//
|
||||||
return (
|
// return (
|
||||||
<div>
|
// <div>
|
||||||
<div
|
// <div
|
||||||
ref={ref}
|
// ref={ref}
|
||||||
className={css`
|
// className={css`
|
||||||
position: absolute;
|
// position: absolute;
|
||||||
background: purple;
|
// background: purple;
|
||||||
top: ${selection.bbox.top}px;
|
// top: ${selection.bbox.top}px;
|
||||||
left: ${selection.bbox.left}px;
|
// left: ${selection.bbox.left}px;
|
||||||
width: ${selection.bbox.width}px;
|
// width: ${selection.bbox.width}px;
|
||||||
height: ${selection.bbox.height}px;
|
// height: ${selection.bbox.height}px;
|
||||||
`}
|
// `}
|
||||||
>
|
// >
|
||||||
Annotations editor maybe?
|
// Annotations editor maybe?
|
||||||
<Button onClick={() => {}}>Create annotation</Button>
|
// <Button onClick={() => {}}>Create annotation</Button>
|
||||||
</div>
|
// </div>
|
||||||
</div>
|
// </div>
|
||||||
);
|
// );
|
||||||
};
|
// };
|
||||||
|
@ -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 { Portal } from '../../Portal/Portal';
|
||||||
import { usePlotContext } from '../context';
|
import { usePlotContext } from '../context';
|
||||||
import { CursorPlugin } from './CursorPlugin';
|
|
||||||
import { VizTooltipContainer, SeriesTable, SeriesTableRowProps, TooltipDisplayMode } from '../../VizTooltip';
|
|
||||||
import {
|
import {
|
||||||
|
CartesianCoords2D,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
FieldType,
|
FieldType,
|
||||||
formattedValueToString,
|
formattedValueToString,
|
||||||
@ -11,120 +10,144 @@ import {
|
|||||||
getFieldDisplayName,
|
getFieldDisplayName,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { useGraphNGContext } from '../../GraphNG/hooks';
|
import { SeriesTable, SeriesTableRowProps, TooltipDisplayMode, VizTooltipContainer } from '../../VizTooltip';
|
||||||
|
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||||
|
import { pluginLog } from '../utils';
|
||||||
|
|
||||||
interface TooltipPluginProps {
|
interface TooltipPluginProps {
|
||||||
mode?: TooltipDisplayMode;
|
mode?: TooltipDisplayMode;
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
data: DataFrame[];
|
data: DataFrame;
|
||||||
|
config: UPlotConfigBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @alpha
|
* @alpha
|
||||||
*/
|
*/
|
||||||
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone, ...otherProps }) => {
|
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
|
||||||
const pluginId = 'PlotTooltip';
|
mode = TooltipDisplayMode.Single,
|
||||||
|
timeZone,
|
||||||
|
config,
|
||||||
|
...otherProps
|
||||||
|
}) => {
|
||||||
const plotContext = usePlotContext();
|
const plotContext = usePlotContext();
|
||||||
const graphContext = useGraphNGContext();
|
const plotCanvas = useRef<HTMLDivElement>();
|
||||||
|
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
|
||||||
|
const [focusedSeriesIdx, setFocusedSeriesIdx] = useState<number | null>(null);
|
||||||
|
const [focusedPointIdx, setFocusedPointIdx] = useState<number | null>(null);
|
||||||
|
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null);
|
||||||
|
|
||||||
let xField = graphContext.getXAxisField();
|
// Debug logs
|
||||||
if (!xField) {
|
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;
|
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 });
|
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 (
|
return (
|
||||||
<CursorPlugin id={pluginId}>
|
<Portal>
|
||||||
{({ focusedSeriesIdx, focusedPointIdx, coords }) => {
|
<VizTooltipContainer position={{ x: coords.viewport.x, y: coords.viewport.y }} offset={{ x: 10, y: 10 }}>
|
||||||
if (!plotContext.getPlotInstance()) {
|
{tooltip}
|
||||||
return null;
|
</VizTooltipContainer>
|
||||||
}
|
</Portal>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,8 +1,23 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||||
import { SelectionPlugin } from './SelectionPlugin';
|
import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder';
|
||||||
|
import { pluginLog } from '../utils';
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
|
||||||
|
// selection bounding box, relative to canvas
|
||||||
|
bbox: {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface ZoomPluginProps {
|
interface ZoomPluginProps {
|
||||||
onZoom: (range: { from: number; to: number }) => void;
|
onZoom: (range: { from: number; to: number }) => void;
|
||||||
|
config: UPlotConfigBuilder;
|
||||||
}
|
}
|
||||||
|
|
||||||
// min px width that triggers zoom
|
// min px width that triggers zoom
|
||||||
@ -11,17 +26,40 @@ const MIN_ZOOM_DIST = 5;
|
|||||||
/**
|
/**
|
||||||
* @alpha
|
* @alpha
|
||||||
*/
|
*/
|
||||||
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom }) => {
|
export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom, config }) => {
|
||||||
return (
|
const [selection, setSelection] = useState<Selection | null>(null);
|
||||||
<SelectionPlugin
|
|
||||||
id="Zoom"
|
useEffect(() => {
|
||||||
/* very time series oriented for now */
|
if (selection) {
|
||||||
onSelect={(selection) => {
|
pluginLog('ZoomPlugin', false, 'selected', selection);
|
||||||
if (selection.bbox.width < MIN_ZOOM_DIST) {
|
if (selection.bbox.width < MIN_ZOOM_DIST) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onZoom({ from: selection.min, to: selection.max });
|
onZoom({ from: selection.min, to: selection.max });
|
||||||
}}
|
}
|
||||||
/>
|
}, [selection]);
|
||||||
);
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
config.addHook('setSelect', (u) => {
|
||||||
|
const min = u.posToVal(u.select.left, 'x');
|
||||||
|
const max = u.posToVal(u.select.left + u.select.width, 'x');
|
||||||
|
|
||||||
|
setSelection({
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
bbox: {
|
||||||
|
left: u.bbox.left / window.devicePixelRatio + u.select.left,
|
||||||
|
top: u.bbox.top / window.devicePixelRatio,
|
||||||
|
height: u.bbox.height / window.devicePixelRatio,
|
||||||
|
width: u.select.width,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// manually hide selected region (since cursor.drag.setScale = false)
|
||||||
|
/* @ts-ignore */
|
||||||
|
u.setSelect({ left: 0, width: 0 }, false);
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,2 @@
|
|||||||
export { ClickPlugin } from './ClickPlugin';
|
|
||||||
export { SelectionPlugin } from './SelectionPlugin';
|
|
||||||
export { ZoomPlugin } from './ZoomPlugin';
|
export { ZoomPlugin } from './ZoomPlugin';
|
||||||
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
|
||||||
export { TooltipPlugin } from './TooltipPlugin';
|
export { TooltipPlugin } from './TooltipPlugin';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import uPlot, { Options, Hooks, AlignedData } from 'uplot';
|
import { Options, AlignedData } from 'uplot';
|
||||||
import { TimeRange } from '@grafana/data';
|
import { TimeRange } from '@grafana/data';
|
||||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||||
|
|
||||||
@ -8,13 +8,6 @@ export type PlotConfig = Pick<
|
|||||||
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
|
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type PlotPlugin = {
|
|
||||||
id: string;
|
|
||||||
/** can mutate provided opts as necessary */
|
|
||||||
opts?: (self: uPlot, opts: Options) => void;
|
|
||||||
hooks: Hooks.ArraysOrFuncs;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface PlotPluginProps {
|
export interface PlotPluginProps {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { DataFrame, dateTime, Field, FieldType } from '@grafana/data';
|
import { DataFrame, dateTime, Field, FieldType } from '@grafana/data';
|
||||||
import { AlignedData, Options } from 'uplot';
|
|
||||||
import { PlotPlugin, PlotProps } from './types';
|
|
||||||
import { StackingMode } from './config';
|
import { StackingMode } from './config';
|
||||||
import { createLogger } from '../../utils/logger';
|
import { createLogger } from '../../utils/logger';
|
||||||
import { attachDebugger } from '../../utils';
|
import { attachDebugger } from '../../utils';
|
||||||
|
import { AlignedData, Options, PaddingSide } from 'uplot';
|
||||||
|
|
||||||
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
|
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
|
||||||
|
|
||||||
@ -11,27 +10,28 @@ export function timeFormatToTemplate(f: string) {
|
|||||||
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
|
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPlugin>): Options {
|
const paddingSide: PaddingSide = (u, side, sidesWithAxes) => {
|
||||||
return {
|
let hasCrossAxis = side % 2 ? sidesWithAxes[0] || sidesWithAxes[2] : sidesWithAxes[1] || sidesWithAxes[3];
|
||||||
width: props.width,
|
|
||||||
height: props.height,
|
return sidesWithAxes[side] || !hasCrossAxis ? 0 : 8;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
|
||||||
|
focus: {
|
||||||
|
alpha: 1,
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
focus: {
|
focus: {
|
||||||
alpha: 1,
|
prox: 30,
|
||||||
},
|
},
|
||||||
cursor: {
|
},
|
||||||
focus: {
|
legend: {
|
||||||
prox: 30,
|
show: false,
|
||||||
},
|
},
|
||||||
},
|
padding: [paddingSide, paddingSide, paddingSide, paddingSide],
|
||||||
legend: {
|
series: [],
|
||||||
show: false,
|
hooks: {},
|
||||||
},
|
};
|
||||||
plugins: Object.entries(plugins).map((p) => ({
|
|
||||||
hooks: p[1].hooks,
|
|
||||||
})),
|
|
||||||
hooks: {},
|
|
||||||
} as Options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function preparePlotData(frame: DataFrame, keepFieldTypes?: FieldType[]): AlignedData {
|
export function preparePlotData(frame: DataFrame, keepFieldTypes?: FieldType[]): AlignedData {
|
||||||
@ -111,5 +111,5 @@ export function collectStackingGroups(f: Field, groups: Map<string, number[]>, s
|
|||||||
/** @internal */
|
/** @internal */
|
||||||
export const pluginLogger = createLogger('uPlot Plugin');
|
export const pluginLogger = createLogger('uPlot Plugin');
|
||||||
export const pluginLog = pluginLogger.logger;
|
export const pluginLog = pluginLogger.logger;
|
||||||
|
// pluginLogger.enable();
|
||||||
attachDebugger('graphng', undefined, pluginLogger);
|
attachDebugger('graphng', undefined, pluginLogger);
|
||||||
|
@ -57,6 +57,7 @@ export function ExploreGraphNGPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [showAllTimeSeries, setShowAllTimeSeries] = useState(false);
|
const [showAllTimeSeries, setShowAllTimeSeries] = useState(false);
|
||||||
|
const [structureRev, setStructureRev] = useState(1);
|
||||||
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>({
|
const [fieldConfig, setFieldConfig] = useState<FieldConfigSource>({
|
||||||
defaults: {
|
defaults: {
|
||||||
color: {
|
color: {
|
||||||
@ -95,6 +96,7 @@ export function ExploreGraphNGPanel({
|
|||||||
|
|
||||||
const onLegendClick = useCallback(
|
const onLegendClick = useCallback(
|
||||||
(event: GraphNGLegendEvent) => {
|
(event: GraphNGLegendEvent) => {
|
||||||
|
setStructureRev((r) => r + 1);
|
||||||
setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data));
|
setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data));
|
||||||
},
|
},
|
||||||
[fieldConfig, data]
|
[fieldConfig, data]
|
||||||
@ -122,6 +124,7 @@ export function ExploreGraphNGPanel({
|
|||||||
<Collapse label="Graph" loading={isLoading} isOpen>
|
<Collapse label="Graph" loading={isLoading} isOpen>
|
||||||
<GraphNG
|
<GraphNG
|
||||||
data={seriesToShow}
|
data={seriesToShow}
|
||||||
|
structureRev={structureRev}
|
||||||
width={width}
|
width={width}
|
||||||
height={400}
|
height={400}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
@ -129,10 +132,28 @@ export function ExploreGraphNGPanel({
|
|||||||
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
|
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
>
|
>
|
||||||
<ZoomPlugin onZoom={onUpdateTimeRange} />
|
{(config, alignedDataFrame) => {
|
||||||
<TooltipPlugin data={data} mode={TooltipDisplayMode.Single} timeZone={timeZone} />
|
return (
|
||||||
<ContextMenuPlugin data={data} timeZone={timeZone} />
|
<>
|
||||||
{annotations && <ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />}
|
<ZoomPlugin config={config} onZoom={onUpdateTimeRange} />
|
||||||
|
<TooltipPlugin
|
||||||
|
config={config}
|
||||||
|
data={alignedDataFrame}
|
||||||
|
mode={TooltipDisplayMode.Single}
|
||||||
|
timeZone={timeZone}
|
||||||
|
/>
|
||||||
|
<ContextMenuPlugin config={config} data={alignedDataFrame} timeZone={timeZone} />
|
||||||
|
{annotations && (
|
||||||
|
<ExemplarsPlugin
|
||||||
|
config={config}
|
||||||
|
exemplars={annotations}
|
||||||
|
timeZone={timeZone}
|
||||||
|
getFieldLinks={getFieldLinks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</GraphNG>
|
</GraphNG>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</>
|
</>
|
||||||
|
@ -66,6 +66,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
|||||||
return (
|
return (
|
||||||
<BarChart
|
<BarChart
|
||||||
data={data.series}
|
data={data.series}
|
||||||
|
structureRev={data.structureRev}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
onLegendClick={onLegendClick}
|
onLegendClick={onLegendClick}
|
||||||
|
@ -44,6 +44,7 @@ export const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<TimelineChart
|
<TimelineChart
|
||||||
data={data.series}
|
data={data.series}
|
||||||
|
structureRev={data.structureRev}
|
||||||
timeRange={timeRange}
|
timeRange={timeRange}
|
||||||
timeZone={timeZone}
|
timeZone={timeZone}
|
||||||
width={width}
|
width={width}
|
||||||
|
@ -61,13 +61,37 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
|||||||
onLegendClick={onLegendClick}
|
onLegendClick={onLegendClick}
|
||||||
onSeriesColorChange={onSeriesColorChange}
|
onSeriesColorChange={onSeriesColorChange}
|
||||||
>
|
>
|
||||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
{(config, alignedDataFrame) => {
|
||||||
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode} timeZone={timeZone} />
|
return (
|
||||||
<ContextMenuPlugin data={data.series} timeZone={timeZone} replaceVariables={replaceVariables} />
|
<>
|
||||||
{data.annotations && (
|
<ZoomPlugin config={config} onZoom={onChangeTimeRange} />
|
||||||
<ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
|
<TooltipPlugin
|
||||||
)}
|
data={alignedDataFrame}
|
||||||
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
|
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>
|
</GraphNG>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
|
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
|
||||||
import { EventsCanvas, usePlotContext, useTheme } from '@grafana/ui';
|
import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui';
|
||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
import { AnnotationMarker } from './AnnotationMarker';
|
import { AnnotationMarker } from './AnnotationMarker';
|
||||||
|
|
||||||
interface AnnotationsPluginProps {
|
interface AnnotationsPluginProps {
|
||||||
|
config: UPlotConfigBuilder;
|
||||||
annotations: DataFrame[];
|
annotations: DataFrame[];
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
}
|
}
|
||||||
@ -14,11 +15,10 @@ interface AnnotationsDataFrameViewDTO {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => {
|
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone, config }) => {
|
||||||
const pluginId = 'AnnotationsPlugin';
|
|
||||||
const { isPlotReady, registerPlugin, getPlotInstance } = usePlotContext();
|
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const { getPlotInstance } = usePlotContext();
|
||||||
|
|
||||||
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
|
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
|
||||||
|
|
||||||
const timeFormatter = useCallback(
|
const timeFormatter = useCallback(
|
||||||
@ -31,65 +31,54 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
|||||||
[timeZone]
|
[timeZone]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update annotations views when new annotations came
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPlotReady) {
|
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
|
||||||
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
|
|
||||||
|
|
||||||
for (const frame of annotations) {
|
for (const frame of annotations) {
|
||||||
views.push(new DataFrameView(frame));
|
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;
|
const ctx = u.ctx;
|
||||||
}
|
if (!ctx) {
|
||||||
}, [isPlotReady, annotations]);
|
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(() => {
|
if (!annotation.time) {
|
||||||
const unregister = registerPlugin({
|
continue;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = u.ctx;
|
const xpos = u.valToPos(annotation.time, 'x', true);
|
||||||
if (!ctx) {
|
ctx.beginPath();
|
||||||
return;
|
ctx.lineWidth = 2;
|
||||||
}
|
ctx.strokeStyle = theme.palette.red;
|
||||||
for (let i = 0; i < annotationsRef.current.length; i++) {
|
ctx.setLineDash([5, 5]);
|
||||||
const annotationsView = annotationsRef.current[i];
|
ctx.moveTo(xpos, u.bbox.top);
|
||||||
for (let j = 0; j < annotationsView.length; j++) {
|
ctx.lineTo(xpos, u.bbox.top + u.bbox.height);
|
||||||
const annotation = annotationsView.get(j);
|
ctx.stroke();
|
||||||
|
ctx.closePath();
|
||||||
if (!annotation.time) {
|
}
|
||||||
continue;
|
}
|
||||||
}
|
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;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}, [config, theme]);
|
||||||
return () => {
|
|
||||||
unregister();
|
|
||||||
};
|
|
||||||
}, [registerPlugin, theme.palette.red]);
|
|
||||||
|
|
||||||
const mapAnnotationToXYCoords = useCallback(
|
const mapAnnotationToXYCoords = useCallback(
|
||||||
(frame: DataFrame, index: number) => {
|
(frame: DataFrame, index: number) => {
|
||||||
@ -120,6 +109,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
|
|||||||
return (
|
return (
|
||||||
<EventsCanvas
|
<EventsCanvas
|
||||||
id="annotations"
|
id="annotations"
|
||||||
|
config={config}
|
||||||
events={annotations}
|
events={annotations}
|
||||||
renderEventMarker={renderMarker}
|
renderEventMarker={renderMarker}
|
||||||
mapEventToXYCoords={mapAnnotationToXYCoords}
|
mapEventToXYCoords={mapAnnotationToXYCoords}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React, { useCallback, useRef, useState } from 'react';
|
import React, { useCallback, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { css as cssCore, Global } from '@emotion/react';
|
||||||
import {
|
import {
|
||||||
ClickPlugin,
|
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
GraphContextMenuHeader,
|
GraphContextMenuHeader,
|
||||||
IconName,
|
IconName,
|
||||||
@ -8,10 +8,10 @@ import {
|
|||||||
MenuItemsGroup,
|
MenuItemsGroup,
|
||||||
MenuGroup,
|
MenuGroup,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Portal,
|
UPlotConfigBuilder,
|
||||||
useGraphNGContext,
|
|
||||||
} from '@grafana/ui';
|
} from '@grafana/ui';
|
||||||
import {
|
import {
|
||||||
|
CartesianCoords2D,
|
||||||
DataFrame,
|
DataFrame,
|
||||||
DataFrameView,
|
DataFrameView,
|
||||||
getDisplayProcessor,
|
getDisplayProcessor,
|
||||||
@ -21,9 +21,11 @@ import {
|
|||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { useClickAway } from 'react-use';
|
import { useClickAway } from 'react-use';
|
||||||
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
|
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
|
||||||
|
import { pluginLog } from '@grafana/ui/src/components/uPlot/utils';
|
||||||
|
|
||||||
interface ContextMenuPluginProps {
|
interface ContextMenuPluginProps {
|
||||||
data: DataFrame[];
|
data: DataFrame;
|
||||||
|
config: UPlotConfigBuilder;
|
||||||
defaultItems?: MenuItemsGroup[];
|
defaultItems?: MenuItemsGroup[];
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
onOpen?: () => void;
|
onOpen?: () => void;
|
||||||
@ -33,50 +35,135 @@ interface ContextMenuPluginProps {
|
|||||||
|
|
||||||
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
||||||
data,
|
data,
|
||||||
|
config,
|
||||||
defaultItems,
|
defaultItems,
|
||||||
onClose,
|
onClose,
|
||||||
timeZone,
|
timeZone,
|
||||||
replaceVariables,
|
replaceVariables,
|
||||||
}) => {
|
}) => {
|
||||||
|
const plotCanvas = useRef<HTMLDivElement>();
|
||||||
|
const plotCanvasBBox = useRef<any>({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 });
|
||||||
|
const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null);
|
||||||
|
const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const onClick = useCallback(() => {
|
const openMenu = useCallback(() => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(true);
|
||||||
}, [isOpen]);
|
}, [setIsOpen]);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
}, [setIsOpen]);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
pluginLog('ContextMenuPlugin', false, 'clearing click selection');
|
||||||
|
setPoint(null);
|
||||||
|
}, [setPoint]);
|
||||||
|
|
||||||
|
// Add uPlot hooks to the config, or re-add when the config changed
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const onMouseCapture = (e: MouseEvent) => {
|
||||||
|
setCoords({
|
||||||
|
plotCanvas: {
|
||||||
|
x: e.clientX - plotCanvasBBox.current.left,
|
||||||
|
y: e.clientY - plotCanvasBBox.current.top,
|
||||||
|
},
|
||||||
|
viewport: {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
config.addHook('init', (u) => {
|
||||||
|
const canvas = u.root.querySelector<HTMLDivElement>('.u-over');
|
||||||
|
plotCanvas.current = canvas || undefined;
|
||||||
|
plotCanvas.current?.addEventListener('mousedown', onMouseCapture);
|
||||||
|
plotCanvas.current?.addEventListener('mouseleave', () => {});
|
||||||
|
|
||||||
|
pluginLog('ContextMenuPlugin', false, 'init');
|
||||||
|
// for naive click&drag check
|
||||||
|
let isClick = false;
|
||||||
|
|
||||||
|
// REF: https://github.com/leeoniya/uPlot/issues/239
|
||||||
|
let pts = Array.from(u.root.querySelectorAll<HTMLDivElement>('.u-cursor-pt'));
|
||||||
|
|
||||||
|
plotCanvas.current?.addEventListener('mousedown', (e: MouseEvent) => {
|
||||||
|
isClick = true;
|
||||||
|
});
|
||||||
|
plotCanvas.current?.addEventListener('mousemove', (e: MouseEvent) => {
|
||||||
|
isClick = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: remove listeners on unmount
|
||||||
|
plotCanvas.current?.addEventListener('mouseup', (e: MouseEvent) => {
|
||||||
|
if (!isClick) {
|
||||||
|
setPoint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isClick = true;
|
||||||
|
|
||||||
|
if (e.target) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.classList.contains('u-cursor-pt')) {
|
||||||
|
pluginLog('ContextMenuPlugin', false, 'canvas click');
|
||||||
|
setPoint({ seriesIdx: null, dataIdx: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pts.length > 0) {
|
||||||
|
pts.forEach((pt, i) => {
|
||||||
|
// TODO: remove listeners on unmount
|
||||||
|
pt.addEventListener('click', (e) => {
|
||||||
|
const seriesIdx = i + 1;
|
||||||
|
const dataIdx = u.cursor.idx;
|
||||||
|
pluginLog('ContextMenuPlugin', false, seriesIdx, dataIdx);
|
||||||
|
openMenu();
|
||||||
|
setPoint({ seriesIdx, dataIdx: dataIdx || null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [config, openMenu]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClickPlugin id="ContextMenu" onClick={onClick}>
|
<>
|
||||||
{({ point, coords, clearSelection }) => {
|
<Global
|
||||||
return (
|
styles={cssCore`
|
||||||
<Portal>
|
.uplot .u-cursor-pt {
|
||||||
<ContextMenuView
|
pointer-events: auto !important;
|
||||||
data={data}
|
}
|
||||||
defaultItems={defaultItems}
|
`}
|
||||||
timeZone={timeZone}
|
/>
|
||||||
selection={{ point, coords }}
|
{isOpen && coords && (
|
||||||
replaceVariables={replaceVariables}
|
<ContextMenuView
|
||||||
onClose={() => {
|
data={data}
|
||||||
clearSelection();
|
defaultItems={defaultItems}
|
||||||
if (onClose) {
|
timeZone={timeZone}
|
||||||
onClose();
|
selection={{ point, coords }}
|
||||||
}
|
replaceVariables={replaceVariables}
|
||||||
}}
|
onClose={() => {
|
||||||
/>
|
clearSelection();
|
||||||
</Portal>
|
closeMenu();
|
||||||
);
|
if (onClose) {
|
||||||
}}
|
onClose();
|
||||||
</ClickPlugin>
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
data: DataFrame[];
|
data: DataFrame;
|
||||||
defaultItems?: MenuItemsGroup[];
|
defaultItems?: MenuItemsGroup[];
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
selection: {
|
selection: {
|
||||||
point: { seriesIdx: number | null; dataIdx: number | null };
|
point?: { seriesIdx: number | null; dataIdx: number | null } | null;
|
||||||
coords: { plotCanvas: { x: number; y: number }; viewport: { x: number; y: number } };
|
coords: { plotCanvas: CartesianCoords2D; viewport: CartesianCoords2D };
|
||||||
};
|
};
|
||||||
replaceVariables?: InterpolateFunction;
|
replaceVariables?: InterpolateFunction;
|
||||||
}
|
}
|
||||||
@ -90,7 +177,6 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
|||||||
...otherProps
|
...otherProps
|
||||||
}) => {
|
}) => {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const graphContext = useGraphNGContext();
|
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
if (otherProps.onClose) {
|
if (otherProps.onClose) {
|
||||||
@ -102,7 +188,7 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
});
|
});
|
||||||
|
|
||||||
const xField = graphContext.getXAxisField();
|
const xField = data.fields[0];
|
||||||
|
|
||||||
if (!xField) {
|
if (!xField) {
|
||||||
return null;
|
return null;
|
||||||
@ -110,55 +196,54 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
|||||||
const items = defaultItems ? [...defaultItems] : [];
|
const items = defaultItems ? [...defaultItems] : [];
|
||||||
let renderHeader: () => JSX.Element | null = () => null;
|
let renderHeader: () => JSX.Element | null = () => null;
|
||||||
|
|
||||||
const { seriesIdx, dataIdx } = selection.point;
|
if (selection.point) {
|
||||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
|
const { seriesIdx, dataIdx } = selection.point;
|
||||||
|
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
|
||||||
|
|
||||||
if (seriesIdx && dataIdx) {
|
if (seriesIdx && dataIdx) {
|
||||||
// origin field/frame indexes for inspecting the data
|
const field = data.fields[seriesIdx];
|
||||||
const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx);
|
|
||||||
const frame = data[originFieldIndex.frameIndex];
|
|
||||||
const field = frame.fields[originFieldIndex.fieldIndex];
|
|
||||||
|
|
||||||
const displayValue = field.display!(field.values.get(dataIdx));
|
const displayValue = field.display!(field.values.get(dataIdx));
|
||||||
|
|
||||||
const hasLinks = field.config.links && field.config.links.length > 0;
|
const hasLinks = field.config.links && field.config.links.length > 0;
|
||||||
|
|
||||||
if (hasLinks) {
|
if (hasLinks) {
|
||||||
const linksSupplier = getFieldLinksSupplier({
|
const linksSupplier = getFieldLinksSupplier({
|
||||||
display: displayValue,
|
display: displayValue,
|
||||||
name: field.name,
|
name: field.name,
|
||||||
view: new DataFrameView(frame),
|
view: new DataFrameView(data),
|
||||||
rowIndex: dataIdx,
|
rowIndex: dataIdx,
|
||||||
colIndex: originFieldIndex.fieldIndex,
|
colIndex: seriesIdx,
|
||||||
field: field.config,
|
field: field.config,
|
||||||
hasLinks,
|
hasLinks,
|
||||||
});
|
|
||||||
|
|
||||||
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
|
if (linksSupplier) {
|
||||||
renderHeader = () => (
|
items.push({
|
||||||
<GraphContextMenuHeader
|
items: linksSupplier.getLinks(replaceVariables).map<MenuItemProps>((link) => {
|
||||||
timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
|
return {
|
||||||
displayValue={displayValue}
|
label: link.title,
|
||||||
seriesColor={displayValue.color!}
|
ariaLabel: link.title,
|
||||||
displayName={getFieldDisplayName(field, frame)}
|
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 = () => {
|
const renderMenuGroupItems = () => {
|
||||||
|
@ -6,26 +6,26 @@ import {
|
|||||||
TIME_SERIES_TIME_FIELD_NAME,
|
TIME_SERIES_TIME_FIELD_NAME,
|
||||||
TIME_SERIES_VALUE_FIELD_NAME,
|
TIME_SERIES_VALUE_FIELD_NAME,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { EventsCanvas, FIXED_UNIT, usePlotContext } from '@grafana/ui';
|
import { EventsCanvas, FIXED_UNIT, UPlotConfigBuilder, usePlotContext } from '@grafana/ui';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { ExemplarMarker } from './ExemplarMarker';
|
import { ExemplarMarker } from './ExemplarMarker';
|
||||||
|
|
||||||
interface ExemplarsPluginProps {
|
interface ExemplarsPluginProps {
|
||||||
|
config: UPlotConfigBuilder;
|
||||||
exemplars: DataFrame[];
|
exemplars: DataFrame[];
|
||||||
timeZone: TimeZone;
|
timeZone: TimeZone;
|
||||||
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
|
getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone, getFieldLinks }) => {
|
export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, timeZone, getFieldLinks, config }) => {
|
||||||
const plotCtx = usePlotContext();
|
const plotCtx = usePlotContext();
|
||||||
|
|
||||||
const mapExemplarToXYCoords = useCallback(
|
const mapExemplarToXYCoords = useCallback(
|
||||||
(dataFrame: DataFrame, index: number) => {
|
(dataFrame: DataFrame, index: number) => {
|
||||||
const plotInstance = plotCtx.getPlotInstance();
|
const plotInstance = plotCtx.getPlotInstance();
|
||||||
const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
|
const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
|
||||||
const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
|
const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);
|
||||||
|
|
||||||
if (!time || !value || !plotCtx.isPlotReady || !plotInstance) {
|
if (!time || !value || !plotInstance) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,6 +63,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EventsCanvas
|
<EventsCanvas
|
||||||
|
config={config}
|
||||||
id="exemplars"
|
id="exemplars"
|
||||||
events={exemplars}
|
events={exemplars}
|
||||||
renderEventMarker={renderMarker}
|
renderEventMarker={renderMarker}
|
||||||
|
@ -63,8 +63,16 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
|
|||||||
onLegendClick={onLegendClick}
|
onLegendClick={onLegendClick}
|
||||||
onSeriesColorChange={onSeriesColorChange}
|
onSeriesColorChange={onSeriesColorChange}
|
||||||
>
|
>
|
||||||
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
{(config, alignedDataFrame) => {
|
||||||
<>{/* needs to be an array */}</>
|
return (
|
||||||
|
<TooltipPlugin
|
||||||
|
config={config}
|
||||||
|
data={alignedDataFrame}
|
||||||
|
mode={options.tooltipOptions.mode as any}
|
||||||
|
timeZone={timeZone}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</GraphNG>
|
</GraphNG>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user