mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Proposal: Declarative API for axes, scales and series configuration in Graph NG (#27862)
* Fix gdev dashboard * API for declarative Axis, Series and Scales configuration * Bring back time zone change support * Update tests and fix type errors * Review comments and fixes
This commit is contained in:
parent
0c70308870
commit
b995381816
@ -22,9 +22,6 @@
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"axis": {
|
||||
"grid": true,
|
||||
@ -40,7 +37,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -355,9 +355,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -374,7 +371,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -560,9 +560,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -579,7 +576,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -678,9 +678,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -697,7 +694,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -811,9 +811,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -830,7 +827,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": false,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -899,7 +899,7 @@
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"id": "custom.line.color",
|
||||
"value": {
|
||||
"fixedColor": "purple",
|
||||
"mode": "fixed"
|
||||
@ -960,9 +960,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -979,7 +976,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": false,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1048,7 +1048,7 @@
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"id": "custom.line.color",
|
||||
"value": {
|
||||
"fixedColor": "purple",
|
||||
"mode": "fixed"
|
||||
@ -1109,9 +1109,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -1128,7 +1125,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": false,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1197,7 +1197,7 @@
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"id": "custome.line.color",
|
||||
"value": {
|
||||
"fixedColor": "purple",
|
||||
"mode": "fixed"
|
||||
@ -1258,9 +1258,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -1277,7 +1274,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": false,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1346,7 +1346,7 @@
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"id": "custom.line.color",
|
||||
"value": {
|
||||
"fixedColor": "purple",
|
||||
"mode": "fixed"
|
||||
@ -1422,9 +1422,6 @@
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -1441,7 +1438,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1506,9 +1506,6 @@
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -1525,7 +1522,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1590,9 +1590,6 @@
|
||||
"datasource": null,
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"axis": {
|
||||
"grid": true,
|
||||
@ -1608,7 +1605,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1688,9 +1688,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"axis": {
|
||||
"grid": true,
|
||||
@ -1706,7 +1703,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -1775,9 +1775,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"axis": {
|
||||
"grid": true,
|
||||
@ -1798,7 +1795,10 @@
|
||||
"show": false
|
||||
},
|
||||
"line": {
|
||||
"show": true
|
||||
"show": true,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"points": {
|
||||
"radius": 8,
|
||||
@ -1879,9 +1879,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"axis": {
|
||||
"grid": true,
|
||||
@ -1897,7 +1894,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "connected",
|
||||
"points": {
|
||||
@ -1981,9 +1981,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"axis": {
|
||||
"grid": true,
|
||||
@ -1999,7 +1996,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": true,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -2083,9 +2083,6 @@
|
||||
"datasource": "gdev-testdata",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
},
|
||||
"custom": {
|
||||
"align": null,
|
||||
"axis": {
|
||||
@ -2102,7 +2099,10 @@
|
||||
},
|
||||
"line": {
|
||||
"show": false,
|
||||
"width": 1
|
||||
"width": 1,
|
||||
"color": {
|
||||
"mode": "fixed"
|
||||
}
|
||||
},
|
||||
"nullValues": "null",
|
||||
"points": {
|
||||
@ -2175,7 +2175,7 @@
|
||||
"value": false
|
||||
},
|
||||
{
|
||||
"id": "color",
|
||||
"id": "custom.line.color",
|
||||
"value": {
|
||||
"fixedColor": "purple",
|
||||
"mode": "fixed"
|
||||
|
@ -71,6 +71,8 @@ export {
|
||||
|
||||
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';
|
||||
|
||||
|
@ -2,114 +2,73 @@ import 'uplot/dist/uPlot.min.css';
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import uPlot from 'uplot';
|
||||
import { useTheme } from '../../themes';
|
||||
import { usePrevious } from 'react-use';
|
||||
import { buildPlotContext, PlotContext } from './context';
|
||||
import { buildPlotConfig, pluginLog, preparePlotData, shouldReinitialisePlot } from './utils';
|
||||
import { usePlotPlugins } from './hooks';
|
||||
import { pluginLog, preparePlotData, shouldReinitialisePlot } from './utils';
|
||||
import { usePlotConfig } from './hooks';
|
||||
import { PlotProps } from './types';
|
||||
|
||||
// uPlot abstraction responsible for plot initialisation, setup and refresh
|
||||
// Receives a data frame that is x-axis aligned, as of https://github.com/leeoniya/uPlot/tree/master/docs#data-format
|
||||
// Exposes contexts for plugins registration and uPlot instance access
|
||||
export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
const theme = useTheme();
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// instance of uPlot, exposed via PlotContext
|
||||
const [plotInstance, setPlotInstance] = useState<uPlot>();
|
||||
|
||||
// Array with current plot data points, calculated when data frame is passed to a plot
|
||||
// const [plotData, setPlotData] = useState<uPlot.AlignedData>();
|
||||
// uPlot config
|
||||
const [currentPlotConfig, setCurrentPlotConfig] = useState<uPlot.Options>();
|
||||
// uPlot config API
|
||||
const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig(
|
||||
props.width,
|
||||
props.height,
|
||||
props.timeZone
|
||||
);
|
||||
|
||||
// uPlot plugins API hook
|
||||
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
|
||||
const prevConfig = usePrevious(currentConfig);
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
// Will destroy existing uPlot instance
|
||||
const initPlot = useCallback(() => {
|
||||
if (!currentPlotConfig || !canvasRef?.current) {
|
||||
return;
|
||||
const initPlot = () => {
|
||||
if (!currentConfig || !canvasRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plotInstance) {
|
||||
pluginLog('uPlot core', false, 'destroying existing instance due to reinitialisation');
|
||||
plotInstance.destroy();
|
||||
}
|
||||
|
||||
const data = preparePlotData(props.data);
|
||||
pluginLog('uPlot core', false, 'initialized with', data, currentConfig);
|
||||
return new uPlot(currentConfig, data, canvasRef.current);
|
||||
};
|
||||
|
||||
pluginLog('uPlot core', false, 'initialized with', data, currentPlotConfig);
|
||||
|
||||
setPlotInstance(new uPlot(currentPlotConfig, data, canvasRef.current));
|
||||
}, [props, currentPlotConfig, arePluginsReady, canvasRef.current, plotInstance]);
|
||||
|
||||
const hasConfigChanged = useCallback(() => {
|
||||
const config = buildPlotConfig(props, props.data, plugins, theme);
|
||||
if (!currentPlotConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shouldReinitialisePlot(currentPlotConfig, config);
|
||||
}, [props, props.data, currentPlotConfig]);
|
||||
|
||||
// Initialise uPlot when config changes
|
||||
useEffect(() => {
|
||||
if (!currentPlotConfig) {
|
||||
// Callback executed when there was no change in plot config
|
||||
const updateData = useCallback(() => {
|
||||
if (!plotInstance) {
|
||||
return;
|
||||
}
|
||||
initPlot();
|
||||
}, [currentPlotConfig]);
|
||||
const data = preparePlotData(props.data);
|
||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)');
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance.setData(data);
|
||||
}, [plotInstance, props.data]);
|
||||
|
||||
// Destroy uPlot on when components unmounts
|
||||
// Destroys previous plot instance when plot re-initialised
|
||||
useEffect(() => {
|
||||
const currentInstance = plotInstance;
|
||||
return () => {
|
||||
if (plotInstance) {
|
||||
pluginLog('uPlot core', false, 'destroying existing instance due to unmount');
|
||||
plotInstance.destroy();
|
||||
}
|
||||
currentInstance?.destroy();
|
||||
};
|
||||
}, [plotInstance]);
|
||||
|
||||
// Effect performed when all plugins have registered. Final config is set triggering plot initialisation
|
||||
// Decides if plot should update data or re-initialise
|
||||
useEffect(() => {
|
||||
if (!canvasRef) {
|
||||
throw new Error('Cannot render graph without canvas! Render Canvas as a child of Plot component.');
|
||||
}
|
||||
|
||||
if (!arePluginsReady) {
|
||||
if (!currentConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvasRef.current) {
|
||||
setCurrentPlotConfig(buildPlotConfig(props, props.data, plugins, theme));
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (plotInstance) {
|
||||
console.log('uPlot - destroy instance, unmount');
|
||||
plotInstance.destroy();
|
||||
if (shouldReinitialisePlot(prevConfig, currentConfig)) {
|
||||
const instance = initPlot();
|
||||
if (!instance) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
}, [arePluginsReady]);
|
||||
|
||||
// When data changes try to be clever about config updates, needs some more love
|
||||
useEffect(() => {
|
||||
const data = preparePlotData(props.data);
|
||||
const config = buildPlotConfig(props, props.data, plugins, theme);
|
||||
|
||||
// See if series configs changes, re-initialise if necessary
|
||||
// this is a minimal check, need to update for field config cleverness ;)
|
||||
if (hasConfigChanged()) {
|
||||
setCurrentPlotConfig(config); // will trigger uPlot reinitialisation
|
||||
return;
|
||||
setPlotInstance(instance);
|
||||
} else {
|
||||
pluginLog('uPlot core', true, 'updating plot data(throttled log!)');
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance?.setData(data);
|
||||
updateData();
|
||||
}
|
||||
}, [props.data, props.timeRange]);
|
||||
}, [props.data, props.timeRange, props.timeZone, currentConfig, setPlotInstance]);
|
||||
|
||||
// When size props changed update plot size synchronously
|
||||
useLayoutEffect(() => {
|
||||
@ -123,8 +82,8 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
|
||||
// Memoize plot context
|
||||
const plotCtx = useMemo(() => {
|
||||
return buildPlotContext(registerPlugin, canvasRef, props.data, plotInstance);
|
||||
}, [registerPlugin, canvasRef, props.data, plotInstance]);
|
||||
return buildPlotContext(registerPlugin, addSeries, addAxis, addScale, canvasRef, props.data, plotInstance);
|
||||
}, [registerPlugin, canvasRef, props.data, plotInstance, addSeries, addAxis, addScale]);
|
||||
|
||||
return (
|
||||
<PlotContext.Provider value={plotCtx}>
|
||||
|
@ -16,19 +16,40 @@ interface PlotCanvasContextType {
|
||||
};
|
||||
}
|
||||
|
||||
interface PlotContextType {
|
||||
interface PlotConfigContextType {
|
||||
addSeries: (
|
||||
series: uPlot.Series
|
||||
) => {
|
||||
removeSeries: () => void;
|
||||
updateSeries: () => void;
|
||||
};
|
||||
addScale: (
|
||||
scaleKey: string,
|
||||
scale: uPlot.Scale
|
||||
) => {
|
||||
removeScale: () => void;
|
||||
updateScale: () => void;
|
||||
};
|
||||
addAxis: (
|
||||
axis: uPlot.Axis
|
||||
) => {
|
||||
removeAxis: () => void;
|
||||
updateAxis: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface PlotPluginsContextType {
|
||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
||||
}
|
||||
|
||||
interface PlotContextType extends PlotConfigContextType, PlotPluginsContextType {
|
||||
u?: uPlot;
|
||||
series?: uPlot.Series[];
|
||||
canvas?: PlotCanvasContextType;
|
||||
canvasRef: any;
|
||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
||||
data: DataFrame;
|
||||
}
|
||||
|
||||
type PlotPluginsContextType = {
|
||||
registerPlugin: (plugin: PlotPlugin) => () => void;
|
||||
};
|
||||
|
||||
export const PlotContext = React.createContext<PlotContextType | null>(null);
|
||||
|
||||
// Exposes uPlot instance and bounding box of the entire canvas and plot area
|
||||
@ -51,6 +72,19 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
|
||||
};
|
||||
};
|
||||
|
||||
// Exposes API for building uPlot config
|
||||
export const usePlotConfigContext = (): PlotConfigContextType => {
|
||||
const ctx = useContext(PlotContext);
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotPluginContext');
|
||||
}
|
||||
return {
|
||||
addSeries: ctx!.addSeries,
|
||||
addAxis: ctx!.addAxis,
|
||||
addScale: ctx!.addScale,
|
||||
};
|
||||
};
|
||||
|
||||
interface PlotDataAPI {
|
||||
/** Data frame passed to graph, x-axis aligned */
|
||||
data: DataFrame;
|
||||
@ -136,6 +170,9 @@ export const usePlotCanvas = (): PlotCanvasContextType | null => {
|
||||
|
||||
export const buildPlotContext = (
|
||||
registerPlugin: any,
|
||||
addSeries: any,
|
||||
addAxis: any,
|
||||
addScale: any,
|
||||
canvasRef: any,
|
||||
data: DataFrame,
|
||||
u?: uPlot
|
||||
@ -156,6 +193,9 @@ export const buildPlotContext = (
|
||||
}
|
||||
: undefined,
|
||||
registerPlugin,
|
||||
addSeries,
|
||||
addAxis,
|
||||
addScale,
|
||||
canvasRef,
|
||||
data,
|
||||
};
|
||||
|
13
packages/grafana-ui/src/components/uPlot/geometries/Area.tsx
Normal file
13
packages/grafana-ui/src/components/uPlot/geometries/Area.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { getAreaConfig } from './configGetters';
|
||||
import { AreaProps } from './types';
|
||||
import { useSeriesGeometry } from './SeriesGeometry';
|
||||
|
||||
export const Area: React.FC<AreaProps> = ({ fill = 0.1, scaleKey, color }) => {
|
||||
const getConfig = () => getAreaConfig({ fill, scaleKey, color });
|
||||
useSeriesGeometry(getConfig);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Area.displayName = 'Area';
|
69
packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx
Normal file
69
packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { AxisProps } from './types';
|
||||
import { usePlotConfigContext } from '../context';
|
||||
import { useTheme } from '../../../themes';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
export const useAxisConfig = (getConfig: () => any) => {
|
||||
const { addAxis } = usePlotConfigContext();
|
||||
const updateConfigRef = useRef<(c: uPlot.Axis) => void>(() => {});
|
||||
|
||||
const defaultAxisConfig: uPlot.Axis = {};
|
||||
|
||||
const getUpdateConfigRef = useCallback(() => {
|
||||
return updateConfigRef.current;
|
||||
}, [updateConfigRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = getConfig();
|
||||
const { removeAxis, updateAxis } = addAxis({ ...defaultAxisConfig, ...config });
|
||||
updateConfigRef.current = updateAxis;
|
||||
return () => {
|
||||
removeAxis();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// update series config when config getter is updated
|
||||
useEffect(() => {
|
||||
const config = getConfig();
|
||||
getUpdateConfigRef()({ ...defaultAxisConfig, ...config });
|
||||
}, [getConfig]);
|
||||
};
|
||||
|
||||
export const Axis: React.FC<AxisProps> = props => {
|
||||
const theme = useTheme();
|
||||
const {
|
||||
scaleKey,
|
||||
label,
|
||||
show = true,
|
||||
size = 80,
|
||||
stroke = theme.colors.text,
|
||||
side = 3,
|
||||
grid = true,
|
||||
formatValue,
|
||||
values,
|
||||
} = props;
|
||||
|
||||
const getConfig = () => {
|
||||
let config: uPlot.Axis = {
|
||||
scale: scaleKey,
|
||||
label,
|
||||
show,
|
||||
size,
|
||||
stroke,
|
||||
side,
|
||||
grid: {
|
||||
show: grid,
|
||||
stroke: theme.palette.gray4,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
values: values ? values : formatValue ? (u: uPlot, vals: any[]) => vals.map(v => formatValue(v)) : undefined,
|
||||
};
|
||||
|
||||
return config;
|
||||
};
|
||||
useAxisConfig(getConfig);
|
||||
|
||||
return null;
|
||||
};
|
||||
Axis.displayName = 'Axis';
|
13
packages/grafana-ui/src/components/uPlot/geometries/Line.tsx
Normal file
13
packages/grafana-ui/src/components/uPlot/geometries/Line.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { getLineConfig } from './configGetters';
|
||||
import { useSeriesGeometry } from './SeriesGeometry';
|
||||
import { LineProps } from './types';
|
||||
|
||||
export const Line: React.FC<LineProps> = props => {
|
||||
const getConfig = () => getLineConfig(props);
|
||||
useSeriesGeometry(getConfig);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Line.displayName = 'Line';
|
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { getPointConfig } from './configGetters';
|
||||
import { useSeriesGeometry } from './SeriesGeometry';
|
||||
import { PointProps } from './types';
|
||||
|
||||
export const Point: React.FC<PointProps> = ({ size = 2, stroke, scaleKey }) => {
|
||||
const getConfig = () => getPointConfig({ size, stroke, scaleKey });
|
||||
useSeriesGeometry(getConfig);
|
||||
|
||||
return null;
|
||||
};
|
||||
Point.displayName = 'Point';
|
@ -0,0 +1,48 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { ScaleProps } from './types';
|
||||
import { usePlotConfigContext } from '../context';
|
||||
|
||||
import uPlot from 'uplot';
|
||||
|
||||
const useScaleConfig = (scaleKey: string, getConfig: () => any) => {
|
||||
const { addScale } = usePlotConfigContext();
|
||||
const updateConfigRef = useRef<(c: uPlot.Scale) => void>(() => {});
|
||||
|
||||
const defaultScaleConfig: uPlot.Scale = {};
|
||||
|
||||
const getUpdateConfigRef = useCallback(() => {
|
||||
return updateConfigRef.current;
|
||||
}, [updateConfigRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = getConfig();
|
||||
const { removeScale, updateScale } = addScale(scaleKey, { ...defaultScaleConfig, ...config });
|
||||
updateConfigRef.current = updateScale;
|
||||
return () => {
|
||||
removeScale();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// update series config when config getter is updated
|
||||
useEffect(() => {
|
||||
const config = getConfig();
|
||||
getUpdateConfigRef()({ ...defaultScaleConfig, ...config });
|
||||
}, [getConfig]);
|
||||
};
|
||||
|
||||
export const Scale: React.FC<ScaleProps> = props => {
|
||||
const { scaleKey, time } = props;
|
||||
|
||||
const getConfig = () => {
|
||||
let config: uPlot.Scale = {
|
||||
time: !!time,
|
||||
};
|
||||
return config;
|
||||
};
|
||||
|
||||
useScaleConfig(scaleKey, getConfig);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
Scale.displayName = 'Scale';
|
@ -0,0 +1,74 @@
|
||||
import { usePlotConfigContext } from '../context';
|
||||
import { getAreaConfig, getLineConfig, getPointConfig } from './configGetters';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
const seriesGeometryAllowedGeometries = ['Line', 'Point', 'Area'];
|
||||
|
||||
export const useSeriesGeometry = (getConfig: () => any) => {
|
||||
const { addSeries } = usePlotConfigContext();
|
||||
const updateConfigRef = useRef<(c: uPlot.Series) => void>(() => {});
|
||||
|
||||
const defaultSeriesConfig: uPlot.Series = {
|
||||
width: 0,
|
||||
points: {
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getUpdateConfigRef = useCallback(() => {
|
||||
return updateConfigRef.current;
|
||||
}, [updateConfigRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const config = getConfig();
|
||||
const { removeSeries, updateSeries } = addSeries({ ...defaultSeriesConfig, ...config });
|
||||
updateConfigRef.current = updateSeries;
|
||||
return () => {
|
||||
removeSeries();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// update series config when config getter is updated
|
||||
useEffect(() => {
|
||||
const config = getConfig();
|
||||
getUpdateConfigRef()({ ...defaultSeriesConfig, ...config });
|
||||
}, [getConfig]);
|
||||
};
|
||||
|
||||
const geometriesConfigGetters: Record<string, (props: any) => {}> = {
|
||||
Line: getLineConfig,
|
||||
Point: getPointConfig,
|
||||
Area: getAreaConfig,
|
||||
};
|
||||
|
||||
export const SeriesGeometry: React.FC<{ scaleKey: string; children: React.ReactElement[] }> = props => {
|
||||
const getConfig = () => {
|
||||
let config: uPlot.Series = {
|
||||
points: {
|
||||
show: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (!props.children) {
|
||||
throw new Error('SeriesGeometry requires Line, Point or Area components as children');
|
||||
}
|
||||
|
||||
React.Children.forEach<React.ReactElement>(props.children, child => {
|
||||
if (
|
||||
child.type &&
|
||||
(child.type as any).displayName &&
|
||||
seriesGeometryAllowedGeometries.indexOf((child.type as any).displayName) === -1
|
||||
) {
|
||||
throw new Error(`Can't use ${child.type} in SeriesGeometry`);
|
||||
}
|
||||
config = { ...config, ...geometriesConfigGetters[(child.type as any).displayName](child.props) };
|
||||
});
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
useSeriesGeometry(getConfig);
|
||||
|
||||
return null;
|
||||
};
|
@ -0,0 +1,36 @@
|
||||
import { AreaProps, LineProps, PointProps } from './types';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { getColorFromHexRgbOrName } from '@grafana/data';
|
||||
|
||||
export const getAreaConfig = (props: AreaProps) => {
|
||||
const fill = props.fill
|
||||
? tinycolor(getColorFromHexRgbOrName(props.color))
|
||||
.setAlpha(props.fill)
|
||||
.toRgbString()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
scale: props.scaleKey,
|
||||
fill,
|
||||
};
|
||||
};
|
||||
|
||||
export const getLineConfig = (props: LineProps) => {
|
||||
return {
|
||||
scale: props.scaleKey,
|
||||
stroke: props.stroke,
|
||||
width: props.width,
|
||||
};
|
||||
};
|
||||
|
||||
export const getPointConfig = (props: PointProps) => {
|
||||
return {
|
||||
scale: props.scaleKey,
|
||||
stroke: props.stroke,
|
||||
points: {
|
||||
show: true,
|
||||
size: props.size,
|
||||
stroke: props.stroke,
|
||||
},
|
||||
};
|
||||
};
|
@ -0,0 +1,7 @@
|
||||
import { Area } from './Area';
|
||||
import { Line } from './Line';
|
||||
import { Point } from './Point';
|
||||
import { Axis } from './Axis';
|
||||
import { Scale } from './Scale';
|
||||
import { SeriesGeometry } from './SeriesGeometry';
|
||||
export { Area, Line, Point, SeriesGeometry, Axis, Scale };
|
34
packages/grafana-ui/src/components/uPlot/geometries/types.ts
Normal file
34
packages/grafana-ui/src/components/uPlot/geometries/types.ts
Normal file
@ -0,0 +1,34 @@
|
||||
export interface LineProps {
|
||||
scaleKey: string;
|
||||
stroke: string;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface PointProps {
|
||||
scaleKey: string;
|
||||
size: number;
|
||||
stroke: string;
|
||||
}
|
||||
|
||||
export interface AreaProps {
|
||||
scaleKey: string;
|
||||
fill: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface AxisProps {
|
||||
scaleKey: string;
|
||||
label?: string;
|
||||
show?: boolean;
|
||||
size?: number;
|
||||
stroke?: string;
|
||||
side?: number;
|
||||
grid?: boolean;
|
||||
formatValue?: (v: any) => string;
|
||||
values?: any;
|
||||
}
|
||||
|
||||
export interface ScaleProps {
|
||||
scaleKey: string;
|
||||
time?: boolean;
|
||||
}
|
524
packages/grafana-ui/src/components/uPlot/hooks.test.ts
Normal file
524
packages/grafana-ui/src/components/uPlot/hooks.test.ts
Normal file
@ -0,0 +1,524 @@
|
||||
import { usePlotConfig } from './hooks';
|
||||
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();
|
||||
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
describe('series config', () => {
|
||||
it('should add series', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addSeries = result.current.addSeries;
|
||||
|
||||
act(() => {
|
||||
addSeries({
|
||||
stroke: '#ff0000',
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.series).toHaveLength(2);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
Object {
|
||||
"stroke": "#ff0000",
|
||||
},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should update series', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addSeries = result.current.addSeries;
|
||||
|
||||
act(() => {
|
||||
const { updateSeries } = addSeries({
|
||||
stroke: '#ff0000',
|
||||
});
|
||||
|
||||
updateSeries({
|
||||
stroke: '#00ff00',
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.series).toHaveLength(2);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
Object {
|
||||
"stroke": "#00ff00",
|
||||
},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should remove series', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addSeries = result.current.addSeries;
|
||||
|
||||
act(() => {
|
||||
const { removeSeries } = addSeries({
|
||||
stroke: '#ff0000',
|
||||
});
|
||||
|
||||
removeSeries();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.series).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('axis config', () => {
|
||||
it('should add axis', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addAxis = result.current.addAxis;
|
||||
|
||||
act(() => {
|
||||
addAxis({
|
||||
side: 1,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.axes).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"side": 1,
|
||||
},
|
||||
],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should update axis', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addAxis = result.current.addAxis;
|
||||
|
||||
act(() => {
|
||||
const { updateAxis } = addAxis({
|
||||
side: 1,
|
||||
});
|
||||
|
||||
updateAxis({
|
||||
side: 3,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.axes).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"side": 3,
|
||||
},
|
||||
],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should remove axis', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addAxis = result.current.addAxis;
|
||||
|
||||
act(() => {
|
||||
const { removeAxis } = addAxis({
|
||||
side: 1,
|
||||
});
|
||||
|
||||
removeAxis();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.currentConfig?.axes).toHaveLength(0);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scales config', () => {
|
||||
it('should add scale', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addScale = result.current.addScale;
|
||||
|
||||
act(() => {
|
||||
addScale('x', {
|
||||
time: true,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {
|
||||
"x": Object {
|
||||
"time": true,
|
||||
},
|
||||
},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should update scale', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addScale = result.current.addScale;
|
||||
|
||||
act(() => {
|
||||
const { updateScale } = addScale('x', {
|
||||
time: true,
|
||||
});
|
||||
|
||||
updateScale({
|
||||
time: false,
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {
|
||||
"x": Object {
|
||||
"time": false,
|
||||
},
|
||||
},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should remove scale', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const addScale = result.current.addScale;
|
||||
|
||||
act(() => {
|
||||
const { removeScale } = addScale('x', {
|
||||
time: true,
|
||||
});
|
||||
|
||||
removeScale();
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.scales!)).toHaveLength(0);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugins config', () => {
|
||||
it('should register plugin', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const registerPlugin = result.current.registerPlugin;
|
||||
|
||||
act(() => {
|
||||
registerPlugin({
|
||||
id: 'testPlugin',
|
||||
hooks: {},
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [
|
||||
Object {
|
||||
"hooks": Object {},
|
||||
},
|
||||
],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('should unregister plugin', async () => {
|
||||
const { result, waitForNextUpdate } = renderHook(() => usePlotConfig(0, 0, 'browser'));
|
||||
const registerPlugin = result.current.registerPlugin;
|
||||
|
||||
let unregister: () => void;
|
||||
act(() => {
|
||||
unregister = registerPlugin({
|
||||
id: 'testPlugin',
|
||||
hooks: {},
|
||||
});
|
||||
});
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(1);
|
||||
|
||||
act(() => {
|
||||
unregister();
|
||||
});
|
||||
|
||||
expect(Object.keys(result.current.currentConfig?.plugins!)).toHaveLength(0);
|
||||
expect(result.current.currentConfig).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"axes": Array [],
|
||||
"cursor": Object {
|
||||
"focus": Object {
|
||||
"prox": 30,
|
||||
},
|
||||
},
|
||||
"focus": Object {
|
||||
"alpha": 1,
|
||||
},
|
||||
"height": 0,
|
||||
"hooks": Object {},
|
||||
"legend": Object {
|
||||
"show": false,
|
||||
},
|
||||
"plugins": Array [],
|
||||
"scales": Object {},
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
"width": 0,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { PlotPlugin } from './types';
|
||||
import { pluginLog } from './utils';
|
||||
import uPlot from 'uplot';
|
||||
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
|
||||
|
||||
export const usePlotPlugins = () => {
|
||||
/**
|
||||
@ -8,7 +10,6 @@ export const usePlotPlugins = () => {
|
||||
* Used to build uPlot plugins config
|
||||
*/
|
||||
const [plugins, setPlugins] = useState<Record<string, PlotPlugin>>({});
|
||||
// const registeredPlugins = useRef(0);
|
||||
|
||||
// arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised
|
||||
const [arePluginsReady, setPluginsReady] = useState(false);
|
||||
@ -18,6 +19,7 @@ export const usePlotPlugins = () => {
|
||||
const checkPluginsReady = useCallback(() => {
|
||||
if (cancellationToken.current) {
|
||||
window.cancelAnimationFrame(cancellationToken.current);
|
||||
cancellationToken.current = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -56,7 +58,7 @@ export const usePlotPlugins = () => {
|
||||
});
|
||||
};
|
||||
},
|
||||
[setPlugins]
|
||||
[setPlugins, plugins]
|
||||
);
|
||||
|
||||
// When uPlot mounts let's check if there are any plugins pending registration
|
||||
@ -65,6 +67,7 @@ export const usePlotPlugins = () => {
|
||||
return () => {
|
||||
if (cancellationToken.current) {
|
||||
window.cancelAnimationFrame(cancellationToken.current);
|
||||
cancellationToken.current = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@ -75,3 +78,171 @@ export const usePlotPlugins = () => {
|
||||
registerPlugin,
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_PLOT_CONFIG = {
|
||||
focus: {
|
||||
alpha: 1,
|
||||
},
|
||||
cursor: {
|
||||
focus: {
|
||||
prox: 30,
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
show: false,
|
||||
},
|
||||
hooks: {},
|
||||
};
|
||||
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) => {
|
||||
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
|
||||
const [seriesConfig, setSeriesConfig] = useState<uPlot.Series[]>([{}]);
|
||||
const [axesConfig, setAxisConfig] = useState<uPlot.Axis[]>([]);
|
||||
const [scalesConfig, setScaleConfig] = useState<Record<string, uPlot.Scale>>({});
|
||||
const [currentConfig, setCurrentConfig] = useState<uPlot.Options>();
|
||||
|
||||
const tzDate = useMemo(() => {
|
||||
let fmt = undefined;
|
||||
|
||||
const tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
|
||||
|
||||
if (tz) {
|
||||
fmt = (ts: number) => uPlot.tzDate(new Date(ts * 1e3), tz);
|
||||
}
|
||||
|
||||
return fmt;
|
||||
}, [timeZone]);
|
||||
|
||||
const defaultConfig = useMemo(() => {
|
||||
return {
|
||||
...DEFAULT_PLOT_CONFIG,
|
||||
width,
|
||||
height,
|
||||
plugins: Object.entries(plugins).map(p => ({
|
||||
hooks: p[1].hooks,
|
||||
})),
|
||||
tzDate,
|
||||
} as any;
|
||||
}, [plugins, width, height, tzDate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!arePluginsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentConfig(() => {
|
||||
return {
|
||||
...defaultConfig,
|
||||
series: seriesConfig,
|
||||
axes: axesConfig,
|
||||
scales: scalesConfig,
|
||||
};
|
||||
});
|
||||
}, [arePluginsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentConfig({
|
||||
...defaultConfig,
|
||||
series: seriesConfig,
|
||||
axes: axesConfig,
|
||||
scales: scalesConfig,
|
||||
});
|
||||
}, [defaultConfig, seriesConfig, axesConfig, scalesConfig]);
|
||||
|
||||
const addSeries = useCallback(
|
||||
(s: uPlot.Series) => {
|
||||
let index = 0;
|
||||
setSeriesConfig(sc => {
|
||||
index = sc.length;
|
||||
return [...sc, s];
|
||||
});
|
||||
|
||||
return {
|
||||
removeSeries: () => {
|
||||
setSeriesConfig(c => {
|
||||
const tmp = [...c];
|
||||
tmp.splice(index);
|
||||
return tmp;
|
||||
});
|
||||
},
|
||||
updateSeries: (config: uPlot.Series) => {
|
||||
setSeriesConfig(c => {
|
||||
const tmp = [...c];
|
||||
tmp[index] = config;
|
||||
return tmp;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
[setCurrentConfig]
|
||||
);
|
||||
|
||||
const addAxis = useCallback(
|
||||
(a: uPlot.Axis) => {
|
||||
let index = 0;
|
||||
setAxisConfig(ac => {
|
||||
index = ac.length;
|
||||
return [...ac, a];
|
||||
});
|
||||
|
||||
return {
|
||||
removeAxis: () => {
|
||||
setAxisConfig(a => {
|
||||
const tmp = [...a];
|
||||
tmp.splice(index);
|
||||
return tmp;
|
||||
});
|
||||
},
|
||||
updateAxis: (config: uPlot.Axis) => {
|
||||
setAxisConfig(a => {
|
||||
const tmp = [...a];
|
||||
tmp[index] = config;
|
||||
return tmp;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
[setAxisConfig]
|
||||
);
|
||||
|
||||
const addScale = useCallback(
|
||||
(scaleKey: string, s: uPlot.Scale) => {
|
||||
let key = scaleKey;
|
||||
|
||||
setScaleConfig(sc => {
|
||||
const tmp = { ...sc };
|
||||
tmp[key] = s;
|
||||
return tmp;
|
||||
});
|
||||
|
||||
return {
|
||||
removeScale: () => {
|
||||
setScaleConfig(sc => {
|
||||
const tmp = { ...sc };
|
||||
if (tmp[key]) {
|
||||
delete tmp[key];
|
||||
}
|
||||
return tmp;
|
||||
});
|
||||
},
|
||||
updateScale: (config: uPlot.Scale) => {
|
||||
setScaleConfig(sc => {
|
||||
const tmp = { ...sc };
|
||||
if (tmp[key]) {
|
||||
tmp[key] = config;
|
||||
}
|
||||
return tmp;
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
[setScaleConfig]
|
||||
);
|
||||
|
||||
return {
|
||||
addSeries,
|
||||
addAxis,
|
||||
addScale,
|
||||
registerPlugin,
|
||||
currentConfig,
|
||||
};
|
||||
};
|
||||
|
@ -1,202 +1,22 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import omit from 'lodash/omit';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import {
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getColorFromHexRgbOrName,
|
||||
getFieldDisplayName,
|
||||
getTimeField,
|
||||
getTimeZoneInfo,
|
||||
GrafanaTheme,
|
||||
rangeUtil,
|
||||
RawTimeRange,
|
||||
systemDateFormats,
|
||||
TimeRange,
|
||||
} from '@grafana/data';
|
||||
import { colors } from '../../utils';
|
||||
import { DataFrame, FieldType, getTimeField, rangeUtil, RawTimeRange } from '@grafana/data';
|
||||
import uPlot from 'uplot';
|
||||
import { GraphCustomFieldConfig, PlotPlugin, PlotProps } from './types';
|
||||
|
||||
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
|
||||
import { PlotPlugin, PlotProps } from './types';
|
||||
|
||||
const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g;
|
||||
|
||||
export const timeFormatToTemplate = (f: string) => {
|
||||
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, match => `{${match}}`);
|
||||
};
|
||||
|
||||
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
|
||||
)}`,
|
||||
],
|
||||
];
|
||||
|
||||
export function rangeToMinMax(timeRange: RawTimeRange): [number, number] {
|
||||
const v = rangeUtil.convertRawToRange(timeRange);
|
||||
return [v.from.valueOf() / 1000, v.to.valueOf() / 1000];
|
||||
}
|
||||
|
||||
// based on aligned data frames creates config for scales, axes and series
|
||||
export const buildSeriesConfig = (
|
||||
data: DataFrame,
|
||||
timeRange: TimeRange,
|
||||
theme: GrafanaTheme
|
||||
): {
|
||||
series: uPlot.Series[];
|
||||
scales: Record<string, uPlot.Scale>;
|
||||
axes: uPlot.Axis[];
|
||||
} => {
|
||||
const series: uPlot.Series[] = [{}];
|
||||
const scales: Record<string, uPlot.Scale> = {
|
||||
x: {
|
||||
time: true,
|
||||
// range: rangeToMinMax(timeRange.raw),
|
||||
// auto: true
|
||||
},
|
||||
};
|
||||
|
||||
const axes: uPlot.Axis[] = [];
|
||||
|
||||
let { timeIndex } = getTimeField(data);
|
||||
|
||||
if (timeIndex === undefined) {
|
||||
timeIndex = 0; // assuming first field represents x-domain
|
||||
scales.x.time = false;
|
||||
}
|
||||
|
||||
// x-axis
|
||||
axes.push({
|
||||
show: true,
|
||||
stroke: theme.colors.text,
|
||||
grid: {
|
||||
show: true,
|
||||
stroke: theme.palette.gray4,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
values: timeStampsConfig,
|
||||
});
|
||||
|
||||
let seriesIdx = 0;
|
||||
|
||||
for (let i = 0; i < data.fields.length; i++) {
|
||||
const field = data.fields[i];
|
||||
const config = field.config as FieldConfig<GraphCustomFieldConfig>;
|
||||
const customConfig = config.custom;
|
||||
console.log(customConfig);
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
|
||||
if (i === timeIndex || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scale = config.unit || '__fixed';
|
||||
|
||||
if (!scales[scale]) {
|
||||
scales[scale] = {};
|
||||
axes.push({
|
||||
scale,
|
||||
label: config.custom?.axis?.label,
|
||||
show: true,
|
||||
size: config.custom?.axis?.width || 80,
|
||||
stroke: theme.colors.text,
|
||||
side: config.custom?.axis?.side || 3,
|
||||
grid: {
|
||||
show: config.custom?.axis?.grid,
|
||||
stroke: theme.palette.gray4,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
values: (u, vals) => vals.map(v => formattedValueToString(fmt(v))),
|
||||
});
|
||||
}
|
||||
|
||||
const seriesColor =
|
||||
customConfig?.line?.color && customConfig?.line?.color.fixedColor
|
||||
? getColorFromHexRgbOrName(customConfig.line?.color.fixedColor)
|
||||
: colors[seriesIdx];
|
||||
|
||||
series.push({
|
||||
scale,
|
||||
label: getFieldDisplayName(field, data),
|
||||
stroke: seriesColor,
|
||||
fill: customConfig?.fill?.alpha
|
||||
? tinycolor(seriesColor)
|
||||
.setAlpha(customConfig?.fill?.alpha)
|
||||
.toRgbString()
|
||||
: undefined,
|
||||
width: customConfig?.line?.show ? customConfig?.line?.width || 1 : 0,
|
||||
points: {
|
||||
show: customConfig?.points?.show,
|
||||
size: customConfig?.points?.radius || 5,
|
||||
},
|
||||
spanGaps: customConfig?.nullValues === 'connected',
|
||||
});
|
||||
seriesIdx += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
scales,
|
||||
series,
|
||||
axes,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPlotConfig = (
|
||||
props: PlotProps,
|
||||
data: DataFrame,
|
||||
plugins: Record<string, PlotPlugin>,
|
||||
theme: GrafanaTheme
|
||||
): uPlot.Options => {
|
||||
const seriesConfig = buildSeriesConfig(data, props.timeRange, theme);
|
||||
let tzDate;
|
||||
|
||||
// When plotting time series use correct timezone for timestamps
|
||||
if (seriesConfig.scales.x.time) {
|
||||
const tz = getTimeZoneInfo(props.timeZone, Date.now())?.ianaName;
|
||||
if (tz) {
|
||||
tzDate = (ts: number) => uPlot.tzDate(new Date(ts * 1e3), tz);
|
||||
}
|
||||
}
|
||||
|
||||
export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPlugin>): uPlot.Options => {
|
||||
return {
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
@ -215,9 +35,7 @@ export const buildPlotConfig = (
|
||||
hooks: p[1].hooks,
|
||||
})),
|
||||
hooks: {},
|
||||
tzDate,
|
||||
...seriesConfig,
|
||||
};
|
||||
} as any;
|
||||
};
|
||||
|
||||
export const preparePlotData = (data: DataFrame): uPlot.AlignedData => {
|
||||
@ -253,16 +71,45 @@ export const preparePlotData = (data: DataFrame): uPlot.AlignedData => {
|
||||
return plotData;
|
||||
};
|
||||
|
||||
const isPlottingTime = (config: uPlot.Options) => {
|
||||
let isTimeSeries = false;
|
||||
|
||||
if (!config.scales) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < Object.keys(config.scales).length; i++) {
|
||||
const key = Object.keys(config.scales)[i];
|
||||
if (config.scales[key].time === true) {
|
||||
isTimeSeries = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return isTimeSeries;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 shouldReinitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.Options) => {
|
||||
if (!config && !prevConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!prevConfig && config) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPlottingTime(config!) && prevConfig!.tzDate !== config!.tzDate) {
|
||||
return true;
|
||||
}
|
||||
// reinitialise when number of series, scales or axes changes
|
||||
if (
|
||||
prevConfig.series?.length !== config.series?.length ||
|
||||
prevConfig.axes?.length !== config.axes?.length ||
|
||||
prevConfig.scales?.length !== config.scales?.length
|
||||
prevConfig!.series?.length !== config!.series?.length ||
|
||||
prevConfig!.axes?.length !== config!.axes?.length ||
|
||||
prevConfig!.scales?.length !== config!.scales?.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@ -270,20 +117,20 @@ export const shouldReinitialisePlot = (prevConfig: uPlot.Options, config: uPlot.
|
||||
let idx = 0;
|
||||
|
||||
// reinitialise when any of the series config changes
|
||||
if (config.series && prevConfig.series) {
|
||||
for (const series of config.series) {
|
||||
if (!isEqual(series, prevConfig.series[idx])) {
|
||||
if (config!.series && prevConfig!.series) {
|
||||
for (const series of config!.series) {
|
||||
if (!isEqual(series, prevConfig!.series[idx])) {
|
||||
return true;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.axes && prevConfig.axes) {
|
||||
if (config!.axes && prevConfig!.axes) {
|
||||
idx = 0;
|
||||
for (const axis of config.axes) {
|
||||
for (const axis of config!.axes) {
|
||||
// Comparing axes config, skipping values property as it changes across config builds - probably need to be more clever
|
||||
if (!isEqual(omit(axis, 'values'), omit(prevConfig.axes[idx], 'values'))) {
|
||||
if (!isEqual(omit(axis, 'values'), omit(prevConfig!.axes[idx], 'values'))) {
|
||||
return true;
|
||||
}
|
||||
idx++;
|
||||
|
@ -1,22 +1,87 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
Area,
|
||||
Canvas,
|
||||
colors,
|
||||
ContextMenuPlugin,
|
||||
GraphCustomFieldConfig,
|
||||
LegendDisplayMode,
|
||||
LegendPlugin,
|
||||
Line,
|
||||
Point,
|
||||
SeriesGeometry,
|
||||
Scale,
|
||||
TooltipPlugin,
|
||||
UPlotChart,
|
||||
ZoomPlugin,
|
||||
LegendPlugin,
|
||||
Canvas,
|
||||
LegendDisplayMode,
|
||||
} from '@grafana/ui';
|
||||
import { PanelProps } from '@grafana/data';
|
||||
|
||||
import {
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getColorFromHexRgbOrName,
|
||||
getTimeField,
|
||||
PanelProps,
|
||||
systemDateFormats,
|
||||
} 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';
|
||||
|
||||
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,
|
||||
@ -40,6 +105,90 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
</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))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const seriesColor =
|
||||
customConfig?.line.color && customConfig?.line.color.fixedColor
|
||||
? getColorFromHexRgbOrName(customConfig.line.color.fixedColor)
|
||||
: colors[seriesIdx];
|
||||
|
||||
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}>
|
||||
@ -67,6 +216,9 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
|
||||
|
||||
return (
|
||||
<UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
|
||||
{scales}
|
||||
{axes}
|
||||
{geometries}
|
||||
{builder.addSlot('canvas', <Canvas />).render()}
|
||||
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||
|
@ -63,7 +63,6 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane
|
||||
],
|
||||
},
|
||||
showIf: c => {
|
||||
console.log(c);
|
||||
return c.line.show;
|
||||
},
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user