From a54ac510c450748a816bf74ba52480e3671623d5 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 26 Apr 2021 13:30:04 +0200 Subject: [PATCH] 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 --- .../src/components/BarChart/BarChart.tsx | 64 +++-- .../src/components/GraphNG/GraphNG.tsx | 159 ++++-------- .../src/components/Timeline/TimelineChart.tsx | 157 ++++-------- packages/grafana-ui/src/components/index.ts | 5 +- .../src/components/uPlot/Plot.test.tsx | 88 +------ .../grafana-ui/src/components/uPlot/Plot.tsx | 80 +++--- .../uPlot/config/UPlotConfigBuilder.ts | 7 +- .../src/components/uPlot/context.ts | 34 +-- .../uPlot/geometries/EventsCanvas.tsx | 17 +- .../src/components/uPlot/hooks.test.ts | 135 ---------- .../grafana-ui/src/components/uPlot/hooks.ts | 187 -------------- .../uPlot/plugins/AnnotationsEditorPlugin.tsx | 118 ++++----- .../components/uPlot/plugins/ClickPlugin.tsx | 128 ---------- .../components/uPlot/plugins/CursorPlugin.tsx | 116 --------- .../uPlot/plugins/SelectionPlugin.tsx | 93 ------- .../uPlot/plugins/TooltipPlugin.tsx | 225 +++++++++-------- .../components/uPlot/plugins/ZoomPlugin.tsx | 68 +++-- .../src/components/uPlot/plugins/index.ts | 3 - .../grafana-ui/src/components/uPlot/types.ts | 9 +- .../grafana-ui/src/components/uPlot/utils.ts | 44 ++-- .../features/explore/ExploreGraphNGPanel.tsx | 29 ++- .../plugins/panel/barchart/BarChartPanel.tsx | 1 + .../plugins/panel/timeline/TimelinePanel.tsx | 1 + .../panel/timeseries/TimeSeriesPanel.tsx | 38 ++- .../timeseries/plugins/AnnotationsPlugin.tsx | 104 ++++---- .../timeseries/plugins/ContextMenuPlugin.tsx | 239 ++++++++++++------ .../timeseries/plugins/ExemplarsPlugin.tsx | 9 +- .../plugins/panel/xychart/XYChartPanel.tsx | 12 +- 28 files changed, 739 insertions(+), 1431 deletions(-) delete mode 100644 packages/grafana-ui/src/components/uPlot/hooks.test.ts delete mode 100644 packages/grafana-ui/src/components/uPlot/hooks.ts delete mode 100644 packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx delete mode 100644 packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx delete mode 100644 packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.tsx index 50a8ba0d4a4..170e899d0a5 100644 --- a/packages/grafana-ui/src/components/BarChart/BarChart.tsx +++ b/packages/grafana-ui/src/components/BarChart/BarChart.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { AlignedData } from 'uplot'; -import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data'; +import { DataFrame, TimeRange } from '@grafana/data'; import { VizLayout } from '../VizLayout/VizLayout'; import { Themeable } from '../../types'; import { UPlotChart } from '../uPlot/Plot'; @@ -9,7 +9,7 @@ import { GraphNGLegendEvent } from '../GraphNG/types'; import { BarChartOptions } from './types'; import { withTheme } from '../../themes'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; -import { preparePlotData } from '../uPlot/utils'; +import { pluginLog, preparePlotData } from '../uPlot/utils'; import { LegendDisplayMode } from '../VizLegend/models.gen'; import { PlotLegend } from '../uPlot/PlotLegend'; @@ -20,6 +20,7 @@ export interface BarChartProps extends Themeable, BarChartOptions { height: number; width: number; data: DataFrame[]; + structureRev?: number; // a number that will change when the data[] structure changes onLegendClick?: (event: GraphNGLegendEvent) => void; onSeriesColorChange?: (label: string, color: string) => void; } @@ -33,40 +34,24 @@ interface BarChartState { class UnthemedBarChart extends React.Component { constructor(props: BarChartProps) { super(props); - this.state = {} as BarChartState; - } - - static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) { - const frame = preparePlotFrame(props.data); - - if (!frame) { - return { ...state }; - } - - return { - ...state, - data: preparePlotData(frame), - alignedDataFrame: frame, - }; - } - - componentDidMount() { - const { alignedDataFrame } = this.state; - + const alignedDataFrame = preparePlotFrame(props.data); if (!alignedDataFrame) { return; } - - this.setState({ - config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), - }); + const data = preparePlotData(alignedDataFrame); + const config = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props); + this.state = { + alignedDataFrame, + data, + config, + }; } componentDidUpdate(prevProps: BarChartProps) { - const { data, orientation, groupWidth, barWidth, showValue } = this.props; + const { data, orientation, groupWidth, barWidth, showValue, structureRev } = this.props; const { alignedDataFrame } = this.state; let shouldConfigUpdate = false; - let hasStructureChanged = false; + let stateUpdate = {} as BarChartState; if ( this.state.config === undefined || @@ -78,17 +63,26 @@ class UnthemedBarChart extends React.Component { shouldConfigUpdate = true; } - if (data !== prevProps.data) { - if (!alignedDataFrame) { + if (data !== prevProps.data || shouldConfigUpdate) { + const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev; + const alignedData = preparePlotFrame(data); + + if (!alignedData) { return; } - hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures); + stateUpdate = { + alignedDataFrame: alignedData, + data: preparePlotData(alignedData), + }; + if (shouldConfigUpdate || hasStructureChanged) { + pluginLog('BarChart', false, 'updating config'); + const builder = preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props); + stateUpdate = { ...stateUpdate, config: builder }; + } } - if (shouldConfigUpdate || hasStructureChanged) { - this.setState({ - config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), - }); + if (Object.keys(stateUpdate).length > 0) { + this.setState(stateUpdate); } } diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index b9d28225db0..b22294f7e8f 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -1,21 +1,12 @@ import React from 'react'; import { AlignedData } from 'uplot'; -import { - DataFrame, - DataFrameFieldIndex, - FieldMatcherID, - fieldMatchers, - FieldType, - TimeRange, - TimeZone, -} from '@grafana/data'; +import { DataFrame, FieldMatcherID, fieldMatchers, TimeRange, TimeZone } from '@grafana/data'; import { withTheme } from '../../themes'; import { Themeable } from '../../types'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { GraphNGLegendEvent, XYFieldMatchers } from './types'; -import { GraphNGContext } from './hooks'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; -import { preparePlotData } from '../uPlot/utils'; +import { pluginLog, preparePlotData } from '../uPlot/utils'; import { PlotLegend } from '../uPlot/PlotLegend'; import { UPlotChart } from '../uPlot/Plot'; import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen'; @@ -37,89 +28,41 @@ export interface GraphNGProps extends Themeable { fields?: XYFieldMatchers; // default will assume timeseries data onLegendClick?: (event: GraphNGLegendEvent) => void; onSeriesColorChange?: (label: string, color: string) => void; - children?: React.ReactNode; + children?: (builder: UPlotConfigBuilder, alignedDataFrame: DataFrame) => React.ReactNode; } /** * @internal -- not a public API */ export interface GraphNGState { - data: AlignedData; alignedDataFrame: DataFrame; - dimFields: XYFieldMatchers; - seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[]; + data: AlignedData; config?: UPlotConfigBuilder; } class UnthemedGraphNG extends React.Component { constructor(props: GraphNGProps) { super(props); - let dimFields = props.fields; - if (!dimFields) { - dimFields = { - x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.numeric).get({}), - }; - } - this.state = { dimFields } as GraphNGState; - } + pluginLog('GraphNG', false, 'constructor, data aligment'); + const alignedData = preparePlotFrame(props.data, { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); - /** - * Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array. - * It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume - * that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32) - * - * Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle. - * If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update - * - * This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update, - * the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that - * often (apart from the edit mode interactions) this should be a fair performance compromise. - */ - static getDerivedStateFromProps(props: GraphNGProps, state: GraphNGState) { - let dimFields = props.fields; - - if (!dimFields) { - dimFields = { - x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.numeric).get({}), - }; - } - - const frame = preparePlotFrame(props.data, dimFields); - - if (!frame) { - return { ...state, dimFields }; - } - - return { - ...state, - data: preparePlotData(frame, [FieldType.number]), - alignedDataFrame: frame, - seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!), - dimFields, - }; - } - - componentDidMount() { - const { theme } = this.props; - - // alignedDataFrame is already prepared by getDerivedStateFromProps method - const { alignedDataFrame } = this.state; - - if (!alignedDataFrame) { + if (!alignedData) { return; } - this.setState({ - config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone), - }); + this.state = { + alignedDataFrame: alignedData, + data: preparePlotData(alignedData), + config: preparePlotConfigBuilder(alignedData, props.theme, this.getTimeRange, this.getTimeZone), + }; } componentDidUpdate(prevProps: GraphNGProps) { - const { data, theme, structureRev } = this.props; - const { alignedDataFrame } = this.state; + const { theme, structureRev, data } = this.props; let shouldConfigUpdate = false; let stateUpdate = {} as GraphNGState; @@ -128,13 +71,31 @@ class UnthemedGraphNG extends React.Component { } if (data !== prevProps.data) { - if (!alignedDataFrame) { + pluginLog('GraphNG', false, 'data changed'); + const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev; + + if (hasStructureChanged) { + pluginLog('GraphNG', false, 'schema changed'); + } + + pluginLog('GraphNG', false, 'componentDidUpdate, data aligment'); + const alignedData = preparePlotFrame(data, { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); + + if (!alignedData) { return; } - const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev; + stateUpdate = { + alignedDataFrame: alignedData, + data: preparePlotData(alignedData), + }; + if (shouldConfigUpdate || hasStructureChanged) { - const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone); + pluginLog('GraphNG', false, 'updating config'); + const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone); stateUpdate = { ...stateUpdate, config: builder }; } } @@ -144,10 +105,6 @@ class UnthemedGraphNG extends React.Component { } } - mapSeriesIndexToDataFrameFieldIndex = (i: number) => { - return this.state.seriesToDataFrameFieldIndexMap[i]; - }; - getTimeRange = () => { return this.props.timeRange; }; @@ -178,35 +135,25 @@ class UnthemedGraphNG extends React.Component { } render() { - const { width, height, children, timeZone, timeRange, ...plotProps } = this.props; - - if (!this.state.data || !this.state.config) { + const { width, height, children, timeRange } = this.props; + const { config, alignedDataFrame } = this.state; + if (!config) { return null; } - return ( - - - {(vizWidth: number, vizHeight: number) => ( - - {children} - - )} - - + + {(vizWidth: number, vizHeight: number) => ( + + {children ? children(config, alignedDataFrame) : null} + + )} + ); } } diff --git a/packages/grafana-ui/src/components/Timeline/TimelineChart.tsx b/packages/grafana-ui/src/components/Timeline/TimelineChart.tsx index 9c81aef132c..ffd2eec9c18 100755 --- a/packages/grafana-ui/src/components/Timeline/TimelineChart.tsx +++ b/packages/grafana-ui/src/components/Timeline/TimelineChart.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import { compareArrayValues, compareDataFrameStructures, FieldMatcherID, fieldMatchers } from '@grafana/data'; +import { FieldMatcherID, fieldMatchers } from '@grafana/data'; import { withTheme } from '../../themes'; -import { GraphNGContext } from '../GraphNG/hooks'; import { GraphNGState } from '../GraphNG/GraphNG'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG -import { preparePlotData } from '../uPlot/utils'; +import { pluginLog, preparePlotData } from '../uPlot/utils'; import { PlotLegend } from '../uPlot/PlotLegend'; import { UPlotChart } from '../uPlot/Plot'; import { LegendDisplayMode } from '../VizLegend/models.gen'; @@ -14,77 +13,35 @@ import { TimelineProps } from './types'; class UnthemedTimelineChart extends React.Component { constructor(props: TimelineProps) { super(props); - let dimFields = props.fields; + const { theme, mode, rowHeight, colWidth, showValue } = props; - if (!dimFields) { - dimFields = { - x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), - y: fieldMatchers.get(FieldMatcherID.numeric).get({}), // this may be either numeric or strings, (or bools?) - }; - } - this.state = { dimFields } as GraphNGState; - } - - /** - * Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array. - * It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume - * that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32) - * - * Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle. - * If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update - * - * This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update, - * the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that - * often (apart from the edit mode interactions) this should be a fair performance compromise. - */ - static getDerivedStateFromProps(props: TimelineProps, state: GraphNGState) { - let dimFields = props.fields; - - if (!dimFields) { - dimFields = { + pluginLog('TimelineChart', false, 'constructor, data aligment'); + const alignedData = preparePlotFrame( + props.data, + props.fields || { x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), y: fieldMatchers.get(FieldMatcherID.numeric).get({}), - }; - } + } + ); - const frame = preparePlotFrame(props.data, dimFields); - - if (!frame) { - return { ...state, dimFields }; - } - - return { - ...state, - data: preparePlotData(frame), - alignedDataFrame: frame, - seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!), - dimFields, - }; - } - - componentDidMount() { - const { theme, mode, rowHeight, colWidth, showValue } = this.props; - - // alignedDataFrame is already prepared by getDerivedStateFromProps method - const { alignedDataFrame } = this.state; - - if (!alignedDataFrame) { + if (!alignedData) { return; } - this.setState({ - config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, { + this.state = { + alignedDataFrame: alignedData, + data: preparePlotData(alignedData), + config: preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, { mode, rowHeight, colWidth, showValue, }), - }); + }; } componentDidUpdate(prevProps: TimelineProps) { - const { data, theme, timeZone, mode, rowHeight, colWidth, showValue } = this.props; - const { alignedDataFrame } = this.state; + const { data, theme, timeZone, mode, rowHeight, colWidth, showValue, structureRev } = this.props; let shouldConfigUpdate = false; let stateUpdate = {} as GraphNGState; @@ -99,35 +56,41 @@ class UnthemedTimelineChart extends React.Component shouldConfigUpdate = true; } - if (data !== prevProps.data) { - if (!alignedDataFrame) { + if (data !== prevProps.data || shouldConfigUpdate) { + const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev; + + const alignedData = preparePlotFrame( + data, + this.props.fields || { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + } + ); + if (!alignedData) { return; } - if (!compareArrayValues(data, prevProps.data, compareDataFrameStructures)) { - shouldConfigUpdate = true; + stateUpdate = { + alignedDataFrame: alignedData, + data: preparePlotData(alignedData), + }; + if (shouldConfigUpdate || hasStructureChanged) { + pluginLog('TimelineChart', false, 'updating config'); + const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, { + mode, + rowHeight, + colWidth, + showValue, + }); + stateUpdate = { ...stateUpdate, config: builder }; } } - if (shouldConfigUpdate) { - const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone, { - mode, - rowHeight, - colWidth, - showValue, - }); - stateUpdate = { ...stateUpdate, config: builder }; - } - if (Object.keys(stateUpdate).length > 0) { this.setState(stateUpdate); } } - mapSeriesIndexToDataFrameFieldIndex = (i: number) => { - return this.state.seriesToDataFrameFieldIndexMap[i]; - }; - getTimeRange = () => { return this.props.timeRange; }; @@ -158,35 +121,27 @@ class UnthemedTimelineChart extends React.Component } 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 ( - - - {(vizWidth: number, vizHeight: number) => ( - - {children} - - )} - - + + {(vizWidth: number, vizHeight: number) => ( + + {children ? children(config, alignedDataFrame) : null} + + )} + ); } } diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index d13f9fe8657..8529621bb38 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -224,13 +224,14 @@ export { LegacyForms, LegacyInputStatus }; // WIP, need renames and exports cleanup export * from './uPlot/config'; +export { UPlotConfigBuilder } from './uPlot/config/UPlotConfigBuilder'; export { UPlotChart } from './uPlot/Plot'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; -export { useRefreshAfterGraphRendered } from './uPlot/hooks'; -export { usePlotContext, usePlotPluginContext } from './uPlot/context'; +export { usePlotContext } from './uPlot/context'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG'; export { useGraphNGContext } from './GraphNG/hooks'; +export { preparePlotFrame } from './GraphNG/utils'; export { BarChart } from './BarChart/BarChart'; export { TimelineChart } from './Timeline/TimelineChart'; export { BarChartOptions, BarValueVisibility, BarChartFieldConfig } from './BarChart/types'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index 8bc5d4eb10f..67a698f2a8b 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { UPlotChart } from './Plot'; -import { act, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data'; import { GraphFieldConfig, DrawStyle } from '../uPlot/config'; import uPlot from 'uplot'; @@ -83,11 +83,6 @@ describe('UPlotChart', () => { /> ); - // we wait 1 frame for plugins initialisation logic to finish - act(() => { - mockRaf.step({ count: 1 }); - }); - expect(uPlot).toBeCalledTimes(1); unmount(); expect(destroyMock).toBeCalledTimes(1); @@ -107,11 +102,6 @@ describe('UPlotChart', () => { /> ); - // we wait 1 frame for plugins initialisation logic to finish - act(() => { - mockRaf.step({ count: 1 }); - }); - expect(uPlot).toBeCalledTimes(1); data.fields[1].values.set(0, 1); @@ -154,11 +144,6 @@ describe('UPlotChart', () => { /> ); - // we wait 1 frame for plugins initialisation logic to finish - act(() => { - mockRaf.step({ count: 1 }); - }); - expect(uPlot).toBeCalledTimes(1); const nextConfig = new UPlotConfigBuilder(); @@ -186,16 +171,10 @@ describe('UPlotChart', () => { ); // we wait 1 frame for plugins initialisation logic to finish - act(() => { - mockRaf.step({ count: 1 }); - }); - const nextConfig = new UPlotConfigBuilder(); - nextConfig.addSeries({} as SeriesProps); - rerender( { expect(uPlot).toBeCalledTimes(1); expect(setSizeMock).toBeCalledTimes(1); }); - - it('does not initialize plot when config and data are not in sync', () => { - const { data, timeRange, config } = mockData(); - - // 1 series in data, 2 series in config - config.addSeries({} as SeriesProps); - - render( - - ); - - // 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( - - ); - - // 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( - - ); - - expect(destroyMock).toBeCalledTimes(0); - expect(uPlot).toBeCalledTimes(1); - }); }); }); diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 796078d479d..eef918f8b8c 100755 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -1,10 +1,8 @@ -import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import uPlot, { AlignedData, Options } from 'uplot'; import { buildPlotContext, PlotContext } from './context'; -import { pluginLog } from './utils'; -import { usePlotConfig } from './hooks'; +import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils'; import { PlotProps } from './types'; -import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import usePrevious from 'react-use/lib/usePrevious'; /** @@ -16,67 +14,71 @@ import usePrevious from 'react-use/lib/usePrevious'; export const UPlotChart: React.FC = (props) => { const canvasRef = useRef(null); const plotInstance = useRef(); - const [isPlotReady, setIsPlotReady] = useState(false); const prevProps = usePrevious(props); - const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config); + + const config = useMemo(() => { + return { + ...DEFAULT_PLOT_CONFIG, + width: props.width, + height: props.height, + ms: 1, + ...props.config.getConfig(), + } as uPlot.Options; + }, [props.config]); const getPlotInstance = useCallback(() => { return plotInstance.current; }, []); - // Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed useLayoutEffect(() => { - // 0. Exit early if the component is not ready to initialize uPlot - if (!currentConfig.current || !canvasRef.current || props.width === 0 || props.height === 0) { + if (!plotInstance.current || props.width === 0 || props.height === 0) { return; } - // 0. Exit if the data set length is different than number of series expected to render - // This may happen when GraphNG has not synced config yet with the aligned frame. Alignment happens before the render - // in the getDerivedStateFromProps, while the config creation happens in componentDidUpdate, causing one more render - // of the UPlotChart if the config needs to be updated. - if (currentConfig.current.series.length !== props.data.length) { + pluginLog('uPlot core', false, 'updating size'); + plotInstance.current!.setSize({ + width: props.width, + height: props.height, + }); + }, [props.width, props.height]); + + // Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed + useLayoutEffect(() => { + // 0. Exit early if the component is not ready to initialize uPlot + if (!canvasRef.current || props.width === 0 || props.height === 0) { return; } // 1. When config is ready and there is no uPlot instance, create new uPlot and return - if (isConfigReady && !plotInstance.current) { - plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); - setIsPlotReady(true); + if (!plotInstance.current || !prevProps) { + plotInstance.current = initializePlot(props.data, config, canvasRef.current); return; } - // 2. When dimensions have changed, update uPlot size and return - if (currentConfig.current.width !== prevProps?.width || currentConfig.current.height !== prevProps?.height) { - pluginLog('uPlot core', false, 'updating size'); - plotInstance.current!.setSize({ - width: currentConfig.current.width, - height: currentConfig.current?.height, - }); - return; - } - - // 3. When config has changed re-initialize plot - if (isConfigReady && props.config !== prevProps.config) { + // 2. Reinitialize uPlot if config changed + if (props.config !== prevProps.config) { if (plotInstance.current) { pluginLog('uPlot core', false, 'destroying instance'); plotInstance.current.destroy(); } - plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); + plotInstance.current = initializePlot(props.data, config, canvasRef.current); return; } - // 4. Otherwise, assume only data has changed and update uPlot data - updateData(props.config, props.data, plotInstance.current); - }, [props, isConfigReady]); + // 3. Otherwise, assume only data has changed and update uPlot data + if (props.data !== prevProps.data) { + pluginLog('uPlot core', false, 'updating plot data(throttled log!)', props.data); + plotInstance.current.setData(props.data); + } + }, [props, config]); // When component unmounts, clean the existing uPlot instance useEffect(() => () => plotInstance.current?.destroy(), []); // Memoize plot context const plotCtx = useMemo(() => { - return buildPlotContext(isPlotReady, canvasRef, props.data, registerPlugin, getPlotInstance); - }, [plotInstance, canvasRef, props.data, registerPlugin, getPlotInstance, isPlotReady]); + return buildPlotContext(canvasRef, props.data, getPlotInstance); + }, [plotInstance, canvasRef, props.data, getPlotInstance]); return ( @@ -92,11 +94,3 @@ function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement) pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config); return new uPlot(config, data, el); } - -function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) { - if (!plotInstance || !data) { - return; - } - pluginLog('uPlot core', false, 'updating plot data(throttled log!)', data); - plotInstance.setData(data); -} diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 68bf86c1e7e..d6ce04384f4 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -6,8 +6,7 @@ import { AxisPlacement } from '../config'; import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot'; import { defaultsDeep } from 'lodash'; import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data'; - -type valueof = T[keyof T]; +import { pluginLog } from '../utils'; export class UPlotConfigBuilder { private series: UPlotSeriesBuilder[] = []; @@ -27,7 +26,9 @@ export class UPlotConfigBuilder { this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName; } - addHook(type: keyof Hooks.Defs, hook: valueof) { + addHook(type: T, hook: Hooks.Defs[T]) { + pluginLog('UPlotConfigBuilder', false, 'addHook', type); + if (!this.hooks[type]) { this.hooks[type] = []; } diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index 5706fc1922f..c14ed4ddeab 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -1,6 +1,5 @@ import React, { useContext } from 'react'; import uPlot, { AlignedData, Series } from 'uplot'; -import { PlotPlugin } from './types'; /** * @alpha @@ -18,15 +17,7 @@ interface PlotCanvasContextType { }; } -/** - * @alpha - */ -interface PlotPluginsContextType { - registerPlugin: (plugin: PlotPlugin) => () => void; -} - -interface PlotContextType extends PlotPluginsContextType { - isPlotReady: boolean; +interface PlotContextType { getPlotInstance: () => uPlot | undefined; getSeries: () => Series[]; getCanvas: () => PlotCanvasContextType; @@ -44,40 +35,17 @@ export const usePlotContext = (): PlotContextType => { return useContext(PlotContext); }; -const throwWhenNoContext = (name: string) => { - throw new Error(`${name} must be used within PlotContext or PlotContext is not ready yet!`); -}; - -/** - * Exposes API for registering uPlot plugins - * - * @alpha - */ -export const usePlotPluginContext = (): PlotPluginsContextType => { - const ctx = useContext(PlotContext); - if (Object.keys(ctx).length === 0) { - throwWhenNoContext('usePlotPluginContext'); - } - return { - registerPlugin: ctx!.registerPlugin, - }; -}; - /** * @alpha */ export const buildPlotContext = ( - isPlotReady: boolean, canvasRef: any, data: AlignedData, - registerPlugin: any, getPlotInstance: () => uPlot | undefined ): PlotContextType => { return { - isPlotReady, canvasRef, data, - registerPlugin, getPlotInstance, getSeries: () => getPlotInstance()!.series, getCanvas: () => { diff --git a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx index d45d5cf6ba1..1672e73f57b 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/EventsCanvas.tsx @@ -1,20 +1,29 @@ import { DataFrame } from '@grafana/data'; -import React, { useMemo } from 'react'; +import React, { useLayoutEffect, useMemo, useState } from 'react'; import { usePlotContext } from '../context'; -import { useRefreshAfterGraphRendered } from '../hooks'; import { Marker } from './Marker'; import { XYCanvas } from './XYCanvas'; +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; interface EventsCanvasProps { id: string; + config: UPlotConfigBuilder; events: DataFrame[]; renderEventMarker: (dataFrame: DataFrame, index: number) => React.ReactNode; mapEventToXYCoords: (dataFrame: DataFrame, index: number) => { x: number; y: number } | undefined; } -export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords }: EventsCanvasProps) { +export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords, config }: EventsCanvasProps) { const plotCtx = usePlotContext(); - const renderToken = useRefreshAfterGraphRendered(id); + // render token required to re-render annotation markers. Rendering lines happens in uPlot and the props do not change + // so we need to force the re-render when the draw hook was performed by uPlot + const [renderToken, setRenderToken] = useState(0); + + useLayoutEffect(() => { + config.addHook('draw', () => { + setRenderToken((s) => s + 1); + }); + }, [config, setRenderToken]); const eventMarkers = useMemo(() => { const markers: React.ReactNode[] = []; diff --git a/packages/grafana-ui/src/components/uPlot/hooks.test.ts b/packages/grafana-ui/src/components/uPlot/hooks.test.ts deleted file mode 100644 index 68df840f563..00000000000 --- a/packages/grafana-ui/src/components/uPlot/hooks.test.ts +++ /dev/null @@ -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, -// } -// `); -// }); -// }); -// }); diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts deleted file mode 100644 index 4589fe28526..00000000000 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ /dev/null @@ -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>({}); - - // arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised - const [arePluginsReady, setPluginsReady] = useState(false); - const cancellationToken = useRef(); - 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 = { - 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(); - - 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(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; -} diff --git a/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx index 307d6d82fcd..07ed93184a8 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/AnnotationsEditorPlugin.tsx @@ -1,59 +1,59 @@ -import React, { useRef } from 'react'; -import { SelectionPlugin } from './SelectionPlugin'; -import { css } from '@emotion/css'; -import { Button } from '../../Button'; -import useClickAway from 'react-use/lib/useClickAway'; - -interface AnnotationsEditorPluginProps { - onAnnotationCreate: () => void; -} - -/** - * @alpha - */ -export const AnnotationsEditorPlugin: React.FC = ({ onAnnotationCreate }) => { - const pluginId = 'AnnotationsEditorPlugin'; - - return ( - { - console.log(selection); - }} - lazy - > - {({ selection, clearSelection }) => { - return ; - }} - - ); -}; - -const AnnotationEditor: React.FC = ({ onClose, selection }) => { - const ref = useRef(null); - - useClickAway(ref, () => { - if (onClose) { - onClose(); - } - }); - - return ( -
-
- Annotations editor maybe? - -
-
- ); -}; +// import React, { useRef } from 'react'; +// import { SelectionPlugin } from './SelectionPlugin'; +// import { css } from '@emotion/css'; +// import { Button } from '../../Button'; +// import useClickAway from 'react-use/lib/useClickAway'; +// +// interface AnnotationsEditorPluginProps { +// onAnnotationCreate: () => void; +// } +// +// /** +// * @alpha +// */ +// export const AnnotationsEditorPlugin: React.FC = ({ onAnnotationCreate }) => { +// const pluginId = 'AnnotationsEditorPlugin'; +// +// return ( +// { +// console.log(selection); +// }} +// lazy +// > +// {({ selection, clearSelection }) => { +// return ; +// }} +// +// ); +// }; +// +// const AnnotationEditor: React.FC = ({ onClose, selection }) => { +// const ref = useRef(null); +// +// useClickAway(ref, () => { +// if (onClose) { +// onClose(); +// } +// }); +// +// return ( +//
+//
+// Annotations editor maybe? +// +//
+//
+// ); +// }; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx deleted file mode 100644 index 5bc524cdf0c..00000000000 --- a/packages/grafana-ui/src/components/uPlot/plugins/ClickPlugin.tsx +++ /dev/null @@ -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 = ({ 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('.u-cursor-pt')); - const plotCanvas = u.root.querySelector('.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 ( - <> - - - {({ coords }) => { - if (!point) { - return null; - } - - return children({ - point, - coords, - clearSelection, - }); - }} - - - ); -}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx deleted file mode 100644 index 14ca758016d..00000000000 --- a/packages/grafana-ui/src/components/uPlot/plugins/CursorPlugin.tsx +++ /dev/null @@ -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 = ({ id, children, capture = 'mousemove', lock = false }) => { - const pluginId = `CursorPlugin:${id}`; - const plotCanvas = useRef(null); - const plotCanvasBBox = useRef({ 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(null); - const [focusedPointIdx, setFocusedPointIdx] = useState(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('.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; -}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx deleted file mode 100644 index 64d10a9a8f4..00000000000 --- a/packages/grafana-ui/src/components/uPlot/plugins/SelectionPlugin.tsx +++ /dev/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 = ({ onSelect, onDismiss, lazy, id, children }) => { - const pluginId = `SelectionPlugin:${id}`; - const plotCtx = usePlotContext(); - const [selection, setSelection] = useState(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, - }); -}; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 364a3dcab6e..e5cd1039b28 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -1,9 +1,8 @@ -import React from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Portal } from '../../Portal/Portal'; import { usePlotContext } from '../context'; -import { CursorPlugin } from './CursorPlugin'; -import { VizTooltipContainer, SeriesTable, SeriesTableRowProps, TooltipDisplayMode } from '../../VizTooltip'; import { + CartesianCoords2D, DataFrame, FieldType, formattedValueToString, @@ -11,120 +10,144 @@ import { getFieldDisplayName, TimeZone, } from '@grafana/data'; -import { useGraphNGContext } from '../../GraphNG/hooks'; +import { SeriesTable, SeriesTableRowProps, TooltipDisplayMode, VizTooltipContainer } from '../../VizTooltip'; +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; +import { pluginLog } from '../utils'; interface TooltipPluginProps { mode?: TooltipDisplayMode; timeZone: TimeZone; - data: DataFrame[]; + data: DataFrame; + config: UPlotConfigBuilder; } /** * @alpha */ -export const TooltipPlugin: React.FC = ({ mode = 'single', timeZone, ...otherProps }) => { - const pluginId = 'PlotTooltip'; +export const TooltipPlugin: React.FC = ({ + mode = TooltipDisplayMode.Single, + timeZone, + config, + ...otherProps +}) => { const plotContext = usePlotContext(); - const graphContext = useGraphNGContext(); + const plotCanvas = useRef(); + const plotCanvasBBox = useRef({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }); + const [focusedSeriesIdx, setFocusedSeriesIdx] = useState(null); + const [focusedPointIdx, setFocusedPointIdx] = useState(null); + const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null); - let xField = graphContext.getXAxisField(); - if (!xField) { + // Debug logs + useEffect(() => { + pluginLog('TooltipPlugin', true, `Focused series: ${focusedSeriesIdx}, focused point: ${focusedPointIdx}`); + }, [focusedPointIdx, focusedSeriesIdx]); + + // Add uPlot hooks to the config, or re-add when the config changed + useLayoutEffect(() => { + const onMouseCapture = (e: MouseEvent) => { + setCoords({ + plotCanvas: { + x: e.clientX - plotCanvasBBox.current.left, + y: e.clientY - plotCanvasBBox.current.top, + }, + viewport: { + x: e.clientX, + y: e.clientY, + }, + }); + }; + + config.addHook('init', (u) => { + const canvas = u.root.querySelector('.u-over'); + plotCanvas.current = canvas || undefined; + plotCanvas.current?.addEventListener('mousemove', onMouseCapture); + plotCanvas.current?.addEventListener('mouseleave', () => {}); + }); + + config.addHook('setCursor', (u) => { + setFocusedPointIdx(u.cursor.idx === undefined ? null : u.cursor.idx); + }); + config.addHook('setSeries', (_, idx) => { + setFocusedSeriesIdx(idx); + }); + }, [config]); + + if (!plotContext.getPlotInstance() || focusedPointIdx === null) { return null; } + // GraphNG expects aligned data, let's take field 0 as x field. FTW + let xField = otherProps.data.fields[0]; + if (!xField) { + return null; + } const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); + let tooltip = null; + + const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text; + + // when interacting with a point in single mode + if (mode === TooltipDisplayMode.Single && focusedSeriesIdx !== null) { + const field = otherProps.data.fields[focusedSeriesIdx]; + const plotSeries = plotContext.getSeries(); + + const fieldFmt = field.display || getDisplayProcessor({ field, timeZone }); + const value = fieldFmt(plotContext.data[focusedSeriesIdx!][focusedPointIdx]); + + tooltip = ( + + ); + } + + 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 = ; + } + + if (!tooltip || !coords) { + return null; + } return ( - - {({ focusedSeriesIdx, focusedPointIdx, coords }) => { - if (!plotContext.getPlotInstance()) { - return null; - } - let tooltip = null; - - // when no no cursor interaction - if (focusedPointIdx === null) { - return null; - } - - const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text; - - // origin field/frame indexes for inspecting the data - const originFieldIndex = focusedSeriesIdx - ? graphContext.mapSeriesIndexToDataFrameFieldIndex(focusedSeriesIdx) - : null; - - // when interacting with a point in single mode - if (mode === 'single' && originFieldIndex !== null) { - const field = graphContext.alignedData.fields[focusedSeriesIdx!]; - const plotSeries = plotContext.getSeries(); - const fieldFmt = field.display || getDisplayProcessor({ field, timeZone }); - - const value = fieldFmt(field.values.get(focusedPointIdx)); - - tooltip = ( - - ); - } - - 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 = ; - } - - if (!tooltip) { - return null; - } - - return ( - - - {tooltip} - - - ); - }} - + + + {tooltip} + + ); }; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx index 826a982e96e..f3acb3c406a 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx @@ -1,8 +1,23 @@ -import React from 'react'; -import { SelectionPlugin } from './SelectionPlugin'; +import React, { useEffect, useLayoutEffect, useState } from 'react'; +import { UPlotConfigBuilder } from '../config/UPlotConfigBuilder'; +import { pluginLog } from '../utils'; + +interface Selection { + min: number; + max: number; + + // selection bounding box, relative to canvas + bbox: { + top: number; + left: number; + width: number; + height: number; + }; +} interface ZoomPluginProps { onZoom: (range: { from: number; to: number }) => void; + config: UPlotConfigBuilder; } // min px width that triggers zoom @@ -11,17 +26,40 @@ const MIN_ZOOM_DIST = 5; /** * @alpha */ -export const ZoomPlugin: React.FC = ({ onZoom }) => { - return ( - { - if (selection.bbox.width < MIN_ZOOM_DIST) { - return; - } - onZoom({ from: selection.min, to: selection.max }); - }} - /> - ); +export const ZoomPlugin: React.FC = ({ onZoom, config }) => { + const [selection, setSelection] = useState(null); + + useEffect(() => { + if (selection) { + pluginLog('ZoomPlugin', false, 'selected', selection); + if (selection.bbox.width < MIN_ZOOM_DIST) { + return; + } + onZoom({ from: selection.min, to: selection.max }); + } + }, [selection]); + + useLayoutEffect(() => { + config.addHook('setSelect', (u) => { + const min = u.posToVal(u.select.left, 'x'); + const max = u.posToVal(u.select.left + u.select.width, 'x'); + + setSelection({ + min, + max, + bbox: { + left: u.bbox.left / window.devicePixelRatio + u.select.left, + top: u.bbox.top / window.devicePixelRatio, + height: u.bbox.height / window.devicePixelRatio, + width: u.select.width, + }, + }); + + // manually hide selected region (since cursor.drag.setScale = false) + /* @ts-ignore */ + u.setSelect({ left: 0, width: 0 }, false); + }); + }, [config]); + + return null; }; diff --git a/packages/grafana-ui/src/components/uPlot/plugins/index.ts b/packages/grafana-ui/src/components/uPlot/plugins/index.ts index 86317d27502..4d8cfbb9f9f 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/index.ts +++ b/packages/grafana-ui/src/components/uPlot/plugins/index.ts @@ -1,5 +1,2 @@ -export { ClickPlugin } from './ClickPlugin'; -export { SelectionPlugin } from './SelectionPlugin'; export { ZoomPlugin } from './ZoomPlugin'; -export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin'; export { TooltipPlugin } from './TooltipPlugin'; diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index e894ae49256..f0cf60f63c9 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -1,5 +1,5 @@ import React from 'react'; -import uPlot, { Options, Hooks, AlignedData } from 'uplot'; +import { Options, AlignedData } from 'uplot'; import { TimeRange } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; @@ -8,13 +8,6 @@ export type PlotConfig = Pick< 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' >; -export type PlotPlugin = { - id: string; - /** can mutate provided opts as necessary */ - opts?: (self: uPlot, opts: Options) => void; - hooks: Hooks.ArraysOrFuncs; -}; - export interface PlotPluginProps { id: string; } diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 486963d8abb..703bc709c7c 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,9 +1,8 @@ import { DataFrame, dateTime, Field, FieldType } from '@grafana/data'; -import { AlignedData, Options } from 'uplot'; -import { PlotPlugin, PlotProps } from './types'; import { StackingMode } from './config'; import { createLogger } from '../../utils/logger'; import { attachDebugger } from '../../utils'; +import { AlignedData, Options, PaddingSide } from 'uplot'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; @@ -11,27 +10,28 @@ export function timeFormatToTemplate(f: string) { return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`); } -export function buildPlotConfig(props: PlotProps, plugins: Record): Options { - return { - width: props.width, - height: props.height, +const paddingSide: PaddingSide = (u, side, sidesWithAxes) => { + let hasCrossAxis = side % 2 ? sidesWithAxes[0] || sidesWithAxes[2] : sidesWithAxes[1] || sidesWithAxes[3]; + + return sidesWithAxes[side] || !hasCrossAxis ? 0 : 8; +}; + +export const DEFAULT_PLOT_CONFIG: Partial = { + focus: { + alpha: 1, + }, + cursor: { focus: { - alpha: 1, + prox: 30, }, - cursor: { - focus: { - prox: 30, - }, - }, - legend: { - show: false, - }, - plugins: Object.entries(plugins).map((p) => ({ - hooks: p[1].hooks, - })), - hooks: {}, - } as Options; -} + }, + legend: { + show: false, + }, + padding: [paddingSide, paddingSide, paddingSide, paddingSide], + series: [], + hooks: {}, +}; /** @internal */ export function preparePlotData(frame: DataFrame, keepFieldTypes?: FieldType[]): AlignedData { @@ -111,5 +111,5 @@ export function collectStackingGroups(f: Field, groups: Map, s /** @internal */ export const pluginLogger = createLogger('uPlot Plugin'); export const pluginLog = pluginLogger.logger; - +// pluginLogger.enable(); attachDebugger('graphng', undefined, pluginLogger); diff --git a/public/app/features/explore/ExploreGraphNGPanel.tsx b/public/app/features/explore/ExploreGraphNGPanel.tsx index 4685e1cec39..b0becbba1f8 100644 --- a/public/app/features/explore/ExploreGraphNGPanel.tsx +++ b/public/app/features/explore/ExploreGraphNGPanel.tsx @@ -57,6 +57,7 @@ export function ExploreGraphNGPanel({ }: Props) { const theme = useTheme(); const [showAllTimeSeries, setShowAllTimeSeries] = useState(false); + const [structureRev, setStructureRev] = useState(1); const [fieldConfig, setFieldConfig] = useState({ defaults: { color: { @@ -95,6 +96,7 @@ export function ExploreGraphNGPanel({ const onLegendClick = useCallback( (event: GraphNGLegendEvent) => { + setStructureRev((r) => r + 1); setFieldConfig(hideSeriesConfigFactory(event, fieldConfig, data)); }, [fieldConfig, data] @@ -122,6 +124,7 @@ export function ExploreGraphNGPanel({ - - - - {annotations && } + {(config, alignedDataFrame) => { + return ( + <> + + + + {annotations && ( + + )} + + ); + }} diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index 0f4c72de883..f8b2073ca8b 100755 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -66,6 +66,7 @@ export const BarChartPanel: React.FunctionComponent = ({ return ( = ({ return ( = ({ onLegendClick={onLegendClick} onSeriesColorChange={onSeriesColorChange} > - - - - {data.annotations && ( - - )} - {data.annotations && } + {(config, alignedDataFrame) => { + return ( + <> + + + + {data.annotations && ( + + )} + + {data.annotations && ( + + )} + + ); + }} ); }; diff --git a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx index 3a002df8e61..d98a15cfcda 100644 --- a/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/AnnotationsPlugin.tsx @@ -1,9 +1,10 @@ import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data'; -import { EventsCanvas, usePlotContext, useTheme } from '@grafana/ui'; -import React, { useCallback, useEffect, useRef } from 'react'; +import { EventsCanvas, UPlotConfigBuilder, usePlotContext, useTheme } from '@grafana/ui'; +import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { AnnotationMarker } from './AnnotationMarker'; interface AnnotationsPluginProps { + config: UPlotConfigBuilder; annotations: DataFrame[]; timeZone: TimeZone; } @@ -14,11 +15,10 @@ interface AnnotationsDataFrameViewDTO { tags: string[]; } -export const AnnotationsPlugin: React.FC = ({ annotations, timeZone }) => { - const pluginId = 'AnnotationsPlugin'; - const { isPlotReady, registerPlugin, getPlotInstance } = usePlotContext(); - +export const AnnotationsPlugin: React.FC = ({ annotations, timeZone, config }) => { const theme = useTheme(); + const { getPlotInstance } = usePlotContext(); + const annotationsRef = useRef>>(); const timeFormatter = useCallback( @@ -31,65 +31,54 @@ export const AnnotationsPlugin: React.FC = ({ annotation [timeZone] ); + // Update annotations views when new annotations came useEffect(() => { - if (isPlotReady) { - const views: Array> = []; + const views: Array> = []; - for (const frame of annotations) { - views.push(new DataFrameView(frame)); + for (const frame of annotations) { + views.push(new DataFrameView(frame)); + } + + annotationsRef.current = views; + }, [annotations]); + + useLayoutEffect(() => { + config.addHook('draw', (u) => { + // Render annotation lines on the canvas + /** + * We cannot rely on state value here, as it would require this effect to be dependent on the state value. + */ + if (!annotationsRef.current) { + return null; } - annotationsRef.current = views; - } - }, [isPlotReady, annotations]); + const ctx = u.ctx; + if (!ctx) { + return; + } + for (let i = 0; i < annotationsRef.current.length; i++) { + const annotationsView = annotationsRef.current[i]; + for (let j = 0; j < annotationsView.length; j++) { + const annotation = annotationsView.get(j); - useEffect(() => { - const unregister = registerPlugin({ - id: pluginId, - hooks: { - // Render annotation lines on the canvas - draw: (u) => { - /** - * We cannot rely on state value here, as it would require this effect to be dependent on the state value. - * This would make the plugin re-register making the entire plot to reinitialise. ref is the way to go :) - */ - if (!annotationsRef.current) { - return null; + if (!annotation.time) { + continue; } - const ctx = u.ctx; - if (!ctx) { - return; - } - for (let i = 0; i < annotationsRef.current.length; i++) { - const annotationsView = annotationsRef.current[i]; - for (let j = 0; j < annotationsView.length; j++) { - const annotation = annotationsView.get(j); - - if (!annotation.time) { - continue; - } - - const xpos = u.valToPos(annotation.time, 'x', true); - ctx.beginPath(); - ctx.lineWidth = 2; - ctx.strokeStyle = theme.palette.red; - ctx.setLineDash([5, 5]); - ctx.moveTo(xpos, u.bbox.top); - ctx.lineTo(xpos, u.bbox.top + u.bbox.height); - ctx.stroke(); - ctx.closePath(); - } - } - return; - }, - }, + const xpos = u.valToPos(annotation.time, 'x', true); + ctx.beginPath(); + ctx.lineWidth = 2; + ctx.strokeStyle = theme.palette.red; + ctx.setLineDash([5, 5]); + ctx.moveTo(xpos, u.bbox.top); + ctx.lineTo(xpos, u.bbox.top + u.bbox.height); + ctx.stroke(); + ctx.closePath(); + } + } + return; }); - - return () => { - unregister(); - }; - }, [registerPlugin, theme.palette.red]); + }, [config, theme]); const mapAnnotationToXYCoords = useCallback( (frame: DataFrame, index: number) => { @@ -120,6 +109,7 @@ export const AnnotationsPlugin: React.FC = ({ annotation return ( void; @@ -33,50 +35,135 @@ interface ContextMenuPluginProps { export const ContextMenuPlugin: React.FC = ({ data, + config, defaultItems, onClose, timeZone, replaceVariables, }) => { + const plotCanvas = useRef(); + const plotCanvasBBox = useRef({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 }); + const [coords, setCoords] = useState<{ viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D } | null>(null); + const [point, setPoint] = useState<{ seriesIdx: number | null; dataIdx: number | null } | null>(); const [isOpen, setIsOpen] = useState(false); - const onClick = useCallback(() => { - setIsOpen(!isOpen); - }, [isOpen]); + const openMenu = useCallback(() => { + setIsOpen(true); + }, [setIsOpen]); + + const closeMenu = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + const clearSelection = useCallback(() => { + pluginLog('ContextMenuPlugin', false, 'clearing click selection'); + setPoint(null); + }, [setPoint]); + + // Add uPlot hooks to the config, or re-add when the config changed + useLayoutEffect(() => { + const onMouseCapture = (e: MouseEvent) => { + setCoords({ + plotCanvas: { + x: e.clientX - plotCanvasBBox.current.left, + y: e.clientY - plotCanvasBBox.current.top, + }, + viewport: { + x: e.clientX, + y: e.clientY, + }, + }); + }; + + config.addHook('init', (u) => { + const canvas = u.root.querySelector('.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('.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 ( - - {({ point, coords, clearSelection }) => { - return ( - - { - clearSelection(); - if (onClose) { - onClose(); - } - }} - /> - - ); - }} - + <> + + {isOpen && coords && ( + { + clearSelection(); + closeMenu(); + if (onClose) { + onClose(); + } + }} + /> + )} + ); }; interface ContextMenuProps { - data: DataFrame[]; + data: DataFrame; defaultItems?: MenuItemsGroup[]; timeZone: TimeZone; onClose?: () => void; selection: { - point: { seriesIdx: number | null; dataIdx: number | null }; - coords: { plotCanvas: { x: number; y: number }; viewport: { x: number; y: number } }; + point?: { seriesIdx: number | null; dataIdx: number | null } | null; + coords: { plotCanvas: CartesianCoords2D; viewport: CartesianCoords2D }; }; replaceVariables?: InterpolateFunction; } @@ -90,7 +177,6 @@ export const ContextMenuView: React.FC = ({ ...otherProps }) => { const ref = useRef(null); - const graphContext = useGraphNGContext(); const onClose = () => { if (otherProps.onClose) { @@ -102,7 +188,7 @@ export const ContextMenuView: React.FC = ({ onClose(); }); - const xField = graphContext.getXAxisField(); + const xField = data.fields[0]; if (!xField) { return null; @@ -110,55 +196,54 @@ export const ContextMenuView: React.FC = ({ const items = defaultItems ? [...defaultItems] : []; let renderHeader: () => JSX.Element | null = () => null; - const { seriesIdx, dataIdx } = selection.point; - const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); + if (selection.point) { + const { seriesIdx, dataIdx } = selection.point; + const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); - if (seriesIdx && dataIdx) { - // origin field/frame indexes for inspecting the data - const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx); - const frame = data[originFieldIndex.frameIndex]; - const field = frame.fields[originFieldIndex.fieldIndex]; + if (seriesIdx && dataIdx) { + const field = data.fields[seriesIdx]; - const displayValue = field.display!(field.values.get(dataIdx)); + const displayValue = field.display!(field.values.get(dataIdx)); - const hasLinks = field.config.links && field.config.links.length > 0; + const hasLinks = field.config.links && field.config.links.length > 0; - if (hasLinks) { - const linksSupplier = getFieldLinksSupplier({ - display: displayValue, - name: field.name, - view: new DataFrameView(frame), - rowIndex: dataIdx, - colIndex: originFieldIndex.fieldIndex, - field: field.config, - hasLinks, - }); - - if (linksSupplier) { - items.push({ - items: linksSupplier.getLinks(replaceVariables).map((link) => { - return { - label: link.title, - ariaLabel: link.title, - url: link.href, - target: link.target, - icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName, - onClick: link.onClick, - }; - }), + if (hasLinks) { + const linksSupplier = getFieldLinksSupplier({ + display: displayValue, + name: field.name, + view: new DataFrameView(data), + rowIndex: dataIdx, + colIndex: seriesIdx, + field: field.config, + hasLinks, }); - } - } - // eslint-disable-next-line react/display-name - renderHeader = () => ( - - ); + if (linksSupplier) { + items.push({ + items: linksSupplier.getLinks(replaceVariables).map((link) => { + return { + label: link.title, + ariaLabel: link.title, + url: link.href, + target: link.target, + icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName, + onClick: link.onClick, + }; + }), + }); + } + } + + // eslint-disable-next-line react/display-name + renderHeader = () => ( + + ); + } } const renderMenuGroupItems = () => { diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx index b10e895d6c4..15be9af8283 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarsPlugin.tsx @@ -6,26 +6,26 @@ import { TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME, } from '@grafana/data'; -import { EventsCanvas, FIXED_UNIT, usePlotContext } from '@grafana/ui'; +import { EventsCanvas, FIXED_UNIT, UPlotConfigBuilder, usePlotContext } from '@grafana/ui'; import React, { useCallback } from 'react'; import { ExemplarMarker } from './ExemplarMarker'; interface ExemplarsPluginProps { + config: UPlotConfigBuilder; exemplars: DataFrame[]; timeZone: TimeZone; getFieldLinks: (field: Field, rowIndex: number) => Array>; } -export const ExemplarsPlugin: React.FC = ({ exemplars, timeZone, getFieldLinks }) => { +export const ExemplarsPlugin: React.FC = ({ exemplars, timeZone, getFieldLinks, config }) => { const plotCtx = usePlotContext(); - const mapExemplarToXYCoords = useCallback( (dataFrame: DataFrame, index: number) => { const plotInstance = plotCtx.getPlotInstance(); const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME); const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME); - if (!time || !value || !plotCtx.isPlotReady || !plotInstance) { + if (!time || !value || !plotInstance) { return undefined; } @@ -63,6 +63,7 @@ export const ExemplarsPlugin: React.FC = ({ exemplars, tim return ( = ({ onLegendClick={onLegendClick} onSeriesColorChange={onSeriesColorChange} > - - <>{/* needs to be an array */} + {(config, alignedDataFrame) => { + return ( + + ); + }} ); };