mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MarketTrend: aggressive default field matching (#41574)
This commit is contained in:
parent
7a92faf398
commit
7ffdff9e1f
@ -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",
|
||||||
|
@ -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;
|
||||||
|
@ -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)} />}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
|
121
public/app/plugins/panel/market-trend/fields.test.ts
Normal file
121
public/app/plugins/panel/market-trend/fields.test.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
181
public/app/plugins/panel/market-trend/fields.ts
Normal file
181
public/app/plugins/panel/market-trend/fields.ts
Normal 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;
|
||||||
|
}
|
@ -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 |
@ -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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
@ -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);
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user