From 9c08b34e712b1a348132839e394fb40eb251d096 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 15 Feb 2021 16:46:29 +0100 Subject: [PATCH] GraphNG: refactor core to class component (#30941) * First attempt * Get rid of time range as config invalidation dependency * GraphNG class refactor * Get rid of DataFrame dependency from Plot component, get rid of usePlotData context, rely on XYMatchers for data inspection from within plugins * Bring back legend * Fix Sparkline * Fix Sparkline * Sparkline update * Explore update * fix * BarChart refactor to class * Tweaks * TS fix * Fix tests * Tests * Update packages/grafana-ui/src/components/uPlot/utils.ts * Update public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx * GraphNG: unified legend for BarChart, GraphNG & other uPlot based visualizations (#31175) * Legend experiment * Nits --- .../grafana-data/src/types/fieldOverrides.ts | 2 +- .../components/BarChart/BarChart.story.tsx | 2 +- .../src/components/BarChart/BarChart.tsx | 386 ++----- .../BarChart/__snapshots__/utils.test.ts.snap | 961 ++++++++++++++++++ .../src/components/BarChart/utils.test.ts | 101 ++ .../src/components/BarChart/utils.ts | 190 ++++ .../src/components/GraphNG/GraphNG.tsx | 460 +++------ .../GraphNG/__snapshots__/utils.test.ts.snap | 153 +++ .../src/components/GraphNG/hooks.ts | 45 + .../src/components/GraphNG/types.ts | 7 +- .../src/components/GraphNG/utils.test.ts | 94 ++ .../src/components/GraphNG/utils.ts | 198 ++++ .../src/components/Sparkline/Sparkline.tsx | 93 +- .../src/components/Sparkline/utils.ts | 25 + .../components/VizLayout/VizLayout.story.tsx | 4 +- .../src/components/VizLayout/VizLayout.tsx | 7 +- packages/grafana-ui/src/components/index.ts | 3 +- .../src/components/uPlot/Plot.test.tsx | 24 +- .../grafana-ui/src/components/uPlot/Plot.tsx | 39 +- .../src/components/uPlot/PlotLegend.tsx | 97 ++ .../uPlot/config/UPlotConfigBuilder.test.ts | 7 + .../uPlot/config/UPlotConfigBuilder.ts | 20 +- .../src/components/uPlot/context.ts | 84 +- .../grafana-ui/src/components/uPlot/hooks.ts | 27 +- .../uPlot/plugins/TooltipPlugin.tsx | 69 +- .../grafana-ui/src/components/uPlot/types.ts | 14 +- .../grafana-ui/src/components/uPlot/utils.ts | 34 +- .../features/explore/ExploreGraphNGPanel.tsx | 10 +- .../plugins/panel/barchart/BarChartPanel.tsx | 87 +- .../panel/timeseries/TimeSeriesPanel.tsx | 12 +- .../timeseries/plugins/ContextMenuPlugin.tsx | 119 ++- .../plugins/panel/xychart/XYChartPanel.tsx | 3 +- public/app/plugins/panel/xychart/dims.ts | 6 +- 33 files changed, 2423 insertions(+), 960 deletions(-) create mode 100644 packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/BarChart/utils.test.ts create mode 100644 packages/grafana-ui/src/components/BarChart/utils.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/GraphNG/hooks.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/utils.test.ts create mode 100644 packages/grafana-ui/src/components/GraphNG/utils.ts create mode 100644 packages/grafana-ui/src/components/Sparkline/utils.ts create mode 100644 packages/grafana-ui/src/components/uPlot/PlotLegend.tsx diff --git a/packages/grafana-data/src/types/fieldOverrides.ts b/packages/grafana-data/src/types/fieldOverrides.ts index 31303f08f4a..ae99ac312b2 100644 --- a/packages/grafana-data/src/types/fieldOverrides.ts +++ b/packages/grafana-data/src/types/fieldOverrides.ts @@ -112,10 +112,10 @@ export interface FieldConfigPropertyItem { groupWidth: 0.7, }; - return ; + return ; }; diff --git a/packages/grafana-ui/src/components/BarChart/BarChart.tsx b/packages/grafana-ui/src/components/BarChart/BarChart.tsx index e46aea8afbe..38baeb67cd5 100644 --- a/packages/grafana-ui/src/components/BarChart/BarChart.tsx +++ b/packages/grafana-ui/src/components/BarChart/BarChart.tsx @@ -1,318 +1,138 @@ -import React, { useCallback, useMemo, useRef } from 'react'; -import { - compareDataFrameStructures, - DataFrame, - DefaultTimeZone, - formattedValueToString, - getFieldDisplayName, - getFieldSeriesColor, - getFieldColorModeForField, - TimeRange, - VizOrientation, - fieldReducers, - reduceField, - DisplayValue, -} from '@grafana/data'; - +import React from 'react'; +import { AlignedData } from 'uplot'; +import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data'; import { VizLayout } from '../VizLayout/VizLayout'; import { Themeable } from '../../types'; -import { useRevision } from '../uPlot/hooks'; import { UPlotChart } from '../uPlot/Plot'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; -import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; -import { useTheme } from '../../themes'; -import { GraphNGLegendEvent, GraphNGLegendEventMode } from '../GraphNG/types'; -import { FIXED_UNIT } from '../GraphNG/GraphNG'; -import { LegendDisplayMode, VizLegendItem } from '../VizLegend/types'; -import { VizLegend } from '../VizLegend/VizLegend'; - -import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types'; -import { BarsOptions, getConfig } from './bars'; +import { GraphNGLegendEvent } from '../GraphNG/types'; +import { BarChartOptions } from './types'; +import { withTheme } from '../../themes'; +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { preparePlotData } from '../uPlot/utils'; +import { LegendDisplayMode } from '../VizLegend/types'; +import { PlotLegend } from '../uPlot/PlotLegend'; /** * @alpha */ -export interface Props extends Themeable, BarChartOptions { +export interface BarChartProps extends Themeable, BarChartOptions { height: number; width: number; - data: DataFrame; + data: DataFrame[]; onLegendClick?: (event: GraphNGLegendEvent) => void; onSeriesColorChange?: (label: string, color: string) => void; } -/** - * @alpha - */ -export const BarChart: React.FunctionComponent = ({ - width, - height, - data, - orientation, - groupWidth, - barWidth, - showValue, - legend, - onLegendClick, - onSeriesColorChange, - ...plotProps -}) => { - if (!data || data.fields.length < 2) { - return
Missing data
; +interface BarChartState { + data: AlignedData; + alignedDataFrame: DataFrame; + config?: UPlotConfigBuilder; +} + +class UnthemedBarChart extends React.Component { + constructor(props: BarChartProps) { + super(props); + this.state = {} as BarChartState; } - // dominik? TODO? can this all be moved into `useRevision` - const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { - if (a && b) { - return compareDataFrameStructures(a, b); - } - return false; - }, []); + static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) { + const frame = preparePlotFrame(props.data); - const configRev = useRevision(data, compareFrames); - - const theme = useTheme(); - - // Updates only when the structure changes - const configBuilder = useMemo(() => { - if (!orientation || orientation === VizOrientation.Auto) { - orientation = width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; + if (!frame) { + return { ...state }; } - // bar orientation -> x scale orientation & direction - let xOri: ScaleOrientation, xDir: ScaleDirection, yOri: ScaleOrientation, yDir: ScaleDirection; - - if (orientation === VizOrientation.Vertical) { - xOri = ScaleOrientation.Horizontal; - xDir = ScaleDirection.Right; - yOri = ScaleOrientation.Vertical; - yDir = ScaleDirection.Up; - } else { - xOri = ScaleOrientation.Vertical; - xDir = ScaleDirection.Down; - yOri = ScaleOrientation.Horizontal; - yDir = ScaleDirection.Right; - } - - const formatValue = - showValue !== BarValueVisibility.Never - ? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) - : undefined; - - // Use bar width when only one field - if (data.fields.length === 2) { - groupWidth = barWidth; - barWidth = 1; - } - - const opts: BarsOptions = { - xOri, - xDir, - groupWidth, - barWidth, - formatValue, - onHover: (seriesIdx: number, valueIdx: number) => { - console.log('hover', { seriesIdx, valueIdx }); - }, - onLeave: (seriesIdx: number, valueIdx: number) => { - console.log('leave', { seriesIdx, valueIdx }); - }, + return { + ...state, + data: preparePlotData(frame), + alignedDataFrame: frame, }; - const config = getConfig(opts); + } - const builder = new UPlotConfigBuilder(); + componentDidMount() { + const { alignedDataFrame } = this.state; - builder.addHook('init', config.init); - builder.addHook('drawClear', config.drawClear); - builder.addHook('setCursor', config.setCursor); - - builder.setCursor(config.cursor); - builder.setSelect(config.select); - - builder.addScale({ - scaleKey: 'x', - isTime: false, - distribution: ScaleDistribution.Ordinal, - orientation: xOri, - direction: xDir, - }); - - builder.addAxis({ - scaleKey: 'x', - isTime: false, - placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, - splits: config.xSplits, - values: config.xValues, - grid: false, - ticks: false, - gap: 15, - theme, - }); - - let seriesIndex = 0; - - // iterate the y values - for (let i = 1; i < data.fields.length; i++) { - const field = data.fields[i]; - - field.state!.seriesIndex = seriesIndex++; - - const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; - - const scaleKey = field.config.unit || FIXED_UNIT; - const colorMode = getFieldColorModeForField(field); - const scaleColor = getFieldSeriesColor(field, theme); - const seriesColor = scaleColor.color; - - builder.addSeries({ - scaleKey, - pxAlign: false, - lineWidth: customConfig.lineWidth, - lineColor: seriesColor, - //lineStyle: customConfig.lineStyle, - fillOpacity: customConfig.fillOpacity, - theme, - colorMode, - pathBuilder: config.drawBars, - pointsBuilder: config.drawPoints, - show: !customConfig.hideFrom?.graph, - gradientMode: customConfig.gradientMode, - thresholds: field.config.thresholds, - - // The following properties are not used in the uPlot config, but are utilized as transport for legend config - dataFrameFieldIndex: { - fieldIndex: i, - frameIndex: 0, - }, - fieldName: getFieldDisplayName(field, data), - hideInLegend: customConfig.hideFrom?.legend, - }); - - // The builder will manage unique scaleKeys and combine where appropriate - builder.addScale({ - scaleKey, - min: field.config.min, - max: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, - orientation: yOri, - direction: yDir, - }); - - if (customConfig.axisPlacement !== AxisPlacement.Hidden) { - let placement = customConfig.axisPlacement; - if (!placement || placement === AxisPlacement.Auto) { - placement = AxisPlacement.Left; - } - if (xOri === 1) { - if (placement === AxisPlacement.Left) { - placement = AxisPlacement.Bottom; - } - if (placement === AxisPlacement.Right) { - placement = AxisPlacement.Top; - } - } - - builder.addAxis({ - scaleKey, - label: customConfig.axisLabel, - size: customConfig.axisWidth, - placement, - formatValue: (v) => formattedValueToString(field.display!(v)), - theme, - }); - } + if (!alignedDataFrame) { + return; } - return builder; - }, [data, configRev, orientation, width, height]); + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), + }); + } - const onLabelClick = useCallback( - (legend: VizLegendItem, event: React.MouseEvent) => { - const { fieldIndex } = legend; + componentDidUpdate(prevProps: BarChartProps) { + const { data, orientation, groupWidth, barWidth, showValue } = this.props; + const { alignedDataFrame } = this.state; + let shouldConfigUpdate = false; + let hasStructureChanged = false; - if (!onLegendClick || !fieldIndex) { + if ( + this.state.config === undefined || + orientation !== prevProps.orientation || + groupWidth !== prevProps.groupWidth || + barWidth !== prevProps.barWidth || + showValue !== prevProps.showValue + ) { + shouldConfigUpdate = true; + } + + if (data !== prevProps.data) { + if (!alignedDataFrame) { return; } + hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures); + } - onLegendClick({ - fieldIndex, - mode: GraphNGLegendEventMode.AppendToSelection, + if (shouldConfigUpdate || hasStructureChanged) { + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props), }); - }, - [onLegendClick, data] - ); + } + } - const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden); + renderLegend() { + const { legend, onSeriesColorChange, onLegendClick, data } = this.props; + const { config } = this.state; - const legendItems = configBuilder - .getSeries() - .map((s) => { - const seriesConfig = s.props; - const fieldIndex = seriesConfig.dataFrameFieldIndex; - if (seriesConfig.hideInLegend || !fieldIndex) { - return undefined; - } - - const field = data.fields[fieldIndex.fieldIndex]; - if (!field) { - return undefined; - } - - return { - disabled: !seriesConfig.show ?? false, - fieldIndex, - color: seriesConfig.lineColor!, - label: seriesConfig.fieldName, - yAxis: 1, - getDisplayValues: () => { - if (!legend.calcs?.length) { - return []; - } - - const fieldCalcs = reduceField({ - field, - reducers: legend.calcs, - }); - - return legend.calcs.map((reducer) => { - return { - ...field.display!(fieldCalcs[reducer]), - title: fieldReducers.get(reducer).name, - }; - }); - }, - }; - }) - .filter((i) => i !== undefined) as VizLegendItem[]; - - let legendElement: React.ReactElement | undefined; - - if (hasLegend && legendItems.length > 0) { - legendElement = ( - - - + if (!config || legend.displayMode === LegendDisplayMode.Hidden) { + return; + } + return ( + ); } - return ( - - {(vizWidth: number, vizHeight: number) => ( - - )} - - ); -}; + render() { + const { width, height } = this.props; + const { config, data } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + + )} + + ); + } +} + +export const BarChart = withTheme(UnthemedBarChart); +BarChart.displayName = 'GraphNG'; diff --git a/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..c2cd121a0f5 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap @@ -0,0 +1,961 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder orientation 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder orientation 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder orientation 3`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "left", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "bottom", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "distribution": "ordinal", + "isTime": false, + "orientation": 0, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 1, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder stacking 3`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder value visibility 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; + +exports[`GraphNG utils preparePlotConfigBuilder value visibility 2`] = ` +UPlotConfigBuilder { + "axes": Object { + "m/s": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "bottom", + "scaleKey": "m/s", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "gap": 15, + "grid": false, + "isTime": false, + "placement": "left", + "scaleKey": "x", + "splits": [Function], + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "ticks": false, + "values": [Function], + }, + }, + }, + "bands": Array [], + "cursor": Object { + "points": Object { + "show": false, + }, + "x": false, + "y": false, + }, + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object { + "drawClear": Array [ + [Function], + ], + "init": Array [ + [Function], + ], + "setCursor": Array [ + [Function], + ], + }, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": -1, + "distribution": "ordinal", + "isTime": false, + "orientation": 1, + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "max": undefined, + "min": undefined, + "orientation": 0, + "scaleKey": "m/s", + "softMax": undefined, + "softMin": 0, + }, + }, + ], + "select": Object { + "show": false, + }, + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#808080", + "lineWidth": 2, + "pathBuilder": [Function], + "pointsBuilder": [Function], + "pxAlign": false, + "scaleKey": "m/s", + "show": true, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/components/BarChart/utils.test.ts b/packages/grafana-ui/src/components/BarChart/utils.test.ts new file mode 100644 index 00000000000..631d3b8d707 --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/utils.test.ts @@ -0,0 +1,101 @@ +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { FieldConfig, FieldType, GrafanaTheme, MutableDataFrame, VizOrientation } from '@grafana/data'; +import { BarChartFieldConfig, BarChartOptions, BarStackingMode, BarValueVisibility } from './types'; +import { GraphGradientMode } from '../uPlot/config'; +import { LegendDisplayMode } from '../VizLegend/types'; + +function mockDataFrame() { + const df1 = new MutableDataFrame({ + refId: 'A', + fields: [{ name: 'ts', type: FieldType.string, values: ['a', 'b', 'c'] }], + }); + + const df2 = new MutableDataFrame({ + refId: 'B', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }], + }); + + const f1Config: FieldConfig = { + displayName: 'Metric 1', + decimals: 2, + unit: 'm/s', + custom: { + gradientMode: GraphGradientMode.Opacity, + lineWidth: 2, + fillOpacity: 0.1, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + decimals: 2, + unit: 'kWh', + custom: { + gradientMode: GraphGradientMode.Hue, + lineWidth: 2, + fillOpacity: 0.1, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + state: {}, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + state: {}, + }); + + return preparePlotFrame([df1, df2]); +} + +describe('GraphNG utils', () => { + describe('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + + const config: BarChartOptions = { + orientation: VizOrientation.Auto, + groupWidth: 20, + barWidth: 2, + showValue: BarValueVisibility.Always, + legend: { + displayMode: LegendDisplayMode.List, + placement: 'bottom', + calcs: [], + }, + stacking: BarStackingMode.None, + }; + + it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + orientation: v, + }) + ).toMatchSnapshot(); + }); + + it.each([BarValueVisibility.Always, BarValueVisibility.Auto])('value visibility', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + showValue: v, + }) + ).toMatchSnapshot(); + }); + + it.each([BarStackingMode.None, BarStackingMode.Percent, BarStackingMode.Standard])('stacking', (v) => { + expect( + preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, { + ...config, + stacking: v, + }) + ).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/BarChart/utils.ts b/packages/grafana-ui/src/components/BarChart/utils.ts new file mode 100644 index 00000000000..f5ae05ddbca --- /dev/null +++ b/packages/grafana-ui/src/components/BarChart/utils.ts @@ -0,0 +1,190 @@ +import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; +import { + DataFrame, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldDisplayName, + getFieldSeriesColor, + GrafanaTheme, + MutableDataFrame, + VizOrientation, +} from '@grafana/data'; +import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types'; +import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; +import { BarsOptions, getConfig } from './bars'; +import { FIXED_UNIT } from '../GraphNG/GraphNG'; + +/** @alpha */ +export function preparePlotConfigBuilder( + data: DataFrame, + theme: GrafanaTheme, + { orientation, showValue, groupWidth, barWidth }: BarChartOptions +) { + const builder = new UPlotConfigBuilder(); + + // bar orientation -> x scale orientation & direction + let xOri = ScaleOrientation.Vertical; + let xDir = ScaleDirection.Down; + let yOri = ScaleOrientation.Horizontal; + let yDir = ScaleDirection.Right; + + if (orientation === VizOrientation.Vertical) { + xOri = ScaleOrientation.Horizontal; + xDir = ScaleDirection.Right; + yOri = ScaleOrientation.Vertical; + yDir = ScaleDirection.Up; + } + + const formatValue = + showValue !== BarValueVisibility.Never + ? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value)) + : undefined; + + // Use bar width when only one field + if (data.fields.length === 2) { + groupWidth = barWidth; + barWidth = 1; + } + + const opts: BarsOptions = { + xOri, + xDir, + groupWidth, + barWidth, + formatValue, + onHover: (seriesIdx: number, valueIdx: number) => { + console.log('hover', { seriesIdx, valueIdx }); + }, + onLeave: (seriesIdx: number, valueIdx: number) => { + console.log('leave', { seriesIdx, valueIdx }); + }, + }; + + const config = getConfig(opts); + + builder.addHook('init', config.init); + builder.addHook('drawClear', config.drawClear); + builder.addHook('setCursor', config.setCursor); + + builder.setCursor(config.cursor); + builder.setSelect(config.select); + + builder.addScale({ + scaleKey: 'x', + isTime: false, + distribution: ScaleDistribution.Ordinal, + orientation: xOri, + direction: xDir, + }); + + builder.addAxis({ + scaleKey: 'x', + isTime: false, + placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, + splits: config.xSplits, + values: config.xValues, + grid: false, + ticks: false, + gap: 15, + theme, + }); + + let seriesIndex = 0; + + // iterate the y values + for (let i = 1; i < data.fields.length; i++) { + const field = data.fields[i]; + + field.state!.seriesIndex = seriesIndex++; + + const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom }; + + const scaleKey = field.config.unit || FIXED_UNIT; + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + builder.addSeries({ + scaleKey, + pxAlign: false, + lineWidth: customConfig.lineWidth, + lineColor: seriesColor, + //lineStyle: customConfig.lineStyle, + fillOpacity: customConfig.fillOpacity, + theme, + colorMode, + pathBuilder: config.drawBars, + pointsBuilder: config.drawPoints, + show: !customConfig.hideFrom?.graph, + gradientMode: customConfig.gradientMode, + thresholds: field.config.thresholds, + + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: { + fieldIndex: i, + frameIndex: 0, + }, + fieldName: getFieldDisplayName(field, data), + hideInLegend: customConfig.hideFrom?.legend, + }); + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ + scaleKey, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + orientation: yOri, + direction: yDir, + }); + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let placement = customConfig.axisPlacement; + if (!placement || placement === AxisPlacement.Auto) { + placement = AxisPlacement.Left; + } + if (xOri === 1) { + if (placement === AxisPlacement.Left) { + placement = AxisPlacement.Bottom; + } + if (placement === AxisPlacement.Right) { + placement = AxisPlacement.Top; + } + } + + builder.addAxis({ + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement, + formatValue: (v) => formattedValueToString(field.display!(v)), + theme, + }); + } + } + + return builder; +} + +/** @internal */ +export function preparePlotFrame(data: DataFrame[]) { + const firstFrame = data[0]; + const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); + + if (!firstString) { + throw new Error('No string field in DF'); + } + + const resultFrame = new MutableDataFrame(); + resultFrame.addField(firstString); + + for (const f of firstFrame.fields) { + if (f.type === FieldType.number) { + resultFrame.addField(f); + } + } + + return resultFrame; +} diff --git a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx index 8e5df06f449..e07776bd473 100755 --- a/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx +++ b/packages/grafana-ui/src/components/GraphNG/GraphNG.tsx @@ -1,49 +1,30 @@ -import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import React from 'react'; +import { AlignedData } from 'uplot'; import { + compareArrayValues, compareDataFrameStructures, DataFrame, - DisplayValue, - FieldConfig, - FieldMatcher, + DataFrameFieldIndex, FieldMatcherID, fieldMatchers, - fieldReducers, - FieldType, - formattedValueToString, - getFieldDisplayName, - outerJoinDataFrames, - reduceField, TimeRange, TimeZone, - getFieldColorModeForField, - getFieldSeriesColor, } from '@grafana/data'; -import { useTheme } from '../../themes'; -import { UPlotChart } from '../uPlot/Plot'; -import { - AxisPlacement, - DrawStyle, - GraphFieldConfig, - PointVisibility, - ScaleDirection, - ScaleOrientation, -} from '../uPlot/config'; -import { VizLayout } from '../VizLayout/VizLayout'; -import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types'; -import { VizLegend } from '../VizLegend/VizLegend'; +import { withTheme } from '../../themes'; +import { Themeable } from '../../types'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; -import { useRevision } from '../uPlot/hooks'; -import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types'; -import { isNumber } from 'lodash'; +import { GraphNGLegendEvent, XYFieldMatchers } from './types'; +import { GraphNGContext } from './hooks'; +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { preparePlotData } from '../uPlot/utils'; +import { PlotLegend } from '../uPlot/PlotLegend'; +import { UPlotChart } from '../uPlot/Plot'; +import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/types'; +import { VizLayout } from '../VizLayout/VizLayout'; -const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); +export const FIXED_UNIT = '__fixed'; -export interface XYFieldMatchers { - x: FieldMatcher; // first match - y: FieldMatcher; -} - -export interface GraphNGProps { +export interface GraphNGProps extends Themeable { width: number; height: number; data: DataFrame[]; @@ -56,310 +37,171 @@ export interface GraphNGProps { children?: React.ReactNode; } -const defaultConfig: GraphFieldConfig = { - drawStyle: DrawStyle.Line, - showPoints: PointVisibility.Auto, - axisPlacement: AxisPlacement.Auto, -}; +interface GraphNGState { + data: AlignedData; + alignedDataFrame: DataFrame; + dimFields: XYFieldMatchers; + seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[]; + config?: UPlotConfigBuilder; +} -export const FIXED_UNIT = '__fixed'; +class UnthemedGraphNG extends React.Component { + constructor(props: GraphNGProps) { + super(props); + let dimFields = props.fields; -export const GraphNG: React.FC = ({ - data, - fields, - children, - width, - height, - legend, - timeRange, - timeZone, - onLegendClick, - onSeriesColorChange, - ...plotProps -}) => { - const theme = useTheme(); - const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden); - - const frame = useMemo(() => { - // Default to timeseries config - if (!fields) { - fields = { + if (!dimFields) { + dimFields = { x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), y: fieldMatchers.get(FieldMatcherID.numeric).get({}), }; } - return outerJoinDataFrames({ frames: data, joinBy: fields.x, keep: fields.y, keepOriginIndices: true }); - }, [data, fields]); + this.state = { dimFields } as GraphNGState; + } - const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { - if (a && b) { - return compareDataFrameStructures(a, b); + /** + * Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array. + * It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume + * that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32) + * + * Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle. + * If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update + * + * This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update, + * the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that + * often (apart from the edit mode interactions) this should be a fair performance compromise. + */ + static getDerivedStateFromProps(props: GraphNGProps, state: GraphNGState) { + let dimFields = props.fields; + + if (!dimFields) { + dimFields = { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }; } - return false; - }, []); - const onLabelClick = useCallback( - (legend: VizLegendItem, event: React.MouseEvent) => { - const { fieldIndex } = legend; + const frame = preparePlotFrame(props.data, dimFields); - if (!onLegendClick || !fieldIndex) { + if (!frame) { + return { ...state, dimFields }; + } + + return { + ...state, + data: preparePlotData(frame), + alignedDataFrame: frame, + seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!), + dimFields, + }; + } + + componentDidMount() { + const { theme } = this.props; + + // alignedDataFrame is already prepared by getDerivedStateFromProps method + const { alignedDataFrame } = this.state; + + if (!alignedDataFrame) { + return; + } + + this.setState({ + config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone), + }); + } + + componentDidUpdate(prevProps: GraphNGProps) { + const { data, theme } = this.props; + const { alignedDataFrame } = this.state; + let shouldConfigUpdate = false; + let stateUpdate = {} as GraphNGState; + + if (this.state.config === undefined || this.props.timeZone !== prevProps.timeZone) { + shouldConfigUpdate = true; + } + + if (data !== prevProps.data) { + if (!alignedDataFrame) { return; } - onLegendClick({ - fieldIndex, - mode: mapMouseEventToMode(event), - }); - }, - [onLegendClick, data] - ); + const hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures); - // reference change will not trigger re-render - const currentTimeRange = useRef(timeRange); - - useLayoutEffect(() => { - currentTimeRange.current = timeRange; - }, [timeRange]); - - const configRev = useRevision(frame, compareFrames); - - const configBuilder = useMemo(() => { - const builder = new UPlotConfigBuilder(); - - if (!frame) { - return builder; + if (shouldConfigUpdate || hasStructureChanged) { + const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone); + stateUpdate = { ...stateUpdate, config: builder }; + } } - // X is the first field in the aligned frame - const xField = frame.fields[0]; - let seriesIndex = 0; - - if (xField.type === FieldType.time) { - builder.addScale({ - scaleKey: 'x', - orientation: ScaleOrientation.Horizontal, - direction: ScaleDirection.Right, - isTime: true, - range: () => { - const r = currentTimeRange.current!; - return [r.from.valueOf(), r.to.valueOf()]; - }, - }); - - builder.addAxis({ - scaleKey: 'x', - isTime: true, - placement: AxisPlacement.Bottom, - timeZone, - theme, - }); - } else { - // Not time! - builder.addScale({ - scaleKey: 'x', - orientation: ScaleOrientation.Horizontal, - direction: ScaleDirection.Right, - }); - - builder.addAxis({ - scaleKey: 'x', - placement: AxisPlacement.Bottom, - theme, - }); + if (Object.keys(stateUpdate).length > 0) { + this.setState(stateUpdate); } - let indexByName: Map | undefined = undefined; + } - for (let i = 0; i < frame.fields.length; i++) { - const field = frame.fields[i]; - const config = field.config as FieldConfig; - const customConfig: GraphFieldConfig = { - ...defaultConfig, - ...config.custom, - }; + mapSeriesIndexToDataFrameFieldIndex = (i: number) => { + return this.state.seriesToDataFrameFieldIndexMap[i]; + }; - if (field === xField || field.type !== FieldType.number) { - continue; - } - field.state!.seriesIndex = seriesIndex++; + getTimeRange = () => { + return this.props.timeRange; + }; - const fmt = field.display ?? defaultFormatter; - const scaleKey = config.unit || FIXED_UNIT; - const colorMode = getFieldColorModeForField(field); - const scaleColor = getFieldSeriesColor(field, theme); - const seriesColor = scaleColor.color; + getTimeZone = () => { + return this.props.timeZone; + }; - // The builder will manage unique scaleKeys and combine where appropriate - builder.addScale({ - scaleKey, - orientation: ScaleOrientation.Vertical, - direction: ScaleDirection.Up, - distribution: customConfig.scaleDistribution?.type, - log: customConfig.scaleDistribution?.log, - min: field.config.min, - max: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, - }); + renderLegend() { + const { legend, onSeriesColorChange, onLegendClick, data } = this.props; + const { config } = this.state; - if (customConfig.axisPlacement !== AxisPlacement.Hidden) { - builder.addAxis({ - scaleKey, - label: customConfig.axisLabel, - size: customConfig.axisWidth, - placement: customConfig.axisPlacement ?? AxisPlacement.Auto, - formatValue: (v) => formattedValueToString(fmt(v)), - theme, - }); - } - - const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints; - - let { fillOpacity } = customConfig; - if (customConfig.fillBelowTo) { - if (!indexByName) { - indexByName = getNamesToFieldIndex(frame); - } - const t = indexByName.get(getFieldDisplayName(field, frame)); - const b = indexByName.get(customConfig.fillBelowTo); - if (isNumber(b) && isNumber(t)) { - builder.addBand({ - series: [t, b], - fill: null as any, // using null will have the band use fill options from `t` - }); - } - if (!fillOpacity) { - fillOpacity = 35; // default from flot - } - } - - builder.addSeries({ - scaleKey, - showPoints, - colorMode, - fillOpacity, - theme, - drawStyle: customConfig.drawStyle!, - lineColor: customConfig.lineColor ?? seriesColor, - lineWidth: customConfig.lineWidth, - lineInterpolation: customConfig.lineInterpolation, - lineStyle: customConfig.lineStyle, - barAlignment: customConfig.barAlignment, - pointSize: customConfig.pointSize, - pointColor: customConfig.pointColor ?? seriesColor, - spanNulls: customConfig.spanNulls || false, - show: !customConfig.hideFrom?.graph, - gradientMode: customConfig.gradientMode, - thresholds: config.thresholds, - - // The following properties are not used in the uPlot config, but are utilized as transport for legend config - dataFrameFieldIndex: field.state?.origin, - fieldName: getFieldDisplayName(field, frame), - hideInLegend: customConfig.hideFrom?.legend, - }); + if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) { + return; } - return builder; - }, [configRev, timeZone]); - if (!frame) { return ( -
-

No data found in response

-
+ ); } - const legendItems = configBuilder - .getSeries() - .map((s) => { - const seriesConfig = s.props; - const fieldIndex = seriesConfig.dataFrameFieldIndex; - const axisPlacement = configBuilder.getAxisPlacement(s.props.scaleKey); + render() { + const { width, height, children, timeZone, timeRange, ...plotProps } = this.props; - if (seriesConfig.hideInLegend || !fieldIndex) { - return undefined; - } + if (!this.state.data || !this.state.config) { + return null; + } - const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex]; - - // Hackish: when the data prop and config builder are not in sync yet - if (!field) { - return undefined; - } - - return { - disabled: !seriesConfig.show ?? false, - fieldIndex, - color: seriesConfig.lineColor!, - label: seriesConfig.fieldName, - yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2, - getDisplayValues: () => { - if (!legend.calcs?.length) { - return []; - } - - const fmt = field.display ?? defaultFormatter; - const fieldCalcs = reduceField({ - field, - reducers: legend.calcs, - }); - - return legend.calcs.map((reducer) => { - return { - ...fmt(fieldCalcs[reducer]), - title: fieldReducers.get(reducer).name, - }; - }); - }, - }; - }) - .filter((i) => i !== undefined) as VizLegendItem[]; - - let legendElement: React.ReactElement | undefined; - - if (hasLegend && legendItems.length > 0) { - legendElement = ( - - - + return ( + + + {(vizWidth: number, vizHeight: number) => ( + + {children} + + )} + + ); } - - return ( - - {(vizWidth: number, vizHeight: number) => ( - - {children} - - )} - - ); -}; - -const mapMouseEventToMode = (event: React.MouseEvent): GraphNGLegendEventMode => { - if (event.ctrlKey || event.metaKey || event.shiftKey) { - return GraphNGLegendEventMode.AppendToSelection; - } - return GraphNGLegendEventMode.ToggleSelection; -}; - -function getNamesToFieldIndex(frame: DataFrame): Map { - const names = new Map(); - for (let i = 0; i < frame.fields.length; i++) { - names.set(getFieldDisplayName(frame.fields[i], frame), i); - } - return names; } + +export const GraphNG = withTheme(UnthemedGraphNG); +GraphNG.displayName = 'GraphNG'; diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..2a212716a01 --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -0,0 +1,153 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` +UPlotConfigBuilder { + "axes": Object { + "__fixed": UPlotAxisBuilder { + "props": Object { + "formatValue": [Function], + "label": undefined, + "placement": "left", + "scaleKey": "__fixed", + "size": undefined, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + }, + }, + "x": UPlotAxisBuilder { + "props": Object { + "isTime": true, + "placement": "bottom", + "scaleKey": "x", + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "timeZone": "browser", + }, + }, + }, + "bands": Array [], + "getTimeZone": [Function], + "hasBottomAxis": true, + "hasLeftAxis": true, + "hooks": Object {}, + "scales": Array [ + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "isTime": true, + "orientation": 0, + "range": [Function], + "scaleKey": "x", + }, + }, + UPlotScaleBuilder { + "props": Object { + "direction": 1, + "distribution": undefined, + "log": undefined, + "max": undefined, + "min": undefined, + "orientation": 1, + "scaleKey": "__fixed", + "softMax": undefined, + "softMin": undefined, + }, + }, + ], + "series": Array [ + UPlotSeriesBuilder { + "props": Object { + "barAlignment": undefined, + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 0, + }, + "drawStyle": "line", + "fieldName": "Metric 1", + "fillOpacity": 0.1, + "gradientMode": "opacity", + "hideInLegend": undefined, + "lineColor": "#ff0000", + "lineInterpolation": "linear", + "lineStyle": Object { + "dash": Array [ + 1, + 2, + ], + "fill": "dash", + }, + "lineWidth": 2, + "pointColor": "#808080", + "pointSize": undefined, + "scaleKey": "__fixed", + "show": true, + "showPoints": "always", + "spanNulls": false, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + UPlotSeriesBuilder { + "props": Object { + "barAlignment": -1, + "colorMode": Object { + "description": "Derive colors from thresholds", + "getCalculator": [Function], + "id": "thresholds", + "isByValue": true, + "name": "From thresholds", + }, + "dataFrameFieldIndex": Object { + "fieldIndex": 1, + "frameIndex": 1, + }, + "drawStyle": "bars", + "fieldName": "Metric 2", + "fillOpacity": 0.1, + "gradientMode": "hue", + "hideInLegend": undefined, + "lineColor": "#ff0000", + "lineInterpolation": "linear", + "lineStyle": Object { + "dash": Array [ + 1, + 2, + ], + "fill": "dash", + }, + "lineWidth": 2, + "pointColor": "#808080", + "pointSize": undefined, + "scaleKey": "__fixed", + "show": true, + "showPoints": "always", + "spanNulls": false, + "theme": Object { + "colors": Object { + "panelBg": "#000000", + }, + }, + "thresholds": undefined, + }, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/components/GraphNG/hooks.ts b/packages/grafana-ui/src/components/GraphNG/hooks.ts new file mode 100644 index 00000000000..9d200116da3 --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/hooks.ts @@ -0,0 +1,45 @@ +import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data'; +import { XYFieldMatchers } from './types'; +import React, { useCallback, useContext } from 'react'; + +/** @alpha */ +interface GraphNGContextType { + mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex; + dimFields: XYFieldMatchers; +} + +/** @alpha */ +export const GraphNGContext = React.createContext({} as GraphNGContextType); + +/** + * @alpha + * Exposes API for data frame inspection in Plot plugins + */ +export const useGraphNGContext = () => { + const graphCtx = useContext(GraphNGContext); + + const getXAxisField = useCallback( + (data: DataFrame[]) => { + const xFieldMatcher = graphCtx.dimFields.x; + let xField: Field | null = null; + + for (let i = 0; i < data.length; i++) { + const frame = data[i]; + for (let j = 0; j < frame.fields.length; j++) { + if (xFieldMatcher(frame.fields[j], frame, data)) { + xField = frame.fields[j]; + break; + } + } + } + + return xField; + }, + [graphCtx] + ); + + return { + ...graphCtx, + getXAxisField, + }; +}; diff --git a/packages/grafana-ui/src/components/GraphNG/types.ts b/packages/grafana-ui/src/components/GraphNG/types.ts index 5c164d4b5a5..9ad1323a5e5 100644 --- a/packages/grafana-ui/src/components/GraphNG/types.ts +++ b/packages/grafana-ui/src/components/GraphNG/types.ts @@ -1,4 +1,4 @@ -import { DataFrameFieldIndex } from '@grafana/data'; +import { DataFrameFieldIndex, FieldMatcher } from '@grafana/data'; /** * Mode to describe if a legend is isolated/selected or being appended to an existing @@ -18,3 +18,8 @@ export interface GraphNGLegendEvent { fieldIndex: DataFrameFieldIndex; mode: GraphNGLegendEventMode; } + +export interface XYFieldMatchers { + x: FieldMatcher; // first match + y: FieldMatcher; +} diff --git a/packages/grafana-ui/src/components/GraphNG/utils.test.ts b/packages/grafana-ui/src/components/GraphNG/utils.test.ts new file mode 100644 index 00000000000..30b5a57d48f --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/utils.test.ts @@ -0,0 +1,94 @@ +import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; +import { + DefaultTimeZone, + FieldConfig, + FieldMatcherID, + fieldMatchers, + FieldType, + getDefaultTimeRange, + GrafanaTheme, + MutableDataFrame, +} from '@grafana/data'; +import { BarAlignment, DrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, PointVisibility } from '..'; + +function mockDataFrame() { + const df1 = new MutableDataFrame({ + refId: 'A', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 3] }], + }); + const df2 = new MutableDataFrame({ + refId: 'B', + fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }], + }); + + const f1Config: FieldConfig = { + displayName: 'Metric 1', + decimals: 2, + custom: { + drawStyle: DrawStyle.Line, + gradientMode: GraphGradientMode.Opacity, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + spanNulls: false, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: PointVisibility.Always, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + decimals: 2, + custom: { + drawStyle: DrawStyle.Bars, + gradientMode: GraphGradientMode.Hue, + lineColor: '#ff0000', + lineWidth: 2, + lineInterpolation: LineInterpolation.Linear, + lineStyle: { + fill: 'dash', + dash: [1, 2], + }, + barAlignment: BarAlignment.Before, + fillColor: '#ff0000', + fillOpacity: 0.1, + showPoints: PointVisibility.Always, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + }); + + return preparePlotFrame([df1, df2], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); +} + +describe('GraphNG utils', () => { + test('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + expect( + preparePlotConfigBuilder( + frame!, + { colors: { panelBg: '#000000' } } as GrafanaTheme, + getDefaultTimeRange, + () => DefaultTimeZone + ) + ).toMatchSnapshot(); + }); +}); diff --git a/packages/grafana-ui/src/components/GraphNG/utils.ts b/packages/grafana-ui/src/components/GraphNG/utils.ts new file mode 100644 index 00000000000..e860003666d --- /dev/null +++ b/packages/grafana-ui/src/components/GraphNG/utils.ts @@ -0,0 +1,198 @@ +import React from 'react'; +import isNumber from 'lodash/isNumber'; +import { GraphNGLegendEventMode, XYFieldMatchers } from './types'; +import { + DataFrame, + FieldConfig, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldDisplayName, + getFieldSeriesColor, + GrafanaTheme, + outerJoinDataFrames, + TimeRange, + TimeZone, +} from '@grafana/data'; +import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; +import { FIXED_UNIT } from './GraphNG'; +import { + AxisPlacement, + DrawStyle, + GraphFieldConfig, + PointVisibility, + ScaleDirection, + ScaleOrientation, +} from '../uPlot/config'; + +const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); + +const defaultConfig: GraphFieldConfig = { + drawStyle: DrawStyle.Line, + showPoints: PointVisibility.Auto, + axisPlacement: AxisPlacement.Auto, +}; + +export function mapMouseEventToMode(event: React.MouseEvent): GraphNGLegendEventMode { + if (event.ctrlKey || event.metaKey || event.shiftKey) { + return GraphNGLegendEventMode.AppendToSelection; + } + return GraphNGLegendEventMode.ToggleSelection; +} + +export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers) { + return outerJoinDataFrames({ + frames: data, + joinBy: dimFields.x, + keep: dimFields.y, + keepOriginIndices: true, + }); +} + +export function preparePlotConfigBuilder( + frame: DataFrame, + theme: GrafanaTheme, + getTimeRange: () => TimeRange, + getTimeZone: () => TimeZone +): UPlotConfigBuilder { + const builder = new UPlotConfigBuilder(getTimeZone); + + // X is the first field in the aligned frame + const xField = frame.fields[0]; + let seriesIndex = 0; + + if (xField.type === FieldType.time) { + builder.addScale({ + scaleKey: 'x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + isTime: true, + range: () => { + const r = getTimeRange(); + return [r.from.valueOf(), r.to.valueOf()]; + }, + }); + + builder.addAxis({ + scaleKey: 'x', + isTime: true, + placement: AxisPlacement.Bottom, + timeZone: getTimeZone(), + theme, + }); + } else { + // Not time! + builder.addScale({ + scaleKey: 'x', + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + }); + + builder.addAxis({ + scaleKey: 'x', + placement: AxisPlacement.Bottom, + theme, + }); + } + + let indexByName: Map | undefined = undefined; + + for (let i = 0; i < frame.fields.length; i++) { + const field = frame.fields[i]; + const config = field.config as FieldConfig; + const customConfig: GraphFieldConfig = { + ...defaultConfig, + ...config.custom, + }; + + if (field === xField || field.type !== FieldType.number) { + continue; + } + field.state!.seriesIndex = seriesIndex++; + + const fmt = field.display ?? defaultFormatter; + const scaleKey = config.unit || FIXED_UNIT; + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + // The builder will manage unique scaleKeys and combine where appropriate + builder.addScale({ + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + distribution: customConfig.scaleDistribution?.type, + log: customConfig.scaleDistribution?.log, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + }); + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + builder.addAxis({ + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement: customConfig.axisPlacement ?? AxisPlacement.Auto, + formatValue: (v) => formattedValueToString(fmt(v)), + theme, + }); + } + + const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints; + + let { fillOpacity } = customConfig; + if (customConfig.fillBelowTo) { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame); + } + const t = indexByName.get(getFieldDisplayName(field, frame)); + const b = indexByName.get(customConfig.fillBelowTo); + if (isNumber(b) && isNumber(t)) { + builder.addBand({ + series: [t, b], + fill: null as any, // using null will have the band use fill options from `t` + }); + } + if (!fillOpacity) { + fillOpacity = 35; // default from flot + } + } + + builder.addSeries({ + scaleKey, + showPoints, + colorMode, + fillOpacity, + theme, + drawStyle: customConfig.drawStyle!, + lineColor: customConfig.lineColor ?? seriesColor, + lineWidth: customConfig.lineWidth, + lineInterpolation: customConfig.lineInterpolation, + lineStyle: customConfig.lineStyle, + barAlignment: customConfig.barAlignment, + pointSize: customConfig.pointSize, + pointColor: customConfig.pointColor ?? seriesColor, + spanNulls: customConfig.spanNulls || false, + show: !customConfig.hideFrom?.graph, + gradientMode: customConfig.gradientMode, + thresholds: config.thresholds, + + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: field.state?.origin, + fieldName: getFieldDisplayName(field, frame), + hideInLegend: customConfig.hideFrom?.legend, + }); + } + + return builder; +} + +export function getNamesToFieldIndex(frame: DataFrame): Map { + const names = new Map(); + for (let i = 0; i < frame.fields.length; i++) { + names.set(getFieldDisplayName(frame.fields[i], frame), i); + } + return names; +} diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index e323e51f0b4..a0c0326173f 100755 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -1,13 +1,12 @@ import React, { PureComponent } from 'react'; +import { AlignedData } from 'uplot'; import { compareDataFrameStructures, - DefaultTimeZone, - FieldSparkline, - IndexVector, DataFrame, + FieldConfig, + FieldSparkline, FieldType, getFieldColorModeForField, - FieldConfig, getFieldDisplayName, } from '@grafana/data'; import { @@ -21,8 +20,10 @@ import { import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { UPlotChart } from '../uPlot/Plot'; import { Themeable } from '../../types'; +import { preparePlotData } from '../uPlot/utils'; +import { preparePlotFrame } from './utils'; -export interface Props extends Themeable { +export interface SparklineProps extends Themeable { width: number; height: number; config?: FieldConfig; @@ -30,7 +31,8 @@ export interface Props extends Themeable { } interface State { - data: DataFrame; + data: AlignedData; + alignedDataFrame: DataFrame; configBuilder: UPlotConfigBuilder; } @@ -40,51 +42,53 @@ const defaultConfig: GraphFieldConfig = { axisPlacement: AxisPlacement.Hidden, }; -export class Sparkline extends PureComponent { - constructor(props: Props) { +export class Sparkline extends PureComponent { + constructor(props: SparklineProps) { super(props); - const data = this.prepareData(props); + const alignedDataFrame = preparePlotFrame(props.sparkline, props.config); + const data = preparePlotData(alignedDataFrame); + this.state = { data, - configBuilder: this.prepareConfig(data, props), + alignedDataFrame, + configBuilder: this.prepareConfig(alignedDataFrame), }; } - componentDidUpdate(oldProps: Props) { - if (oldProps.sparkline !== this.props.sparkline) { - const data = this.prepareData(this.props); - if (!compareDataFrameStructures(this.state.data, data)) { - const configBuilder = this.prepareConfig(data, this.props); - this.setState({ data, configBuilder }); - } else { - this.setState({ data }); + static getDerivedStateFromProps(props: SparklineProps, state: State) { + const frame = preparePlotFrame(props.sparkline, props.config); + if (!frame) { + return { ...state }; + } + + return { + ...state, + data: preparePlotData(frame), + alignedDataFrame: frame, + }; + } + + componentDidUpdate(prevProps: SparklineProps, prevState: State) { + const { alignedDataFrame } = this.state; + let stateUpdate = {}; + + if (prevProps.sparkline !== this.props.sparkline) { + if (!alignedDataFrame) { + return; } + const hasStructureChanged = !compareDataFrameStructures(this.state.alignedDataFrame, prevState.alignedDataFrame); + if (hasStructureChanged) { + const configBuilder = this.prepareConfig(alignedDataFrame); + stateUpdate = { configBuilder }; + } + } + if (Object.keys(stateUpdate).length > 0) { + this.setState(stateUpdate); } } - prepareData(props: Props): DataFrame { - const { sparkline } = props; - const length = sparkline.y.values.length; - const yFieldConfig = { - ...sparkline.y.config, - ...this.props.config, - }; - - return { - refId: 'sparkline', - fields: [ - sparkline.x ?? IndexVector.newField(length), - { - ...sparkline.y, - config: yFieldConfig, - }, - ], - length, - }; - } - - prepareConfig(data: DataFrame, props: Props) { + prepareConfig(data: DataFrame) { const { theme } = this.props; const builder = new UPlotConfigBuilder(); @@ -174,14 +178,7 @@ export class Sparkline extends PureComponent { const { width, height, sparkline } = this.props; return ( - + ); } } diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts new file mode 100644 index 00000000000..eed7dd1da9f --- /dev/null +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -0,0 +1,25 @@ +import { DataFrame, FieldConfig, FieldSparkline, IndexVector } from '@grafana/data'; +import { GraphFieldConfig } from '../uPlot/config'; + +/** @internal + * Given a sparkline config returns a DataFrame ready to be turned into Plot data set + **/ +export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig): DataFrame { + const length = sparkline.y.values.length; + const yFieldConfig = { + ...sparkline.y.config, + ...config, + }; + + return { + refId: 'sparkline', + fields: [ + sparkline.x ?? IndexVector.newField(length), + { + ...sparkline.y, + config: yFieldConfig, + }, + ], + length, + }; +} diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx index 962f0ecff88..3f7a3b2f053 100644 --- a/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx @@ -24,7 +24,7 @@ export const BottomLegend = () => { const items = Array.from({ length: legendItems }, (_, i) => i + 1); const legend = ( - + {items.map((_, index) => (
Legend item {index} @@ -47,7 +47,7 @@ export const RightLegend = () => { const items = Array.from({ length: legendItems }, (_, i) => i + 1); const legend = ( - + {items.map((_, index) => (
Legend item {index} diff --git a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx index b8d5ae64422..f574066dab7 100644 --- a/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx +++ b/packages/grafana-ui/src/components/VizLayout/VizLayout.tsx @@ -1,6 +1,7 @@ import React, { FC, CSSProperties, ComponentType } from 'react'; import { useMeasure } from 'react-use'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; +import { LegendPlacement } from '..'; /** * @beta @@ -33,7 +34,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child return
{children(width, height)}
; } - const { position, maxHeight, maxWidth } = legend.props; + const { placement, maxHeight, maxWidth } = legend.props; const [legendRef, legendMeasure] = useMeasure(); let size: VizSize | null = null; @@ -43,7 +44,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child const legendStyle: CSSProperties = {}; - switch (position) { + switch (placement) { case 'bottom': containerStyle.flexDirection = 'column'; legendStyle.maxHeight = maxHeight; @@ -91,7 +92,7 @@ interface VizSize { * @beta */ export interface VizLayoutLegendProps { - position: 'bottom' | 'right'; + placement: LegendPlacement; maxHeight?: string; maxWidth?: string; children: React.ReactNode; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index a4478b7a41c..e277e7dcc4e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -205,8 +205,9 @@ export { UPlotChart } from './uPlot/Plot'; export * from './uPlot/geometries'; export * from './uPlot/plugins'; export { useRefreshAfterGraphRendered } from './uPlot/hooks'; -export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context'; +export { usePlotContext, usePlotPluginContext } from './uPlot/context'; export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG'; +export { useGraphNGContext } from './GraphNG/hooks'; export { BarChart } from './BarChart/BarChart'; export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types'; export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types'; diff --git a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx index 327b3b8e0d4..2e543ac5f46 100644 --- a/packages/grafana-ui/src/components/uPlot/Plot.test.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.test.tsx @@ -6,6 +6,7 @@ import { GraphFieldConfig, DrawStyle } from '../uPlot/config'; import uPlot from 'uplot'; import createMockRaf from 'mock-raf'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +import { preparePlotData } from './utils'; const mockRaf = createMockRaf(); const setDataMock = jest.fn(); @@ -71,10 +72,9 @@ describe('UPlotChart', () => { const { unmount } = render( @@ -96,10 +96,9 @@ describe('UPlotChart', () => { const { rerender } = render( @@ -116,10 +115,9 @@ describe('UPlotChart', () => { rerender( @@ -134,7 +132,7 @@ describe('UPlotChart', () => { const { data, timeRange, config } = mockData(); const { queryAllByTestId } = render( - + ); expect(queryAllByTestId('uplot-main-div')).toHaveLength(1); @@ -146,10 +144,9 @@ describe('UPlotChart', () => { const { rerender } = render( @@ -164,10 +161,9 @@ describe('UPlotChart', () => { rerender( @@ -182,10 +178,9 @@ describe('UPlotChart', () => { const { rerender } = render( @@ -198,10 +193,9 @@ describe('UPlotChart', () => { rerender( diff --git a/packages/grafana-ui/src/components/uPlot/Plot.tsx b/packages/grafana-ui/src/components/uPlot/Plot.tsx index e5f560b135a..bf6acad03cb 100755 --- a/packages/grafana-ui/src/components/uPlot/Plot.tsx +++ b/packages/grafana-ui/src/components/uPlot/Plot.tsx @@ -4,7 +4,6 @@ import { buildPlotContext, PlotContext } from './context'; import { pluginLog } from './utils'; import { usePlotConfig } from './hooks'; import { PlotProps } from './types'; -import { DataFrame, dateTime, FieldType } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import usePrevious from 'react-use/lib/usePrevious'; @@ -19,12 +18,7 @@ export const UPlotChart: React.FC = (props) => { const plotInstance = useRef(); const [isPlotReady, setIsPlotReady] = useState(false); const prevProps = usePrevious(props); - const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig( - props.width, - props.height, - props.timeZone, - props.config - ); + const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config); const getPlotInstance = useCallback(() => { return plotInstance.current; @@ -39,7 +33,7 @@ export const UPlotChart: React.FC = (props) => { // 1. When config is ready and there is no uPlot instance, create new uPlot and return if (isConfigReady && !plotInstance.current) { - plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); + plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); setIsPlotReady(true); return; } @@ -54,18 +48,18 @@ export const UPlotChart: React.FC = (props) => { return; } - // 3. When config or timezone has changed, re-initialize plot - if (isConfigReady && (props.config !== prevProps.config || props.timeZone !== prevProps.timeZone)) { + // 3. When config has changed re-initialize plot + if (isConfigReady && props.config !== prevProps.config) { if (plotInstance.current) { pluginLog('uPlot core', false, 'destroying instance'); plotInstance.current.destroy(); } - plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); + plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current); return; } // 4. Otherwise, assume only data has changed and update uPlot data - updateData(props.data, props.config, plotInstance.current, prepareData(props.data)); + updateData(props.config, props.data, plotInstance.current); }, [props, isConfigReady]); // When component unmounts, clean the existing uPlot instance @@ -86,29 +80,12 @@ export const UPlotChart: React.FC = (props) => { ); }; -function prepareData(frame: DataFrame): AlignedData { - return frame.fields.map((f) => { - if (f.type === FieldType.time) { - if (f.values.length > 0 && typeof f.values.get(0) === 'string') { - const timestamps = []; - for (let i = 0; i < f.values.length; i++) { - timestamps.push(dateTime(f.values.get(i)).valueOf()); - } - return timestamps; - } - return f.values.toArray(); - } - - return f.values.toArray(); - }) as AlignedData; -} - -function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement) { +function initializePlot(data: AlignedData | null, config: Options, el: HTMLDivElement) { pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config); return new uPlot(config, data, el); } -function updateData(frame: DataFrame, config: UPlotConfigBuilder, plotInstance?: uPlot, data?: AlignedData | null) { +function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) { if (!plotInstance || !data) { return; } diff --git a/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx new file mode 100644 index 00000000000..f9beaf2aacc --- /dev/null +++ b/packages/grafana-ui/src/components/uPlot/PlotLegend.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from 'react'; +import { DataFrame, DisplayValue, fieldReducers, reduceField } from '@grafana/data'; +import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; +import { VizLegendItem, VizLegendOptions } from '../VizLegend/types'; +import { AxisPlacement } from './config'; +import { VizLayout } from '../VizLayout/VizLayout'; +import { mapMouseEventToMode } from '../GraphNG/utils'; +import { VizLegend } from '../VizLegend/VizLegend'; +import { GraphNGLegendEvent } from '..'; + +const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); + +interface PlotLegendProps extends VizLegendOptions { + data: DataFrame[]; + config: UPlotConfigBuilder; + onSeriesColorChange?: (label: string, color: string) => void; + onLegendClick?: (event: GraphNGLegendEvent) => void; +} + +export const PlotLegend: React.FC = ({ + data, + config, + onSeriesColorChange, + onLegendClick, + ...legend +}) => { + const onLegendLabelClick = useCallback( + (legend: VizLegendItem, event: React.MouseEvent) => { + const { fieldIndex } = legend; + + if (!onLegendClick || !fieldIndex) { + return; + } + + onLegendClick({ + fieldIndex, + mode: mapMouseEventToMode(event), + }); + }, + [onLegendClick] + ); + + const legendItems = config + .getSeries() + .map((s) => { + const seriesConfig = s.props; + const fieldIndex = seriesConfig.dataFrameFieldIndex; + const axisPlacement = config.getAxisPlacement(s.props.scaleKey); + + if (seriesConfig.hideInLegend || !fieldIndex) { + return undefined; + } + + const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex]; + + return { + disabled: !seriesConfig.show ?? false, + fieldIndex, + color: seriesConfig.lineColor!, + label: seriesConfig.fieldName, + yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2, + getDisplayValues: () => { + if (!legend.calcs?.length) { + return []; + } + + const fmt = field.display ?? defaultFormatter; + const fieldCalcs = reduceField({ + field, + reducers: legend.calcs, + }); + + return legend.calcs.map((reducer) => { + return { + ...fmt(fieldCalcs[reducer]), + title: fieldReducers.get(reducer).name, + }; + }); + }, + }; + }) + .filter((i) => i !== undefined) as VizLegendItem[]; + + return ( + + + + ); +}; + +PlotLegend.displayName = 'PlotLegend'; diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts index 82211932072..ad911915f3e 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts @@ -40,6 +40,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => { "series": Array [ Object {}, ], + "tzDate": [Function], } `); }); @@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => { "width": 1, }, ], + "tzDate": [Function], } `); }); diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts index 2f0da9bfa0c..07f37b5de63 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts @@ -3,8 +3,9 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder'; import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder'; import { AxisPlacement } from '../config'; -import { Cursor, Band, Hooks, BBox } from 'uplot'; +import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot'; import { defaultsDeep } from 'lodash'; +import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data'; type valueof = T[keyof T]; @@ -20,6 +21,8 @@ export class UPlotConfigBuilder { private hasBottomAxis = false; private hooks: Hooks.Arrays = {}; + constructor(private getTimeZone = () => DefaultTimeZone) {} + addHook(type: keyof Hooks.Defs, hook: valueof) { if (!this.hooks[type]) { this.hooks[type] = []; @@ -110,6 +113,8 @@ export class UPlotConfigBuilder { config.cursor = this.cursor || {}; + config.tzDate = this.tzDate; + // When bands exist, only keep fill when defined if (this.bands?.length) { config.bands = this.bands; @@ -159,4 +164,17 @@ export class UPlotConfigBuilder { return axes; } + + private tzDate = (ts: number) => { + if (!this.getTimeZone) { + return new Date(ts); + } + const tz = getTimeZoneInfo(this.getTimeZone(), Date.now())?.ianaName; + + if (!tz) { + return new Date(ts); + } + + return uPlot.tzDate(new Date(ts), tz); + }; } diff --git a/packages/grafana-ui/src/components/uPlot/context.ts b/packages/grafana-ui/src/components/uPlot/context.ts index 13956dac779..24e743dfd2d 100644 --- a/packages/grafana-ui/src/components/uPlot/context.ts +++ b/packages/grafana-ui/src/components/uPlot/context.ts @@ -1,7 +1,6 @@ -import React, { useCallback, useContext } from 'react'; -import uPlot, { Series } from 'uplot'; +import React, { useContext } from 'react'; +import uPlot, { AlignedData, Series } from 'uplot'; import { PlotPlugin } from './types'; -import { DataFrame, Field, FieldConfig } from '@grafana/data'; interface PlotCanvasContextType { // canvas size css pxs @@ -26,7 +25,7 @@ interface PlotContextType extends PlotPluginsContextType { getSeries: () => Series[]; getCanvas: () => PlotCanvasContextType; canvasRef: any; - data: DataFrame; + data: AlignedData; } export const PlotContext = React.createContext({} as PlotContextType); @@ -51,85 +50,10 @@ export const usePlotPluginContext = (): PlotPluginsContextType => { }; }; -// Exposes API for building uPlot config - -interface PlotDataAPI { - /** Data frame passed to graph, x-axis aligned */ - data: DataFrame; - /** Returns field by index */ - getField: (idx: number) => Field; - /** Returns x-axis fields */ - getXAxisFields: () => Field[]; - /** Returns x-axis fields */ - getYAxisFields: () => Field[]; - /** Returns field value by field and value index */ - getFieldValue: (fieldIdx: number, rowIdx: number) => any; - /** Returns field config by field index */ - getFieldConfig: (fieldIdx: number) => FieldConfig; -} - -export const usePlotData = (): PlotDataAPI => { - const ctx = usePlotContext(); - - const getField = useCallback( - (idx: number) => { - if (!ctx) { - throwWhenNoContext('usePlotData'); - } - return ctx!.data.fields[idx]; - }, - [ctx] - ); - - const getFieldConfig = useCallback( - (idx: number) => { - const field: Field = getField(idx); - return field.config; - }, - [ctx] - ); - - const getFieldValue = useCallback( - (fieldIdx: number, rowIdx: number) => { - const field: Field = getField(fieldIdx); - return field.values.get(rowIdx); - }, - [ctx] - ); - - const getXAxisFields = useCallback(() => { - // by uPlot convention x-axis is always first field - // this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html) - return [getField(0)]; - }, [ctx]); - - const getYAxisFields = useCallback(() => { - if (!ctx) { - throwWhenNoContext('usePlotData'); - } - // by uPlot convention x-axis is always first field - // this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html) - return ctx!.data.fields.slice(1); - }, [ctx]); - - if (!ctx) { - throwWhenNoContext('usePlotData'); - } - - return { - data: ctx.data, - getField, - getFieldValue, - getFieldConfig, - getXAxisFields, - getYAxisFields, - }; -}; - export const buildPlotContext = ( isPlotReady: boolean, canvasRef: any, - data: DataFrame, + data: AlignedData, registerPlugin: any, getPlotInstance: () => uPlot | undefined ): PlotContextType => { diff --git a/packages/grafana-ui/src/components/uPlot/hooks.ts b/packages/grafana-ui/src/components/uPlot/hooks.ts index 5d7f1b040c5..4589fe28526 100644 --- a/packages/grafana-ui/src/components/uPlot/hooks.ts +++ b/packages/grafana-ui/src/components/uPlot/hooks.ts @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { PlotPlugin } from './types'; import { pluginLog } from './utils'; -import uPlot, { Options, PaddingSide } from 'uplot'; -import { getTimeZoneInfo, TimeZone } from '@grafana/data'; +import { Options, PaddingSide } from 'uplot'; import { usePlotPluginContext } from './context'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import usePrevious from 'react-use/lib/usePrevious'; +import useMountedState from 'react-use/lib/useMountedState'; export const usePlotPlugins = () => { /** @@ -108,22 +108,11 @@ export const DEFAULT_PLOT_CONFIG: Partial = { hooks: {}, }; -export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => { +export const usePlotConfig = (width: number, height: number, configBuilder: UPlotConfigBuilder) => { const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins(); const [isConfigReady, setIsConfigReady] = useState(false); const currentConfig = useRef(); - const tzDate = useMemo(() => { - let fmt = undefined; - - const tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName; - - if (tz) { - fmt = (ts: number) => uPlot.tzDate(new Date(ts), tz); - } - - return fmt; - }, [timeZone]); useLayoutEffect(() => { if (!arePluginsReady) { @@ -137,12 +126,11 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, plugins: Object.entries(plugins).map((p) => ({ hooks: p[1].hooks, })), - tzDate, ...configBuilder.getConfig(), }; setIsConfigReady(true); - }, [arePluginsReady, plugins, width, height, tzDate, configBuilder]); + }, [arePluginsReady, plugins, width, height, configBuilder]); return { isConfigReady, @@ -158,6 +146,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, */ export const useRefreshAfterGraphRendered = (pluginId: string) => { const pluginsApi = usePlotPluginContext(); + const isMounted = useMountedState(); const [renderToken, setRenderToken] = useState(0); useEffect(() => { @@ -166,7 +155,9 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => { hooks: { // refresh events when uPlot draws draw: () => { - setRenderToken((c) => c + 1); + if (isMounted()) { + setRenderToken((c) => c + 1); + } return; }, }, diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 0d738799937..fb085c469f8 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -1,29 +1,40 @@ import React from 'react'; import { Portal } from '../../Portal/Portal'; -import { usePlotContext, usePlotData } from '../context'; +import { usePlotContext } from '../context'; import { CursorPlugin } from './CursorPlugin'; import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable'; -import { FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data'; +import { + DataFrame, + FieldType, + formattedValueToString, + getDisplayProcessor, + getFieldDisplayName, + TimeZone, +} from '@grafana/data'; import { TooltipContainer } from '../../Chart/TooltipContainer'; import { TooltipMode } from '../../Chart/Tooltip'; +import { useGraphNGContext } from '../../GraphNG/hooks'; interface TooltipPluginProps { mode?: TooltipMode; timeZone: TimeZone; + data: DataFrame[]; } /** * @alpha */ -export const TooltipPlugin: React.FC = ({ mode = 'single', timeZone }) => { +export const TooltipPlugin: React.FC = ({ mode = 'single', timeZone, ...otherProps }) => { const pluginId = 'PlotTooltip'; const plotContext = usePlotContext(); - const { data, getField, getXAxisFields } = usePlotData(); + const graphContext = useGraphNGContext(); - const xAxisFields = getXAxisFields(); - // assuming single x-axis - const xAxisField = xAxisFields[0]; - const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone }); + let xField = graphContext.getXAxisField(otherProps.data); + if (!xField) { + return null; + } + + const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); return ( @@ -31,7 +42,6 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t if (!plotContext.getPlotInstance()) { return null; } - let tooltip = null; // when no no cursor interaction @@ -39,10 +49,17 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t return null; } + const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text; + + // origin field/frame indexes for inspecting the data + const originFieldIndex = focusedSeriesIdx + ? graphContext.mapSeriesIndexToDataFrameFieldIndex(focusedSeriesIdx) + : null; + // when interacting with a point in single mode - if (mode === 'single' && focusedSeriesIdx !== null) { - const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text; - const field = getField(focusedSeriesIdx); + if (mode === 'single' && originFieldIndex !== null) { + const field = otherProps.data[originFieldIndex.frameIndex].fields[originFieldIndex.fieldIndex]; + const fieldFmt = field.display || getDisplayProcessor({ field, timeZone }); tooltip = ( = ({ mode = 'single', t { // TODO: align with uPlot typings color: (plotContext.getSeries()[focusedSeriesIdx!].stroke as any)(), - label: getFieldDisplayName(field, data), + label: getFieldDisplayName(field, otherProps.data[originFieldIndex.frameIndex]), value: fieldFmt(field.values.get(focusedPointIdx)).text, }, ]} @@ -60,10 +77,11 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t } if (mode === 'multi') { - const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text; - tooltip = ( - ((agg, f, i) => { + let series: SeriesTableRowProps[] = []; + + for (let i = 0; i < otherProps.data.length; i++) { + series = series.concat( + otherProps.data[i].fields.reduce((agg, f, j) => { // skipping time field and non-numeric fields if (f.type === FieldType.time || f.type !== FieldType.number) { return agg; @@ -77,16 +95,19 @@ export const TooltipPlugin: React.FC = ({ mode = 'single', t ...agg, { // TODO: align with uPlot typings - color: (plotContext.getSeries()[i].stroke as any)!(), - label: getFieldDisplayName(f, data), + color: (plotContext.getSeries()[j].stroke as any)!(), + label: getFieldDisplayName(f, otherProps.data[i]), value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))), - isActive: focusedSeriesIdx === i, + isActive: originFieldIndex + ? originFieldIndex.frameIndex === i && originFieldIndex.fieldIndex === j + : false, }, ]; - }, [])} - timestamp={xVal} - /> - ); + }, []) + ); + } + + tooltip = ; } if (!tooltip) { diff --git a/packages/grafana-ui/src/components/uPlot/types.ts b/packages/grafana-ui/src/components/uPlot/types.ts index 62ce4e981b3..e894ae49256 100755 --- a/packages/grafana-ui/src/components/uPlot/types.ts +++ b/packages/grafana-ui/src/components/uPlot/types.ts @@ -1,9 +1,12 @@ import React from 'react'; -import uPlot, { Options, Hooks } from 'uplot'; -import { DataFrame, TimeRange, TimeZone } from '@grafana/data'; +import uPlot, { Options, Hooks, AlignedData } from 'uplot'; +import { TimeRange } from '@grafana/data'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; -export type PlotConfig = Pick; +export type PlotConfig = Pick< + Options, + 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' +>; export type PlotPlugin = { id: string; @@ -17,12 +20,11 @@ export interface PlotPluginProps { } export interface PlotProps { - data: DataFrame; - timeRange: TimeRange; - timeZone: TimeZone; + data: AlignedData; width: number; height: number; config: UPlotConfigBuilder; + timeRange: TimeRange; children?: React.ReactNode; } diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 760b0d52e71..a0207af1818 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,5 +1,6 @@ +import { DataFrame, dateTime, FieldType } from '@grafana/data'; import throttle from 'lodash/throttle'; -import { Options } from 'uplot'; +import { AlignedData, Options } from 'uplot'; import { PlotPlugin, PlotProps } from './types'; const LOGGING_ENABLED = false; @@ -31,29 +32,32 @@ export function buildPlotConfig(props: PlotProps, plugins: Record { + if (f.type === FieldType.time) { + if (f.values.length > 0 && typeof f.values.get(0) === 'string') { + const timestamps = []; + for (let i = 0; i < f.values.length; i++) { + timestamps.push(dateTime(f.values.get(i)).valueOf()); + } + return timestamps; + } + return f.values.toArray(); } - } - return isTimeSeries; + return f.values.toArray(); + }) as AlignedData; } // Dev helpers + +/** @internal */ export const throttledLog = throttle((...t: any[]) => { console.log(...t); }, 500); +/** @internal */ export function pluginLog(id: string, throttle = false, ...t: any[]) { if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) { return; diff --git a/public/app/features/explore/ExploreGraphNGPanel.tsx b/public/app/features/explore/ExploreGraphNGPanel.tsx index 7c9ac428af1..858528d7acc 100644 --- a/public/app/features/explore/ExploreGraphNGPanel.tsx +++ b/public/app/features/explore/ExploreGraphNGPanel.tsx @@ -129,14 +129,10 @@ export function ExploreGraphNGPanel({ legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }} timeZone={timeZone} > - - - {annotations ? ( - - ) : ( - <> - )} + + + {annotations && } diff --git a/public/app/plugins/panel/barchart/BarChartPanel.tsx b/public/app/plugins/panel/barchart/BarChartPanel.tsx index 418f63b0bc9..a420e4b45ef 100755 --- a/public/app/plugins/panel/barchart/BarChartPanel.tsx +++ b/public/app/plugins/panel/barchart/BarChartPanel.tsx @@ -1,17 +1,11 @@ import React, { useCallback, useMemo } from 'react'; -import { DataFrame, Field, FieldType, PanelProps } from '@grafana/data'; +import { FieldType, PanelProps, VizOrientation } from '@grafana/data'; import { BarChart, BarChartOptions, GraphNGLegendEvent } from '@grafana/ui'; import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory'; import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory'; -import { config } from 'app/core/config'; interface Props extends PanelProps {} -interface BarData { - error?: string; - frame?: DataFrame; // first string vs all numbers -} - /** * @alpha */ @@ -23,13 +17,13 @@ export const BarChartPanel: React.FunctionComponent = ({ fieldConfig, onFieldConfigChange, }) => { - if (!data || !data.series?.length) { - return ( -
-

No data found in response

-
- ); - } + const orientation = useMemo(() => { + if (!options.orientation || options.orientation === VizOrientation.Auto) { + return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical; + } + + return options.orientation; + }, [width, height, options.orientation]); const onLegendClick = useCallback( (event: GraphNGLegendEvent) => { @@ -45,43 +39,7 @@ export const BarChartPanel: React.FunctionComponent = ({ [fieldConfig, onFieldConfigChange] ); - const barData = useMemo(() => { - const firstFrame = data.series[0]; - const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); - if (!firstString) { - return { - error: 'Bar charts requires a string field', - }; - } - const fields: Field[] = [firstString]; - for (const f of firstFrame.fields) { - if (f.type === FieldType.number) { - fields.push(f); - } - } - if (fields.length < 2) { - return { - error: 'No numeric fields found', - }; - } - - return { - frame: { - ...firstFrame, - fields, // filtered to to the values we have - }, - }; - }, [width, height, options, data]); - - if (barData.error) { - return ( -
-

{barData.error}

-
- ); - } - - if (!barData.frame) { + if (!data || !data.series?.length) { return (

No data found in response

@@ -89,15 +47,38 @@ export const BarChartPanel: React.FunctionComponent = ({ ); } + const firstFrame = data.series[0]; + if (!firstFrame.fields.find((f) => f.type === FieldType.string)) { + return ( +
+

Bar charts requires a string field

+
+ ); + } + if ( + firstFrame.fields.reduce((acc, f) => { + if (f.type === FieldType.number) { + return acc + 1; + } + return acc; + }, 0) < 2 + ) { + return ( +
+

No numeric fields found

+
+ ); + } + return ( ); }; diff --git a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx index ca249b6a875..47e66216d00 100644 --- a/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx +++ b/public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx @@ -41,6 +41,14 @@ export const TimeSeriesPanel: React.FC = ({ [fieldConfig, onFieldConfigChange] ); + if (!data || !data.series?.length) { + return ( +
+

No data found in response

+
+ ); + } + return ( = ({ onLegendClick={onLegendClick} onSeriesColorChange={onSeriesColorChange} > - - + + {data.annotations && ( )} diff --git a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx index 990f9825075..63bf028a461 100644 --- a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef, useMemo } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { ClickPlugin, ContextMenu, @@ -7,12 +7,11 @@ import { MenuItem, MenuItemsGroup, Portal, - usePlotData, + useGraphNGContext, } from '@grafana/ui'; import { + DataFrame, DataFrameView, - DisplayValue, - Field, getDisplayProcessor, getFieldDisplayName, InterpolateFunction, @@ -22,6 +21,7 @@ import { useClickAway } from 'react-use'; import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers'; interface ContextMenuPluginProps { + data: DataFrame[]; defaultItems?: MenuItemsGroup[]; timeZone: TimeZone; onOpen?: () => void; @@ -30,6 +30,7 @@ interface ContextMenuPluginProps { } export const ContextMenuPlugin: React.FC = ({ + data, onClose, timeZone, defaultItems, @@ -47,6 +48,7 @@ export const ContextMenuPlugin: React.FC = ({ return ( = ({ }; interface ContextMenuProps { + data: DataFrame[]; defaultItems?: MenuItemsGroup[]; timeZone: TimeZone; onClose?: () => void; @@ -81,11 +84,11 @@ export const ContextMenuView: React.FC = ({ timeZone, defaultItems, replaceVariables, + data, ...otherProps }) => { const ref = useRef(null); - const { data } = usePlotData(); - const { seriesIdx, dataIdx } = selection.point; + const graphContext = useGraphNGContext(); const onClose = () => { if (otherProps.onClose) { @@ -97,65 +100,69 @@ export const ContextMenuView: React.FC = ({ onClose(); }); - const contextMenuProps = useMemo(() => { - const items = defaultItems ? [...defaultItems] : []; - let field: Field; - let displayValue: DisplayValue; - const timeField = data.fields[0]; - const timeFormatter = timeField.display || getDisplayProcessor({ field: timeField, timeZone }); - let renderHeader: () => JSX.Element | null = () => null; + const xField = graphContext.getXAxisField(data); - if (seriesIdx && dataIdx) { - field = data.fields[seriesIdx]; - displayValue = field.display!(field.values.get(dataIdx)); - const hasLinks = field.config.links && field.config.links.length > 0; + if (!xField) { + return null; + } - if (hasLinks) { - const linksSupplier = getFieldLinksSupplier({ - display: displayValue, - name: field.name, - view: new DataFrameView(data), - rowIndex: dataIdx, - colIndex: seriesIdx, - field: field.config, - hasLinks, + const items = defaultItems ? [...defaultItems] : []; + let renderHeader: () => JSX.Element | null = () => null; + + const { seriesIdx, dataIdx } = selection.point; + const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone }); + + if (seriesIdx && dataIdx) { + // origin field/frame indexes for inspecting the data + const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx); + const frame = data[originFieldIndex.frameIndex]; + const field = frame.fields[originFieldIndex.fieldIndex]; + + const displayValue = field.display!(field.values.get(dataIdx)); + + const hasLinks = field.config.links && field.config.links.length > 0; + + if (hasLinks) { + const linksSupplier = getFieldLinksSupplier({ + display: displayValue, + name: field.name, + view: new DataFrameView(frame), + rowIndex: dataIdx, + colIndex: originFieldIndex.fieldIndex, + field: field.config, + hasLinks, + }); + + if (linksSupplier) { + items.push({ + items: linksSupplier.getLinks(replaceVariables).map((link) => { + return { + label: link.title, + url: link.href, + target: link.target, + icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName, + onClick: link.onClick, + }; + }), }); - - if (linksSupplier) { - items.push({ - items: linksSupplier.getLinks(replaceVariables).map((link) => { - return { - label: link.title, - url: link.href, - target: link.target, - icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName, - onClick: link.onClick, - }; - }), - }); - } } - - // eslint-disable-next-line react/display-name - renderHeader = () => ( - - ); } - return { - renderHeader, - items, - }; - }, [defaultItems, seriesIdx, dataIdx, data]); + // eslint-disable-next-line react/display-name + renderHeader = () => ( + + ); + } return ( = ({ onFieldConfigChange, }) => { const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]); + if (dims.error) { return (
@@ -61,7 +62,7 @@ export const XYChartPanel: React.FC = ({ onLegendClick={onLegendClick} onSeriesColorChange={onSeriesColorChange} > - + <>{/* needs to be an array */} ); diff --git a/public/app/plugins/panel/xychart/dims.ts b/public/app/plugins/panel/xychart/dims.ts index fccbed8ffea..b82fbb11a6a 100644 --- a/public/app/plugins/panel/xychart/dims.ts +++ b/public/app/plugins/panel/xychart/dims.ts @@ -1,7 +1,9 @@ import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data'; -import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/GraphNG'; import { XYDimensionConfig } from './types'; +// TODO: fix import +import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types'; + export enum DimensionError { NoData, BadFrameSelection, @@ -21,7 +23,7 @@ export function isGraphable(field: Field) { return field.type === FieldType.number; } -export function getXYDimensions(cfg: XYDimensionConfig, data?: DataFrame[]): XYDimensions { +export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions { if (!data || !data.length) { return { error: DimensionError.NoData } as XYDimensions; }