mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
MarketTrend: add new alpha panel (#40909)
This commit is contained in:
parent
af61839a26
commit
f0a108afb3
@ -8,6 +8,7 @@ import (
|
||||
pdashlist "github.com/grafana/grafana/public/app/plugins/panel/dashlist:grafanaschema"
|
||||
pgauge "github.com/grafana/grafana/public/app/plugins/panel/gauge:grafanaschema"
|
||||
phistogram "github.com/grafana/grafana/public/app/plugins/panel/histogram:grafanaschema"
|
||||
pmt "github.com/grafana/grafana/public/app/plugins/panel/market-trend:grafanaschema"
|
||||
pnews "github.com/grafana/grafana/public/app/plugins/panel/news:grafanaschema"
|
||||
pstat "github.com/grafana/grafana/public/app/plugins/panel/stat:grafanaschema"
|
||||
st "github.com/grafana/grafana/public/app/plugins/panel/state-timeline:grafanaschema"
|
||||
@ -31,6 +32,7 @@ Family: dashboard.Family & {
|
||||
dashlist: pdashlist.Panel
|
||||
gauge: pgauge.Panel
|
||||
histogram: phistogram.Panel
|
||||
"market-trend": pmt.Panel
|
||||
news: pnews.Panel
|
||||
stat: pstat.Panel
|
||||
"state-timeline": st.Panel
|
||||
|
@ -18,9 +18,11 @@ import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { throttleTime } from 'rxjs/operators';
|
||||
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { Renderers, UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { ScaleProps } from '../uPlot/config/UPlotScaleBuilder';
|
||||
import { AxisProps } from '../uPlot/config/UPlotAxisBuilder';
|
||||
|
||||
/**
|
||||
* @internal -- not a public API
|
||||
@ -41,12 +43,23 @@ export interface GraphNGProps extends Themeable2 {
|
||||
timeZone: TimeZone;
|
||||
legend: VizLegendOptions;
|
||||
fields?: XYFieldMatchers; // default will assume timeseries data
|
||||
renderers?: Renderers;
|
||||
tweakScale?: (opts: ScaleProps) => ScaleProps;
|
||||
tweakAxis?: (opts: AxisProps) => AxisProps;
|
||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
|
||||
prepConfig: (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => UPlotConfigBuilder;
|
||||
propsToDiff?: Array<string | PropDiffFn>;
|
||||
preparePlotFrame?: (frames: DataFrame[], dimFields: XYFieldMatchers) => DataFrame;
|
||||
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement | null;
|
||||
|
||||
/**
|
||||
* needed for propsToDiff to re-init the plot & config
|
||||
* this is a generic approach to plot re-init, without having to specify which panel-level options
|
||||
* should cause invalidation. we can drop this in favor of something like panelOptionsRev that gets passed in
|
||||
* similar to structureRev. then we can drop propsToDiff entirely.
|
||||
*/
|
||||
options?: Record<string, any>;
|
||||
}
|
||||
|
||||
function sameProps(prevProps: any, nextProps: any, propsToDiff: Array<string | PropDiffFn> = []) {
|
||||
|
@ -4,6 +4,7 @@ exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -30,6 +31,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
|
@ -7,8 +7,9 @@ import { LegendDisplayMode } from '@grafana/schema';
|
||||
import { preparePlotConfigBuilder } from './utils';
|
||||
import { withTheme2 } from '../../themes/ThemeContext';
|
||||
import { PanelContext, PanelContextRoot } from '../PanelChrome/PanelContext';
|
||||
import { PropDiffFn } from '../../../../../packages/grafana-ui/src/components/GraphNG/GraphNG';
|
||||
|
||||
const propsToDiff: string[] = ['legend'];
|
||||
const propsToDiff: Array<string | PropDiffFn> = ['legend', 'options'];
|
||||
|
||||
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>;
|
||||
|
||||
@ -18,7 +19,7 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
|
||||
|
||||
prepConfig = (alignedFrame: DataFrame, allFrames: DataFrame[], getTimeRange: () => TimeRange) => {
|
||||
const { eventBus, sync } = this.context;
|
||||
const { theme, timeZone, legend } = this.props;
|
||||
const { theme, timeZone, legend, renderers, tweakAxis, tweakScale } = this.props;
|
||||
|
||||
return preparePlotConfigBuilder({
|
||||
frame: alignedFrame,
|
||||
@ -29,6 +30,9 @@ export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
|
||||
sync,
|
||||
allFrames,
|
||||
legend,
|
||||
renderers,
|
||||
tweakScale,
|
||||
tweakAxis,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -44,7 +44,10 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
eventBus,
|
||||
sync,
|
||||
allFrames,
|
||||
renderers,
|
||||
legend,
|
||||
tweakScale = (opts) => opts,
|
||||
tweakAxis = (opts) => opts,
|
||||
}) => {
|
||||
const builder = new UPlotConfigBuilder(timeZone);
|
||||
|
||||
@ -105,6 +108,9 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
});
|
||||
}
|
||||
|
||||
let customRenderedFields =
|
||||
renderers?.flatMap((r) => Object.values(r.fieldMap).filter((name) => r.indicesOnly.indexOf(name) === -1)) ?? [];
|
||||
|
||||
const stackingGroups: Map<string, number[]> = new Map();
|
||||
|
||||
let indexByName: Map<string, number> | undefined;
|
||||
@ -120,6 +126,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
if (field === xField || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: skip this for fields with custom renderers?
|
||||
field.state!.seriesIndex = seriesIndex++;
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
@ -129,7 +137,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
const seriesColor = scaleColor.color;
|
||||
|
||||
// The builder will manage unique scaleKeys and combine where appropriate
|
||||
builder.addScale({
|
||||
builder.addScale(
|
||||
tweakScale({
|
||||
scaleKey,
|
||||
orientation: ScaleOrientation.Vertical,
|
||||
direction: ScaleDirection.Up,
|
||||
@ -139,14 +148,16 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
max: field.config.max,
|
||||
softMin: customConfig.axisSoftMin,
|
||||
softMax: customConfig.axisSoftMax,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
if (!yScaleKey) {
|
||||
yScaleKey = scaleKey;
|
||||
}
|
||||
|
||||
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
builder.addAxis({
|
||||
builder.addAxis(
|
||||
tweakAxis({
|
||||
scaleKey,
|
||||
label: customConfig.axisLabel,
|
||||
size: customConfig.axisWidth,
|
||||
@ -154,7 +165,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
formatValue: (v) => formattedValueToString(fmt(v)),
|
||||
theme,
|
||||
grid: { show: customConfig.axisGridShow },
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const showPoints =
|
||||
@ -199,28 +211,43 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
|
||||
let { fillOpacity } = customConfig;
|
||||
|
||||
if (customConfig.fillBelowTo && field.state?.origin) {
|
||||
let pathBuilder: uPlot.Series.PathBuilder | null = null;
|
||||
let pointsBuilder: uPlot.Series.Points.Show | null = null;
|
||||
|
||||
if (field.state?.origin) {
|
||||
if (!indexByName) {
|
||||
indexByName = getNamesToFieldIndex(frame, allFrames);
|
||||
}
|
||||
|
||||
const originFrame = allFrames[field.state.origin.frameIndex];
|
||||
const originField = originFrame.fields[field.state.origin.fieldIndex];
|
||||
const originField = originFrame?.fields[field.state.origin.fieldIndex];
|
||||
|
||||
const t = indexByName.get(getFieldDisplayName(originField, originFrame, allFrames));
|
||||
const dispName = getFieldDisplayName(originField ?? field, originFrame, allFrames);
|
||||
|
||||
// disable default renderers
|
||||
if (customRenderedFields.indexOf(dispName) >= 0) {
|
||||
pathBuilder = () => null;
|
||||
pointsBuilder = () => undefined;
|
||||
}
|
||||
|
||||
if (customConfig.fillBelowTo) {
|
||||
const t = indexByName.get(dispName);
|
||||
const b = indexByName.get(customConfig.fillBelowTo);
|
||||
if (isNumber(b) && isNumber(t)) {
|
||||
builder.addBand({
|
||||
series: [t, b],
|
||||
fill: null as any, // using null will have the band use fill options from `t`
|
||||
fill: undefined, // using null will have the band use fill options from `t`
|
||||
});
|
||||
}
|
||||
if (!fillOpacity) {
|
||||
fillOpacity = 35; // default from flot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
builder.addSeries({
|
||||
pathBuilder,
|
||||
pointsBuilder,
|
||||
scaleKey,
|
||||
showPoints,
|
||||
pointsFilter,
|
||||
@ -279,6 +306,18 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
}
|
||||
}
|
||||
|
||||
// hook up custom/composite renderers
|
||||
renderers?.forEach((r) => {
|
||||
let fieldIndices: Record<string, number> = {};
|
||||
|
||||
for (let key in r.fieldMap) {
|
||||
let dispName = r.fieldMap[key];
|
||||
fieldIndices[key] = indexByName!.get(dispName)!;
|
||||
}
|
||||
|
||||
r.init(builder, fieldIndices);
|
||||
});
|
||||
|
||||
builder.scaleKeys = [xScaleKey, yScaleKey];
|
||||
|
||||
// if hovered value is null, how far we may scan left/right to hover nearest non-null
|
||||
@ -377,18 +416,14 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<{ sync: DashboardCursor
|
||||
|
||||
export function getNamesToFieldIndex(frame: DataFrame, allFrames: DataFrame[]): Map<string, number> {
|
||||
const originNames = new Map<string, number>();
|
||||
for (let i = 0; i < frame.fields.length; i++) {
|
||||
const origin = frame.fields[i].state?.origin;
|
||||
frame.fields.forEach((field, i) => {
|
||||
const origin = field.state?.origin;
|
||||
if (origin) {
|
||||
originNames.set(
|
||||
getFieldDisplayName(
|
||||
allFrames[origin.frameIndex].fields[origin.fieldIndex],
|
||||
allFrames[origin.frameIndex],
|
||||
allFrames
|
||||
),
|
||||
i
|
||||
);
|
||||
const origField = allFrames[origin.frameIndex]?.fields[origin.fieldIndex];
|
||||
if (origField) {
|
||||
originNames.set(getFieldDisplayName(origField, allFrames[origin.frameIndex], allFrames), i);
|
||||
}
|
||||
}
|
||||
});
|
||||
return originNames;
|
||||
}
|
||||
|
@ -15,7 +15,9 @@ export interface AxisProps {
|
||||
valueRotation?: number;
|
||||
placement?: AxisPlacement;
|
||||
grid?: Axis.Grid;
|
||||
ticks?: boolean;
|
||||
ticks?: Axis.Ticks;
|
||||
filter?: Axis.Filter;
|
||||
space?: Axis.Space;
|
||||
formatValue?: (v: any) => string;
|
||||
incrs?: Axis.Incrs;
|
||||
splits?: Axis.Splits;
|
||||
@ -86,7 +88,9 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
show = true,
|
||||
placement = AxisPlacement.Auto,
|
||||
grid = { show: true },
|
||||
ticks = true,
|
||||
ticks,
|
||||
space,
|
||||
filter,
|
||||
gap = 5,
|
||||
formatValue,
|
||||
splits,
|
||||
@ -127,17 +131,23 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
stroke: gridColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
},
|
||||
ticks: {
|
||||
show: ticks,
|
||||
ticks: Object.assign(
|
||||
{
|
||||
show: true,
|
||||
stroke: gridColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
size: 4,
|
||||
},
|
||||
ticks
|
||||
),
|
||||
splits,
|
||||
values: values,
|
||||
space: (self, axisIdx, scaleMin, scaleMax, plotDim) => {
|
||||
space:
|
||||
space ??
|
||||
((self, axisIdx, scaleMin, scaleMax, plotDim) => {
|
||||
return this.calculateSpace(self, axisIdx, scaleMin, scaleMax, plotDim);
|
||||
},
|
||||
}),
|
||||
filter,
|
||||
};
|
||||
|
||||
if (label != null && label.length > 0) {
|
||||
|
@ -349,6 +349,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
|
@ -272,6 +272,12 @@ export class UPlotConfigBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
export type Renderers = Array<{
|
||||
fieldMap: Record<string, string>;
|
||||
indicesOnly: string[];
|
||||
init: (config: UPlotConfigBuilder, fieldIndices: Record<string, number>) => void;
|
||||
}>;
|
||||
|
||||
/** @alpha */
|
||||
type UPlotConfigPrepOpts<T extends Record<string, any> = {}> = {
|
||||
frame: DataFrame;
|
||||
@ -280,6 +286,9 @@ type UPlotConfigPrepOpts<T extends Record<string, any> = {}> = {
|
||||
getTimeRange: () => TimeRange;
|
||||
eventBus: EventBus;
|
||||
allFrames: DataFrame[];
|
||||
renderers?: Renderers;
|
||||
tweakScale?: (opts: ScaleProps) => ScaleProps;
|
||||
tweakAxis?: (opts: AxisProps) => AxisProps;
|
||||
} & T;
|
||||
|
||||
/** @alpha */
|
||||
|
@ -38,9 +38,9 @@ export interface SeriesProps extends LineConfig, BarConfig, FillConfig, PointsCo
|
||||
softMax?: number | null;
|
||||
|
||||
drawStyle?: GraphDrawStyle;
|
||||
pathBuilder?: Series.PathBuilder;
|
||||
pointsFilter?: Series.Points.Filter;
|
||||
pointsBuilder?: Series.Points.Show;
|
||||
pathBuilder?: Series.PathBuilder | null;
|
||||
pointsFilter?: Series.Points.Filter | null;
|
||||
pointsBuilder?: Series.Points.Show | null;
|
||||
show?: boolean;
|
||||
dataFrameFieldIndex?: DataFrameFieldIndex;
|
||||
theme: GrafanaTheme2;
|
||||
|
@ -44,6 +44,7 @@ var skipPaths = []string{
|
||||
"public/app/plugins/panel/gauge/models.cue",
|
||||
"public/app/plugins/panel/histogram/models.cue",
|
||||
"public/app/plugins/panel/stat/models.cue",
|
||||
"public/app/plugins/panel/market-trend/models.cue",
|
||||
"public/app/plugins/panel/state-timeline/models.cue",
|
||||
"public/app/plugins/panel/status-history/models.cue",
|
||||
"public/app/plugins/panel/table/models.cue",
|
||||
|
@ -72,6 +72,7 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
|
||||
"icon": {},
|
||||
"live": {},
|
||||
"logs": {},
|
||||
"market-trend": {},
|
||||
"news": {},
|
||||
"nodeGraph": {},
|
||||
"piechart": {},
|
||||
|
5
pkg/tsdb/grafanads/testdata/list.golden.txt
vendored
5
pkg/tsdb/grafanads/testdata/list.golden.txt
vendored
@ -5,7 +5,7 @@ Frame[0] {
|
||||
"pathSeparator": "/"
|
||||
}
|
||||
Name:
|
||||
Dimensions: 2 Fields by 6 Rows
|
||||
Dimensions: 2 Fields by 7 Rows
|
||||
+--------------------------+------------------+
|
||||
| Name: name | Name: media-type |
|
||||
| Labels: | Labels: |
|
||||
@ -15,10 +15,11 @@ Dimensions: 2 Fields by 6 Rows
|
||||
| flight_info_by_state.csv | |
|
||||
| gdp_per_capita.csv | |
|
||||
| js_libraries.csv | |
|
||||
| ohlc_dogecoin.csv | |
|
||||
| population_by_state.csv | |
|
||||
| weight_height.csv | |
|
||||
+--------------------------+------------------+
|
||||
|
||||
|
||||
====== TEST DATA RESPONSE (arrow base64) ======
|
||||
FRAME=QVJST1cxAAD/////uAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAKQAAAADAAAATAAAACgAAAAEAAAA0P7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADw/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAABD///8IAAAAPAAAADAAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInBhdGhTZXBhcmF0b3IiOiIvIn0AAAAABAAAAG1ldGEAAAAAAgAAAHwAAAAEAAAAnv///xQAAABAAAAAQAAAAAAAAAU8AAAAAQAAAAQAAACM////CAAAABQAAAAKAAAAbWVkaWEtdHlwZQAABAAAAG5hbWUAAAAAAAAAAIj///8KAAAAbWVkaWEtdHlwZQAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABIAAAAAAAABUQAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAG5hbWUAAAAABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAABAAAAG5hbWUAAAAA/////9gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAADAAAAAAAAAABQAAAAAAAADAwAKABgADAAIAAQACgAAABQAAAB4AAAABgAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAACAAAAAAAAAAKAAAAAAAAAAAAAAAAAAAACgAAAAAAAAACAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAACAAAABgAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAFwAAAC8AAABBAAAAUQAAAGgAAAB5AAAAAAAAAGJyb3dzZXJfbWFya2V0c2hhcmUuY3N2ZmxpZ2h0X2luZm9fYnlfc3RhdGUuY3N2Z2RwX3Blcl9jYXBpdGEuY3N2anNfbGlicmFyaWVzLmNzdnBvcHVsYXRpb25fYnlfc3RhdGUuY3N2d2VpZ2h0X2hlaWdodC5jc3YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAADAAUABIADAAIAAQADAAAABAAAAAsAAAAPAAAAAAAAwABAAAAyAEAAAAAAADgAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKAAwAAAAIAAQACgAAAAgAAACkAAAAAwAAAEwAAAAoAAAABAAAAND+//8IAAAADAAAAAAAAAAAAAAABQAAAHJlZklkAAAA8P7//wgAAAAMAAAAAAAAAAAAAAAEAAAAbmFtZQAAAAAQ////CAAAADwAAAAwAAAAeyJ0eXBlIjoiZGlyZWN0b3J5LWxpc3RpbmciLCJwYXRoU2VwYXJhdG9yIjoiLyJ9AAAAAAQAAABtZXRhAAAAAAIAAAB8AAAABAAAAJ7///8UAAAAQAAAAEAAAAAAAAAFPAAAAAEAAAAEAAAAjP///wgAAAAUAAAACgAAAG1lZGlhLXR5cGUAAAQAAABuYW1lAAAAAAAAAACI////CgAAAG1lZGlhLXR5cGUAAAAAEgAYABQAAAATAAwAAAAIAAQAEgAAABQAAABEAAAASAAAAAAAAAVEAAAAAQAAAAwAAAAIAAwACAAEAAgAAAAIAAAAEAAAAAQAAABuYW1lAAAAAAQAAABuYW1lAAAAAAAAAAAEAAQABAAAAAQAAABuYW1lAAAAAOgBAABBUlJPVzE=
|
||||
FRAME=QVJST1cxAAD/////uAEAABAAAAAAAAoADgAMAAsABAAKAAAAFAAAAAAAAAEDAAoADAAAAAgABAAKAAAACAAAAKQAAAADAAAATAAAACgAAAAEAAAA0P7//wgAAAAMAAAAAAAAAAAAAAAFAAAAcmVmSWQAAADw/v//CAAAAAwAAAAAAAAAAAAAAAQAAABuYW1lAAAAABD///8IAAAAPAAAADAAAAB7InR5cGUiOiJkaXJlY3RvcnktbGlzdGluZyIsInBhdGhTZXBhcmF0b3IiOiIvIn0AAAAABAAAAG1ldGEAAAAAAgAAAHwAAAAEAAAAnv///xQAAABAAAAAQAAAAAAAAAU8AAAAAQAAAAQAAACM////CAAAABQAAAAKAAAAbWVkaWEtdHlwZQAABAAAAG5hbWUAAAAAAAAAAIj///8KAAAAbWVkaWEtdHlwZQAAAAASABgAFAAAABMADAAAAAgABAASAAAAFAAAAEQAAABIAAAAAAAABUQAAAABAAAADAAAAAgADAAIAAQACAAAAAgAAAAQAAAABAAAAG5hbWUAAAAABAAAAG5hbWUAAAAAAAAAAAQABAAEAAAABAAAAG5hbWUAAAAA/////9gAAAAUAAAAAAAAAAwAFgAUABMADAAEAAwAAADQAAAAAAAAABQAAAAAAAADAwAKABgADAAIAAQACgAAABQAAAB4AAAABwAAAAAAAAAAAAAABgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAIAAAAAAAAACQAAAAAAAAALAAAAAAAAAAAAAAAAAAAACwAAAAAAAAACAAAAAAAAAA0AAAAAAAAAAAAAAAAAAAAAAAAAACAAAABwAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAFwAAAC8AAABBAAAAUQAAAGIAAAB5AAAAigAAAGJyb3dzZXJfbWFya2V0c2hhcmUuY3N2ZmxpZ2h0X2luZm9fYnlfc3RhdGUuY3N2Z2RwX3Blcl9jYXBpdGEuY3N2anNfbGlicmFyaWVzLmNzdm9obGNfZG9nZWNvaW4uY3N2cG9wdWxhdGlvbl9ieV9zdGF0ZS5jc3Z3ZWlnaHRfaGVpZ2h0LmNzdgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAwAFAASAAwACAAEAAwAAAAQAAAALAAAADwAAAAAAAMAAQAAAMgBAAAAAAAA4AAAAAAAAADQAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAMAAAACAAEAAoAAAAIAAAApAAAAAMAAABMAAAAKAAAAAQAAADQ/v//CAAAAAwAAAAAAAAAAAAAAAUAAAByZWZJZAAAAPD+//8IAAAADAAAAAAAAAAAAAAABAAAAG5hbWUAAAAAEP///wgAAAA8AAAAMAAAAHsidHlwZSI6ImRpcmVjdG9yeS1saXN0aW5nIiwicGF0aFNlcGFyYXRvciI6Ii8ifQAAAAAEAAAAbWV0YQAAAAACAAAAfAAAAAQAAACe////FAAAAEAAAABAAAAAAAAABTwAAAABAAAABAAAAIz///8IAAAAFAAAAAoAAABtZWRpYS10eXBlAAAEAAAAbmFtZQAAAAAAAAAAiP///woAAABtZWRpYS10eXBlAAAAABIAGAAUAAAAEwAMAAAACAAEABIAAAAUAAAARAAAAEgAAAAAAAAFRAAAAAEAAAAMAAAACAAMAAgABAAIAAAACAAAABAAAAAEAAAAbmFtZQAAAAAEAAAAbmFtZQAAAAAAAAAABAAEAAQAAAAEAAAAbmFtZQAAAADoAQAAQVJST1cx
|
||||
|
@ -44,6 +44,7 @@ import * as textPanel from 'app/plugins/panel/text/module';
|
||||
import * as timeseriesPanel from 'app/plugins/panel/timeseries/module';
|
||||
import * as stateTimelinePanel from 'app/plugins/panel/state-timeline/module';
|
||||
import * as statusHistoryPanel from 'app/plugins/panel/status-history/module';
|
||||
import * as marketTrendPanel from 'app/plugins/panel/market-trend/module';
|
||||
import * as graphPanel from 'app/plugins/panel/graph/module';
|
||||
import * as xyChartPanel from 'app/plugins/panel/xychart/module';
|
||||
import * as dashListPanel from 'app/plugins/panel/dashlist/module';
|
||||
@ -99,6 +100,7 @@ const builtInPlugins: any = {
|
||||
'app/plugins/panel/timeseries/module': timeseriesPanel,
|
||||
'app/plugins/panel/state-timeline/module': stateTimelinePanel,
|
||||
'app/plugins/panel/status-history/module': statusHistoryPanel,
|
||||
'app/plugins/panel/market-trend/module': marketTrendPanel,
|
||||
'app/plugins/panel/graph/module': graphPanel,
|
||||
'app/plugins/panel/xychart/module': xyChartPanel,
|
||||
'app/plugins/panel/geomap/module': geomapPanel,
|
||||
|
@ -13,6 +13,7 @@ export const CSVFileEditor = ({ onChange, query }: EditorProps) => {
|
||||
'population_by_state.csv',
|
||||
'gdp_per_capita.csv',
|
||||
'js_libraries.csv',
|
||||
'ohlc_dogecoin.csv',
|
||||
'weight_height.csv',
|
||||
'browser_marketshare.csv',
|
||||
].map((name) => ({ label: name, value: name }));
|
||||
|
@ -4,6 +4,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 1`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -30,6 +31,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -138,6 +140,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 2`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -164,6 +167,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -272,6 +276,7 @@ exports[`BarChart utils preparePlotConfigBuilder orientation 3`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -298,6 +303,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -406,6 +412,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 1`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -432,6 +439,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -540,6 +548,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 2`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -566,6 +575,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -674,6 +684,7 @@ exports[`BarChart utils preparePlotConfigBuilder stacking 3`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -700,6 +711,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -808,6 +820,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 1`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -834,6 +847,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
@ -942,6 +956,7 @@ exports[`BarChart utils preparePlotConfigBuilder value visibility 2`] = `
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 15,
|
||||
"grid": Object {
|
||||
@ -968,6 +983,7 @@ Object {
|
||||
"values": [Function],
|
||||
},
|
||||
Object {
|
||||
"filter": undefined,
|
||||
"font": "12px \\"Roboto\\", \\"Helvetica\\", \\"Arial\\", sans-serif",
|
||||
"gap": 5,
|
||||
"grid": Object {
|
||||
|
@ -121,7 +121,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<BarChartOptions> = ({
|
||||
splits: config.xSplits,
|
||||
values: config.xValues,
|
||||
grid: { show: false },
|
||||
ticks: false,
|
||||
ticks: { show: false },
|
||||
gap: 15,
|
||||
valueRotation: valueRotation * -1,
|
||||
theme,
|
||||
|
316
public/app/plugins/panel/market-trend/MarketTrendPanel.tsx
Normal file
316
public/app/plugins/panel/market-trend/MarketTrendPanel.tsx
Normal file
@ -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>
|
||||
);
|
||||
};
|
30
public/app/plugins/panel/market-trend/img/candlestick.svg
Normal file
30
public/app/plugins/panel/market-trend/img/candlestick.svg
Normal file
@ -0,0 +1,30 @@
|
||||
<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">
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<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"/>
|
||||
<g>
|
||||
<rect x="32.5" y="4.5" style="fill:#98CCFD;" width="6" height="15"/>
|
||||
<g>
|
||||
<path style="fill:#4788C7;" d="M38,5v14h-5V5H38 M39,4h-7v16h7V4L39,4z"/>
|
||||
</g>
|
||||
</g>
|
||||
<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>
|
After Width: | Height: | Size: 1.5 KiB |
29
public/app/plugins/panel/market-trend/models.cue
Normal file
29
public/app/plugins/panel/market-trend/models.cue
Normal file
@ -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: []
|
||||
}
|
52
public/app/plugins/panel/market-trend/models.gen.ts
Normal file
52
public/app/plugins/panel/market-trend/models.gen.ts
Normal file
@ -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;
|
||||
}
|
102
public/app/plugins/panel/market-trend/module.tsx
Normal file
102
public/app/plugins/panel/market-trend/module.tsx
Normal file
@ -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 });
|
17
public/app/plugins/panel/market-trend/plugin.json
Normal file
17
public/app/plugins/panel/market-trend/plugin.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
181
public/app/plugins/panel/market-trend/utils.ts
Normal file
181
public/app/plugins/panel/market-trend/utils.ts
Normal file
@ -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();
|
||||
};
|
||||
}
|
@ -196,7 +196,7 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn<TimelineOptions> = ({
|
||||
splits: coreConfig.ySplits,
|
||||
values: coreConfig.yValues,
|
||||
grid: { show: false },
|
||||
ticks: false,
|
||||
ticks: { show: false },
|
||||
gap: 16,
|
||||
theme,
|
||||
});
|
||||
|
2102
public/testdata/ohlc_dogecoin.csv
vendored
Normal file
2102
public/testdata/ohlc_dogecoin.csv
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,7 @@
|
||||
SED=$(command -v gsed)
|
||||
SED=${SED:-"sed"}
|
||||
|
||||
FILES=$(grep -rl '"schemaVersion": 3[01]' devenv)
|
||||
FILES=$(grep -rl '"schemaVersion": 3[0123]' devenv)
|
||||
set -e
|
||||
set -x
|
||||
for DASH in ${FILES}; do echo "${DASH}"; grep -v 'null,$' "${DASH}" > "${DASH}-nulless"; mv "${DASH}-nulless" "${DASH}"; done
|
||||
|
Loading…
Reference in New Issue
Block a user