mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 16:15:42 -06:00
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
This commit is contained in:
parent
f9a293afea
commit
9c08b34e71
@ -112,10 +112,10 @@ export interface FieldConfigPropertyItem<TOptions = any, TValue = any, TSettings
|
||||
export interface ApplyFieldOverrideOptions {
|
||||
data?: DataFrame[];
|
||||
fieldConfig: FieldConfigSource;
|
||||
fieldConfigRegistry?: FieldConfigOptionsRegistry;
|
||||
replaceVariables: InterpolateFunction;
|
||||
theme: GrafanaTheme;
|
||||
timeZone?: TimeZone;
|
||||
fieldConfigRegistry?: FieldConfigOptionsRegistry;
|
||||
}
|
||||
|
||||
export enum FieldConfigProperty {
|
||||
|
@ -61,5 +61,5 @@ export const Basic: React.FC = () => {
|
||||
groupWidth: 0.7,
|
||||
};
|
||||
|
||||
return <BarChart data={data[0]} width={600} height={400} theme={theme} {...options} />;
|
||||
return <BarChart data={data} width={600} height={400} {...options} />;
|
||||
};
|
||||
|
@ -1,318 +1,138 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
compareDataFrameStructures,
|
||||
DataFrame,
|
||||
DefaultTimeZone,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
getFieldSeriesColor,
|
||||
getFieldColorModeForField,
|
||||
TimeRange,
|
||||
VizOrientation,
|
||||
fieldReducers,
|
||||
reduceField,
|
||||
DisplayValue,
|
||||
} from '@grafana/data';
|
||||
|
||||
import React from 'react';
|
||||
import { AlignedData } from 'uplot';
|
||||
import { compareArrayValues, compareDataFrameStructures, DataFrame, TimeRange } from '@grafana/data';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { Themeable } from '../../types';
|
||||
import { useRevision } from '../uPlot/hooks';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config';
|
||||
import { useTheme } from '../../themes';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from '../GraphNG/types';
|
||||
import { FIXED_UNIT } from '../GraphNG/GraphNG';
|
||||
import { LegendDisplayMode, VizLegendItem } from '../VizLegend/types';
|
||||
import { VizLegend } from '../VizLegend/VizLegend';
|
||||
|
||||
import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarChartFieldConfig } from './types';
|
||||
import { BarsOptions, getConfig } from './bars';
|
||||
import { GraphNGLegendEvent } from '../GraphNG/types';
|
||||
import { BarChartOptions } from './types';
|
||||
import { withTheme } from '../../themes';
|
||||
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
|
||||
import { preparePlotData } from '../uPlot/utils';
|
||||
import { LegendDisplayMode } from '../VizLegend/types';
|
||||
import { PlotLegend } from '../uPlot/PlotLegend';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface Props extends Themeable, BarChartOptions {
|
||||
export interface BarChartProps extends Themeable, BarChartOptions {
|
||||
height: number;
|
||||
width: number;
|
||||
data: DataFrame;
|
||||
data: DataFrame[];
|
||||
onLegendClick?: (event: GraphNGLegendEvent) => void;
|
||||
onSeriesColorChange?: (label: string, color: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const BarChart: React.FunctionComponent<Props> = ({
|
||||
width,
|
||||
height,
|
||||
data,
|
||||
orientation,
|
||||
groupWidth,
|
||||
barWidth,
|
||||
showValue,
|
||||
legend,
|
||||
onLegendClick,
|
||||
onSeriesColorChange,
|
||||
...plotProps
|
||||
}) => {
|
||||
if (!data || data.fields.length < 2) {
|
||||
return <div>Missing data</div>;
|
||||
interface BarChartState {
|
||||
data: AlignedData;
|
||||
alignedDataFrame: DataFrame;
|
||||
config?: UPlotConfigBuilder;
|
||||
}
|
||||
|
||||
class UnthemedBarChart extends React.Component<BarChartProps, BarChartState> {
|
||||
constructor(props: BarChartProps) {
|
||||
super(props);
|
||||
this.state = {} as BarChartState;
|
||||
}
|
||||
|
||||
// dominik? TODO? can this all be moved into `useRevision`
|
||||
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
|
||||
if (a && b) {
|
||||
return compareDataFrameStructures(a, b);
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
static getDerivedStateFromProps(props: BarChartProps, state: BarChartState) {
|
||||
const frame = preparePlotFrame(props.data);
|
||||
|
||||
const configRev = useRevision(data, compareFrames);
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
// Updates only when the structure changes
|
||||
const configBuilder = useMemo(() => {
|
||||
if (!orientation || orientation === VizOrientation.Auto) {
|
||||
orientation = width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
|
||||
if (!frame) {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
// bar orientation -> x scale orientation & direction
|
||||
let xOri: ScaleOrientation, xDir: ScaleDirection, yOri: ScaleOrientation, yDir: ScaleDirection;
|
||||
|
||||
if (orientation === VizOrientation.Vertical) {
|
||||
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 =
|
||||
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 });
|
||||
},
|
||||
return {
|
||||
...state,
|
||||
data: preparePlotData(frame),
|
||||
alignedDataFrame: frame,
|
||||
};
|
||||
const config = getConfig(opts);
|
||||
}
|
||||
|
||||
const builder = new UPlotConfigBuilder();
|
||||
componentDidMount() {
|
||||
const { alignedDataFrame } = this.state;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
if (!alignedDataFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
return builder;
|
||||
}, [data, configRev, orientation, width, height]);
|
||||
this.setState({
|
||||
config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
|
||||
});
|
||||
}
|
||||
|
||||
const onLabelClick = useCallback(
|
||||
(legend: VizLegendItem, event: React.MouseEvent) => {
|
||||
const { fieldIndex } = legend;
|
||||
componentDidUpdate(prevProps: BarChartProps) {
|
||||
const { data, orientation, groupWidth, barWidth, showValue } = this.props;
|
||||
const { alignedDataFrame } = this.state;
|
||||
let shouldConfigUpdate = false;
|
||||
let hasStructureChanged = false;
|
||||
|
||||
if (!onLegendClick || !fieldIndex) {
|
||||
if (
|
||||
this.state.config === undefined ||
|
||||
orientation !== prevProps.orientation ||
|
||||
groupWidth !== prevProps.groupWidth ||
|
||||
barWidth !== prevProps.barWidth ||
|
||||
showValue !== prevProps.showValue
|
||||
) {
|
||||
shouldConfigUpdate = true;
|
||||
}
|
||||
|
||||
if (data !== prevProps.data) {
|
||||
if (!alignedDataFrame) {
|
||||
return;
|
||||
}
|
||||
hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures);
|
||||
}
|
||||
|
||||
onLegendClick({
|
||||
fieldIndex,
|
||||
mode: GraphNGLegendEventMode.AppendToSelection,
|
||||
if (shouldConfigUpdate || hasStructureChanged) {
|
||||
this.setState({
|
||||
config: preparePlotConfigBuilder(alignedDataFrame, this.props.theme, this.props),
|
||||
});
|
||||
},
|
||||
[onLegendClick, data]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
renderLegend() {
|
||||
const { legend, onSeriesColorChange, onLegendClick, data } = this.props;
|
||||
const { config } = this.state;
|
||||
|
||||
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];
|
||||
if (!field) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: !seriesConfig.show ?? false,
|
||||
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>
|
||||
if (!config || legend.displayMode === LegendDisplayMode.Hidden) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<PlotLegend
|
||||
data={data}
|
||||
config={config}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
onLegendClick={onLegendClick}
|
||||
{...legend}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout width={width} height={height} legend={legendElement}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
data={data}
|
||||
config={configBuilder}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK
|
||||
timeZone={DefaultTimeZone}
|
||||
/>
|
||||
)}
|
||||
</VizLayout>
|
||||
);
|
||||
};
|
||||
render() {
|
||||
const { width, height } = this.props;
|
||||
const { config, data } = this.state;
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout width={width} height={height} legend={this.renderLegend()}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
data={data}
|
||||
config={config}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
timeRange={({ from: 1, to: 1 } as unknown) as TimeRange} // HACK
|
||||
/>
|
||||
)}
|
||||
</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],
|
||||
}
|
||||
`;
|
101
packages/grafana-ui/src/components/BarChart/utils.test.ts
Normal file
101
packages/grafana-ui/src/components/BarChart/utils.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
190
packages/grafana-ui/src/components/BarChart/utils.ts
Normal file
190
packages/grafana-ui/src/components/BarChart/utils.ts
Normal file
@ -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 {
|
||||
compareArrayValues,
|
||||
compareDataFrameStructures,
|
||||
DataFrame,
|
||||
DisplayValue,
|
||||
FieldConfig,
|
||||
FieldMatcher,
|
||||
DataFrameFieldIndex,
|
||||
FieldMatcherID,
|
||||
fieldMatchers,
|
||||
fieldReducers,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getFieldDisplayName,
|
||||
outerJoinDataFrames,
|
||||
reduceField,
|
||||
TimeRange,
|
||||
TimeZone,
|
||||
getFieldColorModeForField,
|
||||
getFieldSeriesColor,
|
||||
} from '@grafana/data';
|
||||
import { useTheme } from '../../themes';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import {
|
||||
AxisPlacement,
|
||||
DrawStyle,
|
||||
GraphFieldConfig,
|
||||
PointVisibility,
|
||||
ScaleDirection,
|
||||
ScaleOrientation,
|
||||
} from '../uPlot/config';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { LegendDisplayMode, VizLegendItem, VizLegendOptions } from '../VizLegend/types';
|
||||
import { VizLegend } from '../VizLegend/VizLegend';
|
||||
import { withTheme } from '../../themes';
|
||||
import { Themeable } from '../../types';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { useRevision } from '../uPlot/hooks';
|
||||
import { GraphNGLegendEvent, GraphNGLegendEventMode } from './types';
|
||||
import { isNumber } from 'lodash';
|
||||
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 { LegendDisplayMode, VizLegendOptions } from '../VizLegend/types';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
|
||||
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;
|
||||
height: number;
|
||||
data: DataFrame[];
|
||||
@ -56,310 +37,171 @@ export interface GraphNGProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultConfig: GraphFieldConfig = {
|
||||
drawStyle: DrawStyle.Line,
|
||||
showPoints: PointVisibility.Auto,
|
||||
axisPlacement: AxisPlacement.Auto,
|
||||
};
|
||||
interface GraphNGState {
|
||||
data: AlignedData;
|
||||
alignedDataFrame: DataFrame;
|
||||
dimFields: XYFieldMatchers;
|
||||
seriesToDataFrameFieldIndexMap: DataFrameFieldIndex[];
|
||||
config?: UPlotConfigBuilder;
|
||||
}
|
||||
|
||||
export const FIXED_UNIT = '__fixed';
|
||||
class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
|
||||
constructor(props: GraphNGProps) {
|
||||
super(props);
|
||||
let dimFields = props.fields;
|
||||
|
||||
export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
data,
|
||||
fields,
|
||||
children,
|
||||
width,
|
||||
height,
|
||||
legend,
|
||||
timeRange,
|
||||
timeZone,
|
||||
onLegendClick,
|
||||
onSeriesColorChange,
|
||||
...plotProps
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
|
||||
const frame = useMemo(() => {
|
||||
// Default to timeseries config
|
||||
if (!fields) {
|
||||
fields = {
|
||||
if (!dimFields) {
|
||||
dimFields = {
|
||||
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
|
||||
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
|
||||
};
|
||||
}
|
||||
return outerJoinDataFrames({ frames: data, joinBy: fields.x, keep: fields.y, keepOriginIndices: true });
|
||||
}, [data, fields]);
|
||||
this.state = { dimFields } as GraphNGState;
|
||||
}
|
||||
|
||||
const compareFrames = useCallback((a?: DataFrame | null, b?: DataFrame | null) => {
|
||||
if (a && b) {
|
||||
return compareDataFrameStructures(a, b);
|
||||
/**
|
||||
* Since no matter the nature of the change (data vs config only) we always calculate the plot-ready AlignedData array.
|
||||
* 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)
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* This is a way of optimizing the uPlot rendering, yet there are consequences: when there is a config update,
|
||||
* 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.
|
||||
*/
|
||||
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({}),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const onLabelClick = useCallback(
|
||||
(legend: VizLegendItem, event: React.MouseEvent) => {
|
||||
const { fieldIndex } = legend;
|
||||
const frame = preparePlotFrame(props.data, dimFields);
|
||||
|
||||
if (!onLegendClick || !fieldIndex) {
|
||||
if (!frame) {
|
||||
return { ...state, dimFields };
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: preparePlotData(frame),
|
||||
alignedDataFrame: frame,
|
||||
seriesToDataFrameFieldIndexMap: frame.fields.map((f) => f.state!.origin!),
|
||||
dimFields,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const { theme } = this.props;
|
||||
|
||||
// alignedDataFrame is already prepared by getDerivedStateFromProps method
|
||||
const { alignedDataFrame } = this.state;
|
||||
|
||||
if (!alignedDataFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
config: preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone),
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: GraphNGProps) {
|
||||
const { data, theme } = this.props;
|
||||
const { alignedDataFrame } = this.state;
|
||||
let shouldConfigUpdate = false;
|
||||
let stateUpdate = {} as GraphNGState;
|
||||
|
||||
if (this.state.config === undefined || this.props.timeZone !== prevProps.timeZone) {
|
||||
shouldConfigUpdate = true;
|
||||
}
|
||||
|
||||
if (data !== prevProps.data) {
|
||||
if (!alignedDataFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
onLegendClick({
|
||||
fieldIndex,
|
||||
mode: mapMouseEventToMode(event),
|
||||
});
|
||||
},
|
||||
[onLegendClick, data]
|
||||
);
|
||||
const hasStructureChanged = !compareArrayValues(data, prevProps.data, compareDataFrameStructures);
|
||||
|
||||
// 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) {
|
||||
return builder;
|
||||
if (shouldConfigUpdate || hasStructureChanged) {
|
||||
const builder = preparePlotConfigBuilder(alignedDataFrame, theme, this.getTimeRange, this.getTimeZone);
|
||||
stateUpdate = { ...stateUpdate, config: builder };
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
if (Object.keys(stateUpdate).length > 0) {
|
||||
this.setState(stateUpdate);
|
||||
}
|
||||
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,
|
||||
};
|
||||
mapSeriesIndexToDataFrameFieldIndex = (i: number) => {
|
||||
return this.state.seriesToDataFrameFieldIndexMap[i];
|
||||
};
|
||||
|
||||
if (field === xField || field.type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
field.state!.seriesIndex = seriesIndex++;
|
||||
getTimeRange = () => {
|
||||
return this.props.timeRange;
|
||||
};
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scaleKey = config.unit || FIXED_UNIT;
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const scaleColor = getFieldSeriesColor(field, theme);
|
||||
const seriesColor = scaleColor.color;
|
||||
getTimeZone = () => {
|
||||
return this.props.timeZone;
|
||||
};
|
||||
|
||||
// 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,
|
||||
});
|
||||
renderLegend() {
|
||||
const { legend, onSeriesColorChange, onLegendClick, data } = this.props;
|
||||
const { config } = this.state;
|
||||
|
||||
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,
|
||||
});
|
||||
if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) {
|
||||
return;
|
||||
}
|
||||
return builder;
|
||||
}, [configRev, timeZone]);
|
||||
|
||||
if (!frame) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
<PlotLegend
|
||||
data={data}
|
||||
config={config}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
onLegendClick={onLegendClick}
|
||||
{...legend}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const legendItems = configBuilder
|
||||
.getSeries()
|
||||
.map<VizLegendItem | undefined>((s) => {
|
||||
const seriesConfig = s.props;
|
||||
const fieldIndex = seriesConfig.dataFrameFieldIndex;
|
||||
const axisPlacement = configBuilder.getAxisPlacement(s.props.scaleKey);
|
||||
render() {
|
||||
const { width, height, children, timeZone, timeRange, ...plotProps } = this.props;
|
||||
|
||||
if (seriesConfig.hideInLegend || !fieldIndex) {
|
||||
return undefined;
|
||||
}
|
||||
if (!this.state.data || !this.state.config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
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[];
|
||||
|
||||
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 (
|
||||
<GraphNGContext.Provider
|
||||
value={{
|
||||
mapSeriesIndexToDataFrameFieldIndex: this.mapSeriesIndexToDataFrameFieldIndex,
|
||||
dimFields: this.state.dimFields,
|
||||
}}
|
||||
>
|
||||
<VizLayout width={width} height={height} legend={this.renderLegend()}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
{...plotProps}
|
||||
config={this.state.config!}
|
||||
data={this.state.data}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
timeRange={timeRange}
|
||||
>
|
||||
{children}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</VizLayout>
|
||||
</GraphNGContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<VizLayout width={width} height={height} legend={legendElement}>
|
||||
{(vizWidth: number, vizHeight: number) => (
|
||||
<UPlotChart
|
||||
data={frame}
|
||||
config={configBuilder}
|
||||
width={vizWidth}
|
||||
height={vizHeight}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
{...plotProps}
|
||||
>
|
||||
{children}
|
||||
</UPlotChart>
|
||||
)}
|
||||
</VizLayout>
|
||||
);
|
||||
};
|
||||
|
||||
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],
|
||||
}
|
||||
`;
|
45
packages/grafana-ui/src/components/GraphNG/hooks.ts
Normal file
45
packages/grafana-ui/src/components/GraphNG/hooks.ts
Normal file
@ -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
|
||||
@ -18,3 +18,8 @@ export interface GraphNGLegendEvent {
|
||||
fieldIndex: DataFrameFieldIndex;
|
||||
mode: GraphNGLegendEventMode;
|
||||
}
|
||||
|
||||
export interface XYFieldMatchers {
|
||||
x: FieldMatcher; // first match
|
||||
y: FieldMatcher;
|
||||
}
|
||||
|
94
packages/grafana-ui/src/components/GraphNG/utils.test.ts
Normal file
94
packages/grafana-ui/src/components/GraphNG/utils.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
198
packages/grafana-ui/src/components/GraphNG/utils.ts
Normal file
198
packages/grafana-ui/src/components/GraphNG/utils.ts
Normal file
@ -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 { AlignedData } from 'uplot';
|
||||
import {
|
||||
compareDataFrameStructures,
|
||||
DefaultTimeZone,
|
||||
FieldSparkline,
|
||||
IndexVector,
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldSparkline,
|
||||
FieldType,
|
||||
getFieldColorModeForField,
|
||||
FieldConfig,
|
||||
getFieldDisplayName,
|
||||
} from '@grafana/data';
|
||||
import {
|
||||
@ -21,8 +20,10 @@ import {
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { Themeable } from '../../types';
|
||||
import { preparePlotData } from '../uPlot/utils';
|
||||
import { preparePlotFrame } from './utils';
|
||||
|
||||
export interface Props extends Themeable {
|
||||
export interface SparklineProps extends Themeable {
|
||||
width: number;
|
||||
height: number;
|
||||
config?: FieldConfig<GraphFieldConfig>;
|
||||
@ -30,7 +31,8 @@ export interface Props extends Themeable {
|
||||
}
|
||||
|
||||
interface State {
|
||||
data: DataFrame;
|
||||
data: AlignedData;
|
||||
alignedDataFrame: DataFrame;
|
||||
configBuilder: UPlotConfigBuilder;
|
||||
}
|
||||
|
||||
@ -40,51 +42,53 @@ const defaultConfig: GraphFieldConfig = {
|
||||
axisPlacement: AxisPlacement.Hidden,
|
||||
};
|
||||
|
||||
export class Sparkline extends PureComponent<Props, State> {
|
||||
constructor(props: Props) {
|
||||
export class Sparkline extends PureComponent<SparklineProps, State> {
|
||||
constructor(props: SparklineProps) {
|
||||
super(props);
|
||||
|
||||
const data = this.prepareData(props);
|
||||
const alignedDataFrame = preparePlotFrame(props.sparkline, props.config);
|
||||
const data = preparePlotData(alignedDataFrame);
|
||||
|
||||
this.state = {
|
||||
data,
|
||||
configBuilder: this.prepareConfig(data, props),
|
||||
alignedDataFrame,
|
||||
configBuilder: this.prepareConfig(alignedDataFrame),
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(oldProps: Props) {
|
||||
if (oldProps.sparkline !== this.props.sparkline) {
|
||||
const data = this.prepareData(this.props);
|
||||
if (!compareDataFrameStructures(this.state.data, data)) {
|
||||
const configBuilder = this.prepareConfig(data, this.props);
|
||||
this.setState({ data, configBuilder });
|
||||
} else {
|
||||
this.setState({ data });
|
||||
static getDerivedStateFromProps(props: SparklineProps, state: State) {
|
||||
const frame = preparePlotFrame(props.sparkline, props.config);
|
||||
if (!frame) {
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
data: preparePlotData(frame),
|
||||
alignedDataFrame: frame,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
prepareData(props: Props): DataFrame {
|
||||
const { sparkline } = props;
|
||||
const length = sparkline.y.values.length;
|
||||
const yFieldConfig = {
|
||||
...sparkline.y.config,
|
||||
...this.props.config,
|
||||
};
|
||||
|
||||
return {
|
||||
refId: 'sparkline',
|
||||
fields: [
|
||||
sparkline.x ?? IndexVector.newField(length),
|
||||
{
|
||||
...sparkline.y,
|
||||
config: yFieldConfig,
|
||||
},
|
||||
],
|
||||
length,
|
||||
};
|
||||
}
|
||||
|
||||
prepareConfig(data: DataFrame, props: Props) {
|
||||
prepareConfig(data: DataFrame) {
|
||||
const { theme } = this.props;
|
||||
const builder = new UPlotConfigBuilder();
|
||||
|
||||
@ -174,14 +178,7 @@ export class Sparkline extends PureComponent<Props, State> {
|
||||
const { width, height, sparkline } = this.props;
|
||||
|
||||
return (
|
||||
<UPlotChart
|
||||
data={data}
|
||||
config={configBuilder}
|
||||
width={width}
|
||||
height={height}
|
||||
timeRange={sparkline.timeRange!}
|
||||
timeZone={DefaultTimeZone}
|
||||
/>
|
||||
<UPlotChart data={data} config={configBuilder} width={width} height={height} timeRange={sparkline.timeRange!} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
25
packages/grafana-ui/src/components/Sparkline/utils.ts
Normal file
25
packages/grafana-ui/src/components/Sparkline/utils.ts
Normal file
@ -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 legend = (
|
||||
<VizLayout.Legend position="bottom" maxHeight="30%">
|
||||
<VizLayout.Legend placement="bottom" maxHeight="30%">
|
||||
{items.map((_, index) => (
|
||||
<div style={{ height: '30px', width: '100%', background: 'blue', marginBottom: '2px' }} key={index}>
|
||||
Legend item {index}
|
||||
@ -47,7 +47,7 @@ export const RightLegend = () => {
|
||||
const items = Array.from({ length: legendItems }, (_, i) => i + 1);
|
||||
|
||||
const legend = (
|
||||
<VizLayout.Legend position="right" maxWidth="50%">
|
||||
<VizLayout.Legend placement="right" maxWidth="50%">
|
||||
{items.map((_, index) => (
|
||||
<div style={{ height: '30px', width: `${legendWidth}px`, background: 'blue', marginBottom: '2px' }} key={index}>
|
||||
Legend item {index}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React, { FC, CSSProperties, ComponentType } from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
|
||||
import { LegendPlacement } from '..';
|
||||
|
||||
/**
|
||||
* @beta
|
||||
@ -33,7 +34,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
||||
return <div style={containerStyle}>{children(width, height)}</div>;
|
||||
}
|
||||
|
||||
const { position, maxHeight, maxWidth } = legend.props;
|
||||
const { placement, maxHeight, maxWidth } = legend.props;
|
||||
const [legendRef, legendMeasure] = useMeasure();
|
||||
let size: VizSize | null = null;
|
||||
|
||||
@ -43,7 +44,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
|
||||
|
||||
const legendStyle: CSSProperties = {};
|
||||
|
||||
switch (position) {
|
||||
switch (placement) {
|
||||
case 'bottom':
|
||||
containerStyle.flexDirection = 'column';
|
||||
legendStyle.maxHeight = maxHeight;
|
||||
@ -91,7 +92,7 @@ interface VizSize {
|
||||
* @beta
|
||||
*/
|
||||
export interface VizLayoutLegendProps {
|
||||
position: 'bottom' | 'right';
|
||||
placement: LegendPlacement;
|
||||
maxHeight?: string;
|
||||
maxWidth?: string;
|
||||
children: React.ReactNode;
|
||||
|
@ -205,8 +205,9 @@ export { UPlotChart } from './uPlot/Plot';
|
||||
export * from './uPlot/geometries';
|
||||
export * from './uPlot/plugins';
|
||||
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
|
||||
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';
|
||||
export { usePlotContext, usePlotPluginContext } from './uPlot/context';
|
||||
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
|
||||
export { useGraphNGContext } from './GraphNG/hooks';
|
||||
export { BarChart } from './BarChart/BarChart';
|
||||
export { BarChartOptions, BarStackingMode, BarValueVisibility, BarChartFieldConfig } from './BarChart/types';
|
||||
export { GraphNGLegendEvent, GraphNGLegendEventMode } from './GraphNG/types';
|
||||
|
@ -6,6 +6,7 @@ import { GraphFieldConfig, DrawStyle } from '../uPlot/config';
|
||||
import uPlot from 'uplot';
|
||||
import createMockRaf from 'mock-raf';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
import { preparePlotData } from './utils';
|
||||
|
||||
const mockRaf = createMockRaf();
|
||||
const setDataMock = jest.fn();
|
||||
@ -71,10 +72,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
const { unmount } = render(
|
||||
<UPlotChart
|
||||
data={data} // mock
|
||||
data={preparePlotData(data)} // mock
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
@ -96,10 +96,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
data={data} // mock
|
||||
data={preparePlotData(data)} // mock
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
@ -116,10 +115,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
rerender(
|
||||
<UPlotChart
|
||||
data={data} // changed
|
||||
data={preparePlotData(data)} // changed
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
@ -134,7 +132,7 @@ describe('UPlotChart', () => {
|
||||
const { data, timeRange, config } = mockData();
|
||||
|
||||
const { queryAllByTestId } = render(
|
||||
<UPlotChart data={data} config={config} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
|
||||
<UPlotChart data={preparePlotData(data)} config={config} timeRange={timeRange} width={0} height={0} />
|
||||
);
|
||||
|
||||
expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
|
||||
@ -146,10 +144,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
data={data} // frame
|
||||
data={preparePlotData(data)} // frame
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
@ -164,10 +161,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
rerender(
|
||||
<UPlotChart
|
||||
data={data}
|
||||
data={preparePlotData(data)}
|
||||
config={new UPlotConfigBuilder()}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
@ -182,10 +178,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
const { rerender } = render(
|
||||
<UPlotChart
|
||||
data={data} // frame
|
||||
data={preparePlotData(data)} // frame
|
||||
config={config}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
@ -198,10 +193,9 @@ describe('UPlotChart', () => {
|
||||
|
||||
rerender(
|
||||
<UPlotChart
|
||||
data={data} // frame
|
||||
data={preparePlotData(data)} // frame
|
||||
config={new UPlotConfigBuilder()}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={200}
|
||||
height={200}
|
||||
/>
|
||||
|
@ -4,7 +4,6 @@ import { buildPlotContext, PlotContext } from './context';
|
||||
import { pluginLog } from './utils';
|
||||
import { usePlotConfig } from './hooks';
|
||||
import { PlotProps } from './types';
|
||||
import { DataFrame, dateTime, FieldType } from '@grafana/data';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
|
||||
@ -19,12 +18,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
|
||||
const plotInstance = useRef<uPlot>();
|
||||
const [isPlotReady, setIsPlotReady] = useState(false);
|
||||
const prevProps = usePrevious(props);
|
||||
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(
|
||||
props.width,
|
||||
props.height,
|
||||
props.timeZone,
|
||||
props.config
|
||||
);
|
||||
const { isConfigReady, currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.config);
|
||||
|
||||
const getPlotInstance = useCallback(() => {
|
||||
return plotInstance.current;
|
||||
@ -39,7 +33,7 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
|
||||
|
||||
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
|
||||
if (isConfigReady && !plotInstance.current) {
|
||||
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current);
|
||||
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
|
||||
setIsPlotReady(true);
|
||||
return;
|
||||
}
|
||||
@ -54,18 +48,18 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. When config or timezone has changed, re-initialize plot
|
||||
if (isConfigReady && (props.config !== prevProps.config || props.timeZone !== prevProps.timeZone)) {
|
||||
// 3. When config has changed re-initialize plot
|
||||
if (isConfigReady && props.config !== prevProps.config) {
|
||||
if (plotInstance.current) {
|
||||
pluginLog('uPlot core', false, 'destroying instance');
|
||||
plotInstance.current.destroy();
|
||||
}
|
||||
plotInstance.current = initializePlot(prepareData(props.data), currentConfig.current, canvasRef.current);
|
||||
plotInstance.current = initializePlot(props.data, currentConfig.current, canvasRef.current);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Otherwise, assume only data has changed and update uPlot data
|
||||
updateData(props.data, props.config, plotInstance.current, prepareData(props.data));
|
||||
updateData(props.config, props.data, plotInstance.current);
|
||||
}, [props, isConfigReady]);
|
||||
|
||||
// When component unmounts, clean the existing uPlot instance
|
||||
@ -86,29 +80,12 @@ export const UPlotChart: React.FC<PlotProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
function prepareData(frame: DataFrame): AlignedData {
|
||||
return frame.fields.map((f) => {
|
||||
if (f.type === FieldType.time) {
|
||||
if (f.values.length > 0 && typeof f.values.get(0) === 'string') {
|
||||
const timestamps = [];
|
||||
for (let i = 0; i < f.values.length; i++) {
|
||||
timestamps.push(dateTime(f.values.get(i)).valueOf());
|
||||
}
|
||||
return timestamps;
|
||||
}
|
||||
return f.values.toArray();
|
||||
}
|
||||
|
||||
return f.values.toArray();
|
||||
}) as AlignedData;
|
||||
}
|
||||
|
||||
function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement) {
|
||||
function initializePlot(data: AlignedData | null, config: Options, el: HTMLDivElement) {
|
||||
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
|
||||
return new uPlot(config, data, el);
|
||||
}
|
||||
|
||||
function updateData(frame: DataFrame, config: UPlotConfigBuilder, plotInstance?: uPlot, data?: AlignedData | null) {
|
||||
function updateData(config: UPlotConfigBuilder, data?: AlignedData | null, plotInstance?: uPlot) {
|
||||
if (!plotInstance || !data) {
|
||||
return;
|
||||
}
|
||||
|
97
packages/grafana-ui/src/components/uPlot/PlotLegend.tsx
Normal file
97
packages/grafana-ui/src/components/uPlot/PlotLegend.tsx
Normal file
@ -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 [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -103,6 +104,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -171,6 +173,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -219,6 +222,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -268,6 +272,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -341,6 +346,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"series": Array [
|
||||
Object {},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
@ -477,6 +483,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
"width": 1,
|
||||
},
|
||||
],
|
||||
"tzDate": [Function],
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
@ -3,8 +3,9 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { AxisPlacement } from '../config';
|
||||
import { Cursor, Band, Hooks, BBox } from 'uplot';
|
||||
import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
|
||||
import { defaultsDeep } from 'lodash';
|
||||
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
|
||||
|
||||
type valueof<T> = T[keyof T];
|
||||
|
||||
@ -20,6 +21,8 @@ export class UPlotConfigBuilder {
|
||||
private hasBottomAxis = false;
|
||||
private hooks: Hooks.Arrays = {};
|
||||
|
||||
constructor(private getTimeZone = () => DefaultTimeZone) {}
|
||||
|
||||
addHook(type: keyof Hooks.Defs, hook: valueof<Hooks.Defs>) {
|
||||
if (!this.hooks[type]) {
|
||||
this.hooks[type] = [];
|
||||
@ -110,6 +113,8 @@ export class UPlotConfigBuilder {
|
||||
|
||||
config.cursor = this.cursor || {};
|
||||
|
||||
config.tzDate = this.tzDate;
|
||||
|
||||
// When bands exist, only keep fill when defined
|
||||
if (this.bands?.length) {
|
||||
config.bands = this.bands;
|
||||
@ -159,4 +164,17 @@ export class UPlotConfigBuilder {
|
||||
|
||||
return axes;
|
||||
}
|
||||
|
||||
private tzDate = (ts: number) => {
|
||||
if (!this.getTimeZone) {
|
||||
return new Date(ts);
|
||||
}
|
||||
const tz = getTimeZoneInfo(this.getTimeZone(), Date.now())?.ianaName;
|
||||
|
||||
if (!tz) {
|
||||
return new Date(ts);
|
||||
}
|
||||
|
||||
return uPlot.tzDate(new Date(ts), tz);
|
||||
};
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import uPlot, { Series } from 'uplot';
|
||||
import React, { useContext } from 'react';
|
||||
import uPlot, { AlignedData, Series } from 'uplot';
|
||||
import { PlotPlugin } from './types';
|
||||
import { DataFrame, Field, FieldConfig } from '@grafana/data';
|
||||
|
||||
interface PlotCanvasContextType {
|
||||
// canvas size css pxs
|
||||
@ -26,7 +25,7 @@ interface PlotContextType extends PlotPluginsContextType {
|
||||
getSeries: () => Series[];
|
||||
getCanvas: () => PlotCanvasContextType;
|
||||
canvasRef: any;
|
||||
data: DataFrame;
|
||||
data: AlignedData;
|
||||
}
|
||||
|
||||
export const PlotContext = React.createContext<PlotContextType>({} as PlotContextType);
|
||||
@ -51,85 +50,10 @@ export const usePlotPluginContext = (): PlotPluginsContextType => {
|
||||
};
|
||||
};
|
||||
|
||||
// Exposes API for building uPlot config
|
||||
|
||||
interface PlotDataAPI {
|
||||
/** Data frame passed to graph, x-axis aligned */
|
||||
data: DataFrame;
|
||||
/** Returns field by index */
|
||||
getField: (idx: number) => Field;
|
||||
/** Returns x-axis fields */
|
||||
getXAxisFields: () => Field[];
|
||||
/** Returns x-axis fields */
|
||||
getYAxisFields: () => Field[];
|
||||
/** Returns field value by field and value index */
|
||||
getFieldValue: (fieldIdx: number, rowIdx: number) => any;
|
||||
/** Returns field config by field index */
|
||||
getFieldConfig: (fieldIdx: number) => FieldConfig;
|
||||
}
|
||||
|
||||
export const usePlotData = (): PlotDataAPI => {
|
||||
const ctx = usePlotContext();
|
||||
|
||||
const getField = useCallback(
|
||||
(idx: number) => {
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
return ctx!.data.fields[idx];
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
|
||||
const getFieldConfig = useCallback(
|
||||
(idx: number) => {
|
||||
const field: Field = getField(idx);
|
||||
return field.config;
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
|
||||
const getFieldValue = useCallback(
|
||||
(fieldIdx: number, rowIdx: number) => {
|
||||
const field: Field = getField(fieldIdx);
|
||||
return field.values.get(rowIdx);
|
||||
},
|
||||
[ctx]
|
||||
);
|
||||
|
||||
const getXAxisFields = useCallback(() => {
|
||||
// by uPlot convention x-axis is always first field
|
||||
// this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html)
|
||||
return [getField(0)];
|
||||
}, [ctx]);
|
||||
|
||||
const getYAxisFields = useCallback(() => {
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
// by uPlot convention x-axis is always first field
|
||||
// this may change when we introduce non-time x-axis and multiple x-axes (https://leeoniya.github.io/uPlot/demos/time-periods.html)
|
||||
return ctx!.data.fields.slice(1);
|
||||
}, [ctx]);
|
||||
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotData');
|
||||
}
|
||||
|
||||
return {
|
||||
data: ctx.data,
|
||||
getField,
|
||||
getFieldValue,
|
||||
getFieldConfig,
|
||||
getXAxisFields,
|
||||
getYAxisFields,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildPlotContext = (
|
||||
isPlotReady: boolean,
|
||||
canvasRef: any,
|
||||
data: DataFrame,
|
||||
data: AlignedData,
|
||||
registerPlugin: any,
|
||||
getPlotInstance: () => uPlot | undefined
|
||||
): PlotContextType => {
|
||||
|
@ -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 { pluginLog } from './utils';
|
||||
import uPlot, { Options, PaddingSide } from 'uplot';
|
||||
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
|
||||
import { Options, PaddingSide } from 'uplot';
|
||||
import { usePlotPluginContext } from './context';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
import useMountedState from 'react-use/lib/useMountedState';
|
||||
|
||||
export const usePlotPlugins = () => {
|
||||
/**
|
||||
@ -108,22 +108,11 @@ export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => {
|
||||
export const usePlotConfig = (width: number, height: number, configBuilder: UPlotConfigBuilder) => {
|
||||
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
|
||||
const [isConfigReady, setIsConfigReady] = useState(false);
|
||||
|
||||
const currentConfig = useRef<Options>();
|
||||
const tzDate = useMemo(() => {
|
||||
let fmt = undefined;
|
||||
|
||||
const tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
|
||||
|
||||
if (tz) {
|
||||
fmt = (ts: number) => uPlot.tzDate(new Date(ts), tz);
|
||||
}
|
||||
|
||||
return fmt;
|
||||
}, [timeZone]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!arePluginsReady) {
|
||||
@ -137,12 +126,11 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
|
||||
plugins: Object.entries(plugins).map((p) => ({
|
||||
hooks: p[1].hooks,
|
||||
})),
|
||||
tzDate,
|
||||
...configBuilder.getConfig(),
|
||||
};
|
||||
|
||||
setIsConfigReady(true);
|
||||
}, [arePluginsReady, plugins, width, height, tzDate, configBuilder]);
|
||||
}, [arePluginsReady, plugins, width, height, configBuilder]);
|
||||
|
||||
return {
|
||||
isConfigReady,
|
||||
@ -158,6 +146,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
|
||||
*/
|
||||
export const useRefreshAfterGraphRendered = (pluginId: string) => {
|
||||
const pluginsApi = usePlotPluginContext();
|
||||
const isMounted = useMountedState();
|
||||
const [renderToken, setRenderToken] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@ -166,7 +155,9 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => {
|
||||
hooks: {
|
||||
// refresh events when uPlot draws
|
||||
draw: () => {
|
||||
setRenderToken((c) => c + 1);
|
||||
if (isMounted()) {
|
||||
setRenderToken((c) => c + 1);
|
||||
}
|
||||
return;
|
||||
},
|
||||
},
|
||||
|
@ -1,29 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Portal } from '../../Portal/Portal';
|
||||
import { usePlotContext, usePlotData } from '../context';
|
||||
import { usePlotContext } from '../context';
|
||||
import { CursorPlugin } from './CursorPlugin';
|
||||
import { SeriesTable, SeriesTableRowProps } from '../../Graph/GraphTooltip/SeriesTable';
|
||||
import { FieldType, formattedValueToString, getDisplayProcessor, getFieldDisplayName, TimeZone } from '@grafana/data';
|
||||
import {
|
||||
DataFrame,
|
||||
FieldType,
|
||||
formattedValueToString,
|
||||
getDisplayProcessor,
|
||||
getFieldDisplayName,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { TooltipContainer } from '../../Chart/TooltipContainer';
|
||||
import { TooltipMode } from '../../Chart/Tooltip';
|
||||
import { useGraphNGContext } from '../../GraphNG/hooks';
|
||||
|
||||
interface TooltipPluginProps {
|
||||
mode?: TooltipMode;
|
||||
timeZone: TimeZone;
|
||||
data: DataFrame[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone }) => {
|
||||
export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', timeZone, ...otherProps }) => {
|
||||
const pluginId = 'PlotTooltip';
|
||||
const plotContext = usePlotContext();
|
||||
const { data, getField, getXAxisFields } = usePlotData();
|
||||
const graphContext = useGraphNGContext();
|
||||
|
||||
const xAxisFields = getXAxisFields();
|
||||
// assuming single x-axis
|
||||
const xAxisField = xAxisFields[0];
|
||||
const xAxisFmt = xAxisField.display || getDisplayProcessor({ field: xAxisField, timeZone });
|
||||
let xField = graphContext.getXAxisField(otherProps.data);
|
||||
if (!xField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
|
||||
|
||||
return (
|
||||
<CursorPlugin id={pluginId}>
|
||||
@ -31,7 +42,6 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
if (!plotContext.getPlotInstance()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let tooltip = null;
|
||||
|
||||
// when no no cursor interaction
|
||||
@ -39,10 +49,17 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
return null;
|
||||
}
|
||||
|
||||
const xVal = xFieldFmt(xField!.values.get(focusedPointIdx)).text;
|
||||
|
||||
// origin field/frame indexes for inspecting the data
|
||||
const originFieldIndex = focusedSeriesIdx
|
||||
? graphContext.mapSeriesIndexToDataFrameFieldIndex(focusedSeriesIdx)
|
||||
: null;
|
||||
|
||||
// when interacting with a point in single mode
|
||||
if (mode === 'single' && focusedSeriesIdx !== null) {
|
||||
const xVal = xAxisFmt(xAxisFields[0]!.values.get(focusedPointIdx)).text;
|
||||
const field = getField(focusedSeriesIdx);
|
||||
if (mode === 'single' && originFieldIndex !== null) {
|
||||
const field = otherProps.data[originFieldIndex.frameIndex].fields[originFieldIndex.fieldIndex];
|
||||
|
||||
const fieldFmt = field.display || getDisplayProcessor({ field, timeZone });
|
||||
tooltip = (
|
||||
<SeriesTable
|
||||
@ -50,7 +67,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
{
|
||||
// TODO: align with uPlot typings
|
||||
color: (plotContext.getSeries()[focusedSeriesIdx!].stroke as any)(),
|
||||
label: getFieldDisplayName(field, data),
|
||||
label: getFieldDisplayName(field, otherProps.data[originFieldIndex.frameIndex]),
|
||||
value: fieldFmt(field.values.get(focusedPointIdx)).text,
|
||||
},
|
||||
]}
|
||||
@ -60,10 +77,11 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
}
|
||||
|
||||
if (mode === 'multi') {
|
||||
const xVal = xAxisFmt(xAxisFields[0].values.get(focusedPointIdx)).text;
|
||||
tooltip = (
|
||||
<SeriesTable
|
||||
series={data.fields.reduce<SeriesTableRowProps[]>((agg, f, i) => {
|
||||
let series: SeriesTableRowProps[] = [];
|
||||
|
||||
for (let i = 0; i < otherProps.data.length; i++) {
|
||||
series = series.concat(
|
||||
otherProps.data[i].fields.reduce<SeriesTableRowProps[]>((agg, f, j) => {
|
||||
// skipping time field and non-numeric fields
|
||||
if (f.type === FieldType.time || f.type !== FieldType.number) {
|
||||
return agg;
|
||||
@ -77,16 +95,19 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({ mode = 'single', t
|
||||
...agg,
|
||||
{
|
||||
// TODO: align with uPlot typings
|
||||
color: (plotContext.getSeries()[i].stroke as any)!(),
|
||||
label: getFieldDisplayName(f, data),
|
||||
color: (plotContext.getSeries()[j].stroke as any)!(),
|
||||
label: getFieldDisplayName(f, otherProps.data[i]),
|
||||
value: formattedValueToString(f.display!(f.values.get(focusedPointIdx!))),
|
||||
isActive: focusedSeriesIdx === i,
|
||||
isActive: originFieldIndex
|
||||
? originFieldIndex.frameIndex === i && originFieldIndex.fieldIndex === j
|
||||
: false,
|
||||
},
|
||||
];
|
||||
}, [])}
|
||||
timestamp={xVal}
|
||||
/>
|
||||
);
|
||||
}, [])
|
||||
);
|
||||
}
|
||||
|
||||
tooltip = <SeriesTable series={series} timestamp={xVal} />;
|
||||
}
|
||||
|
||||
if (!tooltip) {
|
||||
|
@ -1,9 +1,12 @@
|
||||
import React from 'react';
|
||||
import uPlot, { Options, Hooks } from 'uplot';
|
||||
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
|
||||
import uPlot, { Options, Hooks, AlignedData } from 'uplot';
|
||||
import { TimeRange } from '@grafana/data';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
|
||||
export type PlotConfig = Pick<Options, 'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select'>;
|
||||
export type PlotConfig = Pick<
|
||||
Options,
|
||||
'series' | 'scales' | 'axes' | 'cursor' | 'bands' | 'hooks' | 'select' | 'tzDate'
|
||||
>;
|
||||
|
||||
export type PlotPlugin = {
|
||||
id: string;
|
||||
@ -17,12 +20,11 @@ export interface PlotPluginProps {
|
||||
}
|
||||
|
||||
export interface PlotProps {
|
||||
data: DataFrame;
|
||||
timeRange: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
data: AlignedData;
|
||||
width: number;
|
||||
height: number;
|
||||
config: UPlotConfigBuilder;
|
||||
timeRange: TimeRange;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DataFrame, dateTime, FieldType } from '@grafana/data';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { Options } from 'uplot';
|
||||
import { AlignedData, Options } from 'uplot';
|
||||
import { PlotPlugin, PlotProps } from './types';
|
||||
|
||||
const LOGGING_ENABLED = false;
|
||||
@ -31,29 +32,32 @@ export function buildPlotConfig(props: PlotProps, plugins: Record<string, PlotPl
|
||||
} as Options;
|
||||
}
|
||||
|
||||
export function isPlottingTime(config: Options) {
|
||||
let isTimeSeries = false;
|
||||
|
||||
if (!config.scales) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < Object.keys(config.scales).length; i++) {
|
||||
const key = Object.keys(config.scales)[i];
|
||||
if (config.scales[key].time === true) {
|
||||
isTimeSeries = true;
|
||||
break;
|
||||
/** @internal */
|
||||
export function preparePlotData(frame: DataFrame): AlignedData {
|
||||
return frame.fields.map((f) => {
|
||||
if (f.type === FieldType.time) {
|
||||
if (f.values.length > 0 && typeof f.values.get(0) === 'string') {
|
||||
const timestamps = [];
|
||||
for (let i = 0; i < f.values.length; i++) {
|
||||
timestamps.push(dateTime(f.values.get(i)).valueOf());
|
||||
}
|
||||
return timestamps;
|
||||
}
|
||||
return f.values.toArray();
|
||||
}
|
||||
}
|
||||
|
||||
return isTimeSeries;
|
||||
return f.values.toArray();
|
||||
}) as AlignedData;
|
||||
}
|
||||
|
||||
// Dev helpers
|
||||
|
||||
/** @internal */
|
||||
export const throttledLog = throttle((...t: any[]) => {
|
||||
console.log(...t);
|
||||
}, 500);
|
||||
|
||||
/** @internal */
|
||||
export function pluginLog(id: string, throttle = false, ...t: any[]) {
|
||||
if (process.env.NODE_ENV === 'production' || !LOGGING_ENABLED) {
|
||||
return;
|
||||
|
@ -129,14 +129,10 @@ export function ExploreGraphNGPanel({
|
||||
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
|
||||
timeZone={timeZone}
|
||||
>
|
||||
<TooltipPlugin mode="single" timeZone={timeZone} />
|
||||
<ZoomPlugin onZoom={onUpdateTimeRange} />
|
||||
<ContextMenuPlugin timeZone={timeZone} />
|
||||
{annotations ? (
|
||||
<ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<TooltipPlugin data={data} mode="single" timeZone={timeZone} />
|
||||
<ContextMenuPlugin data={data} timeZone={timeZone} />
|
||||
{annotations && <ExemplarsPlugin exemplars={annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />}
|
||||
</GraphNG>
|
||||
</Collapse>
|
||||
</>
|
||||
|
@ -1,17 +1,11 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { DataFrame, Field, FieldType, PanelProps } from '@grafana/data';
|
||||
import { FieldType, PanelProps, VizOrientation } from '@grafana/data';
|
||||
import { BarChart, BarChartOptions, GraphNGLegendEvent } from '@grafana/ui';
|
||||
import { changeSeriesColorConfigFactory } from '../timeseries/overrides/colorSeriesConfigFactory';
|
||||
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
|
||||
import { config } from 'app/core/config';
|
||||
|
||||
interface Props extends PanelProps<BarChartOptions> {}
|
||||
|
||||
interface BarData {
|
||||
error?: string;
|
||||
frame?: DataFrame; // first string vs all numbers
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@ -23,13 +17,13 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
fieldConfig,
|
||||
onFieldConfigChange,
|
||||
}) => {
|
||||
if (!data || !data.series?.length) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const orientation = useMemo(() => {
|
||||
if (!options.orientation || options.orientation === VizOrientation.Auto) {
|
||||
return width < height ? VizOrientation.Horizontal : VizOrientation.Vertical;
|
||||
}
|
||||
|
||||
return options.orientation;
|
||||
}, [width, height, options.orientation]);
|
||||
|
||||
const onLegendClick = useCallback(
|
||||
(event: GraphNGLegendEvent) => {
|
||||
@ -45,43 +39,7 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
[fieldConfig, onFieldConfigChange]
|
||||
);
|
||||
|
||||
const barData = useMemo<BarData>(() => {
|
||||
const firstFrame = data.series[0];
|
||||
const firstString = firstFrame.fields.find((f) => f.type === FieldType.string);
|
||||
if (!firstString) {
|
||||
return {
|
||||
error: 'Bar charts requires a string field',
|
||||
};
|
||||
}
|
||||
const fields: Field[] = [firstString];
|
||||
for (const f of firstFrame.fields) {
|
||||
if (f.type === FieldType.number) {
|
||||
fields.push(f);
|
||||
}
|
||||
}
|
||||
if (fields.length < 2) {
|
||||
return {
|
||||
error: 'No numeric fields found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
frame: {
|
||||
...firstFrame,
|
||||
fields, // filtered to to the values we have
|
||||
},
|
||||
};
|
||||
}, [width, height, options, data]);
|
||||
|
||||
if (barData.error) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>{barData.error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!barData.frame) {
|
||||
if (!data || !data.series?.length) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
@ -89,15 +47,38 @@ export const BarChartPanel: React.FunctionComponent<Props> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const firstFrame = data.series[0];
|
||||
if (!firstFrame.fields.find((f) => f.type === FieldType.string)) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>Bar charts requires a string field</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (
|
||||
firstFrame.fields.reduce((acc, f) => {
|
||||
if (f.type === FieldType.number) {
|
||||
return acc + 1;
|
||||
}
|
||||
return acc;
|
||||
}, 0) < 2
|
||||
) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No numeric fields found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BarChart
|
||||
data={barData.frame}
|
||||
data={data.series}
|
||||
width={width}
|
||||
height={height}
|
||||
theme={config.theme}
|
||||
onLegendClick={onLegendClick}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
{...options}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
@ -41,6 +41,14 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
[fieldConfig, onFieldConfigChange]
|
||||
);
|
||||
|
||||
if (!data || !data.series?.length) {
|
||||
return (
|
||||
<div className="panel-empty">
|
||||
<p>No data found in response</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GraphNG
|
||||
data={data.series}
|
||||
@ -52,9 +60,9 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
|
||||
onLegendClick={onLegendClick}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
>
|
||||
<TooltipPlugin mode={options.tooltipOptions.mode} timeZone={timeZone} />
|
||||
<ZoomPlugin onZoom={onChangeTimeRange} />
|
||||
<ContextMenuPlugin timeZone={timeZone} replaceVariables={replaceVariables} />
|
||||
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode} timeZone={timeZone} />
|
||||
<ContextMenuPlugin data={data.series} timeZone={timeZone} replaceVariables={replaceVariables} />
|
||||
{data.annotations && (
|
||||
<ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} getFieldLinks={getFieldLinks} />
|
||||
)}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import {
|
||||
ClickPlugin,
|
||||
ContextMenu,
|
||||
@ -7,12 +7,11 @@ import {
|
||||
MenuItem,
|
||||
MenuItemsGroup,
|
||||
Portal,
|
||||
usePlotData,
|
||||
useGraphNGContext,
|
||||
} from '@grafana/ui';
|
||||
import {
|
||||
DataFrame,
|
||||
DataFrameView,
|
||||
DisplayValue,
|
||||
Field,
|
||||
getDisplayProcessor,
|
||||
getFieldDisplayName,
|
||||
InterpolateFunction,
|
||||
@ -22,6 +21,7 @@ import { useClickAway } from 'react-use';
|
||||
import { getFieldLinksSupplier } from '../../../../features/panel/panellinks/linkSuppliers';
|
||||
|
||||
interface ContextMenuPluginProps {
|
||||
data: DataFrame[];
|
||||
defaultItems?: MenuItemsGroup[];
|
||||
timeZone: TimeZone;
|
||||
onOpen?: () => void;
|
||||
@ -30,6 +30,7 @@ interface ContextMenuPluginProps {
|
||||
}
|
||||
|
||||
export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
||||
data,
|
||||
onClose,
|
||||
timeZone,
|
||||
defaultItems,
|
||||
@ -47,6 +48,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
||||
return (
|
||||
<Portal>
|
||||
<ContextMenuView
|
||||
data={data}
|
||||
defaultItems={defaultItems}
|
||||
timeZone={timeZone}
|
||||
selection={{ point, coords }}
|
||||
@ -66,6 +68,7 @@ export const ContextMenuPlugin: React.FC<ContextMenuPluginProps> = ({
|
||||
};
|
||||
|
||||
interface ContextMenuProps {
|
||||
data: DataFrame[];
|
||||
defaultItems?: MenuItemsGroup[];
|
||||
timeZone: TimeZone;
|
||||
onClose?: () => void;
|
||||
@ -81,11 +84,11 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
||||
timeZone,
|
||||
defaultItems,
|
||||
replaceVariables,
|
||||
data,
|
||||
...otherProps
|
||||
}) => {
|
||||
const ref = useRef(null);
|
||||
const { data } = usePlotData();
|
||||
const { seriesIdx, dataIdx } = selection.point;
|
||||
const graphContext = useGraphNGContext();
|
||||
|
||||
const onClose = () => {
|
||||
if (otherProps.onClose) {
|
||||
@ -97,65 +100,69 @@ export const ContextMenuView: React.FC<ContextMenuProps> = ({
|
||||
onClose();
|
||||
});
|
||||
|
||||
const contextMenuProps = useMemo(() => {
|
||||
const items = defaultItems ? [...defaultItems] : [];
|
||||
let field: Field;
|
||||
let displayValue: DisplayValue;
|
||||
const timeField = data.fields[0];
|
||||
const timeFormatter = timeField.display || getDisplayProcessor({ field: timeField, timeZone });
|
||||
let renderHeader: () => JSX.Element | null = () => null;
|
||||
const xField = graphContext.getXAxisField(data);
|
||||
|
||||
if (seriesIdx && dataIdx) {
|
||||
field = data.fields[seriesIdx];
|
||||
displayValue = field.display!(field.values.get(dataIdx));
|
||||
const hasLinks = field.config.links && field.config.links.length > 0;
|
||||
if (!xField) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hasLinks) {
|
||||
const linksSupplier = getFieldLinksSupplier({
|
||||
display: displayValue,
|
||||
name: field.name,
|
||||
view: new DataFrameView(data),
|
||||
rowIndex: dataIdx,
|
||||
colIndex: seriesIdx,
|
||||
field: field.config,
|
||||
hasLinks,
|
||||
const items = defaultItems ? [...defaultItems] : [];
|
||||
let renderHeader: () => JSX.Element | null = () => null;
|
||||
|
||||
const { seriesIdx, dataIdx } = selection.point;
|
||||
const xFieldFmt = xField.display || getDisplayProcessor({ field: xField, timeZone });
|
||||
|
||||
if (seriesIdx && dataIdx) {
|
||||
// origin field/frame indexes for inspecting the data
|
||||
const originFieldIndex = graphContext.mapSeriesIndexToDataFrameFieldIndex(seriesIdx);
|
||||
const frame = data[originFieldIndex.frameIndex];
|
||||
const field = frame.fields[originFieldIndex.fieldIndex];
|
||||
|
||||
const displayValue = field.display!(field.values.get(dataIdx));
|
||||
|
||||
const hasLinks = field.config.links && field.config.links.length > 0;
|
||||
|
||||
if (hasLinks) {
|
||||
const linksSupplier = getFieldLinksSupplier({
|
||||
display: displayValue,
|
||||
name: field.name,
|
||||
view: new DataFrameView(frame),
|
||||
rowIndex: dataIdx,
|
||||
colIndex: originFieldIndex.fieldIndex,
|
||||
field: field.config,
|
||||
hasLinks,
|
||||
});
|
||||
|
||||
if (linksSupplier) {
|
||||
items.push({
|
||||
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => {
|
||||
return {
|
||||
label: link.title,
|
||||
url: link.href,
|
||||
target: link.target,
|
||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||
onClick: link.onClick,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
if (linksSupplier) {
|
||||
items.push({
|
||||
items: linksSupplier.getLinks(replaceVariables).map<MenuItem>((link) => {
|
||||
return {
|
||||
label: link.title,
|
||||
url: link.href,
|
||||
target: link.target,
|
||||
icon: `${link.target === '_self' ? 'link' : 'external-link-alt'}` as IconName,
|
||||
onClick: link.onClick,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderHeader = () => (
|
||||
<GraphContextMenuHeader
|
||||
timestamp={timeFormatter(timeField.values.get(dataIdx)).text}
|
||||
displayValue={displayValue}
|
||||
seriesColor={displayValue.color!}
|
||||
displayName={getFieldDisplayName(field, data)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
renderHeader,
|
||||
items,
|
||||
};
|
||||
}, [defaultItems, seriesIdx, dataIdx, data]);
|
||||
// eslint-disable-next-line react/display-name
|
||||
renderHeader = () => (
|
||||
<GraphContextMenuHeader
|
||||
timestamp={xFieldFmt(xField.values.get(dataIdx)).text}
|
||||
displayValue={displayValue}
|
||||
seriesColor={displayValue.color!}
|
||||
displayName={getFieldDisplayName(field, frame)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
{...contextMenuProps}
|
||||
items={items}
|
||||
renderHeader={renderHeader}
|
||||
x={selection.coords.viewport.x}
|
||||
y={selection.coords.viewport.y}
|
||||
onClose={onClose}
|
||||
|
@ -19,6 +19,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
|
||||
onFieldConfigChange,
|
||||
}) => {
|
||||
const dims = useMemo(() => getXYDimensions(options.dims, data.series), [options.dims, data.series]);
|
||||
|
||||
if (dims.error) {
|
||||
return (
|
||||
<div>
|
||||
@ -61,7 +62,7 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
|
||||
onLegendClick={onLegendClick}
|
||||
onSeriesColorChange={onSeriesColorChange}
|
||||
>
|
||||
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||
<TooltipPlugin data={data.series} mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
|
||||
<>{/* needs to be an array */}</>
|
||||
</GraphNG>
|
||||
);
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { DataFrame, Field, FieldMatcher, FieldType, getFieldDisplayName } from '@grafana/data';
|
||||
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/GraphNG';
|
||||
import { XYDimensionConfig } from './types';
|
||||
|
||||
// TODO: fix import
|
||||
import { XYFieldMatchers } from '@grafana/ui/src/components/GraphNG/types';
|
||||
|
||||
export enum DimensionError {
|
||||
NoData,
|
||||
BadFrameSelection,
|
||||
@ -21,7 +23,7 @@ export function isGraphable(field: Field) {
|
||||
return field.type === FieldType.number;
|
||||
}
|
||||
|
||||
export function getXYDimensions(cfg: XYDimensionConfig, data?: DataFrame[]): XYDimensions {
|
||||
export function getXYDimensions(cfg?: XYDimensionConfig, data?: DataFrame[]): XYDimensions {
|
||||
if (!data || !data.length) {
|
||||
return { error: DimensionError.NoData } as XYDimensions;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user