Commit 9c08b34e by Dominik Prokop Committed by GitHub

GraphNG: refactor core to class component (#30941)

* First attempt

* Get rid of time range as config invalidation dependency

* GraphNG class refactor

* Get rid of DataFrame dependency from Plot component, get rid of usePlotData context, rely on XYMatchers for data inspection from within plugins

* Bring back legend

* Fix Sparkline

* Fix Sparkline

* Sparkline update

* Explore update

* fix

* BarChart refactor to class

* Tweaks

* TS fix

* Fix tests

* Tests

* Update packages/grafana-ui/src/components/uPlot/utils.ts

* Update public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx

* GraphNG: unified legend for BarChart, GraphNG & other uPlot based visualizations (#31175)

* Legend experiment

* Nits
parent f9a293af
......@@ -112,10 +112,10 @@ export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings
export interface ApplyFieldOverrideOptions {
data?: DataFrame[];
fieldConfig: FieldConfigSource;
fieldConfigRegistry?: FieldConfigOptionsRegistry;
replaceVariables: InterpolateFunction;
theme: GrafanaTheme;
timeZone?: TimeZone;
fieldConfigRegistry?: FieldConfigOptionsRegistry;
}
export enum FieldConfigProperty {
......
......@@ -61,5 +61,5 @@ export const Basic: React.FC = () => {
groupWidth: 0.7,
};
return <BarChart data={data[0]} width={600} height={400} theme={theme} {...options} />;
return <BarChart data={data} width={600} height={400} {...options} />;
};
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { FieldConfig, FieldType, GrafanaTheme, MutableDataFrame, VizOrientation } from '@grafana/data';
import { BarChartFieldConfig, BarChartOptions, BarStackingMode, BarValueVisibility } from './types';
import { GraphGradientMode } from '../uPlot/config';
import { LegendDisplayMode } from '../VizLegend/types';
function mockDataFrame() {
const df1 = new MutableDataFrame({
refId: 'A',
fields: [{ name: 'ts', type: FieldType.string, values: ['a', 'b', 'c'] }],
});
const df2 = new MutableDataFrame({
refId: 'B',
fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }],
});
const f1Config: FieldConfig<BarChartFieldConfig> = {
displayName: 'Metric 1',
decimals: 2,
unit: 'm/s',
custom: {
gradientMode: GraphGradientMode.Opacity,
lineWidth: 2,
fillOpacity: 0.1,
},
};
const f2Config: FieldConfig<BarChartFieldConfig> = {
displayName: 'Metric 2',
decimals: 2,
unit: 'kWh',
custom: {
gradientMode: GraphGradientMode.Hue,
lineWidth: 2,
fillOpacity: 0.1,
},
};
df1.addField({
name: 'metric1',
type: FieldType.number,
config: f1Config,
state: {},
});
df2.addField({
name: 'metric2',
type: FieldType.number,
config: f2Config,
state: {},
});
return preparePlotFrame([df1, df2]);
}
describe('GraphNG utils', () => {
describe('preparePlotConfigBuilder', () => {
const frame = mockDataFrame();
const config: BarChartOptions = {
orientation: VizOrientation.Auto,
groupWidth: 20,
barWidth: 2,
showValue: BarValueVisibility.Always,
legend: {
displayMode: LegendDisplayMode.List,
placement: 'bottom',
calcs: [],
},
stacking: BarStackingMode.None,
};
it.each([VizOrientation.Auto, VizOrientation.Horizontal, VizOrientation.Vertical])('orientation', (v) => {
expect(
preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, {
...config,
orientation: v,
})
).toMatchSnapshot();
});
it.each([BarValueVisibility.Always, BarValueVisibility.Auto])('value visibility', (v) => {
expect(
preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, {
...config,
showValue: v,
})
).toMatchSnapshot();
});
it.each([BarStackingMode.None, BarStackingMode.Percent, BarStackingMode.Standard])('stacking', (v) => {
expect(
preparePlotConfigBuilder(frame!, { colors: { panelBg: '#000000' } } as GrafanaTheme, {
...config,
stacking: v,
})
).toMatchSnapshot();
});
});
});
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import {
DataFrame,
FieldType,
formattedValueToString,
getFieldColorModeForField,
getFieldDisplayName,
getFieldSeriesColor,
GrafanaTheme,
MutableDataFrame,
VizOrientation,
} from '@grafana/data';
import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config';
import { BarsOptions, getConfig } from './bars';
import { FIXED_UNIT } from '../GraphNG/GraphNG';
/** @alpha */
export function preparePlotConfigBuilder(
data: DataFrame,
theme: GrafanaTheme,
{ orientation, showValue, groupWidth, barWidth }: BarChartOptions
) {
const builder = new UPlotConfigBuilder();
// bar orientation -> x scale orientation & direction
let xOri = ScaleOrientation.Vertical;
let xDir = ScaleDirection.Down;
let yOri = ScaleOrientation.Horizontal;
let yDir = ScaleDirection.Right;
if (orientation === VizOrientation.Vertical) {
xOri = ScaleOrientation.Horizontal;
xDir = ScaleDirection.Right;
yOri = ScaleOrientation.Vertical;
yDir = ScaleDirection.Up;
}
const formatValue =
showValue !== BarValueVisibility.Never
? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value))
: undefined;
// Use bar width when only one field
if (data.fields.length === 2) {
groupWidth = barWidth;
barWidth = 1;
}
const opts: BarsOptions = {
xOri,
xDir,
groupWidth,
barWidth,
formatValue,
onHover: (seriesIdx: number, valueIdx: number) => {
console.log('hover', { seriesIdx, valueIdx });
},
onLeave: (seriesIdx: number, valueIdx: number) => {
console.log('leave', { seriesIdx, valueIdx });
},
};
const config = getConfig(opts);
builder.addHook('init', config.init);
builder.addHook('drawClear', config.drawClear);
builder.addHook('setCursor', config.setCursor);
builder.setCursor(config.cursor);
builder.setSelect(config.select);
builder.addScale({
scaleKey: 'x',
isTime: false,
distribution: ScaleDistribution.Ordinal,
orientation: xOri,
direction: xDir,
});
builder.addAxis({
scaleKey: 'x',
isTime: false,
placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left,
splits: config.xSplits,
values: config.xValues,
grid: false,
ticks: false,
gap: 15,
theme,
});
let seriesIndex = 0;
// iterate the y values
for (let i = 1; i < data.fields.length; i++) {
const field = data.fields[i];
field.state!.seriesIndex = seriesIndex++;
const customConfig: BarChartFieldConfig = { ...defaultBarChartFieldConfig, ...field.config.custom };
const scaleKey = field.config.unit || FIXED_UNIT;
const colorMode = getFieldColorModeForField(field);
const scaleColor = getFieldSeriesColor(field, theme);
const seriesColor = scaleColor.color;
builder.addSeries({
scaleKey,
pxAlign: false,
lineWidth: customConfig.lineWidth,
lineColor: seriesColor,
//lineStyle: customConfig.lineStyle,
fillOpacity: customConfig.fillOpacity,
theme,
colorMode,
pathBuilder: config.drawBars,
pointsBuilder: config.drawPoints,
show: !customConfig.hideFrom?.graph,
gradientMode: customConfig.gradientMode,
thresholds: field.config.thresholds,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
dataFrameFieldIndex: {
fieldIndex: i,
frameIndex: 0,
},
fieldName: getFieldDisplayName(field, data),
hideInLegend: customConfig.hideFrom?.legend,
});
// The builder will manage unique scaleKeys and combine where appropriate
builder.addScale({
scaleKey,
min: field.config.min,
max: field.config.max,
softMin: customConfig.axisSoftMin,
softMax: customConfig.axisSoftMax,
orientation: yOri,
direction: yDir,
});
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
let placement = customConfig.axisPlacement;
if (!placement || placement === AxisPlacement.Auto) {
placement = AxisPlacement.Left;
}
if (xOri === 1) {
if (placement === AxisPlacement.Left) {
placement = AxisPlacement.Bottom;
}
if (placement === AxisPlacement.Right) {
placement = AxisPlacement.Top;
}
}
builder.addAxis({
scaleKey,
label: customConfig.axisLabel,
size: customConfig.axisWidth,
placement,
formatValue: (v) => formattedValueToString(field.display!(v)),
theme,
});
}
}
return builder;
}
/** @internal */
export function preparePlotFrame(data: DataFrame[]) {
const firstFrame = data[0];
const firstString = firstFrame.fields.find((f) => f.type === FieldType.string);
if (!firstString) {
throw new Error('No string field in DF');
}
const resultFrame = new MutableDataFrame();
resultFrame.addField(firstString);
for (const f of firstFrame.fields) {
if (f.type === FieldType.number) {
resultFrame.addField(f);
}
}
return resultFrame;
}
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GraphNG utils preparePlotConfigBuilder 1`] = `
UPlotConfigBuilder {
"axes": Object {
"__fixed": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "left",
"scaleKey": "__fixed",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"isTime": true,
"placement": "bottom",
"scaleKey": "x",
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"timeZone": "browser",
},
},
},
"bands": Array [],
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"isTime": true,
"orientation": 0,
"range": [Function],
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"distribution": undefined,
"log": undefined,
"max": undefined,
"min": undefined,
"orientation": 1,
"scaleKey": "__fixed",
"softMax": undefined,
"softMin": undefined,
},
},
],
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"barAlignment": undefined,
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"drawStyle": "line",
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#ff0000",
"lineInterpolation": "linear",
"lineStyle": Object {
"dash": Array [
1,
2,
],
"fill": "dash",
},
"lineWidth": 2,
"pointColor": "#808080",
"pointSize": undefined,
"scaleKey": "__fixed",
"show": true,
"showPoints": "always",
"spanNulls": false,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
UPlotSeriesBuilder {
"props": Object {
"barAlignment": -1,
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 1,
},
"drawStyle": "bars",
"fieldName": "Metric 2",
"fillOpacity": 0.1,
"gradientMode": "hue",
"hideInLegend": undefined,
"lineColor": "#ff0000",
"lineInterpolation": "linear",
"lineStyle": Object {
"dash": Array [
1,
2,
],
"fill": "dash",
},
"lineWidth": 2,
"pointColor": "#808080",
"pointSize": undefined,
"scaleKey": "__fixed",
"show": true,
"showPoints": "always",
"spanNulls": false,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
import { DataFrame, DataFrameFieldIndex, Field } from '@grafana/data';
import { XYFieldMatchers } from './types';
import React, { useCallback, useContext } from 'react';
/** @alpha */
interface GraphNGContextType {
mapSeriesIndexToDataFrameFieldIndex: (index: number) => DataFrameFieldIndex;
dimFields: XYFieldMatchers;
}
/** @alpha */
export const GraphNGContext = React.createContext<GraphNGContextType>({} as GraphNGContextType);
/**
* @alpha
* Exposes API for data frame inspection in Plot plugins
*/
export const useGraphNGContext = () => {
const graphCtx = useContext<GraphNGContextType>(GraphNGContext);
const getXAxisField = useCallback(
(data: DataFrame[]) => {
const xFieldMatcher = graphCtx.dimFields.x;
let xField: Field | null = null;
for (let i = 0; i < data.length; i++) {
const frame = data[i];
for (let j = 0; j < frame.fields.length; j++) {
if (xFieldMatcher(frame.fields[j], frame, data)) {
xField = frame.fields[j];
break;
}
}
}
return xField;
},
[graphCtx]
);
return {
...graphCtx,
getXAxisField,
};
};
import { DataFrameFieldIndex } from '@grafana/data';
import { DataFrameFieldIndex, FieldMatcher } from '@grafana/data';
/**
* Mode to describe if a legend is isolated/selected or being appended to an existing
......@@ -18,3 +18,8 @@ export interface GraphNGLegendEvent {
fieldIndex: DataFrameFieldIndex;
mode: GraphNGLegendEventMode;
}
export interface XYFieldMatchers {
x: FieldMatcher; // first match
y: FieldMatcher;
}
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import {
DefaultTimeZone,
FieldConfig,
FieldMatcherID,
fieldMatchers,
FieldType,
getDefaultTimeRange,
GrafanaTheme,
MutableDataFrame,
} from '@grafana/data';
import { BarAlignment, DrawStyle, GraphFieldConfig, GraphGradientMode, LineInterpolation, PointVisibility } from '..';
function mockDataFrame() {
const df1 = new MutableDataFrame({
refId: 'A',
fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 3] }],
});
const df2 = new MutableDataFrame({
refId: 'B',
fields: [{ name: 'ts', type: FieldType.time, values: [1, 2, 4] }],
});
const f1Config: FieldConfig<GraphFieldConfig> = {
displayName: 'Metric 1',
decimals: 2,
custom: {
drawStyle: DrawStyle.Line,
gradientMode: GraphGradientMode.Opacity,
lineColor: '#ff0000',
lineWidth: 2,
lineInterpolation: LineInterpolation.Linear,
lineStyle: {
fill: 'dash',
dash: [1, 2],
},
spanNulls: false,
fillColor: '#ff0000',
fillOpacity: 0.1,
showPoints: PointVisibility.Always,
},
};
const f2Config: FieldConfig<GraphFieldConfig> = {
displayName: 'Metric 2',
decimals: 2,
custom: {
drawStyle: DrawStyle.Bars,
gradientMode: GraphGradientMode.Hue,
lineColor: '#ff0000',
lineWidth: 2,
lineInterpolation: LineInterpolation.Linear,
lineStyle: {
fill: 'dash',
dash: [1, 2],
},
barAlignment: BarAlignment.Before,
fillColor: '#ff0000',
fillOpacity: 0.1,
showPoints: PointVisibility.Always,
},
};
df1.addField({
name: 'metric1',
type: FieldType.number,
config: f1Config,
});
df2.addField({
name: 'metric2',
type: FieldType.number,
config: f2Config,
});
return preparePlotFrame([df1, df2], {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
});
}
describe('GraphNG utils', () => {
test('preparePlotConfigBuilder', () => {
const frame = mockDataFrame();
expect(
preparePlotConfigBuilder(
frame!,
{ colors: { panelBg: '#000000' } } as GrafanaTheme,
getDefaultTimeRange,
() => DefaultTimeZone
)
).toMatchSnapshot();
});
});
import React from 'react';
import isNumber from 'lodash/isNumber';
import { GraphNGLegendEventMode, XYFieldMatchers } from './types';
import {
DataFrame,
FieldConfig,
FieldType,
formattedValueToString,
getFieldColorModeForField,
getFieldDisplayName,
getFieldSeriesColor,
GrafanaTheme,
outerJoinDataFrames,
TimeRange,
TimeZone,
} from '@grafana/data';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { FIXED_UNIT } from './GraphNG';
import {
AxisPlacement,
DrawStyle,
GraphFieldConfig,
PointVisibility,
ScaleDirection,
ScaleOrientation,
} from '../uPlot/config';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const defaultConfig: GraphFieldConfig = {
drawStyle: DrawStyle.Line,
showPoints: PointVisibility.Auto,
axisPlacement: AxisPlacement.Auto,
};
export function mapMouseEventToMode(event: React.MouseEvent): GraphNGLegendEventMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return GraphNGLegendEventMode.AppendToSelection;
}
return GraphNGLegendEventMode.ToggleSelection;
}
export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers) {
return outerJoinDataFrames({
frames: data,
joinBy: dimFields.x,
keep: dimFields.y,
keepOriginIndices: true,
});
}
export function preparePlotConfigBuilder(
frame: DataFrame,
theme: GrafanaTheme,
getTimeRange: () => TimeRange,
getTimeZone: () => TimeZone
): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder(getTimeZone);
// X is the first field in the aligned frame
const xField = frame.fields[0];
let seriesIndex = 0;
if (xField.type === FieldType.time) {
builder.addScale({
scaleKey: 'x',
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
isTime: true,
range: () => {
const r = getTimeRange();
return [r.from.valueOf(), r.to.valueOf()];
},
});
builder.addAxis({
scaleKey: 'x',
isTime: true,
placement: AxisPlacement.Bottom,
timeZone: getTimeZone(),
theme,
});
} else {
// Not time!
builder.addScale({
scaleKey: 'x',
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
});
builder.addAxis({
scaleKey: 'x',
placement: AxisPlacement.Bottom,
theme,
});
}
let indexByName: Map<string, number> | undefined = undefined;
for (let i = 0; i < frame.fields.length; i++) {
const field = frame.fields[i];
const config = field.config as FieldConfig<GraphFieldConfig>;
const customConfig: GraphFieldConfig = {
...defaultConfig,
...config.custom,
};
if (field === xField || field.type !== FieldType.number) {
continue;
}
field.state!.seriesIndex = seriesIndex++;
const fmt = field.display ?? defaultFormatter;
const scaleKey = config.unit || FIXED_UNIT;
const colorMode = getFieldColorModeForField(field);
const scaleColor = getFieldSeriesColor(field, theme);
const seriesColor = scaleColor.color;
// The builder will manage unique scaleKeys and combine where appropriate
builder.addScale({
scaleKey,
orientation: ScaleOrientation.Vertical,
direction: ScaleDirection.Up,
distribution: customConfig.scaleDistribution?.type,
log: customConfig.scaleDistribution?.log,
min: field.config.min,
max: field.config.max,
softMin: customConfig.axisSoftMin,
softMax: customConfig.axisSoftMax,
});
if (customConfig.axisPlacement !== AxisPlacement.Hidden) {
builder.addAxis({
scaleKey,
label: customConfig.axisLabel,
size: customConfig.axisWidth,
placement: customConfig.axisPlacement ?? AxisPlacement.Auto,
formatValue: (v) => formattedValueToString(fmt(v)),
theme,
});
}
const showPoints = customConfig.drawStyle === DrawStyle.Points ? PointVisibility.Always : customConfig.showPoints;
let { fillOpacity } = customConfig;
if (customConfig.fillBelowTo) {
if (!indexByName) {
indexByName = getNamesToFieldIndex(frame);
}
const t = indexByName.get(getFieldDisplayName(field, frame));
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`
});
}
if (!fillOpacity) {
fillOpacity = 35; // default from flot
}
}
builder.addSeries({
scaleKey,
showPoints,
colorMode,
fillOpacity,
theme,
drawStyle: customConfig.drawStyle!,
lineColor: customConfig.lineColor ?? seriesColor,
lineWidth: customConfig.lineWidth,
lineInterpolation: customConfig.lineInterpolation,
lineStyle: customConfig.lineStyle,
barAlignment: customConfig.barAlignment,
pointSize: customConfig.pointSize,
pointColor: customConfig.pointColor ?? seriesColor,
spanNulls: customConfig.spanNulls || false,
show: !customConfig.hideFrom?.graph,
gradientMode: customConfig.gradientMode,
thresholds: config.thresholds,
// The following properties are not used in the uPlot config, but are utilized as transport for legend config
dataFrameFieldIndex: field.state?.origin,
fieldName: getFieldDisplayName(field, frame),
hideInLegend: customConfig.hideFrom?.legend,
});
}
return builder;
}
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
const names = new Map<string, number>();
for (let i = 0; i < frame.fields.length; i++) {
names.set(getFieldDisplayName(frame.fields[i], frame), i);
}
return names;
}
import React, { PureComponent } from 'react';
import { AlignedData } from 'uplot';
import {
compareDataFrameStructures,
DefaultTimeZone,
FieldSparkline,
IndexVector,
DataFrame,
FieldConfig,
FieldSparkline,
FieldType,
getFieldColorModeForField,
FieldConfig,
getFieldDisplayName,
} from '@grafana/data';
import {
......@@ -21,8 +20,10 @@ import {
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { UPlotChart } from '../uPlot/Plot';
import { Themeable } from '../../types';
import { preparePlotData } from '../uPlot/utils';
import { preparePlotFrame } from './utils';
export interface Props extends Themeable {
export interface SparklineProps extends Themeable {
width: number;
height: number;
config?: FieldConfig<GraphFieldConfig>;
......@@ -30,7 +31,8 @@ export interface Props extends Themeable {
}
interface State {
data: DataFrame;
data: AlignedData;
alignedDataFrame: DataFrame;
configBuilder: UPlotConfigBuilder;
}
......@@ -40,51 +42,53 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Hidden,
};
export class Sparkline extends PureComponent<Props, State> {
constructor(props: Props) {
export class Sparkline extends PureComponent<SparklineProps, State> {
constructor(props: SparklineProps) {
super(props);
const data = this.prepareData(props);
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
const data = preparePlotData(alignedDataFrame);
this.state = {
data,
configBuilder: this.prepareConfig(data, props),
alignedDataFrame,
configBuilder: this.prepareConfig(alignedDataFrame),
};
}
componentDidUpdate(oldProps: Props) {
if (oldProps.sparkline !== this.props.sparkline) {
const data = this.prepareData(this.props);
if (!compareDataFrameStructures(this.state.data, data)) {
const configBuilder = this.prepareConfig(data, this.props);
this.setState({ data, configBuilder });
} else {
this.setState({ data });
}
static getDerivedStateFromProps(props: SparklineProps, state: State) {
const frame = preparePlotFrame(props.sparkline, props.config);
if (!frame) {
return { ...state };
}
}
prepareData(props: Props): DataFrame {
const { sparkline } = props;
const length = sparkline.y.values.length;
const yFieldConfig = {
...sparkline.y.config,
...this.props.config,
};
return {
refId: 'sparkline',
fields: [
sparkline.x ?? IndexVector.newField(length),
{
...sparkline.y,
config: yFieldConfig,
},
],
length,
...state,
data: preparePlotData(frame),
alignedDataFrame: frame,
};
}
prepareConfig(data: DataFrame, props: Props) {
componentDidUpdate(prevProps: SparklineProps, prevState: State) {
const { alignedDataFrame } = this.state;
let stateUpdate = {};
if (prevProps.sparkline !== this.props.sparkline) {
if (!alignedDataFrame) {
return;
}
const hasStructureChanged = !compareDataFrameStructures(this.state.alignedDataFrame, prevState.alignedDataFrame);
if (hasStructureChanged) {
const configBuilder = this.prepareConfig(alignedDataFrame);
stateUpdate = { configBuilder };
}
}
if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate);
}
}
prepareConfig(data: DataFrame) {
const { theme } = this.props;
const builder = new UPlotConfigBuilder();
......@@ -174,14 +178,7 @@ export class Sparkline extends PureComponent<Props, State> {
const { width, height, sparkline } = this.props;
return (
<UPlotChart
data={data}
config={configBuilder}
width={width}
height={height}
timeRange={sparkline.timeRange!}
timeZone={DefaultTimeZone}
/>
<UPlotChart data={data} config={configBuilder} width={width} height={height} timeRange={sparkline.timeRange!} />
);
}
}
import { DataFrame, FieldConfig, FieldSparkline, IndexVector } from '@grafana/data';
import { GraphFieldConfig } from '../uPlot/config';
/** @internal
* Given a sparkline config returns a DataFrame ready to be turned into Plot data set
**/
export function preparePlotFrame(sparkline: FieldSparkline, config?: FieldConfig<GraphFieldConfig>): DataFrame {
const length = sparkline.y.values.length;
const yFieldConfig = {
...sparkline.y.config,
...config,
};
return {
refId: 'sparkline',
fields: [
sparkline.x ?? IndexVector.newField(length),
{
...sparkline.y,
config: yFieldConfig,
},
],
length,
};
}
......@@ -24,7 +24,7 @@ export const BottomLegend = () => {
const items = Array.from({ length: legendItems }, (_, i) => i + 1);
const legend = (
<VizLayout.Legend position="bottom" maxHeight="30%">
<VizLayout.Legend placement="bottom" maxHeight="30%">
{items.map((_, index) => (
<div style={{ height: '30px', width: '100%', background: 'blue', marginBottom: '2px' }} key={index}>
Legend item {index}
......@@ -47,7 +47,7 @@ export const RightLegend = () => {
const items = Array.from({ length: legendItems }, (_, i) => i + 1);
const legend = (
<VizLayout.Legend position="right" maxWidth="50%">
<VizLayout.Legend placement="right" maxWidth="50%">
{items.map((_, index) => (
<div style={{ height: '30px', width: `${legendWidth}px`, background: 'blue', marginBottom: '2px' }} key={index}>
Legend item {index}
......
import React, { FC, CSSProperties, ComponentType } from 'react';
import { useMeasure } from 'react-use';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { LegendPlacement } from '..';
/**
* @beta
......@@ -33,7 +34,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
return <div style={containerStyle}>{children(width, height)}</div>;
}
const { position, maxHeight, maxWidth } = legend.props;
const { placement, maxHeight, maxWidth } = legend.props;
const [legendRef, legendMeasure] = useMeasure();
let size: VizSize | null = null;
......@@ -43,7 +44,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
const legendStyle: CSSProperties = {};
switch (position) {
switch (placement) {
case 'bottom':
containerStyle.flexDirection = 'column';
legendStyle.maxHeight = maxHeight;
......@@ -91,7 +92,7 @@ interface VizSize {
* @beta
*/
export interface VizLayoutLegendProps {
position: 'bottom' | 'right';
placement: LegendPlacement;
maxHeight?: string;
maxWidth?: string;
children: React.ReactNode;
......
......@@ -205,8 +205,9 @@ export { UPlotChart } from './uPlot/Plot';
export * from './uPlot/geometries';
export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
export { usePlotContext, usePlotPluginContext } from './uPlot/context';
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
export { useGraphNGContext } from './GraphNG/hooks';
export { BarChart } from './BarChart/BarChart';
export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';
......
......@@ -6,6 +6,7 @@ import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
import uPlot from 'uplot';
import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import { preparePlotData } from './utils';
const mockRaf = createMockRaf();
const setDataMock = jest.fn();
......@@ -71,10 +72,9 @@ describe('UPlotChart', () => {
const { unmount } = render(
<UPlotChart
data={data} // mock
data={preparePlotData(data)} // mock
config={config}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
/>
......@@ -96,10 +96,9 @@ describe('UPlotChart', () => {
const { rerender } = render(
<UPlotChart
data={data} // mock
data={preparePlotData(data)} // mock
config={config}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
/>
......@@ -116,10 +115,9 @@ describe('UPlotChart', () => {
rerender(
<UPlotChart
data={data} // changed
data={preparePlotData(data)} // changed
config={config}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
/>
......@@ -134,7 +132,7 @@ describe('UPlotChart', () => {
const { data, timeRange, config } = mockData();
const { queryAllByTestId } = render(
<UPlotChart data={data} config={config} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
<UPlotChart data={preparePlotData(data)} config={config} timeRange={timeRange} width={0} height={0} />
);
expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
......@@ -146,10 +144,9 @@ describe('UPlotChart', () => {
const { rerender } = render(
<UPlotChart
data={data} // frame
data={preparePlotData(data)} // frame
config={config}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
/>
......@@ -164,10 +161,9 @@ describe('UPlotChart', () => {
rerender(
<UPlotChart
data={data}
data={preparePlotData(data)}
config={new UPlotConfigBuilder()}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
/>
......@@ -182,10 +178,9 @@ describe('UPlotChart', () => {
const { rerender } = render(
<UPlotChart
data={data} // frame
data={preparePlotData(data)} // frame
config={config}
timeRange={timeRange}
timeZone={'browser'}
width={100}
height={100}
/>
......@@ -198,10 +193,9 @@ describe('UPlotChart', () => {
rerender(
<UPlotChart
data={data} // frame
data={preparePlotData(data)} // frame
config={new UPlotConfigBuilder()}
timeRange={timeRange}
timeZone={'browser'}
width={200}
height={200}
/>
......
......@@ -4,7 +4,6 @@ import { buildPlotContext, PlotContext } from './context';
import { pluginLog } from './utils';
import { usePlotConfig } from './hooks';
import { PlotProps } from './types';
import { DataFrame, dateTime, FieldType } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import usePrevious from 'react-use/lib/usePrevious';
......@@ -19,12 +18,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
const plotInstance = useRef<uPlot>();
const [isPlotReady, setIsPlotReady] = useState(false);
const prevProps = usePrevious(props);
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(
props.width,
props.height,
props.timeZone,
props.config
);
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config);
const getPlotInstance = useCallback(() => {
return plotInstance.current;
......@@ -39,7 +33,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
if (isConfigReady && !plotInstance.current) {
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current);
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
setIsPlotReady(true);
return;
}
......@@ -54,18 +48,18 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
return;
}
// 3. When config or timezone has changed, re-initialize plot
if (isConfigReady && (props.config !== prevProps.config || props.timeZone !== prevProps.timeZone)) {
// 3. When config has changed re-initialize plot
if (isConfigReady && props.config !== prevProps.config) {
if (plotInstance.current) {
pluginLog('uPlot core', false, 'destroying instance');
plotInstance.current.destroy();
}
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current);
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
return;
}
// 4. Otherwise, assume only data has changed and update uPlot data
updateData(props.data, props.config, plotInstance.current, prepareData(props.data));
updateData(props.config, props.data, plotInstance.current);
}, [props, isConfigReady]);
// When component unmounts, clean the existing uPlot instance
......@@ -86,29 +80,12 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
);
};
function prepareData(frame: DataFrame): AlignedData {
return frame.fields.map((f) => {
if (f.type === FieldType.time) {
if (f.values.length > 0 && typeof f.values.get(0) === 'string') {
const timestamps = [];
for (let i = 0; i < f.values.length; i++) {
timestamps.push(dateTime(f.values.get(i)).valueOf());
}
return timestamps;
}
return f.values.toArray();
}
return f.values.toArray();
}) as AlignedData;
}
function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement) {
function initializePlot(data: AlignedData | null, config: Options, el: HTMLDivElement) {
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
return new uPlot(config, data, el);
}
function updateData(frame: DataFrame, config: UPlotConfigBuilder, plotInstance?: uPlot, data?: AlignedData | null) {
function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) {
if (!plotInstance || !data) {
return;
}
......
import React, { useCallback } from 'react';
import { DataFrame, DisplayValue, fieldReducers, reduceField } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import { VizLegendItem, VizLegendOptions } from '../VizLegend/types';
import { AxisPlacement } from './config';
import { VizLayout } from '../VizLayout/VizLayout';
import { mapMouseEventToMode } from '../GraphNG/utils';
import { VizLegend } from '../VizLegend/VizLegend';
import { GraphNGLegendEvent } from '..';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
interface PlotLegendProps extends VizLegendOptions {
data: DataFrame[];
config: UPlotConfigBuilder;
onSeriesColorChange?: (label: string, color: string) => void;
onLegendClick?: (event: GraphNGLegendEvent) => void;
}
export const PlotLegend: React.FC<PlotLegendProps> = ({
data,
config,
onSeriesColorChange,
onLegendClick,
...legend
}) => {
const onLegendLabelClick = useCallback(
(legend: VizLegendItem, event: React.MouseEvent) => {
const { fieldIndex } = legend;
if (!onLegendClick || !fieldIndex) {
return;
}
onLegendClick({
fieldIndex,
mode: mapMouseEventToMode(event),
});
},
[onLegendClick]
);
const legendItems = config
.getSeries()
.map<VizLegendItem | undefined>((s) => {
const seriesConfig = s.props;
const fieldIndex = seriesConfig.dataFrameFieldIndex;
const axisPlacement = config.getAxisPlacement(s.props.scaleKey);
if (seriesConfig.hideInLegend || !fieldIndex) {
return undefined;
}
const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex];
return {
disabled: !seriesConfig.show ?? false,
fieldIndex,
color: seriesConfig.lineColor!,
label: seriesConfig.fieldName,
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
getDisplayValues: () => {
if (!legend.calcs?.length) {
return [];
}
const fmt = field.display ?? defaultFormatter;
const fieldCalcs = reduceField({
field,
reducers: legend.calcs,
});
return legend.calcs.map<DisplayValue>((reducer) => {
return {
...fmt(fieldCalcs[reducer]),
title: fieldReducers.get(reducer).name,
};
});
},
};
})
.filter((i) => i !== undefined) as VizLegendItem[];
return (
<VizLayout.Legend placement={legend.placement} maxHeight="35%" maxWidth="60%">
<VizLegend
onLabelClick={onLegendLabelClick}
placement={legend.placement}
items={legendItems}
displayMode={legend.displayMode}
onSeriesColorChange={onSeriesColorChange}
/>
</VizLayout.Legend>
);
};
PlotLegend.displayName = 'PlotLegend';
......@@ -40,6 +40,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [
Object {},
],
"tzDate": [Function],
}
`);
});
......@@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [
Object {},
],
"tzDate": [Function],
}
`);
});
......@@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [
Object {},
],
"tzDate": [Function],
}
`);
});
......@@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [
Object {},
],
"tzDate": [Function],
}
`);
});
......@@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [
Object {},
],
"tzDate": [Function],
}
`);
});
......@@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [
Object {},
],
"tzDate": [Function],
}
`);
});
......@@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => {
"width": 1,
},
],
"tzDate": [Function],
}
`);
});
......
......@@ -3,8 +3,9 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { AxisPlacement } from '../config';
import { Cursor, Band, Hooks, BBox } from 'uplot';
import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
import { defaultsDeep } from 'lodash';
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
type valueof<T> = T[keyof T];
......@@ -20,6 +21,8 @@ export class UPlotConfigBuilder {
private hasBottomAxis = false;
private hooks: Hooks.Arrays = {};
constructor(private getTimeZone = () => DefaultTimeZone) {}
addHook(type: keyof Hooks.Defs, hook: valueof<Hooks.Defs>) {
if (!this.hooks[type]) {
this.hooks[type] = [];
......@@ -110,6 +113,8 @@ export class UPlotConfigBuilder {
config.cursor = this.cursor || {};
config.tzDate = this.tzDate;
// When bands exist, only keep fill when defined
if (this.bands?.length) {
config.bands = this.bands;
......@@ -159,4 +164,17 @@ export class UPlotConfigBuilder {
return axes;
}
private tzDate = (ts: number) => {
if (!this.getTimeZone) {
return new Date(ts);
}
const tz = getTimeZoneInfo(this.getTimeZone(), Date.now())?.ianaName;
if (!tz) {
return new Date(ts);
}
return uPlot.tzDate(new Date(ts), tz);
};
}
import React, { useCallback, useContext } from 'react';
import uPlot, { Series } from 'uplot';
import React, { useContext } from 'react';
import uPlot, { AlignedData, Series } from 'uplot';
import { PlotPlugin } from './types';
import { DataFrame, Field, FieldConfig } from '@grafana/data';
interface PlotCanvasContextType {
// canvas size css pxs
......@@ -26,7 +25,7 @@ interface PlotContextType extends PlotPluginsContextType {
getSeries: () => Series[];
getCanvas: () => PlotCanvasContextType;
canvasRef: any;
data: DataFrame;
data: AlignedData;
}
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
......@@ -51,85 +50,10 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
};
};
// Exposes API for building uPlot config
interface PlotDataAPI {
/** Data frame passed to graph, x-axis aligned */
data: DataFrame;
/** Returns field by index */
getField: (idx: number) => Field;
/** Returns x-axis fields */
getXAxisFields: () => Field[];
/** Returns x-axis fields */
getYAxisFields: () => Field[];
/** Returns field value by field and value index */
getFieldValue: (fieldIdx: number, rowIdx: number) => any;
/** Returns field config by field index */
getFieldConfig: (fieldIdx: number) => FieldConfig;
}
export const usePlotData = (): PlotDataAPI => {
const ctx = usePlotContext();
const getField = useCallback(
(idx: number) => {
if (!ctx) {
throwWhenNoContext('usePlotData');
}
return ctx!.data.fields[idx];
},
[ctx]
);
const getFieldConfig = useCallback(
(idx: number) => {
const field: Field = getField(idx);
return field.config;
},
[ctx]
);
const getFieldValue = useCallback(
(fieldIdx: number, rowIdx: number) => {
const field: Field = getField(fieldIdx);
return field.values.get(rowIdx);
},
[ctx]
);
const getXAxisFields = useCallback(() => {
// by uPlot convention x-axis is always first field
// this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html)
return [getField(0)];
}, [ctx]);
const getYAxisFields = useCallback(() => {
if (!ctx) {
throwWhenNoContext('usePlotData');
}
// by uPlot convention x-axis is always first field
// this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html)
return ctx!.data.fields.slice(1);
}, [ctx]);
if (!ctx) {
throwWhenNoContext('usePlotData');
}
return {
data: ctx.data,
getField,
getFieldValue,
getFieldConfig,
getXAxisFields,
getYAxisFields,
};
};
export const buildPlotContext = (
isPlotReady: boolean,
canvasRef: any,
data: DataFrame,
data: AlignedData,
registerPlugin: any,
getPlotInstance: () => uPlot | undefined
): PlotContextType => {
......
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PlotPlugin } from './types';
import { pluginLog } from './utils';
import uPlot, { Options, PaddingSide } from 'uplot';
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
import { Options, PaddingSide } from 'uplot';
import { usePlotPluginContext } from './context';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import usePrevious from 'react-use/lib/usePrevious';
import useMountedState from 'react-use/lib/useMountedState';
export const usePlotPlugins = () => {
/**
......@@ -108,22 +108,11 @@ export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
hooks: {},
};
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => {
export const usePlotConfig = (width: number, height: number, configBuilder: UPlotConfigBuilder) => {
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
const [isConfigReady, setIsConfigReady] = useState(false);
const currentConfig = useRef<Options>();
const tzDate = useMemo(() => {
let fmt = undefined;
const tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
if (tz) {
fmt = (ts: number) => uPlot.tzDate(new Date(ts), tz);
}
return fmt;
}, [timeZone]);
useLayoutEffect(() => {
if (!arePluginsReady) {
......@@ -137,12 +126,11 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
plugins: Object.entries(plugins).map((p) => ({
hooks: p[1].hooks,
})),
tzDate,
...configBuilder.getConfig(),
};
setIsConfigReady(true);
}, [arePluginsReady, plugins, width, height, tzDate, configBuilder]);
}, [arePluginsReady, plugins, width, height, configBuilder]);
return {
isConfigReady,
......@@ -158,6 +146,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
*/
export const useRefreshAfterGraphRendered = (pluginId: string) => {
const pluginsApi = usePlotPluginContext();
const isMounted = useMountedState();
const [renderToken, setRenderToken] = useState(0);
useEffect(() => {
......@@ -166,7 +155,9 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => {
hooks: {
// refresh events when uPlot draws
draw: () => {
setRenderToken((c) => c + 1);
if (isMounted()) {
setRenderToken((c) => c + 1);
}
return;
},
},
......
import React from 'react';
import { Portal } from '../../Portal/Portal';
import { usePlotContext, usePlotData } from '../context';
import { usePlotContext } from '../context';
import { CursorPlugin } from './CursorPlugin';
import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable';
import { FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data';
import {
DataFrame,
FieldType,
formattedValueToString,
getDisplayProcessor,
getFieldDisplayName,
TimeZone,
} from '@grafana/data';
import { TooltipContainer } from '../../Chart/TooltipContainer';
import { TooltipMode } from '../../Chart/Tooltip';
import { useGraphNGContext } from '../../GraphNG/hooks';
interface TooltipPluginProps {
mode?: TooltipMode;
timeZone: TimeZone;
data: DataFrame[];
}
/**
* @alpha
*/
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone }) => {
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone, ...otherProps }) => {
const pluginId = 'PlotTooltip';
const plotContext = usePlotContext();
const { data, getField, getXAxisFields } = usePlotData();
const graphContext = useGraphNGContext();
const xAxisFields = getXAxisFields();
// assuming single x-axis
const xAxisField = xAxisFields[0];
const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone });
let xField = graphContext.getXAxisField(otherProps.data);
if (!xField) {
return null;
}
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
return (
<CursorPlugin id={pluginId}>
......@@ -31,7 +42,6 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
if (!plotContext.getPlotInstance()) {
return null;
}
let tooltip = null;
// when no no cursor interaction
......@@ -39,10 +49,17 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
return null;
}
const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text;
// origin field/frame indexes for inspecting the data
const originFieldIndex = focusedSeriesIdx
? graphContext.mapSeriesIndexToDataFrameFieldIndex(focusedSeriesIdx)
: null;
// when interacting with a point in single mode
if (mode === 'single' && focusedSeriesIdx !== null) {
const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text;
const field = getField(focusedSeriesIdx);
if (mode === 'single' && originFieldIndex !== null) {
const field = otherProps.data[originFieldIndex.frameIndex].fields[originFieldIndex.fieldIndex];
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
tooltip = (
<SeriesTable
......@@ -50,7 +67,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
{
// TODO: align with uPlot typings
color: (plotContext.getSeries()[focusedSeriesIdx!].stroke as any)(),
label: getFieldDisplayName(field, data),
label: getFieldDisplayName(field, otherProps.data[originFieldIndex.frameIndex]),
value: fieldFmt(field.values.get(focusedPointIdx)).text,
},
]}
......@@ -60,10 +77,11 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
}
if (mode === 'multi') {
const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text;
tooltip = (
<SeriesTable
series={data.fields.reduce<SeriesTableRowProps[]>((agg, f, i) => {
let series: SeriesTableRowProps[] = [];
for (let i = 0; i < otherProps.data.length; i++) {
series = series.concat(
otherProps.data[i].fields.reduce<SeriesTableRowProps[]>((agg, f, j) => {
// skipping time field and non-numeric fields
if (f.type === FieldType.time || f.type !== FieldType.number) {
return agg;
......@@ -77,16 +95,19 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
...agg,
{
// TODO: align with uPlot typings
color: (plotContext.getSeries()[i].stroke as any)!(),
label: getFieldDisplayName(f, data),
color: (plotContext.getSeries()[j].stroke as any)!(),
label: getFieldDisplayName(f, otherProps.data[i]),
value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))),
isActive: focusedSeriesIdx === i,
isActive: originFieldIndex
? originFieldIndex.frameIndex === i && originFieldIndex.fieldIndex === j
: false,
},
];
}, [])}
timestamp={xVal}
/>
);
}, [])
);
}
tooltip = <SeriesTable series={series} timestamp={xVal} />;
}
if (!tooltip) {
......
import React from 'react';
import uPlot, { Options, Hooks } from 'uplot';
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
import uPlot, { Options, Hooks, AlignedData } from 'uplot';
import { TimeRange } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
export type PlotConfig = Pick<Options, 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select'>;
export type PlotConfig = Pick<
Options,
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
>;
export type PlotPlugin = {
id: string;
......@@ -17,12 +20,11 @@ export interface PlotPluginProps {
}
export interface PlotProps {
data: DataFrame;
timeRange: TimeRange;
timeZone: TimeZone;
data: AlignedData;
width: number;
height: number;
config: UPlotConfigBuilder;
timeRange: TimeRange;
children?: React.ReactNode;
}
......
import { DataFrame, dateTime, FieldType } from '@grafana/data';
import throttle from 'lodash/throttle';
import { Options } from 'uplot';
import { AlignedData, Options } from 'uplot';
import { PlotPlugin, PlotProps } from './types';
const LOGGING_ENABLED = false;
......@@ -31,29 +32,32 @@ export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPl
} as Options;
}
export function isPlottingTime(config: Options) {
let isTimeSeries = false;
if (!config.scales) {
return false;
}
for (let i = 0; i < Object.keys(config.scales).length; i++) {
const key = Object.keys(config.scales)[i];
if (config.scales[key].time === true) {
isTimeSeries = true;
break;
/** @internal */
export function preparePlotData(frame: DataFrame): AlignedData {
return frame.fields.map((f) => {
if (f.type === FieldType.time) {
if (f.values.length > 0 && typeof f.values.get(0) === 'string') {
const timestamps = [];
for (let i = 0; i < f.values.length; i++) {
timestamps.push(dateTime(f.values.get(i)).valueOf());
}
return timestamps;
}
return f.values.toArray();
}
}
return isTimeSeries;
return f.values.toArray();
}) as AlignedData;
}
// Dev helpers
/** @internal */
export const throttledLog = throttle((...t: any[]) => {
console.log(...t);
}, 500);
/** @internal */
export function pluginLog(id: string, throttle = false, ...t: any[]) {
if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) {
return;
......
......@@ -129,14 +129,10 @@ export function ExploreGraphNGPanel({
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeZone={timeZone}
>
<TooltipPlugin mode="single" timeZone={timeZone} />
<ZoomPlugin onZoom={onUpdateTimeRange} />
<ContextMenuPlugin timeZone={timeZone} />
{annotations ? (
<ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
) : (
<></>
)}
<TooltipPlugin data={data} mode="single" timeZone={timeZone} />
<ContextMenuPlugin data={data} timeZone={timeZone} />
{annotations && <ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />}
</GraphNG>
</Collapse>
</>
......
import React, { useCallback, useMemo } from 'react';
import { DataFrame, Field, FieldType, PanelProps } from '@grafana/data';
import { FieldType, PanelProps, VizOrientation } from '@grafana/data';
import { BarChart, BarChartOptions, GraphNGLegendEvent } from '@grafana/ui';
import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory';
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
import { config } from 'app/core/config';
interface Props extends PanelProps<BarChartOptions> {}
interface BarData {
error?: string;
frame?: DataFrame; // first string vs all numbers
}
/**
* @alpha
*/
......@@ -23,13 +17,13 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
fieldConfig,
onFieldConfigChange,
}) => {
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
const orientation = useMemo(() => {
if (!options.orientation || options.orientation === VizOrientation.Auto) {
return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
}
return options.orientation;
}, [width, height, options.orientation]);
const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => {
......@@ -45,59 +39,46 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
[fieldConfig, onFieldConfigChange]
);
const barData = useMemo<BarData>(() => {
const firstFrame = data.series[0];
const firstString = firstFrame.fields.find((f) => f.type === FieldType.string);
if (!firstString) {
return {
error: 'Bar charts requires a string field',
};
}
const fields: Field[] = [firstString];
for (const f of firstFrame.fields) {
if (f.type === FieldType.number) {
fields.push(f);
}
}
if (fields.length < 2) {
return {
error: 'No numeric fields found',
};
}
return {
frame: {
...firstFrame,
fields, // filtered to to the values we have
},
};
}, [width, height, options, data]);
if (barData.error) {
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
<p>{barData.error}</p>
<p>No data found in response</p>
</div>
);
}
if (!barData.frame) {
const firstFrame = data.series[0];
if (!firstFrame.fields.find((f) => f.type === FieldType.string)) {
return (
<div className="panel-empty">
<p>No data found in response</p>
<p>Bar charts requires a string field</p>
</div>
);
}
if (
firstFrame.fields.reduce((acc, f) => {
if (f.type === FieldType.number) {
return acc + 1;
}
return acc;
}, 0) < 2
) {
return (
<div className="panel-empty">
<p>No numeric fields found</p>
</div>
);
}
return (
<BarChart
data={barData.frame}
data={data.series}
width={width}
height={height}
theme={config.theme}
onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange}
{...options}
orientation={orientation}
/>
);
};
......@@ -41,6 +41,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
[fieldConfig, onFieldConfigChange]
);
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
return (
<GraphNG
data={data.series}
......@@ -52,9 +60,9 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange}
>
<TooltipPlugin mode={options.tooltipOptions.mode} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />
<ContextMenuPlugin timeZone={timeZone} replaceVariables={replaceVariables} />
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode} timeZone={timeZone} />
<ContextMenuPlugin data={data.series} timeZone={timeZone} replaceVariables={replaceVariables} />
{data.annotations && (
<ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
)}
......
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import {
ClickPlugin,
ContextMenu,
......@@ -7,12 +7,11 @@ import {
MenuItem,
MenuItemsGroup,
Portal,
usePlotData,
useGraphNGContext,
} from '@grafana/ui';
import {
DataFrame,
DataFrameView,
DisplayValue,
Field,
getDisplayProcessor,
getFieldDisplayName,
InterpolateFunction,
......@@ -22,6 +21,7 @@ import { useClickAway } from 'react-use';
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
interface ContextMenuPluginProps {
data: DataFrame[];
defaultItems?: MenuItemsGroup[];
timeZone: TimeZone;
onOpen?: () => void;
......@@ -30,6 +30,7 @@ interface ContextMenuPluginProps {
}
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
data,
onClose,
timeZone,
defaultItems,
......@@ -47,6 +48,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
return (
<Portal>
<ContextMenuView
data={data}
defaultItems={defaultItems}
timeZone={timeZone}
selection={{ point, coords }}
......@@ -66,6 +68,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
};
interface ContextMenuProps {
data: DataFrame[];
defaultItems?: MenuItemsGroup[];
timeZone: TimeZone;
onClose?: () => void;
......@@ -81,11 +84,11 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
timeZone,
defaultItems,
replaceVariables,
data,
...otherProps
}) => {
const ref = useRef(null);
const { data } = usePlotData();
const { seriesIdx, dataIdx } = selection.point;
const graphContext = useGraphNGContext();
const onClose = () => {
if (otherProps.onClose) {
......@@ -97,65 +100,69 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
onClose();
});
const contextMenuProps = useMemo(() => {
const items = defaultItems ? [...defaultItems] : [];
let field: Field;
let displayValue: DisplayValue;
const timeField = data.fields[0];
const timeFormatter = timeField.display || getDisplayProcessor({ field: timeField, timeZone });
let renderHeader: () => JSX.Element | null = () => null;
if (seriesIdx && dataIdx) {
field = data.fields[seriesIdx];
displayValue = field.display!(field.values.get(dataIdx));
const hasLinks = field.config.links && field.config.links.length > 0;
if (hasLinks) {
const linksSupplier = getFieldLinksSupplier({
display: displayValue,
name: field.name,
view: new DataFrameView(data),
rowIndex: dataIdx,
colIndex: seriesIdx,
field: field.config,
hasLinks,
});
const xField = graphContext.getXAxisField(data);
if (linksSupplier) {
items.push({
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => {
return {
label: link.title,
url: link.href,
target: link.target,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
onClick: link.onClick,
};
}),
});
}
}
if (!xField) {
return null;
}
const items = defaultItems ? [...defaultItems] : [];
let renderHeader: () => JSX.Element | null = () => null;
const { seriesIdx, dataIdx } = selection.point;
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
// eslint-disable-next-line react/display-name
renderHeader = () => (
<GraphContextMenuHeader
timestamp={timeFormatter(timeField.values.get(dataIdx)).text}
displayValue={displayValue}
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, data)}
/>
);
if (seriesIdx && dataIdx) {
// origin field/frame indexes for inspecting the data
const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx);
const frame = data[originFieldIndex.frameIndex];
const field = frame.fields[originFieldIndex.fieldIndex];
const displayValue = field.display!(field.values.get(dataIdx));
const hasLinks = field.config.links && field.config.links.length > 0;
if (hasLinks) {
const linksSupplier = getFieldLinksSupplier({
display: displayValue,
name: field.name,
view: new DataFrameView(frame),
rowIndex: dataIdx,
colIndex: originFieldIndex.fieldIndex,
field: field.config,
hasLinks,
});
if (linksSupplier) {
items.push({
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => {
return {
label: link.title,
url: link.href,
target: link.target,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
onClick: link.onClick,
};
}),
});
}
}
return {
renderHeader,
items,
};
}, [defaultItems, seriesIdx, dataIdx, data]);
// eslint-disable-next-line react/display-name
renderHeader = () => (
<GraphContextMenuHeader
timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
displayValue={displayValue}
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, frame)}
/>
);
}
return (
<ContextMenu
{...contextMenuProps}
items={items}
renderHeader={renderHeader}
x={selection.coords.viewport.x}
y={selection.coords.viewport.y}
onClose={onClose}
......
......@@ -19,6 +19,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
onFieldConfigChange,
}) => {
const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]);
if (dims.error) {
return (
<div>
......@@ -61,7 +62,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange}
>
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<>{/* needs to be an array */}</>
</GraphNG>
);
......
import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data';
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/GraphNG';
import { XYDimensionConfig } from './types';
// TODO: fix import
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
export enum DimensionError {
NoData,
BadFrameSelection,
......@@ -21,7 +23,7 @@ export function isGraphable(field: Field) {
return field.type === FieldType.number;
}
export function getXYDimensions(cfg: XYDimensionConfig, data?: DataFrame[]): XYDimensions {
export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions {
if (!data || !data.length) {
return { error: DimensionError.NoData } as XYDimensions;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment