diff --git a/docs/sources/visualizations/time-series/_index.md b/docs/sources/visualizations/time-series/_index.md index cf384933397..e0cec74145f 100644 --- a/docs/sources/visualizations/time-series/_index.md +++ b/docs/sources/visualizations/time-series/_index.md @@ -35,6 +35,15 @@ Use these options to choose how to display your time series data. - [Graph stacked time series]({{< relref "./graph-time-series-stacking.md" >}}) - [Graph and color schemes]({{< relref "./graph-color-scheme.md" >}}) +### Transform + +Use this option to transform the series values without affecting the values shown in the tooltip, context menu, and legend. + +- **Negative Y transform -** Flip the results to negative values on the Y axis. +- **Constant -** Show first value as a constant line. + +> **Note:** Transform option is only available as an override. + ## Axis For more information about adjusting your time series axes, refer to [Change axis display]({{< relref "change-axis-display.md" >}}). diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts index b56f9e2b53e..1ba88fab1a9 100644 --- a/packages/grafana-data/src/field/overrides/processors.ts +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -74,6 +74,8 @@ export const valueMappingsOverrideProcessor = ( export interface SelectFieldConfigSettings { allowCustomValue?: boolean; + isClearable?: boolean; + /** The default options */ options: Array>; diff --git a/packages/grafana-schema/src/schema/graph.cue b/packages/grafana-schema/src/schema/graph.cue index 0af58f7c5b1..cc1abcf37b9 100644 --- a/packages/grafana-schema/src/schema/graph.cue +++ b/packages/grafana-schema/src/schema/graph.cue @@ -7,6 +7,7 @@ LineInterpolation: "linear" | "smooth" | "stepBefore" | "stepAfter" @cue ScaleDistribution: "linear" | "log" | "ordinal" @cuetsy(kind="enum") GraphGradientMode: "none" | "opacity" | "hue" | "scheme" @cuetsy(kind="enum") StackingMode: "none" | "normal" | "percent" @cuetsy(kind="enum") +GraphTransform: "constant" | "negative-Y" @cuetsy(kind="enum") BarAlignment: -1 | 0 | 1 @cuetsy(kind="enum",memberNames="Before|Center|After") ScaleOrientation: 0 | 1 @cuetsy(kind="enum",memberNames="Horizontal|Vertical") ScaleDirection: 1 | 1 | -1 | -1 @cuetsy(kind="enum",memberNames="Up|Right|Down|Left") @@ -84,4 +85,5 @@ GraphFieldConfig: { drawStyle?: GraphDrawStyle gradientMode?: GraphGradientMode thresholdsStyle?: GraphThresholdsStyleConfig + transform?: GraphTransform } @cuetsy(kind="interface") diff --git a/packages/grafana-schema/src/schema/graph.gen.ts b/packages/grafana-schema/src/schema/graph.gen.ts index 0ed0f810bf4..623d750a250 100644 --- a/packages/grafana-schema/src/schema/graph.gen.ts +++ b/packages/grafana-schema/src/schema/graph.gen.ts @@ -24,6 +24,11 @@ export enum GraphDrawStyle { Points = 'points', } +export enum GraphTransform { + Constant = 'constant', + NegativeY = 'negative-Y', +} + export enum LineInterpolation { Linear = 'linear', Smooth = 'smooth', @@ -256,6 +261,7 @@ export interface GraphFieldConfig drawStyle?: GraphDrawStyle; gradientMode?: GraphGradientMode; thresholdsStyle?: GraphThresholdsStyleConfig; + transform?: GraphTransform; } export interface VizLegendOptions { diff --git a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap index 8128c4dd89a..069b5835e4c 100644 --- a/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap @@ -60,12 +60,14 @@ Object { ], "bands": Array [ Object { + "dir": -1, "series": Array [ 2, 1, ], }, Object { + "dir": -1, "series": Array [ 4, 3, diff --git a/packages/grafana-ui/src/components/OptionsUI/select.tsx b/packages/grafana-ui/src/components/OptionsUI/select.tsx index ac45c393c91..2c3405bbcc0 100644 --- a/packages/grafana-ui/src/components/OptionsUI/select.tsx +++ b/packages/grafana-ui/src/components/OptionsUI/select.tsx @@ -67,7 +67,8 @@ export class SelectValueEditor extends React.PureComponent, State value={current} defaultValue={value} allowCustomValue={settings?.allowCustomValue} - onChange={(e) => onChange(e.value)} + isClearable={settings?.isClearable} + onChange={(e) => onChange(e?.value)} options={options} /> ); diff --git a/packages/grafana-ui/src/components/TimeSeries/utils.ts b/packages/grafana-ui/src/components/TimeSeries/utils.ts index 650e748a284..d812a8dc93d 100644 --- a/packages/grafana-ui/src/components/TimeSeries/utils.ts +++ b/packages/grafana-ui/src/components/TimeSeries/utils.ts @@ -26,7 +26,7 @@ import { VizLegendOptions, StackingMode, } from '@grafana/schema'; -import { collectStackingGroups, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; +import { collectStackingGroups, INTERNAL_NEGATIVE_Y_PREFIX, orderIdsByCalcs, preparePlotData } from '../uPlot/utils'; import uPlot from 'uplot'; import { buildScaleKey } from '../GraphNG/utils'; @@ -332,11 +332,12 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ } if (stackingGroups.size !== 0) { - for (const [_, seriesIds] of stackingGroups.entries()) { + for (const [group, seriesIds] of stackingGroups.entries()) { const seriesIdxs = orderIdsByCalcs({ ids: seriesIds, legend, frame }); for (let j = seriesIdxs.length - 1; j > 0; j--) { builder.addBand({ series: [seriesIdxs[j], seriesIdxs[j - 1]], + dir: group.startsWith(INTERNAL_NEGATIVE_Y_PREFIX) ? 1 : -1, }); } } diff --git a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx index 8ad44161faf..7643b7a5333 100644 --- a/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/SeriesTable.tsx @@ -9,7 +9,7 @@ import { useStyles2 } from '../../themes'; */ export interface SeriesTableRowProps { color?: string; - label?: string; + label?: React.ReactNode; value?: string | GraphSeriesValue; isActive?: boolean; } diff --git a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx index 19053fc5f52..45557b917d4 100644 --- a/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx +++ b/packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx @@ -231,6 +231,7 @@ export const TooltipPlugin: React.FC = ({ const v = otherProps.data.fields[i].values.get(focusedPointIdxs[i]!); const display = field.display!(v); + sortIdx.push([series.length, v]); series.push({ color: display.color || FALLBACK_COLOR, diff --git a/packages/grafana-ui/src/components/uPlot/utils.test.ts b/packages/grafana-ui/src/components/uPlot/utils.test.ts index 47060f9ddc0..873e26ba4c1 100644 --- a/packages/grafana-ui/src/components/uPlot/utils.test.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.test.ts @@ -1,6 +1,6 @@ import { orderIdsByCalcs, preparePlotData, timeFormatToTemplate } from './utils'; import { FieldType, MutableDataFrame } from '@grafana/data'; -import { StackingMode } from '@grafana/schema'; +import { GraphTransform, StackingMode } from '@grafana/schema'; describe('timeFormatToTemplate', () => { it.each` @@ -53,6 +53,76 @@ describe('preparePlotData', () => { `); }); + describe('transforms', () => { + it('negative-y transform', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { name: 'a', values: [-10, 20, 10] }, + { name: 'b', values: [10, 10, 10] }, + { name: 'c', values: [20, 20, 20], config: { custom: { transform: GraphTransform.NegativeY } } }, + ], + }); + expect(preparePlotData([df])).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + -10, + 20, + 10, + ], + Array [ + 10, + 10, + 10, + ], + Array [ + -20, + -20, + -20, + ], + ] + `); + }); + it('constant transform', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { name: 'a', values: [-10, 20, 10], config: { custom: { transform: GraphTransform.Constant } } }, + { name: 'b', values: [10, 10, 10] }, + { name: 'c', values: [20, 20, 20] }, + ], + }); + expect(preparePlotData([df])).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + -10, + -10, + -10, + ], + Array [ + 10, + 10, + 10, + ], + Array [ + 20, + 20, + 20, + ], + ] + `); + }); + }); describe('stacking', () => { it('none', () => { const df = new MutableDataFrame({ @@ -148,6 +218,67 @@ describe('preparePlotData', () => { `); }); + it('standard with negative y transform', () => { + const df = new MutableDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [9997, 9998, 9999] }, + { + name: 'a', + values: [-10, 20, 10], + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + { + name: 'b', + values: [10, 10, 10], + config: { custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' } } }, + }, + { + name: 'c', + values: [20, 20, 20], + config: { + custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' }, transform: GraphTransform.NegativeY }, + }, + }, + { + name: 'd', + values: [10, 10, 10], + config: { + custom: { stacking: { mode: StackingMode.Normal, group: 'stackA' }, transform: GraphTransform.NegativeY }, + }, + }, + ], + }); + expect(preparePlotData([df])).toMatchInlineSnapshot(` + Array [ + Array [ + 9997, + 9998, + 9999, + ], + Array [ + -10, + 20, + 10, + ], + Array [ + 0, + 30, + 20, + ], + Array [ + -20, + -20, + -20, + ], + Array [ + -30, + -30, + -30, + ], + ] + `); + }); + it('standard with multiple groups', () => { const df = new MutableDataFrame({ fields: [ diff --git a/packages/grafana-ui/src/components/uPlot/utils.ts b/packages/grafana-ui/src/components/uPlot/utils.ts index 0a0288ddade..35cc9b39b65 100755 --- a/packages/grafana-ui/src/components/uPlot/utils.ts +++ b/packages/grafana-ui/src/components/uPlot/utils.ts @@ -1,11 +1,12 @@ import { DataFrame, ensureTimeField, Field, FieldType } from '@grafana/data'; -import { StackingMode, VizLegendOptions } from '@grafana/schema'; +import { GraphFieldConfig, GraphTransform, StackingMode, VizLegendOptions } from '@grafana/schema'; import { orderBy } from 'lodash'; import uPlot, { AlignedData, Options, PaddingSide } from 'uplot'; import { attachDebugger } from '../../utils'; import { createLogger } from '../../utils/logger'; const ALLOWED_FORMAT_STRINGS_REGEX = /\b(YYYY|YY|MMMM|MMM|MM|M|DD|D|WWWW|WWW|HH|H|h|AA|aa|a|mm|m|ss|s|fff)\b/g; +export const INTERNAL_NEGATIVE_Y_PREFIX = '__internalNegY'; export function timeFormatToTemplate(f: string) { return f.replace(ALLOWED_FORMAT_STRINGS_REGEX, (match) => `{${match}}`); @@ -60,7 +61,16 @@ export function preparePlotData( } collectStackingGroups(f, stackingGroups, seriesIndex); - result.push(f.values.toArray()); + const customConfig: GraphFieldConfig = f.config.custom || {}; + + const values = f.values.toArray(); + if (customConfig.transform === GraphTransform.NegativeY) { + result.push(values.map((v) => v * -1)); + } else if (customConfig.transform === GraphTransform.Constant) { + result.push(new Array(values.length).fill(values[0])); + } else { + result.push(values); + } seriesIndex++; } @@ -128,10 +138,15 @@ export function collectStackingGroups(f: Field, groups: Map, s customConfig.stacking?.group && !customConfig.hideFrom?.viz ) { - if (!groups.has(customConfig.stacking.group)) { - groups.set(customConfig.stacking.group, [seriesIdx]); + const group = + customConfig.transform === GraphTransform.NegativeY + ? `${INTERNAL_NEGATIVE_Y_PREFIX}-${customConfig.stacking.group}` + : customConfig.stacking.group; + + if (!groups.has(group)) { + groups.set(group, [seriesIdx]); } else { - groups.set(customConfig.stacking.group, groups.get(customConfig.stacking.group)!.concat(seriesIdx)); + groups.set(group, groups.get(group)!.concat(seriesIdx)); } } } diff --git a/public/app/plugins/panel/timeseries/config.ts b/public/app/plugins/panel/timeseries/config.ts index 073784bf461..dae74c592a2 100644 --- a/public/app/plugins/panel/timeseries/config.ts +++ b/public/app/plugins/panel/timeseries/config.ts @@ -16,6 +16,7 @@ import { VisibilityMode, StackingMode, GraphTresholdsStyleMode, + GraphTransform, } from '@grafana/schema'; import { graphFieldOptions, commonOptionsBuilder } from '@grafana/ui'; @@ -181,6 +182,29 @@ export function getGraphFieldConfig(cfg: GraphFieldConfig): SetFieldConfigOption }); commonOptionsBuilder.addStackingConfig(builder, cfg.stacking, categoryStyles); + + builder.addSelect({ + category: categoryStyles, + name: 'Transform', + path: 'transform', + settings: { + options: [ + { + label: 'Constant', + value: GraphTransform.Constant, + description: 'The first value will be shown as a constant line', + }, + { + label: 'Negative Y', + value: GraphTransform.NegativeY, + description: 'Flip the results to negative values on the y axis', + }, + ], + isClearable: true, + }, + hideFromDefaults: true, + }); + commonOptionsBuilder.addAxisConfig(builder, cfg); commonOptionsBuilder.addHideFrom(builder);