Commit f989e371 by Dominik Prokop Committed by GitHub

Graph NG: fix toggling queries and extract Graph component from graph3 panel (#28290)

* Fix issue when data and config is not in sync

* Extract GraphNG component from graph panel and add some tests coverage

* Update packages/grafana-ui/src/components/uPlot/hooks.test.ts

* Update packages/grafana-ui/src/components/uPlot/hooks.test.ts

* Update packages/grafana-ui/src/components/uPlot/hooks.test.ts

* Fix grid color and annotations refresh
parent 448114f6
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<GraphCustomFieldConfig>,
});
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(<GraphNG data={[data]} timeRange={timeRange} timeZone={'browser'} width={100} height={100} />);
}).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(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
onDataUpdate={onDataUpdateSpy}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
data.fields[1].values.set(0, 1);
rerender(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
onDataUpdate={onDataUpdateSpy}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
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(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={0}
height={0}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
expect(onPlotInitSpy).not.toBeCalled();
});
it('reinitializes plot when number of series change', () => {
const { data, timeRange } = mockData();
const onPlotInitSpy = jest.fn();
const { rerender } = render(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
data.addField({
name: 'Value1',
type: FieldType.number,
values: new ArrayVector([1, 2, 3]),
config: {
custom: {
line: { show: true },
},
} as FieldConfig<GraphCustomFieldConfig>,
});
rerender(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(2);
});
it('reinitializes plot when series field config changes', () => {
const { data, timeRange } = mockData();
const onPlotInitSpy = jest.fn();
const { rerender } = render(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(1);
data.fields[1].config.custom.line.width = 5;
rerender(
<GraphNG
data={[data]}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(2);
});
});
});
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<PlotProps, 'data'> {
data: DataFrame[];
}
export const GraphNG: React.FC<GraphNGProps> = ({ data, children, ...plotProps }) => {
const theme = useTheme();
const [alignedData, setAlignedData] = useState<DataFrame | null>(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 (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
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(<Scale key="scale-x" scaleKey="x" />);
} else {
scales.push(<Scale key="scale-x" scaleKey="x" time />);
}
axes.push(<Axis key="axis-scale--x" scaleKey="x" values={timeStampsConfig} side={2} />);
let seriesIdx = 0;
const uniqueScales: Record<string, boolean> = {};
for (let i = 0; i < alignedData.fields.length; i++) {
const seriesGeometry = [];
const field = alignedData.fields[i];
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
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(<Scale key={`scale-${scale}`} scaleKey={scale} />);
axes.push(
<Axis
key={`axis-${scale}-${i}`}
scaleKey={scale}
label={config.custom?.axis?.label}
size={config.custom?.axis?.width}
side={config.custom?.axis?.side || 3}
grid={config.custom?.axis?.grid}
formatValue={v => 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(
<Line
key={`line-${scale}-${i}`}
scaleKey={scale}
stroke={seriesColor}
width={customConfig?.line.show ? customConfig?.line.width || 1 : 0}
/>
);
}
if (customConfig?.points?.show) {
seriesGeometry.push(
<Point key={`point-${scale}-${i}`} scaleKey={scale} size={customConfig?.points?.radius} stroke={seriesColor} />
);
}
if (customConfig?.fill?.alpha) {
seriesGeometry.push(
<Area key={`area-${scale}-${i}`} scaleKey={scale} fill={customConfig?.fill.alpha} color={seriesColor} />
);
}
if (seriesGeometry.length > 1) {
geometries.push(
<SeriesGeometry key={`seriesGeometry-${scale}-${i}`} scaleKey={scale}>
{seriesGeometry}
</SeriesGeometry>
);
} else {
geometries.push(seriesGeometry);
}
seriesIdx++;
}
return (
<UPlotChart data={alignedData} {...plotProps}>
{scales}
{axes}
{geometries}
{children}
</UPlotChart>
);
};
...@@ -69,15 +69,6 @@ export { ...@@ -69,15 +69,6 @@ export {
BigValueTextMode, BigValueTextMode,
} from './BigValue/BigValue'; } 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 { Gauge } from './Gauge/Gauge';
export { Graph } from './Graph/Graph'; export { Graph } from './Graph/Graph';
export { GraphLegend } from './Graph/GraphLegend'; export { GraphLegend } from './Graph/GraphLegend';
...@@ -208,3 +199,14 @@ const LegacyForms = { ...@@ -208,3 +199,14 @@ const LegacyForms = {
Switch, Switch,
}; };
export { LegacyForms, LegacyInputStatus }; 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';
...@@ -3,7 +3,7 @@ import { css } from 'emotion'; ...@@ -3,7 +3,7 @@ import { css } from 'emotion';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { usePrevious } from 'react-use'; import { usePrevious } from 'react-use';
import { buildPlotContext, PlotContext } from './context'; import { buildPlotContext, PlotContext } from './context';
import { pluginLog, preparePlotData, shouldReinitialisePlot } from './utils'; import { pluginLog, preparePlotData, shouldInitialisePlot } from './utils';
import { usePlotConfig } from './hooks'; import { usePlotConfig } from './hooks';
import { PlotProps } from './types'; import { PlotProps } from './types';
...@@ -13,6 +13,7 @@ import { PlotProps } from './types'; ...@@ -13,6 +13,7 @@ import { PlotProps } from './types';
export const UPlotChart: React.FC<PlotProps> = props => { export const UPlotChart: React.FC<PlotProps> = props => {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const [plotInstance, setPlotInstance] = useState<uPlot>(); const [plotInstance, setPlotInstance] = useState<uPlot>();
const plotData = useRef<uPlot.AlignedData>();
// uPlot config API // uPlot config API
const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig( const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig(
...@@ -20,36 +21,29 @@ export const UPlotChart: React.FC<PlotProps> = props => { ...@@ -20,36 +21,29 @@ export const UPlotChart: React.FC<PlotProps> = props => {
props.height, props.height,
props.timeZone props.timeZone
); );
const prevConfig = usePrevious(currentConfig); const prevConfig = usePrevious(currentConfig);
const getPlotInstance = useCallback(() => { const getPlotInstance = useCallback(() => {
if (!plotInstance) { if (!plotInstance) {
throw new Error("Plot hasn't initialised yet"); throw new Error("Plot hasn't initialised yet");
} }
return plotInstance; return plotInstance;
}, [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 // Callback executed when there was no change in plot config
const updateData = useCallback(() => { const updateData = useCallback(() => {
if (!plotInstance) { if (!plotInstance || !plotData.current) {
return; return;
} }
const data = preparePlotData(props.data); pluginLog('uPlot core', false, 'updating plot data(throttled log!)', plotData.current);
pluginLog('uPlot core', false, 'updating plot data(throttled log!)');
// If config hasn't changed just update uPlot's data // If config hasn't changed just update uPlot's data
plotInstance.setData(data); plotInstance.setData(plotData.current);
}, [plotInstance, props.data]);
if (props.onDataUpdate) {
props.onDataUpdate(plotData.current);
}
}, [plotInstance, props.onDataUpdate]);
// Destroys previous plot instance when plot re-initialised // Destroys previous plot instance when plot re-initialised
useEffect(() => { useEffect(() => {
...@@ -59,22 +53,38 @@ export const UPlotChart: React.FC<PlotProps> = props => { ...@@ -59,22 +53,38 @@ export const UPlotChart: React.FC<PlotProps> = props => {
}; };
}, [plotInstance]); }, [plotInstance]);
useLayoutEffect(() => {
plotData.current = preparePlotData(props.data);
}, [props.data]);
// Decides if plot should update data or re-initialise // Decides if plot should update data or re-initialise
useEffect(() => { useLayoutEffect(() => {
if (!currentConfig) { // Make sure everything is ready before proceeding
if (!currentConfig || !plotData.current) {
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; return;
} }
if (shouldReinitialisePlot(prevConfig, currentConfig)) { if (shouldInitialisePlot(prevConfig, currentConfig)) {
const instance = initPlot(); if (!canvasRef.current) {
if (!instance) { throw new Error('Missing Canvas component as a child of the plot.');
return;
} }
const instance = initPlot(plotData.current, currentConfig, canvasRef.current);
if (props.onPlotInit) {
props.onPlotInit();
}
setPlotInstance(instance); setPlotInstance(instance);
} else { } else {
updateData(); updateData();
} }
}, [props.data, props.timeRange, props.timeZone, currentConfig, setPlotInstance]); }, [currentConfig, updateData, setPlotInstance, props.onPlotInit]);
// When size props changed update plot size synchronously // When size props changed update plot size synchronously
useLayoutEffect(() => { useLayoutEffect(() => {
...@@ -114,3 +124,9 @@ export const UPlotChart: React.FC<PlotProps> = props => { ...@@ -114,3 +124,9 @@ export const UPlotChart: React.FC<PlotProps> = props => {
</PlotContext.Provider> </PlotContext.Provider>
); );
}; };
// 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);
}
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { AxisProps } from './types'; import { AxisProps } from './types';
import { usePlotConfigContext } from '../context'; import { usePlotConfigContext } from '../context';
import { useTheme } from '../../../themes'; import { useTheme } from '../../../themes';
...@@ -32,6 +32,7 @@ export const useAxisConfig = (getConfig: () => any) => { ...@@ -32,6 +32,7 @@ export const useAxisConfig = (getConfig: () => any) => {
export const Axis: React.FC<AxisProps> = props => { export const Axis: React.FC<AxisProps> = props => {
const theme = useTheme(); const theme = useTheme();
const gridColor = useMemo(() => (theme.isDark ? theme.palette.gray1 : theme.palette.gray4), [theme]);
const { const {
scaleKey, scaleKey,
label, label,
...@@ -54,7 +55,12 @@ export const Axis: React.FC<AxisProps> = props => { ...@@ -54,7 +55,12 @@ export const Axis: React.FC<AxisProps> = props => {
side, side,
grid: { grid: {
show: grid, show: grid,
stroke: theme.palette.gray4, stroke: gridColor,
width: 1 / devicePixelRatio,
},
ticks: {
show: true,
stroke: gridColor,
width: 1 / devicePixelRatio, width: 1 / devicePixelRatio,
}, },
values: values ? values : formatValue ? (u: uPlot, vals: any[]) => vals.map(v => formatValue(v)) : undefined, values: values ? values : formatValue ? (u: uPlot, vals: any[]) => vals.map(v => formatValue(v)) : undefined,
......
...@@ -3,8 +3,7 @@ import { renderHook, act } from '@testing-library/react-hooks'; ...@@ -3,8 +3,7 @@ import { renderHook, act } from '@testing-library/react-hooks';
describe('usePlotConfig', () => { describe('usePlotConfig', () => {
it('returns default plot config', async () => { it('returns default plot config', async () => {
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser')); const { result } = renderHook(() => usePlotConfig(0, 0, 'browser'));
await waitForNextUpdate();
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
Object { Object {
...@@ -34,7 +33,7 @@ describe('usePlotConfig', () => { ...@@ -34,7 +33,7 @@ describe('usePlotConfig', () => {
}); });
describe('series config', () => { describe('series config', () => {
it('should add series', async () => { 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; const addSeries = result.current.addSeries;
act(() => { act(() => {
...@@ -42,7 +41,6 @@ describe('usePlotConfig', () => { ...@@ -42,7 +41,6 @@ describe('usePlotConfig', () => {
stroke: '#ff0000', stroke: '#ff0000',
}); });
}); });
await waitForNextUpdate();
expect(result.current.currentConfig?.series).toHaveLength(2); expect(result.current.currentConfig?.series).toHaveLength(2);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -76,7 +74,7 @@ describe('usePlotConfig', () => { ...@@ -76,7 +74,7 @@ describe('usePlotConfig', () => {
}); });
it('should update series', async () => { 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; const addSeries = result.current.addSeries;
act(() => { act(() => {
...@@ -88,7 +86,6 @@ describe('usePlotConfig', () => { ...@@ -88,7 +86,6 @@ describe('usePlotConfig', () => {
stroke: '#00ff00', stroke: '#00ff00',
}); });
}); });
await waitForNextUpdate();
expect(result.current.currentConfig?.series).toHaveLength(2); expect(result.current.currentConfig?.series).toHaveLength(2);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -122,7 +119,7 @@ describe('usePlotConfig', () => { ...@@ -122,7 +119,7 @@ describe('usePlotConfig', () => {
}); });
it('should remove series', async () => { 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; const addSeries = result.current.addSeries;
act(() => { act(() => {
...@@ -132,7 +129,6 @@ describe('usePlotConfig', () => { ...@@ -132,7 +129,6 @@ describe('usePlotConfig', () => {
removeSeries(); removeSeries();
}); });
await waitForNextUpdate();
expect(result.current.currentConfig?.series).toHaveLength(1); expect(result.current.currentConfig?.series).toHaveLength(1);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -165,7 +161,7 @@ describe('usePlotConfig', () => { ...@@ -165,7 +161,7 @@ describe('usePlotConfig', () => {
describe('axis config', () => { describe('axis config', () => {
it('should add axis', async () => { 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; const addAxis = result.current.addAxis;
act(() => { act(() => {
...@@ -173,7 +169,6 @@ describe('usePlotConfig', () => { ...@@ -173,7 +169,6 @@ describe('usePlotConfig', () => {
side: 1, side: 1,
}); });
}); });
await waitForNextUpdate();
expect(result.current.currentConfig?.axes).toHaveLength(1); expect(result.current.currentConfig?.axes).toHaveLength(1);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -208,7 +203,7 @@ describe('usePlotConfig', () => { ...@@ -208,7 +203,7 @@ describe('usePlotConfig', () => {
}); });
it('should update axis', async () => { 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; const addAxis = result.current.addAxis;
act(() => { act(() => {
...@@ -220,7 +215,6 @@ describe('usePlotConfig', () => { ...@@ -220,7 +215,6 @@ describe('usePlotConfig', () => {
side: 3, side: 3,
}); });
}); });
await waitForNextUpdate();
expect(result.current.currentConfig?.axes).toHaveLength(1); expect(result.current.currentConfig?.axes).toHaveLength(1);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -255,7 +249,7 @@ describe('usePlotConfig', () => { ...@@ -255,7 +249,7 @@ describe('usePlotConfig', () => {
}); });
it('should remove axis', async () => { 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; const addAxis = result.current.addAxis;
act(() => { act(() => {
...@@ -265,7 +259,6 @@ describe('usePlotConfig', () => { ...@@ -265,7 +259,6 @@ describe('usePlotConfig', () => {
removeAxis(); removeAxis();
}); });
await waitForNextUpdate();
expect(result.current.currentConfig?.axes).toHaveLength(0); expect(result.current.currentConfig?.axes).toHaveLength(0);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -298,7 +291,7 @@ describe('usePlotConfig', () => { ...@@ -298,7 +291,7 @@ describe('usePlotConfig', () => {
describe('scales config', () => { describe('scales config', () => {
it('should add scale', async () => { 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; const addScale = result.current.addScale;
act(() => { act(() => {
...@@ -306,7 +299,6 @@ describe('usePlotConfig', () => { ...@@ -306,7 +299,6 @@ describe('usePlotConfig', () => {
time: true, time: true,
}); });
}); });
await waitForNextUpdate();
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1); expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -341,7 +333,7 @@ describe('usePlotConfig', () => { ...@@ -341,7 +333,7 @@ describe('usePlotConfig', () => {
}); });
it('should update scale', async () => { 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; const addScale = result.current.addScale;
act(() => { act(() => {
...@@ -353,7 +345,6 @@ describe('usePlotConfig', () => { ...@@ -353,7 +345,6 @@ describe('usePlotConfig', () => {
time: false, time: false,
}); });
}); });
await waitForNextUpdate();
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1); expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -388,7 +379,7 @@ describe('usePlotConfig', () => { ...@@ -388,7 +379,7 @@ describe('usePlotConfig', () => {
}); });
it('should remove scale', async () => { 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; const addScale = result.current.addScale;
act(() => { act(() => {
...@@ -398,7 +389,6 @@ describe('usePlotConfig', () => { ...@@ -398,7 +389,6 @@ describe('usePlotConfig', () => {
removeScale(); removeScale();
}); });
await waitForNextUpdate();
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(0); expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(0);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -431,7 +421,7 @@ describe('usePlotConfig', () => { ...@@ -431,7 +421,7 @@ describe('usePlotConfig', () => {
describe('plugins config', () => { describe('plugins config', () => {
it('should register plugin', async () => { 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; const registerPlugin = result.current.registerPlugin;
act(() => { act(() => {
...@@ -440,7 +430,6 @@ describe('usePlotConfig', () => { ...@@ -440,7 +430,6 @@ describe('usePlotConfig', () => {
hooks: {}, hooks: {},
}); });
}); });
await waitForNextUpdate();
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1); expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
expect(result.current.currentConfig).toMatchInlineSnapshot(` expect(result.current.currentConfig).toMatchInlineSnapshot(`
...@@ -475,7 +464,7 @@ describe('usePlotConfig', () => { ...@@ -475,7 +464,7 @@ describe('usePlotConfig', () => {
}); });
it('should unregister plugin', async () => { 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; const registerPlugin = result.current.registerPlugin;
let unregister: () => void; let unregister: () => void;
...@@ -485,7 +474,6 @@ describe('usePlotConfig', () => { ...@@ -485,7 +474,6 @@ describe('usePlotConfig', () => {
hooks: {}, hooks: {},
}); });
}); });
await waitForNextUpdate();
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1); expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
......
...@@ -14,8 +14,8 @@ export const usePlotPlugins = () => { ...@@ -14,8 +14,8 @@ export const usePlotPlugins = () => {
// arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised // arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised
const [arePluginsReady, setPluginsReady] = useState(false); const [arePluginsReady, setPluginsReady] = useState(false);
const cancellationToken = useRef<number>(); const cancellationToken = useRef<number>();
const isMounted = useRef(false);
const checkPluginsReady = useCallback(() => { const checkPluginsReady = useCallback(() => {
if (cancellationToken.current) { if (cancellationToken.current) {
...@@ -29,7 +29,9 @@ export const usePlotPlugins = () => { ...@@ -29,7 +29,9 @@ export const usePlotPlugins = () => {
* and arePluginsReady will be deferred to next animation frame. * and arePluginsReady will be deferred to next animation frame.
*/ */
cancellationToken.current = window.requestAnimationFrame(function() { cancellationToken.current = window.requestAnimationFrame(function() {
setPluginsReady(true); if (isMounted.current) {
setPluginsReady(true);
}
}); });
}, [cancellationToken, setPluginsReady]); }, [cancellationToken, setPluginsReady]);
...@@ -66,9 +68,9 @@ export const usePlotPlugins = () => { ...@@ -66,9 +68,9 @@ export const usePlotPlugins = () => {
useEffect(() => { useEffect(() => {
checkPluginsReady(); checkPluginsReady();
return () => { return () => {
isMounted.current = false;
if (cancellationToken.current) { if (cancellationToken.current) {
window.cancelAnimationFrame(cancellationToken.current); window.cancelAnimationFrame(cancellationToken.current);
cancellationToken.current = undefined;
} }
}; };
}, []); }, []);
...@@ -92,6 +94,7 @@ export const DEFAULT_PLOT_CONFIG = { ...@@ -92,6 +94,7 @@ export const DEFAULT_PLOT_CONFIG = {
legend: { legend: {
show: false, show: false,
}, },
series: [],
hooks: {}, hooks: {},
}; };
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) => { export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) => {
...@@ -113,7 +116,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) ...@@ -113,7 +116,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone)
return fmt; return fmt;
}, [timeZone]); }, [timeZone]);
const defaultConfig = useMemo(() => { const defaultConfig = useMemo<uPlot.Options>(() => {
return { return {
...DEFAULT_PLOT_CONFIG, ...DEFAULT_PLOT_CONFIG,
width, width,
...@@ -122,7 +125,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) ...@@ -122,7 +125,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone)
hooks: p[1].hooks, hooks: p[1].hooks,
})), })),
tzDate, tzDate,
} as any; };
}, [plugins, width, height, tzDate]); }, [plugins, width, height, tzDate]);
useEffect(() => { useEffect(() => {
......
...@@ -56,9 +56,13 @@ export interface PlotPluginProps { ...@@ -56,9 +56,13 @@ export interface PlotPluginProps {
export interface PlotProps { export interface PlotProps {
data: DataFrame; data: DataFrame;
width: number;
height: number;
timeRange: TimeRange; timeRange: TimeRange;
timeZone: TimeZone; 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?: () => {};
} }
...@@ -93,16 +93,18 @@ const isPlottingTime = (config: uPlot.Options) => { ...@@ -93,16 +93,18 @@ const isPlottingTime = (config: uPlot.Options) => {
* Based on two config objects indicates whether or not uPlot needs reinitialisation * 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 * 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) { if (!config && !prevConfig) {
return false; return false;
} }
if (!prevConfig && config) { if (config) {
if (config.width === 0 || config.height === 0) { if (config.width === 0 || config.height === 0) {
return false; return false;
} }
return true; if (!prevConfig) {
return true;
}
} }
if (isPlottingTime(config!) && prevConfig!.tzDate !== config!.tzDate) { if (isPlottingTime(config!) && prevConfig!.tzDate !== config!.tzDate) {
......
import React, { useEffect, useState } from 'react'; import React from 'react';
import { import {
Area,
Canvas, Canvas,
ContextMenuPlugin, ContextMenuPlugin,
GraphCustomFieldConfig,
LegendDisplayMode, LegendDisplayMode,
LegendPlugin, LegendPlugin,
Line,
Point,
Scale,
SeriesGeometry,
TooltipPlugin, TooltipPlugin,
UPlotChart,
ZoomPlugin, ZoomPlugin,
useTheme, GraphNG,
} from '@grafana/ui'; } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import {
DataFrame,
FieldConfig,
FieldType,
formattedValueToString,
getTimeField,
PanelProps,
getFieldColorModeForField,
systemDateFormats,
} from '@grafana/data';
import { Options } from './types'; import { Options } from './types';
import { alignAndSortDataFramesByFieldName } from './utils';
import { VizLayout } from './VizLayout'; 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 { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin'; import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
interface GraphPanelProps extends PanelProps<Options> {} interface GraphPanelProps extends PanelProps<Options> {}
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<GraphPanelProps> = ({ export const GraphPanel: React.FC<GraphPanelProps> = ({
data, data,
timeRange, timeRange,
...@@ -94,117 +25,6 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({ ...@@ -94,117 +25,6 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
options, options,
onChangeTimeRange, onChangeTimeRange,
}) => { }) => {
const theme = useTheme();
const [alignedData, setAlignedData] = useState<DataFrame | null>(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 (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
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(<Scale scaleKey="x" />);
} else {
scales.push(<Scale scaleKey="x" time />);
}
axes.push(<Axis scaleKey="x" values={timeStampsConfig} side={2} />);
let seriesIdx = 0;
const uniqueScales: Record<string, boolean> = {};
for (let i = 0; i < alignedData.fields.length; i++) {
const seriesGeometry = [];
const field = alignedData.fields[i];
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
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(<Scale scaleKey={scale} />);
axes.push(
<Axis
key={`axis-${scale}-${i}`}
scaleKey={scale}
label={config.custom?.axis?.label}
size={config.custom?.axis?.width}
side={config.custom?.axis?.side || 3}
grid={config.custom?.axis?.grid}
formatValue={v => 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(
<Line
key={`line-${scale}-${i}`}
scaleKey={scale}
stroke={seriesColor}
width={customConfig?.line.show ? customConfig?.line.width || 1 : 0}
/>
);
}
if (customConfig?.points?.show) {
seriesGeometry.push(
<Point key={`point-${scale}-${i}`} scaleKey={scale} size={customConfig?.points?.radius} stroke={seriesColor} />
);
}
if (customConfig?.fill?.alpha) {
seriesGeometry.push(
<Area key={`area-${scale}-${i}`} scaleKey={scale} fill={customConfig?.fill.alpha} color={seriesColor} />
);
}
if (seriesGeometry.length > 1) {
geometries.push(
<SeriesGeometry key={`seriesGeometry-${scale}-${i}`} scaleKey={scale}>
{seriesGeometry}
</SeriesGeometry>
);
} else {
geometries.push(seriesGeometry);
}
seriesIdx++;
}
return ( return (
<VizLayout width={width} height={height}> <VizLayout width={width} height={height}>
{({ builder, getLayout }) => { {({ builder, getLayout }) => {
...@@ -230,10 +50,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({ ...@@ -230,10 +50,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
} }
return ( return (
<UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}> <GraphNG data={data.series} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
{scales}
{axes}
{geometries}
{builder.addSlot('canvas', <Canvas />).render()} {builder.addSlot('canvas', <Canvas />).render()}
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} /> <TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} /> <ZoomPlugin onZoom={onChangeTimeRange} />
...@@ -243,7 +60,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({ ...@@ -243,7 +60,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />} {data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
{/* TODO: */} {/* TODO: */}
{/*<AnnotationsEditorPlugin />*/} {/*<AnnotationsEditorPlugin />*/}
</UPlotChart> </GraphNG>
); );
}} }}
</VizLayout> </VizLayout>
......
...@@ -33,7 +33,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation ...@@ -33,7 +33,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
); );
useEffect(() => { useEffect(() => {
if (plotCtx.isPlotReady && annotations.length > 0) { if (plotCtx.isPlotReady) {
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = []; const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
for (const frame of annotations) { for (const frame of annotations) {
......
...@@ -42,7 +42,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim ...@@ -42,7 +42,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
// THIS EVENT ONLY MOCKS EXEMPLAR Y VALUE!!!! TO BE REMOVED WHEN WE GET CORRECT EXEMPLARS SHAPE VIA PROPS // THIS EVENT ONLY MOCKS EXEMPLAR Y VALUE!!!! TO BE REMOVED WHEN WE GET CORRECT EXEMPLARS SHAPE VIA PROPS
useEffect(() => { useEffect(() => {
if (plotCtx.isPlotReady && exemplars.length) { if (plotCtx.isPlotReady) {
const mocks: DataFrame[] = []; const mocks: DataFrame[] = [];
for (const frame of exemplars) { for (const frame of exemplars) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment