MarketTrend: aggressive default field matching (#41574)

This commit is contained in:
Ryan McKinley 2021-11-11 22:04:37 -08:00 committed by GitHub
parent 7a92faf398
commit 7ffdff9e1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 458 additions and 144 deletions

View File

@ -149,13 +149,7 @@
"down": "red", "down": "red",
"up": "green" "up": "green"
}, },
"fieldMap": { "fields": {},
"close": "close",
"high": "high",
"low": "low",
"open": "open",
"volume": "volume"
},
"legend": { "legend": {
"calcs": [], "calcs": [],
"displayMode": "list", "displayMode": "list",
@ -238,12 +232,7 @@
"down": "red", "down": "red",
"up": "green" "up": "green"
}, },
"fieldMap": { "fields": {},
"close": "close",
"high": "high",
"low": "low",
"open": "open"
},
"legend": { "legend": {
"calcs": [], "calcs": [],
"displayMode": "list", "displayMode": "list",
@ -342,12 +331,7 @@
"down": "red", "down": "red",
"up": "blue" "up": "blue"
}, },
"fieldMap": { "fields": { },
"close": "close",
"high": "high",
"low": "low",
"open": "open"
},
"legend": { "legend": {
"calcs": [], "calcs": [],
"displayMode": "list", "displayMode": "list",
@ -479,11 +463,7 @@
"down": "red", "down": "red",
"up": "yellow" "up": "yellow"
}, },
"fieldMap": { "fields": {},
"close": "close",
"open": "open",
"volume": "volume"
},
"legend": { "legend": {
"calcs": [], "calcs": [],
"displayMode": "list", "displayMode": "list",

View File

@ -1,4 +1,3 @@
import { ComponentType } from 'react';
import { import {
DataLink, DataLink,
Field, Field,
@ -171,11 +170,6 @@ export interface StatsPickerConfigSettings {
defaultStat?: string; defaultStat?: string;
} }
interface FieldNamePickerInfoProps {
name?: string;
field?: Field;
}
export interface FieldNamePickerConfigSettings { export interface FieldNamePickerConfigSettings {
/** /**
* Function is a predicate, to test each element of the array. * Function is a predicate, to test each element of the array.
@ -188,13 +182,7 @@ export interface FieldNamePickerConfigSettings {
*/ */
noFieldsMessage?: string; noFieldsMessage?: string;
/** /**addFieldNamePicker
* When a field is selected, this component can show aditional
* information, including validation etc
*/
info?: ComponentType<FieldNamePickerInfoProps> | null;
/**
* Sets the width to a pixel value. * Sets the width to a pixel value.
*/ */
width?: number; width?: number;

View File

@ -15,11 +15,11 @@ export const FieldNamePicker: React.FC<StandardEditorProps<string, FieldNamePick
const selectOptions = useSelectOptions(names, value); const selectOptions = useSelectOptions(names, value);
const onSelectChange = useCallback( const onSelectChange = useCallback(
(selection: SelectableValue<string>) => { (selection?: SelectableValue<string>) => {
if (!frameHasName(selection.value, names)) { if (selection && !frameHasName(selection.value, names)) {
return; return; // can not select name that does not exist?
} }
return onChange(selection.value!); return onChange(selection?.value);
}, },
[names, onChange] [names, onChange]
); );
@ -35,8 +35,8 @@ export const FieldNamePicker: React.FC<StandardEditorProps<string, FieldNamePick
onChange={onSelectChange} onChange={onSelectChange}
noOptionsMessage={settings.noFieldsMessage} noOptionsMessage={settings.noFieldsMessage}
width={settings.width} width={settings.width}
isClearable={true}
/> />
{settings.info && <settings.info name={value} field={names.fields.get(value)} />}
</> </>
); );
}; };

View File

@ -2,14 +2,13 @@
// with some extra renderers passed to the <TimeSeries> component // with some extra renderers passed to the <TimeSeries> component
import React, { useMemo } from 'react'; 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 { 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 { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin'; import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin'; import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin'; import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin';
import { prepareGraphableFields } from '../timeseries/utils';
import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin'; import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin';
import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin'; import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
@ -17,22 +16,10 @@ import { drawMarkers, FieldIndices } from './utils';
import { defaultColors, MarketOptions, MarketTrendMode } from './models.gen'; import { defaultColors, MarketOptions, MarketTrendMode } from './models.gen';
import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder'; import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder';
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder'; import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { findField } from 'app/features/dimensions'; import { prepareCandlestickFields } from './fields';
interface MarketPanelProps extends PanelProps<MarketOptions> {} interface MarketPanelProps extends PanelProps<MarketOptions> {}
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<MarketPanelProps> = ({ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
data, data,
timeRange, timeRange,
@ -50,11 +37,9 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange }); return getFieldLinksForExplore({ field, rowIndex, splitOpenFn: onSplitOpen, range: timeRange });
}; };
const { frames, warn } = useMemo( const theme = useTheme2();
() => prepareGraphableFields(data?.series, config.theme2),
// eslint-disable-next-line react-hooks/exhaustive-deps const info = useMemo(() => prepareCandlestickFields(data?.series, options, theme), [data, options, theme]);
[data, options]
);
const { renderers, tweakScale, tweakAxis } = useMemo(() => { const { renderers, tweakScale, tweakAxis } = useMemo(() => {
let tweakScale = (opts: ScaleProps) => opts; let tweakScale = (opts: ScaleProps) => opts;
@ -66,20 +51,20 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
tweakAxis, 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; return doNothing;
} }
const { mode, priceStyle, fieldMap, colorStrategy } = options; const { mode, priceStyle, colorStrategy } = options;
const colors = { ...defaultColors, ...options.colors }; const colors = { ...defaultColors, ...options.colors };
let { open, high, low, close, volume } = fieldMap; let { open, high, low, close, volume } = fieldMap; // names from matched fields
if ( if (open == null || close == null) {
open == null ||
close == null ||
findFieldInFrames(frames, open) == null ||
findFieldInFrames(frames, close) == null
) {
return doNothing; return doNothing;
} }
@ -91,7 +76,7 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
// find volume field and set overrides // find volume field and set overrides
if (volume != null && mode !== MarketTrendMode.Price) { if (volume != null && mode !== MarketTrendMode.Price) {
let volumeField = findFieldInFrames(frames, volume); let volumeField = info.volume!;
if (volumeField != null) { if (volumeField != null) {
shouldRenderVolume = true; shouldRenderVolume = true;
@ -147,12 +132,7 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
} }
} }
let shouldRenderPrice = let shouldRenderPrice = mode !== MarketTrendMode.Volume && high != null && low != null;
mode !== MarketTrendMode.Volume &&
high != null &&
low != null &&
findFieldInFrames(frames, high) != null &&
findFieldInFrames(frames, low) != null;
if (!shouldRenderPrice && !shouldRenderVolume) { if (!shouldRenderPrice && !shouldRenderVolume) {
return doNothing; return doNothing;
@ -162,11 +142,11 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
let indicesOnly = []; let indicesOnly = [];
if (shouldRenderPrice) { if (shouldRenderPrice) {
fields = { open, high, low, close }; fields = { open, high: high!, low: low!, close };
// hide series from legend that are rendered as composite markers // hide series from legend that are rendered as composite markers
for (let key in fields) { for (let key in fields) {
let field = findFieldInFrames(frames, fields[key])!; let field = (info as any)[key] as Field;
field.config = { field.config = {
...field.config, ...field.config,
custom: { custom: {
@ -183,7 +163,7 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
} }
if (shouldRenderVolume) { if (shouldRenderVolume) {
fields.volume = volume; fields.volume = volume!;
fields.open = open; fields.open = open;
fields.close = close; fields.close = close;
} }
@ -219,10 +199,10 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, data.structureRev]); }, [options, data.structureRev]);
if (!frames || warn) { if (!info.frame || info.warn) {
return ( return (
<div className="panel-empty"> <div className="panel-empty">
<p>{warn ?? 'No data found in response'}</p> <p>{info.warn ?? 'No data found in response'}</p>
</div> </div>
); );
} }
@ -231,7 +211,7 @@ export const MarketTrendPanel: React.FC<MarketPanelProps> = ({
return ( return (
<TimeSeries <TimeSeries
frames={frames} frames={[info.frame]}
structureRev={data.structureRev} structureRev={data.structureRev}
timeRange={timeRange} timeRange={timeRange}
timeZone={timeZone} timeZone={timeZone}

View File

@ -0,0 +1,121 @@
import { createTheme, toDataFrame } from '@grafana/data';
import { prepareCandlestickFields } from './fields';
import { MarketOptions } from './models.gen';
const theme = createTheme();
describe('Candlestick data', () => {
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]);
});
});

View File

@ -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<keyof CandlestickFieldMap, FieldPickerInfo> = {
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<Field>();
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;
}

View File

@ -1,30 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" viewBox="0 0 40 40" style="enable-background:new 0 0 40 40;" xml:space="preserve"> <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" x="0px" y="0px" viewBox="0 0 40 40" style="enable-background:new 0 0 40 40;" xml:space="preserve">
<path style="fill:#4788C7;" d="M35.5,23L35.5,23c-0.275,0-0.5-0.225-0.5-0.5v-21C35,1.225,35.225,1,35.5,1l0,0 C35.775,1,36,1.225,36,1.5v21C36,22.775,35.775,23,35.5,23z"/> <defs>
<path style="fill:#4788C7;" d="M14.5,30L14.5,30c-0.275,0-0.5-0.225-0.5-0.5v-23C14,6.225,14.225,6,14.5,6h0 C14.775,6,15,6.225,15,6.5v23C15,29.775,14.775,30,14.5,30z"/> <style>.cls-1{fill:#84aff1;}.cls-2{fill:url(#linear-gradient);}.cls-3{fill:#3865ab;}</style>
<path style="fill:#4788C7;" d="M25.5,35L25.5,35c-0.275,0-0.5-0.225-0.5-0.5v-17c0-0.275,0.225-0.5,0.5-0.5l0,0 c0.275,0,0.5,0.225,0.5,0.5v17C26,34.775,25.775,35,25.5,35z"/> <linearGradient id="linear-gradient" y1="40.18" x2="82.99" y2="40.18" gradientUnits="userSpaceOnUse">
<path style="fill:#4788C7;" d="M4.5,39L4.5,39C4.225,39,4,38.775,4,38.5v-19C4,19.225,4.225,19,4.5,19h0C4.775,19,5,19.225,5,19.5 v19C5,38.775,4.775,39,4.5,39z"/> <stop offset="0" stop-color="#f2cc0c" />
<g> <stop offset="1" stop-color="#ff9830" />
<rect x="32.5" y="4.5" style="fill:#98CCFD;" width="6" height="15"/> </linearGradient>
</defs>
<g> <g>
<path style="fill:#4788C7;" d="M38,5v14h-5V5H38 M39,4h-7v16h7V4L39,4z"/> <path class="cls-3" d="M35.5,23L35.5,23c-0.275,0-0.5-0.225-0.5-0.5v-21C35,1.225,35.225,1,35.5,1l0,0 C35.775,1,36,1.225,36,1.5v21C36,22.775,35.775,23,35.5,23z" />
<path class="cls-3" d="M14.5,30L14.5,30c-0.275,0-0.5-0.225-0.5-0.5v-23C14,6.225,14.225,6,14.5,6h0 C14.775,6,15,6.225,15,6.5v23C15,29.775,14.775,30,14.5,30z" />
<path class="cls-3" d="M25.5,35L25.5,35c-0.275,0-0.5-0.225-0.5-0.5v-17c0-0.275,0.225-0.5,0.5-0.5l0,0 c0.275,0,0.5,0.225,0.5,0.5v17C26,34.775,25.775,35,25.5,35z" />
<path class="cls-3" d="M4.5,39L4.5,39C4.225,39,4,38.775,4,38.5v-19C4,19.225,4.225,19,4.5,19h0C4.775,19,5,19.225,5,19.5 v19C5,38.775,4.775,39,4.5,39z" />
<g>
<rect x="32.5" y="4.5" class="cls-1" width="6" height="15" />
<g>
<path class="cls-3" d="M38,5v14h-5V5H38 M39,4h-7v16h7V4L39,4z" />
</g>
</g>
<g>
<rect x="11.5" y="9.5" class="cls-1" width="6" height="17" />
<g>
<path class="cls-3" d="M17,10v16h-5V10H17 M18,9h-7v18h7V9L18,9z" />
</g>
</g>
<g>
<rect x="22.5" y="20.5" class="cls-1" width="6" height="11" />
<g>
<path class="cls-3" d="M28,21v10h-5V21H28 M29,20h-7v12h7V20L29,20z" />
</g>
</g>
<g>
<rect x="1.5" y="22.5" class="cls-1" width="6" height="13" />
<g>
<path class="cls-3" d="M7,23v12H2V23H7 M8,22H1v14h7V22L8,22z" />
</g>
</g>
</g> </g>
</g> </svg>
<g>
<rect x="11.5" y="9.5" style="fill:#98CCFD;" width="6" height="17"/>
<g>
<path style="fill:#4788C7;" d="M17,10v16h-5V10H17 M18,9h-7v18h7V9L18,9z"/>
</g>
</g>
<g>
<rect x="22.5" y="20.5" style="fill:#DFF0FE;" width="6" height="11"/>
<g>
<path style="fill:#4788C7;" d="M28,21v10h-5V21H28 M29,20h-7v12h7V20L29,20z"/>
</g>
</g>
<g>
<rect x="1.5" y="22.5" style="fill:#DFF0FE;" width="6" height="13"/>
<g>
<path style="fill:#4788C7;" d="M7,23v12H2V23H7 M8,22H1v14h7V22L8,22z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -3,7 +3,7 @@
// It is currenty hand written but will serve as the target for cuetsy // 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]); export const modelVersion = Object.freeze([1, 0]);
@ -27,8 +27,12 @@ export enum ColorStrategy {
Inter = 'inter', Inter = 'inter',
} }
interface SemanticFieldMap { export interface CandlestickFieldMap {
[semanticName: string]: string; open?: string;
high?: string;
low?: string;
close?: string;
volume?: string;
} }
export interface MarketTrendColors { export interface MarketTrendColors {
@ -43,10 +47,23 @@ export const defaultColors: MarketTrendColors = {
flat: 'gray', flat: 'gray',
}; };
export interface MarketOptions extends TimeSeriesOptions { export interface MarketOptions extends OptionsWithLegend {
mode: MarketTrendMode; mode: MarketTrendMode;
priceStyle: PriceStyle; priceStyle: PriceStyle;
colorStrategy: ColorStrategy; colorStrategy: ColorStrategy;
fieldMap: SemanticFieldMap; fields: CandlestickFieldMap;
colors: MarketTrendColors; 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: [],
},
};

View File

@ -1,9 +1,26 @@
import { GraphFieldConfig } from '@grafana/schema'; 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 { commonOptionsBuilder } from '@grafana/ui';
import { MarketTrendPanel } from './MarketTrendPanel'; 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 { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config';
import { CandlestickData, candlestickFieldsInfo, FieldPickerInfo, prepareCandlestickFields } from './fields';
import { config } from '@grafana/runtime';
const modeOptions = [ const modeOptions = [
{ label: 'Price & Volume', value: MarketTrendMode.PriceVolume }, { label: 'Price & Volume', value: MarketTrendMode.PriceVolume },
@ -30,9 +47,42 @@ function getMarketFieldConfig() {
return v; return v;
} }
const numericFieldFilter = (f: Field) => f.type === FieldType.number;
function addFieldPicker(
builder: PanelOptionsEditorBuilder<MarketOptions>,
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<MarketOptions, GraphFieldConfig>(MarketTrendPanel) export const plugin = new PanelPlugin<MarketOptions, GraphFieldConfig>(MarketTrendPanel)
.useFieldConfig(getMarketFieldConfig()) .useFieldConfig(getMarketFieldConfig())
.setPanelOptions((builder) => { .setPanelOptions((builder, context) => {
const opts = context.options ?? defaultPanelOptions;
const info = prepareCandlestickFields(context.data, opts, config.theme2);
builder builder
.addRadio({ .addRadio({
path: 'mode', path: 'mode',
@ -71,31 +121,19 @@ export const plugin = new PanelPlugin<MarketOptions, GraphFieldConfig>(MarketTre
path: 'colors.down', path: 'colors.down',
name: 'Down color', name: 'Down color',
defaultValue: defaultColors.down, 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.addTooltipOptions(builder);
commonOptionsBuilder.addLegendOptions(builder); commonOptionsBuilder.addLegendOptions(builder);
}) })