From b9953818161bd030dc7a04d2f1dd110e167df567 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 29 Sep 2020 13:25:10 +0200 Subject: [PATCH] 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 --- .../dev-dashboards/panel-graph/graph-ng.json | 138 ++--- packages/grafana-ui/src/components/index.ts | 2 + .../grafana-ui/src/components/uPlot/Plot.tsx | 117 ++-- .../src/components/uPlot/context.ts | 52 +- .../src/components/uPlot/geometries/Area.tsx | 13 + .../src/components/uPlot/geometries/Axis.tsx | 69 +++ .../src/components/uPlot/geometries/Line.tsx | 13 + .../src/components/uPlot/geometries/Point.tsx | 12 + .../src/components/uPlot/geometries/Scale.tsx | 48 ++ .../uPlot/geometries/SeriesGeometry.tsx | 74 +++ .../uPlot/geometries/configGetters.ts | 36 ++ .../src/components/uPlot/geometries/index.ts | 7 + .../src/components/uPlot/geometries/types.ts | 34 ++ .../src/components/uPlot/hooks.test.ts | 524 ++++++++++++++++++ .../grafana-ui/src/components/uPlot/hooks.ts | 177 +++++- .../grafana-ui/src/components/uPlot/utils.ts | 241 ++------ .../app/plugins/panel/graph3/GraphPanel.tsx | 160 +++++- public/app/plugins/panel/graph3/module.tsx | 1 - 18 files changed, 1359 insertions(+), 359 deletions(-) create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/Area.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/Line.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/Point.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/Scale.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/SeriesGeometry.tsx create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/configGetters.ts create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/index.ts create mode 100644 packages/grafana-ui/src/components/uPlot/geometries/types.ts create mode 100644 packages/grafana-ui/src/components/uPlot/hooks.test.ts diff --git a/devenv/dev-dashboards/panel-graph/graph-ng.json b/devenv/dev-dashboards/panel-graph/graph-ng.json index b0f87c0222b..f2da6fb9d14 100644 --- a/devenv/dev-dashboards/panel-graph/graph-ng.json +++ b/devenv/dev-dashboards/panel-graph/graph-ng.json @@ -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" diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 912e59974e7..a9de0732105 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -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'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index 3bb548709e1..78b9eed3a54 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -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 = props => { - const theme = useTheme(); const canvasRef = useRef(null); - - // instance of uPlot, exposed via PlotContext const [plotInstance, setPlotInstance] = useState(); - // Array with current plot data points, calculated when data frame is passed to a plot - // const [plotData, setPlotData] = useState(); - // uPlot config - const [currentPlotConfig, setCurrentPlotConfig] = useState(); + // 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 = 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 ( diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index 896ae97e0e4..0451a48dbfd 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -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(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, }; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Area.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Area.tsx new file mode 100644 index 00000000000..dbd431c2a49 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/Area.tsx @@ -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 = ({ fill = 0.1, scaleKey, color }) => { + const getConfig = () => getAreaConfig({ fill, scaleKey, color }); + useSeriesGeometry(getConfig); + + return null; +}; + +Area.displayName = 'Area'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx new file mode 100644 index 00000000000..58cec2caf01 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/Axis.tsx @@ -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 = 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'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Line.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Line.tsx new file mode 100644 index 00000000000..a219f8a5394 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/Line.tsx @@ -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 = props => { + const getConfig = () => getLineConfig(props); + useSeriesGeometry(getConfig); + + return null; +}; + +Line.displayName = 'Line'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Point.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Point.tsx new file mode 100644 index 00000000000..f6ec25ebfaa --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/Point.tsx @@ -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 = ({ size = 2, stroke, scaleKey }) => { + const getConfig = () => getPointConfig({ size, stroke, scaleKey }); + useSeriesGeometry(getConfig); + + return null; +}; +Point.displayName = 'Point'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/Scale.tsx b/packages/grafana-ui/src/components/uPlot/geometries/Scale.tsx new file mode 100644 index 00000000000..1599e36e424 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/Scale.tsx @@ -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 = props => { + const { scaleKey, time } = props; + + const getConfig = () => { + let config: uPlot.Scale = { + time: !!time, + }; + return config; + }; + + useScaleConfig(scaleKey, getConfig); + + return null; +}; + +Scale.displayName = 'Scale'; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/SeriesGeometry.tsx b/packages/grafana-ui/src/components/uPlot/geometries/SeriesGeometry.tsx new file mode 100644 index 00000000000..d69b8b18ab1 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/SeriesGeometry.tsx @@ -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 {}> = { + 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(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; +}; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/configGetters.ts b/packages/grafana-ui/src/components/uPlot/geometries/configGetters.ts new file mode 100644 index 00000000000..9f67671c915 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/configGetters.ts @@ -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, + }, + }; +}; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/index.ts b/packages/grafana-ui/src/components/uPlot/geometries/index.ts new file mode 100644 index 00000000000..e42234239e0 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/index.ts @@ -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 }; diff --git a/packages/grafana-ui/src/components/uPlot/geometries/types.ts b/packages/grafana-ui/src/components/uPlot/geometries/types.ts new file mode 100644 index 00000000000..a57f079fa05 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/geometries/types.ts @@ -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; +} diff --git a/packages/grafana-ui/src/components/uPlot/hooks.test.ts b/packages/grafana-ui/src/components/uPlot/hooks.test.ts new file mode 100644 index 00000000000..a722b7919c4 --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/hooks.test.ts @@ -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, + } + `); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts index 64cae7afcd2..9485165e536 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -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>({}); - // 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([{}]); + const [axesConfig, setAxisConfig] = useState([]); + const [scalesConfig, setScaleConfig] = useState>({}); + const [currentConfig, setCurrentConfig] = useState(); + + 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, + }; +}; diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 82c7b4dece1..4821e6861a5 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -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; - axes: uPlot.Axis[]; -} => { - const series: uPlot.Series[] = [{}]; - const scales: Record = { - 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; - 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, - 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): 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++; diff --git a/public/app/plugins/panel/graph3/GraphPanel.tsx b/public/app/plugins/panel/graph3/GraphPanel.tsx index e12c5e1261a..2addf83b47d 100644 --- a/public/app/plugins/panel/graph3/GraphPanel.tsx +++ b/public/app/plugins/panel/graph3/GraphPanel.tsx @@ -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 {} const TIME_FIELD_NAME = 'Time'; +const timeStampsConfig = [ + [3600 * 24 * 365, '{YYYY}', 7, '{YYYY}'], + [3600 * 24 * 28, `{${timeFormatToTemplate(systemDateFormats.interval.month)}`, 7, '{MMM}\n{YYYY}'], + [ + 3600 * 24, + `{${timeFormatToTemplate(systemDateFormats.interval.day)}`, + 7, + `${timeFormatToTemplate(systemDateFormats.interval.day)}\n${timeFormatToTemplate(systemDateFormats.interval.year)}`, + ], + [ + 3600, + `{${timeFormatToTemplate(systemDateFormats.interval.minute)}`, + 4, + `${timeFormatToTemplate(systemDateFormats.interval.minute)}\n${timeFormatToTemplate( + systemDateFormats.interval.day + )}`, + ], + [ + 60, + `{${timeFormatToTemplate(systemDateFormats.interval.second)}`, + 4, + `${timeFormatToTemplate(systemDateFormats.interval.second)}\n${timeFormatToTemplate( + systemDateFormats.interval.day + )}`, + ], + [ + 1, + `:{ss}`, + 2, + `:{ss}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( + systemDateFormats.interval.minute + )}`, + ], + [ + 1e-3, + ':{ss}.{fff}', + 2, + `:{ss}.{fff}\n${timeFormatToTemplate(systemDateFormats.interval.day)} ${timeFormatToTemplate( + systemDateFormats.interval.minute + )}`, + ], +]; + +const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); + export const GraphPanel: React.FC = ({ data, timeRange, @@ -40,6 +105,90 @@ export const GraphPanel: React.FC = ({ ); } + const geometries: React.ReactNode[] = []; + const scales: React.ReactNode[] = []; + const axes: React.ReactNode[] = []; + + let { timeIndex } = getTimeField(alignedData); + + if (timeIndex === undefined) { + timeIndex = 0; // assuming first field represents x-domain + scales.push(); + } else { + scales.push(); + } + + axes.push(); + + let seriesIdx = 0; + const uniqueScales: Record = {}; + + for (let i = 0; i < alignedData.fields.length; i++) { + const seriesGeometry = []; + const field = alignedData.fields[i]; + const config = field.config as FieldConfig; + const customConfig = config.custom; + if (i === timeIndex || field.type !== FieldType.number) { + continue; + } + const fmt = field.display ?? defaultFormatter; + const scale = config.unit || '__fixed'; + + if (!uniqueScales[scale]) { + uniqueScales[scale] = true; + scales.push(); + axes.push( + formattedValueToString(fmt(v))} + /> + ); + } + + const seriesColor = + customConfig?.line.color && customConfig?.line.color.fixedColor + ? getColorFromHexRgbOrName(customConfig.line.color.fixedColor) + : colors[seriesIdx]; + + if (customConfig?.line?.show) { + seriesGeometry.push( + + ); + } + + if (customConfig?.points?.show) { + seriesGeometry.push( + + ); + } + + if (customConfig?.fill?.alpha) { + seriesGeometry.push( + + ); + } + if (seriesGeometry.length > 1) { + geometries.push( + + {seriesGeometry} + + ); + } else { + geometries.push(seriesGeometry); + } + + seriesIdx++; + } return ( @@ -67,6 +216,9 @@ export const GraphPanel: React.FC = ({ return ( + {scales} + {axes} + {geometries} {builder.addSlot('canvas', ).render()} diff --git a/public/app/plugins/panel/graph3/module.tsx b/public/app/plugins/panel/graph3/module.tsx index 80ebd1fd16a..78e5912c6ad 100644 --- a/public/app/plugins/panel/graph3/module.tsx +++ b/public/app/plugins/panel/graph3/module.tsx @@ -63,7 +63,6 @@ export const plugin = new PanelPlugin(GraphPane ], }, showIf: c => { - console.log(c); return c.line.show; }, })