GraphNG: Using new VizLayout, moving Legend into GraphNG and some other refactorings (#28913)

* Graph refactorings

* Move legend to GraphNG and use new VizLayout

* Things are working

* remove unused things

* Update

* Fixed ng test dashboard

* Update

* More refactoring

* Removed plugin

* Upgrade uplot

* Auto size axis

* Axis scaling

* Fixed tests

* updated

* minor simplification

* Fixed selection color

* Fixed story

* Minor story fix

* Improve x-axis formatting

* Tweaks

* Update

* Updated

* Updates to handle timezone

* Updated

* Fixing types

* Update

* Fixed type

* Updated
This commit is contained in:
Torkel Ödegaard 2020-11-09 15:31:03 +01:00 committed by GitHub
parent 76f4c11430
commit 71fffcb17c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 492 additions and 741 deletions

View File

@ -23,7 +23,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -39,9 +39,6 @@
"alpha": 0.1
},
"line": {
"color": {
"mode": "fixed"
},
"show": true,
"width": 1
},
@ -359,7 +356,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -564,7 +561,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -682,7 +679,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -815,7 +812,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -964,7 +961,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -1054,7 +1051,7 @@
"id": "color",
"value": {
"fixedColor": "purple",
"mode": "fixed"
"mode": "palette-classic"
}
},
{
@ -1113,7 +1110,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -1203,7 +1200,7 @@
"id": "color",
"value": {
"fixedColor": "purple",
"mode": "fixed"
"mode": "palette-classic"
}
},
{
@ -1262,7 +1259,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -1352,7 +1349,7 @@
"id": "color",
"value": {
"fixedColor": "purple",
"mode": "fixed"
"mode": "palette-classic"
}
},
{
@ -1426,7 +1423,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -1444,7 +1441,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -1486,7 +1483,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "single"
@ -1513,7 +1510,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -1531,7 +1528,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -1573,7 +1570,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "multi"
@ -1600,7 +1597,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -1617,7 +1614,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -1659,7 +1656,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "none"
@ -1686,7 +1683,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -1703,7 +1700,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -1746,7 +1743,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "multi"
@ -1791,7 +1788,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -1808,7 +1805,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -1851,7 +1848,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "single"
@ -1881,7 +1878,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -1915,7 +1912,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -1958,7 +1955,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "single"
@ -1988,7 +1985,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -2005,7 +2002,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -2048,7 +2045,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "single"
@ -2093,7 +2090,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -2110,7 +2107,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -2153,7 +2150,7 @@
"legend": {
"asTable": false,
"isVisible": true,
"placement": "under"
"placement": "bottom"
},
"tooltipOptions": {
"mode": "single"
@ -2198,7 +2195,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"align": null,
@ -2216,7 +2213,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": false,
"width": 1
@ -2295,7 +2292,7 @@
"id": "color",
"value": {
"fixedColor": "purple",
"mode": "fixed"
"mode": "palette-classic"
}
}
]
@ -2363,7 +2360,7 @@
"fieldConfig": {
"defaults": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"custom": {
"axis": {
@ -2380,7 +2377,7 @@
},
"line": {
"color": {
"mode": "fixed"
"mode": "palette-classic"
},
"show": true,
"width": 1
@ -2469,7 +2466,7 @@
"refresh": false,
"schemaVersion": 26,
"style": "dark",
"tags": [],
"tags": ["gdev", "panel-tests"],
"templating": {
"list": []
},
@ -2481,7 +2478,7 @@
"refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"]
},
"timezone": "Africa/Accra",
"title": "Graph NG - tests",
"title": "Panel Tests - Graph NG",
"uid": "TkZXxlNG3",
"version": 65
}

View File

