mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
GraphNG: Init refactorings and fixes (#29275)
* When comparing field config, shallowly compare custom config * Refactoring plot init and data update (WIP) * GraphNG: Fixed points mode * Fixed min & max from frame config * Fixed axis left / right auto logic * Minor tweak to cursor color * Fixed time axis now that uPlot deals in milliseconds as well * fixed ts issue * Updated test * Fixed axis placement logic again * Added new unit test for axis placement logic * Removed unused props * Fixed zoom issue due to uPlot time resolution change * Add back millisecond time tick support * Comment out GraphNG test * Fixed being able to switch legend on/off * Updated unit tests * GraphNG: Fixed hiding axis * Frame comparison: allow skipping properties * Update y-axis ranges without reinitializing uPlot * update snap * GraphNG: Fixed axis label placement and spacing issues * update snaps Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
parent
c574126ebf
commit
7e21863982
@ -92,4 +92,147 @@ describe('test comparisons', () => {
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should skip provided properties', () => {
|
||||
expect(
|
||||
compareDataFrameStructures(
|
||||
{
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
...field1.config,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
...field1.config,
|
||||
unit: 'rpm',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
['unit']
|
||||
)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('custom config comparison', () => {
|
||||
it('handles custom config shallow equality', () => {
|
||||
const a = {
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
custom: {
|
||||
a: 1,
|
||||
b: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const b = {
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
custom: {
|
||||
a: 1,
|
||||
b: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(compareDataFrameStructures(a, b)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles custom config shallow inequality', () => {
|
||||
const a = {
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
custom: {
|
||||
a: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const b = {
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
custom: {
|
||||
a: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(compareDataFrameStructures(a, b)).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not compare deeply', () => {
|
||||
const a = {
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
custom: {
|
||||
a: {
|
||||
b: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const b = {
|
||||
...frameB,
|
||||
fields: [
|
||||
field0,
|
||||
{
|
||||
...field1,
|
||||
config: {
|
||||
custom: {
|
||||
a: {
|
||||
b: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(compareDataFrameStructures(a, b)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -14,13 +14,14 @@ import { DataFrame } from '../types/dataFrame';
|
||||
*
|
||||
* @beta
|
||||
*/
|
||||
export function compareDataFrameStructures(a: DataFrame, b: DataFrame): boolean {
|
||||
export function compareDataFrameStructures(a: DataFrame, b: DataFrame, skipProperties?: string[]): boolean {
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
if (a?.fields?.length !== b?.fields?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < a.fields.length; i++) {
|
||||
const fA = a.fields[i];
|
||||
const fB = b.fields[i];
|
||||
@ -30,14 +31,34 @@ export function compareDataFrameStructures(a: DataFrame, b: DataFrame): boolean
|
||||
const cfgA = fA.config as any;
|
||||
const cfgB = fB.config as any;
|
||||
|
||||
const keys = Object.keys(cfgA);
|
||||
if (keys.length !== Object.keys(cfgB).length) {
|
||||
let aKeys = Object.keys(cfgA);
|
||||
let bKeys = Object.keys(cfgB);
|
||||
|
||||
if (skipProperties) {
|
||||
aKeys = aKeys.filter(k => skipProperties.indexOf(k) < 0);
|
||||
bKeys = aKeys.filter(k => skipProperties.indexOf(k) < 0);
|
||||
}
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false;
|
||||
}
|
||||
for (const key of keys) {
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (skipProperties && skipProperties.indexOf(key) > -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!cfgB.hasOwnProperty(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key === 'custom') {
|
||||
if (!shallowCompare(cfgA[key], cfgB[key])) {
|
||||
return false;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (cfgA[key] !== cfgB[key]) {
|
||||
return false;
|
||||
}
|
||||
@ -65,3 +86,33 @@ export function compareArrayValues<T>(a: T[], b: T[], cmp: (a: T, b: T) => boole
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two objects are equal shallowly
|
||||
*
|
||||
* @beta
|
||||
*/
|
||||
export function shallowCompare<T extends {}>(a: T, b: T, cmp?: (valA: any, valB: any) => boolean) {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (a === b) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let key of aKeys) {
|
||||
if (cmp) {
|
||||
//@ts-ignore
|
||||
return cmp(a[key], b[key]);
|
||||
}
|
||||
//@ts-ignore
|
||||
if (a[key] !== b[key]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
@ -84,20 +84,12 @@ describe('GraphNG', () => {
|
||||
describe('config update', () => {
|
||||
it('should skip plot intialization for width and height equal 0', () => {
|
||||
const { data, timeRange } = mockData();
|
||||
const onPlotInitSpy = jest.fn();
|
||||
|
||||
render(
|
||||
<GraphNG
|
||||
data={[data]}
|
||||
timeRange={timeRange}
|
||||
timeZone={'browser'}
|
||||
width={0}
|
||||
height={0}
|
||||
onPlotInit={onPlotInitSpy}
|
||||
/>
|
||||
const { queryAllByTestId } = render(
|
||||
<GraphNG data={[data]} timeRange={timeRange} timeZone={'browser'} width={0} height={0} />
|
||||
);
|
||||
|
||||
expect(onPlotInitSpy).not.toBeCalled();
|
||||
expect(queryAllByTestId('uplot-main-div')).toHaveLength(1);
|
||||
});
|
||||
|
||||
// it('reinitializes plot when number of series change', () => {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
compareDataFrameStructures,
|
||||
DataFrame,
|
||||
FieldConfig,
|
||||
FieldType,
|
||||
@ -11,12 +12,13 @@ import {
|
||||
import { mergeTimeSeriesData } from './utils';
|
||||
import { UPlotChart } from '../uPlot/Plot';
|
||||
import { PlotProps } from '../uPlot/types';
|
||||
import { AxisPlacement, getUPlotSideFromAxis, GraphFieldConfig, GraphMode, PointMode } from '../uPlot/config';
|
||||
import { AxisPlacement, GraphFieldConfig, GraphMode, PointMode } from '../uPlot/config';
|
||||
import { useTheme } from '../../themes';
|
||||
import { VizLayout } from '../VizLayout/VizLayout';
|
||||
import { LegendDisplayMode, LegendItem, LegendOptions } from '../Legend/Legend';
|
||||
import { GraphLegend } from '../Graph/GraphLegend';
|
||||
import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder';
|
||||
import { useRevision } from '../uPlot/hooks';
|
||||
|
||||
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
|
||||
|
||||
@ -41,10 +43,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
timeZone,
|
||||
...plotProps
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const alignedFrameWithGapTest = useMemo(() => mergeTimeSeriesData(data), [data]);
|
||||
const legendItemsRef = useRef<LegendItem[]>([]);
|
||||
const hasLegend = legend && legend.displayMode !== LegendDisplayMode.Hidden;
|
||||
|
||||
if (alignedFrameWithGapTest == null) {
|
||||
return (
|
||||
@ -54,7 +53,15 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
const theme = useTheme();
|
||||
const legendItemsRef = useRef<LegendItem[]>([]);
|
||||
const hasLegend = useRef(legend && legend.displayMode !== LegendDisplayMode.Hidden);
|
||||
const alignedFrame = alignedFrameWithGapTest.frame;
|
||||
const compareFrames = useCallback(
|
||||
(a: DataFrame, b: DataFrame) => compareDataFrameStructures(a, b, ['min', 'max']),
|
||||
[]
|
||||
);
|
||||
const configRev = useRevision(alignedFrame, compareFrames);
|
||||
|
||||
const configBuilder = useMemo(() => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
@ -76,15 +83,13 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
builder.addAxis({
|
||||
scaleKey: 'x',
|
||||
isTime: true,
|
||||
side: getUPlotSideFromAxis(AxisPlacement.Bottom),
|
||||
placement: AxisPlacement.Bottom,
|
||||
timeZone,
|
||||
theme,
|
||||
});
|
||||
|
||||
let seriesIdx = 0;
|
||||
const legendItems: LegendItem[] = [];
|
||||
let hasLeftAxis = false;
|
||||
let hasYAxis = false;
|
||||
|
||||
for (let i = 0; i < alignedFrame.fields.length; i++) {
|
||||
const field = alignedFrame.fields[i];
|
||||
@ -97,23 +102,17 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
|
||||
const fmt = field.display ?? defaultFormatter;
|
||||
const scale = config.unit || '__fixed';
|
||||
const side = customConfig.axisPlacement ?? (hasLeftAxis ? AxisPlacement.Right : AxisPlacement.Left);
|
||||
const isNewScale = !builder.hasScale(scale);
|
||||
|
||||
if (!builder.hasScale(scale) && customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
if (side === AxisPlacement.Left) {
|
||||
hasLeftAxis = true;
|
||||
}
|
||||
|
||||
builder.addScale({ scaleKey: scale });
|
||||
if (isNewScale && customConfig.axisPlacement !== AxisPlacement.Hidden) {
|
||||
builder.addScale({ scaleKey: scale, min: field.config.min, max: field.config.max });
|
||||
builder.addAxis({
|
||||
scaleKey: scale,
|
||||
label: customConfig.axisLabel,
|
||||
side: getUPlotSideFromAxis(side),
|
||||
grid: !hasYAxis,
|
||||
placement: customConfig.axisPlacement ?? AxisPlacement.Auto,
|
||||
formatValue: v => formattedValueToString(fmt(v)),
|
||||
theme,
|
||||
});
|
||||
hasYAxis = true;
|
||||
}
|
||||
|
||||
// need to update field state here because we use a transform to merge framesP
|
||||
@ -121,13 +120,14 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
|
||||
const colorMode = getFieldColorModeForField(field);
|
||||
const seriesColor = colorMode.getCalculator(field, theme)(0, 0);
|
||||
const pointsMode = customConfig.mode === GraphMode.Points ? PointMode.Always : customConfig.points;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: scale,
|
||||
line: (customConfig.mode ?? GraphMode.Line) === GraphMode.Line,
|
||||
lineColor: seriesColor,
|
||||
lineWidth: customConfig.lineWidth,
|
||||
points: customConfig.points !== PointMode.Never,
|
||||
points: pointsMode,
|
||||
pointSize: customConfig.pointRadius,
|
||||
pointColor: seriesColor,
|
||||
fill: customConfig.fillAlpha !== undefined,
|
||||
@ -135,11 +135,13 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
fillColor: seriesColor,
|
||||
});
|
||||
|
||||
if (hasLegend) {
|
||||
if (hasLegend.current) {
|
||||
const axisPlacement = builder.getAxisPlacement(scale);
|
||||
|
||||
legendItems.push({
|
||||
color: seriesColor,
|
||||
label: getFieldDisplayName(field, alignedFrame),
|
||||
yAxis: side === AxisPlacement.Right ? 3 : 1,
|
||||
yAxis: axisPlacement === AxisPlacement.Left ? 1 : 2,
|
||||
});
|
||||
}
|
||||
|
||||
@ -148,7 +150,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({
|
||||
|
||||
legendItemsRef.current = legendItems;
|
||||
return builder;
|
||||
}, [alignedFrameWithGapTest, hasLegend]);
|
||||
}, [configRev]);
|
||||
|
||||
let legendElement: React.ReactElement | undefined;
|
||||
|
||||
|
@ -8,3 +8,12 @@
|
||||
.u-select {
|
||||
background: rgba(120, 120, 130, 0.2);
|
||||
}
|
||||
|
||||
.u-cursor-x {
|
||||
border-right: 1px dashed rgba(120, 120, 130, 0.5);
|
||||
}
|
||||
|
||||
.u-cursor-y {
|
||||
width: 100%;
|
||||
border-bottom: 1px dashed rgba(120, 120, 130, 0.5);
|
||||
}
|
||||
|
@ -1,10 +1,13 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { usePrevious } from 'react-use';
|
||||
import uPlot, { Options, AlignedData, AlignedDataWithGapTest } from 'uplot';
|
||||
import uPlot, { AlignedData, AlignedDataWithGapTest } from 'uplot';
|
||||
import { buildPlotContext, PlotContext } from './context';
|
||||
import { pluginLog, shouldInitialisePlot } from './utils';
|
||||
import { pluginLog } from './utils';
|
||||
import { usePlotConfig } from './hooks';
|
||||
import { PlotProps } from './types';
|
||||
import { usePrevious } from 'react-use';
|
||||
import { DataFrame, FieldType } from '@grafana/data';
|
||||
import isNumber from 'lodash/isNumber';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
|
||||
// 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
|
||||
@ -13,11 +16,24 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [plotInstance, setPlotInstance] = useState<uPlot>();
|
||||
const plotData = useRef<AlignedDataWithGapTest>();
|
||||
const previousConfig = usePrevious(props.config);
|
||||
|
||||
// uPlot config API
|
||||
const { currentConfig, registerPlugin } = usePlotConfig(props.width, props.height, props.timeZone, props.config);
|
||||
|
||||
const prevConfig = usePrevious(currentConfig);
|
||||
const initializePlot = useCallback(() => {
|
||||
if (!currentConfig || !plotData) {
|
||||
return;
|
||||
}
|
||||
if (!canvasRef.current) {
|
||||
throw new Error('Missing Canvas component as a child of the plot.');
|
||||
}
|
||||
|
||||
pluginLog('UPlotChart: init uPlot', false, 'initialized with', plotData.current, currentConfig);
|
||||
const instance = new uPlot(currentConfig, plotData.current, canvasRef.current);
|
||||
|
||||
setPlotInstance(instance);
|
||||
}, [setPlotInstance, currentConfig]);
|
||||
|
||||
const getPlotInstance = useCallback(() => {
|
||||
if (!plotInstance) {
|
||||
@ -27,22 +43,21 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
return plotInstance;
|
||||
}, [plotInstance]);
|
||||
|
||||
// Callback executed when there was no change in plot config
|
||||
const updateData = useCallback(() => {
|
||||
if (!plotInstance || !plotData.current) {
|
||||
return;
|
||||
useLayoutEffect(() => {
|
||||
plotData.current = {
|
||||
data: props.data.frame.fields.map(f => f.values.toArray()) as AlignedData,
|
||||
isGap: props.data.isGap,
|
||||
};
|
||||
|
||||
if (plotInstance && previousConfig === props.config) {
|
||||
updateData(props.data.frame, props.config, plotInstance, plotData.current.data);
|
||||
}
|
||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', plotData.current);
|
||||
}, [props.data, props.config]);
|
||||
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance.setData(plotData.current);
|
||||
useLayoutEffect(() => {
|
||||
initializePlot();
|
||||
}, [currentConfig]);
|
||||
|
||||
if (props.onDataUpdate) {
|
||||
props.onDataUpdate(plotData.current.data!);
|
||||
}
|
||||
}, [plotInstance, props.onDataUpdate]);
|
||||
|
||||
// Destroys previous plot instance when plot re-initialised
|
||||
useEffect(() => {
|
||||
const currentInstance = plotInstance;
|
||||
return () => {
|
||||
@ -50,42 +65,6 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
};
|
||||
}, [plotInstance]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
plotData.current = {
|
||||
data: props.data.frame.fields.map(f => f.values.toArray()) as AlignedData,
|
||||
isGap: props.data.isGap,
|
||||
};
|
||||
}, [props.data]);
|
||||
|
||||
// Decides if plot should update data or re-initialise
|
||||
useLayoutEffect(() => {
|
||||
// Make sure everything is ready before proceeding
|
||||
if (!currentConfig || !plotData.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do nothing if there is data vs series config mismatch. This may happen when the data was updated and made this
|
||||
// effect fire before the config update triggered the effect.
|
||||
if (currentConfig.series.length !== plotData.current.data!.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldInitialisePlot(prevConfig, currentConfig)) {
|
||||
if (!canvasRef.current) {
|
||||
throw new Error('Missing Canvas component as a child of the plot.');
|
||||
}
|
||||
const instance = initPlot(plotData.current.data!, currentConfig, canvasRef.current);
|
||||
|
||||
if (props.onPlotInit) {
|
||||
props.onPlotInit();
|
||||
}
|
||||
|
||||
setPlotInstance(instance);
|
||||
} else {
|
||||
updateData();
|
||||
}
|
||||
}, [currentConfig, updateData, setPlotInstance, props.onPlotInit]);
|
||||
|
||||
// When size props changed update plot size synchronously
|
||||
useLayoutEffect(() => {
|
||||
if (plotInstance) {
|
||||
@ -103,14 +82,46 @@ export const UPlotChart: React.FC<PlotProps> = props => {
|
||||
|
||||
return (
|
||||
<PlotContext.Provider value={plotCtx}>
|
||||
<div ref={plotCtx.canvasRef} />
|
||||
<div ref={plotCtx.canvasRef} data-testid="uplot-main-div" />
|
||||
{props.children}
|
||||
</PlotContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Main function initialising uPlot. If final config is not settled it will do nothing
|
||||
function initPlot(data: AlignedData, config: Options, ref: HTMLDivElement) {
|
||||
pluginLog('uPlot core', false, 'initialized with', data, config);
|
||||
return new uPlot(config, data, ref);
|
||||
// Callback executed when there was no change in plot config
|
||||
function updateData(frame: DataFrame, config: UPlotConfigBuilder, plotInstance?: uPlot, data?: AlignedData | null) {
|
||||
if (!plotInstance || !data) {
|
||||
return;
|
||||
}
|
||||
pluginLog('uPlot core', false, 'updating plot data(throttled log!)', data);
|
||||
updateScales(frame, config, plotInstance);
|
||||
// If config hasn't changed just update uPlot's data
|
||||
plotInstance.setData(data);
|
||||
}
|
||||
|
||||
function updateScales(frame: DataFrame, config: UPlotConfigBuilder, plotInstance: uPlot) {
|
||||
let yRange: [number, number] | undefined = undefined;
|
||||
|
||||
for (let i = 0; i < frame.fields.length; i++) {
|
||||
if (frame.fields[i].type !== FieldType.number) {
|
||||
continue;
|
||||
}
|
||||
if (isNumber(frame.fields[i].config.min) && isNumber(frame.fields[i].config.max)) {
|
||||
yRange = [frame.fields[i].config.min!, frame.fields[i].config.max!];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const scalesConfig = config.getConfig().scales;
|
||||
|
||||
if (scalesConfig && yRange) {
|
||||
for (const scale in scalesConfig) {
|
||||
if (!scalesConfig.hasOwnProperty(scale)) {
|
||||
continue;
|
||||
}
|
||||
if (scale !== 'x') {
|
||||
plotInstance.setScale(scale, { min: yRange[0], max: yRange[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -9,19 +9,6 @@ export enum AxisPlacement {
|
||||
Hidden = 'hidden',
|
||||
}
|
||||
|
||||
export function getUPlotSideFromAxis(axis: AxisPlacement) {
|
||||
switch (axis) {
|
||||
case AxisPlacement.Top:
|
||||
return 0;
|
||||
case AxisPlacement.Right:
|
||||
return 1;
|
||||
case AxisPlacement.Bottom:
|
||||
return 2;
|
||||
case AxisPlacement.Left:
|
||||
}
|
||||
return 3; // default everythign to the left
|
||||
}
|
||||
|
||||
export enum PointMode {
|
||||
Auto = 'auto', // will show points when the density is low or line is hidden
|
||||
Always = 'always',
|
||||
|
@ -2,15 +2,15 @@ import { dateTimeFormat, GrafanaTheme, systemDateFormats, TimeZone } from '@graf
|
||||
import uPlot, { Axis } from 'uplot';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
import { measureText } from '../../../utils/measureText';
|
||||
import { AxisPlacement } from '../config';
|
||||
|
||||
export interface AxisProps {
|
||||
scaleKey: string;
|
||||
theme: GrafanaTheme;
|
||||
label?: string;
|
||||
stroke?: string;
|
||||
show?: boolean;
|
||||
size?: number;
|
||||
side?: Axis.Side;
|
||||
placement?: AxisPlacement;
|
||||
grid?: boolean;
|
||||
formatValue?: (v: any) => string;
|
||||
values?: any;
|
||||
@ -24,7 +24,7 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
scaleKey,
|
||||
label,
|
||||
show = true,
|
||||
side = 3,
|
||||
placement = AxisPlacement.Auto,
|
||||
grid = true,
|
||||
formatValue,
|
||||
values,
|
||||
@ -32,16 +32,15 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
timeZone,
|
||||
theme,
|
||||
} = this.props;
|
||||
const stroke = this.props.stroke || theme.colors.text;
|
||||
const gridColor = theme.isDark ? theme.palette.gray25 : theme.palette.gray90;
|
||||
|
||||
let config: Axis = {
|
||||
scale: scaleKey,
|
||||
label,
|
||||
show,
|
||||
stroke,
|
||||
side,
|
||||
font: '12px Roboto',
|
||||
stroke: theme.colors.text,
|
||||
side: getUPlotSideFromAxis(placement),
|
||||
font: `12px 'Roboto'`,
|
||||
labelFont: `12px 'Roboto'`,
|
||||
size: calculateAxisSize,
|
||||
grid: {
|
||||
show: grid,
|
||||
@ -57,6 +56,11 @@ export class UPlotAxisBuilder extends PlotConfigBuilder<AxisProps, Axis> {
|
||||
space: calculateSpace,
|
||||
};
|
||||
|
||||
if (label !== undefined && label !== null && label.length > 0) {
|
||||
config.label = label;
|
||||
config.labelSize = 18;
|
||||
}
|
||||
|
||||
if (values) {
|
||||
config.values = values;
|
||||
} else if (isTime) {
|
||||
@ -102,20 +106,24 @@ function calculateAxisSize(self: uPlot, values: string[], axisIdx: number) {
|
||||
}
|
||||
}
|
||||
|
||||
return measureText(maxLength, 12).width - 8;
|
||||
let axisWidth = measureText(maxLength, 12).width + 18;
|
||||
return axisWidth;
|
||||
}
|
||||
|
||||
/** Format time axis ticks */
|
||||
function formatTime(self: uPlot, splits: number[], axisIdx: number, foundSpace: number, foundIncr: number): string[] {
|
||||
const timeZone = (self.axes[axisIdx] as any).timeZone;
|
||||
const scale = self.scales.x;
|
||||
const range = (scale?.max ?? 0) - (scale?.min ?? 0);
|
||||
const range = (scale?.max ?? 0) - (scale?.min ?? 0) / 1000;
|
||||
const oneDay = 86400;
|
||||
const oneYear = 31536000;
|
||||
|
||||
foundIncr = foundIncr / 1000;
|
||||
let format = systemDateFormats.interval.minute;
|
||||
|
||||
if (foundIncr <= 45) {
|
||||
if (foundIncr < 1) {
|
||||
format = systemDateFormats.interval.second.replace('ss', 'ss.SS');
|
||||
} else if (foundIncr <= 45) {
|
||||
format = systemDateFormats.interval.second;
|
||||
} else if (foundIncr <= 7200 || range <= oneDay) {
|
||||
format = systemDateFormats.interval.minute;
|
||||
@ -127,5 +135,19 @@ function formatTime(self: uPlot, splits: number[], axisIdx: number, foundSpace:
|
||||
format = systemDateFormats.interval.month;
|
||||
}
|
||||
|
||||
return splits.map(v => dateTimeFormat(v * 1000, { format, timeZone }));
|
||||
return splits.map(v => dateTimeFormat(v, { format, timeZone }));
|
||||
}
|
||||
|
||||
export function getUPlotSideFromAxis(axis: AxisPlacement) {
|
||||
switch (axis) {
|
||||
case AxisPlacement.Top:
|
||||
return 0;
|
||||
case AxisPlacement.Right:
|
||||
return 1;
|
||||
case AxisPlacement.Bottom:
|
||||
return 2;
|
||||
case AxisPlacement.Left:
|
||||
}
|
||||
|
||||
return 3; // default everythign to the left
|
||||
}
|
||||
|
@ -3,6 +3,7 @@
|
||||
import { UPlotConfigBuilder } from './UPlotConfigBuilder';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
import { expect } from '../../../../../../public/test/lib/common';
|
||||
import { AxisPlacement, PointMode } from '../config';
|
||||
|
||||
describe('UPlotConfigBuilder', () => {
|
||||
describe('scales config', () => {
|
||||
@ -21,9 +22,11 @@ describe('UPlotConfigBuilder', () => {
|
||||
"axes": Array [],
|
||||
"scales": Object {
|
||||
"scale-x": Object {
|
||||
"range": undefined,
|
||||
"time": true,
|
||||
},
|
||||
"scale-y": Object {
|
||||
"range": undefined,
|
||||
"time": false,
|
||||
},
|
||||
},
|
||||
@ -55,14 +58,13 @@ describe('UPlotConfigBuilder', () => {
|
||||
scaleKey: 'scale-x',
|
||||
label: 'test label',
|
||||
timeZone: 'browser',
|
||||
side: 2,
|
||||
placement: AxisPlacement.Bottom,
|
||||
isTime: false,
|
||||
formatValue: () => 'test value',
|
||||
grid: false,
|
||||
show: true,
|
||||
size: 1,
|
||||
stroke: '#ff0000',
|
||||
theme: { isDark: true, palette: { gray25: '#ffffff' } } as GrafanaTheme,
|
||||
theme: { isDark: true, palette: { gray25: '#ffffff' }, colors: { text: 'gray' } } as GrafanaTheme,
|
||||
values: [],
|
||||
});
|
||||
|
||||
@ -70,19 +72,21 @@ describe('UPlotConfigBuilder', () => {
|
||||
Object {
|
||||
"axes": Array [
|
||||
Object {
|
||||
"font": "12px Roboto",
|
||||
"font": "12px 'Roboto'",
|
||||
"grid": Object {
|
||||
"show": false,
|
||||
"stroke": "#ffffff",
|
||||
"width": 1,
|
||||
},
|
||||
"label": "test label",
|
||||
"labelFont": "12px 'Roboto'",
|
||||
"labelSize": 18,
|
||||
"scale": "scale-x",
|
||||
"show": true,
|
||||
"side": 2,
|
||||
"size": [Function],
|
||||
"space": [Function],
|
||||
"stroke": "#ff0000",
|
||||
"stroke": "gray",
|
||||
"ticks": Object {
|
||||
"show": true,
|
||||
"stroke": "#ffffff",
|
||||
@ -99,6 +103,24 @@ describe('UPlotConfigBuilder', () => {
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('Handles auto axis placement', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
builder.addAxis({
|
||||
scaleKey: 'y1',
|
||||
placement: AxisPlacement.Auto,
|
||||
theme: { isDark: true, palette: { gray25: '#ffffff' } } as GrafanaTheme,
|
||||
});
|
||||
builder.addAxis({
|
||||
scaleKey: 'y2',
|
||||
placement: AxisPlacement.Auto,
|
||||
theme: { isDark: true, palette: { gray25: '#ffffff' } } as GrafanaTheme,
|
||||
});
|
||||
|
||||
expect(builder.getAxisPlacement('y1')).toBe(AxisPlacement.Left);
|
||||
expect(builder.getAxisPlacement('y2')).toBe(AxisPlacement.Right);
|
||||
});
|
||||
|
||||
it('allows series configuration', () => {
|
||||
const builder = new UPlotConfigBuilder();
|
||||
builder.addSeries({
|
||||
@ -106,7 +128,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
fill: true,
|
||||
fillColor: '#ff0000',
|
||||
fillOpacity: 0.5,
|
||||
points: true,
|
||||
points: PointMode.Auto,
|
||||
pointSize: 5,
|
||||
pointColor: '#00ff00',
|
||||
line: true,
|
||||
@ -123,7 +145,7 @@ describe('UPlotConfigBuilder', () => {
|
||||
Object {
|
||||
"fill": "rgba(255, 0, 0, 0.5)",
|
||||
"points": Object {
|
||||
"show": true,
|
||||
"fill": "#00ff00",
|
||||
"size": 5,
|
||||
"stroke": "#00ff00",
|
||||
},
|
||||
|
@ -2,15 +2,34 @@ import { PlotSeriesConfig } from '../types';
|
||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { AxisPlacement } from '../config';
|
||||
|
||||
export class UPlotConfigBuilder {
|
||||
private series: UPlotSeriesBuilder[] = [];
|
||||
private axes: UPlotAxisBuilder[] = [];
|
||||
private axes: Record<string, UPlotAxisBuilder> = {};
|
||||
private scales: UPlotScaleBuilder[] = [];
|
||||
private registeredScales: string[] = [];
|
||||
|
||||
hasLeftAxis = false;
|
||||
|
||||
addAxis(props: AxisProps) {
|
||||
this.axes.push(new UPlotAxisBuilder(props));
|
||||
props.placement = props.placement ?? AxisPlacement.Auto;
|
||||
|
||||
// Handle auto placement logic
|
||||
if (props.placement === AxisPlacement.Auto) {
|
||||
props.placement = this.hasLeftAxis ? AxisPlacement.Right : AxisPlacement.Left;
|
||||
}
|
||||
|
||||
if (props.placement === AxisPlacement.Left) {
|
||||
this.hasLeftAxis = true;
|
||||
}
|
||||
|
||||
this.axes[props.scaleKey] = new UPlotAxisBuilder(props);
|
||||
}
|
||||
|
||||
getAxisPlacement(scaleKey: string): AxisPlacement {
|
||||
const axis = this.axes[scaleKey];
|
||||
return axis?.props.placement! ?? AxisPlacement.Left;
|
||||
}
|
||||
|
||||
addSeries(props: SeriesProps) {
|
||||
@ -28,7 +47,7 @@ export class UPlotConfigBuilder {
|
||||
|
||||
getConfig() {
|
||||
const config: PlotSeriesConfig = { series: [{}] };
|
||||
config.axes = this.axes.map(a => a.getConfig());
|
||||
config.axes = Object.values(this.axes).map(a => a.getConfig());
|
||||
config.series = [...config.series, ...this.series.map(s => s.getConfig())];
|
||||
config.scales = this.scales.reduce((acc, s) => {
|
||||
return { ...acc, ...s.getConfig() };
|
||||
|
@ -1,17 +1,22 @@
|
||||
import isNumber from 'lodash/isNumber';
|
||||
import { Scale } from 'uplot';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
|
||||
export interface ScaleProps {
|
||||
scaleKey: string;
|
||||
isTime?: boolean;
|
||||
min?: number | null;
|
||||
max?: number | null;
|
||||
}
|
||||
|
||||
export class UPlotScaleBuilder extends PlotConfigBuilder<ScaleProps, Scale> {
|
||||
getConfig() {
|
||||
const { isTime, scaleKey } = this.props;
|
||||
const { isTime, scaleKey, min, max } = this.props;
|
||||
const range = isNumber(min) && isNumber(max) ? [min, max] : undefined;
|
||||
return {
|
||||
[scaleKey]: {
|
||||
time: !!isTime,
|
||||
range,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { Series } from 'uplot';
|
||||
import { PointMode } from '../config';
|
||||
import { PlotConfigBuilder } from '../types';
|
||||
|
||||
export interface SeriesProps {
|
||||
@ -7,7 +8,7 @@ export interface SeriesProps {
|
||||
line?: boolean;
|
||||
lineColor?: string;
|
||||
lineWidth?: number;
|
||||
points?: boolean;
|
||||
points?: PointMode;
|
||||
pointSize?: number;
|
||||
pointColor?: string;
|
||||
fill?: boolean;
|
||||
@ -26,15 +27,20 @@ export class UPlotSeriesBuilder extends PlotConfigBuilder<SeriesProps, Series> {
|
||||
}
|
||||
: {};
|
||||
|
||||
const pointsConfig = points
|
||||
? {
|
||||
const pointsConfig: Partial<Series> = {
|
||||
points: {
|
||||
show: true,
|
||||
size: pointSize,
|
||||
stroke: pointColor,
|
||||
fill: pointColor,
|
||||
size: pointSize,
|
||||
},
|
||||
};
|
||||
|
||||
// we cannot set points.show property above (even to undefined) as that will clear uPlot's default auto behavior
|
||||
if (points === PointMode.Never) {
|
||||
pointsConfig.points!.show = false;
|
||||
} else if (points === PointMode.Always) {
|
||||
pointsConfig.points!.show = true;
|
||||
}
|
||||
: {};
|
||||
|
||||
const areaConfig =
|
||||
fillOpacity !== undefined
|
||||
|
@ -5,6 +5,7 @@ import uPlot, { Options } from 'uplot';
|
||||
import { getTimeZoneInfo, TimeZone } from '@grafana/data';
|
||||
import { usePlotPluginContext } from './context';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
import usePrevious from 'react-use/lib/usePrevious';
|
||||
|
||||
export const usePlotPlugins = () => {
|
||||
/**
|
||||
@ -104,6 +105,7 @@ export const DEFAULT_PLOT_CONFIG = {
|
||||
hooks: {},
|
||||
};
|
||||
|
||||
//pass plain confsig object,memoize!
|
||||
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone, configBuilder: UPlotConfigBuilder) => {
|
||||
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
|
||||
const [currentConfig, setCurrentConfig] = useState<Options>();
|
||||
@ -124,7 +126,6 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
|
||||
if (!arePluginsReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentConfig({
|
||||
...DEFAULT_PLOT_CONFIG,
|
||||
width,
|
||||
@ -135,7 +136,7 @@ export const usePlotConfig = (width: number, height: number, timeZone: TimeZone,
|
||||
tzDate,
|
||||
...configBuilder.getConfig(),
|
||||
});
|
||||
}, [arePluginsReady, plugins, width, height, configBuilder]);
|
||||
}, [arePluginsReady, plugins, width, height, tzDate, configBuilder]);
|
||||
|
||||
return {
|
||||
registerPlugin,
|
||||
@ -171,3 +172,17 @@ export const useRefreshAfterGraphRendered = (pluginId: string) => {
|
||||
|
||||
return renderToken;
|
||||
};
|
||||
|
||||
export function useRevision<T>(dep: T, cmp: (prev: T, next: T) => boolean) {
|
||||
const [rev, setRev] = useState(0);
|
||||
const prevDep = usePrevious(dep);
|
||||
|
||||
useEffect(() => {
|
||||
const hasConfigChanged = prevDep ? !cmp(prevDep, dep) : true;
|
||||
if (hasConfigChanged) {
|
||||
setRev(r => r + 1);
|
||||
}
|
||||
}, [dep]);
|
||||
|
||||
return rev;
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export const ZoomPlugin: React.FC<ZoomPluginProps> = ({ onZoom }) => {
|
||||
if (selection.bbox.width < MIN_ZOOM_DIST) {
|
||||
return;
|
||||
}
|
||||
onZoom({ from: selection.min * 1000, to: selection.max * 1000 });
|
||||
onZoom({ from: selection.min, to: selection.max });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import uPlot, { Options, AlignedData, Series, Hooks } from 'uplot';
|
||||
import uPlot, { Options, Series, Hooks } from 'uplot';
|
||||
import { DataFrame, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { UPlotConfigBuilder } from './config/UPlotConfigBuilder';
|
||||
|
||||
@ -23,14 +23,10 @@ export interface PlotProps {
|
||||
height: number;
|
||||
config: UPlotConfigBuilder;
|
||||
children?: React.ReactElement[];
|
||||
/** Callback performed when uPlot data is updated */
|
||||
onDataUpdate?: (data: AlignedData) => {};
|
||||
/** Callback performed when uPlot is (re)initialized */
|
||||
onPlotInit?: () => {};
|
||||
}
|
||||
|
||||
export abstract class PlotConfigBuilder<P, T> {
|
||||
constructor(protected props: P) {}
|
||||
constructor(public props: P) {}
|
||||
abstract getConfig(): T;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,4 @@
|
||||
import throttle from 'lodash/throttle';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import omit from 'lodash/omit';
|
||||
import { rangeUtil, RawTimeRange } from '@grafana/data';
|
||||
import { Options } from 'uplot';
|
||||
import { PlotPlugin, PlotProps } from './types';
|
||||
@ -38,7 +36,7 @@ export const buildPlotConfig = (props: PlotProps, plugins: Record<string, PlotPl
|
||||
} as any;
|
||||
};
|
||||
|
||||
const isPlottingTime = (config: Options) => {
|
||||
export const isPlottingTime = (config: Options) => {
|
||||
let isTimeSeries = false;
|
||||
|
||||
if (!config.scales) {
|
||||
@ -56,63 +54,6 @@ const isPlottingTime = (config: Options) => {
|
||||
return isTimeSeries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Based on two config objects indicates whether or not uPlot needs reinitialisation
|
||||
* This COULD be done based on data frames, but keeping it this way for now as a simplification
|
||||
*/
|
||||
export const shouldInitialisePlot = (prevConfig?: Options, config?: Options) => {
|
||||
if (!config && !prevConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config) {
|
||||
if (config.width === 0 || config.height === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!prevConfig) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlottingTime(config!) && prevConfig!.tzDate !== config!.tzDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// reinitialise when number of series, scales or axes changes
|
||||
if (
|
||||
prevConfig!.series?.length !== config!.series?.length ||
|
||||
prevConfig!.axes?.length !== config!.axes?.length ||
|
||||
prevConfig!.scales?.length !== config!.scales?.length
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
|
||||
// reinitialise when any of the series config changes
|
||||
if (config!.series && prevConfig!.series) {
|
||||
for (const series of config!.series) {
|
||||
if (!isEqual(series, prevConfig!.series[idx])) {
|
||||
return true;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (config!.axes && prevConfig!.axes) {
|
||||
idx = 0;
|
||||
for (const axis of config!.axes) {
|
||||
// Comparing axes config, skipping values property as it changes across config builds - probably need to be more clever
|
||||
if (!isEqual(omit(axis, 'values', 'size'), omit(prevConfig!.axes[idx], 'values', 'size'))) {
|
||||
return true;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Dev helpers
|
||||
export const throttledLog = throttle((...t: any[]) => {
|
||||
console.log(...t);
|
||||
|
@ -67,7 +67,6 @@ export const plugin = new PanelPlugin<Options, GraphFieldConfig>(GraphPanel)
|
||||
.addRadio({
|
||||
path: 'points',
|
||||
name: 'Points',
|
||||
description: 'NOTE: auto vs always are currently the same',
|
||||
defaultValue: graphFieldOptions.points[0].value,
|
||||
settings: {
|
||||
options: graphFieldOptions.points,
|
||||
|
Loading…
Reference in New Issue
Block a user