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
pull/31225/head
Dominik Prokop 5 years ago committed by GitHub
parent f9a293afea
commit 9c08b34e71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-data/src/types/fieldOverrides.ts
  2. 2
      packages/grafana-ui/src/components/BarChart/BarChart.story.tsx
  3. 342
      packages/grafana-ui/src/components/BarChart/BarChart.tsx
  4. 961
      packages/grafana-ui/src/components/BarChart/__snapshots__/utils.test.ts.snap
  5. 101
      packages/grafana-ui/src/components/BarChart/utils.test.ts
  6. 190
      packages/grafana-ui/src/components/BarChart/utils.ts
  7. 410
      packages/grafana-ui/src/components/GraphNG/GraphNG.tsx
  8. 153
      packages/grafana-ui/src/components/GraphNG/__snapshots__/utils.test.ts.snap
  9. 45
      packages/grafana-ui/src/components/GraphNG/hooks.ts
  10. 7
      packages/grafana-ui/src/components/GraphNG/types.ts
  11. 94
      packages/grafana-ui/src/components/GraphNG/utils.test.ts
  12. 198
      packages/grafana-ui/src/components/GraphNG/utils.ts
  13. 89
      packages/grafana-ui/src/components/Sparkline/Sparkline.tsx
  14. 25
      packages/grafana-ui/src/components/Sparkline/utils.ts
  15. 4
      packages/grafana-ui/src/components/VizLayout/VizLayout.story.tsx
  16. 7
      packages/grafana-ui/src/components/VizLayout/VizLayout.tsx
  17. 3
      packages/grafana-ui/src/components/index.ts
  18. 24
      packages/grafana-ui/src/components/uPlot/Plot.test.tsx
  19. 39
      packages/grafana-ui/src/components/uPlot/Plot.tsx
  20. 97
      packages/grafana-ui/src/components/uPlot/PlotLegend.tsx
  21. 7
      packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.test.ts
  22. 20
      packages/grafana-ui/src/components/uPlot/config/UPlotConfigBuilder.ts
  23. 84
      packages/grafana-ui/src/components/uPlot/context.ts
  24. 25
      packages/grafana-ui/src/components/uPlot/hooks.ts
  25. 67
      packages/grafana-ui/src/components/uPlot/plugins/TooltipPlugin.tsx
  26. 14
      packages/grafana-ui/src/components/uPlot/types.ts
  27. 30
      packages/grafana-ui/src/components/uPlot/utils.ts
  28. 10
      public/app/features/explore/ExploreGraphNGPanel.tsx
  29. 73
      public/app/plugins/panel/barchart/BarChartPanel.tsx
  30. 12
      public/app/plugins/panel/timeseries/TimeSeriesPanel.tsx
  31. 55
      public/app/plugins/panel/timeseries/plugins/ContextMenuPlugin.tsx
  32. 3
      public/app/plugins/panel/xychart/XYChartPanel.tsx
  33. 6
      public/app/plugins/panel/xychart/dims.ts

@ -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 = () => {
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} />;
}; };

