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 {
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';
......@@ -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<PlotProps> = props => {
const canvasRef = useRef<HTMLDivElement>(null);
const [plotInstance, setPlotInstance] = useState<uPlot>();
const plotData = useRef<uPlot.AlignedData>();
// uPlot config API
const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig(
......@@ -20,36 +21,29 @@ export const UPlotChart: React.FC<PlotProps> = 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<PlotProps> = 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;
}
// 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 (shouldReinitialisePlot(prevConfig, currentConfig)) {
const instance = initPlot();
if (!instance) {
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<PlotProps> = props => {
</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 { usePlotConfigContext } from '../context';
import { useTheme } from '../../../themes';
......@@ -32,6 +32,7 @@ export const useAxisConfig = (getConfig: () => any) => {
export const Axis: React.FC<AxisProps> = 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<AxisProps> = 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,
......
......@@ -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);
......
......@@ -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<number>();
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<uPlot.Options>(() => {
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(() => {
......
......@@ -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?: () => {};
}
......@@ -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) {
......
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<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> = ({
data,
timeRange,
......@@ -94,117 +25,6 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
options,
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 (
<VizLayout width={width} height={height}>
{({ builder, getLayout }) => {
......@@ -230,10 +50,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
}
return (
<UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
{scales}
{axes}
{geometries}
<GraphNG data={data.series} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
{builder.addSlot('canvas', <Canvas />).render()}
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />
......@@ -243,7 +60,7 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
{/* TODO: */}
{/*<AnnotationsEditorPlugin />*/}
</UPlotChart>
</GraphNG>
);
}}
</VizLayout>
......
......@@ -33,7 +33,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
);
useEffect(() => {
if (plotCtx.isPlotReady && annotations.length > 0) {
if (plotCtx.isPlotReady) {
const views: Array<DataFrameView<AnnotationsDataFrameViewDTO>> = [];
for (const frame of annotations) {
......
......@@ -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
useEffect(() => {
if (plotCtx.isPlotReady && exemplars.length) {
if (plotCtx.isPlotReady) {
const mocks: DataFrame[] = [];
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