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 ...@@ -112,10 +112,10 @@ export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings
export interface ApplyFieldOverrideOptions { export interface ApplyFieldOverrideOptions {
data?: DataFrame[]; data?: DataFrame[];
fieldConfig: FieldConfigSource; fieldConfig: FieldConfigSource;
fieldConfigRegistry?: FieldConfigOptionsRegistry;
replaceVariables: InterpolateFunction; replaceVariables: InterpolateFunction;
theme: GrafanaTheme; theme: GrafanaTheme;
timeZone?: TimeZone; timeZone?: TimeZone;
fieldConfigRegistry?: FieldConfigOptionsRegistry;
} }
export enum FieldConfigProperty { export enum FieldConfigProperty {
......
...@@ -61,5 +61,5 @@ export const Basic: React.FC = () => { ...@@ -61,5 +61,5 @@ export const Basic: React.FC = () => {
groupWidth: 0.7, 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 * Mode to describe if a legend is isolated/selected or being appended to an existing
...@@ -18,3 +18,8 @@ export interface GraphNGLegendEvent { ...@@ -18,3 +18,8 @@ export interface GraphNGLegendEvent {
fieldIndex: DataFrameFieldIndex; fieldIndex: DataFrameFieldIndex;
mode: GraphNGLegendEventMode; 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 React, { PureComponent } from 'react';
import { AlignedData } from 'uplot';
import { import {
compareDataFrameStructures, compareDataFrameStructures,
DefaultTimeZone,
FieldSparkline,
IndexVector,
DataFrame, DataFrame,
FieldConfig,
FieldSparkline,
FieldType, FieldType,
getFieldColorModeForField, getFieldColorModeForField,
FieldConfig,
getFieldDisplayName, getFieldDisplayName,
} from '@grafana/data'; } from '@grafana/data';
import { import {
...@@ -21,8 +20,10 @@ import { ...@@ -21,8 +20,10 @@ import {
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { UPlotChart } from '../uPlot/Plot'; import { UPlotChart } from '../uPlot/Plot';
import { Themeable } from '../../types'; 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; width: number;
height: number; height: number;
config?: FieldConfig<GraphFieldConfig>; config?: FieldConfig<GraphFieldConfig>;
...@@ -30,7 +31,8 @@ export interface Props extends Themeable { ...@@ -30,7 +31,8 @@ export interface Props extends Themeable {
} }
interface State { interface State {
data: DataFrame; data: AlignedData;
alignedDataFrame: DataFrame;
configBuilder: UPlotConfigBuilder; configBuilder: UPlotConfigBuilder;
} }
...@@ -40,51 +42,53 @@ const defaultConfig: GraphFieldConfig = { ...@@ -40,51 +42,53 @@ const defaultConfig: GraphFieldConfig = {
axisPlacement: AxisPlacement.Hidden, axisPlacement: AxisPlacement.Hidden,
}; };
export class Sparkline extends PureComponent<Props, State> { export class Sparkline extends PureComponent<SparklineProps, State> {
constructor(props: Props) { constructor(props: SparklineProps) {
super(props); super(props);
const data = this.prepareData(props); const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
const data = preparePlotData(alignedDataFrame);
this.state = { this.state = {
data, data,
configBuilder: this.prepareConfig(data, props), alignedDataFrame,
configBuilder: this.prepareConfig(alignedDataFrame),
}; };
} }
componentDidUpdate(oldProps: Props) { static getDerivedStateFromProps(props: SparklineProps, state: State) {
if (oldProps.sparkline !== this.props.sparkline) { const frame = preparePlotFrame(props.sparkline, props.config);
const data = this.prepareData(this.props); if (!frame) {
if (!compareDataFrameStructures(this.state.data, data)) { return { ...state };
const configBuilder = this.prepareConfig(data, this.props);
this.setState({ data, configBuilder });
} else {
this.setState({ data });
}
} }
}
prepareData(props: Props): DataFrame {
const { sparkline } = props;
const length = sparkline.y.values.length;
const yFieldConfig = {
...sparkline.y.config,
...this.props.config,
};
return { return {
refId: 'sparkline', ...state,
fields: [ data: preparePlotData(frame),
sparkline.x ?? IndexVector.newField(length), alignedDataFrame: frame,
{
...sparkline.y,
config: yFieldConfig,
},
],
length,
}; };
} }
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 { theme } = this.props;
const builder = new UPlotConfigBuilder(); const builder = new UPlotConfigBuilder();
...@@ -174,14 +178,7 @@ export class Sparkline extends PureComponent<Props, State> { ...@@ -174,14 +178,7 @@ export class Sparkline extends PureComponent<Props, State> {
const { width, height, sparkline } = this.props; const { width, height, sparkline } = this.props;
return ( return (
<UPlotChart <UPlotChart data={data} config={configBuilder} width={width} height={height} timeRange={sparkline.timeRange!} />
data={data}
config={configBuilder}
width={width}
height={height}
timeRange={sparkline.timeRange!}
timeZone={DefaultTimeZone}
/>
); );
} }
} }
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 = () => { ...@@ -24,7 +24,7 @@ export const BottomLegend = () => {
const items = Array.from({ length: legendItems }, (_, i) => i + 1); const items = Array.from({ length: legendItems }, (_, i) => i + 1);
const legend = ( const legend = (
<VizLayout.Legend position="bottom" maxHeight="30%"> <VizLayout.Legend placement="bottom" maxHeight="30%">
{items.map((_, index) => ( {items.map((_, index) => (
<div style={{ height: '30px', width: '100%', background: 'blue', marginBottom: '2px' }} key={index}> <div style={{ height: '30px', width: '100%', background: 'blue', marginBottom: '2px' }} key={index}>
Legend item {index} Legend item {index}
...@@ -47,7 +47,7 @@ export const RightLegend = () => { ...@@ -47,7 +47,7 @@ export const RightLegend = () => {
const items = Array.from({ length: legendItems }, (_, i) => i + 1); const items = Array.from({ length: legendItems }, (_, i) => i + 1);
const legend = ( const legend = (
<VizLayout.Legend position="right" maxWidth="50%"> <VizLayout.Legend placement="right" maxWidth="50%">
{items.map((_, index) => ( {items.map((_, index) => (
<div style={{ height: '30px', width: `${legendWidth}px`, background: 'blue', marginBottom: '2px' }} key={index}> <div style={{ height: '30px', width: `${legendWidth}px`, background: 'blue', marginBottom: '2px' }} key={index}>
Legend item {index} Legend item {index}
......
import React, { FC, CSSProperties, ComponentType } from 'react'; import React, { FC, CSSProperties, ComponentType } from 'react';
import { useMeasure } from 'react-use'; import { useMeasure } from 'react-use';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { LegendPlacement } from '..';
/** /**
* @beta * @beta
...@@ -33,7 +34,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child ...@@ -33,7 +34,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
return <div style={containerStyle}>{children(width, height)}</div>; return <div style={containerStyle}>{children(width, height)}</div>;
} }
const { position, maxHeight, maxWidth } = legend.props; const { placement, maxHeight, maxWidth } = legend.props;
const [legendRef, legendMeasure] = useMeasure(); const [legendRef, legendMeasure] = useMeasure();
let size: VizSize | null = null; let size: VizSize | null = null;
...@@ -43,7 +44,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child ...@@ -43,7 +44,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
const legendStyle: CSSProperties = {}; const legendStyle: CSSProperties = {};
switch (position) { switch (placement) {
case 'bottom': case 'bottom':
containerStyle.flexDirection = 'column'; containerStyle.flexDirection = 'column';
legendStyle.maxHeight = maxHeight; legendStyle.maxHeight = maxHeight;
...@@ -91,7 +92,7 @@ interface VizSize { ...@@ -91,7 +92,7 @@ interface VizSize {
* @beta * @beta
*/ */
export interface VizLayoutLegendProps { export interface VizLayoutLegendProps {
position: 'bottom' | 'right'; placement: LegendPlacement;
maxHeight?: string; maxHeight?: string;
maxWidth?: string; maxWidth?: string;
children: React.ReactNode; children: React.ReactNode;
......
...@@ -205,8 +205,9 @@ export { UPlotChart } from './uPlot/Plot'; ...@@ -205,8 +205,9 @@ export { UPlotChart } from './uPlot/Plot';
export * from './uPlot/geometries'; export * from './uPlot/geometries';
export * from './uPlot/plugins'; export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks'; 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 { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
export { useGraphNGContext } from './GraphNG/hooks';
export { BarChart } from './BarChart/BarChart'; export { BarChart } from './BarChart/BarChart';
export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types'; export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types'; export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';
......
...@@ -6,6 +6,7 @@ import { GraphFieldConfig, DrawStyle } from '../uPlot/config'; ...@@ -6,6 +6,7 @@ import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
import uPlot from 'uplot'; import uPlot from 'uplot';
import createMockRaf from 'mock-raf'; import createMockRaf from 'mock-raf';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import { preparePlotData } from './utils';
const mockRaf = createMockRaf(); const mockRaf = createMockRaf();
const setDataMock = jest.fn(); const setDataMock = jest.fn();
...@@ -71,10 +72,9 @@ describe('UPlotChart', () => { ...@@ -71,10 +72,9 @@ describe('UPlotChart', () => {
const { unmount } = render( const { unmount } = render(
<UPlotChart <UPlotChart
data={data} // mock data={preparePlotData(data)} // mock
config={config} config={config}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={100} width={100}
height={100} height={100}
/> />
...@@ -96,10 +96,9 @@ describe('UPlotChart', () => { ...@@ -96,10 +96,9 @@ describe('UPlotChart', () => {
const { rerender } = render( const { rerender } = render(
<UPlotChart <UPlotChart
data={data} // mock data={preparePlotData(data)} // mock
config={config} config={config}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={100} width={100}
height={100} height={100}
/> />
...@@ -116,10 +115,9 @@ describe('UPlotChart', () => { ...@@ -116,10 +115,9 @@ describe('UPlotChart', () => {
rerender( rerender(
<UPlotChart <UPlotChart
data={data} // changed data={preparePlotData(data)} // changed
config={config} config={config}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={100} width={100}
height={100} height={100}
/> />
...@@ -134,7 +132,7 @@ describe('UPlotChart', () => { ...@@ -134,7 +132,7 @@ describe('UPlotChart', () => {
const { data, timeRange, config } = mockData(); const { data, timeRange, config } = mockData();
const { queryAllByTestId } = render( 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); expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
...@@ -146,10 +144,9 @@ describe('UPlotChart', () => { ...@@ -146,10 +144,9 @@ describe('UPlotChart', () => {
const { rerender } = render( const { rerender } = render(
<UPlotChart <UPlotChart
data={data} // frame data={preparePlotData(data)} // frame
config={config} config={config}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={100} width={100}
height={100} height={100}
/> />
...@@ -164,10 +161,9 @@ describe('UPlotChart', () => { ...@@ -164,10 +161,9 @@ describe('UPlotChart', () => {
rerender( rerender(
<UPlotChart <UPlotChart
data={data} data={preparePlotData(data)}
config={new UPlotConfigBuilder()} config={new UPlotConfigBuilder()}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={100} width={100}
height={100} height={100}
/> />
...@@ -182,10 +178,9 @@ describe('UPlotChart', () => { ...@@ -182,10 +178,9 @@ describe('UPlotChart', () => {
const { rerender } = render( const { rerender } = render(
<UPlotChart <UPlotChart
data={data} // frame data={preparePlotData(data)} // frame
config={config} config={config}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={100} width={100}
height={100} height={100}
/> />
...@@ -198,10 +193,9 @@ describe('UPlotChart', () => { ...@@ -198,10 +193,9 @@ describe('UPlotChart', () => {
rerender( rerender(
<UPlotChart <UPlotChart
data={data} // frame data={preparePlotData(data)} // frame
config={new UPlotConfigBuilder()} config={new UPlotConfigBuilder()}
timeRange={timeRange} timeRange={timeRange}
timeZone={'browser'}
width={200} width={200}
height={200} height={200}
/> />
......
...@@ -4,7 +4,6 @@ import { buildPlotContext, PlotContext } from './context'; ...@@ -4,7 +4,6 @@ import { buildPlotContext, PlotContext } from './context';
import { pluginLog } from './utils'; import { pluginLog } from './utils';
import { usePlotConfig } from './hooks'; import { usePlotConfig } from './hooks';
import { PlotProps } from './types'; import { PlotProps } from './types';
import { DataFrame, dateTime, FieldType } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import usePrevious from 'react-use/lib/usePrevious'; import usePrevious from 'react-use/lib/usePrevious';
...@@ -19,12 +18,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => { ...@@ -19,12 +18,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
const plotInstance = useRef<uPlot>(); const plotInstance = useRef<uPlot>();
const [isPlotReady, setIsPlotReady] = useState(false); const [isPlotReady, setIsPlotReady] = useState(false);
const prevProps = usePrevious(props); const prevProps = usePrevious(props);
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig( const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config);
props.width,
props.height,
props.timeZone,
props.config
);
const getPlotInstance = useCallback(() => { const getPlotInstance = useCallback(() => {
return plotInstance.current; return plotInstance.current;
...@@ -39,7 +33,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => { ...@@ -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 // 1. When config is ready and there is no uPlot instance, create new uPlot and return
if (isConfigReady && !plotInstance.current) { 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); setIsPlotReady(true);
return; return;
} }
...@@ -54,18 +48,18 @@ export const UPlotChart: React.FC<PlotProps> = (props) => { ...@@ -54,18 +48,18 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
return; return;
} }
// 3. When config or timezone has changed, re-initialize plot // 3. When config has changed re-initialize plot
if (isConfigReady && (props.config !== prevProps.config || props.timeZone !== prevProps.timeZone)) { if (isConfigReady && props.config !== prevProps.config) {
if (plotInstance.current) { if (plotInstance.current) {
pluginLog('uPlot core', false, 'destroying instance'); pluginLog('uPlot core', false, 'destroying instance');
plotInstance.current.destroy(); plotInstance.current.destroy();
} }
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current); plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
return; return;
} }
// 4. Otherwise, assume only data has changed and update uPlot data // 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]); }, [props, isConfigReady]);
// When component unmounts, clean the existing uPlot instance // When component unmounts, clean the existing uPlot instance
...@@ -86,29 +80,12 @@ export const UPlotChart: React.FC<PlotProps> = (props) => { ...@@ -86,29 +80,12 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
); );
}; };
function prepareData(frame: DataFrame): AlignedData { function initializePlot(data: AlignedData | null, config: Options, el: HTMLDivElement) {
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) {
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config); pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
return new uPlot(config, data, el); 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) { if (!plotInstance || !data) {
return; 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', () => { ...@@ -40,6 +40,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
...@@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => { ...@@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
...@@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => { ...@@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
...@@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => { ...@@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
...@@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => { ...@@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
...@@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => { ...@@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
...@@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => { ...@@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => {
"width": 1, "width": 1,
}, },
], ],
"tzDate": [Function],
} }
`); `);
}); });
......
...@@ -3,8 +3,9 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder'; ...@@ -3,8 +3,9 @@ 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';
import { AxisPlacement } from '../config'; import { AxisPlacement } from '../config';
import { Cursor, Band, Hooks, BBox } from 'uplot'; import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
import { defaultsDeep } from 'lodash'; import { defaultsDeep } from 'lodash';
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
type valueof<T> = T[keyof T]; type valueof<T> = T[keyof T];
...@@ -20,6 +21,8 @@ export class UPlotConfigBuilder { ...@@ -20,6 +21,8 @@ export class UPlotConfigBuilder {
private hasBottomAxis = false; private hasBottomAxis = false;
private hooks: Hooks.Arrays = {}; private hooks: Hooks.Arrays = {};
constructor(private getTimeZone = () => DefaultTimeZone) {}
addHook(type: keyof Hooks.Defs, hook: valueof<Hooks.Defs>) { addHook(type: keyof Hooks.Defs, hook: valueof<Hooks.Defs>) {
if (!this.hooks[type]) { if (!this.hooks[type]) {
this.hooks[type] = []; this.hooks[type] = [];
...@@ -110,6 +113,8 @@ export class UPlotConfigBuilder { ...@@ -110,6 +113,8 @@ export class UPlotConfigBuilder {
config.cursor = this.cursor || {}; config.cursor = this.cursor || {};
config.tzDate = this.tzDate;
// When bands exist, only keep fill when defined // When bands exist, only keep fill when defined
if (this.bands?.length) { if (this.bands?.length) {
config.bands = this.bands; config.bands = this.bands;
...@@ -159,4 +164,17 @@ export class UPlotConfigBuilder { ...@@ -159,4 +164,17 @@ export class UPlotConfigBuilder {
return axes; 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 React, { useContext } from 'react';
import uPlot, { Series } from 'uplot'; import uPlot, { AlignedData, Series } from 'uplot';
import { PlotPlugin } from './types'; import { PlotPlugin } from './types';
import { DataFrame, Field, FieldConfig } from '@grafana/data';
interface PlotCanvasContextType { interface PlotCanvasContextType {
// canvas size css pxs // canvas size css pxs
...@@ -26,7 +25,7 @@ interface PlotContextType extends PlotPluginsContextType { ...@@ -26,7 +25,7 @@ interface PlotContextType extends PlotPluginsContextType {
getSeries: () => Series[]; getSeries: () => Series[];
getCanvas: () => PlotCanvasContextType; getCanvas: () => PlotCanvasContextType;
canvasRef: any; canvasRef: any;
data: DataFrame; data: AlignedData;
} }
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType); export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
...@@ -51,85 +50,10 @@ export const usePlotPluginContext = (): PlotPluginsContextType => { ...@@ -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 = ( export const buildPlotContext = (
isPlotReady: boolean, isPlotReady: boolean,
canvasRef: any, canvasRef: any,
data: DataFrame, data: AlignedData,
registerPlugin: any, registerPlugin: any,
getPlotInstance: () => uPlot | undefined getPlotInstance: () => uPlot | undefined
): PlotContextType => { ): PlotContextType => {
......
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { PlotPlugin } from './types'; import { PlotPlugin } from './types';
import { pluginLog } from './utils'; import { pluginLog } from './utils';
import uPlot, { Options, PaddingSide } from 'uplot'; import { Options, PaddingSide } from 'uplot';
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
import { usePlotPluginContext } from './context'; import { usePlotPluginContext } from './context';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
import usePrevious from 'react-use/lib/usePrevious'; import usePrevious from 'react-use/lib/usePrevious';
import useMountedState from 'react-use/lib/useMountedState';
export const usePlotPlugins = () => { export const usePlotPlugins = () => {
/** /**
...@@ -108,22 +108,11 @@ export const DEFAULT_PLOT_CONFIG: Partial<Options> = { ...@@ -108,22 +108,11 @@ export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
hooks: {}, 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 { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
const [isConfigReady, setIsConfigReady] = useState(false); const [isConfigReady, setIsConfigReady] = useState(false);
const currentConfig = useRef<Options>(); 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(() => { useLayoutEffect(() => {
if (!arePluginsReady) { if (!arePluginsReady) {
...@@ -137,12 +126,11 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, ...@@ -137,12 +126,11 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
plugins: Object.entries(plugins).map((p) => ({ plugins: Object.entries(plugins).map((p) => ({
hooks: p[1].hooks, hooks: p[1].hooks,
})), })),
tzDate,
...configBuilder.getConfig(), ...configBuilder.getConfig(),
}; };
setIsConfigReady(true); setIsConfigReady(true);
}, [arePluginsReady, plugins, width, height, tzDate, configBuilder]); }, [arePluginsReady, plugins, width, height, configBuilder]);
return { return {
isConfigReady, isConfigReady,
...@@ -158,6 +146,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, ...@@ -158,6 +146,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
*/ */
export const useRefreshAfterGraphRendered = (pluginId: string) => { export const useRefreshAfterGraphRendered = (pluginId: string) => {
const pluginsApi = usePlotPluginContext(); const pluginsApi = usePlotPluginContext();
const isMounted = useMountedState();
const [renderToken, setRenderToken] = useState(0); const [renderToken, setRenderToken] = useState(0);
useEffect(() => { useEffect(() => {
...@@ -166,7 +155,9 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => { ...@@ -166,7 +155,9 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => {
hooks: { hooks: {
// refresh events when uPlot draws // refresh events when uPlot draws
draw: () => { draw: () => {
setRenderToken((c) => c + 1); if (isMounted()) {
setRenderToken((c) => c + 1);
}
return; return;
}, },
}, },
......
import React from 'react'; import React from 'react';
import { Portal } from '../../Portal/Portal'; import { Portal } from '../../Portal/Portal';
import { usePlotContext, usePlotData } from '../context'; import { usePlotContext } from '../context';
import { CursorPlugin } from './CursorPlugin'; import { CursorPlugin } from './CursorPlugin';
import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable'; 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 { TooltipContainer } from '../../Chart/TooltipContainer';
import { TooltipMode } from '../../Chart/Tooltip'; import { TooltipMode } from '../../Chart/Tooltip';
import { useGraphNGContext } from '../../GraphNG/hooks';
interface TooltipPluginProps { interface TooltipPluginProps {
mode?: TooltipMode; mode?: TooltipMode;
timeZone: TimeZone; timeZone: TimeZone;
data: DataFrame[];
} }
/** /**
* @alpha * @alpha
*/ */
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone }) => { export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone, ...otherProps }) => {
const pluginId = 'PlotTooltip'; const pluginId = 'PlotTooltip';
const plotContext = usePlotContext(); const plotContext = usePlotContext();
const { data, getField, getXAxisFields } = usePlotData(); const graphContext = useGraphNGContext();
const xAxisFields = getXAxisFields(); let xField = graphContext.getXAxisField(otherProps.data);
// assuming single x-axis if (!xField) {
const xAxisField = xAxisFields[0]; return null;
const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone }); }
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
return ( return (
<CursorPlugin id={pluginId}> <CursorPlugin id={pluginId}>
...@@ -31,7 +42,6 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -31,7 +42,6 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
if (!plotContext.getPlotInstance()) { if (!plotContext.getPlotInstance()) {
return null; return null;
} }
let tooltip = null; let tooltip = null;
// when no no cursor interaction // when no no cursor interaction
...@@ -39,10 +49,17 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -39,10 +49,17 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
return null; 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 // when interacting with a point in single mode
if (mode === 'single' && focusedSeriesIdx !== null) { if (mode === 'single' && originFieldIndex !== null) {
const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text; const field = otherProps.data[originFieldIndex.frameIndex].fields[originFieldIndex.fieldIndex];
const field = getField(focusedSeriesIdx);
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone }); const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
tooltip = ( tooltip = (
<SeriesTable <SeriesTable
...@@ -50,7 +67,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -50,7 +67,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
{ {
// TODO: align with uPlot typings // TODO: align with uPlot typings
color: (plotContext.getSeries()[focusedSeriesIdx!].stroke as any)(), 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, value: fieldFmt(field.values.get(focusedPointIdx)).text,
}, },
]} ]}
...@@ -60,10 +77,11 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -60,10 +77,11 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
} }
if (mode === 'multi') { if (mode === 'multi') {
const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text; let series: SeriesTableRowProps[] = [];
tooltip = (
<SeriesTable for (let i = 0; i < otherProps.data.length; i++) {
series={data.fields.reduce<SeriesTableRowProps[]>((agg, f, i) => { series = series.concat(
otherProps.data[i].fields.reduce<SeriesTableRowProps[]>((agg, f, j) => {
// skipping time field and non-numeric fields // skipping time field and non-numeric fields
if (f.type === FieldType.time || f.type !== FieldType.number) { if (f.type === FieldType.time || f.type !== FieldType.number) {
return agg; return agg;
...@@ -77,16 +95,19 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t ...@@ -77,16 +95,19 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
...agg, ...agg,
{ {
// TODO: align with uPlot typings // TODO: align with uPlot typings
color: (plotContext.getSeries()[i].stroke as any)!(), color: (plotContext.getSeries()[j].stroke as any)!(),
label: getFieldDisplayName(f, data), label: getFieldDisplayName(f, otherProps.data[i]),
value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))), 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) { if (!tooltip) {
......
import React from 'react'; import React from 'react';
import uPlot, { Options, Hooks } from 'uplot'; import uPlot, { Options, Hooks, AlignedData } from 'uplot';
import { DataFrame, TimeRange, TimeZone } from '@grafana/data'; import { TimeRange } from '@grafana/data';
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder'; 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 = { export type PlotPlugin = {
id: string; id: string;
...@@ -17,12 +20,11 @@ export interface PlotPluginProps { ...@@ -17,12 +20,11 @@ export interface PlotPluginProps {
} }
export interface PlotProps { export interface PlotProps {
data: DataFrame; data: AlignedData;
timeRange: TimeRange;
timeZone: TimeZone;
width: number; width: number;
height: number; height: number;
config: UPlotConfigBuilder; config: UPlotConfigBuilder;
timeRange: TimeRange;
children?: React.ReactNode; children?: React.ReactNode;
} }
......
import { DataFrame, dateTime, FieldType } from '@grafana/data';
import throttle from 'lodash/throttle'; import throttle from 'lodash/throttle';
import { Options } from 'uplot'; import { AlignedData, Options } from 'uplot';
import { PlotPlugin, PlotProps } from './types'; import { PlotPlugin, PlotProps } from './types';
const LOGGING_ENABLED = false; const LOGGING_ENABLED = false;
...@@ -31,29 +32,32 @@ export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPl ...@@ -31,29 +32,32 @@ export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPl
} as Options; } as Options;
} }
export function isPlottingTime(config: Options) { /** @internal */
let isTimeSeries = false; export function preparePlotData(frame: DataFrame): AlignedData {
return frame.fields.map((f) => {
if (!config.scales) { if (f.type === FieldType.time) {
return false; if (f.values.length > 0 && typeof f.values.get(0) === 'string') {
} const timestamps = [];
for (let i = 0; i < f.values.length; i++) {
for (let i = 0; i < Object.keys(config.scales).length; i++) { timestamps.push(dateTime(f.values.get(i)).valueOf());
const key = Object.keys(config.scales)[i]; }
if (config.scales[key].time === true) { return timestamps;
isTimeSeries = true; }
break; return f.values.toArray();
} }
}
return isTimeSeries; return f.values.toArray();
}) as AlignedData;
} }
// Dev helpers // Dev helpers
/** @internal */
export const throttledLog = throttle((...t: any[]) => { export const throttledLog = throttle((...t: any[]) => {
console.log(...t); console.log(...t);
}, 500); }, 500);
/** @internal */
export function pluginLog(id: string, throttle = false, ...t: any[]) { export function pluginLog(id: string, throttle = false, ...t: any[]) {
if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) { if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) {
return; return;
......
...@@ -129,14 +129,10 @@ export function ExploreGraphNGPanel({ ...@@ -129,14 +129,10 @@ export function ExploreGraphNGPanel({
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }} legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeZone={timeZone} timeZone={timeZone}
> >
<TooltipPlugin mode="single" timeZone={timeZone} />
<ZoomPlugin onZoom={onUpdateTimeRange} /> <ZoomPlugin onZoom={onUpdateTimeRange} />
<ContextMenuPlugin timeZone={timeZone} /> <TooltipPlugin data={data} mode="single" timeZone={timeZone} />
{annotations ? ( <ContextMenuPlugin data={data} timeZone={timeZone} />
<ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} /> {annotations && <ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />}
) : (
<></>
)}
</GraphNG> </GraphNG>
</Collapse> </Collapse>
</> </>
......
import React, { useCallback, useMemo } from 'react'; 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 { BarChart, BarChartOptions, GraphNGLegendEvent } from '@grafana/ui';
import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory'; import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory';
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory'; import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
import { config } from 'app/core/config';
interface Props extends PanelProps<BarChartOptions> {} interface Props extends PanelProps<BarChartOptions> {}
interface BarData {
error?: string;
frame?: DataFrame; // first string vs all numbers
}
/** /**
* @alpha * @alpha
*/ */
...@@ -23,13 +17,13 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({ ...@@ -23,13 +17,13 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
fieldConfig, fieldConfig,
onFieldConfigChange, onFieldConfigChange,
}) => { }) => {
if (!data || !data.series?.length) { const orientation = useMemo(() => {
return ( if (!options.orientation || options.orientation === VizOrientation.Auto) {
<div className="panel-empty"> return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
<p>No data found in response</p> }
</div>
); return options.orientation;
} }, [width, height, options.orientation]);
const onLegendClick = useCallback( const onLegendClick = useCallback(
(event: GraphNGLegendEvent) => { (event: GraphNGLegendEvent) => {
...@@ -45,59 +39,46 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({ ...@@ -45,59 +39,46 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
[fieldConfig, onFieldConfigChange] [fieldConfig, onFieldConfigChange]
); );
const barData = useMemo<BarData>(() => { if (!data || !data.series?.length) {
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) {
return ( return (
<div className="panel-empty"> <div className="panel-empty">
<p>{barData.error}</p> <p>No data found in response</p>
</div> </div>
); );
} }
if (!barData.frame) { const firstFrame = data.series[0];
if (!firstFrame.fields.find((f) => f.type === FieldType.string)) {
return ( return (
<div className="panel-empty"> <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> </div>
); );
} }
return ( return (
<BarChart <BarChart
data={barData.frame} data={data.series}
width={width} width={width}
height={height} height={height}
theme={config.theme}
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
{...options} {...options}
orientation={orientation}
/> />
); );
}; };
...@@ -41,6 +41,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({ ...@@ -41,6 +41,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
[fieldConfig, onFieldConfigChange] [fieldConfig, onFieldConfigChange]
); );
if (!data || !data.series?.length) {
return (
<div className="panel-empty">
<p>No data found in response</p>
</div>
);
}
return ( return (
<GraphNG <GraphNG
data={data.series} data={data.series}
...@@ -52,9 +60,9 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({ ...@@ -52,9 +60,9 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
> >
<TooltipPlugin mode={options.tooltipOptions.mode} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} /> <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 && ( {data.annotations && (
<ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} /> <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
)} )}
......
import React, { useState, useCallback, useRef, useMemo } from 'react'; import React, { useCallback, useRef, useState } from 'react';
import { import {
ClickPlugin, ClickPlugin,
ContextMenu, ContextMenu,
...@@ -7,12 +7,11 @@ import { ...@@ -7,12 +7,11 @@ import {
MenuItem, MenuItem,
MenuItemsGroup, MenuItemsGroup,
Portal, Portal,
usePlotData, useGraphNGContext,
} from '@grafana/ui'; } from '@grafana/ui';
import { import {
DataFrame,
DataFrameView, DataFrameView,
DisplayValue,
Field,
getDisplayProcessor, getDisplayProcessor,
getFieldDisplayName, getFieldDisplayName,
InterpolateFunction, InterpolateFunction,
...@@ -22,6 +21,7 @@ import { useClickAway } from 'react-use'; ...@@ -22,6 +21,7 @@ import { useClickAway } from 'react-use';
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers'; import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
interface ContextMenuPluginProps { interface ContextMenuPluginProps {
data: DataFrame[];
defaultItems?: MenuItemsGroup[]; defaultItems?: MenuItemsGroup[];
timeZone: TimeZone; timeZone: TimeZone;
onOpen?: () => void; onOpen?: () => void;
...@@ -30,6 +30,7 @@ interface ContextMenuPluginProps { ...@@ -30,6 +30,7 @@ interface ContextMenuPluginProps {
} }
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
data,
onClose, onClose,
timeZone, timeZone,
defaultItems, defaultItems,
...@@ -47,6 +48,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ ...@@ -47,6 +48,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
return ( return (
<Portal> <Portal>
<ContextMenuView <ContextMenuView
data={data}
defaultItems={defaultItems} defaultItems={defaultItems}
timeZone={timeZone} timeZone={timeZone}
selection={{ point, coords }} selection={{ point, coords }}
...@@ -66,6 +68,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({ ...@@ -66,6 +68,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
}; };
interface ContextMenuProps { interface ContextMenuProps {
data: DataFrame[];
defaultItems?: MenuItemsGroup[]; defaultItems?: MenuItemsGroup[];
timeZone: TimeZone; timeZone: TimeZone;
onClose?: () => void; onClose?: () => void;
...@@ -81,11 +84,11 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({ ...@@ -81,11 +84,11 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
timeZone, timeZone,
defaultItems, defaultItems,
replaceVariables, replaceVariables,
data,
...otherProps ...otherProps
}) => { }) => {
const ref = useRef(null); const ref = useRef(null);
const { data } = usePlotData(); const graphContext = useGraphNGContext();
const { seriesIdx, dataIdx } = selection.point;
const onClose = () => { const onClose = () => {
if (otherProps.onClose) { if (otherProps.onClose) {
...@@ -97,65 +100,69 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({ ...@@ -97,65 +100,69 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
onClose(); onClose();
}); });
const contextMenuProps = useMemo(() => { const xField = graphContext.getXAxisField(data);
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,
});
if (linksSupplier) { if (!xField) {
items.push({ return null;
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => { }
return {
label: link.title, const items = defaultItems ? [...defaultItems] : [];
url: link.href, let renderHeader: () => JSX.Element | null = () => null;
target: link.target,
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName, const { seriesIdx, dataIdx } = selection.point;
onClick: link.onClick, const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
};
}),
});
}
}
// eslint-disable-next-line react/display-name if (seriesIdx && dataIdx) {
renderHeader = () => ( // origin field/frame indexes for inspecting the data
<GraphContextMenuHeader const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx);
timestamp={timeFormatter(timeField.values.get(dataIdx)).text} const frame = data[originFieldIndex.frameIndex];
displayValue={displayValue} const field = frame.fields[originFieldIndex.fieldIndex];
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, data)} 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 { // eslint-disable-next-line react/display-name
renderHeader, renderHeader = () => (
items, <GraphContextMenuHeader
}; timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
}, [defaultItems, seriesIdx, dataIdx, data]); displayValue={displayValue}
seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, frame)}
/>
);
}
return ( return (
<ContextMenu <ContextMenu
{...contextMenuProps} items={items}
renderHeader={renderHeader}
x={selection.coords.viewport.x} x={selection.coords.viewport.x}
y={selection.coords.viewport.y} y={selection.coords.viewport.y}
onClose={onClose} onClose={onClose}
......
...@@ -19,6 +19,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({ ...@@ -19,6 +19,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
onFieldConfigChange, onFieldConfigChange,
}) => { }) => {
const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]); const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]);
if (dims.error) { if (dims.error) {
return ( return (
<div> <div>
...@@ -61,7 +62,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({ ...@@ -61,7 +62,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
onLegendClick={onLegendClick} onLegendClick={onLegendClick}
onSeriesColorChange={onSeriesColorChange} 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 */}</> <>{/* needs to be an array */}</>
</GraphNG> </GraphNG>
); );
......
import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data'; import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data';
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/GraphNG';
import { XYDimensionConfig } from './types'; import { XYDimensionConfig } from './types';
// TODO: fix import
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
export enum DimensionError { export enum DimensionError {
NoData, NoData,
BadFrameSelection, BadFrameSelection,
...@@ -21,7 +23,7 @@ export function isGraphable(field: Field) { ...@@ -21,7 +23,7 @@ export function isGraphable(field: Field) {
return field.type === FieldType.number; 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) { if (!data || !data.length) {
return { error: DimensionError.NoData } as XYDimensions; 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