@ -1,318 +1,138 @@
import React, { useCallback, useMemo, useRef } from 'react'; import React from 'react';
import { import { AlignedData } from 'uplot';
compareDataFrameStructures, import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data';
DataFrame,
DefaultTimeZone,
formattedValueToString,
getFieldDisplayName,
getFieldSeriesColor,
getFieldColorModeForField,
TimeRange,
VizOrientation,
fieldReducers,
reduceField,
DisplayValue,
} from '@grafana/data';
import { VizLayout } from '../VizLayout/VizLayout'; import { VizLayout } from '../VizLayout/VizLayout';
import { Themeable } from '../../types'; import { Themeable } from '../../types';
import { useRevision } from '../uPlot/hooks';
import { UPlotChart } from '../uPlot/Plot'; import { UPlotChart } from '../uPlot/Plot';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config'; import { GraphNGLegendEvent } from '../GraphNG/types';
import { useTheme } from '../../themes'; import { BarChartOptions } from './types';
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '../GraphNG/types'; import { withTheme } from '../../themes';
import { FIXED_UNIT } from '../GraphNG/GraphNG'; import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { LegendDisplayMode, VizLegendItem } from '../VizLegend/types'; import { preparePlotData } from '../uPlot/utils';
import { VizLegend } from '../VizLegend/VizLegend'; import { LegendDisplayMode } from '../VizLegend/types';
import { PlotLegend } from '../uPlot/PlotLegend';
import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types';
import { BarsOptions, getConfig } from './bars';
/** /**
* @alpha * @alpha
*/ */
export interface Props extends Themeable, BarChartOptions { export interface BarChartProps extends Themeable, BarChartOptions {
height: number; height: number;
width: number; width: number;
data: DataFrame; data: DataFrame[];
onLegendClick?: (event: GraphNGLegendEvent) => void; onLegendClick?: (event: GraphNGLegendEvent) => void;
onSeriesColorChange?: (label: string, color: string) => void; onSeriesColorChange?: (label: string, color: string) => void;
} }
/** interface BarChartState {
* @alpha data: AlignedData;
*/ alignedDataFrame: DataFrame;
export const BarChart: React.FunctionComponent<Props> = ({ config?: UPlotConfigBuilder;
width,
height,
data,
orientation,
groupWidth,
barWidth,
showValue,
legend,
onLegendClick,
onSeriesColorChange,
...plotProps
}) => {
if (!data || data.fields.length < 2) {
return <div>Missing data</div>;
} }
// dominik? TODO? can this all be moved into `useRevision` class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { constructor(props: BarChartProps) {
if (a && b) { super(props);
return compareDataFrameStructures(a, b); this.state = {} as BarChartState;
} }
return false;
}, []);
const configRev = useRevision(data, compareFrames);
const theme = useTheme(); static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) {
const frame = preparePlotFrame(props.data);
// Updates only when the structure changes if (!frame) {
const configBuilder = useMemo(() => { return { ...state };
if (!orientation || orientation === VizOrientation.Auto) {
orientation = width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
} }
// bar orientation -> x scale orientation & direction return {
let xOri: ScaleOrientation, xDir: ScaleDirection, yOri: ScaleOrientation, yDir: ScaleDirection; ...state,
data: preparePlotData(frame),
if (orientation === VizOrientation.Vertical) { alignedDataFrame: frame,
xOri = ScaleOrientation.Horizontal; };
xDir = ScaleDirection.Right;
yOri = ScaleOrientation.Vertical;
yDir = ScaleDirection.Up;
} else {
xOri = ScaleOrientation.Vertical;
xDir = ScaleDirection.Down;
yOri = ScaleOrientation.Horizontal;
yDir = ScaleDirection.Right;
} }
const formatValue = componentDidMount() {
showValue !== BarValueVisibility.Never const { alignedDataFrame } = this.state;
? (seriesIdx: number, value: any) => formattedValueToString(data.fields[seriesIdx].display!(value))
: undefined;
// Use bar width when only one field if (!alignedDataFrame) {
if (data.fields.length === 2) { return;
groupWidth = barWidth;
barWidth = 1;
} }
const opts: BarsOptions = { this.setState({
xOri, config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
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);
const builder = new UPlotConfigBuilder();
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({ componentDidUpdate(prevProps: BarChartProps) {
scaleKey: 'x', const { data, orientation, groupWidth, barWidth, showValue } = this.props;
isTime: false, const { alignedDataFrame } = this.state;
placement: xOri === 0 ? AxisPlacement.Bottom : AxisPlacement.Left, let shouldConfigUpdate = false;
splits: config.xSplits, let hasStructureChanged = false;
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 if (
builder.addScale({ this.state.config === undefined ||
scaleKey, orientation !== prevProps.orientation ||
min: field.config.min, groupWidth !== prevProps.groupWidth ||
max: field.config.max, barWidth !== prevProps.barWidth ||
softMin: customConfig.axisSoftMin, showValue !== prevProps.showValue
softMax: customConfig.axisSoftMax, ) {
orientation: yOri, shouldConfigUpdate = true;
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; if (data !== prevProps.data) {
if (!alignedDataFrame) {
return;
} }
hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures);
} }
builder.addAxis({ if (shouldConfigUpdate || hasStructureChanged) {
scaleKey, this.setState({
label: customConfig.axisLabel, config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
size: customConfig.axisWidth,
placement,
formatValue: (v) => formattedValueToString(field.display!(v)),
theme,
}); });
} }
} }
return builder; renderLegend() {
}, [data, configRev, orientation, width, height]); const { legend, onSeriesColorChange, onLegendClick, data } = this.props;
const { config } = this.state;
const onLabelClick = useCallback(
(legend: VizLegendItem, event: React.MouseEvent) => {
const { fieldIndex } = legend;
if (!onLegendClick || !fieldIndex) { if (!config || legend.displayMode === LegendDisplayMode.Hidden) {
return; return;
} }
return (
onLegendClick({ <PlotLegend
fieldIndex, data={data}
mode: GraphNGLegendEventMode.AppendToSelection, config={config}
}); onSeriesColorChange={onSeriesColorChange}
}, onLegendClick={onLegendClick}
[onLegendClick, data] {...legend}
/>
); );
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const legendItems = configBuilder
.getSeries()
.map<VizLegendItem | undefined>((s) => {
const seriesConfig = s.props;
const fieldIndex = seriesConfig.dataFrameFieldIndex;
if (seriesConfig.hideInLegend || !fieldIndex) {
return undefined;
} }
const field = data.fields[fieldIndex.fieldIndex]; render() {
if (!field) { const { width, height } = this.props;
return undefined; const { config, data } = this.state;
}
return { if (!config) {
disabled: !seriesConfig.show ?? false, return null;
fieldIndex,
color: seriesConfig.lineColor!,
label: seriesConfig.fieldName,
yAxis: 1,
getDisplayValues: () => {
if (!legend.calcs?.length) {
return [];
}
const fieldCalcs = reduceField({
field,
reducers: legend.calcs,
});
return legend.calcs.map<DisplayValue>((reducer) => {
return {
...field.display!(fieldCalcs[reducer]),
title: fieldReducers.get(reducer).name,
};
});
},
};
})
.filter((i) => i !== undefined) as VizLegendItem[];
let legendElement: React.ReactElement | undefined;
if (hasLegend && legendItems.length > 0) {
legendElement = (
<VizLayout.Legend position={legend.placement} maxHeight="35%" maxWidth="60%">
<VizLegend
onLabelClick={onLabelClick}
placement={legend.placement}
items={legendItems}
displayMode={legend.displayMode}
onSeriesColorChange={onSeriesColorChange}
/>
</VizLayout.Legend>
);
} }
return ( return (
<VizLayout width={width} height={height} legend={legendElement}> <VizLayout width={width} height={height} legend={this.renderLegend()}>
{(vizWidth: number, vizHeight: number) => ( {(vizWidth: number, vizHeight: number) => (
<UPlotChart <UPlotChart
data={data} data={data}
config={configBuilder} config={config}
width={vizWidth} width={vizWidth}
height={vizHeight} height={vizHeight}
timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK
timeZone={DefaultTimeZone}
/> />
)} )}
</VizLayout> </VizLayout>
); );
}; }
}
export const BarChart = withTheme(UnthemedBarChart);
BarChart.displayName = 'GraphNG';

