Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
(cherry picked from commit 4c8c2f6c96)
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
This commit is contained in:
co-authored by
Ryan McKinley
parent
c6fb06721f
commit
e69544b6ea
@@ -115,7 +115,7 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
|||||||
|
|
||||||
state = {
|
state = {
|
||||||
alignedFrame,
|
alignedFrame,
|
||||||
alignedData: config!.prepData!(alignedFrame),
|
alignedData: config!.prepData!([alignedFrame]) as AlignedData,
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
|||||||
|
|
||||||
if (shouldReconfig) {
|
if (shouldReconfig) {
|
||||||
newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange);
|
newState.config = this.props.prepConfig(newState.alignedFrame, this.props.frames, this.getTimeRange);
|
||||||
newState.alignedData = newState.config.prepData!(newState.alignedFrame);
|
newState.alignedData = newState.config.prepData!([newState.alignedFrame]) as AlignedData;
|
||||||
pluginLog('GraphNG', false, 'config recreated', newState.config);
|
pluginLog('GraphNG', false, 'config recreated', newState.config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ Object {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"__fixed": Object {
|
"__fixed": Object {
|
||||||
@@ -127,6 +128,7 @@ Object {
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
],
|
],
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -149,6 +151,7 @@ Object {
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
],
|
],
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -171,6 +174,7 @@ Object {
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
],
|
],
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -193,6 +197,7 @@ Object {
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
],
|
],
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -215,6 +220,7 @@ Object {
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
],
|
],
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
|||||||
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
|
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
data: preparePlotData(alignedDataFrame),
|
data: preparePlotData([alignedDataFrame]),
|
||||||
alignedDataFrame,
|
alignedDataFrame,
|
||||||
configBuilder: this.prepareConfig(alignedDataFrame),
|
configBuilder: this.prepareConfig(alignedDataFrame),
|
||||||
};
|
};
|
||||||
@@ -64,7 +64,7 @@ export class Sparkline extends PureComponent<SparklineProps, State> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
data: preparePlotData(frame),
|
data: preparePlotData([frame]),
|
||||||
alignedDataFrame: frame,
|
alignedDataFrame: frame,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,12 +246,14 @@ export { LegacyForms, LegacyInputStatus };
|
|||||||
|
|
||||||
// WIP, need renames and exports cleanup
|
// WIP, need renames and exports cleanup
|
||||||
export * from './uPlot/config';
|
export * from './uPlot/config';
|
||||||
export { UPlotConfigBuilder, UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder';
|
export { ScaleDistribution } from '@grafana/schema';
|
||||||
|
export { UPlotConfigBuilder } from './uPlot/config/UPlotConfigBuilder';
|
||||||
export { UPlotChart } from './uPlot/Plot';
|
export { UPlotChart } from './uPlot/Plot';
|
||||||
export { PlotLegend } from './uPlot/PlotLegend';
|
export { PlotLegend } from './uPlot/PlotLegend';
|
||||||
export * from './uPlot/geometries';
|
export * from './uPlot/geometries';
|
||||||
export * from './uPlot/plugins';
|
export * from './uPlot/plugins';
|
||||||
export { PlotTooltipInterpolator, PlotSelection } from './uPlot/types';
|
export { PlotTooltipInterpolator, PlotSelection } from './uPlot/types';
|
||||||
|
export { UPlotConfigPrepFn } from './uPlot/config/UPlotConfigBuilder';
|
||||||
export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG';
|
export { GraphNG, GraphNGProps, FIXED_UNIT } from './GraphNG/GraphNG';
|
||||||
export { TimeSeries } from './TimeSeries/TimeSeries';
|
export { TimeSeries } from './TimeSeries/TimeSeries';
|
||||||
export { useGraphNGContext } from './GraphNG/hooks';
|
export { useGraphNGContext } from './GraphNG/hooks';
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const mockData = () => {
|
|||||||
|
|
||||||
const config = new UPlotConfigBuilder();
|
const config = new UPlotConfigBuilder();
|
||||||
config.addSeries({} as SeriesProps);
|
config.addSeries({} as SeriesProps);
|
||||||
return { data, timeRange, config };
|
return { data: [data], timeRange, config };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('UPlotChart', () => {
|
describe('UPlotChart', () => {
|
||||||
@@ -104,7 +104,7 @@ describe('UPlotChart', () => {
|
|||||||
|
|
||||||
expect(uPlot).toBeCalledTimes(1);
|
expect(uPlot).toBeCalledTimes(1);
|
||||||
|
|
||||||
data.fields[1].values.set(0, 1);
|
data[0].fields[1].values.set(0, 1);
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<UPlotChart
|
<UPlotChart
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { createRef } from 'react';
|
import React, { createRef } from 'react';
|
||||||
import uPlot, { Options } from 'uplot';
|
import uPlot, { AlignedData, Options } from 'uplot';
|
||||||
import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils';
|
import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils';
|
||||||
import { PlotProps } from './types';
|
import { PlotProps } from './types';
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ export class UPlotChart extends React.Component<PlotProps, UPlotChartState> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pluginLog('UPlot', false, 'Reinitializing plot', config);
|
pluginLog('UPlot', false, 'Reinitializing plot', config);
|
||||||
const plot = new uPlot(config, this.props.data, this.plotContainer!.current!);
|
const plot = new uPlot(config, this.props.data as AlignedData, this.plotContainer!.current!);
|
||||||
|
|
||||||
if (plotRef) {
|
if (plotRef) {
|
||||||
plotRef(plot);
|
plotRef(plot);
|
||||||
@@ -100,12 +100,12 @@ export class UPlotChart extends React.Component<PlotProps, UPlotChartState> {
|
|||||||
} else if (!sameConfig(prevProps, this.props)) {
|
} else if (!sameConfig(prevProps, this.props)) {
|
||||||
this.reinitPlot();
|
this.reinitPlot();
|
||||||
} else if (!sameData(prevProps, this.props)) {
|
} else if (!sameData(prevProps, this.props)) {
|
||||||
plot?.setData(this.props.data);
|
plot?.setData(this.props.data as AlignedData);
|
||||||
|
|
||||||
// this is a uPlot cache-busting hack for bar charts in case x axis labels changed
|
// this is a uPlot cache-busting hack for bar charts in case x axis labels changed
|
||||||
// since the x scale's "range" doesnt change, the axis size doesnt get recomputed, which is where the tick labels are regenerated & cached
|
// since the x scale's "range" doesnt change, the axis size doesnt get recomputed, which is where the tick labels are regenerated & cached
|
||||||
// the more expensive, more proper/thorough way to do this is to force all axes to recalc: plot?.redraw(false, true);
|
// the more expensive, more proper/thorough way to do this is to force all axes to recalc: plot?.redraw(false, true);
|
||||||
if (plot && typeof this.props.data[0][0] === 'string') {
|
if (plot && typeof this.props.data[0]?.[0] === 'string') {
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
plot.axes[0]._values = this.props.data[0];
|
plot.axes[0]._values = this.props.data[0];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export interface AxisProps {
|
|||||||
formatValue?: (v: any) => string;
|
formatValue?: (v: any) => string;
|
||||||
incrs?: Axis.Incrs;
|
incrs?: Axis.Incrs;
|
||||||
splits?: Axis.Splits;
|
splits?: Axis.Splits;
|
||||||
values?: any;
|
values?: Axis.Values;
|
||||||
isTime?: boolean;
|
isTime?: boolean;
|
||||||
timeZone?: TimeZone;
|
timeZone?: TimeZone;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
@@ -87,6 +88,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-x": Object {
|
"scale-x": Object {
|
||||||
@@ -166,6 +168,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-y": Object {
|
"scale-y": Object {
|
||||||
@@ -218,6 +221,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-y": Object {
|
"scale-y": Object {
|
||||||
@@ -271,6 +275,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"scale-y": Object {
|
"scale-y": Object {
|
||||||
@@ -387,6 +392,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
@@ -505,6 +511,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
@@ -513,6 +520,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -619,6 +627,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"hooks": Object {},
|
"hooks": Object {},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {},
|
"scales": Object {},
|
||||||
"select": undefined,
|
"select": undefined,
|
||||||
@@ -627,6 +636,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -644,6 +654,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
"width": 1,
|
"width": 1,
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -661,6 +672,7 @@ describe('UPlotConfigBuilder', () => {
|
|||||||
"width": 1,
|
"width": 1,
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding } from 'uplot';
|
import uPlot, { Cursor, Band, Hooks, Select, AlignedData, Padding, Series } from 'uplot';
|
||||||
import { merge } from 'lodash';
|
import { merge } from 'lodash';
|
||||||
import {
|
import {
|
||||||
DataFrame,
|
DataFrame,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
TimeRange,
|
TimeRange,
|
||||||
TimeZone,
|
TimeZone,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { PlotConfig, PlotTooltipInterpolator } from '../types';
|
import { FacetedData, PlotConfig, PlotTooltipInterpolator } from '../types';
|
||||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||||
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||||
@@ -31,7 +31,7 @@ const cursorDefaults: Cursor = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type PrepData = (frame: DataFrame) => AlignedData;
|
type PrepData = (frames: DataFrame[]) => AlignedData | FacetedData;
|
||||||
|
|
||||||
export class UPlotConfigBuilder {
|
export class UPlotConfigBuilder {
|
||||||
private series: UPlotSeriesBuilder[] = [];
|
private series: UPlotSeriesBuilder[] = [];
|
||||||
@@ -45,7 +45,8 @@ export class UPlotConfigBuilder {
|
|||||||
private hooks: Hooks.Arrays = {};
|
private hooks: Hooks.Arrays = {};
|
||||||
private tz: string | undefined = undefined;
|
private tz: string | undefined = undefined;
|
||||||
private sync = false;
|
private sync = false;
|
||||||
private frame: DataFrame | undefined = undefined;
|
private mode: uPlot.Mode = 1;
|
||||||
|
private frames: DataFrame[] | undefined = undefined;
|
||||||
// to prevent more than one threshold per scale
|
// to prevent more than one threshold per scale
|
||||||
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
private thresholds: Record<string, UPlotThresholdOptions> = {};
|
||||||
// Custom handler for closest datapoint and series lookup
|
// Custom handler for closest datapoint and series lookup
|
||||||
@@ -112,6 +113,10 @@ export class UPlotConfigBuilder {
|
|||||||
this.cursor = merge({}, this.cursor, cursor);
|
this.cursor = merge({}, this.cursor, cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMode(mode: uPlot.Mode) {
|
||||||
|
this.mode = mode;
|
||||||
|
}
|
||||||
|
|
||||||
setSelect(select: Select) {
|
setSelect(select: Select) {
|
||||||
this.select = select;
|
this.select = select;
|
||||||
}
|
}
|
||||||
@@ -151,9 +156,9 @@ export class UPlotConfigBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setPrepData(prepData: PrepData) {
|
setPrepData(prepData: PrepData) {
|
||||||
this.prepData = (frame) => {
|
this.prepData = (frames) => {
|
||||||
this.frame = frame;
|
this.frames = frames;
|
||||||
return prepData(frame);
|
return prepData(frames);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,10 +176,13 @@ export class UPlotConfigBuilder {
|
|||||||
|
|
||||||
getConfig() {
|
getConfig() {
|
||||||
const config: PlotConfig = {
|
const config: PlotConfig = {
|
||||||
|
mode: this.mode,
|
||||||
series: [
|
series: [
|
||||||
{
|
this.mode === 2
|
||||||
value: () => '',
|
? ((null as unknown) as Series)
|
||||||
},
|
: {
|
||||||
|
value: () => '',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
config.axes = this.ensureNonOverlappingAxes(Object.values(this.axes)).map((a) => a.getConfig());
|
config.axes = this.ensureNonOverlappingAxes(Object.values(this.axes)).map((a) => a.getConfig());
|
||||||
@@ -193,19 +201,24 @@ export class UPlotConfigBuilder {
|
|||||||
|
|
||||||
// interpolate for gradients/thresholds
|
// interpolate for gradients/thresholds
|
||||||
if (typeof s !== 'string') {
|
if (typeof s !== 'string') {
|
||||||
let field = this.frame!.fields[seriesIdx];
|
let field = this.frames![0].fields[seriesIdx];
|
||||||
s = field.display!(field.values.get(u.cursor.idxs![seriesIdx]!)).color!;
|
s = field.display!(field.values.get(u.cursor.idxs![seriesIdx]!)).color!;
|
||||||
}
|
}
|
||||||
|
|
||||||
return s + alphaHex;
|
return s + alphaHex;
|
||||||
};
|
};
|
||||||
|
|
||||||
config.cursor = merge({}, cursorDefaults, this.cursor, {
|
config.cursor = merge(
|
||||||
points: {
|
{},
|
||||||
stroke: pointColorFn('80'),
|
cursorDefaults,
|
||||||
fill: pointColorFn(),
|
{
|
||||||
|
points: {
|
||||||
|
stroke: pointColorFn('80'),
|
||||||
|
fill: pointColorFn(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
this.cursor
|
||||||
|
);
|
||||||
|
|
||||||
config.tzDate = this.tzDate;
|
config.tzDate = this.tzDate;
|
||||||
config.padding = this.padding;
|
config.padding = this.padding;
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface SeriesProps extends LineConfig, BarConfig, FillConfig, PointsCo
|
|||||||
pxAlign?: boolean;
|
pxAlign?: boolean;
|
||||||
gradientMode?: GraphGradientMode;
|
gradientMode?: GraphGradientMode;
|
||||||
|
|
||||||
|
facets?: uPlot.Series.Facet[];
|
||||||
|
|
||||||
/** Used when gradientMode is set to Scheme */
|
/** Used when gradientMode is set to Scheme */
|
||||||
thresholds?: ThresholdsConfig;
|
thresholds?: ThresholdsConfig;
|
||||||
colorMode?: FieldColorMode;
|
colorMode?: FieldColorMode;
|
||||||
@@ -48,6 +50,7 @@ export interface SeriesProps extends LineConfig, BarConfig, FillConfig, PointsCo
|
|||||||
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||||
getConfig() {
|
getConfig() {
|
||||||
const {
|
const {
|
||||||
|
facets,
|
||||||
drawStyle,
|
drawStyle,
|
||||||
pathBuilder,
|
pathBuilder,
|
||||||
pointsBuilder,
|
pointsBuilder,
|
||||||
@@ -132,6 +135,7 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
scale: scaleKey,
|
scale: scaleKey,
|
||||||
|
facets,
|
||||||
spanGaps: typeof spanNulls === 'number' ? false : spanNulls,
|
spanGaps: typeof spanNulls === 'number' ? false : spanNulls,
|
||||||
value: () => '',
|
value: () => '',
|
||||||
pxAlign,
|
pxAlign,
|
||||||
|
|||||||
@@ -5,15 +5,19 @@ import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
|||||||
|
|
||||||
export type PlotConfig = Pick<
|
export type PlotConfig = Pick<
|
||||||
Options,
|
Options,
|
||||||
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding'
|
'mode' | 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate' | 'padding'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface PlotPluginProps {
|
export interface PlotPluginProps {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FacetValues = any[];
|
||||||
|
export type FacetSeries = FacetValues[];
|
||||||
|
export type FacetedData = [_: null, ...series: FacetSeries];
|
||||||
|
|
||||||
export interface PlotProps {
|
export interface PlotProps {
|
||||||
data: AlignedData;
|
data: AlignedData | FacetedData;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
config: UPlotConfigBuilder;
|
config: UPlotConfigBuilder;
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ describe('preparePlotData', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates array from DataFrame', () => {
|
it('creates array from DataFrame', () => {
|
||||||
expect(preparePlotData(df)).toMatchInlineSnapshot(`
|
expect(preparePlotData([df])).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Array [
|
Array [
|
||||||
9997,
|
9997,
|
||||||
@@ -75,7 +75,7 @@ describe('preparePlotData', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(preparePlotData(df)).toMatchInlineSnapshot(`
|
expect(preparePlotData([df])).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Array [
|
Array [
|
||||||
9997,
|
9997,
|
||||||
@@ -122,7 +122,7 @@ describe('preparePlotData', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(preparePlotData(df)).toMatchInlineSnapshot(`
|
expect(preparePlotData([df])).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Array [
|
Array [
|
||||||
9997,
|
9997,
|
||||||
@@ -185,7 +185,7 @@ describe('preparePlotData', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(preparePlotData(df)).toMatchInlineSnapshot(`
|
expect(preparePlotData([df])).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Array [
|
Array [
|
||||||
9997,
|
9997,
|
||||||
@@ -260,7 +260,7 @@ describe('preparePlotData', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(preparePlotData(df)).toMatchInlineSnapshot(`
|
expect(preparePlotData([df])).toMatchInlineSnapshot(`
|
||||||
Array [
|
Array [
|
||||||
Array [
|
Array [
|
||||||
9997,
|
9997,
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ interface StackMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
export function preparePlotData(frame: DataFrame, onStackMeta?: (meta: StackMeta) => void): AlignedData {
|
export function preparePlotData(frames: DataFrame[], onStackMeta?: (meta: StackMeta) => void): AlignedData {
|
||||||
|
const frame = frames[0];
|
||||||
const result: any[] = [];
|
const result: any[] = [];
|
||||||
const stackingGroups: Map<string, number[]> = new Map();
|
const stackingGroups: Map<string, number[]> = new Map();
|
||||||
let seriesIndex = 0;
|
let seriesIndex = 0;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DataFrame, getFieldColorModeForField, getScaleCalculator, GrafanaTheme2 } from '@grafana/data';
|
import { DataFrame, Field, getFieldColorModeForField, getScaleCalculator, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { ColorDimensionConfig, DimensionSupplier } from './types';
|
import { ColorDimensionConfig, DimensionSupplier } from './types';
|
||||||
import { findField, getLastNotNullFieldValue } from './utils';
|
import { findField, getLastNotNullFieldValue } from './utils';
|
||||||
|
|
||||||
@@ -11,7 +11,14 @@ export function getColorDimension(
|
|||||||
config: ColorDimensionConfig,
|
config: ColorDimensionConfig,
|
||||||
theme: GrafanaTheme2
|
theme: GrafanaTheme2
|
||||||
): DimensionSupplier<string> {
|
): DimensionSupplier<string> {
|
||||||
const field = findField(frame, config.field);
|
return getColorDimensionForField(findField(frame, config.field), config, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorDimensionForField(
|
||||||
|
field: Field | undefined,
|
||||||
|
config: ColorDimensionConfig,
|
||||||
|
theme: GrafanaTheme2
|
||||||
|
): DimensionSupplier<string> {
|
||||||
if (!field) {
|
if (!field) {
|
||||||
const v = theme.visualization.getColorByName(config.fixed) ?? 'grey';
|
const v = theme.visualization.getColorByName(config.fixed) ?? 'grey';
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const fixedColorOption: SelectableValue<string> = {
|
|||||||
export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig, any, any>> = (props) => {
|
export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig, any, any>> = (props) => {
|
||||||
const { value, context, onChange } = props;
|
const { value, context, onChange } = props;
|
||||||
|
|
||||||
|
const defaultColor = 'dark-green';
|
||||||
|
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
const fieldName = value?.field;
|
const fieldName = value?.field;
|
||||||
const isFixed = Boolean(!fieldName);
|
const isFixed = Boolean(!fieldName);
|
||||||
@@ -31,7 +33,7 @@ export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig,
|
|||||||
field,
|
field,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const fixed = value.fixed ?? 'grey';
|
const fixed = value.fixed ?? defaultColor;
|
||||||
onChange({
|
onChange({
|
||||||
...value,
|
...value,
|
||||||
field: undefined,
|
field: undefined,
|
||||||
@@ -46,7 +48,7 @@ export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig,
|
|||||||
(c: string) => {
|
(c: string) => {
|
||||||
onChange({
|
onChange({
|
||||||
field: undefined,
|
field: undefined,
|
||||||
fixed: c ?? 'grey',
|
fixed: c ?? defaultColor,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange]
|
||||||
@@ -65,7 +67,7 @@ export const ColorDimensionEditor: FC<StandardEditorProps<ColorDimensionConfig,
|
|||||||
/>
|
/>
|
||||||
{isFixed && (
|
{isFixed && (
|
||||||
<div className={styles.picker}>
|
<div className={styles.picker}>
|
||||||
<ColorPicker color={value?.fixed ?? 'grey'} onChange={onColorChange} enableNamedColors={true} />
|
<ColorPicker color={value?.fixed ?? defaultColor} onChange={onColorChange} enableNamedColors={true} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DataFrame } from '@grafana/data';
|
import { DataFrame, Field } from '@grafana/data';
|
||||||
|
import { ScaleDimensionMode } from '.';
|
||||||
import { getMinMaxAndDelta } from '../../../../packages/grafana-data/src/field/scale';
|
import { getMinMaxAndDelta } from '../../../../packages/grafana-data/src/field/scale';
|
||||||
import { ScaleDimensionConfig, DimensionSupplier, ScaleDimensionOptions } from './types';
|
import { ScaleDimensionConfig, DimensionSupplier, ScaleDimensionOptions } from './types';
|
||||||
import { findField, getLastNotNullFieldValue } from './utils';
|
import { findField, getLastNotNullFieldValue } from './utils';
|
||||||
@@ -11,7 +12,14 @@ export function getScaledDimension(
|
|||||||
frame: DataFrame | undefined,
|
frame: DataFrame | undefined,
|
||||||
config: ScaleDimensionConfig
|
config: ScaleDimensionConfig
|
||||||
): DimensionSupplier<number> {
|
): DimensionSupplier<number> {
|
||||||
const field = findField(frame, config.field);
|
return getScaledDimensionForField(findField(frame, config?.field), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getScaledDimensionForField(
|
||||||
|
field: Field | undefined,
|
||||||
|
config: ScaleDimensionConfig,
|
||||||
|
mode?: ScaleDimensionMode
|
||||||
|
): DimensionSupplier<number> {
|
||||||
if (!field) {
|
if (!field) {
|
||||||
const v = config.fixed ?? 0;
|
const v = config.fixed ?? 0;
|
||||||
return {
|
return {
|
||||||
@@ -32,6 +40,19 @@ export function getScaledDimension(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let scaled = (percent: number) => config.min + percent * delta;
|
||||||
|
if (mode === ScaleDimensionMode.Quadratic) {
|
||||||
|
const maxArea = Math.PI * (config.max / 2) ** 2;
|
||||||
|
const minArea = Math.PI * (config.min / 2) ** 2;
|
||||||
|
const deltaArea = maxArea - minArea;
|
||||||
|
|
||||||
|
// quadratic scaling (px area)
|
||||||
|
scaled = (percent: number) => {
|
||||||
|
let area = minArea + deltaArea * percent;
|
||||||
|
return Math.sqrt(area / Math.PI) * 2;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const get = (i: number) => {
|
const get = (i: number) => {
|
||||||
const value = field.values.get(i);
|
const value = field.values.get(i);
|
||||||
let percent = 0;
|
let percent = 0;
|
||||||
@@ -43,7 +64,7 @@ export function getScaledDimension(
|
|||||||
} else if (percent < 0) {
|
} else if (percent < 0) {
|
||||||
percent = 0;
|
percent = 0;
|
||||||
}
|
}
|
||||||
return config.min + percent * delta;
|
return scaled(percent);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DataFrame, formattedValueToString } from '@grafana/data';
|
import { DataFrame, Field, formattedValueToString } from '@grafana/data';
|
||||||
import { DimensionSupplier, TextDimensionConfig, TextDimensionMode } from './types';
|
import { DimensionSupplier, TextDimensionConfig, TextDimensionMode } from './types';
|
||||||
import { findField, getLastNotNullFieldValue } from './utils';
|
import { findField, getLastNotNullFieldValue } from './utils';
|
||||||
|
|
||||||
@@ -7,6 +7,13 @@ import { findField, getLastNotNullFieldValue } from './utils';
|
|||||||
//---------------------------------------------------------
|
//---------------------------------------------------------
|
||||||
|
|
||||||
export function getTextDimension(frame: DataFrame | undefined, config: TextDimensionConfig): DimensionSupplier<string> {
|
export function getTextDimension(frame: DataFrame | undefined, config: TextDimensionConfig): DimensionSupplier<string> {
|
||||||
|
return getTextDimensionForField(findField(frame, config.field), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextDimensionForField(
|
||||||
|
field: Field | undefined,
|
||||||
|
config: TextDimensionConfig
|
||||||
|
): DimensionSupplier<string> {
|
||||||
let v = config.fixed;
|
let v = config.fixed;
|
||||||
const mode = config.mode ?? TextDimensionMode.Fixed;
|
const mode = config.mode ?? TextDimensionMode.Fixed;
|
||||||
if (mode === TextDimensionMode.Fixed) {
|
if (mode === TextDimensionMode.Fixed) {
|
||||||
@@ -18,7 +25,6 @@ export function getTextDimension(frame: DataFrame | undefined, config: TextDimen
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const field = findField(frame, config.field);
|
|
||||||
if (mode === TextDimensionMode.Template) {
|
if (mode === TextDimensionMode.Template) {
|
||||||
const disp = (v: any) => {
|
const disp = (v: any) => {
|
||||||
return `TEMPLATE[${config.fixed} // ${v}]`;
|
return `TEMPLATE[${config.fixed} // ${v}]`;
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ export interface DimensionSupplier<T = any> {
|
|||||||
get: (index: number) => T;
|
get: (index: number) => T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ScaleDimensionMode {
|
||||||
|
Linear = 'linear',
|
||||||
|
Quadratic = 'quad',
|
||||||
|
}
|
||||||
|
|
||||||
/** This will map the field value% to a scaled value within the range */
|
/** This will map the field value% to a scaled value within the range */
|
||||||
export interface ScaleDimensionConfig extends BaseDimensionConfig<number> {
|
export interface ScaleDimensionConfig extends BaseDimensionConfig<number> {
|
||||||
min: number;
|
min: number;
|
||||||
|
|||||||
@@ -89,6 +89,24 @@ export function findField(frame?: DataFrame, name?: string): Field | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function findFieldIndex(frame?: DataFrame, name?: string): number | undefined {
|
||||||
|
if (!frame || !name?.length) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < frame.fields.length; i++) {
|
||||||
|
const field = frame.fields[i];
|
||||||
|
if (name === field.name) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
const disp = getFieldDisplayName(field, frame);
|
||||||
|
if (name === disp) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function getLastNotNullFieldValue<T>(field: Field): T {
|
export function getLastNotNullFieldValue<T>(field: Field): T {
|
||||||
const calcs = field.state?.calcs;
|
const calcs = field.state?.calcs;
|
||||||
if (calcs) {
|
if (calcs) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -109,6 +110,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -212,6 +214,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -239,6 +242,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -342,6 +346,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -369,6 +374,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -472,6 +478,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -499,6 +506,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -602,6 +610,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -629,6 +638,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -732,6 +742,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -759,6 +770,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -862,6 +874,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -889,6 +902,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
@@ -992,6 +1006,7 @@ Object {
|
|||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"mode": 1,
|
||||||
"padding": undefined,
|
"padding": undefined,
|
||||||
"scales": Object {
|
"scales": Object {
|
||||||
"m/s": Object {
|
"m/s": Object {
|
||||||
@@ -1019,6 +1034,7 @@ Object {
|
|||||||
"value": [Function],
|
"value": [Function],
|
||||||
},
|
},
|
||||||
Object {
|
Object {
|
||||||
|
"facets": undefined,
|
||||||
"fill": [Function],
|
"fill": [Function],
|
||||||
"paths": [Function],
|
"paths": [Function],
|
||||||
"points": Object {
|
"points": Object {
|
||||||
|
|||||||
@@ -308,10 +308,10 @@ export function getConfig(opts: BarsOptions, theme: GrafanaTheme2) {
|
|||||||
|
|
||||||
let alignedTotals: AlignedData | null = null;
|
let alignedTotals: AlignedData | null = null;
|
||||||
|
|
||||||
function prepData(alignedFrame: DataFrame) {
|
function prepData(frames: DataFrame[]) {
|
||||||
alignedTotals = null;
|
alignedTotals = null;
|
||||||
|
|
||||||
return preparePlotData(alignedFrame, ({ totals }) => {
|
return preparePlotData(frames, ({ totals }) => {
|
||||||
alignedTotals = totals;
|
alignedTotals = totals;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { StandardEditorProps } from '@grafana/data';
|
||||||
|
|
||||||
|
import { XYChartOptions, ScatterFieldConfig } from './models.gen';
|
||||||
|
|
||||||
|
export const ExplicitEditor: FC<StandardEditorProps<ScatterFieldConfig[], any, XYChartOptions>> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
context,
|
||||||
|
}) => {
|
||||||
|
return <div>TODO: explicit scatter config</div>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { stylesFactory } from '@grafana/ui';
|
||||||
|
import { DataFrame, Field, formattedValueToString, getFieldDisplayName, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { config } from 'app/core/config';
|
||||||
|
import { ScatterSeries } from './types';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
series: ScatterSeries;
|
||||||
|
data: DataFrame[]; // source data
|
||||||
|
rowIndex?: number; // the hover row
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TooltipView extends PureComponent<Props> {
|
||||||
|
style = getStyles(config.theme2);
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { series, data, rowIndex } = this.props;
|
||||||
|
if (!series || rowIndex == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const frame = series.frame(data);
|
||||||
|
const y = undefined; // series.y(frame);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className={this.style.infoWrap}>
|
||||||
|
<tbody>
|
||||||
|
{frame.fields.map((f, i) => (
|
||||||
|
<tr key={`${i}/${rowIndex}`} className={f === y ? this.style.highlight : ''}>
|
||||||
|
<th>{getFieldDisplayName(f, frame)}:</th>
|
||||||
|
<td>{fmt(f, rowIndex)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(field: Field, row: number): string {
|
||||||
|
const v = field.values.get(row);
|
||||||
|
if (field.display) {
|
||||||
|
return formattedValueToString(field.display(v));
|
||||||
|
}
|
||||||
|
return `${v}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
||||||
|
infoWrap: css`
|
||||||
|
padding: 8px;
|
||||||
|
th {
|
||||||
|
font-weight: ${theme.typography.fontWeightMedium};
|
||||||
|
padding: ${theme.spacing(0.25, 2)};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
highlight: css`
|
||||||
|
background: ${theme.colors.action.hover};
|
||||||
|
`,
|
||||||
|
}));
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { LegendDisplayMode, UPlotChart, useTheme2, VizLayout, VizLegend, VizLegendItem } from '@grafana/ui';
|
||||||
|
import { PanelProps } from '@grafana/data';
|
||||||
|
import { XYChartOptions } from './models.gen';
|
||||||
|
import { prepData, prepScatter } from './scatter';
|
||||||
|
|
||||||
|
interface XYChartPanelProps extends PanelProps<XYChartOptions> {}
|
||||||
|
|
||||||
|
export const XYChartPanel: React.FC<XYChartPanelProps> = ({
|
||||||
|
data,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
options,
|
||||||
|
fieldConfig,
|
||||||
|
timeRange,
|
||||||
|
//onFieldConfigChange,
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme2();
|
||||||
|
|
||||||
|
const info = useMemo(() => {
|
||||||
|
console.log('prepScatter!');
|
||||||
|
return prepScatter(options, data, theme, () => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data.structureRev, options]);
|
||||||
|
|
||||||
|
// preps data in various shapes...aligned, stacked, merged, interpolated, etc..
|
||||||
|
const scatterData = useMemo(() => {
|
||||||
|
console.log('prepData!');
|
||||||
|
return prepData(info, data.series);
|
||||||
|
}, [info, data.series]);
|
||||||
|
|
||||||
|
const legend = useMemo(() => {
|
||||||
|
const items: VizLegendItem[] = [];
|
||||||
|
for (const s of info.series) {
|
||||||
|
const frame = s.frame(data.series);
|
||||||
|
if (frame) {
|
||||||
|
for (const item of s.legend(frame)) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VizLayout.Legend placement="bottom">
|
||||||
|
<VizLegend placement="bottom" items={items} displayMode={LegendDisplayMode.List} />
|
||||||
|
</VizLayout.Legend>
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [info]);
|
||||||
|
|
||||||
|
if (info.error) {
|
||||||
|
return (
|
||||||
|
<div className="panel-empty">
|
||||||
|
<p>{info.error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VizLayout width={width} height={height} legend={legend}>
|
||||||
|
{(vizWidth: number, vizHeight: number) => (
|
||||||
|
// <pre style={{ width: vizWidth, height: vizHeight, border: '1px solid green', margin: '0px' }}>
|
||||||
|
// {JSON.stringify(scatterData, null, 2)}
|
||||||
|
// </pre>
|
||||||
|
<UPlotChart config={info.builder!} data={scatterData} width={vizWidth} height={vizHeight} timeRange={timeRange}>
|
||||||
|
{/*children ? children(config, alignedFrame) : null*/}
|
||||||
|
</UPlotChart>
|
||||||
|
)}
|
||||||
|
</VizLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import React, { useCallback, useMemo } from 'react';
|
|
||||||
import { Button, GraphNGLegendEvent, TimeSeries, TooltipPlugin } from '@grafana/ui';
|
|
||||||
import { PanelProps } from '@grafana/data';
|
|
||||||
import { Options } from './types';
|
|
||||||
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
|
|
||||||
import { getXYDimensions } from './dims';
|
|
||||||
|
|
||||||
interface XYChartPanelProps extends PanelProps<Options> {}
|
|
||||||
|
|
||||||
export const XYChartPanel: React.FC<XYChartPanelProps> = ({
|
|
||||||
data,
|
|
||||||
timeRange,
|
|
||||||
timeZone,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
options,
|
|
||||||
fieldConfig,
|
|
||||||
onFieldConfigChange,
|
|
||||||
}) => {
|
|
||||||
const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]);
|
|
||||||
|
|
||||||
const frames = useMemo(() => [dims.frame], [dims]);
|
|
||||||
|
|
||||||
const onLegendClick = useCallback(
|
|
||||||
(event: GraphNGLegendEvent) => {
|
|
||||||
onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, frames));
|
|
||||||
},
|
|
||||||
[fieldConfig, onFieldConfigChange, frames]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dims.error) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>ERROR: {dims.error}</div>
|
|
||||||
{dims.hasData && (
|
|
||||||
<div>
|
|
||||||
<Button onClick={() => alert('TODO, switch vis')}>Show as Table</Button>
|
|
||||||
{dims.hasTime && <Button onClick={() => alert('TODO, switch vis')}>Show as Time series</Button>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TimeSeries
|
|
||||||
frames={frames}
|
|
||||||
structureRev={data.structureRev}
|
|
||||||
fields={dims.fields}
|
|
||||||
timeRange={timeRange}
|
|
||||||
timeZone={timeZone}
|
|
||||||
width={width}
|
|
||||||
height={height}
|
|
||||||
legend={options.legend}
|
|
||||||
onLegendClick={onLegendClick}
|
|
||||||
>
|
|
||||||
{(config, alignedDataFrame) => {
|
|
||||||
return (
|
|
||||||
<TooltipPlugin
|
|
||||||
config={config}
|
|
||||||
data={alignedDataFrame}
|
|
||||||
mode={options.tooltip.mode as any}
|
|
||||||
timeZone={timeZone}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</TimeSeries>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { PanelProps } from '@grafana/data';
|
||||||
|
import { XYChartOptions } from './models.gen';
|
||||||
|
import { ScatterHoverEvent, ScatterSeries } from './types';
|
||||||
|
import {
|
||||||
|
LegendDisplayMode,
|
||||||
|
Portal,
|
||||||
|
UPlotChart,
|
||||||
|
UPlotConfigBuilder,
|
||||||
|
VizLayout,
|
||||||
|
VizLegend,
|
||||||
|
VizLegendItem,
|
||||||
|
VizTooltipContainer,
|
||||||
|
} from '@grafana/ui';
|
||||||
|
import { FacetedData } from '@grafana/ui/src/components/uPlot/types';
|
||||||
|
import { prepData, prepScatter } from './scatter';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { TooltipView } from './TooltipView';
|
||||||
|
|
||||||
|
type Props = PanelProps<XYChartOptions>;
|
||||||
|
type State = {
|
||||||
|
error?: string;
|
||||||
|
series: ScatterSeries[];
|
||||||
|
builder?: UPlotConfigBuilder;
|
||||||
|
facets?: FacetedData;
|
||||||
|
hover?: ScatterHoverEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class XYChartPanel2 extends PureComponent<Props, State> {
|
||||||
|
state: State = {
|
||||||
|
series: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.initSeries(); // also data
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(oldProps: Props) {
|
||||||
|
const { options, data } = this.props;
|
||||||
|
const configsChanged = options !== oldProps.options || data.structureRev !== oldProps.data.structureRev;
|
||||||
|
|
||||||
|
if (configsChanged) {
|
||||||
|
this.initSeries();
|
||||||
|
} else if (data !== oldProps.data) {
|
||||||
|
this.initFacets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scatterHoverCallback = (hover?: ScatterHoverEvent) => {
|
||||||
|
this.setState({ hover });
|
||||||
|
};
|
||||||
|
|
||||||
|
getData = () => {
|
||||||
|
return this.props.data.series;
|
||||||
|
};
|
||||||
|
|
||||||
|
initSeries = () => {
|
||||||
|
const { options, data } = this.props;
|
||||||
|
const info: State = prepScatter(options, this.getData, config.theme2, this.scatterHoverCallback);
|
||||||
|
if (info.series.length && data.series) {
|
||||||
|
info.facets = prepData(info, data.series);
|
||||||
|
info.error = undefined;
|
||||||
|
}
|
||||||
|
this.setState(info);
|
||||||
|
};
|
||||||
|
|
||||||
|
initFacets = () => {
|
||||||
|
this.setState({
|
||||||
|
facets: prepData(this.state, this.props.data.series),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
renderLegend = () => {
|
||||||
|
const { data } = this.props;
|
||||||
|
const { series } = this.state;
|
||||||
|
const items: VizLegendItem[] = [];
|
||||||
|
for (const s of series) {
|
||||||
|
const frame = s.frame(data.series);
|
||||||
|
if (frame) {
|
||||||
|
for (const item of s.legend(frame)) {
|
||||||
|
items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VizLayout.Legend placement="bottom">
|
||||||
|
<VizLegend placement="bottom" items={items} displayMode={LegendDisplayMode.List} />
|
||||||
|
</VizLayout.Legend>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { width, height, timeRange, data } = this.props;
|
||||||
|
const { error, facets, builder, hover, series } = this.state;
|
||||||
|
if (error || !builder) {
|
||||||
|
return (
|
||||||
|
<div className="panel-empty">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VizLayout width={width} height={height} legend={this.renderLegend()}>
|
||||||
|
{(vizWidth: number, vizHeight: number) => (
|
||||||
|
// <pre style={{ width: vizWidth, height: vizHeight, border: '1px solid green', margin: '0px' }}>
|
||||||
|
// {JSON.stringify(scatterData, null, 2)}
|
||||||
|
// </pre>
|
||||||
|
<UPlotChart config={builder} data={facets!} width={vizWidth} height={vizHeight} timeRange={timeRange}>
|
||||||
|
{/*children ? children(config, alignedFrame) : null*/}
|
||||||
|
</UPlotChart>
|
||||||
|
)}
|
||||||
|
</VizLayout>
|
||||||
|
<Portal>
|
||||||
|
{hover && (
|
||||||
|
<VizTooltipContainer position={{ x: hover.pageX, y: hover.pageY }} offset={{ x: 10, y: 10 }}>
|
||||||
|
<TooltipView series={series[hover.scatterIndex]} rowIndex={hover.xIndex} data={data.series} />
|
||||||
|
</VizTooltipContainer>
|
||||||
|
)}
|
||||||
|
</Portal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
getFieldDisplayName,
|
getFieldDisplayName,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
import { XYDimensionConfig, Options } from './types';
|
import { XYDimensionConfig, XYChartOptions } from './models.gen';
|
||||||
import { getXYDimensions, isGraphable } from './dims';
|
import { getXYDimensions, isGraphable } from './dims';
|
||||||
|
|
||||||
interface XYInfo {
|
interface XYInfo {
|
||||||
@@ -18,7 +18,7 @@ interface XYInfo {
|
|||||||
yFields: Array<SelectableValue<boolean>>;
|
yFields: Array<SelectableValue<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const XYDimsEditor: FC<StandardEditorProps<XYDimensionConfig, any, Options>> = ({
|
export const XYDimsEditor: FC<StandardEditorProps<XYDimensionConfig, any, XYChartOptions>> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
FieldColorModeId,
|
||||||
|
FieldConfigProperty,
|
||||||
|
FieldType,
|
||||||
|
identityOverrideProcessor,
|
||||||
|
SetFieldConfigOptionsArgs,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { LineStyle, VisibilityMode } from '@grafana/schema';
|
||||||
|
|
||||||
|
import { commonOptionsBuilder, graphFieldOptions } from '@grafana/ui';
|
||||||
|
import { LineStyleEditor } from '../timeseries/LineStyleEditor';
|
||||||
|
import { ScatterFieldConfig, ScatterLineMode } from './models.gen';
|
||||||
|
|
||||||
|
const categoryStyles = undefined; // ['Scatter styles'];
|
||||||
|
|
||||||
|
export function getScatterFieldConfig(cfg: ScatterFieldConfig): SetFieldConfigOptionsArgs<ScatterFieldConfig> {
|
||||||
|
return {
|
||||||
|
standardOptions: {
|
||||||
|
[FieldConfigProperty.Color]: {
|
||||||
|
settings: {
|
||||||
|
byValueSupport: true,
|
||||||
|
bySeriesSupport: true,
|
||||||
|
preferThresholdsMode: false,
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
mode: FieldColorModeId.PaletteClassic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
useCustomConfig: (builder) => {
|
||||||
|
builder
|
||||||
|
.addRadio({
|
||||||
|
path: 'point',
|
||||||
|
name: 'Points',
|
||||||
|
category: categoryStyles,
|
||||||
|
defaultValue: cfg.point,
|
||||||
|
settings: {
|
||||||
|
options: graphFieldOptions.showPoints,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addSliderInput({
|
||||||
|
path: 'pointSize.fixed',
|
||||||
|
name: 'Point size',
|
||||||
|
category: categoryStyles,
|
||||||
|
defaultValue: cfg.pointSize?.fixed,
|
||||||
|
settings: {
|
||||||
|
min: 1,
|
||||||
|
max: 100,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
showIf: (c) => c.point !== VisibilityMode.Never,
|
||||||
|
})
|
||||||
|
.addRadio({
|
||||||
|
path: 'line',
|
||||||
|
name: 'Lines',
|
||||||
|
category: categoryStyles,
|
||||||
|
defaultValue: cfg.line,
|
||||||
|
settings: {
|
||||||
|
options: [
|
||||||
|
{ label: 'None', value: ScatterLineMode.None },
|
||||||
|
{ label: 'Linear', value: ScatterLineMode.Linear },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addCustomEditor<void, LineStyle>({
|
||||||
|
id: 'lineStyle',
|
||||||
|
path: 'lineStyle',
|
||||||
|
name: 'Line style',
|
||||||
|
category: categoryStyles,
|
||||||
|
showIf: (c) => c.line !== ScatterLineMode.None,
|
||||||
|
editor: LineStyleEditor,
|
||||||
|
override: LineStyleEditor,
|
||||||
|
process: identityOverrideProcessor,
|
||||||
|
shouldApply: (f) => f.type === FieldType.number,
|
||||||
|
})
|
||||||
|
.addSliderInput({
|
||||||
|
path: 'lineWidth',
|
||||||
|
name: 'Line width',
|
||||||
|
category: categoryStyles,
|
||||||
|
defaultValue: cfg.lineWidth,
|
||||||
|
settings: {
|
||||||
|
min: 0,
|
||||||
|
max: 10,
|
||||||
|
step: 1,
|
||||||
|
},
|
||||||
|
showIf: (c) => c.line !== ScatterLineMode.None,
|
||||||
|
});
|
||||||
|
|
||||||
|
commonOptionsBuilder.addAxisConfig(builder, cfg);
|
||||||
|
commonOptionsBuilder.addHideFrom(builder);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data';
|
import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data';
|
||||||
import { XYDimensionConfig } from './types';
|
import { XYDimensionConfig } from './models.gen';
|
||||||
|
|
||||||
// TODO: fix import
|
// TODO: fix import
|
||||||
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
|
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
OptionsWithTooltip,
|
||||||
|
OptionsWithLegend,
|
||||||
|
LineStyle,
|
||||||
|
VisibilityMode,
|
||||||
|
HideableFieldConfig,
|
||||||
|
AxisConfig,
|
||||||
|
AxisPlacement,
|
||||||
|
} from '@grafana/schema';
|
||||||
|
import {
|
||||||
|
ColorDimensionConfig,
|
||||||
|
DimensionSupplier,
|
||||||
|
ScaleDimensionConfig,
|
||||||
|
TextDimensionConfig,
|
||||||
|
} from 'app/features/dimensions';
|
||||||
|
|
||||||
|
export enum ScatterLineMode {
|
||||||
|
None = 'none',
|
||||||
|
Linear = 'linear',
|
||||||
|
// Smooth
|
||||||
|
// r2, etc
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScatterFieldConfig extends HideableFieldConfig, AxisConfig {
|
||||||
|
line?: ScatterLineMode;
|
||||||
|
lineWidth?: number;
|
||||||
|
lineStyle?: LineStyle;
|
||||||
|
lineColor?: ColorDimensionConfig;
|
||||||
|
|
||||||
|
point?: VisibilityMode;
|
||||||
|
pointSize?: ScaleDimensionConfig; // only 'fixed' is exposed in the UI
|
||||||
|
pointColor?: ColorDimensionConfig;
|
||||||
|
pointSymbol?: DimensionSupplier<string>;
|
||||||
|
|
||||||
|
label?: VisibilityMode;
|
||||||
|
labelValue?: TextDimensionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Configured in the panel level */
|
||||||
|
export interface ScatterSeriesConfig extends ScatterFieldConfig {
|
||||||
|
x?: string;
|
||||||
|
y?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultScatterConfig: ScatterFieldConfig = {
|
||||||
|
line: ScatterLineMode.None, // no line
|
||||||
|
lineWidth: 1,
|
||||||
|
lineStyle: {
|
||||||
|
fill: 'solid',
|
||||||
|
},
|
||||||
|
point: VisibilityMode.Auto,
|
||||||
|
pointSize: {
|
||||||
|
fixed: 5,
|
||||||
|
min: 1,
|
||||||
|
max: 20,
|
||||||
|
},
|
||||||
|
axisPlacement: AxisPlacement.Auto,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Old config saved with 8.0+ */
|
||||||
|
export interface XYDimensionConfig {
|
||||||
|
frame: number;
|
||||||
|
x?: string; // name | first
|
||||||
|
exclude?: string[]; // all other numbers except
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XYChartOptions extends OptionsWithLegend, OptionsWithTooltip {
|
||||||
|
mode?: 'xy' | 'explicit';
|
||||||
|
dims: XYDimensionConfig;
|
||||||
|
|
||||||
|
series?: ScatterSeriesConfig[];
|
||||||
|
}
|
||||||
@@ -1,25 +1,69 @@
|
|||||||
import { GraphFieldConfig, GraphDrawStyle } from '@grafana/schema';
|
|
||||||
import { PanelPlugin } from '@grafana/data';
|
import { PanelPlugin } from '@grafana/data';
|
||||||
import { commonOptionsBuilder } from '@grafana/ui';
|
import { commonOptionsBuilder } from '@grafana/ui';
|
||||||
import { XYChartPanel } from './XYChartPanel';
|
import { defaultScatterConfig, XYChartOptions, ScatterFieldConfig } from './models.gen';
|
||||||
import { Options } from './types';
|
import { getScatterFieldConfig } from './config';
|
||||||
import { XYDimsEditor } from './XYDimsEditor';
|
import { XYDimsEditor } from './XYDimsEditor';
|
||||||
import { getGraphFieldConfig, defaultGraphConfig } from '../timeseries/config';
|
import { XYChartPanel2 } from './XYChartPanel2';
|
||||||
|
import { ColorDimensionEditor, ScaleDimensionEditor } from 'app/features/dimensions/editors';
|
||||||
|
|
||||||
export const plugin = new PanelPlugin<Options, GraphFieldConfig>(XYChartPanel)
|
export const plugin = new PanelPlugin<XYChartOptions, ScatterFieldConfig>(XYChartPanel2)
|
||||||
.useFieldConfig(
|
.useFieldConfig(getScatterFieldConfig(defaultScatterConfig))
|
||||||
getGraphFieldConfig({
|
|
||||||
...defaultGraphConfig,
|
|
||||||
drawStyle: GraphDrawStyle.Points,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.setPanelOptions((builder) => {
|
.setPanelOptions((builder) => {
|
||||||
builder.addCustomEditor({
|
builder
|
||||||
id: 'xyPlotConfig',
|
.addRadio({
|
||||||
path: 'dims',
|
path: 'mode',
|
||||||
name: 'Data',
|
name: 'Mode',
|
||||||
editor: XYDimsEditor,
|
defaultValue: 'single',
|
||||||
});
|
settings: {
|
||||||
|
options: [
|
||||||
|
{ value: 'xy', label: 'XY', description: 'No changes to saved model since 8.0' },
|
||||||
|
{ value: 'explicit', label: 'Explicit' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.addCustomEditor({
|
||||||
|
id: 'xyPlotConfig',
|
||||||
|
path: 'dims',
|
||||||
|
name: 'Data',
|
||||||
|
editor: XYDimsEditor,
|
||||||
|
showIf: (cfg) => cfg.mode === 'xy',
|
||||||
|
})
|
||||||
|
.addFieldNamePicker({
|
||||||
|
path: 'series[0].x',
|
||||||
|
name: 'X Field',
|
||||||
|
showIf: (cfg) => cfg.mode === 'explicit',
|
||||||
|
})
|
||||||
|
.addFieldNamePicker({
|
||||||
|
path: 'series[0].y',
|
||||||
|
name: 'Y Field',
|
||||||
|
showIf: (cfg) => cfg.mode === 'explicit',
|
||||||
|
})
|
||||||
|
.addCustomEditor({
|
||||||
|
id: 'seriesZerox.pointColor',
|
||||||
|
path: 'series[0].pointColor',
|
||||||
|
name: 'Point color',
|
||||||
|
editor: ColorDimensionEditor,
|
||||||
|
settings: {},
|
||||||
|
defaultValue: {},
|
||||||
|
showIf: (cfg) => cfg.mode === 'explicit',
|
||||||
|
})
|
||||||
|
.addCustomEditor({
|
||||||
|
id: 'seriesZerox.pointSize',
|
||||||
|
path: 'series[0].pointSize',
|
||||||
|
name: 'Point size',
|
||||||
|
editor: ScaleDimensionEditor,
|
||||||
|
settings: {
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
fixed: 5,
|
||||||
|
min: 1,
|
||||||
|
max: 50,
|
||||||
|
},
|
||||||
|
showIf: (cfg) => cfg.mode === 'explicit',
|
||||||
|
});
|
||||||
|
|
||||||
commonOptionsBuilder.addTooltipOptions(builder);
|
commonOptionsBuilder.addTooltipOptions(builder);
|
||||||
commonOptionsBuilder.addLegendOptions(builder);
|
commonOptionsBuilder.addLegendOptions(builder);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import {
|
||||||
|
DataFrame,
|
||||||
|
FieldColorModeId,
|
||||||
|
fieldColorModeRegistry,
|
||||||
|
getDisplayProcessor,
|
||||||
|
getFieldColorModeForField,
|
||||||
|
getFieldDisplayName,
|
||||||
|
getFieldSeriesColor,
|
||||||
|
GrafanaTheme2,
|
||||||
|
} from '@grafana/data';
|
||||||
|
import { AxisPlacement, ScaleDirection, ScaleOrientation, VisibilityMode } from '@grafana/schema';
|
||||||
|
import { UPlotConfigBuilder } from '@grafana/ui';
|
||||||
|
import { FacetedData, FacetSeries } from '@grafana/ui/src/components/uPlot/types';
|
||||||
|
import {
|
||||||
|
findFieldIndex,
|
||||||
|
getScaledDimensionForField,
|
||||||
|
ScaleDimensionConfig,
|
||||||
|
ScaleDimensionMode,
|
||||||
|
} from 'app/features/dimensions';
|
||||||
|
import { config } from '@grafana/runtime';
|
||||||
|
import { defaultScatterConfig, ScatterFieldConfig, ScatterLineMode, XYChartOptions } from './models.gen';
|
||||||
|
import { pointWithin, Quadtree, Rect } from '../barchart/quadtree';
|
||||||
|
import { alpha } from '@grafana/data/src/themes/colorManipulator';
|
||||||
|
import uPlot from 'uplot';
|
||||||
|
import { DimensionValues, ScatterHoverCallback, ScatterSeries } from './types';
|
||||||
|
import { isGraphable } from './dims';
|
||||||
|
|
||||||
|
export interface ScatterPanelInfo {
|
||||||
|
error?: string;
|
||||||
|
series: ScatterSeries[];
|
||||||
|
builder?: UPlotConfigBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is called when options or structure rev changes
|
||||||
|
*/
|
||||||
|
export function prepScatter(
|
||||||
|
options: XYChartOptions,
|
||||||
|
getData: () => DataFrame[],
|
||||||
|
theme: GrafanaTheme2,
|
||||||
|
ttip: ScatterHoverCallback
|
||||||
|
): ScatterPanelInfo {
|
||||||
|
let series: ScatterSeries[];
|
||||||
|
let builder: UPlotConfigBuilder;
|
||||||
|
|
||||||
|
try {
|
||||||
|
series = prepSeries(options, getData());
|
||||||
|
builder = prepConfig(getData, series, theme, ttip);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('prepScatter ERROR', e);
|
||||||
|
return {
|
||||||
|
error: e.message,
|
||||||
|
series: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
series,
|
||||||
|
builder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Dims {
|
||||||
|
pointColorIndex?: number;
|
||||||
|
pointColorFixed?: string;
|
||||||
|
|
||||||
|
pointSizeIndex?: number;
|
||||||
|
pointSizeConfig?: ScaleDimensionConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getScatterSeries(
|
||||||
|
seriesIndex: number,
|
||||||
|
frames: DataFrame[],
|
||||||
|
frameIndex: number,
|
||||||
|
xIndex: number,
|
||||||
|
yIndex: number,
|
||||||
|
dims: Dims
|
||||||
|
): ScatterSeries {
|
||||||
|
const frame = frames[frameIndex];
|
||||||
|
const y = frame.fields[yIndex];
|
||||||
|
let state = y.state ?? {};
|
||||||
|
state.seriesIndex = seriesIndex;
|
||||||
|
y.state = state;
|
||||||
|
|
||||||
|
// Color configs
|
||||||
|
//----------------
|
||||||
|
let seriesColor = dims.pointColorFixed
|
||||||
|
? config.theme2.visualization.getColorByName(dims.pointColorFixed)
|
||||||
|
: getFieldSeriesColor(y, config.theme2).color;
|
||||||
|
let pointColor: DimensionValues<string> = () => seriesColor;
|
||||||
|
const fieldConfig: ScatterFieldConfig = { ...defaultScatterConfig, ...y.config.custom };
|
||||||
|
let pointColorMode = fieldColorModeRegistry.get(FieldColorModeId.PaletteClassic);
|
||||||
|
if (dims.pointColorIndex) {
|
||||||
|
const f = frames[frameIndex].fields[dims.pointColorIndex];
|
||||||
|
if (f) {
|
||||||
|
const disp =
|
||||||
|
f.display ??
|
||||||
|
getDisplayProcessor({
|
||||||
|
field: f,
|
||||||
|
theme: config.theme2,
|
||||||
|
});
|
||||||
|
pointColorMode = getFieldColorModeForField(y);
|
||||||
|
if (pointColorMode.isByValue) {
|
||||||
|
const index = dims.pointColorIndex;
|
||||||
|
pointColor = (frame: DataFrame) => {
|
||||||
|
// Yes we can improve this later
|
||||||
|
return frame.fields[index].values.toArray().map((v) => disp(v).color!);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
seriesColor = pointColorMode.getCalculator(f, config.theme2)(f.values.get(0), 1);
|
||||||
|
pointColor = () => seriesColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size configs
|
||||||
|
//----------------
|
||||||
|
let pointSizeHints = dims.pointSizeConfig;
|
||||||
|
let pointSizeFixed = dims.pointSizeConfig?.fixed ?? y.config.custom?.pointSizeConfig?.fixed ?? 5;
|
||||||
|
let pointSize: DimensionValues<number> = () => pointSizeFixed;
|
||||||
|
if (dims.pointSizeIndex) {
|
||||||
|
pointSize = (frame) => {
|
||||||
|
const s = getScaledDimensionForField(
|
||||||
|
frame.fields[dims.pointSizeIndex!],
|
||||||
|
dims.pointSizeConfig!,
|
||||||
|
ScaleDimensionMode.Quadratic
|
||||||
|
);
|
||||||
|
const vals = Array(frame.length);
|
||||||
|
for (let i = 0; i < frame.length; i++) {
|
||||||
|
vals[i] = s.get(i);
|
||||||
|
}
|
||||||
|
return vals;
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
pointSizeHints = {
|
||||||
|
fixed: pointSizeFixed,
|
||||||
|
min: pointSizeFixed,
|
||||||
|
max: pointSizeFixed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Series config
|
||||||
|
//----------------
|
||||||
|
const name = getFieldDisplayName(y, frame, frames);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
|
||||||
|
frame: (frames) => frames[frameIndex],
|
||||||
|
|
||||||
|
x: (frame) => frame.fields[xIndex],
|
||||||
|
y: (frame) => frame.fields[yIndex],
|
||||||
|
legend: (frame) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: name,
|
||||||
|
color: seriesColor, // single color for series?
|
||||||
|
getItemKey: () => name,
|
||||||
|
yAxis: yIndex, // << but not used
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
line: fieldConfig.line ?? ScatterLineMode.None,
|
||||||
|
lineWidth: fieldConfig.lineWidth ?? 2,
|
||||||
|
lineStyle: fieldConfig.lineStyle!,
|
||||||
|
lineColor: () => seriesColor,
|
||||||
|
|
||||||
|
point: fieldConfig.point!,
|
||||||
|
pointSize,
|
||||||
|
pointColor,
|
||||||
|
pointSymbol: (frame: DataFrame, from?: number) => 'circle', // single field, multiple symbols.... kinda equals multiple series 🤔
|
||||||
|
|
||||||
|
label: VisibilityMode.Never,
|
||||||
|
labelValue: () => '',
|
||||||
|
|
||||||
|
hints: {
|
||||||
|
pointSize: pointSizeHints!,
|
||||||
|
pointColor: {
|
||||||
|
mode: pointColorMode,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepSeries(options: XYChartOptions, frames: DataFrame[]): ScatterSeries[] {
|
||||||
|
let seriesIndex = 0;
|
||||||
|
if (!frames.length) {
|
||||||
|
throw 'missing data';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.mode === 'explicit') {
|
||||||
|
if (options.series?.length) {
|
||||||
|
for (const series of options.series) {
|
||||||
|
if (!series?.x) {
|
||||||
|
throw 'Select X dimension';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!series?.y) {
|
||||||
|
throw 'Select Y dimension';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let frameIndex = 0; frameIndex < frames.length; frameIndex++) {
|
||||||
|
const frame = frames[frameIndex];
|
||||||
|
const xIndex = findFieldIndex(frame, series.x);
|
||||||
|
|
||||||
|
if (xIndex != null) {
|
||||||
|
// TODO: this should find multiple y fields
|
||||||
|
const yIndex = findFieldIndex(frame, series.y);
|
||||||
|
|
||||||
|
if (yIndex == null) {
|
||||||
|
throw 'Y must be in the same frame as X';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dims: Dims = {
|
||||||
|
pointColorFixed: series.pointColor?.fixed,
|
||||||
|
pointColorIndex: findFieldIndex(frame, series.pointColor?.field),
|
||||||
|
pointSizeConfig: series.pointSize,
|
||||||
|
pointSizeIndex: findFieldIndex(frame, series.pointSize?.field),
|
||||||
|
};
|
||||||
|
return [getScatterSeries(seriesIndex++, frames, frameIndex, xIndex, yIndex, dims)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default behavior
|
||||||
|
const dims = options.dims ?? {};
|
||||||
|
const frameIndex = dims.frame ?? 0;
|
||||||
|
const frame = frames[frameIndex];
|
||||||
|
const numericIndicies: number[] = [];
|
||||||
|
|
||||||
|
let xIndex = findFieldIndex(frame, dims.x);
|
||||||
|
for (let i = 0; i < frame.fields.length; i++) {
|
||||||
|
if (isGraphable(frame.fields[i])) {
|
||||||
|
if (xIndex == null || i === xIndex) {
|
||||||
|
xIndex = i;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (dims.exclude && dims.exclude.includes(getFieldDisplayName(frame.fields[i], frame, frames))) {
|
||||||
|
continue; // skip
|
||||||
|
}
|
||||||
|
|
||||||
|
numericIndicies.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (xIndex == null) {
|
||||||
|
throw 'Missing X dimension';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!numericIndicies.length) {
|
||||||
|
throw 'No Y values';
|
||||||
|
}
|
||||||
|
return numericIndicies.map((yIndex) => getScatterSeries(seriesIndex++, frames, frameIndex, xIndex!, yIndex, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DrawBubblesOpts {
|
||||||
|
each: (u: uPlot, seriesIdx: number, dataIdx: number, lft: number, top: number, wid: number, hgt: number) => void;
|
||||||
|
disp: {
|
||||||
|
//unit: 3,
|
||||||
|
size: {
|
||||||
|
values: (u: uPlot, seriesIdx: number) => number[];
|
||||||
|
};
|
||||||
|
color: {
|
||||||
|
values: (u: uPlot, seriesIdx: number) => string[];
|
||||||
|
alpha: (u: uPlot, seriesIdx: number) => string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//const prepConfig: UPlotConfigPrepFnXY<XYChartOptions> = ({ frames, series, theme }) => {
|
||||||
|
const prepConfig = (
|
||||||
|
getData: () => DataFrame[],
|
||||||
|
scatterSeries: ScatterSeries[],
|
||||||
|
theme: GrafanaTheme2,
|
||||||
|
ttip: ScatterHoverCallback
|
||||||
|
) => {
|
||||||
|
let qt: Quadtree;
|
||||||
|
let hRect: Rect | null;
|
||||||
|
|
||||||
|
function drawBubblesFactory(opts: DrawBubblesOpts) {
|
||||||
|
const drawBubbles: uPlot.Series.PathBuilder = (u, seriesIdx, idx0, idx1) => {
|
||||||
|
uPlot.orient(
|
||||||
|
u,
|
||||||
|
seriesIdx,
|
||||||
|
(
|
||||||
|
series,
|
||||||
|
dataX,
|
||||||
|
dataY,
|
||||||
|
scaleX,
|
||||||
|
scaleY,
|
||||||
|
valToPosX,
|
||||||
|
valToPosY,
|
||||||
|
xOff,
|
||||||
|
yOff,
|
||||||
|
xDim,
|
||||||
|
yDim,
|
||||||
|
moveTo,
|
||||||
|
lineTo,
|
||||||
|
rect,
|
||||||
|
arc
|
||||||
|
) => {
|
||||||
|
const scatterInfo = scatterSeries[seriesIdx - 1];
|
||||||
|
let d = (u.data[seriesIdx] as unknown) as FacetSeries;
|
||||||
|
|
||||||
|
let showLine = scatterInfo.line !== ScatterLineMode.None;
|
||||||
|
let showPoints = scatterInfo.point === VisibilityMode.Always;
|
||||||
|
if (!showPoints && scatterInfo.point === VisibilityMode.Auto) {
|
||||||
|
showPoints = d[0].length < 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// always show something
|
||||||
|
if (!showPoints && !showLine) {
|
||||||
|
showLine = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let strokeWidth = 1;
|
||||||
|
|
||||||
|
u.ctx.save();
|
||||||
|
|
||||||
|
u.ctx.rect(u.bbox.left, u.bbox.top, u.bbox.width, u.bbox.height);
|
||||||
|
u.ctx.clip();
|
||||||
|
|
||||||
|
u.ctx.fillStyle = (series.fill as any)(); // assumes constant
|
||||||
|
u.ctx.strokeStyle = (series.stroke as any)();
|
||||||
|
u.ctx.lineWidth = strokeWidth;
|
||||||
|
|
||||||
|
let deg360 = 2 * Math.PI;
|
||||||
|
|
||||||
|
// leon forgot to add these to the uPlot's Scale interface, but they exist!
|
||||||
|
//let xKey = scaleX.key as string;
|
||||||
|
//let yKey = scaleY.key as string;
|
||||||
|
let xKey = series.facets![0].scale;
|
||||||
|
let yKey = series.facets![1].scale;
|
||||||
|
|
||||||
|
let pointHints = scatterInfo.hints.pointSize;
|
||||||
|
const colorByValue = scatterInfo.hints.pointColor.mode.isByValue;
|
||||||
|
|
||||||
|
let maxSize = (pointHints.max ?? pointHints.fixed) * devicePixelRatio;
|
||||||
|
|
||||||
|
// todo: this depends on direction & orientation
|
||||||
|
// todo: calc once per redraw, not per path
|
||||||
|
let filtLft = u.posToVal(-maxSize / 2, xKey);
|
||||||
|
let filtRgt = u.posToVal(u.bbox.width / devicePixelRatio + maxSize / 2, xKey);
|
||||||
|
let filtBtm = u.posToVal(u.bbox.height / devicePixelRatio + maxSize / 2, yKey);
|
||||||
|
let filtTop = u.posToVal(-maxSize / 2, yKey);
|
||||||
|
|
||||||
|
let sizes = opts.disp.size.values(u, seriesIdx);
|
||||||
|
let pointColors = opts.disp.color.values(u, seriesIdx);
|
||||||
|
let pointAlpha = opts.disp.color.alpha(u, seriesIdx);
|
||||||
|
|
||||||
|
let linePath: Path2D | null = showLine ? new Path2D() : null;
|
||||||
|
|
||||||
|
for (let i = 0; i < d[0].length; i++) {
|
||||||
|
let xVal = d[0][i];
|
||||||
|
let yVal = d[1][i];
|
||||||
|
let size = sizes[i] * devicePixelRatio;
|
||||||
|
|
||||||
|
if (xVal >= filtLft && xVal <= filtRgt && yVal >= filtBtm && yVal <= filtTop) {
|
||||||
|
let cx = valToPosX(xVal, scaleX, xDim, xOff);
|
||||||
|
let cy = valToPosY(yVal, scaleY, yDim, yOff);
|
||||||
|
|
||||||
|
if (showLine) {
|
||||||
|
linePath!.lineTo(cx, cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showPoints) {
|
||||||
|
u.ctx.moveTo(cx + size / 2, cy);
|
||||||
|
u.ctx.beginPath();
|
||||||
|
u.ctx.arc(cx, cy, size / 2, 0, deg360);
|
||||||
|
|
||||||
|
if (colorByValue) {
|
||||||
|
u.ctx.fillStyle = pointAlpha[i];
|
||||||
|
u.ctx.strokeStyle = pointColors[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
u.ctx.fill();
|
||||||
|
u.ctx.stroke();
|
||||||
|
opts.each(
|
||||||
|
u,
|
||||||
|
seriesIdx,
|
||||||
|
i,
|
||||||
|
cx - size / 2 - strokeWidth / 2,
|
||||||
|
cy - size / 2 - strokeWidth / 2,
|
||||||
|
size + strokeWidth,
|
||||||
|
size + strokeWidth
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLine) {
|
||||||
|
let frame = scatterInfo.frame(getData());
|
||||||
|
u.ctx.strokeStyle = scatterInfo.lineColor(frame);
|
||||||
|
u.ctx.lineWidth = scatterInfo.lineWidth * devicePixelRatio;
|
||||||
|
|
||||||
|
const { lineStyle } = scatterInfo;
|
||||||
|
if (lineStyle && lineStyle.fill !== 'solid') {
|
||||||
|
if (lineStyle.fill === 'dot') {
|
||||||
|
u.ctx.lineCap = 'round';
|
||||||
|
}
|
||||||
|
u.ctx.setLineDash(lineStyle.dash ?? [10, 10]);
|
||||||
|
}
|
||||||
|
|
||||||
|
u.ctx.stroke(linePath!);
|
||||||
|
}
|
||||||
|
|
||||||
|
u.ctx.restore();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return drawBubbles;
|
||||||
|
}
|
||||||
|
|
||||||
|
let drawBubbles = drawBubblesFactory({
|
||||||
|
disp: {
|
||||||
|
size: {
|
||||||
|
//unit: 3, // raw CSS pixels
|
||||||
|
values: (u, seriesIdx) => {
|
||||||
|
return u.data[seriesIdx][2] as any; // already contains final pixel geometry
|
||||||
|
//let [minValue, maxValue] = getSizeMinMax(u);
|
||||||
|
//return u.data[seriesIdx][2].map(v => getSize(v, minValue, maxValue));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
// string values
|
||||||
|
values: (u, seriesIdx) => {
|
||||||
|
return u.data[seriesIdx][3] as any;
|
||||||
|
},
|
||||||
|
alpha: (u, seriesIdx) => {
|
||||||
|
return u.data[seriesIdx][4] as any;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => {
|
||||||
|
// we get back raw canvas coords (included axes & padding). translate to the plotting area origin
|
||||||
|
lft -= u.bbox.left;
|
||||||
|
top -= u.bbox.top;
|
||||||
|
qt.add({ x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const builder = new UPlotConfigBuilder();
|
||||||
|
|
||||||
|
builder.setCursor({
|
||||||
|
drag: { setScale: true },
|
||||||
|
dataIdx: (u, seriesIdx) => {
|
||||||
|
if (seriesIdx === 1) {
|
||||||
|
hRect = null;
|
||||||
|
|
||||||
|
let dist = Infinity;
|
||||||
|
let cx = u.cursor.left! * devicePixelRatio;
|
||||||
|
let cy = u.cursor.top! * devicePixelRatio;
|
||||||
|
|
||||||
|
qt.get(cx, cy, 1, 1, (o) => {
|
||||||
|
if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) {
|
||||||
|
let ocx = o.x + o.w / 2;
|
||||||
|
let ocy = o.y + o.h / 2;
|
||||||
|
|
||||||
|
let dx = ocx - cx;
|
||||||
|
let dy = ocy - cy;
|
||||||
|
|
||||||
|
let d = Math.sqrt(dx ** 2 + dy ** 2);
|
||||||
|
|
||||||
|
// test against radius for actual hover
|
||||||
|
if (d <= o.w / 2) {
|
||||||
|
// only hover bbox with closest distance
|
||||||
|
if (d <= dist) {
|
||||||
|
dist = d;
|
||||||
|
hRect = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return hRect && seriesIdx === hRect.sidx ? hRect.didx : null;
|
||||||
|
},
|
||||||
|
points: {
|
||||||
|
size: (u, seriesIdx) => {
|
||||||
|
return hRect && seriesIdx === hRect.sidx ? hRect.w / devicePixelRatio : 0;
|
||||||
|
},
|
||||||
|
fill: (u, seriesIdx) => 'rgba(255,255,255,0.4)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// clip hover points/bubbles to plotting area
|
||||||
|
builder.addHook('init', (u, r) => {
|
||||||
|
u.over.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
|
|
||||||
|
let rect: DOMRect;
|
||||||
|
|
||||||
|
// rect of .u-over (grid area)
|
||||||
|
builder.addHook('syncRect', (u, r) => {
|
||||||
|
rect = r;
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addHook('setLegend', (u) => {
|
||||||
|
// console.log('TTIP???', u.cursor.idxs);
|
||||||
|
if (u.cursor.idxs != null) {
|
||||||
|
for (let i = 0; i < u.cursor.idxs.length; i++) {
|
||||||
|
const sel = u.cursor.idxs[i];
|
||||||
|
if (sel != null) {
|
||||||
|
ttip({
|
||||||
|
scatterIndex: i - 1,
|
||||||
|
xIndex: sel,
|
||||||
|
pageX: rect.left + u.cursor.left!,
|
||||||
|
pageY: rect.top + u.cursor.top!,
|
||||||
|
});
|
||||||
|
return; // only show the first one
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ttip(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addHook('drawClear', (u) => {
|
||||||
|
qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height);
|
||||||
|
|
||||||
|
qt.clear();
|
||||||
|
|
||||||
|
// force-clear the path cache to cause drawBars() to rebuild new quadtree
|
||||||
|
u.series.forEach((s, i) => {
|
||||||
|
if (i > 0) {
|
||||||
|
// @ts-ignore
|
||||||
|
s._paths = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.setMode(2);
|
||||||
|
|
||||||
|
const frames = getData();
|
||||||
|
let xField = scatterSeries[0].x(scatterSeries[0].frame(frames));
|
||||||
|
|
||||||
|
builder.addScale({
|
||||||
|
scaleKey: 'x',
|
||||||
|
isTime: false,
|
||||||
|
orientation: ScaleOrientation.Horizontal,
|
||||||
|
direction: ScaleDirection.Right,
|
||||||
|
range: (u, min, max) => [min, max],
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addAxis({
|
||||||
|
scaleKey: 'x',
|
||||||
|
placement: AxisPlacement.Bottom,
|
||||||
|
theme,
|
||||||
|
label: xField.config.custom.axisLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
scatterSeries.forEach((s) => {
|
||||||
|
let frame = s.frame(frames);
|
||||||
|
let field = s.y(frame);
|
||||||
|
|
||||||
|
const lineColor = s.lineColor(frame);
|
||||||
|
const pointColor = asSingleValue(frame, s.pointColor) as string;
|
||||||
|
//const lineColor = s.lineColor(frame);
|
||||||
|
//const lineWidth = s.lineWidth;
|
||||||
|
|
||||||
|
let scaleKey = field.config.unit ?? 'y';
|
||||||
|
|
||||||
|
builder.addScale({
|
||||||
|
scaleKey,
|
||||||
|
orientation: ScaleOrientation.Vertical,
|
||||||
|
direction: ScaleDirection.Up,
|
||||||
|
range: (u, min, max) => [min, max],
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addAxis({
|
||||||
|
scaleKey,
|
||||||
|
theme,
|
||||||
|
label: field.config.custom.axisLabel,
|
||||||
|
values: (u, splits) => splits.map((s) => field.display!(s).text),
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.addSeries({
|
||||||
|
facets: [
|
||||||
|
{
|
||||||
|
scale: 'x',
|
||||||
|
auto: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scale: scaleKey,
|
||||||
|
auto: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pathBuilder: drawBubbles, // drawBubbles({disp: {size: {values: () => }}})
|
||||||
|
theme,
|
||||||
|
scaleKey: '', // facets' scales used (above)
|
||||||
|
lineColor: lineColor as string,
|
||||||
|
fillColor: alpha(pointColor, 0.5),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
builder.setPrepData((frames) => {
|
||||||
|
let seriesData = lookup.fieldMaps.flatMap((f, i) => {
|
||||||
|
let { fields } = frames[i];
|
||||||
|
|
||||||
|
return f.y.map((yIndex, frameSeriesIndex) => {
|
||||||
|
let xValues = fields[f.x[frameSeriesIndex]].values.toArray();
|
||||||
|
let yValues = fields[f.y[frameSeriesIndex]].values.toArray();
|
||||||
|
let sizeValues = f.size;
|
||||||
|
|
||||||
|
if (!Array.isArray(sizeValues)) {
|
||||||
|
sizeValues = Array(xValues.length).fill(sizeValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [xValues, yValues, sizeValues];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return [null, ...seriesData];
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is called everytime the data changes
|
||||||
|
*
|
||||||
|
* from? is this where we would support that? -- need the previous values
|
||||||
|
*/
|
||||||
|
export function prepData(info: ScatterPanelInfo, data: DataFrame[], from?: number): FacetedData {
|
||||||
|
if (info.error) {
|
||||||
|
return [null];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
null,
|
||||||
|
...info.series.map((s, idx) => {
|
||||||
|
const frame = s.frame(data);
|
||||||
|
|
||||||
|
let colorValues;
|
||||||
|
let colorAlphaValues;
|
||||||
|
const r = s.pointColor(frame);
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
colorValues = r;
|
||||||
|
colorAlphaValues = r.map((c) => alpha(c as string, 0.5));
|
||||||
|
} else {
|
||||||
|
colorValues = Array(frame.length).fill(r);
|
||||||
|
colorAlphaValues = Array(frame.length).fill(alpha(r as string, 0.5));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
s.x(frame).values.toArray(), // X
|
||||||
|
s.y(frame).values.toArray(), // Y
|
||||||
|
asArray(frame, s.pointSize),
|
||||||
|
colorValues,
|
||||||
|
colorAlphaValues,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function asArray<T>(frame: DataFrame, lookup: DimensionValues<T>): T[] {
|
||||||
|
const r = lookup(frame);
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
return Array(frame.length).fill(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asSingleValue<T>(frame: DataFrame, lookup: DimensionValues<T>): T {
|
||||||
|
const r = lookup(frame);
|
||||||
|
if (Array.isArray(r)) {
|
||||||
|
return r[0];
|
||||||
|
}
|
||||||
|
return r;
|
||||||
|
}
|
||||||
@@ -1,10 +1,60 @@
|
|||||||
import { OptionsWithTooltip, OptionsWithLegend } from '@grafana/schema';
|
import { DataFrame, Field, FieldColorMode } from '@grafana/data';
|
||||||
export interface XYDimensionConfig {
|
import { LineStyle, VisibilityMode } from '@grafana/schema';
|
||||||
frame: number;
|
import { VizLegendItem } from '@grafana/ui';
|
||||||
x?: string; // name | first
|
import { ScaleDimensionConfig } from 'app/features/dimensions';
|
||||||
exclude?: string[]; // all other numbers except
|
import { ScatterLineMode } from './models.gen';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export type DimensionValues<T> = (frame: DataFrame, from?: number) => T | T[];
|
||||||
|
|
||||||
|
export interface ScatterHoverEvent {
|
||||||
|
scatterIndex: number;
|
||||||
|
xIndex: number;
|
||||||
|
pageX: number;
|
||||||
|
pageY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Options extends OptionsWithLegend, OptionsWithTooltip {
|
export type ScatterHoverCallback = (evt?: ScatterHoverEvent) => void;
|
||||||
dims: XYDimensionConfig;
|
|
||||||
|
export interface LegendInfo {
|
||||||
|
color: CanvasRenderingContext2D['strokeStyle'];
|
||||||
|
text: string;
|
||||||
|
symbol: string;
|
||||||
|
openEditor?: (evt: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using field where we will need formatting/scale/axis info
|
||||||
|
// Use raw or DimensionValues when the values can be used directly
|
||||||
|
export interface ScatterSeries {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** Finds the relevant frame from the raw panel data */
|
||||||
|
frame: (frames: DataFrame[]) => DataFrame;
|
||||||
|
|
||||||
|
x: (frame: DataFrame) => Field;
|
||||||
|
y: (frame: DataFrame) => Field;
|
||||||
|
|
||||||
|
legend: (frame: DataFrame) => VizLegendItem[]; // could be single if symbol is constant
|
||||||
|
|
||||||
|
line: ScatterLineMode;
|
||||||
|
lineWidth: number;
|
||||||
|
lineStyle: LineStyle;
|
||||||
|
lineColor: (frame: DataFrame) => CanvasRenderingContext2D['strokeStyle'];
|
||||||
|
|
||||||
|
point: VisibilityMode;
|
||||||
|
pointSize: DimensionValues<number>;
|
||||||
|
pointColor: DimensionValues<CanvasRenderingContext2D['strokeStyle']>;
|
||||||
|
pointSymbol: DimensionValues<string>; // single field, multiple symbols.... kinda equals multiple series
|
||||||
|
|
||||||
|
label: VisibilityMode;
|
||||||
|
labelValue: DimensionValues<string>;
|
||||||
|
|
||||||
|
hints: {
|
||||||
|
pointSize: ScaleDimensionConfig;
|
||||||
|
pointColor: {
|
||||||
|
mode: FieldColorMode;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user