GraphNG: refactor (#33348)

This commit is contained in:
Leon Sorokin 2021-05-05 03:44:31 -05:00 committed by GitHub
parent 545d930a13
commit a5c13feb61
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 552 additions and 598 deletions

View File

@ -1,4 +1,4 @@
import uPlot, { Axis, Series, Cursor, BBox } from 'uplot';
import uPlot, { Axis, Series, Cursor, Select } from 'uplot';
import { Quadtree, Rect, pointWithin } from './quadtree';
import { distribute, SPACE_BETWEEN } from './distribute';
@ -212,7 +212,7 @@ export function getConfig(opts: BarsOptions) {
// disable selection
// uPlot types do not export the Select interface prior to 1.6.4
const select: Partial<BBox> = {
const select: Partial<Select> = {
show: false,
};

View File

@ -14,6 +14,7 @@ import { BarChartFieldConfig, BarChartOptions, BarValueVisibility, defaultBarCha
import { AxisPlacement, ScaleDirection, ScaleDistribution, ScaleOrientation } from '../uPlot/config';
import { BarsOptions, getConfig } from './bars';
import { FIXED_UNIT } from '../GraphNG/GraphNG';
import { Select } from 'uplot';
/** @alpha */
export function preparePlotConfigBuilder(
@ -68,7 +69,7 @@ export function preparePlotConfigBuilder(
builder.addHook('setCursor', config.setCursor);
builder.setCursor(config.cursor);
builder.setSelect(config.select);
builder.setSelect(config.select as Select);
builder.addScale({
scaleKey: 'x',

View File

@ -1,15 +1,16 @@
import { FieldColorModeId, toDataFrame, dateTime } from '@grafana/data';
import React from 'react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { GraphNG, GraphNGProps } from './GraphNG';
import { GraphNGProps } from './GraphNG';
import { LegendDisplayMode, LegendPlacement } from '../VizLegend/models.gen';
import { prepDataForStorybook } from '../../utils/storybook/data';
import { useTheme2 } from '../../themes';
import { Story } from '@storybook/react';
import { TimeSeries } from '../TimeSeries/TimeSeries';
export default {
title: 'Visualizations/GraphNG',
component: GraphNG,
component: TimeSeries,
decorators: [withCenteredStory],
parameters: {
knobs: {
@ -51,9 +52,9 @@ export const Lines: Story<StoryProps> = ({ placement, unit, legendDisplayMode, .
const data = prepDataForStorybook([seriesA], theme);
return (
<GraphNG
<TimeSeries
{...args}
data={data}
frames={data}
legend={{
displayMode:
legendDisplayMode === 'hidden'

View File

@ -1,16 +1,14 @@
import React from 'react';
import { AlignedData } from 'uplot';
import { DataFrame, FieldMatcherID, fieldMatchers, TimeRange, TimeZone } from '@grafana/data';
import { DataFrame, FieldMatcherID, fieldMatchers, FieldType, TimeRange, TimeZone } from '@grafana/data';
import { Themeable2 } from '../../types';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { GraphNGLegendEvent, XYFieldMatchers } from './types';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { pluginLog, preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend';
import { preparePlotFrame } from './utils';
import { preparePlotData } from '../uPlot/utils';
import { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode, VizLegendOptions } from '../VizLegend/models.gen';
import { VizLegendOptions } from '../VizLegend/models.gen';
import { VizLayout } from '../VizLayout/VizLayout';
import { withTheme2 } from '../../themes/ThemeContext';
/**
* @internal -- not a public API
@ -20,141 +18,124 @@ export const FIXED_UNIT = '__fixed';
export interface GraphNGProps extends Themeable2 {
width: number;
height: number;
data: DataFrame[];
structureRev?: number; // a number that will change when the data[] structure changes
frames: DataFrame[];
structureRev?: number; // a number that will change when the frames[] structure changes
timeRange: TimeRange;
legend: VizLegendOptions;
timeZone: TimeZone;
legend: VizLegendOptions;
fields?: XYFieldMatchers; // default will assume timeseries data
onLegendClick?: (event: GraphNGLegendEvent) => void;
children?: (builder: UPlotConfigBuilder, alignedDataFrame: DataFrame) => React.ReactNode;
children?: (builder: UPlotConfigBuilder, alignedFrame: DataFrame) => React.ReactNode;
prepConfig: (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => UPlotConfigBuilder;
propsToDiff?: string[];
renderLegend: (config: UPlotConfigBuilder) => React.ReactElement;
}
function sameProps(prevProps: any, nextProps: any, propsToDiff: string[] = []) {
for (const propName of propsToDiff) {
if (nextProps[propName] !== prevProps[propName]) {
return false;
}
}
return true;
}
/**
* @internal -- not a public API
*/
export interface GraphNGState {
alignedDataFrame: DataFrame;
data: AlignedData;
alignedFrame: DataFrame;
alignedData: AlignedData;
config?: UPlotConfigBuilder;
}
class UnthemedGraphNG extends React.Component<GraphNGProps, GraphNGState> {
/**
* "Time as X" core component, expectes ascending x
*/
export class GraphNG extends React.Component<GraphNGProps, GraphNGState> {
constructor(props: GraphNGProps) {
super(props);
this.state = this.prepState(props);
}
pluginLog('GraphNG', false, 'constructor, data aligment');
const alignedData = preparePlotFrame(props.data, {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
});
getTimeRange = () => this.props.timeRange;
if (!alignedData) {
return;
prepState(props: GraphNGProps, withConfig = true) {
let state: GraphNGState = null as any;
const { frames, fields } = props;
const alignedFrame = preparePlotFrame(
frames,
fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}
);
if (alignedFrame) {
state = {
alignedFrame,
alignedData: preparePlotData(alignedFrame, [FieldType.number]),
};
if (withConfig) {
state.config = props.prepConfig(alignedFrame, this.getTimeRange);
}
}
this.state = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
config: preparePlotConfigBuilder(alignedData, props.theme, this.getTimeRange, this.getTimeZone),
};
return state;
}
componentDidUpdate(prevProps: GraphNGProps) {
const { theme, structureRev, data } = this.props;
let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState;
const { frames, structureRev, timeZone, propsToDiff } = this.props;
if (this.state.config === undefined || this.props.timeZone !== prevProps.timeZone) {
shouldConfigUpdate = true;
}
const propsChanged = !sameProps(prevProps, this.props, propsToDiff);
if (data !== prevProps.data) {
pluginLog('GraphNG', false, 'data changed');
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
if (frames !== prevProps.frames || propsChanged) {
let newState = this.prepState(this.props, false);
if (hasStructureChanged) {
pluginLog('GraphNG', false, 'schema changed');
if (newState) {
const shouldReconfig =
this.state.config === undefined ||
timeZone !== prevProps.timeZone ||
structureRev !== prevProps.structureRev ||
!structureRev ||
propsChanged;
if (shouldReconfig) {
newState.config = this.props.prepConfig(newState.alignedFrame, this.getTimeRange);
}
}
pluginLog('GraphNG', false, 'componentDidUpdate, data aligment');
const alignedData = preparePlotFrame(data, {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
});
if (!alignedData) {
return;
}
stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
if (shouldConfigUpdate || hasStructureChanged) {
pluginLog('GraphNG', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone);
stateUpdate = { ...stateUpdate, config: builder };
}
newState && this.setState(newState);
}
if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate);
}
}
getTimeRange = () => {
return this.props.timeRange;
};
getTimeZone = () => {
return this.props.timeZone;
};
renderLegend() {
const { legend, onLegendClick, data } = this.props;
const { config } = this.state;
if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) {
return;
}
return (
<PlotLegend
data={data}
config={config}
onLegendClick={onLegendClick}
maxHeight="35%"
maxWidth="60%"
{...legend}
/>
);
}
render() {
const { width, height, children, timeRange } = this.props;
const { config, alignedDataFrame } = this.state;
const { width, height, children, timeRange, renderLegend } = this.props;
const { config, alignedFrame } = this.state;
if (!config) {
return null;
}
return (
<VizLayout width={width} height={height} legend={this.renderLegend()}>
<VizLayout width={width} height={height} legend={renderLegend(config)}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
config={this.state.config!}
data={this.state.data}
data={this.state.alignedData}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children ? children(config, alignedDataFrame) : null}
{children ? children(config, alignedFrame) : null}
</UPlotChart>
)}
</VizLayout>
);
}
}
export const GraphNG = withTheme2(UnthemedGraphNG);
GraphNG.displayName = 'GraphNG';

View File

@ -1,4 +1,5 @@
import { preparePlotConfigBuilder, preparePlotFrame } from './utils';
import { preparePlotFrame } from './utils';
import { preparePlotConfigBuilder } from '../TimeSeries/utils';
import {
createTheme,
DefaultTimeZone,
@ -188,12 +189,12 @@ jest.mock('@grafana/data', () => ({
describe('GraphNG utils', () => {
test('preparePlotConfigBuilder', () => {
const frame = mockDataFrame();
const result = preparePlotConfigBuilder(
frame!,
createTheme(),
getDefaultTimeRange,
() => DefaultTimeZone
).getConfig();
const result = preparePlotConfigBuilder({
frame: frame!,
theme: createTheme(),
timeZone: DefaultTimeZone,
getTimeRange: getDefaultTimeRange,
}).getConfig();
expect(result).toMatchSnapshot();
});
});

View File

@ -1,41 +1,22 @@
import React from 'react';
import { isNumber } from 'lodash';
import { GraphNGLegendEventMode, XYFieldMatchers } from './types';
import {
ArrayVector,
DataFrame,
FieldConfig,
FieldType,
formattedValueToString,
getFieldColorModeForField,
getFieldDisplayName,
getFieldSeriesColor,
GrafanaTheme2,
outerJoinDataFrames,
TimeRange,
TimeZone,
} from '@grafana/data';
import { nullToUndefThreshold } from './nullToUndefThreshold';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { FIXED_UNIT } from './GraphNG';
import {
AxisPlacement,
DrawStyle,
GraphFieldConfig,
GraphTresholdsStyleMode,
PointVisibility,
ScaleDirection,
ScaleOrientation,
} from '../uPlot/config';
import { collectStackingGroups } from '../uPlot/utils';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const defaultConfig: GraphFieldConfig = {
drawStyle: DrawStyle.Line,
showPoints: PointVisibility.Auto,
axisPlacement: AxisPlacement.Auto,
};
export interface PrepConfigOpts {
frame: DataFrame;
theme: GrafanaTheme2;
timeZone: TimeZone;
getTimeRange: () => TimeRange;
[prop: string]: any;
}
export function mapMouseEventToMode(event: React.MouseEvent): GraphNGLegendEventMode {
if (event.ctrlKey || event.metaKey || event.shiftKey) {
@ -72,192 +53,10 @@ function applySpanNullsThresholds(frames: DataFrame[]) {
export function preparePlotFrame(frames: DataFrame[], dimFields: XYFieldMatchers) {
applySpanNullsThresholds(frames);
let joined = outerJoinDataFrames({
return outerJoinDataFrames({
frames: frames,
joinBy: dimFields.x,
keep: dimFields.y,
keepOriginIndices: true,
});
return joined;
}
export function preparePlotConfigBuilder(
frame: DataFrame,
theme: GrafanaTheme2,
getTimeRange: () => TimeRange,
getTimeZone: () => TimeZone
): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder(getTimeZone);
// X is the first field in the aligned frame
const xField = frame.fields[0];
if (!xField) {
return builder; // empty frame with no options
}
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,
});
}
const stackingGroups: Map<string, number[]> = new Map();
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,
});
// Render thresholds in graph
if (customConfig.thresholdsStyle && config.thresholds) {
const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off;
if (thresholdDisplay !== GraphTresholdsStyleMode.Off) {
builder.addThresholds({
config: customConfig.thresholdsStyle,
thresholds: config.thresholds,
scaleKey,
theme,
});
}
}
collectStackingGroups(field, stackingGroups, seriesIndex);
}
if (stackingGroups.size !== 0) {
builder.setStacking(true);
for (const [_, seriesIdxs] of stackingGroups.entries()) {
for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]],
});
}
}
}
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;
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { DataFrame, TimeRange } from '@grafana/data';
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { PlotLegend } from '../uPlot/PlotLegend';
import { LegendDisplayMode } from '../VizLegend/models.gen';
import { preparePlotConfigBuilder } from './utils';
import { withTheme2 } from '../../themes/ThemeContext';
const propsToDiff: string[] = [];
type TimeSeriesProps = Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'>;
export class UnthemedTimeSeries extends React.Component<TimeSeriesProps> {
prepConfig = (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => {
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
...this.props,
});
};
renderLegend = (config: UPlotConfigBuilder) => {
const { legend, onLegendClick, frames } = this.props;
if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) {
return;
}
return (
<PlotLegend
data={frames}
config={config}
onLegendClick={onLegendClick}
maxHeight="35%"
maxWidth="60%"
{...legend}
/>
);
};
render() {
return (
<GraphNG
{...this.props}
prepConfig={this.prepConfig}
propsToDiff={propsToDiff}
renderLegend={this.renderLegend as any}
/>
);
}
}
export const TimeSeries = withTheme2(UnthemedTimeSeries);
TimeSeries.displayName = 'TimeSeries';

View File

@ -0,0 +1,209 @@
import { isNumber } from 'lodash';
import {
DataFrame,
FieldConfig,
FieldType,
formattedValueToString,
getFieldColorModeForField,
getFieldDisplayName,
getFieldSeriesColor,
} from '@grafana/data';
import { PrepConfigOpts } from '../GraphNG/utils';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { FIXED_UNIT } from '../GraphNG/GraphNG';
import {
AxisPlacement,
DrawStyle,
GraphFieldConfig,
GraphTresholdsStyleMode,
PointVisibility,
ScaleDirection,
ScaleOrientation,
} from '../uPlot/config';
import { collectStackingGroups } from '../uPlot/utils';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const defaultConfig: GraphFieldConfig = {
drawStyle: DrawStyle.Line,
showPoints: PointVisibility.Auto,
axisPlacement: AxisPlacement.Auto,
};
type PrepConfig = (opts: PrepConfigOpts) => UPlotConfigBuilder;
export const preparePlotConfigBuilder: PrepConfig = ({ frame, theme, timeZone, getTimeRange }) => {
const builder = new UPlotConfigBuilder(timeZone);
// X is the first field in the aligned frame
const xField = frame.fields[0];
if (!xField) {
return builder; // empty frame with no options
}
let seriesIndex = 0;
if (xField.type === FieldType.time) {
builder.addScale({
scaleKey: 'x',
orientation: ScaleOrientation.Horizontal,
direction: ScaleDirection.Right,
isTime: true,
range: () => {
const timeRange = getTimeRange();
return [timeRange.from.valueOf(), timeRange.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,
});
}
const stackingGroups: Map<string, number[]> = new Map();
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,
});
// Render thresholds in graph
if (customConfig.thresholdsStyle && config.thresholds) {
const thresholdDisplay = customConfig.thresholdsStyle.mode ?? GraphTresholdsStyleMode.Off;
if (thresholdDisplay !== GraphTresholdsStyleMode.Off) {
builder.addThresholds({
config: customConfig.thresholdsStyle,
thresholds: config.thresholds,
scaleKey,
theme,
});
}
}
collectStackingGroups(field, stackingGroups, seriesIndex);
}
if (stackingGroups.size !== 0) {
builder.setStacking(true);
for (const [_, seriesIdxs] of stackingGroups.entries()) {
for (let j = seriesIdxs.length - 1; j > 0; j--) {
builder.addBand({
series: [seriesIdxs[j], seriesIdxs[j - 1]],
});
}
}
}
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;
}

View File

@ -1,146 +1,44 @@
import React from 'react';
import { FieldMatcherID, fieldMatchers } from '@grafana/data';
import { withTheme2 } from '../../themes/ThemeContext';
import { GraphNGState } from '../GraphNG/GraphNG';
import { preparePlotConfigBuilder, preparePlotFrame } from './utils'; // << preparePlotConfigBuilder is really the only change vs GraphNG
import { pluginLog, preparePlotData } from '../uPlot/utils';
import { PlotLegend } from '../uPlot/PlotLegend';
import { UPlotChart } from '../uPlot/Plot';
import { LegendDisplayMode } from '../VizLegend/models.gen';
import { VizLayout } from '../VizLayout/VizLayout';
import { TimelineProps } from './types';
import { DataFrame, TimeRange } from '@grafana/data';
import { GraphNG, GraphNGProps } from '../GraphNG/GraphNG';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { preparePlotConfigBuilder } from './utils';
import { BarValueVisibility, TimelineMode } from './types';
class UnthemedTimelineChart extends React.Component<TimelineProps, GraphNGState> {
constructor(props: TimelineProps) {
super(props);
const { theme, mode, rowHeight, colWidth, showValue } = props;
/**
* @alpha
*/
export interface TimelineProps extends Omit<GraphNGProps, 'prepConfig' | 'propsToDiff' | 'renderLegend'> {
mode: TimelineMode;
rowHeight: number;
showValue: BarValueVisibility;
colWidth?: number;
}
pluginLog('TimelineChart', false, 'constructor, data aligment');
const alignedData = preparePlotFrame(
props.data,
props.fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}
);
const propsToDiff = ['mode', 'rowHeight', 'colWidth', 'showValue'];
if (!alignedData) {
return;
}
this.state = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
config: preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
}),
};
}
componentDidUpdate(prevProps: TimelineProps) {
const { data, theme, timeZone, mode, rowHeight, colWidth, showValue, structureRev } = this.props;
let shouldConfigUpdate = false;
let stateUpdate = {} as GraphNGState;
if (
this.state.config === undefined ||
timeZone !== prevProps.timeZone ||
mode !== prevProps.mode ||
rowHeight !== prevProps.rowHeight ||
colWidth !== prevProps.colWidth ||
showValue !== prevProps.showValue
) {
shouldConfigUpdate = true;
}
if (data !== prevProps.data || shouldConfigUpdate) {
const hasStructureChanged = structureRev !== prevProps.structureRev || !structureRev;
const alignedData = preparePlotFrame(
data,
this.props.fields || {
x: fieldMatchers.get(FieldMatcherID.firstTimeField).get({}),
y: fieldMatchers.get(FieldMatcherID.numeric).get({}),
}
);
if (!alignedData) {
return;
}
stateUpdate = {
alignedDataFrame: alignedData,
data: preparePlotData(alignedData),
};
if (shouldConfigUpdate || hasStructureChanged) {
pluginLog('TimelineChart', false, 'updating config');
const builder = preparePlotConfigBuilder(alignedData, theme, this.getTimeRange, this.getTimeZone, {
mode,
rowHeight,
colWidth,
showValue,
});
stateUpdate = { ...stateUpdate, config: builder };
}
}
if (Object.keys(stateUpdate).length > 0) {
this.setState(stateUpdate);
}
}
getTimeRange = () => {
return this.props.timeRange;
export class UnthemedTimelineChart extends React.Component<TimelineProps> {
prepConfig = (alignedFrame: DataFrame, getTimeRange: () => TimeRange) => {
return preparePlotConfigBuilder({
frame: alignedFrame,
getTimeRange,
...this.props,
});
};
getTimeZone = () => {
return this.props.timeZone;
renderLegend = (config: UPlotConfigBuilder) => {
return;
};
renderLegend() {
const { legend, onLegendClick, data } = this.props;
const { config } = this.state;
if (!config || (legend && legend.displayMode === LegendDisplayMode.Hidden)) {
return;
}
return (
<PlotLegend
data={data}
config={config}
onLegendClick={onLegendClick}
maxHeight="35%"
maxWidth="60%"
{...legend}
/>
);
}
render() {
const { width, height, children, timeRange } = this.props;
const { config, alignedDataFrame } = this.state;
if (!config) {
return null;
}
return (
<VizLayout width={width} height={height}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
config={this.state.config!}
data={this.state.data}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
>
{children ? children(config, alignedDataFrame) : null}
</UPlotChart>
)}
</VizLayout>
<GraphNG
{...this.props}
prepConfig={this.prepConfig}
propsToDiff={propsToDiff}
renderLegend={this.renderLegend as any}
/>
);
}
}

View File

@ -1,4 +1,3 @@
import { GraphNGProps } from '../GraphNG/GraphNG';
import { GraphGradientMode, HideableFieldConfig } from '../uPlot/config';
import { VizLegendOptions } from '../VizLegend/models.gen';
@ -47,13 +46,3 @@ export enum TimelineMode {
Spans = 'spans',
Grid = 'grid',
}
/**
* @alpha
*/
export interface TimelineProps extends GraphNGProps {
mode: TimelineMode;
rowHeight: number;
showValue: BarValueVisibility;
colWidth?: number;
}

View File

@ -7,19 +7,18 @@ import {
formattedValueToString,
getFieldDisplayName,
outerJoinDataFrames,
TimeRange,
TimeZone,
classicColors,
Field,
GrafanaTheme2,
} from '@grafana/data';
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
import { TimelineCoreOptions, getConfig } from './timeline';
import { FIXED_UNIT } from '../GraphNG/GraphNG';
import { AxisPlacement, GraphGradientMode, ScaleDirection, ScaleOrientation } from '../uPlot/config';
import { measureText } from '../../utils/measureText';
import { PrepConfigOpts } from '../GraphNG/utils';
import { TimelineFieldConfig } from '../..';
import { BarValueVisibility, TimelineMode } from './types';
const defaultConfig: TimelineFieldConfig = {
lineWidth: 0,
@ -43,21 +42,27 @@ export function preparePlotFrame(data: DataFrame[], dimFields: XYFieldMatchers)
});
}
export type uPlotConfigBuilderSupplier = (
frame: DataFrame,
theme: GrafanaTheme2,
getTimeRange: () => TimeRange,
getTimeZone: () => TimeZone
) => UPlotConfigBuilder;
interface PrepConfigOptsTimeline extends PrepConfigOpts {
mode: TimelineMode;
rowHeight: number;
colWidth?: number;
showValue: BarValueVisibility;
}
export function preparePlotConfigBuilder(
frame: DataFrame,
theme: GrafanaTheme2,
getTimeRange: () => TimeRange,
getTimeZone: () => TimeZone,
coreOptions: Partial<TimelineCoreOptions>
): UPlotConfigBuilder {
const builder = new UPlotConfigBuilder(getTimeZone);
type PrepConfig = (opts: PrepConfigOptsTimeline) => UPlotConfigBuilder;
export const preparePlotConfigBuilder: PrepConfig = ({
frame,
theme,
timeZone,
getTimeRange,
mode,
rowHeight,
colWidth,
showValue,
}) => {
const builder = new UPlotConfigBuilder(timeZone);
const isDiscrete = (field: Field) => {
const mode = field.config?.color?.mode;
@ -86,12 +91,12 @@ export function preparePlotConfigBuilder(
const opts: TimelineCoreOptions = {
// should expose in panel config
mode: coreOptions.mode!,
mode: mode!,
numSeries: frame.fields.length - 1,
isDiscrete: (seriesIdx) => isDiscrete(frame.fields[seriesIdx]),
rowHeight: coreOptions.rowHeight!,
colWidth: coreOptions.colWidth,
showValue: coreOptions.showValue!,
rowHeight: rowHeight!,
colWidth: colWidth,
showValue: showValue!,
label: (seriesIdx) => getFieldDisplayName(frame.fields[seriesIdx], frame),
fill: colorLookup,
stroke: colorLookup,
@ -136,7 +141,7 @@ export function preparePlotConfigBuilder(
isTime: true,
splits: coreConfig.xSplits!,
placement: AxisPlacement.Bottom,
timeZone: getTimeZone(),
timeZone,
theme,
});
@ -192,7 +197,7 @@ export function preparePlotConfigBuilder(
}
return builder;
}
};
export function getNamesToFieldIndex(frame: DataFrame): Map<string, number> {
const names = new Map<string, number>();

View File

@ -240,6 +240,7 @@ export * from './uPlot/geometries';
export * from './uPlot/plugins';
export { usePlotContext } from './uPlot/context';
export { GraphNG, FIXED_UNIT } from './GraphNG/GraphNG';
export { TimeSeries } from './TimeSeries/TimeSeries';
export { useGraphNGContext } from './GraphNG/hooks';
export { preparePlotFrame } from './GraphNG/utils';
export { BarChart } from './BarChart/BarChart';

View File

@ -1,94 +1,110 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import uPlot, { AlignedData, Options } from 'uplot';
import { PlotContext } from './context';
import { DEFAULT_PLOT_CONFIG, pluginLog } from './utils';
import React, { createRef } from 'react';
import uPlot, { Options } from 'uplot';
import { PlotContext, PlotContextType } from './context';
import { DEFAULT_PLOT_CONFIG } from './utils';
import { PlotProps } from './types';
import usePrevious from 'react-use/lib/usePrevious';
function sameDims(prevProps: PlotProps, nextProps: PlotProps) {
return nextProps.width === prevProps.width && nextProps.height === prevProps.height;
}
function sameData(prevProps: PlotProps, nextProps: PlotProps) {
return nextProps.data === prevProps.data;
}
function sameConfig(prevProps: PlotProps, nextProps: PlotProps) {
return nextProps.config === prevProps.config;
}
type UPlotChartState = {
ctx: PlotContextType;
};
/**
* @internal
* uPlot abstraction responsible for plot initialisation, setup and refresh
* Receives a data frame that is x-axis aligned, as of https://github.com/leeoniya/uPlot/tree/master/docs#data-format
* Exposes contexts for plugins registration and uPlot instance access
* Exposes context for uPlot instance access
*/
export const UPlotChart: React.FC<PlotProps> = (props) => {
const plotContainer = useRef<HTMLDivElement>(null);
const plotInstance = useRef<uPlot>();
const prevProps = usePrevious(props);
export class UPlotChart extends React.Component<PlotProps, UPlotChartState> {
plotContainer = createRef<HTMLDivElement>();
const config = useMemo(() => {
return {
...DEFAULT_PLOT_CONFIG,
width: props.width,
height: props.height,
ms: 1,
...props.config.getConfig(),
} as uPlot.Options;
}, [props.config]);
constructor(props: PlotProps) {
super(props);
useLayoutEffect(() => {
if (!plotInstance.current || props.width === 0 || props.height === 0) {
return;
}
pluginLog('uPlot core', false, 'updating size');
plotInstance.current.setSize({
width: props.width,
height: props.height,
});
}, [props.width, props.height]);
// Effect responsible for uPlot updates/initialization logic. It's performed whenever component's props have changed
useLayoutEffect(() => {
// 0. Exit early if the component is not ready to initialize uPlot
if (!plotContainer.current || props.width === 0 || props.height === 0) {
return;
}
// 1. When config is ready and there is no uPlot instance, create new uPlot and return
if (!plotInstance.current || !prevProps) {
plotInstance.current = initializePlot(props.data, config, plotContainer.current);
return;
}
// 2. Reinitialize uPlot if config changed
if (props.config !== prevProps.config) {
if (plotInstance.current) {
pluginLog('uPlot core', false, 'destroying instance');
plotInstance.current.destroy();
}
plotInstance.current = initializePlot(props.data, config, plotContainer.current);
return;
}
// 3. Otherwise, assume only data has changed and update uPlot data
if (props.data !== prevProps.data) {
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', props.data);
plotInstance.current.setData(props.data);
}
}, [props, config]);
// When component unmounts, clean the existing uPlot instance
useEffect(() => () => plotInstance.current?.destroy(), []);
// Memoize plot context
const plotCtx = useMemo(() => {
return {
getPlot: () => plotInstance.current,
this.state = {
ctx: {
plot: null,
},
};
}, []);
}
return (
<PlotContext.Provider value={plotCtx}>
<div style={{ position: 'relative' }}>
<div ref={plotContainer} data-testid="uplot-main-div" />
{props.children}
</div>
</PlotContext.Provider>
);
};
reinitPlot() {
let { ctx } = this.state;
function initializePlot(data: AlignedData, config: Options, el: HTMLDivElement) {
pluginLog('UPlotChart: init uPlot', false, 'initialized with', data, config);
return new uPlot(config, data, el);
ctx.plot?.destroy();
let { width, height } = this.props;
if (width === 0 && height === 0) {
return;
}
const config: Options = {
...DEFAULT_PLOT_CONFIG,
width: this.props.width,
height: this.props.height,
ms: 1 as 1,
...this.props.config.getConfig(),
};
this.setState({
ctx: {
plot: new uPlot(config, this.props.data, this.plotContainer!.current!),
},
});
}
componentDidMount() {
this.reinitPlot();
}
componentWillUnmount() {
this.state.ctx.plot?.destroy();
}
shouldComponentUpdate(nextProps: PlotProps, nextState: UPlotChartState) {
return (
nextState.ctx !== this.state.ctx ||
!sameDims(this.props, nextProps) ||
!sameData(this.props, nextProps) ||
!sameConfig(this.props, nextProps)
);
}
componentDidUpdate(prevProps: PlotProps, prevState: object) {
let { ctx } = this.state;
if (!sameDims(prevProps, this.props)) {
ctx.plot?.setSize({
width: this.props.width,
height: this.props.height,
});
} else if (!sameConfig(prevProps, this.props)) {
this.reinitPlot();
} else if (!sameData(prevProps, this.props)) {
ctx.plot?.setData(this.props.data);
}
}
render() {
return (
<PlotContext.Provider value={this.state.ctx}>
<div style={{ position: 'relative' }}>
<div ref={this.plotContainer} data-testid="uplot-main-div" />
{this.props.children}
</div>
</PlotContext.Provider>
);
}
}

View File

@ -3,9 +3,9 @@ import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
import { AxisPlacement } from '../config';
import uPlot, { Cursor, Band, Hooks, BBox } from 'uplot';
import uPlot, { Cursor, Band, Hooks, Select } from 'uplot';
import { defaultsDeep } from 'lodash';
import { DefaultTimeZone, getTimeZoneInfo } from '@grafana/data';
import { DefaultTimeZone, getTimeZoneInfo, TimeZone } from '@grafana/data';
import { pluginLog } from '../utils';
import { getThresholdsDrawHook, UPlotThresholdOptions } from './UPlotThresholds';
@ -16,8 +16,7 @@ export class UPlotConfigBuilder {
private bands: Band[] = [];
private cursor: Cursor | undefined;
private isStacking = false;
// uPlot types don't export the Select interface prior to 1.6.4
private select: Partial<BBox> | undefined;
private select: uPlot.Select | undefined;
private hasLeftAxis = false;
private hasBottomAxis = false;
private hooks: Hooks.Arrays = {};
@ -25,8 +24,8 @@ export class UPlotConfigBuilder {
// to prevent more than one threshold per scale
private thresholds: Record<string, UPlotThresholdOptions> = {};
constructor(getTimeZone = () => DefaultTimeZone) {
this.tz = getTimeZoneInfo(getTimeZone(), Date.now())?.ianaName;
constructor(timeZone: TimeZone = DefaultTimeZone) {
this.tz = getTimeZoneInfo(timeZone, Date.now())?.ianaName;
}
addHook<T extends keyof Hooks.Defs>(type: T, hook: Hooks.Defs[T]) {
@ -85,8 +84,7 @@ export class UPlotConfigBuilder {
this.cursor = cursor;
}
// uPlot types don't export the Select interface prior to 1.6.4
setSelect(select: Partial<BBox>) {
setSelect(select: Select) {
this.select = select;
}

View File

@ -1,8 +1,8 @@
import React, { useContext } from 'react';
import uPlot from 'uplot';
interface PlotContextType {
getPlot: () => uPlot | undefined;
export interface PlotContextType {
plot: uPlot | null;
}
/**

View File

@ -27,7 +27,7 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords
const eventMarkers = useMemo(() => {
const markers: React.ReactNode[] = [];
const plotInstance = plotCtx.getPlot();
const plotInstance = plotCtx.plot;
if (!plotInstance || events.length === 0) {
return markers;
}
@ -50,7 +50,7 @@ export function EventsCanvas({ id, events, renderEventMarker, mapEventToXYCoords
return <>{markers}</>;
}, [events, renderEventMarker, renderToken, plotCtx]);
if (!plotCtx.getPlot()) {
if (!plotCtx.plot) {
return null;
}

View File

@ -10,7 +10,7 @@ interface XYCanvasProps {}
*/
export const XYCanvas: React.FC<XYCanvasProps> = ({ children }) => {
const plotCtx = usePlotContext();
const plotInstance = plotCtx.getPlot();
const plotInstance = plotCtx.plot;
if (!plotInstance) {
return null;

View File

@ -74,7 +74,7 @@ export const TooltipPlugin: React.FC<TooltipPluginProps> = ({
});
}, [config]);
const plotInstance = plotCtx.getPlot();
const plotInstance = plotCtx.plot;
if (!plotInstance || focusedPointIdx === null) {
return null;
}

View File

@ -15,7 +15,6 @@ import {
import {
Collapse,
DrawStyle,
GraphNG,
GraphNGLegendEvent,
Icon,
LegendDisplayMode,
@ -24,6 +23,7 @@ import {
useTheme2,
ZoomPlugin,
TooltipDisplayMode,
TimeSeries,
} from '@grafana/ui';
import { defaultGraphConfig, getGraphFieldConfig } from 'app/plugins/panel/timeseries/config';
import { hideSeriesConfigFactory } from 'app/plugins/panel/timeseries/overrides/hideSeriesConfigFactory';
@ -135,8 +135,8 @@ export function ExploreGraphNGPanel({
)}
<Collapse label="Graph" loading={isLoading} isOpen>
<GraphNG
data={seriesToShow}
<TimeSeries
frames={seriesToShow}
structureRev={structureRev}
width={width}
height={400}
@ -167,7 +167,7 @@ export function ExploreGraphNGPanel({
</>
);
}}
</GraphNG>
</TimeSeries>
</Collapse>
</>
);

View File

@ -6,7 +6,7 @@ import {
NavModelItem,
PanelData,
} from '@grafana/data';
import { GraphNG, LegendDisplayMode, Table } from '@grafana/ui';
import { LegendDisplayMode, Table, TimeSeries } from '@grafana/ui';
import { config } from 'app/core/config';
import React, { FC, useMemo, useState } from 'react';
import { useObservable } from 'react-use';
@ -65,10 +65,10 @@ export const TestStuffPage: FC = () => {
{({ width }) => {
return (
<div>
<GraphNG
<TimeSeries
width={width}
height={300}
data={data.series}
frames={data.series}
legend={{ displayMode: LegendDisplayMode.List, placement: 'bottom', calcs: [] }}
timeRange={data.timeRange}
timeZone="browser"

View File

@ -35,7 +35,7 @@ export const TimelinePanel: React.FC<TimelinePanelProps> = ({
return (
<TimelineChart
data={data.series}
frames={data.series}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}

View File

@ -1,5 +1,5 @@
import { Field, PanelProps } from '@grafana/data';
import { GraphNG, GraphNGLegendEvent, TooltipPlugin, ZoomPlugin } from '@grafana/ui';
import { TimeSeries, GraphNGLegendEvent, TooltipPlugin, ZoomPlugin } from '@grafana/ui';
import { getFieldLinksForExplore } from 'app/features/explore/utils/links';
import React, { useCallback } from 'react';
import { hideSeriesConfigFactory } from './overrides/hideSeriesConfigFactory';
@ -42,8 +42,8 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
}
return (
<GraphNG
data={data.series}
<TimeSeries
frames={data.series}
structureRev={data.structureRev}
timeRange={timeRange}
timeZone={timeZone}
@ -83,6 +83,6 @@ export const TimeSeriesPanel: React.FC<TimeSeriesPanelProps> = ({
</>
);
}}
</GraphNG>
</TimeSeries>
);
};

View File

@ -68,7 +68,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
(frame: DataFrame, index: number) => {
const view = new DataFrameView<AnnotationsDataFrameViewDTO>(frame);
const annotation = view.get(index);
const plotInstance = plotCtx.getPlot();
const plotInstance = plotCtx.plot;
if (!annotation.time || !plotInstance) {
return undefined;
}

View File

@ -22,7 +22,7 @@ export const ExemplarsPlugin: React.FC<ExemplarsPluginProps> = ({ exemplars, tim
const mapExemplarToXYCoords = useCallback(
(dataFrame: DataFrame, index: number) => {
const plotInstance = plotCtx.getPlot();
const plotInstance = plotCtx.plot;
const time = dataFrame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME);
const value = dataFrame.fields.find((f) => f.name === TIME_SERIES_VALUE_FIELD_NAME);

View File

@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import { Button, GraphNG, GraphNGLegendEvent, TooltipPlugin } from '@grafana/ui';
import { Button, GraphNGLegendEvent, TimeSeries, TooltipPlugin } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import { hideSeriesConfigFactory } from '../timeseries/overrides/hideSeriesConfigFactory';
@ -43,8 +43,8 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
}
return (
<GraphNG
data={frames}
<TimeSeries
frames={frames}
structureRev={data.structureRev}
fields={dims.fields}
timeRange={timeRange}
@ -64,6 +64,6 @@ export const XYChartPanel: React.FC<XYChartPanelProps> = ({
/>
);
}}
</GraphNG>
</TimeSeries>
);
};