grafana/public/app/plugins/panel/candlestick/CandlestickPanel.tsx

330 lines
11 KiB
TypeScript

// this file is pretty much a copy-paste of TimeSeriesPanel.tsx :(
// with some extra renderers passed to the <TimeSeries> component
import React, { useMemo } from 'react';
import uPlot from 'uplot';
import { Field, getDisplayProcessor, getLinksSupplier, PanelProps } from '@grafana/data';
import { PanelDataErrorView } from '@grafana/runtime';
import { TooltipDisplayMode } from '@grafana/schema';
import { TimeSeries, TooltipPlugin, UPlotConfigBuilder, usePanelContext, useTheme2, ZoomPlugin } from '@grafana/ui';
import { AxisProps } from '@grafana/ui/src/components/uPlot/config/UPlotAxisBuilder';
import { ScaleProps } from '@grafana/ui/src/components/uPlot/config/UPlotScaleBuilder';
import { config } from 'app/core/config';
import { AnnotationEditorPlugin } from '../timeseries/plugins/AnnotationEditorPlugin';
import { AnnotationsPlugin } from '../timeseries/plugins/AnnotationsPlugin';
import { ContextMenuPlugin } from '../timeseries/plugins/ContextMenuPlugin';
import { ExemplarsPlugin } from '../timeseries/plugins/ExemplarsPlugin';
import { OutsideRangePlugin } from '../timeseries/plugins/OutsideRangePlugin';
import { ThresholdControlsPlugin } from '../timeseries/plugins/ThresholdControlsPlugin';
import { prepareCandlestickFields } from './fields';
import { Options, defaultCandlestickColors, VizDisplayMode } from './types';
import { drawMarkers, FieldIndices } from './utils';
interface CandlestickPanelProps extends PanelProps<Options> {}
export const CandlestickPanel = ({
data,
id,
timeRange,
timeZone,
width,
height,
options,
fieldConfig,
onChangeTimeRange,
replaceVariables,
}: CandlestickPanelProps) => {
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, showThresholds } = usePanelContext();
const theme = useTheme2();
const info = useMemo(() => {
return prepareCandlestickFields(data.series, options, theme, timeRange);
}, [data.series, options, theme, timeRange]);
const { renderers, tweakScale, tweakAxis, shouldRenderPrice } = useMemo(() => {
let tweakScale = (opts: ScaleProps, forField: Field) => opts;
let tweakAxis = (opts: AxisProps, forField: Field) => opts;
let doNothing = {
renderers: [],
tweakScale,
tweakAxis,
shouldRenderPrice: false,
};
if (!info) {
return doNothing;
}
// 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, candleStyle, colorStrategy } = options;
const colors = { ...defaultCandlestickColors, ...options.colors };
let { open, high, low, close, volume } = fieldMap; // names from matched fields
if (open == null || close == null) {
return doNothing;
}
let volumeAlpha = 0.5;
let volumeIdx = -1;
let shouldRenderVolume = false;
// find volume field and set overrides
if (volume != null && mode !== VizDisplayMode.Candles) {
let volumeField = info.volume!;
if (volumeField != null) {
shouldRenderVolume = true;
let { fillOpacity } = volumeField.config.custom;
if (fillOpacity) {
volumeAlpha = fillOpacity / 100;
}
// we only want to put volume on own shorter axis when rendered with price
if (mode !== VizDisplayMode.Volume) {
volumeField.config = { ...volumeField.config };
volumeField.config.unit = 'short';
volumeField.display = getDisplayProcessor({
field: volumeField,
theme: config.theme2,
});
tweakAxis = (opts: AxisProps, forField: Field) => {
// we can't do forField === info.volume because of copies :(
if (forField.name === info.volume?.name) {
let filter = (u: uPlot, splits: number[]) => {
let _splits = [];
let max = u.series[volumeIdx].max;
for (let i = 0; i < splits.length; i++) {
_splits.push(splits[i]);
if (max && splits[i] > max) {
break;
}
}
return _splits;
};
opts.space = 20; // reduce tick spacing
opts.filter = filter; // hide tick labels
opts.ticks = { ...opts.ticks, filter }; // hide tick marks
}
return opts;
};
tweakScale = (opts: ScaleProps, forField: Field) => {
// we can't do forField === info.volume because of copies :(
if (forField.name === info.volume?.name) {
opts.range = (u: uPlot, min: number, max: number) => [0, max * 7];
}
return opts;
};
}
}
}
let shouldRenderPrice = mode !== VizDisplayMode.Volume && high != null && low != null;
if (!shouldRenderPrice && !shouldRenderVolume) {
return doNothing;
}
let fields: Record<string, string> = {};
let indicesOnly = [];
if (shouldRenderPrice) {
fields = { open, high: high!, low: low!, close };
} else {
// these fields should not be omitted from normal rendering if they arent rendered
// as part of price markers. they're only here so we can get back their indicies in the
// init callback below. TODO: remove this when field mapping happens in the panel instead of deep
indicesOnly.push(open, close);
}
if (shouldRenderVolume) {
fields.volume = volume!;
fields.open = open;
fields.close = close;
}
return {
shouldRenderPrice,
renderers: [
{
fieldMap: fields,
indicesOnly,
init: (builder: UPlotConfigBuilder, fieldIndices: FieldIndices) => {
volumeIdx = fieldIndices.volume!;
builder.addHook(
'drawAxes',
drawMarkers({
mode,
fields: fieldIndices,
upColor: config.theme2.visualization.getColorByName(colors.up),
downColor: config.theme2.visualization.getColorByName(colors.down),
flatColor: config.theme2.visualization.getColorByName(colors.flat),
volumeAlpha,
colorStrategy,
candleStyle,
flatAsUp: true,
})
);
},
},
],
tweakScale,
tweakAxis,
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, data.structureRev, data.series.length]);
if (!info) {
return (
<PanelDataErrorView
panelId={id}
fieldConfig={fieldConfig}
data={data}
needsTimeField={true}
needsNumberField={true}
/>
);
}
if (shouldRenderPrice) {
// hide series from legend that are rendered as composite markers
for (let key in renderers[0].fieldMap) {
let field: Field = (info as any)[key];
field.config = {
...field.config,
custom: {
...field.config.custom,
hideFrom: { legend: true, tooltip: false, viz: false },
},
};
}
}
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations());
return (
<TimeSeries
frames={[info.frame]}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
width={width}
height={height}
legend={options.legend}
renderers={renderers}
tweakAxis={tweakAxis}
tweakScale={tweakScale}
options={options}
>
{(config, alignedDataFrame) => {
alignedDataFrame.fields.forEach((field) => {
field.getLinks = getLinksSupplier(
alignedDataFrame,
field,
field.state!.scopedVars!,
replaceVariables,
timeZone
);
});
return (
<>
<ZoomPlugin config={config} onZoom={onChangeTimeRange} withZoomY={true} />
<TooltipPlugin
data={alignedDataFrame}
config={config}
mode={TooltipDisplayMode.Multi}
sync={sync}
timeZone={timeZone}
/>
{/* Renders annotation markers*/}
{data.annotations && (
<AnnotationsPlugin annotations={data.annotations} config={config} timeZone={timeZone} />
)}
{/* Enables annotations creation*/}
{enableAnnotationCreation ? (
<AnnotationEditorPlugin data={alignedDataFrame} timeZone={timeZone} config={config}>
{({ startAnnotating }) => {
return (
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={
enableAnnotationCreation
? [
{
items: [
{
label: 'Add annotation',
ariaLabel: 'Add annotation',
icon: 'comment-alt',
onClick: (e, p) => {
if (!p) {
return;
}
startAnnotating({ coords: p.coords });
},
},
],
},
]
: []
}
/>
);
}}
</AnnotationEditorPlugin>
) : (
<ContextMenuPlugin
data={alignedDataFrame}
config={config}
timeZone={timeZone}
replaceVariables={replaceVariables}
defaultItems={[]}
/>
)}
{data.annotations && <ExemplarsPlugin config={config} exemplars={data.annotations} timeZone={timeZone} />}
{((canEditThresholds && onThresholdsChange) || showThresholds) && (
<ThresholdControlsPlugin
config={config}
fieldConfig={fieldConfig}
onThresholdsChange={canEditThresholds ? onThresholdsChange : undefined}
/>
)}
<OutsideRangePlugin config={config} onChangeTimeRange={onChangeTimeRange} />
</>
);
}}
</TimeSeries>
);
};