mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
76f4c11430
commit
71fffcb17c
@ -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
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -10,3 +10,4 @@ export {
|
||||
standardTransformersRegistry,
|
||||
} from './standardTransformersRegistry';
|
||||
export { RegexpOrNamesMatcherOptions } from './matchers/nameMatcher';
|
||||
export { outerJoinDataFrames } from './transformers/seriesToColumns';
|
||||
|
@ -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++) {
|
||||
|
@ -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;
|
||||
|
@ -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",
|
||||
|
@ -41,10 +41,10 @@ const getStoriesKnobs = (isList = false) => {
|
||||
const legendPlacement = select<LegendPlacement>(
|
||||
'Legend placement',
|
||||
{
|
||||
under: 'under',
|
||||
bottom: 'bottom',
|
||||
right: 'right',
|
||||
},
|
||||
'under'
|
||||
'bottom'
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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'};
|
||||
`,
|
||||
}));
|
||||
|
||||
|
53
packages/grafana-ui/src/components/GraphNG/GraphNG.story.tsx
Normal file
53
packages/grafana-ui/src/components/GraphNG/GraphNG.story.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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!);
|
||||
};
|
||||
|
@ -53,10 +53,10 @@ const getStoriesKnobs = (table = false) => {
|
||||
const legendPlacement = select<LegendPlacement>(
|
||||
'Legend placement',
|
||||
{
|
||||
under: 'under',
|
||||
bottom: 'bottom',
|
||||
right: 'right',
|
||||
},
|
||||
'under'
|
||||
'bottom'
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -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 {}
|
||||
|
||||
|
@ -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} />
|
||||
|
@ -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 = {
|
||||
|
@ -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':
|
||||
|
@ -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';
|
||||
|
@ -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';
|
@ -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);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -78,8 +78,9 @@ export const usePlotConfigContext = (): PlotConfigContextType => {
|
||||
const ctx = usePlotContext();
|
||||
|
||||
if (!ctx) {
|
||||
throwWhenNoContext('usePlotPluginContext');
|
||||
throwWhenNoContext('usePlotConfigContext');
|
||||
}
|
||||
|
||||
return {
|
||||
addSeries: ctx!.addSeries,
|
||||
addAxis: ctx!.addAxis,
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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[]>([{}]);
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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 => {
|
||||
|
@ -4,4 +4,3 @@ export { ZoomPlugin } from './ZoomPlugin';
|
||||
export { AnnotationsEditorPlugin } from './AnnotationsEditorPlugin';
|
||||
export { ContextMenuPlugin } from './ContextMenuPlugin';
|
||||
export { TooltipPlugin } from './TooltipPlugin';
|
||||
export { LegendPlugin } from './LegendPlugin';
|
||||
|
@ -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++;
|
||||
|
14
packages/grafana-ui/src/utils/storybook/data.ts
Normal file
14
packages/grafana-ui/src/utils/storybook/data.ts
Normal 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),
|
||||
});
|
||||
}
|
@ -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}
|
||||
|
@ -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 };
|
||||
|
@ -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} />
|
||||
|
@ -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' },
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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' },
|
||||
],
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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];
|
||||
};
|
23
yarn.lock
23
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user