mirror of https://github.com/grafana/grafana
MarketTrend: add new alpha panel (#40909)
parent
af61839a26
commit
f0a108afb3
@ -0,0 +1,316 @@ |
|||||||
|
// 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 { DataFrame, Field, getDisplayProcessor, PanelProps } from '@grafana/data'; |
||||||
|
import { TooltipDisplayMode } from '@grafana/schema'; |
||||||
|
import { usePanelContext, TimeSeries, TooltipPlugin, ZoomPlugin, UPlotConfigBuilder } 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'; |
||||||
|
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'; |
||||||
|
|
||||||
|
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> = ({ |
||||||
|
data, |
||||||
|
timeRange, |
||||||
|
timeZone, |
||||||
|
width, |
||||||
|
height, |
||||||
|
options, |
||||||
|
fieldConfig, |
||||||
|
onChangeTimeRange, |
||||||
|
replaceVariables, |
||||||
|
}) => { |
||||||
|
const { sync, canAddAnnotations, onThresholdsChange, canEditThresholds, onSplitOpen } = usePanelContext(); |
||||||
|
|
||||||
|
const getFieldLinks = (field: Field, rowIndex: number) => { |
||||||
|
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 { renderers, tweakScale, tweakAxis } = useMemo(() => { |
||||||
|
let tweakScale = (opts: ScaleProps) => opts; |
||||||
|
let tweakAxis = (opts: AxisProps) => opts; |
||||||
|
|
||||||
|
let doNothing = { |
||||||
|
renderers: [], |
||||||
|
tweakScale, |
||||||
|
tweakAxis, |
||||||
|
}; |
||||||
|
|
||||||
|
if (options.fieldMap == null) { |
||||||
|
return doNothing; |
||||||
|
} |
||||||
|
|
||||||
|
const { mode, priceStyle, fieldMap, colorStrategy } = options; |
||||||
|
const colors = { ...defaultColors, ...options.colors }; |
||||||
|
let { open, high, low, close, volume } = fieldMap; |
||||||
|
|
||||||
|
if ( |
||||||
|
open == null || |
||||||
|
close == null || |
||||||
|
findFieldInFrames(frames, open) == null || |
||||||
|
findFieldInFrames(frames, close) == null |
||||||
|
) { |
||||||
|
return doNothing; |
||||||
|
} |
||||||
|
|
||||||
|
let volumeAlpha = 0.5; |
||||||
|
|
||||||
|
let volumeIdx = -1; |
||||||
|
|
||||||
|
let shouldRenderVolume = false; |
||||||
|
|
||||||
|
// find volume field and set overrides
|
||||||
|
if (volume != null && mode !== MarketTrendMode.Price) { |
||||||
|
let volumeField = findFieldInFrames(frames, 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 !== MarketTrendMode.Volume) { |
||||||
|
volumeField.config = { ...volumeField.config }; |
||||||
|
volumeField.config.unit = 'short'; |
||||||
|
volumeField.display = getDisplayProcessor({ |
||||||
|
field: volumeField, |
||||||
|
theme: config.theme2, |
||||||
|
}); |
||||||
|
|
||||||
|
tweakAxis = (opts: AxisProps) => { |
||||||
|
if (opts.scaleKey === 'short') { |
||||||
|
let filter = (u: uPlot, splits: number[]) => { |
||||||
|
let _splits = []; |
||||||
|
let max = u.series[volumeIdx].max as number; |
||||||
|
|
||||||
|
for (let i = 0; i < splits.length; i++) { |
||||||
|
_splits.push(splits[i]); |
||||||
|
|
||||||
|
if (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) => { |
||||||
|
if (opts.scaleKey === 'short') { |
||||||
|
opts.range = (u: uPlot, min: number, max: number) => [0, max * 7]; |
||||||
|
} |
||||||
|
|
||||||
|
return opts; |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let shouldRenderPrice = |
||||||
|
mode !== MarketTrendMode.Volume && |
||||||
|
high != null && |
||||||
|
low != null && |
||||||
|
findFieldInFrames(frames, high) != null && |
||||||
|
findFieldInFrames(frames, low) != null; |
||||||
|
|
||||||
|
if (!shouldRenderPrice && !shouldRenderVolume) { |
||||||
|
return doNothing; |
||||||
|
} |
||||||
|
|
||||||
|
let fields: Record<string, string> = {}; |
||||||
|
let indicesOnly = []; |
||||||
|
|
||||||
|
if (shouldRenderPrice) { |
||||||
|
fields = { open, high, low, close }; |
||||||
|
|
||||||
|
// hide series from legend that are rendered as composite markers
|
||||||
|
for (let key in fields) { |
||||||
|
let field = findFieldInFrames(frames, fields[key])!; |
||||||
|
field.config = { |
||||||
|
...field.config, |
||||||
|
custom: { |
||||||
|
...field.config.custom, |
||||||
|
hideFrom: { legend: true, tooltip: false, viz: false }, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
} 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 { |
||||||
|
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, |
||||||
|
priceStyle, |
||||||
|
flatAsUp: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
tweakScale, |
||||||
|
tweakAxis, |
||||||
|
}; |
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [options, data.structureRev]); |
||||||
|
|
||||||
|
if (!frames || warn) { |
||||||
|
return ( |
||||||
|
<div className="panel-empty"> |
||||||
|
<p>{warn ?? 'No data found in response'}</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const enableAnnotationCreation = Boolean(canAddAnnotations && canAddAnnotations()); |
||||||
|
|
||||||
|
return ( |
||||||
|
<TimeSeries |
||||||
|
frames={frames} |
||||||
|
structureRev={data.structureRev} |
||||||
|
timeRange={timeRange} |
||||||
|
timeZone={timeZone} |
||||||
|
width={width} |
||||||
|
height={height} |
||||||
|
legend={options.legend} |
||||||
|
renderers={renderers} |
||||||
|
tweakAxis={tweakAxis} |
||||||
|
tweakScale={tweakScale} |
||||||
|
options={options} |
||||||
|
> |
||||||
|
{(config, alignedDataFrame) => { |
||||||
|
return ( |
||||||
|
<> |
||||||
|
<ZoomPlugin config={config} onZoom={onChangeTimeRange} /> |
||||||
|
<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*/} |
||||||
|
<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> |
||||||
|
{data.annotations && ( |
||||||
|
<ExemplarsPlugin |
||||||
|
config={config} |
||||||
|
exemplars={data.annotations} |
||||||
|
timeZone={timeZone} |
||||||
|
getFieldLinks={getFieldLinks} |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
{canEditThresholds && onThresholdsChange && ( |
||||||
|
<ThresholdControlsPlugin |
||||||
|
config={config} |
||||||
|
fieldConfig={fieldConfig} |
||||||
|
onThresholdsChange={onThresholdsChange} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</> |
||||||
|
); |
||||||
|
}} |
||||||
|
</TimeSeries> |
||||||
|
); |
||||||
|
}; |
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,29 @@ |
|||||||
|
// Copyright 2021 Grafana Labs |
||||||
|
// |
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
// you may not use this file except in compliance with the License. |
||||||
|
// You may obtain a copy of the License at |
||||||
|
// |
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0 |
||||||
|
// |
||||||
|
// Unless required by applicable law or agreed to in writing, software |
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
// See the License for the specific language governing permissions and |
||||||
|
// limitations under the License. |
||||||
|
|
||||||
|
package grafanaschema |
||||||
|
|
||||||
|
Panel: { |
||||||
|
lineages: [ |
||||||
|
[ |
||||||
|
{ |
||||||
|
PanelOptions: { |
||||||
|
// anything for now |
||||||
|
... |
||||||
|
} |
||||||
|
} |
||||||
|
] |
||||||
|
] |
||||||
|
migrations: [] |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
// NOTE: This file will be auto generated from models.cue
|
||||||
|
// It is currenty hand written but will serve as the target for cuetsy
|
||||||
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
import { TimeSeriesOptions } from '../timeseries/types'; |
||||||
|
|
||||||
|
export const modelVersion = Object.freeze([1, 0]); |
||||||
|
|
||||||
|
export enum MarketTrendMode { |
||||||
|
Price = 'price', |
||||||
|
Volume = 'volume', |
||||||
|
PriceVolume = 'pricevolume', |
||||||
|
} |
||||||
|
|
||||||
|
export enum PriceStyle { |
||||||
|
Candles = 'candles', |
||||||
|
OHLCBars = 'ohlcbars', |
||||||
|
} |
||||||
|
|
||||||
|
export enum ColorStrategy { |
||||||
|
// up/down color depends on current close vs current open
|
||||||
|
// filled always
|
||||||
|
Intra = 'intra', |
||||||
|
// up/down color depends on current close vs prior close
|
||||||
|
// filled/hollow depends on current close vs current open
|
||||||
|
Inter = 'inter', |
||||||
|
} |
||||||
|
|
||||||
|
interface SemanticFieldMap { |
||||||
|
[semanticName: string]: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface MarketTrendColors { |
||||||
|
up: string; |
||||||
|
down: string; |
||||||
|
flat: string; |
||||||
|
} |
||||||
|
|
||||||
|
export const defaultColors: MarketTrendColors = { |
||||||
|
up: 'green', |
||||||
|
down: 'red', |
||||||
|
flat: 'gray', |
||||||
|
}; |
||||||
|
|
||||||
|
export interface MarketOptions extends TimeSeriesOptions { |
||||||
|
mode: MarketTrendMode; |
||||||
|
priceStyle: PriceStyle; |
||||||
|
colorStrategy: ColorStrategy; |
||||||
|
fieldMap: SemanticFieldMap; |
||||||
|
colors: MarketTrendColors; |
||||||
|
} |
@ -0,0 +1,102 @@ |
|||||||
|
import { GraphFieldConfig } from '@grafana/schema'; |
||||||
|
import { FieldConfigProperty, PanelPlugin, SelectableValue } from '@grafana/data'; |
||||||
|
import { commonOptionsBuilder } from '@grafana/ui'; |
||||||
|
import { MarketTrendPanel } from './MarketTrendPanel'; |
||||||
|
import { defaultColors, MarketOptions, MarketTrendMode, ColorStrategy, PriceStyle } from './models.gen'; |
||||||
|
import { defaultGraphConfig, getGraphFieldConfig } from '../timeseries/config'; |
||||||
|
|
||||||
|
const modeOptions = [ |
||||||
|
{ label: 'Price & Volume', value: MarketTrendMode.PriceVolume }, |
||||||
|
{ label: 'Price', value: MarketTrendMode.Price }, |
||||||
|
{ label: 'Volume', value: MarketTrendMode.Volume }, |
||||||
|
] as Array<SelectableValue<MarketTrendMode>>; |
||||||
|
|
||||||
|
const priceStyle = [ |
||||||
|
{ label: 'Candles', value: PriceStyle.Candles }, |
||||||
|
{ label: 'OHLC Bars', value: PriceStyle.OHLCBars }, |
||||||
|
] as Array<SelectableValue<PriceStyle>>; |
||||||
|
|
||||||
|
const colorStrategy = [ |
||||||
|
{ label: 'Since Open', value: 'intra' }, |
||||||
|
{ label: 'Since Prior Close', value: 'inter' }, |
||||||
|
] as Array<SelectableValue<ColorStrategy>>; |
||||||
|
|
||||||
|
function getMarketFieldConfig() { |
||||||
|
const v = getGraphFieldConfig(defaultGraphConfig); |
||||||
|
v.standardOptions![FieldConfigProperty.Unit] = { |
||||||
|
settings: {}, |
||||||
|
defaultValue: 'currencyUSD', |
||||||
|
}; |
||||||
|
return v; |
||||||
|
} |
||||||
|
|
||||||
|
export const plugin = new PanelPlugin<MarketOptions, GraphFieldConfig>(MarketTrendPanel) |
||||||
|
.useFieldConfig(getMarketFieldConfig()) |
||||||
|
.setPanelOptions((builder) => { |
||||||
|
builder |
||||||
|
.addRadio({ |
||||||
|
path: 'mode', |
||||||
|
name: 'Mode', |
||||||
|
description: '', |
||||||
|
defaultValue: MarketTrendMode.PriceVolume, |
||||||
|
settings: { |
||||||
|
options: modeOptions, |
||||||
|
}, |
||||||
|
}) |
||||||
|
.addRadio({ |
||||||
|
path: 'priceStyle', |
||||||
|
name: 'Price style', |
||||||
|
description: '', |
||||||
|
defaultValue: PriceStyle.Candles, |
||||||
|
settings: { |
||||||
|
options: priceStyle, |
||||||
|
}, |
||||||
|
showIf: (opts) => opts.mode !== MarketTrendMode.Volume, |
||||||
|
}) |
||||||
|
.addRadio({ |
||||||
|
path: 'colorStrategy', |
||||||
|
name: 'Color strategy', |
||||||
|
description: '', |
||||||
|
defaultValue: ColorStrategy.Intra, |
||||||
|
settings: { |
||||||
|
options: colorStrategy, |
||||||
|
}, |
||||||
|
}) |
||||||
|
.addColorPicker({ |
||||||
|
path: 'colors.up', |
||||||
|
name: 'Up color', |
||||||
|
defaultValue: defaultColors.up, |
||||||
|
}) |
||||||
|
.addColorPicker({ |
||||||
|
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, |
||||||
|
}); |
||||||
|
|
||||||
|
// commonOptionsBuilder.addTooltipOptions(builder);
|
||||||
|
commonOptionsBuilder.addLegendOptions(builder); |
||||||
|
}) |
||||||
|
.setDataSupport({ annotations: true, alertStates: true }); |
@ -0,0 +1,17 @@ |
|||||||
|
{ |
||||||
|
"type": "panel", |
||||||
|
"name": "Market trend", |
||||||
|
"id": "market-trend", |
||||||
|
"state": "alpha", |
||||||
|
|
||||||
|
"info": { |
||||||
|
"author": { |
||||||
|
"name": "Grafana Labs", |
||||||
|
"url": "https://grafana.com" |
||||||
|
}, |
||||||
|
"logos": { |
||||||
|
"small": "img/candlestick.svg", |
||||||
|
"large": "img/candlestick.svg" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,181 @@ |
|||||||
|
import { MarketTrendMode, ColorStrategy, PriceStyle } from './models.gen'; |
||||||
|
import uPlot from 'uplot'; |
||||||
|
import { colorManipulator } from '@grafana/data'; |
||||||
|
|
||||||
|
const { alpha } = colorManipulator; |
||||||
|
|
||||||
|
export type FieldIndices = Record<string, number>; |
||||||
|
|
||||||
|
interface RendererOpts { |
||||||
|
mode: MarketTrendMode; |
||||||
|
priceStyle: PriceStyle; |
||||||
|
fields: FieldIndices; |
||||||
|
colorStrategy: ColorStrategy; |
||||||
|
upColor: string; |
||||||
|
downColor: string; |
||||||
|
flatColor: string; |
||||||
|
volumeAlpha: number; |
||||||
|
flatAsUp: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function drawMarkers(opts: RendererOpts) { |
||||||
|
let { mode, priceStyle, fields, colorStrategy, upColor, downColor, flatColor, volumeAlpha, flatAsUp = true } = opts; |
||||||
|
|
||||||
|
let drawPrice = mode !== MarketTrendMode.Volume && fields.high != null && fields.low != null; |
||||||
|
let asCandles = drawPrice && priceStyle === PriceStyle.Candles; |
||||||
|
let drawVolume = mode !== MarketTrendMode.Price && fields.volume != null; |
||||||
|
|
||||||
|
function selectPath(priceDir: number, flatPath: Path2D, upPath: Path2D, downPath: Path2D, flatAsUp: boolean) { |
||||||
|
return priceDir > 0 ? upPath : priceDir < 0 ? downPath : flatAsUp ? upPath : flatPath; |
||||||
|
} |
||||||
|
|
||||||
|
let tIdx = 0, |
||||||
|
oIdx = fields.open, |
||||||
|
hIdx = fields.high, |
||||||
|
lIdx = fields.low, |
||||||
|
cIdx = fields.close, |
||||||
|
vIdx = fields.volume; |
||||||
|
|
||||||
|
return (u: uPlot) => { |
||||||
|
// split by discrete color to reduce draw calls
|
||||||
|
let downPath, upPath, flatPath; |
||||||
|
// with adjusted reduced
|
||||||
|
let downPathVol, upPathVol, flatPathVol; |
||||||
|
|
||||||
|
if (drawPrice) { |
||||||
|
flatPath = new Path2D(); |
||||||
|
upPath = new Path2D(); |
||||||
|
downPath = new Path2D(); |
||||||
|
} |
||||||
|
|
||||||
|
if (drawVolume) { |
||||||
|
downPathVol = new Path2D(); |
||||||
|
upPathVol = new Path2D(); |
||||||
|
flatPathVol = new Path2D(); |
||||||
|
} |
||||||
|
|
||||||
|
let hollowPath = new Path2D(); |
||||||
|
|
||||||
|
let ctx = u.ctx; |
||||||
|
|
||||||
|
let tData = u.data[tIdx!]; |
||||||
|
|
||||||
|
let oData = u.data[oIdx!]; |
||||||
|
let cData = u.data[cIdx!]; |
||||||
|
|
||||||
|
let hData = drawPrice ? u.data[hIdx!] : null; |
||||||
|
let lData = drawPrice ? u.data[lIdx!] : null; |
||||||
|
let vData = drawVolume ? u.data[vIdx!] : null; |
||||||
|
|
||||||
|
let zeroPx = vIdx != null ? Math.round(u.valToPos(0, u.series[vIdx!].scale!, true)) : null; |
||||||
|
|
||||||
|
let [idx0, idx1] = u.series[0].idxs!; |
||||||
|
|
||||||
|
let colWidth = u.bbox.width / (idx1 - idx0); |
||||||
|
let barWidth = Math.round(0.6 * colWidth); |
||||||
|
|
||||||
|
let stickWidth = 2; |
||||||
|
let outlineWidth = 2; |
||||||
|
|
||||||
|
if (barWidth <= 12) { |
||||||
|
stickWidth = outlineWidth = 1; |
||||||
|
} |
||||||
|
|
||||||
|
let halfWidth = Math.floor(barWidth / 2); |
||||||
|
|
||||||
|
for (let i = idx0; i <= idx1; i++) { |
||||||
|
let tPx = Math.round(u.valToPos(tData[i]!, 'x', true)); |
||||||
|
|
||||||
|
// current close vs prior close
|
||||||
|
let interDir = i === idx0 ? 0 : Math.sign(cData[i]! - cData[i - 1]!); |
||||||
|
// current close vs current open
|
||||||
|
let intraDir = Math.sign(cData[i]! - oData[i]!); |
||||||
|
|
||||||
|
// volume
|
||||||
|
if (drawVolume) { |
||||||
|
let outerPath = selectPath( |
||||||
|
colorStrategy === ColorStrategy.Inter ? interDir : intraDir, |
||||||
|
flatPathVol as Path2D, |
||||||
|
upPathVol as Path2D, |
||||||
|
downPathVol as Path2D, |
||||||
|
i === idx0 && ColorStrategy.Inter ? false : flatAsUp |
||||||
|
); |
||||||
|
|
||||||
|
let vPx = Math.round(u.valToPos(vData![i]!, u.series[vIdx!].scale!, true)); |
||||||
|
outerPath.rect(tPx - halfWidth, vPx, barWidth, zeroPx! - vPx); |
||||||
|
} |
||||||
|
|
||||||
|
if (drawPrice) { |
||||||
|
let outerPath = selectPath( |
||||||
|
colorStrategy === ColorStrategy.Inter ? interDir : intraDir, |
||||||
|
flatPath as Path2D, |
||||||
|
upPath as Path2D, |
||||||
|
downPath as Path2D, |
||||||
|
i === idx0 && ColorStrategy.Inter ? false : flatAsUp |
||||||
|
); |
||||||
|
|
||||||
|
// stick
|
||||||
|
let hPx = Math.round(u.valToPos(hData![i]!, u.series[hIdx!].scale!, true)); |
||||||
|
let lPx = Math.round(u.valToPos(lData![i]!, u.series[lIdx!].scale!, true)); |
||||||
|
outerPath.rect(tPx - Math.floor(stickWidth / 2), hPx, stickWidth, lPx - hPx); |
||||||
|
|
||||||
|
let oPx = Math.round(u.valToPos(oData[i]!, u.series[oIdx!].scale!, true)); |
||||||
|
let cPx = Math.round(u.valToPos(cData[i]!, u.series[cIdx!].scale!, true)); |
||||||
|
|
||||||
|
if (asCandles) { |
||||||
|
// rect
|
||||||
|
let top = Math.min(oPx, cPx); |
||||||
|
let btm = Math.max(oPx, cPx); |
||||||
|
let hgt = Math.max(1, btm - top); |
||||||
|
outerPath.rect(tPx - halfWidth, top, barWidth, hgt); |
||||||
|
|
||||||
|
if (colorStrategy === ColorStrategy.Inter) { |
||||||
|
if (intraDir >= 0 && hgt > outlineWidth * 2) { |
||||||
|
hollowPath.rect( |
||||||
|
tPx - halfWidth + outlineWidth, |
||||||
|
top + outlineWidth, |
||||||
|
barWidth - outlineWidth * 2, |
||||||
|
hgt - outlineWidth * 2 |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
outerPath.rect(tPx - halfWidth, oPx, halfWidth, stickWidth); |
||||||
|
outerPath.rect(tPx, cPx, halfWidth, stickWidth); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
ctx.save(); |
||||||
|
|
||||||
|
ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height); |
||||||
|
ctx.clip(); |
||||||
|
|
||||||
|
if (drawVolume) { |
||||||
|
ctx.fillStyle = alpha(upColor, volumeAlpha); |
||||||
|
ctx.fill(upPathVol as Path2D); |
||||||
|
|
||||||
|
ctx.fillStyle = alpha(downColor, volumeAlpha); |
||||||
|
ctx.fill(downPathVol as Path2D); |
||||||
|
|
||||||
|
ctx.fillStyle = alpha(flatColor, volumeAlpha); |
||||||
|
ctx.fill(flatPathVol as Path2D); |
||||||
|
} |
||||||
|
|
||||||
|
if (drawPrice) { |
||||||
|
ctx.fillStyle = upColor; |
||||||
|
ctx.fill(upPath as Path2D); |
||||||
|
|
||||||
|
ctx.fillStyle = downColor; |
||||||
|
ctx.fill(downPath as Path2D); |
||||||
|
|
||||||
|
ctx.fillStyle = flatColor; |
||||||
|
ctx.fill(flatPath as Path2D); |
||||||
|
|
||||||
|
ctx.globalCompositeOperation = 'destination-out'; |
||||||
|
ctx.fill(hollowPath); |
||||||
|
} |
||||||
|
|
||||||
|
ctx.restore(); |
||||||
|
}; |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue