diff --git a/.betterer.results b/.betterer.results index c303a90fc4d..2e2644ad9aa 100644 --- a/.betterer.results +++ b/.betterer.results @@ -1024,6 +1024,29 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-ui/src/graveyard/Graph/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Unexpected any. Specify a different type.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"], + [0, 0, 0, "Unexpected any. Specify a different type.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"], + [0, 0, 0, "Do not use any type assertions.", "5"], + [0, 0, 0, "Do not use any type assertions.", "6"], + [0, 0, 0, "Do not use any type assertions.", "7"], + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Do not use any type assertions.", "9"], + [0, 0, 0, "Do not use any type assertions.", "10"], + [0, 0, 0, "Do not use any type assertions.", "11"] + ], + "packages/grafana-ui/src/graveyard/GraphNG/hooks.ts:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "packages/grafana-ui/src/graveyard/GraphNG/nullInsertThreshold.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -1035,6 +1058,11 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "1"], [0, 0, 0, "Do not use any type assertions.", "2"] ], + "packages/grafana-ui/src/graveyard/TimeSeries/utils.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"], + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] + ], "packages/grafana-ui/src/options/builder/axis.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1139ac6950a..b18eeac0350 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -332,7 +332,9 @@ /packages/grafana-ui/src/components/VizRepeater/ @grafana/dataviz-squad /packages/grafana-ui/src/components/VizTooltip/ @grafana/dataviz-squad /packages/grafana-ui/src/components/Sparkline/ @grafana/grafana-frontend-platform @grafana/app-o11y-visualizations +/packages/grafana-ui/src/graveyard/Graph/ @grafana/dataviz-squad /packages/grafana-ui/src/graveyard/GraphNG/ @grafana/dataviz-squad +/packages/grafana-ui/src/graveyard/TimeSeries/ @grafana/dataviz-squad /packages/grafana-ui/src/utils/storybook/ @grafana/plugins-platform-frontend /packages/grafana-data/src/transformations/ @grafana/dataviz-squad /packages/grafana-data/src/**/*logs* @grafana/observability-logs diff --git a/packages/grafana-ui/src/components/Menu/Menu.story.tsx b/packages/grafana-ui/src/components/Menu/Menu.story.tsx index a92e0755e7b..4a547fb84ae 100644 --- a/packages/grafana-ui/src/components/Menu/Menu.story.tsx +++ b/packages/grafana-ui/src/components/Menu/Menu.story.tsx @@ -1,6 +1,7 @@ import { Meta } from '@storybook/react'; import React from 'react'; +import { GraphContextMenuHeader } from '..'; import { StoryExample } from '../../utils/storybook/StoryExample'; import { VerticalGroup } from '../Layout/Layout'; @@ -109,6 +110,30 @@ export function Examples() { + + + } + ariaLabel="Menu header" + > + + + + + + + + + diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index cd3c980768a..ec094bea02e 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -150,6 +150,7 @@ export { VizLegend } from './VizLegend/VizLegend'; export { VizLegendListItem } from './VizLegend/VizLegendListItem'; export { Alert, type AlertVariant } from './Alert/Alert'; +export { GraphSeriesToggler, type GraphSeriesTogglerAPI } from '../graveyard/Graph/GraphSeriesToggler'; export { Collapse, ControlledCollapse } from './Collapse/Collapse'; export { CollapsableSection } from './Collapse/CollapsableSection'; export { DataLinkButton } from './DataLinks/DataLinkButton'; @@ -295,3 +296,19 @@ export { type UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder'; export * from './PanelChrome/types'; export { Label as BrowserLabel } from './BrowserLabel/Label'; export { PanelContainer } from './PanelContainer/PanelContainer'; + +// ----------------------------------------------------- +// Graveyard: exported, but no longer used internally +// These will be removed in the future +// ----------------------------------------------------- + +export { Graph } from '../graveyard/Graph/Graph'; +export { GraphWithLegend } from '../graveyard/Graph/GraphWithLegend'; +export { GraphContextMenu, GraphContextMenuHeader } from '../graveyard/Graph/GraphContextMenu'; +export { graphTimeFormat, graphTickFormatter } from '../graveyard/Graph/utils'; + +export { GraphNG, type GraphNGProps } from '../graveyard/GraphNG/GraphNG'; +export { TimeSeries } from '../graveyard/TimeSeries/TimeSeries'; +export { useGraphNGContext } from '../graveyard/GraphNG/hooks'; +export { preparePlotFrame, buildScaleKey } from '../graveyard/GraphNG/utils'; +export { type GraphNGLegendEvent } from '../graveyard/GraphNG/types'; diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 363c49e3d54..3ecbc180bd9 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,9 +1,7 @@ import { FieldMatcherID, fieldMatchers, FieldType, MutableDataFrame } from '@grafana/data'; import { BarAlignment, GraphDrawStyle, GraphTransform, LineInterpolation, StackingMode } from '@grafana/schema'; -// required for tests... but we actually have a duplicate copy that is used in the timeseries panel -// https://github.com/grafana/grafana/blob/v10.3.3/public/app/core/components/GraphNG/utils.test.ts -import { preparePlotFrame } from '../../graveyard/GraphNG/utils'; +import { preparePlotFrame } from '..'; import { getStackingGroups, preparePlotData2, timeFormatToTemplate } from './utils'; diff --git a/packages/grafana-ui/src/graveyard/Graph/Graph.test.tsx b/packages/grafana-ui/src/graveyard/Graph/Graph.test.tsx new file mode 100644 index 00000000000..847d6e883c0 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/Graph.test.tsx @@ -0,0 +1,185 @@ +import { act, render, screen } from '@testing-library/react'; +import $ from 'jquery'; +import React from 'react'; + +import { GraphSeriesXY, FieldType, dateTime, FieldColorModeId, DisplayProcessor } from '@grafana/data'; +import { TooltipDisplayMode } from '@grafana/schema'; + +import { VizTooltip } from '../../components/VizTooltip'; + +import Graph from './Graph'; + +const display: DisplayProcessor = (v) => ({ numeric: Number(v), text: String(v), color: 'red' }); + +const series: GraphSeriesXY[] = [ + { + data: [ + [1546372800000, 10], + [1546376400000, 20], + [1546380000000, 10], + ], + color: 'red', + isVisible: true, + label: 'A-series', + seriesIndex: 0, + timeField: { + type: FieldType.time, + name: 'time', + values: [1546372800000, 1546376400000, 1546380000000], + config: {}, + }, + valueField: { + type: FieldType.number, + name: 'a-series', + values: [10, 20, 10], + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } }, + display, + }, + timeStep: 3600000, + yAxis: { + index: 0, + }, + }, + { + data: [ + [1546372800000, 20], + [1546376400000, 30], + [1546380000000, 40], + ], + color: 'blue', + isVisible: true, + label: 'B-series', + seriesIndex: 0, + timeField: { + type: FieldType.time, + name: 'time', + values: [1546372800000, 1546376400000, 1546380000000], + config: {}, + }, + valueField: { + type: FieldType.number, + name: 'b-series', + values: [20, 30, 40], + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'blue' } }, + display, + }, + timeStep: 3600000, + yAxis: { + index: 0, + }, + }, +]; + +const mockTimeRange = { + from: dateTime(1546372800000), + to: dateTime(1546380000000), + raw: { + from: dateTime(1546372800000), + to: dateTime(1546380000000), + }, +}; + +const mockGraphProps = (multiSeries = false) => { + return { + width: 200, + height: 100, + series, + timeRange: mockTimeRange, + timeZone: 'browser', + }; +}; + +window.ResizeObserver = class ResizeObserver { + constructor() {} + observe() {} + unobserve() {} + disconnect() {} +}; + +describe('Graph', () => { + describe('with tooltip', () => { + describe('in single mode', () => { + it("doesn't render tooltip when not hovering over a datapoint", () => { + const graphWithTooltip = ( + + + + ); + render(graphWithTooltip); + + const timestamp = screen.queryByLabelText('Timestamp'); + const tableRow = screen.queryByTestId('SeriesTableRow'); + const seriesIcon = screen.queryByTestId('series-icon'); + + expect(timestamp).toBeFalsy(); + expect(timestamp?.parentElement).toBeFalsy(); + expect(tableRow?.parentElement).toBeFalsy(); + expect(seriesIcon).toBeFalsy(); + }); + + it('renders tooltip when hovering over a datapoint', () => { + // Given + const graphWithTooltip = ( + + + + ); + render(graphWithTooltip); + const eventArgs = { + pos: { + x: 120, + y: 50, + }, + activeItem: { + seriesIndex: 0, + dataIndex: 1, + series: { seriesIndex: 0 }, + }, + }; + act(() => { + $('div.graph-panel__chart').trigger('plothover', [eventArgs.pos, eventArgs.activeItem]); + }); + const timestamp = screen.getByLabelText('Timestamp'); + const tooltip = screen.getByTestId('SeriesTableRow').parentElement; + + expect(timestamp.parentElement?.isEqualNode(tooltip)).toBe(true); + expect(screen.getAllByTestId('series-icon')).toHaveLength(1); + }); + }); + + describe('in All Series mode', () => { + it('it renders all series summary regardless of mouse position', () => { + // Given + const graphWithTooltip = ( + + + + ); + render(graphWithTooltip); + + // When + const eventArgs = { + // This "is" more or less between first and middle point. Flot would not have picked any point as active one at this position + pos: { + x: 80, + y: 50, + }, + activeItem: null, + }; + // Then + act(() => { + $('div.graph-panel__chart').trigger('plothover', [eventArgs.pos, eventArgs.activeItem]); + }); + const timestamp = screen.getByLabelText('Timestamp'); + + const tableRows = screen.getAllByTestId('SeriesTableRow'); + expect(tableRows).toHaveLength(2); + expect(timestamp.parentElement?.isEqualNode(tableRows[0].parentElement)).toBe(true); + expect(timestamp.parentElement?.isEqualNode(tableRows[1].parentElement)).toBe(true); + + const seriesIcon = screen.getAllByTestId('series-icon'); + expect(seriesIcon).toHaveLength(2); + }); + }); + }); +}); diff --git a/packages/grafana-ui/src/graveyard/Graph/Graph.tsx b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx new file mode 100644 index 00000000000..624d19e29c6 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/Graph.tsx @@ -0,0 +1,400 @@ +// Libraries +import $ from 'jquery'; +import { uniqBy } from 'lodash'; +import React, { PureComponent } from 'react'; + +// Types +import { TimeRange, GraphSeriesXY, TimeZone, createDimension } from '@grafana/data'; +import { TooltipDisplayMode } from '@grafana/schema'; + +import { VizTooltipProps, VizTooltipContentProps, ActiveDimensions, VizTooltip } from '../../components/VizTooltip'; +import { FlotPosition } from '../../components/VizTooltip/VizTooltip'; + +import { GraphContextMenu, GraphContextMenuProps, ContextDimensions } from './GraphContextMenu'; +import { GraphTooltip } from './GraphTooltip/GraphTooltip'; +import { GraphDimensions } from './GraphTooltip/types'; +import { FlotItem } from './types'; +import { graphTimeFormat, graphTickFormatter } from './utils'; + +/** @deprecated */ +export interface GraphProps { + ariaLabel?: string; + children?: JSX.Element | JSX.Element[]; + series: GraphSeriesXY[]; + timeRange: TimeRange; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs + timeZone?: TimeZone; // NOTE: we should aim to make `time` a property of the axis, not force it for all graphs + showLines?: boolean; + showPoints?: boolean; + showBars?: boolean; + width: number; + height: number; + isStacked?: boolean; + lineWidth?: number; + onHorizontalRegionSelected?: (from: number, to: number) => void; +} + +/** @deprecated */ +interface GraphState { + pos?: FlotPosition; + contextPos?: FlotPosition; + isTooltipVisible: boolean; + isContextVisible: boolean; + activeItem?: FlotItem; + contextItem?: FlotItem; +} + +/** + * This is a react wrapper for the angular, flot based graph visualization. + * Rather than using this component, you should use the ` with + * timeseries panel configs. + * + * @deprecated + */ +export class Graph extends PureComponent { + static defaultProps = { + showLines: true, + showPoints: false, + showBars: false, + isStacked: false, + lineWidth: 1, + }; + + state: GraphState = { + isTooltipVisible: false, + isContextVisible: false, + }; + + element: HTMLElement | null = null; + $element: JQuery | null = null; + + componentDidUpdate(prevProps: GraphProps, prevState: GraphState) { + if (prevProps !== this.props) { + this.draw(); + } + } + + componentDidMount() { + this.draw(); + if (this.element) { + this.$element = $(this.element); + this.$element.bind('plotselected', this.onPlotSelected); + this.$element.bind('plothover', this.onPlotHover); + this.$element.bind('plotclick', this.onPlotClick); + } + } + + componentWillUnmount() { + if (this.$element) { + this.$element.unbind('plotselected', this.onPlotSelected); + } + } + + onPlotSelected = (event: JQuery.Event, ranges: { xaxis: { from: number; to: number } }) => { + const { onHorizontalRegionSelected } = this.props; + if (onHorizontalRegionSelected) { + onHorizontalRegionSelected(ranges.xaxis.from, ranges.xaxis.to); + } + }; + + onPlotHover = (event: JQuery.Event, pos: FlotPosition, item?: FlotItem) => { + this.setState({ + isTooltipVisible: true, + activeItem: item, + pos, + }); + }; + + onPlotClick = (event: JQuery.Event, contextPos: FlotPosition, item?: FlotItem) => { + this.setState({ + isContextVisible: true, + isTooltipVisible: false, + contextItem: item, + contextPos, + }); + }; + + getYAxes(series: GraphSeriesXY[]) { + if (series.length === 0) { + return [{ show: true, min: -1, max: 1 }]; + } + return uniqBy( + series.map((s) => { + const index = s.yAxis ? s.yAxis.index : 1; + const min = s.yAxis && s.yAxis.min && !isNaN(s.yAxis.min) ? s.yAxis.min : null; + const tickDecimals = + s.yAxis && s.yAxis.tickDecimals && !isNaN(s.yAxis.tickDecimals) ? s.yAxis.tickDecimals : null; + return { + show: true, + index, + position: index === 1 ? 'left' : 'right', + min, + tickDecimals, + }; + }), + (yAxisConfig) => yAxisConfig.index + ); + } + + renderTooltip = () => { + const { children, series, timeZone } = this.props; + const { pos, activeItem, isTooltipVisible } = this.state; + let tooltipElement: React.ReactElement | undefined; + + if (!isTooltipVisible || !pos || series.length === 0) { + return null; + } + + // Find children that indicate tooltip to be rendered + React.Children.forEach(children, (c) => { + // We have already found tooltip + if (tooltipElement) { + return; + } + const childType = c && c.type && (c.type.displayName || c.type.name); + + if (childType === VizTooltip.displayName) { + tooltipElement = c; + } + }); + // If no tooltip provided, skip rendering + if (!tooltipElement) { + return null; + } + const tooltipElementProps = tooltipElement.props; + + const tooltipMode = tooltipElementProps.mode || 'single'; + + // If mode is single series and user is not hovering over item, skip rendering + if (!activeItem && tooltipMode === 'single') { + return null; + } + + // Check if tooltip needs to be rendered with custom tooltip component, otherwise default to GraphTooltip + const tooltipContentRenderer = tooltipElementProps.tooltipComponent || GraphTooltip; + // Indicates column(field) index in y-axis dimension + const seriesIndex = activeItem ? activeItem.series.seriesIndex : 0; + // Indicates row index in active field values + const rowIndex = activeItem ? activeItem.dataIndex : undefined; + + const activeDimensions: ActiveDimensions = { + // Described x-axis active item + // When hovering over an item - let's take it's dataIndex, otherwise undefined + // Tooltip itself needs to figure out correct datapoint display information based on pos passed to it + xAxis: [seriesIndex, rowIndex], + // Describes y-axis active item + yAxis: activeItem ? [activeItem.series.seriesIndex, activeItem.dataIndex] : null, + }; + + const tooltipContentProps: VizTooltipContentProps = { + dimensions: { + // time/value dimension columns are index-aligned - see getGraphSeriesModel + xAxis: createDimension( + 'xAxis', + series.map((s) => s.timeField) + ), + yAxis: createDimension( + 'yAxis', + series.map((s) => s.valueField) + ), + }, + activeDimensions, + pos, + mode: tooltipElementProps.mode || TooltipDisplayMode.Single, + timeZone, + }; + + const tooltipContent = React.createElement(tooltipContentRenderer, { ...tooltipContentProps }); + + return React.cloneElement(tooltipElement, { + content: tooltipContent, + position: { x: pos.pageX, y: pos.pageY }, + offset: { x: 10, y: 10 }, + }); + }; + + renderContextMenu = () => { + const { series } = this.props; + const { contextPos, contextItem, isContextVisible } = this.state; + + if (!isContextVisible || !contextPos || !contextItem || series.length === 0) { + return null; + } + + // Indicates column(field) index in y-axis dimension + const seriesIndex = contextItem ? contextItem.series.seriesIndex : 0; + // Indicates row index in context field values + const rowIndex = contextItem ? contextItem.dataIndex : undefined; + + const contextDimensions: ContextDimensions = { + // Described x-axis context item + xAxis: [seriesIndex, rowIndex], + // Describes y-axis context item + yAxis: contextItem ? [contextItem.series.seriesIndex, contextItem.dataIndex] : null, + }; + + const dimensions: GraphDimensions = { + // time/value dimension columns are index-aligned - see getGraphSeriesModel + xAxis: createDimension( + 'xAxis', + series.map((s) => s.timeField) + ), + yAxis: createDimension( + 'yAxis', + series.map((s) => s.valueField) + ), + }; + + const closeContext = () => this.setState({ isContextVisible: false }); + + const getContextMenuSource = () => { + return { + datapoint: contextItem.datapoint, + dataIndex: contextItem.dataIndex, + series: contextItem.series, + seriesIndex: contextItem.series.seriesIndex, + pageX: contextPos.pageX, + pageY: contextPos.pageY, + }; + }; + + const contextContentProps: GraphContextMenuProps = { + x: contextPos.pageX, + y: contextPos.pageY, + onClose: closeContext, + getContextMenuSource: getContextMenuSource, + timeZone: this.props.timeZone, + dimensions, + contextDimensions, + }; + + return ; + }; + + getBarWidth = () => { + const { series } = this.props; + return Math.min(...series.map((s) => s.timeStep)); + }; + + draw() { + if (this.element === null) { + return; + } + + const { + width, + series, + timeRange, + showLines, + showBars, + showPoints, + isStacked, + lineWidth, + timeZone, + onHorizontalRegionSelected, + } = this.props; + + if (!width) { + return; + } + + const ticks = width / 100; + const min = timeRange.from.valueOf(); + const max = timeRange.to.valueOf(); + const yaxes = this.getYAxes(series); + + const flotOptions = { + legend: { + show: false, + }, + series: { + stack: isStacked, + lines: { + show: showLines, + lineWidth: lineWidth, + zero: false, + }, + points: { + show: showPoints, + fill: 1, + fillColor: false, + radius: 2, + }, + bars: { + show: showBars, + fill: 1, + // Dividig the width by 1.5 to make the bars not touch each other + barWidth: showBars ? this.getBarWidth() / 1.5 : 1, + zero: false, + lineWidth: lineWidth, + }, + shadowSize: 0, + }, + xaxis: { + timezone: timeZone, + show: true, + mode: 'time', + min: min, + max: max, + label: 'Datetime', + ticks: ticks, + timeformat: graphTimeFormat(ticks, min, max), + tickFormatter: graphTickFormatter, + }, + yaxes, + grid: { + minBorderMargin: 0, + markings: [], + backgroundColor: null, + borderWidth: 0, + hoverable: true, + clickable: true, + color: '#a1a1a1', + margin: { left: 0, right: 0 }, + labelMarginX: 0, + mouseActiveRadius: 30, + }, + selection: { + mode: onHorizontalRegionSelected ? 'x' : null, + color: '#666', + }, + crosshair: { + mode: 'x', + }, + }; + + try { + $.plot( + this.element, + series.filter((s) => s.isVisible), + flotOptions + ); + } catch (err) { + console.error('Graph rendering error', err, flotOptions, series); + throw new Error('Error rendering panel'); + } + } + + render() { + const { ariaLabel, height, width, series } = this.props; + const noDataToBeDisplayed = series.length === 0; + const tooltip = this.renderTooltip(); + const context = this.renderContextMenu(); + return ( +
+
(this.element = e)} + style={{ height, width }} + onMouseLeave={() => { + this.setState({ isTooltipVisible: false }); + }} + /> + {noDataToBeDisplayed &&
No data
} + {tooltip} + {context} +
+ ); + } +} + +export default Graph; diff --git a/public/app/angular/components/legacy_graph_panel/GraphContextMenu.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx similarity index 86% rename from public/app/angular/components/legacy_graph_panel/GraphContextMenu.tsx rename to packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx index 8d828e6bdf2..5b3da31708a 100644 --- a/public/app/angular/components/legacy_graph_panel/GraphContextMenu.tsx +++ b/packages/grafana-ui/src/graveyard/Graph/GraphContextMenu.tsx @@ -9,29 +9,21 @@ import { TimeZone, FormattedValue, GrafanaTheme2, - Dimension, } from '@grafana/data'; -import { - ContextMenu, - ContextMenuProps, - FormattedValueDisplay, - HorizontalGroup, - MenuGroup, - MenuGroupProps, - MenuItem, - SeriesIcon, - useStyles2, -} from '@grafana/ui'; + +import { ContextMenu, ContextMenuProps } from '../../components/ContextMenu/ContextMenu'; +import { FormattedValueDisplay } from '../../components/FormattedValueDisplay/FormattedValueDisplay'; +import { HorizontalGroup } from '../../components/Layout/Layout'; +import { MenuGroup, MenuGroupProps } from '../../components/Menu/MenuGroup'; +import { MenuItem } from '../../components/Menu/MenuItem'; +import { SeriesIcon } from '../../components/VizLegend/SeriesIcon'; +import { useStyles2 } from '../../themes'; + +import { GraphDimensions } from './GraphTooltip/types'; /** @deprecated */ export type ContextDimensions = { [key in keyof T]: [number, number | undefined] | null }; -/** @deprecated */ -export interface GraphDimensions extends Dimensions { - xAxis: Dimension; - yAxis: Dimension; -} - /** @deprecated */ export type GraphContextMenuProps = ContextMenuProps & { getContextMenuSource: () => FlotDataPoint | null; diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx new file mode 100644 index 00000000000..fc960695a68 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphSeriesToggler.tsx @@ -0,0 +1,89 @@ +import { difference, isEqual } from 'lodash'; +import React, { Component } from 'react'; + +import { GraphSeriesXY } from '@grafana/data'; + +/** @deprecated */ +export interface GraphSeriesTogglerAPI { + onSeriesToggle: (label: string, event: React.MouseEvent) => void; + toggledSeries: GraphSeriesXY[]; +} + +/** @deprecated */ +export interface GraphSeriesTogglerProps { + children: (api: GraphSeriesTogglerAPI) => JSX.Element; + series: GraphSeriesXY[]; + onHiddenSeriesChanged?: (hiddenSeries: string[]) => void; +} + +/** @deprecated */ +export interface GraphSeriesTogglerState { + hiddenSeries: string[]; + toggledSeries: GraphSeriesXY[]; +} + +/** @deprecated */ +export class GraphSeriesToggler extends Component { + constructor(props: GraphSeriesTogglerProps) { + super(props); + + this.onSeriesToggle = this.onSeriesToggle.bind(this); + + this.state = { + hiddenSeries: [], + toggledSeries: props.series, + }; + } + + componentDidUpdate(prevProps: Readonly) { + const { series } = this.props; + if (!isEqual(prevProps.series, series)) { + this.setState({ hiddenSeries: [], toggledSeries: series }); + } + } + + onSeriesToggle(label: string, event: React.MouseEvent) { + const { series, onHiddenSeriesChanged } = this.props; + const { hiddenSeries } = this.state; + + if (event.ctrlKey || event.metaKey || event.shiftKey) { + // Toggling series with key makes the series itself to toggle + const newHiddenSeries = + hiddenSeries.indexOf(label) > -1 + ? hiddenSeries.filter((series) => series !== label) + : hiddenSeries.concat([label]); + + const toggledSeries = series.map((series) => ({ + ...series, + isVisible: newHiddenSeries.indexOf(series.label) === -1, + })); + this.setState({ hiddenSeries: newHiddenSeries, toggledSeries }, () => + onHiddenSeriesChanged ? onHiddenSeriesChanged(newHiddenSeries) : undefined + ); + return; + } + + // Toggling series with out key toggles all the series but the clicked one + const allSeriesLabels = series.map((series) => series.label); + const newHiddenSeries = + hiddenSeries.length + 1 === allSeriesLabels.length ? [] : difference(allSeriesLabels, [label]); + const toggledSeries = series.map((series) => ({ + ...series, + isVisible: newHiddenSeries.indexOf(series.label) === -1, + })); + + this.setState({ hiddenSeries: newHiddenSeries, toggledSeries }, () => + onHiddenSeriesChanged ? onHiddenSeriesChanged(newHiddenSeries) : undefined + ); + } + + render() { + const { children } = this.props; + const { toggledSeries } = this.state; + + return children({ + onSeriesToggle: this.onSeriesToggle, + toggledSeries, + }); + } +} diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/GraphTooltip.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/GraphTooltip.tsx new file mode 100644 index 00000000000..d58c0816dfe --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/GraphTooltip.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { TooltipDisplayMode } from '@grafana/schema'; + +import { VizTooltipContentProps } from '../../../components/VizTooltip'; + +import { MultiModeGraphTooltip } from './MultiModeGraphTooltip'; +import { SingleModeGraphTooltip } from './SingleModeGraphTooltip'; +import { GraphDimensions } from './types'; + +/** @deprecated */ +export const GraphTooltip = ({ + mode = TooltipDisplayMode.Single, + dimensions, + activeDimensions, + pos, + timeZone, +}: VizTooltipContentProps) => { + // When + // [1] no active dimension or + // [2] no xAxis position + // we assume no tooltip should be rendered + if (!activeDimensions || !activeDimensions.xAxis) { + return null; + } + + if (mode === 'single') { + return ; + } else { + return ( + + ); + } +}; + +GraphTooltip.displayName = 'GraphTooltip'; diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx new file mode 100644 index 00000000000..1af501183a6 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createDimension, createTheme, FieldType, DisplayProcessor } from '@grafana/data'; + +import { ActiveDimensions } from '../../../components/VizTooltip'; + +import { MultiModeGraphTooltip } from './MultiModeGraphTooltip'; +import { GraphDimensions } from './types'; + +let dimensions: GraphDimensions; + +describe('MultiModeGraphTooltip', () => { + const display: DisplayProcessor = (v) => ({ numeric: Number(v), text: String(v), color: 'red' }); + const theme = createTheme(); + + describe('when shown when hovering over a datapoint', () => { + beforeEach(() => { + dimensions = { + xAxis: createDimension('xAxis', [ + { + config: {}, + values: [0, 100, 200], + name: 'A-series time', + type: FieldType.time, + display, + }, + { + config: {}, + values: [0, 100, 200], + name: 'B-series time', + type: FieldType.time, + display, + }, + ]), + yAxis: createDimension('yAxis', [ + { + config: {}, + values: [10, 20, 10], + name: 'A-series values', + type: FieldType.number, + display, + }, + { + config: {}, + values: [20, 30, 40], + name: 'B-series values', + type: FieldType.number, + display, + }, + ]), + }; + }); + + it('highlights series of the datapoint', () => { + // We are simulating hover over A-series, middle point + const activeDimensions: ActiveDimensions = { + xAxis: [0, 1], // column, row + yAxis: [0, 1], // column, row + }; + render( + + ); + + // We rendered two series rows + const rows = screen.getAllByTestId('SeriesTableRow'); + expect(rows.length).toEqual(2); + + // We expect A-series(1st row) not to be highlighted + expect(rows[0]).toHaveStyle(`font-weight: ${theme.typography.fontWeightMedium}`); + // We expect B-series(2nd row) not to be highlighted + expect(rows[1]).not.toHaveStyle(`font-weight: ${theme.typography.fontWeightMedium}`); + }); + + it("doesn't highlight series when not hovering over datapoint", () => { + // We are simulating hover over graph, but not datapoint + const activeDimensions: ActiveDimensions = { + xAxis: [0, undefined], // no active point in time + yAxis: null, // no active series + }; + + render( + + ); + + // We rendered two series rows + const rows = screen.getAllByTestId('SeriesTableRow'); + expect(rows.length).toEqual(2); + + // We expect A-series(1st row) not to be highlighted + expect(rows[0]).not.toHaveStyle(`font-weight: ${theme.typography.fontWeightMedium}`); + // We expect B-series(2nd row) not to be highlighted + expect(rows[1]).not.toHaveStyle(`font-weight: ${theme.typography.fontWeightMedium}`); + }); + }); +}); diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.tsx new file mode 100644 index 00000000000..bfe2856c700 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/MultiModeGraphTooltip.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { getValueFromDimension } from '@grafana/data'; + +import { SeriesTable } from '../../../components/VizTooltip'; +import { FlotPosition } from '../../../components/VizTooltip/VizTooltip'; +import { getMultiSeriesGraphHoverInfo } from '../utils'; + +import { GraphTooltipContentProps } from './types'; + +/** @deprecated */ +type Props = GraphTooltipContentProps & { + // We expect position to figure out correct values when not hovering over a datapoint + pos: FlotPosition; +}; + +/** @deprecated */ +export const MultiModeGraphTooltip = ({ dimensions, activeDimensions, pos, timeZone }: Props) => { + let activeSeriesIndex: number | null = null; + // when no x-axis provided, skip rendering + if (activeDimensions.xAxis === null) { + return null; + } + + if (activeDimensions.yAxis) { + activeSeriesIndex = activeDimensions.yAxis[0]; + } + + // when not hovering over a point, time is undefined, and we use pos.x as time + const time = activeDimensions.xAxis[1] + ? getValueFromDimension(dimensions.xAxis, activeDimensions.xAxis[0], activeDimensions.xAxis[1]) + : pos.x; + + const hoverInfo = getMultiSeriesGraphHoverInfo(dimensions.yAxis.columns, dimensions.xAxis.columns, time, timeZone); + const timestamp = hoverInfo.time; + + const series = hoverInfo.results.map((s, i) => { + return { + color: s.color, + label: s.label, + value: s.value, + isActive: activeSeriesIndex === i, + }; + }); + + return ; +}; + +MultiModeGraphTooltip.displayName = 'MultiModeGraphTooltip'; diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/SingleModeGraphTooltip.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/SingleModeGraphTooltip.tsx new file mode 100644 index 00000000000..7eb4d61872c --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/SingleModeGraphTooltip.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { + getValueFromDimension, + getColumnFromDimension, + formattedValueToString, + getFieldDisplayName, +} from '@grafana/data'; + +import { SeriesTable } from '../../../components/VizTooltip'; + +import { GraphTooltipContentProps } from './types'; + +/** @deprecated */ +export const SingleModeGraphTooltip = ({ dimensions, activeDimensions, timeZone }: GraphTooltipContentProps) => { + // not hovering over a point, skip rendering + if ( + activeDimensions.yAxis === null || + activeDimensions.yAxis[1] === undefined || + activeDimensions.xAxis === null || + activeDimensions.xAxis[1] === undefined + ) { + return null; + } + const time = getValueFromDimension(dimensions.xAxis, activeDimensions.xAxis[0], activeDimensions.xAxis[1]); + const timeField = getColumnFromDimension(dimensions.xAxis, activeDimensions.xAxis[0]); + const processedTime = timeField.display ? formattedValueToString(timeField.display(time)) : time; + + const valueField = getColumnFromDimension(dimensions.yAxis, activeDimensions.yAxis[0]); + const value = getValueFromDimension(dimensions.yAxis, activeDimensions.yAxis[0], activeDimensions.yAxis[1]); + const display = valueField.display!; + const disp = display(value); + + return ( + + ); +}; + +SingleModeGraphTooltip.displayName = 'SingleModeGraphTooltip'; diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/types.ts b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/types.ts new file mode 100644 index 00000000000..d4d68b729c1 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphTooltip/types.ts @@ -0,0 +1,16 @@ +import { Dimension, Dimensions, TimeZone } from '@grafana/data'; + +import { ActiveDimensions } from '../../../components/VizTooltip'; + +/** @deprecated */ +export interface GraphDimensions extends Dimensions { + xAxis: Dimension; + yAxis: Dimension; +} + +/** @deprecated */ +export interface GraphTooltipContentProps { + dimensions: GraphDimensions; // Dimension[] + activeDimensions: ActiveDimensions; + timeZone?: TimeZone; +} diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.internal.story.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.internal.story.tsx new file mode 100644 index 00000000000..0830b44f198 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.internal.story.tsx @@ -0,0 +1,141 @@ +import { Story } from '@storybook/react'; +import React from 'react'; + +import { GraphSeriesXY, FieldType, dateTime, FieldColorModeId } from '@grafana/data'; +import { LegendDisplayMode } from '@grafana/schema'; + +import { GraphWithLegend, GraphWithLegendProps } from './GraphWithLegend'; + +export default { + title: 'Visualizations/Graph/GraphWithLegend', + component: GraphWithLegend, + parameters: { + controls: { + exclude: ['className', 'ariaLabel', 'legendDisplayMode', 'series'], + }, + }, + argTypes: { + displayMode: { control: { type: 'radio' }, options: ['table', 'list', 'hidden'] }, + placement: { control: { type: 'radio' }, options: ['bottom', 'right'] }, + rightAxisSeries: { name: 'Right y-axis series, i.e. A,C' }, + timeZone: { control: { type: 'radio' }, options: ['browser', 'utc'] }, + width: { control: { type: 'range', min: 200, max: 800 } }, + height: { control: { type: 'range', min: 1700, step: 300 } }, + lineWidth: { control: { type: 'range', min: 1, max: 10 } }, + }, +}; + +const series: GraphSeriesXY[] = [ + { + data: [ + [1546372800000, 10], + [1546376400000, 20], + [1546380000000, 10], + ], + color: 'red', + isVisible: true, + label: 'A-series', + seriesIndex: 0, + timeField: { + type: FieldType.time, + name: 'time', + values: [1546372800000, 1546376400000, 1546380000000], + config: {}, + }, + valueField: { + type: FieldType.number, + name: 'a-series', + values: [10, 20, 10], + config: { + color: { + mode: FieldColorModeId.Fixed, + fixedColor: 'red', + }, + }, + }, + timeStep: 3600000, + yAxis: { + index: 1, + }, + }, + { + data: [ + [1546372800000, 20], + [1546376400000, 30], + [1546380000000, 40], + ], + color: 'blue', + isVisible: true, + label: 'B-series', + seriesIndex: 1, + timeField: { + type: FieldType.time, + name: 'time', + values: [1546372800000, 1546376400000, 1546380000000], + config: {}, + }, + valueField: { + type: FieldType.number, + name: 'b-series', + values: [20, 30, 40], + config: { + color: { + mode: FieldColorModeId.Fixed, + fixedColor: 'blue', + }, + }, + }, + timeStep: 3600000, + yAxis: { + index: 1, + }, + }, +]; + +interface StoryProps extends GraphWithLegendProps { + rightAxisSeries: string; + displayMode: string; +} + +export const WithLegend: Story = ({ rightAxisSeries, displayMode, legendDisplayMode, ...args }) => { + const props: Partial = { + series: series.map((s) => { + if ( + rightAxisSeries + .split(',') + .map((s) => s.trim()) + .indexOf(s.label.split('-')[0]) > -1 + ) { + s.yAxis = { index: 2 }; + } else { + s.yAxis = { index: 1 }; + } + return s; + }), + }; + + return ( + + ); +}; +WithLegend.args = { + rightAxisSeries: '', + displayMode: 'list', + onToggleSort: () => {}, + timeRange: { + from: dateTime(1546372800000), + to: dateTime(1546380000000), + raw: { + from: dateTime(1546372800000), + to: dateTime(1546380000000), + }, + }, + timeZone: 'browser', + width: 600, + height: 300, + placement: 'bottom', +}; diff --git a/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.tsx b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.tsx new file mode 100644 index 00000000000..a590d5d1ed1 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/GraphWithLegend.tsx @@ -0,0 +1,132 @@ +// Libraries + +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2, GraphSeriesValue } from '@grafana/data'; +import { LegendDisplayMode, LegendPlacement } from '@grafana/schema'; + +import { CustomScrollbar } from '../../components/CustomScrollbar/CustomScrollbar'; +import { VizLegend } from '../../components/VizLegend/VizLegend'; +import { VizLegendItem } from '../../components/VizLegend/types'; +import { useStyles2 } from '../../themes'; + +import { Graph, GraphProps } from './Graph'; + +export interface GraphWithLegendProps extends GraphProps { + legendDisplayMode: LegendDisplayMode; + legendVisibility: boolean; + placement: LegendPlacement; + hideEmpty?: boolean; + hideZero?: boolean; + sortLegendBy?: string; + sortLegendDesc?: boolean; + onSeriesToggle?: (label: string, event: React.MouseEvent) => void; + onToggleSort: (sortBy: string) => void; +} + +const shouldHideLegendItem = (data: GraphSeriesValue[][], hideEmpty = false, hideZero = false) => { + const isZeroOnlySeries = data.reduce((acc, current) => acc + (current[1] || 0), 0) === 0; + const isNullOnlySeries = !data.reduce((acc, current) => acc && current[1] !== null, true); + + return (hideEmpty && isNullOnlySeries) || (hideZero && isZeroOnlySeries); +}; + +export const GraphWithLegend = (props: GraphWithLegendProps) => { + const { + series, + timeRange, + width, + height, + showBars, + showLines, + showPoints, + sortLegendBy, + sortLegendDesc, + legendDisplayMode, + legendVisibility, + placement, + onSeriesToggle, + onToggleSort, + hideEmpty, + hideZero, + isStacked, + lineWidth, + onHorizontalRegionSelected, + timeZone, + children, + ariaLabel, + } = props; + const { graphContainer, wrapper, legendContainer } = useStyles2(getGraphWithLegendStyles, props.placement); + + const legendItems = series.reduce((acc, s) => { + return shouldHideLegendItem(s.data, hideEmpty, hideZero) + ? acc + : acc.concat([ + { + label: s.label, + color: s.color || '', + disabled: !s.isVisible, + yAxis: s.yAxis.index, + getDisplayValues: () => s.info || [], + }, + ]); + }, []); + + return ( +
+
+ + {children} + +
+ + {legendVisibility && ( +
+ + { + if (onSeriesToggle) { + onSeriesToggle(item.label, event); + } + }} + onToggleSort={onToggleSort} + /> + +
+ )} +
+ ); +}; + +const getGraphWithLegendStyles = (_theme: GrafanaTheme2, placement: LegendPlacement) => ({ + wrapper: css({ + display: 'flex', + flexDirection: placement === 'bottom' ? 'column' : 'row', + }), + graphContainer: css({ + minHeight: '65%', + flexGrow: 1, + }), + legendContainer: css({ + padding: '10px 0', + maxHeight: placement === 'bottom' ? '35%' : 'none', + }), +}); diff --git a/packages/grafana-ui/src/graveyard/Graph/types.ts b/packages/grafana-ui/src/graveyard/Graph/types.ts new file mode 100644 index 00000000000..60cdb44b983 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/types.ts @@ -0,0 +1,9 @@ +/** @deprecated */ +export interface FlotItem { + datapoint: [number, number]; + dataIndex: number; + series: T; + seriesIndex: number; + pageX: number; + pageY: number; +} diff --git a/packages/grafana-ui/src/graveyard/Graph/utils.test.ts b/packages/grafana-ui/src/graveyard/Graph/utils.test.ts new file mode 100644 index 00000000000..f68ed2e4b3f --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/utils.test.ts @@ -0,0 +1,233 @@ +import { + toDataFrame, + FieldType, + FieldCache, + FieldColorModeId, + Field, + applyFieldOverrides, + createTheme, + DataFrame, +} from '@grafana/data'; + +import { getTheme } from '../../themes'; + +import { getMultiSeriesGraphHoverInfo, findHoverIndexFromData, graphTimeFormat } from './utils'; + +const mockResult = ( + value: string, + datapointIndex: number, + seriesIndex: number, + color?: string, + label?: string, + time?: string +) => ({ + value, + datapointIndex, + seriesIndex, + color, + label, + time, +}); + +function passThroughFieldOverrides(frame: DataFrame) { + return applyFieldOverrides({ + data: [frame], + fieldConfig: { + defaults: {}, + overrides: [], + }, + replaceVariables: (val: string) => val, + timeZone: 'utc', + theme: createTheme(), + }); +} + +// A and B series have the same x-axis range and the datapoints are x-axis aligned +const aSeries = passThroughFieldOverrides( + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [10000, 20000, 30000, 80000] }, + { + name: 'value', + type: FieldType.number, + values: [10, 20, 10, 25], + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } }, + }, + ], + }) +)[0]; + +const bSeries = passThroughFieldOverrides( + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [10000, 20000, 30000, 80000] }, + { + name: 'value', + type: FieldType.number, + values: [30, 60, 30, 40], + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'blue' } }, + }, + ], + }) +)[0]; + +// C-series has the same x-axis range as A and B but is missing the middle point +const cSeries = passThroughFieldOverrides( + toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [10000, 30000, 80000] }, + { + name: 'value', + type: FieldType.number, + values: [30, 30, 30], + config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'yellow' } }, + }, + ], + }) +)[0]; + +function getFixedThemedColor(field: Field): string { + return getTheme().visualization.getColorByName(field.config.color!.fixedColor!); +} + +describe('Graph utils', () => { + describe('getMultiSeriesGraphHoverInfo', () => { + describe('when series datapoints are x-axis aligned', () => { + it('returns a datapoints that user hovers over', () => { + const aCache = new FieldCache(aSeries); + const aValueField = aCache.getFieldByName('value'); + const aTimeField = aCache.getFieldByName('time'); + const bCache = new FieldCache(bSeries); + const bValueField = bCache.getFieldByName('value'); + const bTimeField = bCache.getFieldByName('time'); + + const result = getMultiSeriesGraphHoverInfo([aValueField!, bValueField!], [aTimeField!, bTimeField!], 0); + expect(result.time).toBe('1970-01-01 00:00:10'); + expect(result.results[0]).toEqual( + mockResult('10', 0, 0, getFixedThemedColor(aValueField!), aValueField!.name, '1970-01-01 00:00:10') + ); + expect(result.results[1]).toEqual( + mockResult('30', 0, 1, getFixedThemedColor(bValueField!), bValueField!.name, '1970-01-01 00:00:10') + ); + }); + + describe('returns the closest datapoints before the hover position', () => { + it('when hovering right before a datapoint', () => { + const aCache = new FieldCache(aSeries); + const aValueField = aCache.getFieldByName('value'); + const aTimeField = aCache.getFieldByName('time'); + const bCache = new FieldCache(bSeries); + const bValueField = bCache.getFieldByName('value'); + const bTimeField = bCache.getFieldByName('time'); + + // hovering right before middle point + const result = getMultiSeriesGraphHoverInfo([aValueField!, bValueField!], [aTimeField!, bTimeField!], 19900); + expect(result.time).toBe('1970-01-01 00:00:10'); + expect(result.results[0]).toEqual( + mockResult('10', 0, 0, getFixedThemedColor(aValueField!), aValueField!.name, '1970-01-01 00:00:10') + ); + expect(result.results[1]).toEqual( + mockResult('30', 0, 1, getFixedThemedColor(bValueField!), bValueField!.name, '1970-01-01 00:00:10') + ); + }); + + it('when hovering right after a datapoint', () => { + const aCache = new FieldCache(aSeries); + const aValueField = aCache.getFieldByName('value'); + const aTimeField = aCache.getFieldByName('time'); + const bCache = new FieldCache(bSeries); + const bValueField = bCache.getFieldByName('value'); + const bTimeField = bCache.getFieldByName('time'); + + // hovering right after middle point + const result = getMultiSeriesGraphHoverInfo([aValueField!, bValueField!], [aTimeField!, bTimeField!], 20100); + expect(result.time).toBe('1970-01-01 00:00:20'); + expect(result.results[0]).toEqual( + mockResult('20', 1, 0, getFixedThemedColor(aValueField!), aValueField!.name, '1970-01-01 00:00:20') + ); + expect(result.results[1]).toEqual( + mockResult('60', 1, 1, getFixedThemedColor(bValueField!), bValueField!.name, '1970-01-01 00:00:20') + ); + }); + }); + }); + + describe('when series x-axes are not aligned', () => { + // aSeries and cSeries are not aligned + // cSeries is missing a middle point + it('hovering over a middle point', () => { + const aCache = new FieldCache(aSeries); + const aValueField = aCache.getFieldByName('value'); + const aTimeField = aCache.getFieldByName('time'); + const cCache = new FieldCache(cSeries); + const cValueField = cCache.getFieldByName('value'); + const cTimeField = cCache.getFieldByName('time'); + + // hovering on a middle point + // aSeries has point at that time, cSeries doesn't + const result = getMultiSeriesGraphHoverInfo([aValueField!, cValueField!], [aTimeField!, cTimeField!], 20000); + + // we expect a time of the hovered point + expect(result.time).toBe('1970-01-01 00:00:20'); + // we expect middle point from aSeries (the one we are hovering over) + expect(result.results[0]).toEqual( + mockResult('20', 1, 0, getFixedThemedColor(aValueField!), aValueField!.name, '1970-01-01 00:00:20') + ); + // we expect closest point before hovered point from cSeries (1st point) + expect(result.results[1]).toEqual( + mockResult('30', 0, 1, getFixedThemedColor(cValueField!), cValueField!.name, '1970-01-01 00:00:10') + ); + }); + + it('hovering right after over the middle point', () => { + const aCache = new FieldCache(aSeries); + const aValueField = aCache.getFieldByName('value'); + const aTimeField = aCache.getFieldByName('time'); + const cCache = new FieldCache(cSeries); + const cValueField = cCache.getFieldByName('value'); + const cTimeField = cCache.getFieldByName('time'); + + // aSeries has point at that time, cSeries doesn't + const result = getMultiSeriesGraphHoverInfo([aValueField!, cValueField!], [aTimeField!, cTimeField!], 20100); + + // we expect the time of the closest point before hover + expect(result.time).toBe('1970-01-01 00:00:20'); + // we expect the closest datapoint before hover from aSeries + expect(result.results[0]).toEqual( + mockResult('20', 1, 0, getFixedThemedColor(aValueField!), aValueField!.name, '1970-01-01 00:00:20') + ); + // we expect the closest datapoint before hover from cSeries (1st point) + expect(result.results[1]).toEqual( + mockResult('30', 0, 1, getFixedThemedColor(cValueField!), cValueField!.name, '1970-01-01 00:00:10') + ); + }); + }); + }); + + describe('findHoverIndexFromData', () => { + it('returns index of the closest datapoint before hover position', () => { + const cache = new FieldCache(aSeries); + const timeField = cache.getFieldByName('time'); + // hovering over 1st datapoint + expect(findHoverIndexFromData(timeField!, 0)).toBe(0); + // hovering over right before 2nd datapoint + expect(findHoverIndexFromData(timeField!, 19900)).toBe(0); + // hovering over 2nd datapoint + expect(findHoverIndexFromData(timeField!, 20000)).toBe(1); + // hovering over right before 3rd datapoint + expect(findHoverIndexFromData(timeField!, 29900)).toBe(1); + // hovering over 3rd datapoint + expect(findHoverIndexFromData(timeField!, 30000)).toBe(2); + }); + }); + + describe('graphTimeFormat', () => { + it('graphTimeFormat', () => { + expect(graphTimeFormat(5, 1, 45 * 5 * 1000)).toBe('HH:mm:ss'); + expect(graphTimeFormat(5, 1, 7200 * 5 * 1000)).toBe('HH:mm'); + expect(graphTimeFormat(5, 1, 80000 * 5 * 1000)).toBe('MM/DD HH:mm'); + expect(graphTimeFormat(5, 1, 2419200 * 5 * 1000)).toBe('MM/DD'); + expect(graphTimeFormat(5, 1, 12419200 * 5 * 1000)).toBe('YYYY-MM'); + }); + }); +}); diff --git a/packages/grafana-ui/src/graveyard/Graph/utils.ts b/packages/grafana-ui/src/graveyard/Graph/utils.ts new file mode 100644 index 00000000000..ebc34ad470e --- /dev/null +++ b/packages/grafana-ui/src/graveyard/Graph/utils.ts @@ -0,0 +1,147 @@ +import { + GraphSeriesValue, + Field, + formattedValueToString, + getFieldDisplayName, + TimeZone, + dateTimeFormat, + systemDateFormats, +} from '@grafana/data'; + +/** + * Returns index of the closest datapoint BEFORE hover position + * + * @param posX + * @param series + * @deprecated + */ +export const findHoverIndexFromData = (xAxisDimension: Field, xPos: number) => { + let lower = 0; + let upper = xAxisDimension.values.length - 1; + let middle; + + while (true) { + if (lower > upper) { + return Math.max(upper, 0); + } + middle = Math.floor((lower + upper) / 2); + const xPosition = xAxisDimension.values[middle]; + + if (xPosition === xPos) { + return middle; + } else if (xPosition && xPosition < xPos) { + lower = middle + 1; + } else { + upper = middle - 1; + } + } +}; + +interface MultiSeriesHoverInfo { + value: string; + time: string; + datapointIndex: number; + seriesIndex: number; + label?: string; + color?: string; +} + +/** + * Returns information about closest datapoints when hovering over a Graph + * + * @param seriesList list of series visible on the Graph + * @param pos mouse cursor position, based on jQuery.flot position + * @deprecated + */ +export const getMultiSeriesGraphHoverInfo = ( + // x and y axis dimensions order is aligned + yAxisDimensions: Field[], + xAxisDimensions: Field[], + /** Well, time basically */ + xAxisPosition: number, + timeZone?: TimeZone +): { + results: MultiSeriesHoverInfo[]; + time?: GraphSeriesValue; +} => { + let i, field, hoverIndex, hoverDistance, pointTime; + + const results: MultiSeriesHoverInfo[] = []; + + let minDistance, minTime; + + for (i = 0; i < yAxisDimensions.length; i++) { + field = yAxisDimensions[i]; + const time = xAxisDimensions[i]; + hoverIndex = findHoverIndexFromData(time, xAxisPosition); + hoverDistance = xAxisPosition - time.values[hoverIndex]; + pointTime = time.values[hoverIndex]; + // Take the closest point before the cursor, or if it does not exist, the closest after + if ( + minDistance === undefined || + (hoverDistance >= 0 && (hoverDistance < minDistance || minDistance < 0)) || + (hoverDistance < 0 && hoverDistance > minDistance) + ) { + minDistance = hoverDistance; + minTime = time.display ? formattedValueToString(time.display(pointTime)) : pointTime; + } + + const disp = field.display!(field.values[hoverIndex]); + + results.push({ + value: formattedValueToString(disp), + datapointIndex: hoverIndex, + seriesIndex: i, + color: disp.color, + label: getFieldDisplayName(field), + time: time.display ? formattedValueToString(time.display(pointTime)) : pointTime, + }); + } + + return { + results, + time: minTime, + }; +}; + +/** @deprecated */ +export const graphTickFormatter = (epoch: number, axis: any) => { + return dateTimeFormat(epoch, { + format: axis?.options?.timeformat, + timeZone: axis?.options?.timezone, + }); +}; + +/** @deprecated */ +export const graphTimeFormat = (ticks: number | null, min: number | null, max: number | null): string => { + if (min && max && ticks) { + const range = max - min; + const secPerTick = range / ticks / 1000; + // Need have 10 millisecond margin on the day range + // As sometimes last 24 hour dashboard evaluates to more than 86400000 + const oneDay = 86400010; + const oneYear = 31536000000; + + if (secPerTick <= 10) { + return systemDateFormats.interval.millisecond; + } + if (secPerTick <= 45) { + return systemDateFormats.interval.second; + } + if (range <= oneDay) { + return systemDateFormats.interval.minute; + } + if (secPerTick <= 80000) { + return systemDateFormats.interval.hour; + } + if (range <= oneYear) { + return systemDateFormats.interval.day; + } + if (secPerTick <= 31536000) { + return systemDateFormats.interval.month; + } + return systemDateFormats.interval.year; + } + + return systemDateFormats.interval.minute; +}; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx b/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx new file mode 100644 index 00000000000..52dfadb27a6 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/GraphNG.tsx @@ -0,0 +1,275 @@ +import React, { Component } from 'react'; +import { Subscription } from 'rxjs'; +import { throttleTime } from 'rxjs/operators'; +import uPlot, { AlignedData } from 'uplot'; + +import { + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + Field, + FieldMatcherID, + fieldMatchers, + FieldType, + LegacyGraphHoverEvent, + TimeRange, + TimeZone, +} from '@grafana/data'; +import { VizLegendOptions } from '@grafana/schema'; + +import { PanelContext, PanelContextRoot } from '../../components/PanelChrome/PanelContext'; +import { VizLayout } from '../../components/VizLayout/VizLayout'; +import { UPlotChart } from '../../components/uPlot/Plot'; +import { AxisProps } from '../../components/uPlot/config/UPlotAxisBuilder'; +import { Renderers, UPlotConfigBuilder } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { ScaleProps } from '../../components/uPlot/config/UPlotScaleBuilder'; +import { findMidPointYPosition, pluginLog } from '../../components/uPlot/utils'; +import { Themeable2 } from '../../types'; + +import { GraphNGLegendEvent, XYFieldMatchers } from './types'; +import { preparePlotFrame as defaultPreparePlotFrame } from './utils'; + +/** + * @deprecated + * @internal -- not a public API + */ +export type PropDiffFn = (prev: T, next: T) => boolean; + +/** @deprecated */ +export interface GraphNGProps extends Themeable2 { + frames: DataFrame[]; + structureRev?: number; // a number that will change when the frames[] structure changes + width: number; + height: number; + timeRange: TimeRange; + timeZone: TimeZone[] | TimeZone; + legend: VizLegendOptions; + fields?: XYFieldMatchers; // default will assume timeseries data + renderers?: Renderers; + tweakScale?: (opts: ScaleProps, forField: Field) => ScaleProps; + tweakAxis?: (opts: AxisProps, forField: Field) => AxisProps; + onLegendClick?: (event: GraphNGLegendEvent) => void; + children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode; + prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder; + propsToDiff?: Array; + preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame | null; + renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null; + + /** + * needed for propsToDiff to re-init the plot & config + * this is a generic approach to plot re-init, without having to specify which panel-level options + * should cause invalidation. we can drop this in favor of something like panelOptionsRev that gets passed in + * similar to structureRev. then we can drop propsToDiff entirely. + */ + options?: Record; +} + +function sameProps(prevProps: any, nextProps: any, propsToDiff: Array = []) { + for (const propName of propsToDiff) { + if (typeof propName === 'function') { + if (!propName(prevProps, nextProps)) { + return false; + } + } else if (nextProps[propName] !== prevProps[propName]) { + return false; + } + } + + return true; +} + +/** + * @internal -- not a public API + * @deprecated + */ +export interface GraphNGState { + alignedFrame: DataFrame; + alignedData?: AlignedData; + config?: UPlotConfigBuilder; +} + +/** + * "Time as X" core component, expects ascending x + * @deprecated + */ +export class GraphNG extends Component { + static contextType = PanelContextRoot; + panelContext: PanelContext = {} as PanelContext; + private plotInstance: React.RefObject; + + private subscription = new Subscription(); + + constructor(props: GraphNGProps) { + super(props); + let state = this.prepState(props); + state.alignedData = state.config!.prepData!([state.alignedFrame]) as AlignedData; + this.state = state; + this.plotInstance = React.createRef(); + } + + getTimeRange = () => this.props.timeRange; + + prepState(props: GraphNGProps, withConfig = true) { + let state: GraphNGState = null as any; + + const { frames, fields, preparePlotFrame } = props; + + const preparePlotFrameFn = preparePlotFrame || defaultPreparePlotFrame; + + const alignedFrame = preparePlotFrameFn( + frames, + fields || { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.byTypes).get(new Set([FieldType.number, FieldType.enum])), + }, + props.timeRange + ); + pluginLog('GraphNG', false, 'data aligned', alignedFrame); + + if (alignedFrame) { + let config = this.state?.config; + + if (withConfig) { + config = props.prepConfig(alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config prepared', config); + } + + state = { + alignedFrame, + config, + }; + + pluginLog('GraphNG', false, 'data prepared', state.alignedData); + } + + return state; + } + + handleCursorUpdate(evt: DataHoverEvent | LegacyGraphHoverEvent) { + const time = evt.payload?.point?.time; + const u = this.plotInstance.current; + if (u && time) { + // Try finding left position on time axis + const left = u.valToPos(time, 'x'); + let top; + if (left) { + // find midpoint between points at current idx + top = findMidPointYPosition(u, u.posToIdx(left)); + } + + if (!top || !left) { + return; + } + + u.setCursor({ + left, + top, + }); + } + } + + componentDidMount() { + this.panelContext = this.context as PanelContext; + const { eventBus } = this.panelContext; + + this.subscription.add( + eventBus + .getStream(DataHoverEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: (evt) => { + if (eventBus === evt.origin) { + return; + } + this.handleCursorUpdate(evt); + }, + }) + ); + + // Legacy events (from flot graph) + this.subscription.add( + eventBus + .getStream(LegacyGraphHoverEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: (evt) => this.handleCursorUpdate(evt), + }) + ); + + this.subscription.add( + eventBus + .getStream(DataHoverClearEvent) + .pipe(throttleTime(50)) + .subscribe({ + next: () => { + const u = this.plotInstance?.current; + + // @ts-ignore + if (u && !u.cursor._lock) { + u.setCursor({ + left: -10, + top: -10, + }); + } + }, + }) + ); + } + + componentDidUpdate(prevProps: GraphNGProps) { + const { frames, structureRev, timeZone, propsToDiff } = this.props; + + const propsChanged = !sameProps(prevProps, this.props, propsToDiff); + + if (frames !== prevProps.frames || propsChanged || timeZone !== prevProps.timeZone) { + let newState = this.prepState(this.props, false); + + if (newState) { + const shouldReconfig = + this.state.config === undefined || + timeZone !== prevProps.timeZone || + structureRev !== prevProps.structureRev || + !structureRev || + propsChanged; + + if (shouldReconfig) { + newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange); + pluginLog('GraphNG', false, 'config recreated', newState.config); + } + + newState.alignedData = newState.config!.prepData!([newState.alignedFrame]) as AlignedData; + + this.setState(newState); + } + } + } + + componentWillUnmount() { + this.subscription.unsubscribe(); + } + + render() { + const { width, height, children, renderLegend } = this.props; + const { config, alignedFrame, alignedData } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + ((this.plotInstance as React.MutableRefObject).current = u)} + > + {children ? children(config, alignedFrame) : null} + + )} + + ); + } +} diff --git a/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..3b9265254b5 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/__snapshots__/utils.test.ts.snap @@ -0,0 +1,245 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GraphNG utils preparePlotConfigBuilder 1`] = ` +{ + "axes": [ + { + "filter": undefined, + "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "gap": 5, + "grid": { + "show": true, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "incrs": undefined, + "labelGap": 0, + "rotate": undefined, + "scale": "x", + "show": true, + "side": 2, + "size": [Function], + "space": [Function], + "splits": undefined, + "stroke": "rgb(204, 204, 220)", + "ticks": { + "show": true, + "size": 4, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "timeZone": "utc", + "values": [Function], + }, + { + "filter": undefined, + "font": "12px "Inter", "Helvetica", "Arial", sans-serif", + "gap": 5, + "grid": { + "show": true, + "stroke": "rgba(240, 250, 255, 0.09)", + "width": 1, + }, + "incrs": undefined, + "labelGap": 0, + "rotate": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "side": 3, + "size": [Function], + "space": [Function], + "splits": undefined, + "stroke": "rgb(204, 204, 220)", + "ticks": { + "show": false, + "size": 4, + "stroke": "rgb(204, 204, 220)", + "width": 1, + }, + "timeZone": undefined, + "values": [Function], + }, + ], + "cursor": { + "dataIdx": [Function], + "drag": { + "setScale": false, + }, + "focus": { + "prox": 30, + }, + "points": { + "fill": [Function], + "size": [Function], + "stroke": [Function], + "width": [Function], + }, + "sync": { + "filters": { + "pub": [Function], + }, + "key": "__global_", + "scales": [ + "x", + "__fixed/na-na/na-na/auto/linear/na/number", + ], + }, + }, + "focus": { + "alpha": 1, + }, + "hooks": {}, + "legend": { + "show": false, + }, + "mode": 1, + "ms": 1, + "padding": [ + [Function], + [Function], + [Function], + [Function], + ], + "scales": { + "__fixed/na-na/na-na/auto/linear/na/number": { + "asinh": undefined, + "auto": true, + "dir": 1, + "distr": 1, + "log": undefined, + "ori": 1, + "range": [Function], + "time": undefined, + }, + "x": { + "auto": false, + "dir": 1, + "ori": 0, + "range": [Function], + "time": true, + }, + }, + "select": undefined, + "series": [ + { + "value": [Function], + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": [Function], + "filter": [Function], + "show": true, + "size": undefined, + "stroke": [Function], + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": [Function], + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": "#ff0000", + "filter": [Function], + "show": true, + "size": undefined, + "stroke": "#ff0000", + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": "#ff0000", + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": [Function], + "filter": [Function], + "show": true, + "size": undefined, + "stroke": [Function], + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": [Function], + "value": [Function], + "width": 2, + }, + { + "dash": [ + 1, + 2, + ], + "facets": undefined, + "fill": [Function], + "paths": [Function], + "points": { + "fill": [Function], + "filter": [Function], + "show": true, + "size": undefined, + "stroke": [Function], + }, + "pxAlign": undefined, + "scale": "__fixed/na-na/na-na/auto/linear/na/number", + "show": true, + "spanGaps": false, + "stroke": [Function], + "value": [Function], + "width": 2, + }, + ], + "tzDate": [Function], +} +`; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts b/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts new file mode 100644 index 00000000000..e4f1d46550a --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/hooks.ts @@ -0,0 +1,41 @@ +import React, { useCallback, useContext } from 'react'; + +import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data'; + +import { XYFieldMatchers } from './types'; + +/** @deprecated */ +interface GraphNGContextType { + mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex; + dimFields: XYFieldMatchers; + data: DataFrame; +} + +/** @deprecated */ +export const GraphNGContext = React.createContext({} as GraphNGContextType); + +/** @deprecated */ +export const useGraphNGContext = () => { + const { data, dimFields, mapSeriesIndexToDataFrameFieldIndex } = useContext(GraphNGContext); + + const getXAxisField = useCallback(() => { + const xFieldMatcher = dimFields.x; + let xField: Field | null = null; + + for (let j = 0; j < data.fields.length; j++) { + if (xFieldMatcher(data.fields[j], data, [data])) { + xField = data.fields[j]; + break; + } + } + + return xField; + }, [data, dimFields]); + + return { + dimFields, + mapSeriesIndexToDataFrameFieldIndex, + getXAxisField, + alignedData: data, + }; +}; diff --git a/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts new file mode 100644 index 00000000000..9675cd7ca56 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/GraphNG/utils.test.ts @@ -0,0 +1,522 @@ +import { + createTheme, + DashboardCursorSync, + DataFrame, + DefaultTimeZone, + EventBusSrv, + FieldColorModeId, + FieldConfig, + FieldMatcherID, + fieldMatchers, + FieldType, + getDefaultTimeRange, + MutableDataFrame, +} from '@grafana/data'; +import { + BarAlignment, + GraphDrawStyle, + GraphFieldConfig, + GraphGradientMode, + LineInterpolation, + VisibilityMode, + StackingMode, +} from '@grafana/schema'; + +import { preparePlotConfigBuilder } from '../TimeSeries/utils'; + +import { preparePlotFrame } from './utils'; + +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', + color: { + mode: FieldColorModeId.Fixed, + }, + decimals: 2, + custom: { + drawStyle: GraphDrawStyle.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: VisibilityMode.Always, + stacking: { + group: 'A', + mode: StackingMode.Normal, + }, + }, + }; + + const f2Config: FieldConfig = { + displayName: 'Metric 2', + color: { + mode: FieldColorModeId.Fixed, + }, + decimals: 2, + custom: { + drawStyle: GraphDrawStyle.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: VisibilityMode.Always, + stacking: { + group: 'A', + mode: StackingMode.Normal, + }, + }, + }; + + const f3Config: FieldConfig = { + displayName: 'Metric 3', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.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: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.Normal, + }, + }, + }; + const f4Config: FieldConfig = { + displayName: 'Metric 4', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.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: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.Normal, + }, + }, + }; + const f5Config: FieldConfig = { + displayName: 'Metric 4', + decimals: 2, + color: { + mode: FieldColorModeId.Fixed, + }, + custom: { + drawStyle: GraphDrawStyle.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: VisibilityMode.Always, + stacking: { + group: 'B', + mode: StackingMode.None, + }, + }, + }; + + df1.addField({ + name: 'metric1', + type: FieldType.number, + config: f1Config, + }); + + df2.addField({ + name: 'metric2', + type: FieldType.number, + config: f2Config, + }); + df2.addField({ + name: 'metric3', + type: FieldType.number, + config: f3Config, + }); + df2.addField({ + name: 'metric4', + type: FieldType.number, + config: f4Config, + }); + df2.addField({ + name: 'metric5', + type: FieldType.number, + config: f5Config, + }); + + return preparePlotFrame([df1, df2], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); +} + +jest.mock('@grafana/data', () => ({ + ...jest.requireActual('@grafana/data'), + DefaultTimeZone: 'utc', +})); + +describe('GraphNG utils', () => { + test('preparePlotConfigBuilder', () => { + const frame = mockDataFrame(); + const result = preparePlotConfigBuilder({ + frame: frame!, + theme: createTheme(), + timeZones: [DefaultTimeZone], + getTimeRange: getDefaultTimeRange, + eventBus: new EventBusSrv(), + sync: () => DashboardCursorSync.Tooltip, + allFrames: [frame!], + }).getConfig(); + expect(result).toMatchSnapshot(); + }); + + test('preparePlotFrame appends min bar spaced nulls when > 1 bar series', () => { + const df1: DataFrame = { + name: 'A', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [1, 2, 4, 6, 100], // should find smallest delta === 1 from here + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: [1, 1, 1, 1, 1], + }, + ], + }; + + const df2: DataFrame = { + name: 'B', + length: 5, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [30, 40, 50, 90, 100], // should be appended with two smallest-delta increments + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + }, + }, + values: [2, 2, 2, 2, 2], // bar series should be appended with nulls + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: [3, 3, 3, 3, 3], // line series should be appended with undefineds + }, + ], + }; + + const df3: DataFrame = { + name: 'C', + length: 2, + fields: [ + { + name: 'time', + type: FieldType.time, + config: {}, + values: [1, 1.1], // should not trip up on smaller deltas of non-bars + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Line, + }, + }, + values: [4, 4], + }, + { + name: 'value', + type: FieldType.number, + config: { + custom: { + drawStyle: GraphDrawStyle.Bars, + hideFrom: { + viz: true, // should ignore hidden bar series + }, + }, + }, + values: [4, 4], + }, + ], + }; + + let aligndFrame = preparePlotFrame([df1, df2, df3], { + x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), + y: fieldMatchers.get(FieldMatcherID.numeric).get({}), + }); + + expect(aligndFrame).toMatchInlineSnapshot(` + { + "fields": [ + { + "config": {}, + "name": "time", + "state": { + "nullThresholdApplied": true, + "origin": { + "fieldIndex": 0, + "frameIndex": 0, + }, + }, + "type": "time", + "values": [ + 1, + 1.1, + 2, + 4, + 6, + 30, + 40, + 50, + 90, + 100, + 101, + 102, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": { + "name": "A", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 0, + }, + }, + "type": "number", + "values": [ + 1, + undefined, + 1, + 1, + 1, + undefined, + undefined, + undefined, + undefined, + 1, + null, + null, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "spanNulls": -1, + }, + }, + "labels": { + "name": "B", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 1, + }, + }, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + undefined, + 2, + 2, + 2, + 2, + 2, + null, + null, + ], + }, + { + "config": { + "custom": { + "drawStyle": "line", + }, + }, + "labels": { + "name": "B", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 2, + "frameIndex": 1, + }, + }, + "type": "number", + "values": [ + undefined, + undefined, + undefined, + undefined, + undefined, + 3, + 3, + 3, + 3, + 3, + undefined, + undefined, + ], + }, + { + "config": { + "custom": { + "drawStyle": "line", + }, + }, + "labels": { + "name": "C", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 1, + "frameIndex": 2, + }, + }, + "type": "number", + "values": [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + { + "config": { + "custom": { + "drawStyle": "bars", + "hideFrom": { + "viz": true, + }, + }, + }, + "labels": { + "name": "C", + }, + "name": "value", + "state": { + "origin": { + "fieldIndex": 2, + "frameIndex": 2, + }, + }, + "type": "number", + "values": [ + 4, + 4, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ], + }, + ], + "length": 12, + } + `); + }); +}); diff --git a/packages/grafana-ui/src/graveyard/README.md b/packages/grafana-ui/src/graveyard/README.md index 627570b3aa7..2715ecec38a 100644 --- a/packages/grafana-ui/src/graveyard/README.md +++ b/packages/grafana-ui/src/graveyard/README.md @@ -1,4 +1 @@ Items in this folder are all deprecated and will be removed in the future - -NOTE: GraphNG is include, but not exported. It contains some complex function that are -used in the uPlot helper bundles, but also duplicated in grafana core diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx new file mode 100644 index 00000000000..9a748236d77 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/TimeSeries/TimeSeries.tsx @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; + +import { DataFrame, TimeRange } from '@grafana/data'; + +import { PanelContextRoot } from '../../components/PanelChrome/PanelContext'; +import { hasVisibleLegendSeries, PlotLegend } from '../../components/uPlot/PlotLegend'; +import { UPlotConfigBuilder } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { withTheme2 } from '../../themes/ThemeContext'; +import { GraphNG, GraphNGProps, PropDiffFn } from '../GraphNG/GraphNG'; + +import { preparePlotConfigBuilder } from './utils'; + +const propsToDiff: Array = ['legend', 'options', 'theme']; + +type TimeSeriesProps = Omit; + +export class UnthemedTimeSeries extends Component { + static contextType = PanelContextRoot; + declare context: React.ContextType; + + prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => { + const { eventBus, eventsScope, sync } = this.context; + const { theme, timeZone, renderers, tweakAxis, tweakScale } = this.props; + + return preparePlotConfigBuilder({ + frame: alignedFrame, + theme, + timeZones: Array.isArray(timeZone) ? timeZone : [timeZone], + getTimeRange, + eventBus, + sync, + allFrames, + renderers, + tweakScale, + tweakAxis, + eventsScope, + }); + }; + + renderLegend = (config: UPlotConfigBuilder) => { + const { legend, frames } = this.props; + + if (!config || (legend && !legend.showLegend) || !hasVisibleLegendSeries(config, frames)) { + return null; + } + + return ; + }; + + render() { + return ( + + ); + } +} + +export const TimeSeries = withTheme2(UnthemedTimeSeries); +TimeSeries.displayName = 'TimeSeries'; diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts new file mode 100644 index 00000000000..583358c7c4c --- /dev/null +++ b/packages/grafana-ui/src/graveyard/TimeSeries/utils.test.ts @@ -0,0 +1,274 @@ +import { EventBus, FieldType } from '@grafana/data'; +import { getTheme } from '@grafana/ui'; + +import { preparePlotConfigBuilder } from './utils'; + +describe('when fill below to option is used', () => { + let eventBus: EventBus; + // eslint-disable-next-line + let renderers: any[]; + // eslint-disable-next-line + let tests: any; + + beforeEach(() => { + eventBus = { + publish: jest.fn(), + getStream: jest.fn(), + subscribe: jest.fn(), + removeAllListeners: jest.fn(), + newScopedBus: jest.fn(), + }; + renderers = []; + + tests = [ + { + alignedFrame: { + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test1', custom: { fillBelowTo: 'Test2' }, min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { displayNameFromDS: 'Test2', min: 0, max: 100 }, + values: [4, 5, 6], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test2', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + allFrames: [ + { + name: 'Test1', + refId: 'A', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test1', custom: { fillBelowTo: 'Test2' }, min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + ], + length: 2, + }, + { + name: 'Test2', + refId: 'B', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'Time', + state: { multipleFrames: true, displayName: 'Time', origin: { fieldIndex: 0, frameIndex: 1 } }, + type: FieldType.time, + }, + { + config: { displayNameFromDS: 'Test2', min: 0, max: 100 }, + values: [1, 2, 3], + name: 'Value', + state: { multipleFrames: true, displayName: 'Test2', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 2, + }, + ], + expectedResult: 1, + }, + { + alignedFrame: { + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { custom: { fillBelowTo: 'below_value1' } }, + values: [1, 2, 3], + name: 'value1', + state: { multipleFrames: true, displayName: 'value1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { custom: { fillBelowTo: 'below_value2' } }, + values: [4, 5, 6], + name: 'value2', + state: { multipleFrames: true, displayName: 'value2', origin: { fieldIndex: 2, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value1', + state: { multipleFrames: true, displayName: 'below_value1', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value2', + state: { multipleFrames: true, displayName: 'below_value2', origin: { fieldIndex: 2, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 5, + }, + allFrames: [ + { + refId: 'A', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 0 } }, + type: FieldType.time, + }, + { + config: { custom: { fillBelowTo: 'below_value1' } }, + values: [1, 2, 3], + name: 'value1', + state: { multipleFrames: true, displayName: 'value1', origin: { fieldIndex: 1, frameIndex: 0 } }, + type: FieldType.number, + }, + { + config: { custom: { fillBelowTo: 'below_value2' } }, + values: [4, 5, 6], + name: 'value2', + state: { multipleFrames: true, displayName: 'value2', origin: { fieldIndex: 2, frameIndex: 0 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + { + refId: 'B', + fields: [ + { + config: {}, + values: [1667406900000, 1667407170000, 1667407185000], + name: 'time', + state: { multipleFrames: true, displayName: 'time', origin: { fieldIndex: 0, frameIndex: 1 } }, + type: FieldType.time, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value1', + state: { multipleFrames: true, displayName: 'below_value1', origin: { fieldIndex: 1, frameIndex: 1 } }, + type: FieldType.number, + }, + { + config: {}, + values: [4, 5, 6], + name: 'below_value2', + state: { multipleFrames: true, displayName: 'below_value2', origin: { fieldIndex: 2, frameIndex: 1 } }, + type: FieldType.number, + }, + ], + length: 3, + }, + ], + expectedResult: 2, + }, + ]; + }); + + it('should verify if fill below to is set then builder bands are set', () => { + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); + + it('should verify if fill below to is not set then builder bands are empty', () => { + tests[0].alignedFrame.fields[1].config.custom.fillBelowTo = undefined; + tests[0].allFrames[0].fields[1].config.custom.fillBelowTo = undefined; + tests[1].alignedFrame.fields[1].config.custom.fillBelowTo = undefined; + tests[1].alignedFrame.fields[2].config.custom.fillBelowTo = undefined; + tests[1].allFrames[0].fields[1].config.custom.fillBelowTo = undefined; + tests[1].allFrames[0].fields[2].config.custom.fillBelowTo = undefined; + tests[0].expectedResult = 0; + tests[1].expectedResult = 0; + + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); + + it('should verify if fill below to is set and field name is overriden then builder bands are set', () => { + tests[0].alignedFrame.fields[2].config.displayName = 'newName'; + tests[0].alignedFrame.fields[2].state.displayName = 'newName'; + tests[0].allFrames[1].fields[1].config.displayName = 'newName'; + tests[0].allFrames[1].fields[1].state.displayName = 'newName'; + + tests[1].alignedFrame.fields[3].config.displayName = 'newName'; + tests[1].alignedFrame.fields[3].state.displayName = 'newName'; + tests[1].allFrames[1].fields[1].config.displayName = 'newName'; + tests[1].allFrames[1].fields[1].state.displayName = 'newName'; + + for (const test of tests) { + const builder = preparePlotConfigBuilder({ + frame: test.alignedFrame, + //@ts-ignore + theme: getTheme(), + timeZones: ['browser'], + getTimeRange: jest.fn(), + eventBus, + sync: jest.fn(), + allFrames: test.allFrames, + renderers, + }); + + //@ts-ignore + expect(builder.bands.length).toBe(test.expectedResult); + } + }); +}); diff --git a/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts new file mode 100644 index 00000000000..12a6e3908c6 --- /dev/null +++ b/packages/grafana-ui/src/graveyard/TimeSeries/utils.ts @@ -0,0 +1,668 @@ +import { isNumber } from 'lodash'; +import uPlot from 'uplot'; + +import { + DashboardCursorSync, + DataFrame, + DataHoverClearEvent, + DataHoverEvent, + DataHoverPayload, + FieldConfig, + FieldType, + formattedValueToString, + getFieldColorModeForField, + getFieldSeriesColor, + getFieldDisplayName, + getDisplayProcessor, + FieldColorModeId, + DecimalCount, +} from '@grafana/data'; +import { + AxisPlacement, + GraphDrawStyle, + GraphFieldConfig, + GraphThresholdsStyleMode, + VisibilityMode, + ScaleDirection, + ScaleOrientation, + StackingMode, + GraphTransform, + AxisColorMode, + GraphGradientMode, +} from '@grafana/schema'; + +// unit lookup needed to determine if we want power-of-2 or power-of-10 axis ticks +// see categories.ts is @grafana/data +const IEC_UNITS = new Set([ + 'bytes', + 'bits', + 'kbytes', + 'mbytes', + 'gbytes', + 'tbytes', + 'pbytes', + 'binBps', + 'binbps', + 'KiBs', + 'Kibits', + 'MiBs', + 'Mibits', + 'GiBs', + 'Gibits', + 'TiBs', + 'Tibits', + 'PiBs', + 'Pibits', +]); + +const BIN_INCRS = Array(53); + +for (let i = 0; i < BIN_INCRS.length; i++) { + BIN_INCRS[i] = 2 ** i; +} + +import { UPlotConfigBuilder, UPlotConfigPrepFn } from '../../components/uPlot/config/UPlotConfigBuilder'; +import { getScaleGradientFn } from '../../components/uPlot/config/gradientFills'; +import { getStackingGroups, preparePlotData2 } from '../../components/uPlot/utils'; +import { buildScaleKey } from '../GraphNG/utils'; + +const defaultFormatter = (v: any, decimals: DecimalCount = 1) => (v == null ? '-' : v.toFixed(decimals)); + +const defaultConfig: GraphFieldConfig = { + drawStyle: GraphDrawStyle.Line, + showPoints: VisibilityMode.Auto, + axisPlacement: AxisPlacement.Auto, +}; + +export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ + sync?: () => DashboardCursorSync; +}> = ({ + frame, + theme, + timeZones, + getTimeRange, + eventBus, + sync, + allFrames, + renderers, + tweakScale = (opts) => opts, + tweakAxis = (opts) => opts, + eventsScope = '__global_', +}) => { + const builder = new UPlotConfigBuilder(timeZones[0]); + + let alignedFrame: DataFrame; + + builder.setPrepData((frames) => { + // cache alignedFrame + alignedFrame = frames[0]; + + return preparePlotData2(frames[0], builder.getStackingGroups()); + }); + + // X is the first field in the aligned frame + const xField = frame.fields[0]; + if (!xField) { + return builder; // empty frame with no options + } + + const xScaleKey = 'x'; + let xScaleUnit = '_x'; + let yScaleKey = ''; + + const xFieldAxisPlacement = + xField.config.custom?.axisPlacement !== AxisPlacement.Hidden ? AxisPlacement.Bottom : AxisPlacement.Hidden; + const xFieldAxisShow = xField.config.custom?.axisPlacement !== AxisPlacement.Hidden; + + if (xField.type === FieldType.time) { + xScaleUnit = 'time'; + builder.addScale({ + scaleKey: xScaleKey, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + isTime: true, + range: () => { + const r = getTimeRange(); + return [r.from.valueOf(), r.to.valueOf()]; + }, + }); + + // filters first 2 ticks to make space for timezone labels + const filterTicks: uPlot.Axis.Filter | undefined = + timeZones.length > 1 + ? (u, splits) => { + return splits.map((v, i) => (i < 2 ? null : v)); + } + : undefined; + + for (let i = 0; i < timeZones.length; i++) { + const timeZone = timeZones[i]; + builder.addAxis({ + scaleKey: xScaleKey, + isTime: true, + placement: xFieldAxisPlacement, + show: xFieldAxisShow, + label: xField.config.custom?.axisLabel, + timeZone, + theme, + grid: { show: i === 0 && xField.config.custom?.axisGridShow }, + filter: filterTicks, + }); + } + + // render timezone labels + if (timeZones.length > 1) { + builder.addHook('drawAxes', (u: uPlot) => { + u.ctx.save(); + + u.ctx.fillStyle = theme.colors.text.primary; + u.ctx.textAlign = 'left'; + u.ctx.textBaseline = 'bottom'; + + let i = 0; + u.axes.forEach((a) => { + if (a.side === 2) { + //@ts-ignore + let cssBaseline: number = a._pos + a._size; + u.ctx.fillText(timeZones[i], u.bbox.left, cssBaseline * uPlot.pxRatio); + i++; + } + }); + + u.ctx.restore(); + }); + } + } else { + // Not time! + if (xField.config.unit) { + xScaleUnit = xField.config.unit; + } + + builder.addScale({ + scaleKey: xScaleKey, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + range: (u, dataMin, dataMax) => [xField.config.min ?? dataMin, xField.config.max ?? dataMax], + }); + + builder.addAxis({ + scaleKey: xScaleKey, + placement: xFieldAxisPlacement, + show: xFieldAxisShow, + label: xField.config.custom?.axisLabel, + theme, + grid: { show: xField.config.custom?.axisGridShow }, + formatValue: (v, decimals) => formattedValueToString(xField.display!(v, decimals)), + }); + } + + let customRenderedFields = + renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? []; + + let indexByName: Map | undefined; + + for (let i = 1; i < frame.fields.length; i++) { + const field = frame.fields[i]; + + const config: FieldConfig = { + ...field.config, + custom: { + ...defaultConfig, + ...field.config.custom, + }, + }; + + const customConfig: GraphFieldConfig = config.custom!; + + if (field === xField || (field.type !== FieldType.number && field.type !== FieldType.enum)) { + continue; + } + + let fmt = field.display ?? defaultFormatter; + if (field.config.custom?.stacking?.mode === StackingMode.Percent) { + fmt = getDisplayProcessor({ + field: { + ...field, + config: { + ...field.config, + unit: 'percentunit', + }, + }, + theme, + }); + } + const scaleKey = buildScaleKey(config, field.type); + 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( + tweakScale( + { + scaleKey, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + distribution: customConfig.scaleDistribution?.type, + log: customConfig.scaleDistribution?.log, + linearThreshold: customConfig.scaleDistribution?.linearThreshold, + min: field.config.min, + max: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + centeredZero: customConfig.axisCenteredZero, + range: + customConfig.stacking?.mode === StackingMode.Percent + ? (u: uPlot, dataMin: number, dataMax: number) => { + dataMin = dataMin < 0 ? -1 : 0; + dataMax = dataMax > 0 ? 1 : 0; + return [dataMin, dataMax]; + } + : field.type === FieldType.enum + ? (u: uPlot, dataMin: number, dataMax: number) => { + // this is the exhaustive enum (stable) + let len = field.config.type!.enum!.text!.length; + + return [-1, len]; + + // these are only values that are present + // return [dataMin - 1, dataMax + 1] + } + : undefined, + decimals: field.config.decimals, + }, + field + ) + ); + + if (!yScaleKey) { + yScaleKey = scaleKey; + } + + if (customConfig.axisPlacement !== AxisPlacement.Hidden) { + let axisColor: uPlot.Axis.Stroke | undefined; + + if (customConfig.axisColorMode === AxisColorMode.Series) { + if ( + colorMode.isByValue && + field.config.custom?.gradientMode === GraphGradientMode.Scheme && + colorMode.id === FieldColorModeId.Thresholds + ) { + axisColor = getScaleGradientFn(1, theme, colorMode, field.config.thresholds); + } else { + axisColor = seriesColor; + } + } + + const axisDisplayOptions = { + border: { + show: customConfig.axisBorderShow || false, + width: 1 / devicePixelRatio, + stroke: axisColor || theme.colors.text.primary, + }, + ticks: { + show: customConfig.axisBorderShow || false, + stroke: axisColor || theme.colors.text.primary, + }, + color: axisColor || theme.colors.text.primary, + }; + + let incrs: uPlot.Axis.Incrs | undefined; + + // TODO: these will be dynamic with frame updates, so need to accept getYTickLabels() + let values: uPlot.Axis.Values | undefined; + let splits: uPlot.Axis.Splits | undefined; + + if (IEC_UNITS.has(config.unit!)) { + incrs = BIN_INCRS; + } else if (field.type === FieldType.enum) { + let text = field.config.type!.enum!.text!; + splits = text.map((v: string, i: number) => i); + values = text; + } + + builder.addAxis( + tweakAxis( + { + scaleKey, + label: customConfig.axisLabel, + size: customConfig.axisWidth, + placement: customConfig.axisPlacement ?? AxisPlacement.Auto, + formatValue: (v, decimals) => formattedValueToString(fmt(v, decimals)), + theme, + grid: { show: customConfig.axisGridShow }, + decimals: field.config.decimals, + distr: customConfig.scaleDistribution?.type, + splits, + values, + incrs, + ...axisDisplayOptions, + }, + field + ) + ); + } + + const showPoints = + customConfig.drawStyle === GraphDrawStyle.Points ? VisibilityMode.Always : customConfig.showPoints; + + let pointsFilter: uPlot.Series.Points.Filter = () => null; + + if (customConfig.spanNulls !== true) { + pointsFilter = (u, seriesIdx, show, gaps) => { + let filtered = []; + + let series = u.series[seriesIdx]; + + if (!show && gaps && gaps.length) { + const [firstIdx, lastIdx] = series.idxs!; + const xData = u.data[0]; + const yData = u.data[seriesIdx]; + const firstPos = Math.round(u.valToPos(xData[firstIdx], 'x', true)); + const lastPos = Math.round(u.valToPos(xData[lastIdx], 'x', true)); + + if (gaps[0][0] === firstPos) { + filtered.push(firstIdx); + } + + // show single points between consecutive gaps that share end/start + for (let i = 0; i < gaps.length; i++) { + let thisGap = gaps[i]; + let nextGap = gaps[i + 1]; + + if (nextGap && thisGap[1] === nextGap[0]) { + // approx when data density is > 1pt/px, since gap start/end pixels are rounded + let approxIdx = u.posToIdx(thisGap[1], true); + + if (yData[approxIdx] == null) { + // scan left/right alternating to find closest index with non-null value + for (let j = 1; j < 100; j++) { + if (yData[approxIdx + j] != null) { + approxIdx += j; + break; + } + if (yData[approxIdx - j] != null) { + approxIdx -= j; + break; + } + } + } + + filtered.push(approxIdx); + } + } + + if (gaps[gaps.length - 1][1] === lastPos) { + filtered.push(lastIdx); + } + } + + return filtered.length ? filtered : null; + }; + } + + let { fillOpacity } = customConfig; + + let pathBuilder: uPlot.Series.PathBuilder | null = null; + let pointsBuilder: uPlot.Series.Points.Show | null = null; + + if (field.state?.origin) { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } + + const originFrame = allFrames[field.state.origin.frameIndex]; + const originField = originFrame?.fields[field.state.origin.fieldIndex]; + + const dispName = getFieldDisplayName(originField ?? field, originFrame, allFrames); + + // disable default renderers + if (customRenderedFields.indexOf(dispName) >= 0) { + pathBuilder = () => null; + pointsBuilder = () => undefined; + } else if (customConfig.transform === GraphTransform.Constant) { + // patch some monkeys! + const defaultBuilder = uPlot.paths!.linear!(); + + pathBuilder = (u, seriesIdx) => { + //eslint-disable-next-line + const _data: any[] = (u as any)._data; // uplot.AlignedData not exposed in types + + // the data we want the line renderer to pull is x at each plot edge with paired flat y values + + const r = getTimeRange(); + let xData = [r.from.valueOf(), r.to.valueOf()]; + let firstY = _data[seriesIdx].find((v: number | null | undefined) => v != null); + let yData = [firstY, firstY]; + let fauxData = _data.slice(); + fauxData[0] = xData; + fauxData[seriesIdx] = yData; + + //eslint-disable-next-line + return defaultBuilder( + { + ...u, + _data: fauxData, + } as any, + seriesIdx, + 0, + 1 + ); + }; + } + + if (customConfig.fillBelowTo) { + const fillBelowToField = frame.fields.find( + (f) => + customConfig.fillBelowTo === f.name || + customConfig.fillBelowTo === f.config?.displayNameFromDS || + customConfig.fillBelowTo === getFieldDisplayName(f, frame, allFrames) + ); + + const fillBelowDispName = fillBelowToField + ? getFieldDisplayName(fillBelowToField, frame, allFrames) + : customConfig.fillBelowTo; + + const t = indexByName.get(dispName); + const b = indexByName.get(fillBelowDispName); + if (isNumber(b) && isNumber(t)) { + builder.addBand({ + series: [t, b], + fill: undefined, // using null will have the band use fill options from `t` + }); + + if (!fillOpacity) { + fillOpacity = 35; // default from flot + } + } else { + fillOpacity = 0; + } + } + } + + let dynamicSeriesColor: ((seriesIdx: number) => string | undefined) | undefined = undefined; + + if (colorMode.id === FieldColorModeId.Thresholds) { + dynamicSeriesColor = (seriesIdx) => getFieldSeriesColor(alignedFrame.fields[seriesIdx], theme).color; + } + + builder.addSeries({ + pathBuilder, + pointsBuilder, + scaleKey, + showPoints, + pointsFilter, + colorMode, + fillOpacity, + theme, + dynamicSeriesColor, + drawStyle: customConfig.drawStyle!, + lineColor: customConfig.lineColor ?? seriesColor, + lineWidth: customConfig.lineWidth, + lineInterpolation: customConfig.lineInterpolation, + lineStyle: customConfig.lineStyle, + barAlignment: customConfig.barAlignment, + barWidthFactor: customConfig.barWidthFactor, + barMaxWidth: customConfig.barMaxWidth, + pointSize: customConfig.pointSize, + spanNulls: customConfig.spanNulls || false, + show: !customConfig.hideFrom?.viz, + gradientMode: customConfig.gradientMode, + thresholds: config.thresholds, + hardMin: field.config.min, + hardMax: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + dataFrameFieldIndex: field.state?.origin, + }); + + // Render thresholds in graph + if (customConfig.thresholdsStyle && config.thresholds) { + const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphThresholdsStyleMode.Off; + if (thresholdDisplay !== GraphThresholdsStyleMode.Off) { + builder.addThresholds({ + config: customConfig.thresholdsStyle, + thresholds: config.thresholds, + scaleKey, + theme, + hardMin: field.config.min, + hardMax: field.config.max, + softMin: customConfig.axisSoftMin, + softMax: customConfig.axisSoftMax, + }); + } + } + } + + let stackingGroups = getStackingGroups(frame); + + builder.setStackingGroups(stackingGroups); + + // hook up custom/composite renderers + renderers?.forEach((r) => { + if (!indexByName) { + indexByName = getNamesToFieldIndex(frame, allFrames); + } + let fieldIndices: Record = {}; + + for (let key in r.fieldMap) { + let dispName = r.fieldMap[key]; + fieldIndices[key] = indexByName.get(dispName)!; + } + + r.init(builder, fieldIndices); + }); + + builder.scaleKeys = [xScaleKey, yScaleKey]; + + // if hovered value is null, how far we may scan left/right to hover nearest non-null + const hoverProximityPx = 15; + + let cursor: Partial = { + // this scans left and right from cursor position to find nearest data index with value != null + // TODO: do we want to only scan past undefined values, but halt at explicit null values? + dataIdx: (self, seriesIdx, hoveredIdx, cursorXVal) => { + let seriesData = self.data[seriesIdx]; + + if (seriesData[hoveredIdx] == null) { + let nonNullLft = null, + nonNullRgt = null, + i; + + i = hoveredIdx; + while (nonNullLft == null && i-- > 0) { + if (seriesData[i] != null) { + nonNullLft = i; + } + } + + i = hoveredIdx; + while (nonNullRgt == null && i++ < seriesData.length) { + if (seriesData[i] != null) { + nonNullRgt = i; + } + } + + let xVals = self.data[0]; + + let curPos = self.valToPos(cursorXVal, 'x'); + let rgtPos = nonNullRgt == null ? Infinity : self.valToPos(xVals[nonNullRgt], 'x'); + let lftPos = nonNullLft == null ? -Infinity : self.valToPos(xVals[nonNullLft], 'x'); + + let lftDelta = curPos - lftPos; + let rgtDelta = rgtPos - curPos; + + if (lftDelta <= rgtDelta) { + if (lftDelta <= hoverProximityPx) { + hoveredIdx = nonNullLft!; + } + } else { + if (rgtDelta <= hoverProximityPx) { + hoveredIdx = nonNullRgt!; + } + } + } + + return hoveredIdx; + }, + }; + + if (sync && sync() !== DashboardCursorSync.Off) { + const payload: DataHoverPayload = { + point: { + [xScaleKey]: null, + [yScaleKey]: null, + }, + data: frame, + }; + + const hoverEvent = new DataHoverEvent(payload); + cursor.sync = { + key: eventsScope, + filters: { + pub: (type: string, src: uPlot, x: number, y: number, w: number, h: number, dataIdx: number) => { + if (sync && sync() === DashboardCursorSync.Off) { + return false; + } + + payload.rowIndex = dataIdx; + if (x < 0 && y < 0) { + payload.point[xScaleUnit] = null; + payload.point[yScaleKey] = null; + eventBus.publish(new DataHoverClearEvent()); + } else { + // convert the points + payload.point[xScaleUnit] = src.posToVal(x, xScaleKey); + payload.point[yScaleKey] = src.posToVal(y, yScaleKey); + payload.point.panelRelY = y > 0 ? y / h : 1; // used by old graph panel to position tooltip + eventBus.publish(hoverEvent); + hoverEvent.payload.down = undefined; + } + return true; + }, + }, + scales: [xScaleKey, yScaleKey], + // match: [() => true, (a, b) => a === b], + }; + } + + builder.setSync(); + builder.setCursor(cursor); + + return builder; +}; + +export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map { + const originNames = new Map(); + frame.fields.forEach((field, i) => { + const origin = field.state?.origin; + if (origin) { + const origField = allFrames[origin.frameIndex]?.fields[origin.fieldIndex]; + if (origField) { + originNames.set(getFieldDisplayName(origField, allFrames[origin.frameIndex], allFrames), i); + } + } + }); + return originNames; +} diff --git a/public/app/angular/angular_wrappers.ts b/public/app/angular/angular_wrappers.ts index f3372171166..fc0263ca322 100644 --- a/public/app/angular/angular_wrappers.ts +++ b/public/app/angular/angular_wrappers.ts @@ -3,6 +3,7 @@ import { ColorPicker, DataLinksInlineEditor, DataSourceHttpSettings, + GraphContextMenu, Icon, LegacyForms, SeriesColorPickerPopoverWithTheme, @@ -21,8 +22,6 @@ import { MetricSelect } from '../core/components/Select/MetricSelect'; import { TagFilter } from '../core/components/TagFilter/TagFilter'; import { HelpModal } from '../core/components/help/HelpModal'; -import { GraphContextMenu } from './components/legacy_graph_panel/GraphContextMenu'; - const { SecretFormField } = LegacyForms; export function registerAngularDirectives() { diff --git a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx index 005f3487627..59c6ad9dceb 100644 --- a/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx @@ -2,8 +2,15 @@ import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useClickAway } from 'react-use'; import { CartesianCoords2D, DataFrame, getFieldDisplayName, InterpolateFunction, TimeZone } from '@grafana/data'; -import { ContextMenu, MenuItemProps, MenuItemsGroup, MenuGroup, MenuItem, UPlotConfigBuilder } from '@grafana/ui'; -import { GraphContextMenuHeader } from 'app/angular/components/legacy_graph_panel/GraphContextMenu'; +import { + ContextMenu, + GraphContextMenuHeader, + MenuItemProps, + MenuItemsGroup, + MenuGroup, + MenuItem, + UPlotConfigBuilder, +} from '@grafana/ui'; type ContextMenuSelectionCoords = { viewport: CartesianCoords2D; plotCanvas: CartesianCoords2D }; type ContextMenuSelectionPoint = { seriesIdx: number | null; dataIdx: number | null };