@ -287,8 +287,8 @@
"tinycolor2": "1.4.1",
"tti-polyfill": "0.2.2",
"uuid": "8.3.0",
"whatwg-fetch": "3.1.0",
"visjs-network": "4.25.0"
"visjs-network": "4.25.0",
"whatwg-fetch": "3.1.0"
},
"resolutions": {
"caniuse-db": "1.0.30000772"

View File

@ -10,3 +10,4 @@ export {
standardTransformersRegistry,
} from './standardTransformersRegistry';
export { RegexpOrNamesMatcherOptions } from './matchers/nameMatcher';
export { outerJoinDataFrames } from './transformers/seriesToColumns';

View File

@ -22,63 +22,72 @@ export const seriesToColumnsTransformer: DataTransformerInfo<SeriesToColumnsOpti
operator: options => source =>
source.pipe(
map(data => {
const keyFieldMatch = options.byField || DEFAULT_KEY_FIELD;
const allFields: FieldsToProcess[] = [];
return outerJoinDataFrames(data, options);
})
),
};
for (let frameIndex = 0; frameIndex < data.length; frameIndex++) {
const frame = data[frameIndex];
const keyField = findKeyField(frame, keyFieldMatch);
/**
* @internal
*/
export function outerJoinDataFrames(data: DataFrame[], options: SeriesToColumnsOptions) {
const keyFieldMatch = options.byField || DEFAULT_KEY_FIELD;
const allFields: FieldsToProcess[] = [];
if (!keyField) {
continue;
}
for (let frameIndex = 0; frameIndex < data.length; frameIndex++) {
const frame = data[frameIndex];
const keyField = findKeyField(frame, keyFieldMatch);
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const sourceField = frame.fields[fieldIndex];
if (!keyField) {
continue;
}
if (sourceField === keyField) {
continue;
}
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {
const sourceField = frame.fields[fieldIndex];
let labels = sourceField.labels ?? {};
if (sourceField === keyField) {
continue;
}
if (frame.name) {
labels = { ...labels, name: frame.name };
}
let labels = sourceField.labels ?? {};
allFields.push({
keyField,
sourceField,
newField: {
...sourceField,
state: null,
values: new ArrayVector([]),
labels,
},
});
}
}
if (frame.name) {
labels = { ...labels, name: frame.name };
}
// if no key fields or more than one value field
if (allFields.length <= 1) {
return data;
}
const resultFrame = new MutableDataFrame();
resultFrame.addField({
...allFields[0].keyField,
allFields.push({
keyField,
sourceField,
newField: {
...sourceField,
state: null,
values: new ArrayVector([]),
});
labels,
},
});
}
}
for (const item of allFields) {
item.newField = resultFrame.addField(item.newField);
}
// if no key fields or more than one value field
if (allFields.length <= 1) {
return data;
}
const keyFieldTitle = getFieldDisplayName(resultFrame.fields[0], resultFrame);
const byKeyField: { [key: string]: { [key: string]: any } } = {};
const resultFrame = new MutableDataFrame();
/*
resultFrame.addField({
...allFields[0].keyField,
values: new ArrayVector([]),
});
for (const item of allFields) {
item.newField = resultFrame.addField(item.newField);
}
const keyFieldTitle = getFieldDisplayName(resultFrame.fields[0], resultFrame);
const byKeyField: { [key: string]: { [key: string]: any } } = {};
/*
this loop creates a dictionary object that groups the key fields values
{
"key field first value as string" : {
@ -94,38 +103,36 @@ export const seriesToColumnsTransformer: DataTransformerInfo<SeriesToColumnsOpti
}
*/
for (let fieldIndex = 0; fieldIndex < allFields.length; fieldIndex++) {
const { sourceField, keyField, newField } = allFields[fieldIndex];
const newFieldTitle = getFieldDisplayName(newField, resultFrame);
for (let fieldIndex = 0; fieldIndex < allFields.length; fieldIndex++) {
const { sourceField, keyField, newField } = allFields[fieldIndex];
const newFieldTitle = getFieldDisplayName(newField, resultFrame);
for (let valueIndex = 0; valueIndex < sourceField.values.length; valueIndex++) {
const value = sourceField.values.get(valueIndex);
const keyValue = keyField.values.get(valueIndex);
for (let valueIndex = 0; valueIndex < sourceField.values.length; valueIndex++) {
const value = sourceField.values.get(valueIndex);
const keyValue = keyField.values.get(valueIndex);
if (!byKeyField[keyValue]) {
byKeyField[keyValue] = { [newFieldTitle]: value, [keyFieldTitle]: keyValue };
} else {
byKeyField[keyValue][newFieldTitle] = value;
}
}
}
if (!byKeyField[keyValue]) {
byKeyField[keyValue] = { [newFieldTitle]: value, [keyFieldTitle]: keyValue };
} else {
byKeyField[keyValue][newFieldTitle] = value;
}
}
}
const keyValueStrings = Object.keys(byKeyField);
for (let rowIndex = 0; rowIndex < keyValueStrings.length; rowIndex++) {
const keyValueAsString = keyValueStrings[rowIndex];
const keyValueStrings = Object.keys(byKeyField);
for (let rowIndex = 0; rowIndex < keyValueStrings.length; rowIndex++) {
const keyValueAsString = keyValueStrings[rowIndex];
for (let fieldIndex = 0; fieldIndex < resultFrame.fields.length; fieldIndex++) {
const field = resultFrame.fields[fieldIndex];
const otherColumnName = getFieldDisplayName(field, resultFrame);
const value = byKeyField[keyValueAsString][otherColumnName] ?? null;
field.values.add(value);
}
}
for (let fieldIndex = 0; fieldIndex < resultFrame.fields.length; fieldIndex++) {
const field = resultFrame.fields[fieldIndex];
const otherColumnName = getFieldDisplayName(field, resultFrame);
const value = byKeyField[keyValueAsString][otherColumnName] ?? null;
field.values.add(value);
}
}
return [resultFrame];
})
),
};
return [resultFrame];
}
function findKeyField(frame: DataFrame, matchTitle: string): Field | null {
for (let fieldIndex = 0; fieldIndex < frame.fields.length; fieldIndex++) {

View File

@ -137,6 +137,7 @@ export interface GrafanaTheme extends GrafanaThemeCommons {
// New greys palette used by next-gen form elements
gray98: string;
gray95: string;
gray90: string;
gray85: string;
gray70: string;
gray60: string;

View File

@ -70,7 +70,7 @@
"react-transition-group": "4.3.0",
"slate": "0.47.8",
"tinycolor2": "1.4.1",
"uplot": "1.2.2"
"uplot": "1.3.0"
},
"devDependencies": {
"@rollup/plugin-commonjs": "11.0.2",

View File

@ -41,10 +41,10 @@ const getStoriesKnobs = (isList = false) => {
const legendPlacement = select<LegendPlacement>(
'Legend placement',
{
under: 'under',
bottom: 'bottom',
right: 'right',
},
'under'
'bottom'
);
return {

View File

@ -8,7 +8,6 @@ import union from 'lodash/union';
import sortBy from 'lodash/sortBy';
import { ThemeContext } from '../../themes/ThemeContext';
import { css } from 'emotion';
import { selectThemeVariant } from '../../themes/index';
export interface GraphLegendProps extends LegendProps {
displayMode: LegendDisplayMode;
@ -61,13 +60,7 @@ export const GraphLegend: React.FunctionComponent<GraphLegendProps> = ({
})
: items;
const legendTableEvenRowBackground = selectThemeVariant(
{
dark: theme.palette.dark6,
light: theme.palette.gray5,
},
theme.type
);
const legendTableEvenRowBackground = theme.isDark ? theme.palette.dark6 : theme.palette.gray5;
return (
<LegendTable

View File

@ -86,10 +86,10 @@ const getStoriesKnobs = () => {
const legendPlacement = select<LegendPlacement>(
'Legend placement',
{
under: 'under',
bottom: 'under',
right: 'right',
},
'under'
'bottom'
);
const renderLegendAsTable = select<any>(
'Render legend as',

View File

@ -28,7 +28,7 @@ export interface GraphWithLegendProps extends GraphProps, LegendRenderOptions {
const getGraphWithLegendStyles = stylesFactory(({ placement }: GraphWithLegendProps) => ({
wrapper: css`
display: flex;
flex-direction: ${placement === 'under' ? 'column' : 'row'};
flex-direction: ${placement === 'bottom' ? 'column' : 'row'};
height: 100%;
`,
graphContainer: css`
@ -37,7 +37,7 @@ const getGraphWithLegendStyles = stylesFactory(({ placement }: GraphWithLegendPr
`,
legendContainer: css`
padding: 10px 0;
max-height: ${placement === 'under' ? '35%' : 'none'};
max-height: ${placement === 'bottom' ? '35%' : 'none'};
`,
}));

View File

@ -0,0 +1,53 @@
import { FieldColorModeId, toDataFrame } from '@grafana/data';
import React from 'react';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import { GraphNG } from './GraphNG';
import { dateTime } from '@grafana/data';
import { LegendDisplayMode } from '../Legend/Legend';
import { prepDataForStorybook } from '../../utils/storybook/data';
import { useTheme } from '../../themes';
export default {
title: 'Visualizations/GraphNG',
component: GraphNG,
decorators: [withCenteredStory],
parameters: {
docs: {},
},
};
export const Lines: React.FC = () => {
const theme = useTheme();
const seriesA = toDataFrame({
target: 'SeriesA',
datapoints: [
[10, 1546372800000],
[20, 1546376400000],
[10, 1546380000000],
],
});
seriesA.fields[1].config.custom = { line: { show: true } };
seriesA.fields[1].config.color = { mode: FieldColorModeId.PaletteClassic };
seriesA.fields[1].config.unit = 'degree';
const data = prepDataForStorybook([seriesA], theme);
return (
<GraphNG
data={data}
width={600}
height={400}
timeRange={{
from: dateTime(1546372800000),
to: dateTime(1546380000000),
raw: {
from: dateTime(1546372800000),
to: dateTime(1546380000000),
},
}}
legend={{ isVisible: true, displayMode: LegendDisplayMode.List, placement: 'bottom' }}
timeZone="browser"
></GraphNG>
);
};

View File

@ -1,17 +1,9 @@
import React from 'react';
import { GraphNG } from './GraphNG';
import { render } from '@testing-library/react';
import {
ArrayVector,
DataTransformerID,
dateTime,
FieldConfig,
FieldType,
MutableDataFrame,
standardTransformers,
standardTransformersRegistry,
} from '@grafana/data';
import { Canvas, GraphCustomFieldConfig } from '..';
import { ArrayVector, dateTime, FieldConfig, FieldType, MutableDataFrame } from '@grafana/data';
import { GraphCustomFieldConfig } from '..';
import { LegendDisplayMode, LegendOptions } from '../Legend/Legend';
const mockData = () => {
const data = new MutableDataFrame();
@ -42,25 +34,13 @@ const mockData = () => {
return { data, timeRange };
};
const defaultLegendOptions: LegendOptions = {
isVisible: false,
displayMode: LegendDisplayMode.List,
placement: 'bottom',
};
describe('GraphNG', () => {
beforeAll(() => {
standardTransformersRegistry.setInit(() => [
{
id: DataTransformerID.seriesToColumns,
editor: () => null,
transformation: standardTransformers.seriesToColumnsTransformer,
name: 'outer join',
},
]);
});
it('should throw when rendered without Canvas as child', () => {
const { data, timeRange } = mockData();
expect(() => {
render(<GraphNG data={[data]} timeRange={timeRange} timeZone={'browser'} width={100} height={100} />);
}).toThrow('Missing Canvas component as a child of the plot.');
});
describe('data update', () => {
it('does not re-initialise uPlot when there are no field config changes', () => {
const { data, timeRange } = mockData();
@ -76,9 +56,8 @@ describe('GraphNG', () => {
height={100}
onDataUpdate={onDataUpdateSpy}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
legend={defaultLegendOptions}
></GraphNG>
);
data.fields[1].values.set(0, 1);
@ -92,9 +71,8 @@ describe('GraphNG', () => {
height={100}
onDataUpdate={onDataUpdateSpy}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
legend={defaultLegendOptions}
></GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(1);
@ -118,9 +96,7 @@ describe('GraphNG', () => {
width={0}
height={0}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
></GraphNG>
);
expect(onPlotInitSpy).not.toBeCalled();
@ -138,9 +114,7 @@ describe('GraphNG', () => {
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
></GraphNG>
);
data.addField({
@ -162,9 +136,7 @@ describe('GraphNG', () => {
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
></GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(2);
@ -182,9 +154,7 @@ describe('GraphNG', () => {
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
></GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(1);
@ -198,9 +168,7 @@ describe('GraphNG', () => {
width={100}
height={100}
onPlotInit={onPlotInitSpy}
>
<Canvas />
</GraphNG>
></GraphNG>
);
expect(onPlotInitSpy).toBeCalledTimes(2);

View File

@ -1,77 +1,42 @@
import React, { useEffect, useState } from 'react';
import React, { useMemo } from 'react';
import {
DataFrame,
FieldConfig,
FieldType,
formattedValueToString,
getFieldColorModeForField,
getFieldDisplayName,
getTimeField,
systemDateFormats,
TIME_SERIES_TIME_FIELD_NAME,
} from '@grafana/data';
import { timeFormatToTemplate } from '../uPlot/utils';
import { alignAndSortDataFramesByFieldName } from './utils';
import { Area, Axis, Line, Point, Scale, SeriesGeometry } from '../uPlot/geometries';
import { UPlotChart } from '../uPlot/Plot';
import { AxisSide, GraphCustomFieldConfig, PlotProps } from '../uPlot/types';
import { useTheme } from '../../themes';
const _ = null;
const timeStampsConfig = [
// tick incr default year month day hour min sec mode
[3600 * 24 * 365, '{YYYY}', _, _, _, _, _, _, 1],
[3600 * 24 * 28, `${timeFormatToTemplate(systemDateFormats.interval.month)}`, _, _, _, _, _, _, 1],
[3600 * 24, `${timeFormatToTemplate(systemDateFormats.interval.day)}`, `\n{YYYY}`, _, _, _, _, _, 1],
[
3600,
`${timeFormatToTemplate(systemDateFormats.interval.minute)}`,
_,
_,
`\n${timeFormatToTemplate(systemDateFormats.interval.day)}`,
_,
_,
_,
1,
],
[
60,
`${timeFormatToTemplate(systemDateFormats.interval.minute)}`,
_,
_,
`\n${timeFormatToTemplate(systemDateFormats.interval.day)}`,
_,
_,
_,
1,
],
[1, ':{ss}', _, _, _, _, `\n ${timeFormatToTemplate(systemDateFormats.interval.minute)}`, _, 1],
[1e-3, ':{ss}.{fff}', _, _, _, _, `\n ${timeFormatToTemplate(systemDateFormats.interval.minute)}`, _, 1],
];
import { VizLayout } from '../VizLayout/VizLayout';
import { LegendItem, LegendOptions } from '../Legend/Legend';
import { GraphLegend } from '../Graph/GraphLegend';
const defaultFormatter = (v: any) => (v == null ? '-' : v.toFixed(1));
const TIME_FIELD_NAME = 'Time';
interface GraphNGProps extends Omit<PlotProps, 'data'> {
data: DataFrame[];
legend?: LegendOptions;
}
export const GraphNG: React.FC<GraphNGProps> = ({ data, children, ...plotProps }) => {
export const GraphNG: React.FC<GraphNGProps> = ({
data,
children,
width,
height,
legend,
timeRange,
timeZone,
...plotProps
}) => {
const theme = useTheme();
const [alignedData, setAlignedData] = useState<DataFrame | null>(null);
useEffect(() => {
if (data.length === 0) {
setAlignedData(null);
return;
}
const subscription = alignAndSortDataFramesByFieldName(data, TIME_FIELD_NAME).subscribe(setAlignedData);
return function unsubscribe() {
subscription.unsubscribe();
};
}, [data]);
const alignedData = useMemo(() => alignAndSortDataFramesByFieldName(data, TIME_SERIES_TIME_FIELD_NAME), [data]);
if (!alignedData) {
return (
@ -90,12 +55,13 @@ export const GraphNG: React.FC<GraphNGProps> = ({ data, children, ...plotProps }
timeIndex = 0; // assuming first field represents x-domain
scales.push(<Scale key="scale-x" scaleKey="x" />);
} else {
scales.push(<Scale key="scale-x" scaleKey="x" time />);
scales.push(<Scale key="scale-x" scaleKey="x" isTime />);
}
axes.push(<Axis key="axis-scale--x" scaleKey="x" values={timeStampsConfig} side={AxisSide.Bottom} />);
axes.push(<Axis key="axis-scale-x" scaleKey="x" isTime side={AxisSide.Bottom} timeZone={timeZone} />);
let seriesIdx = 0;
const legendItems: LegendItem[] = [];
const uniqueScales: Record<string, boolean> = {};
for (let i = 0; i < alignedData.fields.length; i++) {
@ -155,6 +121,7 @@ export const GraphNG: React.FC<GraphNGProps> = ({ data, children, ...plotProps }
<Area key={`area-${scale}-${i}`} scaleKey={scale} fill={customConfig?.fill.alpha} color={seriesColor} />
);
}
if (seriesGeometry.length > 1) {
geometries.push(
<SeriesGeometry key={`seriesGeometry-${scale}-${i}`} scaleKey={scale}>
@ -165,15 +132,45 @@ export const GraphNG: React.FC<GraphNGProps> = ({ data, children, ...plotProps }
geometries.push(seriesGeometry);
}
if (legend?.isVisible) {
legendItems.push({
color: seriesColor,
label: getFieldDisplayName(field, alignedData),
isVisible: true,
yAxis: customConfig?.axis?.side === 1 ? 3 : 1,
});
}
seriesIdx++;
}
let legendElement: React.ReactElement | undefined;
if (legend?.isVisible && legendItems.length > 0) {
legendElement = (
<VizLayout.Legend position={legend.placement} maxHeight="35%" maxWidth="60%">
<GraphLegend placement={legend.placement} items={legendItems} displayMode={legend.displayMode} />
</VizLayout.Legend>
);
}
return (
<UPlotChart data={alignedData} {...plotProps}>
{scales}
{axes}
{geometries}
{children}
</UPlotChart>
<VizLayout width={width} height={height} legend={legendElement}>
{(vizWidth: number, vizHeight: number) => (
<UPlotChart
data={alignedData}
width={vizWidth}
height={vizHeight}
timeRange={timeRange}
timeZone={timeZone}
{...plotProps}
>
{scales}
{axes}
{geometries}
{children}
</UPlotChart>
)}
</VizLayout>
);
};

View File

@ -1,9 +1,11 @@
import { Observable } from 'rxjs';
import { DataFrame, FieldType, getTimeField, sortDataFrame, transformDataFrame } from '@grafana/data';
import { map } from 'rxjs/operators';
import { DataFrame, FieldType, getTimeField, outerJoinDataFrames, sortDataFrame } from '@grafana/data';
// very time oriented for now
export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName: string): Observable<DataFrame> => {
export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName: string): DataFrame | null => {
if (!data.length) {
return null;
}
// normalize time field names
// in each frame find first time field and rename it to unified name
for (let i = 0; i < data.length; i++) {
@ -24,21 +26,6 @@ export const alignAndSortDataFramesByFieldName = (data: DataFrame[], fieldName:
return timeIndex !== undefined ? frame.fields.length > 1 : false;
});
// uPlot data needs to be aligned on x-axis (ref. https://github.com/leeoniya/uPlot/issues/108)
// For experimentation just assuming alignment on time field, needs to change
return transformDataFrame(
[
{
id: 'seriesToColumns',
options: { byField: fieldName },
},
],
dataFramesToPlot
).pipe(
map(data => {
const aligned = data[0];
// need to be more "clever", not time only in the future!
return sortDataFrame(aligned, getTimeField(aligned).timeIndex!);
})
);
const aligned = outerJoinDataFrames(dataFramesToPlot, { byField: fieldName })[0];
return sortDataFrame(aligned, getTimeField(aligned).timeIndex!);
};

View File

@ -53,10 +53,10 @@ const getStoriesKnobs = (table = false) => {
const legendPlacement = select<LegendPlacement>(
'Legend placement',
{
under: 'under',
bottom: 'bottom',
right: 'right',
},
'under'
'bottom'
);
return {

View File

@ -24,7 +24,7 @@ export enum LegendDisplayMode {
}
export interface LegendBasicOptions {
isVisible: boolean;
asTable: boolean;
displayMode: LegendDisplayMode;
}
export interface LegendRenderOptions {
@ -33,7 +33,7 @@ export interface LegendRenderOptions {
hideZero?: boolean;
}
export type LegendPlacement = 'under' | 'right' | 'over'; // Over used by piechart
export type LegendPlacement = 'bottom' | 'right';
export interface LegendOptions extends LegendBasicOptions, LegendRenderOptions {}

View File

@ -44,7 +44,7 @@ export const LegendList: React.FunctionComponent<LegendComponentProps> = ({
const getItemKey = (item: LegendItem) => `${item.label}`;
return placement === 'under' ? (
return placement === 'bottom' ? (
<div className={cx(styles.wrapper, className)}>
<div className={styles.section}>
<InlineList items={items.filter(item => item.yAxis === 1)} renderItem={renderItem} getItemKey={getItemKey} />

View File

@ -6,7 +6,6 @@ import { number } from '@storybook/addon-knobs';
import { useTheme } from '../../themes';
import mdx from './Table.mdx';
import {
applyFieldOverrides,
DataFrame,
FieldType,
GrafanaTheme,
@ -15,6 +14,7 @@ import {
ThresholdsMode,
FieldConfig,
} from '@grafana/data';
import { prepDataForStorybook } from '../../utils/storybook/data';
export default {
title: 'Visualizations/Table',
@ -82,16 +82,7 @@ function buildData(theme: GrafanaTheme, config: Record<string, FieldConfig>): Da
]);
}
return applyFieldOverrides({
data: [data],
fieldConfig: {
overrides: [],
defaults: {},
},
theme,
replaceVariables: (value: string) => value,
getDataSourceSettingsByUid: (value: string) => ({} as any),
})[0];
return prepDataForStorybook([data], theme)[0];
}
const defaultThresholds: ThresholdsConfig = {

View File

@ -41,9 +41,7 @@ export const VizLayout: VizLayoutComponentType = ({ width, height, legend, child
flexGrow: 1,
};
const legendStyle: CSSProperties = {
flexGrow: 1,
};
const legendStyle: CSSProperties = {};
switch (position) {
case 'bottom':

View File

@ -210,7 +210,6 @@ export { GraphCustomFieldConfig, AxisSide } from './uPlot/types';
export { UPlotChart } from './uPlot/Plot';
export * from './uPlot/geometries';
export { usePlotConfigContext } from './uPlot/context';
export { Canvas } from './uPlot/Canvas';
export * from './uPlot/plugins';
export { useRefreshAfterGraphRendered } from './uPlot/hooks';
export { usePlotContext, usePlotData, usePlotPluginContext } from './uPlot/context';

View File

@ -1,16 +0,0 @@
import React from 'react';
import { usePlotContext } from './context';
interface CanvasProps {
width?: number;
height?: number;
}
// Ref element to render the uPlot canvas to
// This is a required child of Plot component!
export const Canvas: React.FC<CanvasProps> = () => {
const plotCtx = usePlotContext();
return <div ref={plotCtx.canvasRef} />;
};
Canvas.displayName = 'Canvas';

View File

@ -1,2 +1,10 @@
// importing the uPlot css so it will be bundled with the rest of the styling.
@import '../../node_modules/uplot/dist/uPlot.min.css';
.uplot {
font-family: inherit;
}
.u-select {
background: rgba(120, 120, 130, 0.2);
}

View File

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { css } from 'emotion';
import uPlot from 'uplot';
import { usePrevious } from 'react-use';
import uPlot from 'uplot';
import { buildPlotContext, PlotContext } from './context';
import { pluginLog, preparePlotData, shouldInitialisePlot } from './utils';
import { usePlotConfig } from './hooks';
@ -21,6 +20,7 @@ export const UPlotChart: React.FC<PlotProps> = props => {
props.height,
props.timeZone
);
const prevConfig = usePrevious(currentConfig);
const getPlotInstance = useCallback(() => {
@ -112,15 +112,8 @@ export const UPlotChart: React.FC<PlotProps> = props => {
return (
<PlotContext.Provider value={plotCtx}>
<div
className={css`
position: relative;
width: ${props.width}px;
height: ${props.height}px;
`}
>
{props.children}
</div>
<div ref={plotCtx.canvasRef} />
{props.children}
</PlotContext.Provider>
);
};

View File

@ -78,8 +78,9 @@ export const usePlotConfigContext = (): PlotConfigContextType => {
const ctx = usePlotContext();
if (!ctx) {
throwWhenNoContext('usePlotPluginContext');
throwWhenNoContext('usePlotConfigContext');
}
return {
addSeries: ctx!.addSeries,
addAxis: ctx!.addAxis,

View File

@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { AxisProps } from './types';
import { usePlotConfigContext } from '../context';
import { useTheme } from '../../../themes';
import uPlot from 'uplot';
import { measureText } from '../../../utils';
import { dateTimeFormat, systemDateFormats } from '@grafana/data';
export const useAxisConfig = (getConfig: () => any) => {
const { addAxis } = usePlotConfigContext();
@ -32,17 +34,18 @@ export const useAxisConfig = (getConfig: () => any) => {
export const Axis: React.FC<AxisProps> = props => {
const theme = useTheme();
const gridColor = useMemo(() => (theme.isDark ? theme.palette.gray1 : theme.palette.gray4), [theme]);
const gridColor = theme.isDark ? theme.palette.gray25 : theme.palette.gray90;
const {
scaleKey,
label,
show = true,
size = 80,
stroke = theme.colors.text,
side = 3,
grid = true,
formatValue,
values,
isTime,
timeZone,
} = props;
const getConfig = () => {
@ -50,9 +53,10 @@ export const Axis: React.FC<AxisProps> = props => {
scale: scaleKey,
label,
show,
size,
stroke,
side,
font: '12px Roboto',
size: calculateAxisSize,
grid: {
show: grid,
stroke: gridColor,
@ -63,13 +67,70 @@ export const Axis: React.FC<AxisProps> = props => {
stroke: gridColor,
width: 1 / devicePixelRatio,
},
values: values ? values : formatValue ? (u: uPlot, vals: any[]) => vals.map(v => formatValue(v)) : undefined,
values: values,
};
if (values) {
config.values = values;
} else if (isTime) {
config.values = formatTime;
config.space = 60;
} else if (formatValue) {
config.values = (u: uPlot, vals: any[]) => vals.map(v => formatValue(v));
}
// store timezone
(config as any).timeZone = timeZone;
return config;
};
useAxisConfig(getConfig);
useAxisConfig(getConfig);
return null;
};
function calculateAxisSize(self: uPlot, values: string[], axisIdx: number) {
const axis = self.axes[axisIdx];
if (axis.scale === 'x') {
return 33;
}
if (values === null || !values.length) {
return 0;
}
let maxLength = values[0];
for (let i = 0; i < values.length; i++) {
if (values[i].length > maxLength.length) {
maxLength = maxLength;
}
}
return measureText(maxLength, 12).width;
}
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 oneDay = 86400;
const oneYear = 31536000;
let format = systemDateFormats.interval.minute;
if (foundIncr <= 45) {
format = systemDateFormats.interval.second;
} else if (foundIncr <= 7200 || range <= oneDay) {
format = systemDateFormats.interval.minute;
} else if (foundIncr <= 80000) {
format = systemDateFormats.interval.hour;
} else if (foundIncr <= 2419200 || range <= oneYear) {
format = systemDateFormats.interval.day;
} else if (foundIncr <= 31536000) {
format = systemDateFormats.interval.month;
}
return splits.map(v => dateTimeFormat(v * 1000, { format, timeZone }));
}
Axis.displayName = 'Axis';

View File

@ -30,12 +30,10 @@ const useScaleConfig = (scaleKey: string, getConfig: () => any) => {
}, [getConfig]);
};
export const Scale: React.FC<ScaleProps> = props => {
const { scaleKey, time } = props;
export const Scale: React.FC<ScaleProps> = ({ scaleKey, isTime }) => {
const getConfig = () => {
let config: uPlot.Scale = {
time: !!time,
time: !!isTime,
};
return config;
};

View File

@ -1,3 +1,4 @@
import { TimeZone } from '@grafana/data';
import { AxisSide } from '../types';
export interface LineProps {
@ -28,9 +29,11 @@ export interface AxisProps {
grid?: boolean;
formatValue?: (v: any) => string;
values?: any;
isTime?: boolean;
timeZone?: TimeZone;
}
export interface ScaleProps {
scaleKey: string;
time?: boolean;
isTime?: boolean;
}

View File

@ -10,12 +10,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -48,12 +52,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -93,12 +101,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -136,12 +148,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -180,12 +196,16 @@ describe('usePlotConfig', () => {
],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -226,12 +246,16 @@ describe('usePlotConfig', () => {
],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -266,12 +290,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -306,12 +334,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -352,12 +384,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -396,12 +432,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -437,12 +477,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {
@ -487,12 +531,16 @@ describe('usePlotConfig', () => {
"axes": Array [],
"cursor": Object {
"focus": Object {
"prox": 30,
"prox": -1,
},
},
"focus": Object {
"alpha": 1,
},
"gutters": Object {
"x": 8,
"y": 8,
},
"height": 0,
"hooks": Object {},
"legend": Object {

View File

@ -88,15 +88,20 @@ export const DEFAULT_PLOT_CONFIG = {
},
cursor: {
focus: {
prox: 30,
prox: -1,
},
},
legend: {
show: false,
},
gutters: {
x: 8,
y: 8,
},
series: [],
hooks: {},
};
export const usePlotConfig = (width: number, height: number, timeZone: TimeZone) => {
const { arePluginsReady, plugins, registerPlugin } = usePlotPlugins();
const [seriesConfig, setSeriesConfig] = useState<uPlot.Series[]>([{}]);

View File

@ -1,49 +0,0 @@
import React from 'react';
import { GraphCustomFieldConfig, GraphLegend, LegendDisplayMode, LegendItem } from '../..';
import { usePlotData } from '../context';
import { FieldType, getColorForTheme, getFieldDisplayName } from '@grafana/data';
import { colors } from '../../../utils';
import { useTheme } from '../../../themes';
export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right';
interface LegendPluginProps {
placement: LegendPlacement;
displayMode?: LegendDisplayMode;
}
export const LegendPlugin: React.FC<LegendPluginProps> = ({ placement, displayMode = LegendDisplayMode.List }) => {
const { data } = usePlotData();
const theme = useTheme();
const legendItems: LegendItem[] = [];
let seriesIdx = 0;
for (let i = 0; i < data.fields.length; i++) {
const field = data.fields[i];
if (field.type === FieldType.time) {
continue;
}
legendItems.push({
color:
field.config.color && field.config.color.fixedColor
? getColorForTheme(field.config.color.fixedColor, theme)
: colors[seriesIdx],
label: getFieldDisplayName(field, data),
isVisible: true,
//flot vs uPlot differences
yAxis: (field.config.custom as GraphCustomFieldConfig)?.axis?.side === 1 ? 3 : 1,
});
seriesIdx++;
}
return (
<GraphLegend
placement={placement === 'top' || placement === 'bottom' ? 'under' : 'right'}
items={legendItems}
displayMode={displayMode}
/>
);
};

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { PlotPluginProps } from '../types';
import { usePlotContext, usePlotPluginContext } from '../context';
import { usePlotContext } from '../context';
import { pluginLog } from '../utils';
interface Selection {
@ -32,7 +32,6 @@ interface SelectionPluginProps extends PlotPluginProps {
export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDismiss, lazy, id, children }) => {
const pluginId = `SelectionPlugin:${id}`;
const pluginsApi = usePlotPluginContext();
const plotCtx = usePlotContext();
const [selection, setSelection] = useState<Selection | null>(null);
@ -48,7 +47,7 @@ export const SelectionPlugin: React.FC<SelectionPluginProps> = ({ onSelect, onDi
}, [setSelection]);
useEffect(() => {
pluginsApi.registerPlugin({
plotCtx.registerPlugin({
id: pluginId,
hooks: {
setSelect: u => {

View File

@ -4,4 +4,3 @@ export { ZoomPlugin } from './ZoomPlugin';
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
export { ContextMenuPlugin } from './ContextMenuPlugin';
export { TooltipPlugin } from './TooltipPlugin';
export { LegendPlugin } from './LegendPlugin';

View File

@ -110,6 +110,7 @@ export const shouldInitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.
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 ||
@ -135,7 +136,7 @@ export const shouldInitialisePlot = (prevConfig?: uPlot.Options, config?: uPlot.
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'), omit(prevConfig!.axes[idx], 'values'))) {
if (!isEqual(omit(axis, 'values', 'size'), omit(prevConfig!.axes[idx], 'values', 'size'))) {
return true;
}
idx++;

View File

@ -0,0 +1,14 @@
import { applyFieldOverrides, DataFrame, GrafanaTheme } from '@grafana/data';
export function prepDataForStorybook(data: DataFrame[], theme: GrafanaTheme) {
return applyFieldOverrides({
data: data,
fieldConfig: {
overrides: [],
defaults: {},
},
theme,
replaceVariables: (value: string) => value,
getDataSourceSettingsByUid: (value: string) => ({} as any),
});
}

View File

@ -116,7 +116,7 @@ class UnThemedExploreGraphPanel extends PureComponent<Props, State> {
displayMode={LegendDisplayMode.List}
height={height}
isLegendVisible={true}
placement={'under'}
placement={'bottom'}
width={width}
timeRange={timeRange}
timeZone={timeZone}

View File

@ -16,6 +16,7 @@ import { ExplorePanelData } from '../../../types';
import { getGraphSeriesModel } from '../../../plugins/panel/graph2/getGraphSeriesModel';
import { dataFrameToLogsModel } from '../../../core/logs_model';
import { refreshIntervalToSortOrder } from '../../../core/utils/explore';
import { LegendDisplayMode } from '@grafana/ui';
/**
* When processing response first we try to determine what kind of dataframes we got as one query can return multiple
@ -91,7 +92,7 @@ export const decorateWithGraphResult = (data: ExplorePanelData): ExplorePanelDat
data.request?.timezone ?? 'browser',
{},
{ showBars: false, showLines: true, showPoints: false },
{ asTable: false, isVisible: true, placement: 'under' }
{ displayMode: LegendDisplayMode.List, isVisible: true, placement: 'bottom' }
);
return { ...data, graphResult };

View File

@ -3,7 +3,6 @@ import { GraphWithLegend, Chart } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import { GraphPanelController } from './GraphPanelController';
import { LegendDisplayMode } from '@grafana/ui/src/components/Legend/Legend';
interface GraphPanelProps extends PanelProps<Options> {}
@ -38,7 +37,6 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
showPoints,
tooltipOptions,
};
const { asTable, isVisible, ...legendProps } = legendOptions;
return (
<GraphPanelController
data={data}
@ -55,14 +53,14 @@ export const GraphPanel: React.FunctionComponent<GraphPanelProps> = ({
timeZone={timeZone}
width={width}
height={height}
displayMode={asTable ? LegendDisplayMode.Table : LegendDisplayMode.List}
isLegendVisible={isVisible}
displayMode={legendOptions.displayMode}
isLegendVisible={legendOptions.isVisible}
placement={legendOptions.placement}
sortLegendBy={legendOptions.sortBy}
sortLegendDesc={legendOptions.sortDesc}
onSeriesToggle={onSeriesToggle}
onHorizontalRegionSelected={onHorizontalRegionSelected}
{...graphProps}
{...legendProps}
{...controllerApi}
>
<Chart.Tooltip mode={tooltipOptions.mode} />

View File

@ -1,4 +1,4 @@
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
import { LegendOptions, GraphTooltipOptions, LegendDisplayMode } from '@grafana/ui';
import { YAxis } from '@grafana/data';
export interface SeriesOptions {
@ -28,9 +28,9 @@ export const defaults: Options = {
showPoints: false,
},
legend: {
asTable: false,
isVisible: true,
placement: 'under',
displayMode: LegendDisplayMode.List,
placement: 'bottom',
},
series: {},
tooltipOptions: { mode: 'single' },

View File

@ -1,16 +1,7 @@
import React from 'react';
import {
Canvas,
ContextMenuPlugin,
LegendDisplayMode,
LegendPlugin,
TooltipPlugin,
ZoomPlugin,
GraphNG,
} from '@grafana/ui';
import { ContextMenuPlugin, TooltipPlugin, ZoomPlugin, GraphNG } from '@grafana/ui';
import { PanelProps } from '@grafana/data';
import { Options } from './types';
import { VizLayout } from './VizLayout';
import { AnnotationsPlugin } from './plugins/AnnotationsPlugin';
import { ExemplarsPlugin } from './plugins/ExemplarsPlugin';
@ -26,43 +17,19 @@ export const GraphPanel: React.FC<GraphPanelProps> = ({
onChangeTimeRange,
}) => {
return (
<VizLayout width={width} height={height}>
{({ builder, getLayout }) => {
const layout = getLayout();
// when all layout slots are ready we can calculate the canvas(actual viz) size
const canvasSize = layout.isReady
? {
width: width - (layout.left.width + layout.right.width),
height: height - (layout.top.height + layout.bottom.height),
}
: { width: 0, height: 0 };
if (options.legend.isVisible) {
builder.addSlot(
options.legend.placement,
<LegendPlugin
placement={options.legend.placement}
displayMode={options.legend.asTable ? LegendDisplayMode.Table : LegendDisplayMode.List}
/>
);
} else {
builder.clearSlot(options.legend.placement);
}
return (
<GraphNG data={data.series} timeRange={timeRange} timeZone={timeZone} {...canvasSize}>
{builder.addSlot('canvas', <Canvas />).render()}
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />
<ContextMenuPlugin />
{data.annotations && <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} />}
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
{/* TODO: */}
{/*<AnnotationsEditorPlugin />*/}
</GraphNG>
);
}}
</VizLayout>
<GraphNG
data={data.series}
timeRange={timeRange}
timeZone={timeZone}
width={width}
height={height - 8}
legend={options.legend}
>
<TooltipPlugin mode={options.tooltipOptions.mode as any} timeZone={timeZone} />
<ZoomPlugin onZoom={onChangeTimeRange} />
<ContextMenuPlugin />
{data.annotations && <ExemplarsPlugin exemplars={data.annotations} timeZone={timeZone} />}
{data.annotations && <AnnotationsPlugin annotations={data.annotations} timeZone={timeZone} />}
</GraphNG>
);
};

View File

@ -1,221 +0,0 @@
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { css } from 'emotion';
import { useMeasure } from './useMeasure';
import { LayoutBuilder, LayoutRendererComponent } from './LayoutBuilder';
import { CustomScrollbar } from '@grafana/ui';
type UseMeasureRect = Pick<DOMRectReadOnly, 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'>;
const RESET_DIMENSIONS: UseMeasureRect = {
x: 0,
y: 0,
height: 0,
width: 0,
top: 0,
bottom: 0,
left: 0,
right: 0,
};
const DEFAULT_VIZ_LAYOUT_STATE = {
isReady: false,
top: RESET_DIMENSIONS,
bottom: RESET_DIMENSIONS,
right: RESET_DIMENSIONS,
left: RESET_DIMENSIONS,
canvas: RESET_DIMENSIONS,
};
export type VizLayoutSlots = 'top' | 'bottom' | 'left' | 'right' | 'canvas';
export interface VizLayoutState extends Record<VizLayoutSlots, UseMeasureRect> {
isReady: boolean;
}
interface VizLayoutAPI {
builder: LayoutBuilder<VizLayoutSlots>;
getLayout: () => VizLayoutState;
}
interface VizLayoutProps {
width: number;
height: number;
children: (api: VizLayoutAPI) => React.ReactNode;
}
/**
* Graph viz layout. Consists of 5 slots: top(T), bottom(B), left(L), right(R), canvas:
*
* +-----------------------------------------------+
* | T |
* ----|---------------------------------------|----
* | | | |
* | | | |
* | L | CANVAS SLOT | R |
* | | | |
* | | | |
* ----|---------------------------------------|----
* | B |
* +-----------------------------------------------+
*
*/
const VizLayoutRenderer: LayoutRendererComponent<VizLayoutSlots> = ({ slots, refs, width, height }) => {
return (
<div
className={css`
height: ${height}px;
width: ${width}px;
display: flex;
flex-grow: 1;
flex-direction: column;
`}
>
{slots.top && (
<div
ref={refs.top}
className={css`
width: 100%;
max-height: 35%;
align-self: top;
`}
>
<CustomScrollbar>{slots.top}</CustomScrollbar>
</div>
)}
{(slots.left || slots.right || slots.canvas) && (
<div
className={css`
label: INNER;
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
`}
>
{slots.left && (
<div
ref={refs.left}
className={css`
max-height: 100%;
`}
>
<CustomScrollbar>{slots.left}</CustomScrollbar>
</div>
)}
{slots.canvas && <div>{slots.canvas}</div>}
{slots.right && (
<div
ref={refs.right}
className={css`
max-height: 100%;
`}
>
<CustomScrollbar>{slots.right}</CustomScrollbar>
</div>
)}
</div>
)}
{slots.bottom && (
<div
ref={refs.bottom}
className={css`
width: 100%;
max-height: 35%;
`}
>
<CustomScrollbar>{slots.bottom}</CustomScrollbar>
</div>
)}
</div>
);
};
export const VizLayout: React.FC<VizLayoutProps> = ({ children, width, height }) => {
/**
* Layout slots refs & bboxes
* Refs are passed down to the renderer component by layout builder
* It's up to the renderer to assign refs to correct slots(which are underlying DOM elements)
* */
const [bottomSlotRef, bottomSlotBBox] = useMeasure();
const [topSlotRef, topSlotBBox] = useMeasure();
const [leftSlotRef, leftSlotBBox] = useMeasure();
const [rightSlotRef, rightSlotBBox] = useMeasure();
const [canvasSlotRef, canvasSlotBBox] = useMeasure();
// public fluent API exposed via render prop to build the layout
const builder = useMemo(
() =>
new LayoutBuilder(
VizLayoutRenderer,
{
top: topSlotRef,
bottom: bottomSlotRef,
left: leftSlotRef,
right: rightSlotRef,
canvas: canvasSlotRef,
},
width,
height
),
[bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height]
);
// memoized map of layout slot bboxes, used for exposing correct bboxes when the layout is ready
const bboxMap = useMemo(
() => ({
top: topSlotBBox,
bottom: bottomSlotBBox,
left: leftSlotBBox,
right: rightSlotBBox,
canvas: canvasSlotBBox,
}),
[bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox]
);
const [dimensions, setDimensions] = useState<VizLayoutState>(DEFAULT_VIZ_LAYOUT_STATE);
// when DOM settles we set the layout to be ready to get measurements downstream
useLayoutEffect(() => {
// layout is ready by now
const currentLayout = builder.getLayout();
// map active layout slots to corresponding bboxes
let nextDimensions: Partial<Record<VizLayoutSlots, UseMeasureRect>> = {};
for (const key of Object.keys(currentLayout)) {
nextDimensions[key as VizLayoutSlots] = bboxMap[key as VizLayoutSlots];
}
const nextState = {
// first, reset all bboxes to defaults
...DEFAULT_VIZ_LAYOUT_STATE,
// set layout to ready
isReady: true,
// update state with active slot bboxes
...nextDimensions,
};
setDimensions(nextState);
}, [bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height]);
// returns current state of the layout, bounding rects of all slots to be rendered
const getLayout = useCallback(() => {
return dimensions;
}, [dimensions]);
return (
<div
className={css`
label: PanelVizLayout;
width: ${width}px;
height: ${height}px;
overflow: hidden;
`}
>
{children({
builder: builder,
getLayout,
})}
</div>
);
};

View File

@ -4,6 +4,7 @@ import { GraphPanel } from './GraphPanel';
import { Options } from './types';
export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPanel)
.setNoPadding()
.useFieldConfig({
standardOptions: {
[FieldConfigProperty.Color]: {
@ -62,7 +63,7 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane
.addSliderInput({
path: 'fill.alpha',
name: 'Fill area opacity',
defaultValue: 0.1,
defaultValue: 0,
settings: {
min: 0,
max: 1,
@ -161,8 +162,6 @@ export const plugin = new PanelPlugin<Options, GraphCustomFieldConfig>(GraphPane
defaultValue: 'bottom',
settings: {
options: [
{ value: 'left', label: 'Left' },
{ value: 'top', label: 'Top' },
{ value: 'bottom', label: 'Bottom' },
{ value: 'right', label: 'Right' },
],

View File

@ -1,5 +1,5 @@
import { DataFrame, DataFrameView, dateTimeFormat, systemDateFormats, TimeZone } from '@grafana/data';
import { EventsCanvas, usePlotContext, usePlotPluginContext, useTheme } from '@grafana/ui';
import { EventsCanvas, usePlotContext, useTheme } from '@grafana/ui';
import React, { useCallback, useEffect, useRef } from 'react';
import { AnnotationMarker } from './AnnotationMarker';
@ -17,7 +17,6 @@ interface AnnotationsDataFrameViewDTO {
export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotations, timeZone }) => {
const pluginId = 'AnnotationsPlugin';
const plotCtx = usePlotContext();
const pluginsApi = usePlotPluginContext();
const theme = useTheme();
const annotationsRef = useRef<Array<DataFrameView<AnnotationsDataFrameViewDTO>>>();
@ -45,7 +44,7 @@ export const AnnotationsPlugin: React.FC<AnnotationsPluginProps> = ({ annotation
}, [plotCtx.isPlotReady, annotations]);
useEffect(() => {
const unregister = pluginsApi.registerPlugin({
const unregister = plotCtx.registerPlugin({
id: pluginId,
hooks: {
// Render annotation lines on the canvas

View File

@ -1,7 +1,5 @@
import { LegendOptions, GraphTooltipOptions } from '@grafana/ui';
export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right';
export interface GraphOptions {
// Redraw as time passes
realTimeUpdates?: boolean;
@ -9,10 +7,7 @@ export interface GraphOptions {
export interface Options {
graph: GraphOptions;
legend: Omit<LegendOptions, 'placement'> &
GraphLegendEditorLegendOptions & {
placement: LegendPlacement;
};
legend: LegendOptions;
tooltipOptions: GraphTooltipOptions;
}

View File

@ -1,49 +0,0 @@
import { useState, useMemo } from 'react';
import { useIsomorphicLayoutEffect } from 'react-use';
export type UseMeasureRect = Pick<
DOMRectReadOnly,
'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'
>;
export type UseMeasureRef<E extends HTMLElement = HTMLElement> = (element: E) => void;
export type UseMeasureResult<E extends HTMLElement = HTMLElement> = [UseMeasureRef<E>, UseMeasureRect];
const defaultState: UseMeasureRect = {
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
left: 0,
bottom: 0,
right: 0,
};
export const useMeasure = <E extends HTMLElement = HTMLElement>(): UseMeasureResult<E> => {
const [element, ref] = useState<E | null>(null);
const [rect, setRect] = useState<UseMeasureRect>(defaultState);
const observer = useMemo(
() =>
new (window as any).ResizeObserver((entries: any) => {
if (entries[0]) {
const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect;
setRect({ x, y, width, height, top, left, bottom, right });
}
}),
[]
);
useIsomorphicLayoutEffect(() => {
if (!element) {
setRect(defaultState);
return;
}
observer.observe(element);
return () => {
observer.disconnect();
};
}, [element]);
return [ref, rect];
};

View File

@ -12024,16 +12024,16 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
emittery@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451"
integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ==
emitter-component@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
emittery@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.7.1.tgz#c02375a927a40948c0345cc903072597f5270451"
integrity sha512-d34LN4L6h18Bzz9xpoku2nPwKxCPlPMr3EEKTkoEBi+1/+b0lcRkRJ1UVyyZaKNeqGR3swcGl6s390DNO4YVgQ==
"emoji-regex@>=6.0.0 <=6.1.1":
version "6.1.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.1.1.tgz#c6cd0ec1b0642e2a3c67a1137efc5e796da4f88e"
@ -18794,6 +18794,11 @@ moment@*, moment@2.24.0, moment@2.x, "moment@>= 2.9.0", moment@>=2.14.0, moment@
resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b"
integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg==
moment@^2.20.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
moment@^2.27.0:
version "2.27.0"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
@ -26485,10 +26490,10 @@ update-notifier@^2.5.0:
semver-diff "^2.0.0"
xdg-basedir "^3.0.0"
uplot@1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.2.2.tgz#b8876ab55c8a76fff81673b4b48fa5f76c4d9d2b"
integrity sha512-FiUCvD0QB+y0YGGtzTYhvaGktsddxiIFMSRScEsd97aasfnAGhIvs6aShbaB6/TZpKa6X1qVzFWuNgwnzaWBcg==
uplot@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/uplot/-/uplot-1.3.0.tgz#c805e632c9dc0a2f47041fa0431996cbb42de83a"
integrity sha512-15EIwgOYdTeX6YXRJK6u3sq/gtFFa8ICdQROTeQBStmekhGgl8MixhL6pO66pmxPuzaJUrfIa+o5gvzttMF5rw==
upper-case-first@^1.1.0, upper-case-first@^1.1.2:
version "1.1.2"