diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.test.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.test.tsx new file mode 100644 index 00000000000..0af6c6d90f3 --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.test.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import { GraphNG } from './GraphNG'; +import { render } from '@testing-library/react'; +import { + ArrayVector, + DataTransformerID, + dateTime, + FieldConfig, + FieldType, + MutableDataFrame, + standardTransformers, + standardTransformersRegistry, +} from '@grafana/data'; +import { Canvas, GraphCustomFieldConfig } from '..'; + +const mockData = () => { + const data = new MutableDataFrame(); + + data.addField({ + type: FieldType.time, + name: 'Time', + values: new ArrayVector([1602630000000, 1602633600000, 1602637200000]), + config: {}, + }); + + data.addField({ + type: FieldType.number, + name: 'Value', + values: new ArrayVector([10, 20, 5]), + config: { + custom: { + line: { show: true }, + }, + } as FieldConfig, + }); + + const timeRange = { + from: dateTime(1602673200000), + to: dateTime(1602680400000), + raw: { from: '1602673200000', to: '1602680400000' }, + }; + return { data, timeRange }; +}; + +describe('GraphNG', () => { + beforeAll(() => { + standardTransformersRegistry.setInit(() => [ + { + id: DataTransformerID.seriesToColumns, + editor: () => null, + transformation: standardTransformers.seriesToColumnsTransformer, + name: 'outer join', + }, + ]); + }); + + it('should throw when rendered without Canvas as child', () => { + const { data, timeRange } = mockData(); + expect(() => { + render(); + }).toThrow('Missing Canvas component as a child of the plot.'); + }); + + describe('data update', () => { + it('does not re-initialise uPlot when there are no field config changes', () => { + const { data, timeRange } = mockData(); + const onDataUpdateSpy = jest.fn(); + const onPlotInitSpy = jest.fn(); + + const { rerender } = render( + + + + ); + + data.fields[1].values.set(0, 1); + + rerender( + + + + ); + + expect(onPlotInitSpy).toBeCalledTimes(1); + expect(onDataUpdateSpy).toHaveBeenLastCalledWith([ + [1602630000, 1602633600, 1602637200], + [1, 20, 5], + ]); + }); + }); + + describe('config update', () => { + it('should skip plot intialization for width and height equal 0', () => { + const { data, timeRange } = mockData(); + const onPlotInitSpy = jest.fn(); + + render( + + + + ); + + expect(onPlotInitSpy).not.toBeCalled(); + }); + + it('reinitializes plot when number of series change', () => { + const { data, timeRange } = mockData(); + const onPlotInitSpy = jest.fn(); + + const { rerender } = render( + + + + ); + + data.addField({ + name: 'Value1', + type: FieldType.number, + values: new ArrayVector([1, 2, 3]), + config: { + custom: { + line: { show: true }, + }, + } as FieldConfig, + }); + + rerender( + + + + ); + + expect(onPlotInitSpy).toBeCalledTimes(2); + }); + + it('reinitializes plot when series field config changes', () => { + const { data, timeRange } = mockData(); + const onPlotInitSpy = jest.fn(); + + const { rerender } = render( + + + + ); + expect(onPlotInitSpy).toBeCalledTimes(1); + + data.fields[1].config.custom.line.width = 5; + + rerender( + + + + ); + + expect(onPlotInitSpy).toBeCalledTimes(2); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx new file mode 100644 index 00000000000..e077783ad8a --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from 'react'; +import { + DataFrame, + FieldConfig, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getTimeField, + systemDateFormats, +} from '@grafana/data'; +import { timeFormatToTemplate } from '../uPlot/utils'; +import { alignAndSortDataFramesByFieldName } from './utils'; +import { Area, Axis, Line, Point, Scale, SeriesGeometry } from '../uPlot/geometries'; +import { UPlotChart } from '../uPlot/Plot'; +import { GraphCustomFieldConfig, PlotProps } from '../uPlot/types'; +import { useTheme } from '../../themes'; + +const timeStampsConfig = [ + [3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'], + [3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'], + [ + 3600 * 24, + `{${timeFormatToTemplate(systemDateFormats.interval.day)}`, + 7, + `${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`, + ], + [ + 3600, + `{${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + 4, + `${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate( + systemDateFormats.interval.day + )}`, + ], + [ + 60, + `{${timeFormatToTemplate(systemDateFormats.interval.second)}`, + 4, + `${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate( + systemDateFormats.interval.day + )}`, + ], + [ + 1, + `:{ss}`, + 2, + `:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( + systemDateFormats.interval.minute + )}`, + ], + [ + 1e-3, + ':{ss}.{fff}', + 2, + `:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( + systemDateFormats.interval.minute + )}`, + ], +]; + +const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); + +const TIME_FIELD_NAME = 'Time'; + +interface GraphNGProps extends Omit { + data: DataFrame[]; +} + +export const GraphNG: React.FC = ({ data, children, ...plotProps }) => { + const theme = useTheme(); + const [alignedData, setAlignedData] = useState(null); + + useEffect(() => { + if (data.length === 0) { + setAlignedData(null); + return; + } + + const subscription = alignAndSortDataFramesByFieldName(data, TIME_FIELD_NAME).subscribe(setAlignedData); + + return function unsubscribe() { + subscription.unsubscribe(); + }; + }, [data]); + + if (!alignedData) { + return ( +
+

No data found in response

+
+ ); + } + + const geometries: React.ReactNode[] = []; + const scales: React.ReactNode[] = []; + const axes: React.ReactNode[] = []; + + let { timeIndex } = getTimeField(alignedData); + if (timeIndex === undefined) { + timeIndex = 0; // assuming first field represents x-domain + scales.push(); + } else { + scales.push(); + } + + axes.push(); + + let seriesIdx = 0; + const uniqueScales: Record = {}; + + for (let i = 0; i < alignedData.fields.length; i++) { + const seriesGeometry = []; + const field = alignedData.fields[i]; + const config = field.config as FieldConfig; + const customConfig = config.custom; + + if (i === timeIndex || field.type !== FieldType.number) { + continue; + } + + const fmt = field.display ?? defaultFormatter; + const scale = config.unit || '__fixed'; + + if (!uniqueScales[scale]) { + uniqueScales[scale] = true; + scales.push(); + axes.push( + formattedValueToString(fmt(v))} + /> + ); + } + + // need to update field state here because we use a transform to merge framesP + field.state = { ...field.state, seriesIndex: seriesIdx }; + + const colorMode = getFieldColorModeForField(field); + const seriesColor = colorMode.getCalculator(field, theme)(0, 0); + + if (customConfig?.line?.show) { + seriesGeometry.push( + + ); + } + + if (customConfig?.points?.show) { + seriesGeometry.push( + + ); + } + + if (customConfig?.fill?.alpha) { + seriesGeometry.push( + + ); + } + if (seriesGeometry.length > 1) { + geometries.push( + + {seriesGeometry} + + ); + } else { + geometries.push(seriesGeometry); + } + + seriesIdx++; + } + + return ( + + {scales} + {axes} + {geometries} + {children} + + ); +}; diff --git a/public/app/plugins/panel/graph3/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts similarity index 100% rename from public/app/plugins/panel/graph3/utils.ts rename to packages/grafana-ui/src/components/GraphNG/utils.ts diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 585052693c6..df343ac0d69 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -69,15 +69,6 @@ export { BigValueTextMode, } from './BigValue/BigValue'; -export { GraphCustomFieldConfig } from './uPlot/types'; -export { UPlotChart } from './uPlot/Plot'; -export * from './uPlot/geometries'; -export { usePlotConfigContext } from './uPlot/context'; -export { Canvas } from './uPlot/Canvas'; -export * from './uPlot/plugins'; -export { useRefreshAfterGraphRendered } from './uPlot/hooks'; -export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; - export { Gauge } from './Gauge/Gauge'; export { Graph } from './Graph/Graph'; export { GraphLegend } from './Graph/GraphLegend'; @@ -208,3 +199,14 @@ const LegacyForms = { Switch, }; export { LegacyForms, LegacyInputStatus }; + +// WIP, need renames and exports cleanup +export { GraphCustomFieldConfig } from './uPlot/types'; +export { UPlotChart } from './uPlot/Plot'; +export * from './uPlot/geometries'; +export { usePlotConfigContext } from './uPlot/context'; +export { Canvas } from './uPlot/Canvas'; +export * from './uPlot/plugins'; +export { useRefreshAfterGraphRendered } from './uPlot/hooks'; +export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; +export { GraphNG } from './GraphNG/GraphNG'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 6121b9e2c13..d1b398377ea 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -3,7 +3,7 @@ import { css } from 'emotion'; import uPlot from 'uplot'; import { usePrevious } from 'react-use'; import { buildPlotContext, PlotContext } from './context'; -import { pluginLog, preparePlotData, shouldReinitialisePlot } from './utils'; +import { pluginLog, preparePlotData, shouldInitialisePlot } from './utils'; import { usePlotConfig } from './hooks'; import { PlotProps } from './types'; @@ -13,6 +13,7 @@ import { PlotProps } from './types'; export const UPlotChart: React.FC = props => { const canvasRef = useRef(null); const [plotInstance, setPlotInstance] = useState(); + const plotData = useRef(); // uPlot config API const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig( @@ -20,36 +21,29 @@ export const UPlotChart: React.FC = props => { props.height, props.timeZone ); - const prevConfig = usePrevious(currentConfig); const getPlotInstance = useCallback(() => { if (!plotInstance) { throw new Error("Plot hasn't initialised yet"); } + return plotInstance; }, [plotInstance]); - // Main function initialising uPlot. If final config is not settled it will do nothing - const initPlot = () => { - if (!currentConfig || !canvasRef.current) { - return null; - } - const data = preparePlotData(props.data); - pluginLog('uPlot core', false, 'initialized with', data, currentConfig); - return new uPlot(currentConfig, data, canvasRef.current); - }; - // Callback executed when there was no change in plot config const updateData = useCallback(() => { - if (!plotInstance) { + if (!plotInstance || !plotData.current) { return; } - const data = preparePlotData(props.data); - pluginLog('uPlot core', false, 'updating plot data(throttled log!)'); + pluginLog('uPlot core', false, 'updating plot data(throttled log!)', plotData.current); // If config hasn't changed just update uPlot's data - plotInstance.setData(data); - }, [plotInstance, props.data]); + plotInstance.setData(plotData.current); + + if (props.onDataUpdate) { + props.onDataUpdate(plotData.current); + } + }, [plotInstance, props.onDataUpdate]); // Destroys previous plot instance when plot re-initialised useEffect(() => { @@ -59,22 +53,38 @@ export const UPlotChart: React.FC = props => { }; }, [plotInstance]); + useLayoutEffect(() => { + plotData.current = preparePlotData(props.data); + }, [props.data]); + // Decides if plot should update data or re-initialise - useEffect(() => { - if (!currentConfig) { + useLayoutEffect(() => { + // Make sure everything is ready before proceeding + if (!currentConfig || !plotData.current) { return; } - if (shouldReinitialisePlot(prevConfig, currentConfig)) { - const instance = initPlot(); - if (!instance) { - return; + // Do nothing if there is data vs series config mismatch. This may happen when the data was updated and made this + // effect fire before the config update triggered the effect. + if (currentConfig.series.length !== plotData.current.length) { + return; + } + + if (shouldInitialisePlot(prevConfig, currentConfig)) { + if (!canvasRef.current) { + throw new Error('Missing Canvas component as a child of the plot.'); } + const instance = initPlot(plotData.current, currentConfig, canvasRef.current); + + if (props.onPlotInit) { + props.onPlotInit(); + } + setPlotInstance(instance); } else { updateData(); } - }, [props.data, props.timeRange, props.timeZone, currentConfig, setPlotInstance]); + }, [currentConfig, updateData, setPlotInstance, props.onPlotInit]); // When size props changed update plot size synchronously useLayoutEffect(() => { @@ -114,3 +124,9 @@ export const UPlotChart: React.FC = props => { ); }; + +// Main function initialising uPlot. If final config is not settled it will do nothing +function initPlot(data: uPlot.AlignedData, config: uPlot.Options, ref: HTMLDivElement) { + pluginLog('uPlot core', false, 'initialized with', data, config); + return new uPlot(config, data, ref); +} diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx index 58cec2caf01..bb8196785de 100644 --- a/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx +++ b/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { AxisProps } from './types'; import { usePlotConfigContext } from '../context'; import { useTheme } from '../../../themes'; @@ -32,6 +32,7 @@ export const useAxisConfig = (getConfig: () => any) => { export const Axis: React.FC = props => { const theme = useTheme(); + const gridColor = useMemo(() => (theme.isDark ? theme.palette.gray1 : theme.palette.gray4), [theme]); const { scaleKey, label, @@ -54,7 +55,12 @@ export const Axis: React.FC = props => { side, grid: { show: grid, - stroke: theme.palette.gray4, + stroke: gridColor, + width: 1 / devicePixelRatio, + }, + ticks: { + show: true, + stroke: gridColor, width: 1 / devicePixelRatio, }, values: values ? values : formatValue ? (u: uPlot, vals: any[]) => vals.map(v => formatValue(v)) : undefined, diff --git a/packages/grafana-ui/src/components/uPlot/hooks.test.ts b/packages/grafana-ui/src/components/uPlot/hooks.test.ts index a722b7919c4..4cc0651b893 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.test.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.test.ts @@ -3,8 +3,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; describe('usePlotConfig', () => { it('returns default plot config', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); - await waitForNextUpdate(); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); expect(result.current.currentConfig).toMatchInlineSnapshot(` Object { @@ -34,7 +33,7 @@ describe('usePlotConfig', () => { }); describe('series config', () => { it('should add series', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addSeries = result.current.addSeries; act(() => { @@ -42,7 +41,6 @@ describe('usePlotConfig', () => { stroke: '#ff0000', }); }); - await waitForNextUpdate(); expect(result.current.currentConfig?.series).toHaveLength(2); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -76,7 +74,7 @@ describe('usePlotConfig', () => { }); it('should update series', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addSeries = result.current.addSeries; act(() => { @@ -88,7 +86,6 @@ describe('usePlotConfig', () => { stroke: '#00ff00', }); }); - await waitForNextUpdate(); expect(result.current.currentConfig?.series).toHaveLength(2); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -122,7 +119,7 @@ describe('usePlotConfig', () => { }); it('should remove series', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addSeries = result.current.addSeries; act(() => { @@ -132,7 +129,6 @@ describe('usePlotConfig', () => { removeSeries(); }); - await waitForNextUpdate(); expect(result.current.currentConfig?.series).toHaveLength(1); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -165,7 +161,7 @@ describe('usePlotConfig', () => { describe('axis config', () => { it('should add axis', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addAxis = result.current.addAxis; act(() => { @@ -173,7 +169,6 @@ describe('usePlotConfig', () => { side: 1, }); }); - await waitForNextUpdate(); expect(result.current.currentConfig?.axes).toHaveLength(1); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -208,7 +203,7 @@ describe('usePlotConfig', () => { }); it('should update axis', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addAxis = result.current.addAxis; act(() => { @@ -220,7 +215,6 @@ describe('usePlotConfig', () => { side: 3, }); }); - await waitForNextUpdate(); expect(result.current.currentConfig?.axes).toHaveLength(1); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -255,7 +249,7 @@ describe('usePlotConfig', () => { }); it('should remove axis', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addAxis = result.current.addAxis; act(() => { @@ -265,7 +259,6 @@ describe('usePlotConfig', () => { removeAxis(); }); - await waitForNextUpdate(); expect(result.current.currentConfig?.axes).toHaveLength(0); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -298,7 +291,7 @@ describe('usePlotConfig', () => { describe('scales config', () => { it('should add scale', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addScale = result.current.addScale; act(() => { @@ -306,7 +299,6 @@ describe('usePlotConfig', () => { time: true, }); }); - await waitForNextUpdate(); expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -341,7 +333,7 @@ describe('usePlotConfig', () => { }); it('should update scale', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addScale = result.current.addScale; act(() => { @@ -353,7 +345,6 @@ describe('usePlotConfig', () => { time: false, }); }); - await waitForNextUpdate(); expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -388,7 +379,7 @@ describe('usePlotConfig', () => { }); it('should remove scale', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const addScale = result.current.addScale; act(() => { @@ -398,7 +389,6 @@ describe('usePlotConfig', () => { removeScale(); }); - await waitForNextUpdate(); expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(0); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -431,7 +421,7 @@ describe('usePlotConfig', () => { describe('plugins config', () => { it('should register plugin', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const registerPlugin = result.current.registerPlugin; act(() => { @@ -440,7 +430,6 @@ describe('usePlotConfig', () => { hooks: {}, }); }); - await waitForNextUpdate(); expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1); expect(result.current.currentConfig).toMatchInlineSnapshot(` @@ -475,7 +464,7 @@ describe('usePlotConfig', () => { }); it('should unregister plugin', async () => { - const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); + const { result } = renderHook(() => usePlotConfig(0, 0, 'browser')); const registerPlugin = result.current.registerPlugin; let unregister: () => void; @@ -485,7 +474,6 @@ describe('usePlotConfig', () => { hooks: {}, }); }); - await waitForNextUpdate(); expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1); diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts index 2b9b6518347..c44921b00e1 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -14,8 +14,8 @@ export const usePlotPlugins = () => { // 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) { @@ -29,7 +29,9 @@ export const usePlotPlugins = () => { * and arePluginsReady will be deferred to next animation frame. */ cancellationToken.current = window.requestAnimationFrame(function() { - setPluginsReady(true); + if (isMounted.current) { + setPluginsReady(true); + } }); }, [cancellationToken, setPluginsReady]); @@ -66,9 +68,9 @@ export const usePlotPlugins = () => { useEffect(() => { checkPluginsReady(); return () => { + isMounted.current = false; if (cancellationToken.current) { window.cancelAnimationFrame(cancellationToken.current); - cancellationToken.current = undefined; } }; }, []); @@ -92,6 +94,7 @@ export const DEFAULT_PLOT_CONFIG = { legend: { show: false, }, + series: [], hooks: {}, }; export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) => { @@ -113,7 +116,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) return fmt; }, [timeZone]); - const defaultConfig = useMemo(() => { + const defaultConfig = useMemo(() => { return { ...DEFAULT_PLOT_CONFIG, width, @@ -122,7 +125,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) hooks: p[1].hooks, })), tzDate, - } as any; + }; }, [plugins, width, height, tzDate]); useEffect(() => { diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 4026d8a6c15..4cd97dae04b 100644 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -56,9 +56,13 @@ export interface PlotPluginProps { export interface PlotProps { data: DataFrame; - width: number; - height: number; timeRange: TimeRange; timeZone: TimeZone; - children: React.ReactNode[]; + width: number; + height: number; + children?: React.ReactNode | React.ReactNode[]; + /** Callback performed when uPlot data is updated */ + onDataUpdate?: (data: uPlot.AlignedData) => {}; + /** Callback performed when uPlot is (re)initialized */ + onPlotInit?: () => {}; } diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index a169f45e9b8..ae7037c9745 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -93,16 +93,18 @@ const isPlottingTime = (config: uPlot.Options) => { * Based on two config objects indicates whether or not uPlot needs reinitialisation * This COULD be done based on data frames, but keeping it this way for now as a simplification */ -export const shouldReinitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => { +export const shouldInitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => { if (!config && !prevConfig) { return false; } - if (!prevConfig && config) { + if (config) { if (config.width === 0 || config.height === 0) { return false; } - return true; + if (!prevConfig) { + return true; + } } if (isPlottingTime(config!) && prevConfig!.tzDate !== config!.tzDate) { diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx index aaa0944fdb5..53698f75295 100644 --- a/public/app/plugins/panel/graph3/GraphPanel.tsx +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -1,90 +1,21 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { - Area, Canvas, ContextMenuPlugin, - GraphCustomFieldConfig, LegendDisplayMode, LegendPlugin, - Line, - Point, - Scale, - SeriesGeometry, TooltipPlugin, - UPlotChart, ZoomPlugin, - useTheme, + GraphNG, } from '@grafana/ui'; - -import { - DataFrame, - FieldConfig, - FieldType, - formattedValueToString, - getTimeField, - PanelProps, - getFieldColorModeForField, - systemDateFormats, -} from '@grafana/data'; - +import { PanelProps } from '@grafana/data'; import { Options } from './types'; -import { alignAndSortDataFramesByFieldName } from './utils'; import { VizLayout } from './VizLayout'; - -import { Axis } from '@grafana/ui/src/components/uPlot/geometries/Axis'; -import { timeFormatToTemplate } from '@grafana/ui/src/components/uPlot/utils'; import { AnnotationsPlugin } from './plugins/AnnotationsPlugin'; import { ExemplarsPlugin } from './plugins/ExemplarsPlugin'; interface GraphPanelProps extends PanelProps {} -const TIME_FIELD_NAME = 'Time'; - -const timeStampsConfig = [ - [3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'], - [3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'], - [ - 3600 * 24, - `{${timeFormatToTemplate(systemDateFormats.interval.day)}`, - 7, - `${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`, - ], - [ - 3600, - `{${timeFormatToTemplate(systemDateFormats.interval.minute)}`, - 4, - `${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate( - systemDateFormats.interval.day - )}`, - ], - [ - 60, - `{${timeFormatToTemplate(systemDateFormats.interval.second)}`, - 4, - `${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate( - systemDateFormats.interval.day - )}`, - ], - [ - 1, - `:{ss}`, - 2, - `:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( - systemDateFormats.interval.minute - )}`, - ], - [ - 1e-3, - ':{ss}.{fff}', - 2, - `:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( - systemDateFormats.interval.minute - )}`, - ], -]; - -const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); - export const GraphPanel: React.FC = ({ data, timeRange, @@ -94,117 +25,6 @@ export const GraphPanel: React.FC = ({ options, onChangeTimeRange, }) => { - const theme = useTheme(); - const [alignedData, setAlignedData] = useState(null); - - useEffect(() => { - if (!data || !data.series?.length) { - setAlignedData(null); - return; - } - - const subscription = alignAndSortDataFramesByFieldName(data.series, TIME_FIELD_NAME).subscribe(setAlignedData); - - return function unsubscribe() { - subscription.unsubscribe(); - }; - }, [data]); - - if (!alignedData) { - return ( -
-

No data found in response

-
- ); - } - const geometries: React.ReactNode[] = []; - const scales: React.ReactNode[] = []; - const axes: React.ReactNode[] = []; - - let { timeIndex } = getTimeField(alignedData); - - if (timeIndex === undefined) { - timeIndex = 0; // assuming first field represents x-domain - scales.push(); - } else { - scales.push(); - } - - axes.push(); - - let seriesIdx = 0; - const uniqueScales: Record = {}; - - for (let i = 0; i < alignedData.fields.length; i++) { - const seriesGeometry = []; - const field = alignedData.fields[i]; - const config = field.config as FieldConfig; - const customConfig = config.custom; - - if (i === timeIndex || field.type !== FieldType.number) { - continue; - } - - const fmt = field.display ?? defaultFormatter; - const scale = config.unit || '__fixed'; - - if (!uniqueScales[scale]) { - uniqueScales[scale] = true; - scales.push(); - axes.push( - formattedValueToString(fmt(v))} - /> - ); - } - - // need to update field state here because we use a transform to merge frames - field.state = { ...field.state, seriesIndex: seriesIdx }; - - const colorMode = getFieldColorModeForField(field); - const seriesColor = colorMode.getCalculator(field, theme)(0, 0); - - if (customConfig?.line?.show) { - seriesGeometry.push( - - ); - } - - if (customConfig?.points?.show) { - seriesGeometry.push( - - ); - } - - if (customConfig?.fill?.alpha) { - seriesGeometry.push( - - ); - } - if (seriesGeometry.length > 1) { - geometries.push( - - {seriesGeometry} - - ); - } else { - geometries.push(seriesGeometry); - } - - seriesIdx++; - } - return ( {({ builder, getLayout }) => { @@ -230,10 +50,7 @@ export const GraphPanel: React.FC = ({ } return ( - - {scales} - {axes} - {geometries} + {builder.addSlot('canvas', ).render()} @@ -243,7 +60,7 @@ export const GraphPanel: React.FC = ({ {data.annotations && } {/* TODO: */} {/**/} - +
); }} diff --git a/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx b/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx index dbcdddc7054..337a3f15945 100644 --- a/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx +++ b/public/app/plugins/panel/graph3/plugins/AnnotationsPlugin.tsx @@ -33,7 +33,7 @@ export const AnnotationsPlugin: React.FC = ({ annotation ); useEffect(() => { - if (plotCtx.isPlotReady && annotations.length > 0) { + if (plotCtx.isPlotReady) { const views: Array> = []; for (const frame of annotations) { diff --git a/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx b/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx index 0e9c9b088f5..2489f7ce785 100644 --- a/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx +++ b/public/app/plugins/panel/graph3/plugins/ExemplarsPlugin.tsx @@ -42,7 +42,7 @@ export const ExemplarsPlugin: React.FC = ({ exemplars, tim // THIS EVENT ONLY MOCKS EXEMPLAR Y VALUE!!!! TO BE REMOVED WHEN WE GET CORRECT EXEMPLARS SHAPE VIA PROPS useEffect(() => { - if (plotCtx.isPlotReady && exemplars.length) { + if (plotCtx.isPlotReady) { const mocks: DataFrame[] = []; for (const frame of exemplars) {