@ -0,0 +1,961 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`GraphNG utils preparePlotConfigBuilder orientation 1`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder orientation 2`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder orientation 3`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "left",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "bottom",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"distribution": "ordinal",
"isTime": false,
"orientation": 0,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 1,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder stacking 1`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder stacking 2`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder stacking 3`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder value visibility 1`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;
exports[`GraphNG utils preparePlotConfigBuilder value visibility 2`] = `
UPlotConfigBuilder {
"axes": Object {
"m/s": UPlotAxisBuilder {
"props": Object {
"formatValue": [Function],
"label": undefined,
"placement": "bottom",
"scaleKey": "m/s",
"size": undefined,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
},
},
"x": UPlotAxisBuilder {
"props": Object {
"gap": 15,
"grid": false,
"isTime": false,
"placement": "left",
"scaleKey": "x",
"splits": [Function],
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"ticks": false,
"values": [Function],
},
},
},
"bands": Array [],
"cursor": Object {
"points": Object {
"show": false,
},
"x": false,
"y": false,
},
"getTimeZone": [Function],
"hasBottomAxis": true,
"hasLeftAxis": true,
"hooks": Object {
"drawClear": Array [
[Function],
],
"init": Array [
[Function],
],
"setCursor": Array [
[Function],
],
},
"scales": Array [
UPlotScaleBuilder {
"props": Object {
"direction": -1,
"distribution": "ordinal",
"isTime": false,
"orientation": 1,
"scaleKey": "x",
},
},
UPlotScaleBuilder {
"props": Object {
"direction": 1,
"max": undefined,
"min": undefined,
"orientation": 0,
"scaleKey": "m/s",
"softMax": undefined,
"softMin": 0,
},
},
],
"select": Object {
"show": false,
},
"series": Array [
UPlotSeriesBuilder {
"props": Object {
"colorMode": Object {
"description": "Derive colors from thresholds",
"getCalculator": [Function],
"id": "thresholds",
"isByValue": true,
"name": "From thresholds",
},
"dataFrameFieldIndex": Object {
"fieldIndex": 1,
"frameIndex": 0,
},
"fieldName": "Metric 1",
"fillOpacity": 0.1,
"gradientMode": "opacity",
"hideInLegend": undefined,
"lineColor": "#808080",
"lineWidth": 2,
"pathBuilder": [Function],
"pointsBuilder": [Function],
"pxAlign": false,
"scaleKey": "m/s",
"show": true,
"theme": Object {
"colors": Object {
"panelBg": "#000000",
},
},
"thresholds": undefined,
},
},
],
"tzDate": [Function],
}
`;

