From 7ffdff9e1fe59d8e7ccc03ce42634ecf1a0550e0 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 11 Nov 2021 22:04:37 -0800 Subject: [PATCH] MarketTrend: aggressive default field matching (#41574) --- .../panel-market/market-trend.json | 28 +-- .../src/field/overrides/processors.ts | 14 +- .../components/MatchersUI/FieldNamePicker.tsx | 10 +- .../panel/market-trend/MarketTrendPanel.tsx | 66 +++---- .../plugins/panel/market-trend/fields.test.ts | 121 ++++++++++++ .../app/plugins/panel/market-trend/fields.ts | 181 ++++++++++++++++++ .../panel/market-trend/img/candlestick.svg | 65 ++++--- .../plugins/panel/market-trend/models.gen.ts | 27 ++- .../app/plugins/panel/market-trend/module.tsx | 90 ++++++--- 9 files changed, 458 insertions(+), 144 deletions(-) create mode 100644 public/app/plugins/panel/market-trend/fields.test.ts create mode 100644 public/app/plugins/panel/market-trend/fields.ts diff --git a/devenv/dev-dashboards/panel-market/market-trend.json b/devenv/dev-dashboards/panel-market/market-trend.json index 7211d72dcfd..04f976a7a4f 100644 --- a/devenv/dev-dashboards/panel-market/market-trend.json +++ b/devenv/dev-dashboards/panel-market/market-trend.json @@ -149,13 +149,7 @@ "down": "red", "up": "green" }, - "fieldMap": { - "close": "close", - "high": "high", - "low": "low", - "open": "open", - "volume": "volume" - }, + "fields": {}, "legend": { "calcs": [], "displayMode": "list", @@ -238,12 +232,7 @@ "down": "red", "up": "green" }, - "fieldMap": { - "close": "close", - "high": "high", - "low": "low", - "open": "open" - }, + "fields": {}, "legend": { "calcs": [], "displayMode": "list", @@ -342,12 +331,7 @@ "down": "red", "up": "blue" }, - "fieldMap": { - "close": "close", - "high": "high", - "low": "low", - "open": "open" - }, + "fields": { }, "legend": { "calcs": [], "displayMode": "list", @@ -479,11 +463,7 @@ "down": "red", "up": "yellow" }, - "fieldMap": { - "close": "close", - "open": "open", - "volume": "volume" - }, + "fields": {}, "legend": { "calcs": [], "displayMode": "list", diff --git a/packages/grafana-data/src/field/overrides/processors.ts b/packages/grafana-data/src/field/overrides/processors.ts index a5707f6749e..b56f9e2b53e 100644 --- a/packages/grafana-data/src/field/overrides/processors.ts +++ b/packages/grafana-data/src/field/overrides/processors.ts @@ -1,4 +1,3 @@ -import { ComponentType } from 'react'; import { DataLink, Field, @@ -171,11 +170,6 @@ export interface StatsPickerConfigSettings { defaultStat?: string; } -interface FieldNamePickerInfoProps { - name?: string; - field?: Field; -} - export interface FieldNamePickerConfigSettings { /** * Function is a predicate, to test each element of the array. @@ -188,13 +182,7 @@ export interface FieldNamePickerConfigSettings { */ noFieldsMessage?: string; - /** - * When a field is selected, this component can show aditional - * information, including validation etc - */ - info?: ComponentType | null; - - /** + /**addFieldNamePicker * Sets the width to a pixel value. */ width?: number; diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx index a3a28db2c27..da26fd7c0a4 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldNamePicker.tsx @@ -15,11 +15,11 @@ export const FieldNamePicker: React.FC) => { - if (!frameHasName(selection.value, names)) { - return; + (selection?: SelectableValue) => { + if (selection && !frameHasName(selection.value, names)) { + return; // can not select name that does not exist? } - return onChange(selection.value!); + return onChange(selection?.value); }, [names, onChange] ); @@ -35,8 +35,8 @@ export const FieldNamePicker: React.FC - {settings.info && } ); }; diff --git a/public/app/plugins/panel/market-trend/MarketTrendPanel.tsx b/public/app/plugins/panel/market-trend/MarketTrendPanel.tsx index 9c30711318a..cd32f42aa00 100644 --- a/public/app/plugins/panel/market-trend/MarketTrendPanel.tsx +++ b/public/app/plugins/panel/market-trend/MarketTrendPanel.tsx @@ -2,14 +2,13 @@ // with some extra renderers passed to the component import React, { useMemo } from 'react'; -import { DataFrame, Field, getDisplayProcessor, PanelProps } from '@grafana/data'; +import { Field, getDisplayProcessor, PanelProps } from '@grafana/data'; import { TooltipDisplayMode } from '@grafana/schema'; -import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin, UPlotConfigBuilder } from '@grafana/ui'; +import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin, UPlotConfigBuilder, useTheme2 } from '@grafana/ui'; import { getFieldLinksForExplore } from 'app/features/explore/utils/links'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin'; import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin'; -import { prepareGraphableFields } from '../timeseries/utils'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin'; import { config } from 'app/core/config'; @@ -17,22 +16,10 @@ import { drawMarkers, FieldIndices } from './utils'; import { defaultColors, MarketOptions, MarketTrendMode } from './models.gen'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; -import { findField } from 'app/features/dimensions'; +import { prepareCandlestickFields } from './fields'; interface MarketPanelProps extends PanelProps {} -function findFieldInFrames(frames?: DataFrame[], name?: string): Field | undefined { - if (frames?.length) { - for (const frame of frames) { - const f = findField(frame, name); - if (f) { - return f; - } - } - } - return undefined; -} - export const MarketTrendPanel: React.FC = ({ data, timeRange, @@ -50,11 +37,9 @@ export const MarketTrendPanel: React.FC = ({ return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); }; - const { frames, warn } = useMemo( - () => prepareGraphableFields(data?.series, config.theme2), - // eslint-disable-next-line react-hooks/exhaustive-deps - [data, options] - ); + const theme = useTheme2(); + + const info = useMemo(() => prepareCandlestickFields(data?.series, options, theme), [data, options, theme]); const { renderers, tweakScale, tweakAxis } = useMemo(() => { let tweakScale = (opts: ScaleProps) => opts; @@ -66,20 +51,20 @@ export const MarketTrendPanel: React.FC = ({ tweakAxis, }; - if (options.fieldMap == null) { + // Un-encoding the already parsed special fields + // This takes currently matched fields and saves the name so they can be looked up by name later + // ¯\_(ツ)_/¯ someday this can make more sense! + const fieldMap = info.names; + + if (!Object.keys(fieldMap).length) { return doNothing; } - const { mode, priceStyle, fieldMap, colorStrategy } = options; + const { mode, priceStyle, colorStrategy } = options; const colors = { ...defaultColors, ...options.colors }; - let { open, high, low, close, volume } = fieldMap; + let { open, high, low, close, volume } = fieldMap; // names from matched fields - if ( - open == null || - close == null || - findFieldInFrames(frames, open) == null || - findFieldInFrames(frames, close) == null - ) { + if (open == null || close == null) { return doNothing; } @@ -91,7 +76,7 @@ export const MarketTrendPanel: React.FC = ({ // find volume field and set overrides if (volume != null && mode !== MarketTrendMode.Price) { - let volumeField = findFieldInFrames(frames, volume); + let volumeField = info.volume!; if (volumeField != null) { shouldRenderVolume = true; @@ -147,12 +132,7 @@ export const MarketTrendPanel: React.FC = ({ } } - let shouldRenderPrice = - mode !== MarketTrendMode.Volume && - high != null && - low != null && - findFieldInFrames(frames, high) != null && - findFieldInFrames(frames, low) != null; + let shouldRenderPrice = mode !== MarketTrendMode.Volume && high != null && low != null; if (!shouldRenderPrice && !shouldRenderVolume) { return doNothing; @@ -162,11 +142,11 @@ export const MarketTrendPanel: React.FC = ({ let indicesOnly = []; if (shouldRenderPrice) { - fields = { open, high, low, close }; + fields = { open, high: high!, low: low!, close }; // hide series from legend that are rendered as composite markers for (let key in fields) { - let field = findFieldInFrames(frames, fields[key])!; + let field = (info as any)[key] as Field; field.config = { ...field.config, custom: { @@ -183,7 +163,7 @@ export const MarketTrendPanel: React.FC = ({ } if (shouldRenderVolume) { - fields.volume = volume; + fields.volume = volume!; fields.open = open; fields.close = close; } @@ -219,10 +199,10 @@ export const MarketTrendPanel: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [options, data.structureRev]); - if (!frames || warn) { + if (!info.frame || info.warn) { return (
-

{warn ?? 'No data found in response'}

+

{info.warn ?? 'No data found in response'}

); } @@ -231,7 +211,7 @@ export const MarketTrendPanel: React.FC = ({ return ( { + const options: MarketOptions = {} as MarketOptions; + + it('require a time field', () => { + const info = prepareCandlestickFields( + [ + toDataFrame({ + name: 'hello', + columns: ['a', 'b', 'c'], + rows: [ + ['A', 2, 3], + ['B', 4, 5], + ['C', 6, 7], + ], + }), + ], + options, + theme + ); + expect(info.warn).toMatchInlineSnapshot(`"Data does not have a time field"`); + }); + + it('will match common names by default', () => { + const info = prepareCandlestickFields( + [ + toDataFrame({ + fields: [ + { name: 'time', values: [1] }, + { name: 'a', values: [1] }, + { name: 'min', values: [1] }, + { name: 'MAX', values: [1] }, + { name: 'v', values: [1] }, + ], + }), + ], + options, + theme + ); + expect(info.warn).toBeUndefined(); + expect(info.names).toMatchInlineSnapshot(` + Object { + "close": "Next open", + "high": "MAX", + "low": "min", + "open": "a", + "volume": "v", + } + `); + }); + + it('will support simple timeseries (poorly)', () => { + const info = prepareCandlestickFields( + [ + toDataFrame({ + fields: [ + { + name: 'time', + values: [1, 2, 3], + }, + { + name: 'value', + values: [4, 5, 6], + }, + ], + }), + ], + options, + theme + ); + expect(info.open).toBeDefined(); + expect(info.open).toEqual(info.high); + expect(info.open).toEqual(info.low); + expect(info.open).not.toEqual(info.close); + expect(info.names.close).toMatchInlineSnapshot(`"Next open"`); + + // Close should be offset by one and dupliate last point + expect({ open: info.open!.values.toArray(), close: info.close!.values.toArray() }).toMatchInlineSnapshot(` + Object { + "close": Array [ + 5, + 6, + 6, + ], + "open": Array [ + 4, + 5, + 6, + ], + } + `); + }); + + it('will create open from previous close', () => { + const info = prepareCandlestickFields( + [ + toDataFrame({ + fields: [ + { + name: 'time', + values: [1, 2, 3, 4, 5], + }, + { + name: 'close', + values: [1, 2, 3, 4, 5], + }, + ], + }), + ], + options, + theme + ); + expect(info.open!.values.toArray()).toEqual([1, 1, 2, 3, 4]); + expect(info.close!.values.toArray()).toEqual([1, 2, 3, 4, 5]); + }); +}); diff --git a/public/app/plugins/panel/market-trend/fields.ts b/public/app/plugins/panel/market-trend/fields.ts new file mode 100644 index 00000000000..ccad1a4b3ff --- /dev/null +++ b/public/app/plugins/panel/market-trend/fields.ts @@ -0,0 +1,181 @@ +import { + ArrayVector, + DataFrame, + Field, + FieldType, + getFieldDisplayName, + GrafanaTheme2, + outerJoinDataFrames, +} from '@grafana/data'; +import { findField } from 'app/features/dimensions'; +import { prepareGraphableFields } from '../timeseries/utils'; +import { MarketOptions, CandlestickFieldMap } from './models.gen'; + +export interface FieldPickerInfo { + /** property name */ + key: keyof CandlestickFieldMap; + + /** The display name */ + name: string; + + /** by default pick these fields */ + defaults: string[]; + + /** How is the field used */ + description: string; +} + +export const candlestickFieldsInfo: Record = { + open: { + key: 'open', + name: 'Open', + defaults: ['open', 'o'], + description: 'The value at the beginning of the period', + }, + high: { + key: 'high', + name: 'High', + defaults: ['high', 'h', 'max'], + description: 'The maximum value within the period', + }, + low: { + key: 'low', + name: 'Low', + defaults: ['low', 'l', 'min'], + description: 'The minimum value within the period', + }, + close: { + key: 'close', + name: 'Close', + defaults: ['close', 'c'], + description: 'The value at the end of the measured period', + }, + volume: { + key: 'volume', + name: 'Volume', + defaults: ['volume', 'v'], + description: 'Activity within the measured period', + }, +}; + +export interface CandlestickData { + warn?: string; + noTimeField?: boolean; + + // Special fields + open?: Field; + high?: Field; + low?: Field; + close?: Field; + volume?: Field; + + // All incoming values + aligned: DataFrame; + + // The stuff passed to GraphNG + frame: DataFrame; + + // The real names used + names: CandlestickFieldMap; +} + +function findFieldOrAuto(frame: DataFrame, info: FieldPickerInfo, options: CandlestickFieldMap): Field | undefined { + const field = findField(frame, options[info.key]); + if (!field) { + for (const field of frame.fields) { + const name = getFieldDisplayName(field, frame).toLowerCase(); + if (info.defaults.includes(name) || info.defaults.includes(field.name)) { + return field; + } + } + } + return field; +} + +export function prepareCandlestickFields( + series: DataFrame[] | undefined, + options: MarketOptions, + theme: GrafanaTheme2 +): CandlestickData { + if (!series?.length) { + return { warn: 'No data' } as CandlestickData; + } + + // All fields + const fieldMap = options.fields ?? {}; + const aligned = series.length === 1 ? series[0] : outerJoinDataFrames({ frames: series, enforceSort: true }); + if (!aligned?.length) { + return { warn: 'No data found' } as CandlestickData; + } + const data: CandlestickData = { aligned, frame: aligned, names: {} }; + + // Apply same filter as everythign else in timeseries + const norm = prepareGraphableFields([aligned], theme); + if (norm.warn || norm.noTimeField || !norm.frames?.length) { + return norm as CandlestickData; + } + data.frame = norm.frames[0]; + + // Find the known fields + const used = new Set(); + for (const info of Object.values(candlestickFieldsInfo)) { + const field = findFieldOrAuto(data.frame, info, fieldMap); + if (field) { + data[info.key] = field; + used.add(field); + } + } + + // Use first numeric value as open + if (!data.open && !data.close) { + data.open = data.frame.fields.find((f) => f.type === FieldType.number); + if (data.open) { + used.add(data.open); + } + } + + // Use next open as 'close' value + if (data.open && !data.close && !fieldMap.close) { + const values = data.open.values.toArray().slice(1); + values.push(values[values.length - 1]); // duplicate last value + data.close = { + ...data.open, + values: new ArrayVector(values), + name: 'Next open', + state: undefined, + }; + data.frame.fields.push(data.close); + } + + // Use previous close as 'open' value + if (data.close && !data.open && !fieldMap.open) { + const values = data.close.values.toArray().slice(); + values.unshift(values[0]); // duplicate first value + values.length = data.frame.length; + data.open = { + ...data.close, + values: new ArrayVector(values), + name: 'Previous close', + state: undefined, + }; + data.frame.fields.push(data.open); + } + + // Use the open field for min/max if nothing is set + if (!data.high && !fieldMap.high) { + data.high = data.open; + } + if (!data.low && !fieldMap.low) { + data.low = data.open; + } + + // Register the name of each mapped field + for (const info of Object.values(candlestickFieldsInfo)) { + const f = data[info.key]; + if (f) { + data.names[info.key] = getFieldDisplayName(f, data.frame); + } + } + + return data; +} diff --git a/public/app/plugins/panel/market-trend/img/candlestick.svg b/public/app/plugins/panel/market-trend/img/candlestick.svg index e54f1a41373..4f9685afbc3 100644 --- a/public/app/plugins/panel/market-trend/img/candlestick.svg +++ b/public/app/plugins/panel/market-trend/img/candlestick.svg @@ -1,30 +1,39 @@ - - - - - - - + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/app/plugins/panel/market-trend/models.gen.ts b/public/app/plugins/panel/market-trend/models.gen.ts index d872970a99c..bd023392c05 100644 --- a/public/app/plugins/panel/market-trend/models.gen.ts +++ b/public/app/plugins/panel/market-trend/models.gen.ts @@ -3,7 +3,7 @@ // It is currenty hand written but will serve as the target for cuetsy //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -import { TimeSeriesOptions } from '../timeseries/types'; +import { LegendDisplayMode, OptionsWithLegend } from '@grafana/schema'; export const modelVersion = Object.freeze([1, 0]); @@ -27,8 +27,12 @@ export enum ColorStrategy { Inter = 'inter', } -interface SemanticFieldMap { - [semanticName: string]: string; +export interface CandlestickFieldMap { + open?: string; + high?: string; + low?: string; + close?: string; + volume?: string; } export interface MarketTrendColors { @@ -43,10 +47,23 @@ export const defaultColors: MarketTrendColors = { flat: 'gray', }; -export interface MarketOptions extends TimeSeriesOptions { +export interface MarketOptions extends OptionsWithLegend { mode: MarketTrendMode; priceStyle: PriceStyle; colorStrategy: ColorStrategy; - fieldMap: SemanticFieldMap; + fields: CandlestickFieldMap; colors: MarketTrendColors; } + +export const defaultPanelOptions: MarketOptions = { + mode: MarketTrendMode.PriceVolume, + priceStyle: PriceStyle.Candles, + colorStrategy: ColorStrategy.Intra, + colors: defaultColors, + fields: {}, + legend: { + displayMode: LegendDisplayMode.List, + placement: 'bottom', + calcs: [], + }, +}; diff --git a/public/app/plugins/panel/market-trend/module.tsx b/public/app/plugins/panel/market-trend/module.tsx index 492cc0e0aa3..4ce149a0950 100644 --- a/public/app/plugins/panel/market-trend/module.tsx +++ b/public/app/plugins/panel/market-trend/module.tsx @@ -1,9 +1,26 @@ import { GraphFieldConfig } from '@grafana/schema'; -import { FieldConfigProperty, PanelPlugin, SelectableValue } from '@grafana/data'; +import { + Field, + FieldConfigProperty, + FieldType, + getFieldDisplayName, + PanelOptionsEditorBuilder, + PanelPlugin, + SelectableValue, +} from '@grafana/data'; import { commonOptionsBuilder } from '@grafana/ui'; import { MarketTrendPanel } from './MarketTrendPanel'; -import { defaultColors, MarketOptions, MarketTrendMode, ColorStrategy, PriceStyle } from './models.gen'; +import { + defaultColors, + MarketOptions, + MarketTrendMode, + ColorStrategy, + PriceStyle, + defaultPanelOptions, +} from './models.gen'; import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; +import { CandlestickData, candlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields'; +import { config } from '@grafana/runtime'; const modeOptions = [ { label: 'Price & Volume', value: MarketTrendMode.PriceVolume }, @@ -30,9 +47,42 @@ function getMarketFieldConfig() { return v; } +const numericFieldFilter = (f: Field) => f.type === FieldType.number; + +function addFieldPicker( + builder: PanelOptionsEditorBuilder, + info: FieldPickerInfo, + data: CandlestickData +) { + const current = data[info.key] as Field; + let placeholderText = 'Auto '; + if (current?.config) { + placeholderText += '= ' + getFieldDisplayName(current); + + if (current === data?.open && info.key !== 'open') { + placeholderText += ` (${info.defaults.join(',')})`; + } + } else { + placeholderText += `(${info.defaults.join(',')})`; + } + + builder.addFieldNamePicker({ + path: `fields.${info.key}`, + name: info.name, + description: info.description, + settings: { + filter: numericFieldFilter, + placeholderText, + }, + }); +} + export const plugin = new PanelPlugin(MarketTrendPanel) .useFieldConfig(getMarketFieldConfig()) - .setPanelOptions((builder) => { + .setPanelOptions((builder, context) => { + const opts = context.options ?? defaultPanelOptions; + const info = prepareCandlestickFields(context.data, opts, config.theme2); + builder .addRadio({ path: 'mode', @@ -71,31 +121,19 @@ export const plugin = new PanelPlugin(MarketTre path: 'colors.down', name: 'Down color', defaultValue: defaultColors.down, - }) - .addFieldNamePicker({ - path: 'fieldMap.open', - name: 'Open field', - }) - .addFieldNamePicker({ - path: 'fieldMap.high', - name: 'High field', - showIf: (opts) => opts.mode !== MarketTrendMode.Volume, - }) - .addFieldNamePicker({ - path: 'fieldMap.low', - name: 'Low field', - showIf: (opts) => opts.mode !== MarketTrendMode.Volume, - }) - .addFieldNamePicker({ - path: 'fieldMap.close', - name: 'Close field', - }) - .addFieldNamePicker({ - path: 'fieldMap.volume', - name: 'Volume field', - showIf: (opts) => opts.mode !== MarketTrendMode.Price, }); + addFieldPicker(builder, candlestickFieldsInfo.open, info); + if (opts.mode !== MarketTrendMode.Volume) { + addFieldPicker(builder, candlestickFieldsInfo.high, info); + addFieldPicker(builder, candlestickFieldsInfo.low, info); + } + addFieldPicker(builder, candlestickFieldsInfo.close, info); + + if (opts.mode !== MarketTrendMode.Price) { + addFieldPicker(builder, candlestickFieldsInfo.volume, info); + } + // commonOptionsBuilder.addTooltipOptions(builder); commonOptionsBuilder.addLegendOptions(builder); })