Commit b9953818 by Dominik Prokop Committed by GitHub

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
parent 0c703088
...@@ -71,6 +71,8 @@ export { ...@@ -71,6 +71,8 @@ export {
export { GraphCustomFieldConfig } from './uPlot/types'; export { GraphCustomFieldConfig } from './uPlot/types';
export { UPlotChart } from './uPlot/Plot'; export { UPlotChart } from './uPlot/Plot';
export * from './uPlot/geometries';
export { usePlotConfigContext } from './uPlot/context';
export { Canvas } from './uPlot/Canvas'; export { Canvas } from './uPlot/Canvas';
export * from './uPlot/plugins'; export * from './uPlot/plugins';
......
...@@ -2,114 +2,73 @@ import 'uplot/dist/uPlot.min.css'; ...@@ -2,114 +2,73 @@ import 'uplot/dist/uPlot.min.css';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { css } from 'emotion'; import { css } from 'emotion';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { useTheme } from '../../themes'; import { usePrevious } from 'react-use';
import { buildPlotContext, PlotContext } from './context'; import { buildPlotContext, PlotContext } from './context';
import { buildPlotConfig, pluginLog, preparePlotData, shouldReinitialisePlot } from './utils'; import { pluginLog, preparePlotData, shouldReinitialisePlot } from './utils';
import { usePlotPlugins } from './hooks'; import { usePlotConfig } from './hooks';
import { PlotProps } from './types'; import { PlotProps } from './types';
// uPlot abstraction responsible for plot initialisation, setup and refresh // 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 // 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 // Exposes contexts for plugins registration and uPlot instance access
export const UPlotChart: React.FC<PlotProps> = props => { export const UPlotChart: React.FC<PlotProps> = props => {
const theme = useTheme();
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// instance of uPlot, exposed via PlotContext
const [plotInstance, setPlotInstance] = useState<uPlot>(); const [plotInstance, setPlotInstance] = useState<uPlot>();
// Array with current plot data points, calculated when data frame is passed to a plot // uPlot config API
// const [plotData, setPlotData] = useState<uPlot.AlignedData>(); const { currentConfig, addSeries, addAxis, addScale, registerPlugin } = usePlotConfig(
// uPlot config props.width,
const [currentPlotConfig, setCurrentPlotConfig] = useState<uPlot.Options>(); props.height,
props.timeZone
);
// uPlot plugins API hook const prevConfig = usePrevious(currentConfig);
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
// Main function initialising uPlot. If final config is not settled it will do nothing // Main function initialising uPlot. If final config is not settled it will do nothing
// Will destroy existing uPlot instance const initPlot = () => {
const initPlot = useCallback(() => { if (!currentConfig || !canvasRef.current) {
if (!currentPlotConfig || !canvasRef?.current) { return null;
return;
} }
if (plotInstance) {
pluginLog('uPlot core', false, 'destroying existing instance due to reinitialisation');
plotInstance.destroy();
}
const data = preparePlotData(props.data); 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); // Callback executed when there was no change in plot config
const updateData = useCallback(() => {
setPlotInstance(new uPlot(currentPlotConfig, data, canvasRef.current)); if (!plotInstance) {
}, [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) {
return; return;
} }
initPlot(); const data = preparePlotData(props.data);
}, [currentPlotConfig]); 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(() => { useEffect(() => {
const currentInstance = plotInstance;
return () => { return () => {
if (plotInstance) { currentInstance?.destroy();
pluginLog('uPlot core', false, 'destroying existing instance due to unmount');
plotInstance.destroy();
}
}; };
}, [plotInstance]); }, [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(() => { useEffect(() => {
if (!canvasRef) { if (!currentConfig) {
throw new Error('Cannot render graph without canvas! Render Canvas as a child of Plot component.');
}
if (!arePluginsReady) {
return; return;
} }
if (canvasRef.current) { if (shouldReinitialisePlot(prevConfig, currentConfig)) {
setCurrentPlotConfig(buildPlotConfig(props, props.data, plugins, theme)); const instance = initPlot();
} if (!instance) {
return;
return () => {
if (plotInstance) {
console.log('uPlot - destroy instance, unmount');
plotInstance.destroy();
} }
}; setPlotInstance(instance);
}, [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;
} else { } else {
pluginLog('uPlot core', true, 'updating plot data(throttled log!)'); updateData();
// If config hasn't changed just update uPlot's data
plotInstance?.setData(data);
} }
}, [props.data, props.timeRange]); }, [props.data, props.timeRange, props.timeZone, currentConfig, setPlotInstance]);
// When size props changed update plot size synchronously // When size props changed update plot size synchronously
useLayoutEffect(() => { useLayoutEffect(() => {
...@@ -123,8 +82,8 @@ export const UPlotChart: React.FC<PlotProps> = props => { ...@@ -123,8 +82,8 @@ export const UPlotChart: React.FC<PlotProps> = props => {
// Memoize plot context // Memoize plot context
const plotCtx = useMemo(() => { const plotCtx = useMemo(() => {
return buildPlotContext(registerPlugin, canvasRef, props.data, plotInstance); return buildPlotContext(registerPlugin, addSeries, addAxis, addScale, canvasRef, props.data, plotInstance);
}, [registerPlugin, canvasRef, props.data, plotInstance]); }, [registerPlugin, canvasRef, props.data, plotInstance, addSeries, addAxis, addScale]);
return ( return (
<PlotContext.Provider value={plotCtx}> <PlotContext.Provider value={plotCtx}>
......
...@@ -16,19 +16,40 @@ interface PlotCanvasContextType { ...@@ -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; u?: uPlot;
series?: uPlot.Series[]; series?: uPlot.Series[];
canvas?: PlotCanvasContextType; canvas?: PlotCanvasContextType;
canvasRef: any; canvasRef: any;
registerPlugin: (plugin: PlotPlugin) => () => void;
data: DataFrame; data: DataFrame;
} }
type PlotPluginsContextType = {
registerPlugin: (plugin: PlotPlugin) => () => void;
};
export const PlotContext = React.createContext<PlotContextType | null>(null); export const PlotContext = React.createContext<PlotContextType | null>(null);
// Exposes uPlot instance and bounding box of the entire canvas and plot area // Exposes uPlot instance and bounding box of the entire canvas and plot area
...@@ -51,6 +72,19 @@ export const usePlotPluginContext = (): PlotPluginsContextType => { ...@@ -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 { interface PlotDataAPI {
/** Data frame passed to graph, x-axis aligned */ /** Data frame passed to graph, x-axis aligned */
data: DataFrame; data: DataFrame;
...@@ -136,6 +170,9 @@ export const usePlotCanvas = (): PlotCanvasContextType | null => { ...@@ -136,6 +170,9 @@ export const usePlotCanvas = (): PlotCanvasContextType | null => {
export const buildPlotContext = ( export const buildPlotContext = (
registerPlugin: any, registerPlugin: any,
addSeries: any,
addAxis: any,
addScale: any,
canvasRef: any, canvasRef: any,
data: DataFrame, data: DataFrame,
u?: uPlot u?: uPlot
...@@ -156,6 +193,9 @@ export const buildPlotContext = ( ...@@ -156,6 +193,9 @@ export const buildPlotContext = (
} }
: undefined, : undefined,
registerPlugin, registerPlugin,
addSeries,
addAxis,
addScale,
canvasRef, canvasRef,
data, data,
}; };
......
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';
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';
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';
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';
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';
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;
};
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,
},
};
};
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 };
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;
}
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PlotPlugin } from './types'; import { PlotPlugin } from './types';
import { pluginLog } from './utils'; import { pluginLog } from './utils';
import uPlot from 'uplot';
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
export const usePlotPlugins = () => { export const usePlotPlugins = () => {
/** /**
...@@ -8,7 +10,6 @@ export const usePlotPlugins = () => { ...@@ -8,7 +10,6 @@ export const usePlotPlugins = () => {
* Used to build uPlot plugins config * Used to build uPlot plugins config
*/ */
const [plugins, setPlugins] = useState<Record<string, PlotPlugin>>({}); 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 // arePluginsReady determines whether or not all plugins has already registered and uPlot should be initialised
const [arePluginsReady, setPluginsReady] = useState(false); const [arePluginsReady, setPluginsReady] = useState(false);
...@@ -18,6 +19,7 @@ export const usePlotPlugins = () => { ...@@ -18,6 +19,7 @@ export const usePlotPlugins = () => {
const checkPluginsReady = useCallback(() => { const checkPluginsReady = useCallback(() => {
if (cancellationToken.current) { if (cancellationToken.current) {
window.cancelAnimationFrame(cancellationToken.current); window.cancelAnimationFrame(cancellationToken.current);
cancellationToken.current = undefined;
} }
/** /**
...@@ -56,7 +58,7 @@ export const usePlotPlugins = () => { ...@@ -56,7 +58,7 @@ export const usePlotPlugins = () => {
}); });
}; };
}, },
[setPlugins] [setPlugins, plugins]
); );
// When uPlot mounts let's check if there are any plugins pending registration // When uPlot mounts let's check if there are any plugins pending registration
...@@ -65,6 +67,7 @@ export const usePlotPlugins = () => { ...@@ -65,6 +67,7 @@ export const usePlotPlugins = () => {
return () => { return () => {
if (cancellationToken.current) { if (cancellationToken.current) {
window.cancelAnimationFrame(cancellationToken.current); window.cancelAnimationFrame(cancellationToken.current);
cancellationToken.current = undefined;
} }
}; };
}, []); }, []);
...@@ -75,3 +78,171 @@ export const usePlotPlugins = () => { ...@@ -75,3 +78,171 @@ export const usePlotPlugins = () => {
registerPlugin, 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,
};
};
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import isEqual from 'lodash/isEqual'; import isEqual from 'lodash/isEqual';
import omit from 'lodash/omit'; import omit from 'lodash/omit';
import tinycolor from 'tinycolor2'; import { DataFrame, FieldType, getTimeField, rangeUtil, RawTimeRange } from '@grafana/data';
import {
DataFrame,
FieldConfig,
FieldType,
formattedValueToString,
getColorFromHexRgbOrName,
getFieldDisplayName,
getTimeField,
getTimeZoneInfo,
GrafanaTheme,
rangeUtil,
RawTimeRange,
systemDateFormats,
TimeRange,
} from '@grafana/data';
import { colors } from '../../utils';
import uPlot from 'uplot'; import uPlot from 'uplot';
import { GraphCustomFieldConfig, PlotPlugin, PlotProps } from './types'; import { PlotPlugin, PlotProps } from './types';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
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; 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) => { export const timeFormatToTemplate = (f: string) => {
return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, match => `{${match}}`); 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] { export function rangeToMinMax(timeRange: RawTimeRange): [number, number] {
const v = rangeUtil.convertRawToRange(timeRange); const v = rangeUtil.convertRawToRange(timeRange);
return [v.from.valueOf() / 1000, v.to.valueOf() / 1000]; return [v.from.valueOf() / 1000, v.to.valueOf() / 1000];
} }
// based on aligned data frames creates config for scales, axes and series export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPlugin>): uPlot.Options => {
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);
}
}
return { return {
width: props.width, width: props.width,
height: props.height, height: props.height,
...@@ -215,9 +35,7 @@ export const buildPlotConfig = ( ...@@ -215,9 +35,7 @@ export const buildPlotConfig = (
hooks: p[1].hooks, hooks: p[1].hooks,
})), })),
hooks: {}, hooks: {},
tzDate, } as any;
...seriesConfig,
};
}; };
export const preparePlotData = (data: DataFrame): uPlot.AlignedData => { export const preparePlotData = (data: DataFrame): uPlot.AlignedData => {
...@@ -253,16 +71,45 @@ export const preparePlotData = (data: DataFrame): uPlot.AlignedData => { ...@@ -253,16 +71,45 @@ export const preparePlotData = (data: DataFrame): uPlot.AlignedData => {
return plotData; 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 * Based on two config objects indicates whether or not uPlot needs reinitialisation
* This COULD be done based on data frames, but keeping it this way for now as a simplification * This COULD be done based on data frames, but keeping it this way for now as a simplification
*/ */
export const shouldReinitialisePlot = (prevConfig: uPlot.Options, config: uPlot.Options) => { export const 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 // reinitialise when number of series, scales or axes changes
if ( if (
prevConfig.series?.length !== config.series?.length || prevConfig!.series?.length !== config!.series?.length ||
prevConfig.axes?.length !== config.axes?.length || prevConfig!.axes?.length !== config!.axes?.length ||
prevConfig.scales?.length !== config.scales?.length prevConfig!.scales?.length !== config!.scales?.length
) { ) {
return true; return true;
} }
...@@ -270,20 +117,20 @@ export const shouldReinitialisePlot = (prevConfig: uPlot.Options, config: uPlot. ...@@ -270,20 +117,20 @@ export const shouldReinitialisePlot = (prevConfig: uPlot.Options, config: uPlot.
let idx = 0; let idx = 0;
// reinitialise when any of the series config changes // reinitialise when any of the series config changes
if (config.series && prevConfig.series) { if (config!.series && prevConfig!.series) {
for (const series of config.series) { for (const series of config!.series) {
if (!isEqual(series, prevConfig.series[idx])) { if (!isEqual(series, prevConfig!.series[idx])) {
return true; return true;
} }
idx++; idx++;
} }
} }
if (config.axes && prevConfig.axes) { if (config!.axes && prevConfig!.axes) {
idx = 0; 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 // 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; return true;
} }
idx++; idx++;
......
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { import {
Area,
Canvas,
colors,
ContextMenuPlugin, ContextMenuPlugin,
GraphCustomFieldConfig,
LegendDisplayMode,
LegendPlugin,
Line,
Point,
SeriesGeometry,
Scale,
TooltipPlugin, TooltipPlugin,
UPlotChart, UPlotChart,
ZoomPlugin, ZoomPlugin,
LegendPlugin,
Canvas,
LegendDisplayMode,
} from '@grafana/ui'; } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import {
FieldConfig,
FieldType,
formattedValueToString,
getColorFromHexRgbOrName,
getTimeField,
PanelProps,
systemDateFormats,
} from '@grafana/data';
import { Options } from './types'; import { Options } from './types';
import { alignAndSortDataFramesByFieldName } from './utils'; import { alignAndSortDataFramesByFieldName } from './utils';
import { VizLayout } from './VizLayout'; import { VizLayout } from './VizLayout';
import { Axis } from '@grafana/ui/src/components/uPlot/geometries/Axis';
import { timeFormatToTemplate } from '@grafana/ui/src/components/uPlot/utils';
interface GraphPanelProps extends PanelProps<Options> {} interface GraphPanelProps extends PanelProps<Options> {}
const TIME_FIELD_NAME = 'Time'; const TIME_FIELD_NAME = 'Time';
const timeStampsConfig = [
[3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'],
[3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'],
[
3600 * 24,
`{${timeFormatToTemplate(systemDateFormats.interval.day)}`,
7,
`${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`,
],
[
3600,
`{${timeFormatToTemplate(systemDateFormats.interval.minute)}`,
4,
`${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate(
systemDateFormats.interval.day
)}`,
],
[
60,
`{${timeFormatToTemplate(systemDateFormats.interval.second)}`,
4,
`${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate(
systemDateFormats.interval.day
)}`,
],
[
1,
`:{ss}`,
2,
`:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
systemDateFormats.interval.minute
)}`,
],
[
1e-3,
':{ss}.{fff}',
2,
`:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate(
systemDateFormats.interval.minute
)}`,
],
];
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
export const GraphPanel: React.FC<GraphPanelProps> = ({ export const GraphPanel: React.FC<GraphPanelProps> = ({
data, data,
timeRange, timeRange,
...@@ -40,6 +105,90 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({ ...@@ -40,6 +105,90 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
</div> </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 ( return (
<VizLayout width={width} height={height}> <VizLayout width={width} height={height}>
...@@ -67,6 +216,9 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({ ...@@ -67,6 +216,9 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
return ( return (
<UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}> <UPlotChart data={alignedData} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
{scales}
{axes}
{geometries}
{builder.addSlot('canvas', <Canvas />).render()} {builder.addSlot('canvas', <Canvas />).render()}
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} /> <TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} /> <ZoomPlugin onZoom={onChangeTimeRange} />
......
...@@ -63,7 +63,6 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane ...@@ -63,7 +63,6 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane
], ],
}, },
showIf: c => { showIf: c => {
console.log(c);
return c.line.show; return c.line.show;
}, },
}) })
......
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