@ -0,0 +1,101 @@
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();
});
});
});

@ -0,0 +1,190 @@
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;
}

@ -1,49 +1,30 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import React from 'react';
import { AlignedData } from 'uplot';
import { import {
compareArrayValues,
compareDataFrameStructures, compareDataFrameStructures,
DataFrame, DataFrame,
DisplayValue, DataFrameFieldIndex,
FieldConfig,
FieldMatcher,
FieldMatcherID, FieldMatcherID,
fieldMatchers, fieldMatchers,
fieldReducers,
FieldType,
formattedValueToString,
getFieldDisplayName,
outerJoinDataFrames,
reduceField,
TimeRange, TimeRange,
TimeZone, TimeZone,
getFieldColorModeForField,
getFieldSeriesColor,
} from '@grafana/data'; } from '@grafana/data';
import { useTheme } from '../../themes'; import { withTheme } from '../../themes';
import { Themeable } from '../../types';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
import { GraphNGContext } from './hooks';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend';
import { UPlotChart } from '../uPlot/Plot'; import { UPlotChart } from '../uPlot/Plot';
import { import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/types';
AxisPlacement,
DrawStyle,
GraphFieldConfig,
PointVisibility,
ScaleDirection,
ScaleOrientation,
} from '../uPlot/config';
import { VizLayout } from '../VizLayout/VizLayout'; import { VizLayout } from '../VizLayout/VizLayout';
import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types';
import { VizLegend } from '../VizLegend/VizLegend';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { useRevision } from '../uPlot/hooks';
import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types';
import { isNumber } from 'lodash';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1)); export const FIXED_UNIT = '__fixed';
export interface XYFieldMatchers {
x: FieldMatcher; // first match
y: FieldMatcher;
}
export interface GraphNGProps { export interface GraphNGProps extends Themeable {
width: number; width: number;
height: number; height: number;
data: DataFrame[]; data: DataFrame[];
@ -56,310 +37,171 @@ export interface GraphNGProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
const defaultConfig: GraphFieldConfig = { interface GraphNGState {
drawStyle: DrawStyle.Line, data: AlignedData;
showPoints: PointVisibility.Auto, alignedDataFrame: DataFrame;
axisPlacement: AxisPlacement.Auto, dimFields: XYFieldMatchers;
}; seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[];
config?: UPlotConfigBuilder;
export const FIXED_UNIT = '__fixed'; }
export const GraphNG: React.FC<GraphNGProps> = ({ class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
data, constructor(props: GraphNGProps) {
fields, super(props);
children, let dimFields = props.fields;
width,
height,
legend,
timeRange,
timeZone,
onLegendClick,
onSeriesColorChange,
...plotProps
}) => {
const theme = useTheme();
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
const frame = useMemo(() => { if (!dimFields) {
// Default to timeseries config dimFields = {
if (!fields) {
fields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}), x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}), y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}; };
} }
return outerJoinDataFrames({ frames: data, joinBy: fields.x, keep: fields.y, keepOriginIndices: true }); this.state = { dimFields } as GraphNGState;
}, [data, fields]); }
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => { /**
if (a && b) { * Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
return compareDataFrameStructures(a, b); * It's cheaper than run prev and current AlignedData comparison to indicate necessity of data-only update. We assume
} * that if there were no config updates, we can do data only updates(as described in Plot.tsx, L32)
return false; *
}, []); * Preparing the uPlot-ready data in getDerivedStateFromProps makes the data updates happen only once for a render cycle.
* If we did it in componendDidUpdate we will end up having two data-only updates: 1) for props and 2) for state update
const onLabelClick = useCallback( *
(legend: VizLegendItem, event: React.MouseEvent) => { * This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
const { fieldIndex } = legend; * the data is updated first, and then the uPlot is re-initialized. But since the config updates does not happen that
* often (apart from the edit mode interactions) this should be a fair performance compromise.
if (!onLegendClick || !fieldIndex) { */
return; static getDerivedStateFromProps(props: GraphNGProps, state: GraphNGState) {
let dimFields = props.fields;
if (!dimFields) {
dimFields = {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
};
} }
onLegendClick({ const frame = preparePlotFrame(props.data, dimFields);
fieldIndex,
mode: mapMouseEventToMode(event),
});
},
[onLegendClick, data]
);
// reference change will not trigger re-render
const currentTimeRange = useRef<TimeRange>(timeRange);
useLayoutEffect(() => {
currentTimeRange.current = timeRange;
}, [timeRange]);
const configRev = useRevision(frame, compareFrames);
const configBuilder = useMemo(() => {
const builder = new UPlotConfigBuilder();
if (!frame) { if (!frame) {
return builder; return { ...state, dimFields };
}
// 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 = currentTimeRange.current!;
return [r.from.valueOf(), r.to.valueOf()];
},
});
builder.addAxis({
scaleKey: 'x',
isTime: true,
placement: AxisPlacement.Bottom,
timeZone,
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++) { return {
const field = frame.fields[i]; ...state,
const config = field.config as FieldConfig<GraphFieldConfig>; data: preparePlotData(frame),
const customConfig: GraphFieldConfig = { alignedDataFrame: frame,
...defaultConfig, seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
...config.custom, dimFields,
}; };
if (field === xField || field.type !== FieldType.number) {
continue;
} }
field.state!.seriesIndex = seriesIndex++;
const fmt = field.display ?? defaultFormatter; componentDidMount() {
const scaleKey = config.unit || FIXED_UNIT; const { theme } = this.props;
const colorMode = getFieldColorModeForField(field);
const scaleColor = getFieldSeriesColor(field, theme);
const seriesColor = scaleColor.color;
// The builder will manage unique scaleKeys and combine where appropriate // alignedDataFrame is already prepared by getDerivedStateFromProps method
builder.addScale({ const { alignedDataFrame } = this.state;
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) { if (!alignedDataFrame) {
builder.addAxis({ return;
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; this.setState({
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone),
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({ componentDidUpdate(prevProps: GraphNGProps) {
scaleKey, const { data, theme } = this.props;
showPoints, const { alignedDataFrame } = this.state;
colorMode, let shouldConfigUpdate = false;
fillOpacity, let stateUpdate = {} as GraphNGState;
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 if (this.state.config === undefined || this.props.timeZone !== prevProps.timeZone) {
dataFrameFieldIndex: field.state?.origin, shouldConfigUpdate = true;
fieldName: getFieldDisplayName(field, frame),
hideInLegend: customConfig.hideFrom?.legend,
});
} }
return builder;
}, [configRev, timeZone]);
if (!frame) { if (data !== prevProps.data) {
return ( if (!alignedDataFrame) {
<div className="panel-empty"> return;
<p>No data found in response</p>
</div>
);
} }
const legendItems = configBuilder const hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures);
.getSeries()
.map<VizLegendItem | undefined>((s) => {
const seriesConfig = s.props;
const fieldIndex = seriesConfig.dataFrameFieldIndex;
const axisPlacement = configBuilder.getAxisPlacement(s.props.scaleKey);
if (seriesConfig.hideInLegend || !fieldIndex) { if (shouldConfigUpdate || hasStructureChanged) {
return undefined; const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone);
stateUpdate = { ...stateUpdate, config: builder };
} }
const field = data[fieldIndex.frameIndex]?.fields[fieldIndex.fieldIndex];
// Hackish: when the data prop and config builder are not in sync yet
if (!field) {
return undefined;
} }
return { if (Object.keys(stateUpdate).length > 0) {
disabled: !seriesConfig.show ?? false, this.setState(stateUpdate);
fieldIndex, }
color: seriesConfig.lineColor!,
label: seriesConfig.fieldName,
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
getDisplayValues: () => {
if (!legend.calcs?.length) {
return [];
} }
const fmt = field.display ?? defaultFormatter; mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
const fieldCalcs = reduceField({ return this.state.seriesToDataFrameFieldIndexMap[i];
field, };
reducers: legend.calcs,
});
return legend.calcs.map<DisplayValue>((reducer) => { getTimeRange = () => {
return { return this.props.timeRange;
...fmt(fieldCalcs[reducer]),
title: fieldReducers.get(reducer).name,
}; };
});
}, getTimeZone = () => {
return this.props.timeZone;
}; };
})
.filter((i) => i !== undefined) as VizLegendItem[];
let legendElement: React.ReactElement | undefined; renderLegend() {
const { legend, onSeriesColorChange, onLegendClick, data } = this.props;
const { config } = this.state;
if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) {
return;
}
if (hasLegend && legendItems.length > 0) { return (
legendElement = ( <PlotLegend
<VizLayout.Legend position={legend.placement} maxHeight="35%" maxWidth="60%"> data={data}
<VizLegend config={config}
onLabelClick={onLabelClick}
placement={legend.placement}
items={legendItems}
displayMode={legend.displayMode}
onSeriesColorChange={onSeriesColorChange} onSeriesColorChange={onSeriesColorChange}
onLegendClick={onLegendClick}
{...legend}
/> />
</VizLayout.Legend>
); );
} }
render() {
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props;
if (!this.state.data || !this.state.config) {
return null;
}
return ( return (
<VizLayout width={width} height={height} legend={legendElement}> <GraphNGContext.Provider
value={{
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
dimFields: this.state.dimFields,
}}
>
<VizLayout width={width} height={height} legend={this.renderLegend()}>
{(vizWidth: number, vizHeight: number) => ( {(vizWidth: number, vizHeight: number) => (
<UPlotChart <UPlotChart
data={frame} {...plotProps}
config={configBuilder} config={this.state.config!}
data={this.state.data}
width={vizWidth} width={vizWidth}
height={vizHeight} height={vizHeight}
timeRange={timeRange} timeRange={timeRange}
timeZone={timeZone}
{...plotProps}
> >
{children} {children}
</UPlotChart> </UPlotChart>
)} )}
</VizLayout> </VizLayout>
</GraphNGContext.Provider>
); );
};
const mapMouseEventToMode = (event: React.MouseEvent): GraphNGLegendEventMode => {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
return GraphNGLegendEventMode.AppendToSelection;
} }
return GraphNGLegendEventMode.ToggleSelection;
};
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;
} }
export const GraphNG = withTheme(UnthemedGraphNG);
GraphNG.displayName = 'GraphNG';

@ -0,0 +1,153 @@
// 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],
}
`;

@ -0,0 +1,45 @@
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,
};
};

@ -1,4 +1,4 @@
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 {
fieldIndex: DataFrameFieldIndex; fieldIndex: DataFrameFieldIndex;
mode: GraphNGLegendEventMode; mode: GraphNGLegendEventMode;
} }
export interface XYFieldMatchers {
x: FieldMatcher; // first match
y: FieldMatcher;
}

@ -0,0 +1,94 @@
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();
});
});

@ -0,0 +1,198 @@
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;
}

@ -1,13 +1,12 @@
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 {
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 {
} }
interface State { interface State {
data: DataFrame; data: AlignedData;
alignedDataFrame: DataFrame;
configBuilder: UPlotConfigBuilder; configBuilder: UPlotConfigBuilder;
} }
@ -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> {
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}
/>
); );
} }
} }

@ -0,0 +1,25 @@
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 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 = () => {
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}

@ -1,6 +1,7 @@
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
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
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 {
* @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';
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';
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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', () => {
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';
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) => {
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) => {
// 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) => {
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) => {
); );
}; };
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;
} }

@ -0,0 +1,97 @@
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 [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => {
"series": Array [ "series": Array [
Object {}, Object {},
], ],
"tzDate": [Function],
} }
`); `);
}); });
@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => {
"width": 1, "width": 1,
}, },
], ],
"tzDate": [Function],
} }
`); `);
}); });

@ -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 {
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 {
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 {
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);
};
} }

@ -1,7 +1,6 @@
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 {
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 => {
}; };
}; };
// 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 => {

@ -1,11 +1,11 @@
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> = {
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,
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,
*/ */
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) => {
hooks: { hooks: {
// refresh events when uPlot draws // refresh events when uPlot draws
draw: () => { draw: () => {
if (isMounted()) {
setRenderToken((c) => c + 1); setRenderToken((c) => c + 1);
}
return; return;
}, },
}, },

@ -1,29 +1,40 @@
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
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
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
{ {
// 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
} }
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,18 +95,21 @@ 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) {
return null; return null;
} }

@ -1,9 +1,12 @@
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 {
} }
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;
} }

@ -1,5 +1,6 @@
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
} 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++) {
timestamps.push(dateTime(f.values.get(i)).valueOf());
} }
return timestamps;
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;
} }
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({
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>
</> </>

@ -1,17 +1,11 @@
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,14 +17,14 @@ 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) => {
onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series)); onFieldConfigChange(hideSeriesConfigFactory(event, fieldConfig, data.series));
@ -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]; return (
const firstString = firstFrame.fields.find((f) => f.type === FieldType.string); <div className="panel-empty">
if (!firstString) { <p>No data found in response</p>
return { </div>
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 { const firstFrame = data.series[0];
frame: { if (!firstFrame.fields.find((f) => f.type === FieldType.string)) {
...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>Bar charts requires a string field</p>
</div> </div>
); );
} }
if (
if (!barData.frame) { firstFrame.fields.reduce((acc, f) => {
if (f.type === FieldType.number) {
return acc + 1;
}
return acc;
}, 0) < 2
) {
return ( return (
<div className="panel-empty"> <div className="panel-empty">
<p>No data found in response</p> <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> = ({
[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> = ({
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} />
)} )}

@ -1,4 +1,4 @@
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 {
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';
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 {
} }
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> = ({
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> = ({
}; };
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> = ({
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,26 +100,35 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
onClose(); onClose();
}); });
const contextMenuProps = useMemo(() => { const xField = graphContext.getXAxisField(data);
if (!xField) {
return null;
}
const items = defaultItems ? [...defaultItems] : []; 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; let renderHeader: () => JSX.Element | null = () => null;
const { seriesIdx, dataIdx } = selection.point;
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
if (seriesIdx && dataIdx) { if (seriesIdx && dataIdx) {
field = data.fields[seriesIdx]; // origin field/frame indexes for inspecting the data
displayValue = field.display!(field.values.get(dataIdx)); 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; const hasLinks = field.config.links && field.config.links.length > 0;
if (hasLinks) { if (hasLinks) {
const linksSupplier = getFieldLinksSupplier({ const linksSupplier = getFieldLinksSupplier({
display: displayValue, display: displayValue,
name: field.name, name: field.name,
view: new DataFrameView(data), view: new DataFrameView(frame),
rowIndex: dataIdx, rowIndex: dataIdx,
colIndex: seriesIdx, colIndex: originFieldIndex.fieldIndex,
field: field.config, field: field.config,
hasLinks, hasLinks,
}); });
@ -139,23 +151,18 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
// eslint-disable-next-line react/display-name // eslint-disable-next-line react/display-name
renderHeader = () => ( renderHeader = () => (
<GraphContextMenuHeader <GraphContextMenuHeader
timestamp={timeFormatter(timeField.values.get(dataIdx)).text} timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
displayValue={displayValue} displayValue={displayValue}
seriesColor={displayValue.color!} seriesColor={displayValue.color!}
displayName={getFieldDisplayName(field, data)} displayName={getFieldDisplayName(field, frame)}
/> />
); );
} }
return {
renderHeader,
items,
};
}, [defaultItems, seriesIdx, dataIdx, data]);
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> = ({
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> = ({
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>
); );

@ -1,7 +1,9 @@
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) {
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;
} }

Loading…
Cancel
Save