mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
NodeGraph: Exploration mode (#33623)
* Add exploration option to node layout * Add hidden node count * Add grid layout option * Fix panning bounds calculation * Add legend with sorting * Allow sorting on any stats or arc value * Fix merge * Make sorting better * Reset focused node on layout change * Refactor limit hook a bit * Disable selected layout button * Don't show markers if only 1 node is hidden * Move legend to the bottom * Fix text backgrounds * Add show in graph layout action in grid layout * Center view on the focused node, fix perf issue when expanding big graph * Limit the node counting * Comment and linting fixes * Bit of code cleanup and comments * Add state for computing layout * Prevent computing map with partial data * Add rollup plugin for worker * Add rollup plugin for worker * Enhance data from worker * Fix perf issues with reduce and object creation * Improve comment * Fix tests * Css fixes * Remove worker plugin * Add comments * Fix test * Add test for exploration * Add test switching to grid layout * Apply suggestions from code review Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com> * Remove unused plugin * Fix function name * Remove unused rollup plugin * Review fixes * Fix context menu shown on layout change * Make buttons bigger * Moved NodeGraph to core grafana Co-authored-by: Zoltán Bedi <zoltan.bedi@gmail.com>
This commit is contained in:
parent
290e00cb6f
commit
fdd6620d0a
@ -208,6 +208,7 @@
|
|||||||
"webpack-cli": "3.3.10",
|
"webpack-cli": "3.3.10",
|
||||||
"webpack-dev-server": "3.11.1",
|
"webpack-dev-server": "3.11.1",
|
||||||
"webpack-merge": "4.2.2",
|
"webpack-merge": "4.2.2",
|
||||||
|
"worker-loader": "^3.0.8",
|
||||||
"zone.js": "0.7.8"
|
"zone.js": "0.7.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -10,6 +10,7 @@ export * from './object';
|
|||||||
export * from './namedColorsPalette';
|
export * from './namedColorsPalette';
|
||||||
export * from './series';
|
export * from './series';
|
||||||
export * from './binaryOperators';
|
export * from './binaryOperators';
|
||||||
|
export * from './nodeGraph';
|
||||||
export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders';
|
export { PanelOptionsEditorBuilder, FieldConfigEditorBuilder } from './OptionsUIBuilders';
|
||||||
export { arrayUtils };
|
export { arrayUtils };
|
||||||
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
export { getFlotPairs, getFlotPairsConstant } from './flotPairs';
|
||||||
|
12
packages/grafana-data/src/utils/nodeGraph.ts
Normal file
12
packages/grafana-data/src/utils/nodeGraph.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export enum NodeGraphDataFrameFieldNames {
|
||||||
|
id = 'id',
|
||||||
|
title = 'title',
|
||||||
|
subTitle = 'subTitle',
|
||||||
|
mainStat = 'mainStat',
|
||||||
|
secondaryStat = 'secondaryStat',
|
||||||
|
source = 'source',
|
||||||
|
target = 'target',
|
||||||
|
detail = 'detail__',
|
||||||
|
arc = 'arc__',
|
||||||
|
color = 'color',
|
||||||
|
}
|
@ -26,17 +26,17 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "11.1.5",
|
|
||||||
"@emotion/css": "11.1.3",
|
"@emotion/css": "11.1.3",
|
||||||
|
"@emotion/react": "11.1.5",
|
||||||
|
"@grafana/aws-sdk": "0.0.3",
|
||||||
"@grafana/data": "7.5.0-pre.0",
|
"@grafana/data": "7.5.0-pre.0",
|
||||||
"@grafana/e2e-selectors": "7.5.0-pre.0",
|
"@grafana/e2e-selectors": "7.5.0-pre.0",
|
||||||
"@grafana/slate-react": "0.22.10-grafana",
|
"@grafana/slate-react": "0.22.10-grafana",
|
||||||
"@grafana/tsconfig": "^1.0.0-rc1",
|
"@grafana/tsconfig": "^1.0.0-rc1",
|
||||||
"@grafana/aws-sdk": "0.0.3",
|
"@monaco-editor/react": "4.1.1",
|
||||||
"@popperjs/core": "2.5.4",
|
"@popperjs/core": "2.5.4",
|
||||||
"@sentry/browser": "5.25.0",
|
"@sentry/browser": "5.25.0",
|
||||||
"@testing-library/jest-dom": "5.11.9",
|
"@testing-library/jest-dom": "5.11.9",
|
||||||
"react-select": "4.3.0",
|
|
||||||
"@types/hoist-non-react-statics": "3.3.1",
|
"@types/hoist-non-react-statics": "3.3.1",
|
||||||
"@types/react-beautiful-dnd": "12.1.2",
|
"@types/react-beautiful-dnd": "12.1.2",
|
||||||
"@types/react-color": "3.0.1",
|
"@types/react-color": "3.0.1",
|
||||||
@ -49,7 +49,6 @@
|
|||||||
"@visx/scale": "1.4.0",
|
"@visx/scale": "1.4.0",
|
||||||
"@visx/shape": "1.4.0",
|
"@visx/shape": "1.4.0",
|
||||||
"@visx/tooltip": "1.7.2",
|
"@visx/tooltip": "1.7.2",
|
||||||
"react-router-dom": "^5.2.0",
|
|
||||||
"classnames": "2.2.6",
|
"classnames": "2.2.6",
|
||||||
"d3": "5.15.0",
|
"d3": "5.15.0",
|
||||||
"hoist-non-react-statics": "3.3.2",
|
"hoist-non-react-statics": "3.3.2",
|
||||||
@ -57,7 +56,6 @@
|
|||||||
"jquery": "3.5.1",
|
"jquery": "3.5.1",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"moment": "2.24.0",
|
"moment": "2.24.0",
|
||||||
"@monaco-editor/react": "4.1.1",
|
|
||||||
"monaco-editor": "0.21.2",
|
"monaco-editor": "0.21.2",
|
||||||
"papaparse": "5.3.0",
|
"papaparse": "5.3.0",
|
||||||
"rc-cascader": "1.0.1",
|
"rc-cascader": "1.0.1",
|
||||||
@ -73,6 +71,8 @@
|
|||||||
"react-highlight-words": "0.16.0",
|
"react-highlight-words": "0.16.0",
|
||||||
"react-hook-form": "7.2.3",
|
"react-hook-form": "7.2.3",
|
||||||
"react-popper": "2.2.4",
|
"react-popper": "2.2.4",
|
||||||
|
"react-router-dom": "^5.2.0",
|
||||||
|
"react-select": "4.3.0",
|
||||||
"react-storybook-addon-props-combinations": "1.1.0",
|
"react-storybook-addon-props-combinations": "1.1.0",
|
||||||
"react-table": "7.0.0",
|
"react-table": "7.0.0",
|
||||||
"react-transition-group": "4.4.1",
|
"react-transition-group": "4.4.1",
|
||||||
@ -89,7 +89,6 @@
|
|||||||
"@storybook/addon-storysource": "6.2.7",
|
"@storybook/addon-storysource": "6.2.7",
|
||||||
"@storybook/react": "6.2.7",
|
"@storybook/react": "6.2.7",
|
||||||
"@storybook/theming": "6.2.7",
|
"@storybook/theming": "6.2.7",
|
||||||
"@types/react-router-dom": "^5.1.7",
|
|
||||||
"@types/classnames": "2.2.7",
|
"@types/classnames": "2.2.7",
|
||||||
"@types/common-tags": "^1.8.0",
|
"@types/common-tags": "^1.8.0",
|
||||||
"@types/d3": "5.7.2",
|
"@types/d3": "5.7.2",
|
||||||
@ -101,6 +100,7 @@
|
|||||||
"@types/papaparse": "5.2.0",
|
"@types/papaparse": "5.2.0",
|
||||||
"@types/react": "16.9.9",
|
"@types/react": "16.9.9",
|
||||||
"@types/react-custom-scrollbars": "4.0.5",
|
"@types/react-custom-scrollbars": "4.0.5",
|
||||||
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"@types/react-test-renderer": "16.9.2",
|
"@types/react-test-renderer": "16.9.2",
|
||||||
"@types/react-transition-group": "4.4.0",
|
"@types/react-transition-group": "4.4.0",
|
||||||
"@types/rollup-plugin-visualizer": "2.6.0",
|
"@types/rollup-plugin-visualizer": "2.6.0",
|
||||||
|
@ -1,2 +0,0 @@
|
|||||||
export { NodeGraph } from './NodeGraph';
|
|
||||||
export { DataFrameFieldNames as NodeGraphDataFrameFieldNames } from './utils';
|
|
@ -1,213 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force';
|
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
|
||||||
|
|
||||||
export interface Config {
|
|
||||||
linkDistance: number;
|
|
||||||
linkStrength: number;
|
|
||||||
forceX: number;
|
|
||||||
forceXStrength: number;
|
|
||||||
forceCollide: number;
|
|
||||||
tick: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultConfig: Config = {
|
|
||||||
linkDistance: 150,
|
|
||||||
linkStrength: 0.5,
|
|
||||||
forceX: 2000,
|
|
||||||
forceXStrength: 0.02,
|
|
||||||
forceCollide: 100,
|
|
||||||
tick: 300,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props
|
|
||||||
* in edges from string ids to actual nodes.
|
|
||||||
* TODO: the typing could probably be done better so it's clear that props are filled in after the layout
|
|
||||||
*/
|
|
||||||
export function useLayout(
|
|
||||||
rawNodes: NodeDatum[],
|
|
||||||
rawEdges: EdgeDatum[],
|
|
||||||
config: Config = defaultConfig
|
|
||||||
): { bounds: Bounds; nodes: NodeDatum[]; edges: EdgeDatum[] } {
|
|
||||||
const [nodes, setNodes] = useState<NodeDatum[]>([]);
|
|
||||||
const [edges, setEdges] = useState<EdgeDatum[]>([]);
|
|
||||||
|
|
||||||
// TODO the use effect is probably not needed here right now, but may make sense later if we decide to move the layout
|
|
||||||
// to webworker or just postpone until other things are rendered. Also right now it memoizes this for us.
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawNodes.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// d3 just modifies the nodes directly, so lets make sure we don't leak that outside
|
|
||||||
const rawNodesCopy = rawNodes.map((n) => ({ ...n }));
|
|
||||||
const rawEdgesCopy = rawEdges.map((e) => ({ ...e }));
|
|
||||||
|
|
||||||
// Start withs some hardcoded positions so it starts laid out from left to right
|
|
||||||
let { roots, secondLevelRoots } = initializePositions(rawNodesCopy, rawEdgesCopy);
|
|
||||||
|
|
||||||
// There always seems to be one or more root nodes each with single edge and we want to have them static on the
|
|
||||||
// left neatly in something like grid layout
|
|
||||||
[...roots, ...secondLevelRoots].forEach((n, index) => {
|
|
||||||
n.fx = n.x;
|
|
||||||
});
|
|
||||||
|
|
||||||
const simulation = forceSimulation(rawNodesCopy)
|
|
||||||
.force(
|
|
||||||
'link',
|
|
||||||
forceLink(rawEdgesCopy)
|
|
||||||
.id((d: any) => d.id)
|
|
||||||
.distance(config.linkDistance)
|
|
||||||
.strength(config.linkStrength)
|
|
||||||
)
|
|
||||||
// to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will
|
|
||||||
// apply only to non root nodes
|
|
||||||
.force('x', forceX(config.forceX).strength(config.forceXStrength))
|
|
||||||
// Make sure nodes don't overlap
|
|
||||||
.force('collide', forceCollide(config.forceCollide));
|
|
||||||
|
|
||||||
// 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first
|
|
||||||
// few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay
|
|
||||||
simulation.tick(config.tick);
|
|
||||||
simulation.stop();
|
|
||||||
|
|
||||||
// We do centering here instead of using centering force to keep this more stable
|
|
||||||
centerNodes(rawNodesCopy);
|
|
||||||
setNodes(rawNodesCopy);
|
|
||||||
setEdges(rawEdgesCopy);
|
|
||||||
}, [config, rawNodes, rawEdges]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
bounds: graphBounds(nodes) /* momeoize? loops over all nodes every time and we do it 2 times */,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left
|
|
||||||
* to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a
|
|
||||||
* way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on
|
|
||||||
* than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat
|
|
||||||
* organisation.
|
|
||||||
*
|
|
||||||
* This function directly modifies the nodes given and only returns references to root nodes so they do not have to be
|
|
||||||
* found again later on.
|
|
||||||
*
|
|
||||||
* How the spacing could look like approximately:
|
|
||||||
* 0 - 0 - 0 - 0
|
|
||||||
* \- 0 - 0 |
|
|
||||||
* \- 0 -/
|
|
||||||
* 0 - 0 -/
|
|
||||||
*/
|
|
||||||
function initializePositions(
|
|
||||||
nodes: NodeDatum[],
|
|
||||||
edges: EdgeDatum[]
|
|
||||||
): { roots: NodeDatum[]; secondLevelRoots: NodeDatum[] } {
|
|
||||||
// To prevent going in cycles
|
|
||||||
const alreadyPositioned: { [id: string]: boolean } = {};
|
|
||||||
|
|
||||||
const nodesMap = nodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {} as Record<string, NodeDatum>);
|
|
||||||
const edgesMap = edges.reduce((acc, edge) => {
|
|
||||||
const sourceId = edge.source as number;
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[sourceId]: [...(acc[sourceId] || []), edge],
|
|
||||||
};
|
|
||||||
}, {} as Record<string, EdgeDatum[]>);
|
|
||||||
|
|
||||||
let roots = nodes.filter((n) => n.incoming === 0);
|
|
||||||
|
|
||||||
let secondLevelRoots = roots.reduce<NodeDatum[]>(
|
|
||||||
(acc, r) => [...acc, ...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target as number]) : [])],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const rootYSpacing = 300;
|
|
||||||
const nodeYSpacing = 200;
|
|
||||||
const nodeXSpacing = 200;
|
|
||||||
|
|
||||||
let rootY = 0;
|
|
||||||
for (const root of roots) {
|
|
||||||
let graphLevel = [root];
|
|
||||||
let x = 0;
|
|
||||||
while (graphLevel.length > 0) {
|
|
||||||
const nextGraphLevel: NodeDatum[] = [];
|
|
||||||
let y = rootY;
|
|
||||||
for (const node of graphLevel) {
|
|
||||||
if (alreadyPositioned[node.id]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Initialize positions based on the spacing in the grid
|
|
||||||
node.x = x;
|
|
||||||
node.y = y;
|
|
||||||
alreadyPositioned[node.id] = true;
|
|
||||||
|
|
||||||
// Move to next Y position for next node
|
|
||||||
y += nodeYSpacing;
|
|
||||||
if (edgesMap[node.id]) {
|
|
||||||
nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target as number]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
graphLevel = nextGraphLevel;
|
|
||||||
// Move to next X position for next level
|
|
||||||
x += nodeXSpacing;
|
|
||||||
// Reset Y back to baseline for this root
|
|
||||||
y = rootY;
|
|
||||||
}
|
|
||||||
rootY += rootYSpacing;
|
|
||||||
}
|
|
||||||
return { roots, secondLevelRoots };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates.
|
|
||||||
* Modifies the nodes directly.
|
|
||||||
*/
|
|
||||||
function centerNodes(nodes: NodeDatum[]) {
|
|
||||||
const bounds = graphBounds(nodes);
|
|
||||||
const middleY = bounds.top + (bounds.bottom - bounds.top) / 2;
|
|
||||||
const middleX = bounds.left + (bounds.right - bounds.left) / 2;
|
|
||||||
|
|
||||||
for (let node of nodes) {
|
|
||||||
node.x = node.x! - middleX;
|
|
||||||
node.y = node.y! - middleY;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Bounds {
|
|
||||||
top: number;
|
|
||||||
right: number;
|
|
||||||
bottom: number;
|
|
||||||
left: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get bounds of the graph meaning the extent of the nodes in all directions.
|
|
||||||
*/
|
|
||||||
function graphBounds(nodes: NodeDatum[]): Bounds {
|
|
||||||
if (nodes.length === 0) {
|
|
||||||
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes.reduce(
|
|
||||||
(acc, node) => {
|
|
||||||
if (node.x! > acc.right) {
|
|
||||||
acc.right = node.x!;
|
|
||||||
}
|
|
||||||
if (node.x! < acc.left) {
|
|
||||||
acc.left = node.x!;
|
|
||||||
}
|
|
||||||
if (node.y! > acc.bottom) {
|
|
||||||
acc.bottom = node.y!;
|
|
||||||
}
|
|
||||||
if (node.y! < acc.top) {
|
|
||||||
acc.top = node.y!;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
|
|
||||||
|
|
||||||
export type NodeDatum = SimulationNodeDatum & {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
subTitle: string;
|
|
||||||
dataFrameRowIndex: number;
|
|
||||||
incoming: number;
|
|
||||||
mainStat: string;
|
|
||||||
secondaryStat: string;
|
|
||||||
arcSections: Array<{
|
|
||||||
value: number;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
export type EdgeDatum = SimulationLinkDatum<NodeDatum> & {
|
|
||||||
id: string;
|
|
||||||
mainStat: string;
|
|
||||||
secondaryStat: string;
|
|
||||||
dataFrameRowIndex: number;
|
|
||||||
};
|
|
@ -1,50 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Limits the number of nodes by going from the roots breadth first until we have desired number of nodes.
|
|
||||||
* TODO: there is some possible perf gains as some of the processing is the same as in layout and so we do double
|
|
||||||
* the work.
|
|
||||||
*/
|
|
||||||
export function useNodeLimit(
|
|
||||||
nodes: NodeDatum[],
|
|
||||||
edges: EdgeDatum[],
|
|
||||||
limit: number
|
|
||||||
): { nodes: NodeDatum[]; edges: EdgeDatum[] } {
|
|
||||||
return useMemo(() => {
|
|
||||||
if (nodes.length <= limit) {
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
const edgesMap = edges.reduce<{ [id: string]: EdgeDatum[] }>((acc, e) => {
|
|
||||||
const sourceId = e.source as string;
|
|
||||||
return {
|
|
||||||
...acc,
|
|
||||||
[sourceId]: [...(acc[sourceId] || []), e],
|
|
||||||
};
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
const nodesMap = nodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {} as Record<string, NodeDatum>);
|
|
||||||
|
|
||||||
let roots = nodes.filter((n) => n.incoming === 0);
|
|
||||||
const newNodes: Record<string, NodeDatum> = {};
|
|
||||||
const stack = [...roots];
|
|
||||||
|
|
||||||
while (Object.keys(newNodes).length < limit && stack.length > 0) {
|
|
||||||
let current = stack.shift()!;
|
|
||||||
if (newNodes[current!.id]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
newNodes[current.id] = current;
|
|
||||||
const edges = edgesMap[current.id] || [];
|
|
||||||
for (const edge of edges) {
|
|
||||||
stack.push(nodesMap[edge.target as string]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newEdges = edges.filter((e) => newNodes[e.source as string] && newNodes[e.target as string]);
|
|
||||||
|
|
||||||
return { nodes: Object.values(newNodes), edges: newEdges };
|
|
||||||
}, [edges, limit, nodes]);
|
|
||||||
}
|
|
@ -1,113 +0,0 @@
|
|||||||
import { createTheme } from '@grafana/data';
|
|
||||||
import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils';
|
|
||||||
|
|
||||||
describe('processNodes', () => {
|
|
||||||
const theme = createTheme();
|
|
||||||
|
|
||||||
it('handles empty args', async () => {
|
|
||||||
expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns proper nodes and edges', async () => {
|
|
||||||
expect(
|
|
||||||
processNodes(
|
|
||||||
makeNodesDataFrame(3),
|
|
||||||
makeEdgesDataFrame([
|
|
||||||
[0, 1],
|
|
||||||
[0, 2],
|
|
||||||
[1, 2],
|
|
||||||
]),
|
|
||||||
theme
|
|
||||||
)
|
|
||||||
).toEqual({
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
arcSections: [
|
|
||||||
{
|
|
||||||
color: 'green',
|
|
||||||
value: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'red',
|
|
||||||
value: 0.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
color: 'rgb(226, 192, 61)',
|
|
||||||
dataFrameRowIndex: 0,
|
|
||||||
id: '0',
|
|
||||||
incoming: 0,
|
|
||||||
mainStat: '0.10',
|
|
||||||
secondaryStat: '2.00',
|
|
||||||
subTitle: 'service',
|
|
||||||
title: 'service:0',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
arcSections: [
|
|
||||||
{
|
|
||||||
color: 'green',
|
|
||||||
value: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'red',
|
|
||||||
value: 0.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
color: 'rgb(226, 192, 61)',
|
|
||||||
dataFrameRowIndex: 1,
|
|
||||||
id: '1',
|
|
||||||
incoming: 1,
|
|
||||||
mainStat: '0.10',
|
|
||||||
secondaryStat: '2.00',
|
|
||||||
subTitle: 'service',
|
|
||||||
title: 'service:1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
arcSections: [
|
|
||||||
{
|
|
||||||
color: 'green',
|
|
||||||
value: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
color: 'red',
|
|
||||||
value: 0.5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
color: 'rgb(226, 192, 61)',
|
|
||||||
dataFrameRowIndex: 2,
|
|
||||||
id: '2',
|
|
||||||
incoming: 2,
|
|
||||||
mainStat: '0.10',
|
|
||||||
secondaryStat: '2.00',
|
|
||||||
subTitle: 'service',
|
|
||||||
title: 'service:2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
dataFrameRowIndex: 0,
|
|
||||||
id: '0--1',
|
|
||||||
mainStat: '',
|
|
||||||
secondaryStat: '',
|
|
||||||
source: '0',
|
|
||||||
target: '1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataFrameRowIndex: 1,
|
|
||||||
id: '0--2',
|
|
||||||
mainStat: '',
|
|
||||||
secondaryStat: '',
|
|
||||||
source: '0',
|
|
||||||
target: '2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dataFrameRowIndex: 2,
|
|
||||||
id: '1--2',
|
|
||||||
mainStat: '',
|
|
||||||
secondaryStat: '',
|
|
||||||
source: '1',
|
|
||||||
target: '2',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -10,7 +10,7 @@ import { mapMouseEventToMode } from './utils';
|
|||||||
/**
|
/**
|
||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export const VizLegend: React.FunctionComponent<LegendProps> = ({
|
export function VizLegend<T>({
|
||||||
items,
|
items,
|
||||||
displayMode,
|
displayMode,
|
||||||
sortBy: sortKey,
|
sortBy: sortKey,
|
||||||
@ -20,7 +20,8 @@ export const VizLegend: React.FunctionComponent<LegendProps> = ({
|
|||||||
onToggleSort,
|
onToggleSort,
|
||||||
placement,
|
placement,
|
||||||
className,
|
className,
|
||||||
}) => {
|
itemRenderer,
|
||||||
|
}: LegendProps<T>) {
|
||||||
const { eventBus, onToggleSeriesVisibility } = usePanelContext();
|
const { eventBus, onToggleSeriesVisibility } = usePanelContext();
|
||||||
|
|
||||||
const onMouseEnter = useCallback(
|
const onMouseEnter = useCallback(
|
||||||
@ -73,7 +74,7 @@ export const VizLegend: React.FunctionComponent<LegendProps> = ({
|
|||||||
switch (displayMode) {
|
switch (displayMode) {
|
||||||
case LegendDisplayMode.Table:
|
case LegendDisplayMode.Table:
|
||||||
return (
|
return (
|
||||||
<VizLegendTable
|
<VizLegendTable<T>
|
||||||
className={className}
|
className={className}
|
||||||
items={items}
|
items={items}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
@ -83,22 +84,24 @@ export const VizLegend: React.FunctionComponent<LegendProps> = ({
|
|||||||
onToggleSort={onToggleSort}
|
onToggleSort={onToggleSort}
|
||||||
onLabelMouseEnter={onMouseEnter}
|
onLabelMouseEnter={onMouseEnter}
|
||||||
onLabelMouseOut={onMouseOut}
|
onLabelMouseOut={onMouseOut}
|
||||||
|
itemRenderer={itemRenderer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case LegendDisplayMode.List:
|
case LegendDisplayMode.List:
|
||||||
return (
|
return (
|
||||||
<VizLegendList
|
<VizLegendList<T>
|
||||||
className={className}
|
className={className}
|
||||||
items={items}
|
items={items}
|
||||||
placement={placement}
|
placement={placement}
|
||||||
onLabelMouseEnter={onMouseEnter}
|
onLabelMouseEnter={onMouseEnter}
|
||||||
onLabelMouseOut={onMouseOut}
|
onLabelMouseOut={onMouseOut}
|
||||||
onLabelClick={onLegendLabelClick}
|
onLabelClick={onLegendLabelClick}
|
||||||
|
itemRenderer={itemRenderer}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
VizLegend.displayName = 'Legend';
|
VizLegend.displayName = 'Legend';
|
||||||
|
@ -7,12 +7,12 @@ import { useStyles } from '../../themes';
|
|||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { VizLegendListItem } from './VizLegendListItem';
|
import { VizLegendListItem } from './VizLegendListItem';
|
||||||
|
|
||||||
export interface Props extends VizLegendBaseProps {}
|
export interface Props<T> extends VizLegendBaseProps<T> {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export const VizLegendList: React.FunctionComponent<Props> = ({
|
export const VizLegendList = <T extends unknown>({
|
||||||
items,
|
items,
|
||||||
itemRenderer,
|
itemRenderer,
|
||||||
onLabelMouseEnter,
|
onLabelMouseEnter,
|
||||||
@ -20,7 +20,7 @@ export const VizLegendList: React.FunctionComponent<Props> = ({
|
|||||||
onLabelClick,
|
onLabelClick,
|
||||||
placement,
|
placement,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}: Props<T>) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
|
|
||||||
if (!itemRenderer) {
|
if (!itemRenderer) {
|
||||||
@ -35,11 +35,11 @@ export const VizLegendList: React.FunctionComponent<Props> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getItemKey = (item: VizLegendItem) => `${item.getItemKey ? item.getItemKey() : item.label}`;
|
const getItemKey = (item: VizLegendItem<T>) => `${item.getItemKey ? item.getItemKey() : item.label}`;
|
||||||
|
|
||||||
switch (placement) {
|
switch (placement) {
|
||||||
case 'right': {
|
case 'right': {
|
||||||
const renderItem = (item: VizLegendItem, index: number) => {
|
const renderItem = (item: VizLegendItem<T>, index: number) => {
|
||||||
return <span className={styles.itemRight}>{itemRenderer!(item, index)}</span>;
|
return <span className={styles.itemRight}>{itemRenderer!(item, index)}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ export const VizLegendList: React.FunctionComponent<Props> = ({
|
|||||||
}
|
}
|
||||||
case 'bottom':
|
case 'bottom':
|
||||||
default: {
|
default: {
|
||||||
const renderItem = (item: VizLegendItem, index: number) => {
|
const renderItem = (item: VizLegendItem<T>, index: number) => {
|
||||||
return <span className={styles.itemBottom}>{itemRenderer!(item, index)}</span>;
|
return <span className={styles.itemBottom}>{itemRenderer!(item, index)}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -7,10 +7,10 @@ import { useStyles } from '../../themes';
|
|||||||
import { GrafanaTheme } from '@grafana/data';
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
import { selectors } from '@grafana/e2e-selectors';
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props<T> {
|
||||||
item: VizLegendItem;
|
item: VizLegendItem<T>;
|
||||||
className?: string;
|
className?: string;
|
||||||
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
onLabelClick?: (item: VizLegendItem<T>, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
}
|
}
|
||||||
@ -18,12 +18,13 @@ export interface Props {
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export const VizLegendListItem: React.FunctionComponent<Props> = ({
|
export const VizLegendListItem = <T extends unknown = any>({
|
||||||
item,
|
item,
|
||||||
onLabelClick,
|
onLabelClick,
|
||||||
onLabelMouseEnter,
|
onLabelMouseEnter,
|
||||||
onLabelMouseOut,
|
onLabelMouseOut,
|
||||||
}) => {
|
className,
|
||||||
|
}: Props<T>) => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
|
|
||||||
const onMouseEnter = useCallback(
|
const onMouseEnter = useCallback(
|
||||||
@ -54,13 +55,16 @@ export const VizLegendListItem: React.FunctionComponent<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.itemWrapper} aria-label={selectors.components.VizLegend.seriesName(item.label)}>
|
<div
|
||||||
|
className={cx(styles.itemWrapper, className)}
|
||||||
|
aria-label={selectors.components.VizLegend.seriesName(item.label)}
|
||||||
|
>
|
||||||
<VizLegendSeriesIcon seriesName={item.label} color={item.color} />
|
<VizLegendSeriesIcon seriesName={item.label} color={item.color} />
|
||||||
<div
|
<div
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
onMouseOut={onMouseOut}
|
onMouseOut={onMouseOut}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
className={cx(styles.label, item.disabled && styles.labelDisabled, onLabelClick && styles.clickable)}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</div>
|
</div>
|
||||||
@ -75,14 +79,18 @@ VizLegendListItem.displayName = 'VizLegendListItem';
|
|||||||
const getStyles = (theme: GrafanaTheme) => ({
|
const getStyles = (theme: GrafanaTheme) => ({
|
||||||
label: css`
|
label: css`
|
||||||
label: LegendLabel;
|
label: LegendLabel;
|
||||||
cursor: pointer;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
`,
|
`,
|
||||||
|
clickable: css`
|
||||||
|
label: LegendClickabel;
|
||||||
|
cursor: pointer;
|
||||||
|
`,
|
||||||
labelDisabled: css`
|
labelDisabled: css`
|
||||||
label: LegendLabelDisabled;
|
label: LegendLabelDisabled;
|
||||||
color: ${theme.colors.linkDisabled};
|
color: ${theme.colors.linkDisabled};
|
||||||
`,
|
`,
|
||||||
itemWrapper: css`
|
itemWrapper: css`
|
||||||
|
label: LegendItemWrapper;
|
||||||
display: flex;
|
display: flex;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { FC } from 'react';
|
import React from 'react';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { VizLegendTableProps } from './types';
|
import { VizLegendTableProps } from './types';
|
||||||
import { Icon } from '../Icon/Icon';
|
import { Icon } from '../Icon/Icon';
|
||||||
@ -10,7 +10,7 @@ import { GrafanaTheme } from '@grafana/data';
|
|||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
export const VizLegendTable: FC<VizLegendTableProps> = ({
|
export const VizLegendTable = <T extends unknown>({
|
||||||
items,
|
items,
|
||||||
sortBy: sortKey,
|
sortBy: sortKey,
|
||||||
sortDesc,
|
sortDesc,
|
||||||
@ -20,7 +20,7 @@ export const VizLegendTable: FC<VizLegendTableProps> = ({
|
|||||||
onLabelClick,
|
onLabelClick,
|
||||||
onLabelMouseEnter,
|
onLabelMouseEnter,
|
||||||
onLabelMouseOut,
|
onLabelMouseOut,
|
||||||
}) => {
|
}: VizLegendTableProps<T>): JSX.Element => {
|
||||||
const styles = useStyles(getStyles);
|
const styles = useStyles(getStyles);
|
||||||
|
|
||||||
const columns = items
|
const columns = items
|
||||||
|
@ -7,28 +7,28 @@ export enum SeriesVisibilityChangeBehavior {
|
|||||||
Hide,
|
Hide,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VizLegendBaseProps {
|
export interface VizLegendBaseProps<T> {
|
||||||
placement: LegendPlacement;
|
placement: LegendPlacement;
|
||||||
className?: string;
|
className?: string;
|
||||||
items: VizLegendItem[];
|
items: Array<VizLegendItem<T>>;
|
||||||
seriesVisibilityChangeBehavior?: SeriesVisibilityChangeBehavior;
|
seriesVisibilityChangeBehavior?: SeriesVisibilityChangeBehavior;
|
||||||
onLabelClick?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
onLabelClick?: (item: VizLegendItem<T>, event: React.MouseEvent<HTMLElement>) => void;
|
||||||
itemRenderer?: (item: VizLegendItem, index: number) => JSX.Element;
|
itemRenderer?: (item: VizLegendItem<T>, index: number) => JSX.Element;
|
||||||
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
onLabelMouseEnter?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
||||||
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
onLabelMouseOut?: (item: VizLegendItem, event: React.MouseEvent<HTMLElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VizLegendTableProps extends VizLegendBaseProps {
|
export interface VizLegendTableProps<T> extends VizLegendBaseProps<T> {
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortDesc?: boolean;
|
sortDesc?: boolean;
|
||||||
onToggleSort?: (sortBy: string) => void;
|
onToggleSort?: (sortBy: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegendProps extends VizLegendBaseProps, VizLegendTableProps {
|
export interface LegendProps<T = any> extends VizLegendBaseProps<T>, VizLegendTableProps<T> {
|
||||||
displayMode: LegendDisplayMode;
|
displayMode: LegendDisplayMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VizLegendItem {
|
export interface VizLegendItem<T = any> {
|
||||||
getItemKey?: () => string;
|
getItemKey?: () => string;
|
||||||
label: string;
|
label: string;
|
||||||
color: string;
|
color: string;
|
||||||
@ -37,4 +37,5 @@ export interface VizLegendItem {
|
|||||||
// displayValues?: DisplayValue[];
|
// displayValues?: DisplayValue[];
|
||||||
getDisplayValues?: () => DisplayValue[];
|
getDisplayValues?: () => DisplayValue[];
|
||||||
fieldIndex?: DataFrameFieldIndex;
|
fieldIndex?: DataFrameFieldIndex;
|
||||||
|
data?: T;
|
||||||
}
|
}
|
||||||
|
@ -101,6 +101,7 @@ export { VizLayout, VizLayoutComponentType, VizLayoutLegendProps, VizLayoutProps
|
|||||||
export { VizLegendItem, SeriesVisibilityChangeBehavior } from './VizLegend/types';
|
export { VizLegendItem, SeriesVisibilityChangeBehavior } from './VizLegend/types';
|
||||||
export { LegendPlacement, LegendDisplayMode, VizLegendOptions } from './VizLegend/models.gen';
|
export { LegendPlacement, LegendDisplayMode, VizLegendOptions } from './VizLegend/models.gen';
|
||||||
export { VizLegend } from './VizLegend/VizLegend';
|
export { VizLegend } from './VizLegend/VizLegend';
|
||||||
|
export { VizLegendListItem } from './VizLegend/VizLegendListItem';
|
||||||
|
|
||||||
export { Alert, AlertVariant } from './Alert/Alert';
|
export { Alert, AlertVariant } from './Alert/Alert';
|
||||||
export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
|
export { GraphSeriesToggler, GraphSeriesTogglerAPI } from './Graph/GraphSeriesToggler';
|
||||||
@ -238,5 +239,4 @@ export { useGraphNGContext } from './GraphNG/hooks';
|
|||||||
export { preparePlotFrame } from './GraphNG/utils';
|
export { preparePlotFrame } from './GraphNG/utils';
|
||||||
export { GraphNGLegendEvent } from './GraphNG/types';
|
export { GraphNGLegendEvent } from './GraphNG/types';
|
||||||
export * from './PanelChrome/types';
|
export * from './PanelChrome/types';
|
||||||
export * from './NodeGraph';
|
|
||||||
export { EmotionPerfTest } from './ThemeDemos/EmotionPerfTest';
|
export { EmotionPerfTest } from './ThemeDemos/EmotionPerfTest';
|
||||||
|
@ -14,3 +14,4 @@ export { DOMUtil };
|
|||||||
export { renderOrCallToRender } from './renderOrCallToRender';
|
export { renderOrCallToRender } from './renderOrCallToRender';
|
||||||
export { createLogger } from './logger';
|
export { createLogger } from './logger';
|
||||||
export { attachDebugger } from './debug';
|
export { attachDebugger } from './debug';
|
||||||
|
export * from './nodeGraph';
|
||||||
|
15
packages/grafana-ui/src/utils/nodeGraph.ts
Normal file
15
packages/grafana-ui/src/utils/nodeGraph.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated use it from @grafana/data. Kept here for backward compatibility.
|
||||||
|
*/
|
||||||
|
export enum NodeGraphDataFrameFieldNames {
|
||||||
|
id = 'id',
|
||||||
|
title = 'title',
|
||||||
|
subTitle = 'subTitle',
|
||||||
|
mainStat = 'mainStat',
|
||||||
|
secondaryStat = 'secondaryStat',
|
||||||
|
source = 'source',
|
||||||
|
target = 'target',
|
||||||
|
detail = 'detail__',
|
||||||
|
arc = 'arc__',
|
||||||
|
color = 'color',
|
||||||
|
}
|
@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Badge, NodeGraph, Collapse } from '@grafana/ui';
|
import { Badge, Collapse } from '@grafana/ui';
|
||||||
import { DataFrame, TimeRange } from '@grafana/data';
|
import { DataFrame, TimeRange } from '@grafana/data';
|
||||||
import { ExploreId, StoreState } from '../../types';
|
import { ExploreId, StoreState } from '../../types';
|
||||||
import { splitOpen } from './state/main';
|
import { splitOpen } from './state/main';
|
||||||
import { connect, ConnectedProps } from 'react-redux';
|
import { connect, ConnectedProps } from 'react-redux';
|
||||||
import { useLinks } from './utils/links';
|
import { useLinks } from './utils/links';
|
||||||
|
import { NodeGraph } from '../../plugins/panel/nodeGraph';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
// Edges and Nodes are separate frames
|
// Edges and Nodes are separate frames
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
|
import { DataFrame, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames as Fields } from '@grafana/data';
|
||||||
import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui';
|
|
||||||
import { Span, TraceResponse } from './types';
|
import { Span, TraceResponse } from './types';
|
||||||
|
|
||||||
interface Node {
|
interface Node {
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { DataFrame, DataFrameView, FieldType, MutableDataFrame } from '@grafana/data';
|
import {
|
||||||
import { NodeGraphDataFrameFieldNames as Fields } from '@grafana/ui';
|
DataFrame,
|
||||||
|
DataFrameView,
|
||||||
|
FieldType,
|
||||||
|
MutableDataFrame,
|
||||||
|
NodeGraphDataFrameFieldNames as Fields,
|
||||||
|
} from '@grafana/data';
|
||||||
|
|
||||||
interface Row {
|
interface Row {
|
||||||
traceID: string;
|
traceID: string;
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { ArrayVector, FieldType, MutableDataFrame } from '@grafana/data';
|
import { ArrayVector, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames } from '@grafana/data';
|
||||||
import { nodes, edges } from './testData/serviceMapResponse';
|
import { nodes, edges } from './testData/serviceMapResponse';
|
||||||
import { NodeGraphDataFrameFieldNames } from '@grafana/ui';
|
|
||||||
|
|
||||||
export function generateRandomNodes(count = 10) {
|
export function generateRandomNodes(count = 10) {
|
||||||
const nodes = [];
|
const nodes = [];
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { FieldColorModeId, FieldType, PreferredVisualisationType } from '@grafana/data';
|
import { FieldColorModeId, FieldType, PreferredVisualisationType, NodeGraphDataFrameFieldNames } from '@grafana/data';
|
||||||
import { NodeGraphDataFrameFieldNames } from '@grafana/ui';
|
|
||||||
|
|
||||||
export const nodes = {
|
export const nodes = {
|
||||||
fields: [
|
fields: [
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import React, { memo } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
import { EdgeDatum, NodeDatum } from './types';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useStyles2 } from '../../themes';
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { useStyles2 } from '@grafana/ui';
|
||||||
import { shortenLine } from './utils';
|
import { shortenLine } from './utils';
|
||||||
|
|
||||||
const getStyles = (theme: GrafanaTheme2) => {
|
const getStyles = (theme: GrafanaTheme2) => {
|
88
public/app/plugins/panel/nodeGraph/Legend.tsx
Normal file
88
public/app/plugins/panel/nodeGraph/Legend.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { NodeDatum } from './types';
|
||||||
|
import { Field, FieldColorModeId, getColorForTheme, GrafanaTheme } from '@grafana/data';
|
||||||
|
import { identity } from 'lodash';
|
||||||
|
import { Config } from './layout';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { Icon, LegendDisplayMode, useStyles, useTheme, VizLegend, VizLegendItem, VizLegendListItem } from '@grafana/ui';
|
||||||
|
|
||||||
|
function getStyles() {
|
||||||
|
return {
|
||||||
|
item: css`
|
||||||
|
label: LegendItem;
|
||||||
|
flex-grow: 0;
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nodes: NodeDatum[];
|
||||||
|
onSort: (sort: Config['sort']) => void;
|
||||||
|
sort?: Config['sort'];
|
||||||
|
sortable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Legend = function Legend(props: Props) {
|
||||||
|
const { nodes, onSort, sort, sortable } = props;
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = useStyles(getStyles);
|
||||||
|
const colorItems = getColorLegendItems(nodes, theme);
|
||||||
|
|
||||||
|
const onClick = useCallback(
|
||||||
|
(item) => {
|
||||||
|
onSort({
|
||||||
|
field: item.data!.field,
|
||||||
|
ascending: item.data!.field === sort?.field ? !sort?.ascending : true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[sort, onSort]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VizLegend<ItemData>
|
||||||
|
displayMode={LegendDisplayMode.List}
|
||||||
|
placement={'bottom'}
|
||||||
|
items={colorItems}
|
||||||
|
itemRenderer={(item) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<VizLegendListItem item={item} className={styles.item} onLabelClick={sortable ? onClick : undefined} />
|
||||||
|
{sortable &&
|
||||||
|
(sort?.field === item.data!.field ? <Icon name={sort!.ascending ? 'angle-up' : 'angle-down'} /> : '')}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ItemData {
|
||||||
|
field: Field;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColorLegendItems(nodes: NodeDatum[], theme: GrafanaTheme): Array<VizLegendItem<ItemData>> {
|
||||||
|
const fields = [nodes[0].mainStat, nodes[0].secondaryStat].filter(identity) as Field[];
|
||||||
|
|
||||||
|
const node = nodes.find((n) => n.arcSections.length > 0);
|
||||||
|
if (node) {
|
||||||
|
if (node.arcSections[0]!.config?.color?.mode === FieldColorModeId.Fixed) {
|
||||||
|
// We assume in this case we have a set of fixed colors which map neatly into a basic legend.
|
||||||
|
|
||||||
|
// Lets collect and deduplicate as there isn't a requirement for 0 size arc section to be defined
|
||||||
|
fields.push(...new Set(nodes.map((n) => n.arcSections).flat()));
|
||||||
|
} else {
|
||||||
|
// TODO: probably some sort of gradient which we will have to deal with later
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields.map((f) => {
|
||||||
|
return {
|
||||||
|
label: f.config.displayName || f.name,
|
||||||
|
color: getColorForTheme(f.config.color?.fixedColor || '', theme),
|
||||||
|
yAxis: 0,
|
||||||
|
data: { field: f },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
61
public/app/plugins/panel/nodeGraph/Marker.tsx
Normal file
61
public/app/plugins/panel/nodeGraph/Marker.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import React, { MouseEvent, memo } from 'react';
|
||||||
|
import { NodesMarker } from './types';
|
||||||
|
import { GrafanaTheme } from '@grafana/data';
|
||||||
|
import { css } from 'emotion';
|
||||||
|
import { stylesFactory, useTheme } from '@grafana/ui';
|
||||||
|
|
||||||
|
const nodeR = 40;
|
||||||
|
|
||||||
|
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
||||||
|
mainGroup: css`
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
`,
|
||||||
|
|
||||||
|
mainCircle: css`
|
||||||
|
fill: ${theme.colors.panelBg};
|
||||||
|
stroke: ${theme.colors.border3};
|
||||||
|
`,
|
||||||
|
text: css`
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Marker = memo(function Marker(props: {
|
||||||
|
marker: NodesMarker;
|
||||||
|
onClick?: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void;
|
||||||
|
}) {
|
||||||
|
const { marker, onClick } = props;
|
||||||
|
const { node } = marker;
|
||||||
|
const styles = getStyles(useTheme());
|
||||||
|
|
||||||
|
if (!(node.x !== undefined && node.y !== undefined)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
data-node-id={node.id}
|
||||||
|
className={styles.mainGroup}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event, marker);
|
||||||
|
}}
|
||||||
|
aria-label={`Hidden nodes marker: ${node.id}`}
|
||||||
|
>
|
||||||
|
<circle className={styles.mainCircle} r={nodeR} cx={node.x} cy={node.y} />
|
||||||
|
<g>
|
||||||
|
<foreignObject x={node.x - 25} y={node.y - 25} width="50" height="50">
|
||||||
|
<div className={styles.text}>
|
||||||
|
{/* we limit the count to 101 so if we have more than 100 nodes we don't have exact count */}
|
||||||
|
<span>{marker.count > 100 ? '>100' : marker.count} nodes</span>
|
||||||
|
</div>
|
||||||
|
</foreignObject>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
});
|
@ -1,31 +1,32 @@
|
|||||||
import React, { MouseEvent, memo } from 'react';
|
import React, { MouseEvent, memo } from 'react';
|
||||||
import { css } from '@emotion/css';
|
|
||||||
import tinycolor from 'tinycolor2';
|
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import { getColorForTheme, GrafanaTheme } from '@grafana/data';
|
import { getColorForTheme, GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { useStyles2, useTheme } from '@grafana/ui';
|
||||||
import { NodeDatum } from './types';
|
import { NodeDatum } from './types';
|
||||||
import { stylesFactory, useTheme } from '../../themes';
|
import { css } from 'emotion';
|
||||||
|
import tinycolor from 'tinycolor2';
|
||||||
|
import { statToString } from './utils';
|
||||||
|
|
||||||
const nodeR = 40;
|
const nodeR = 40;
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
mainGroup: css`
|
mainGroup: css`
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
`,
|
`,
|
||||||
|
|
||||||
mainCircle: css`
|
mainCircle: css`
|
||||||
fill: ${theme.colors.panelBg};
|
fill: ${theme.components.panel.background};
|
||||||
`,
|
`,
|
||||||
|
|
||||||
hoverCircle: css`
|
hoverCircle: css`
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
stroke: ${theme.colors.textBlue};
|
stroke: ${theme.colors.primary.text};
|
||||||
`,
|
`,
|
||||||
|
|
||||||
text: css`
|
text: css`
|
||||||
fill: ${theme.colors.text};
|
fill: ${theme.colors.text.primary};
|
||||||
`,
|
`,
|
||||||
|
|
||||||
titleText: css`
|
titleText: css`
|
||||||
@ -33,7 +34,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
background-color: ${tinycolor(theme.colors.bodyBg).setAlpha(0.6).toHex8String()};
|
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.6).toHex8String()};
|
||||||
width: 100px;
|
width: 100px;
|
||||||
`,
|
`,
|
||||||
|
|
||||||
@ -48,10 +49,10 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|||||||
textHovering: css`
|
textHovering: css`
|
||||||
width: 200px;
|
width: 200px;
|
||||||
& span {
|
& span {
|
||||||
background-color: ${tinycolor(theme.colors.bodyBg).setAlpha(0.8).toHex8String()};
|
background-color: ${tinycolor(theme.colors.background.primary).setAlpha(0.8).toHex8String()};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
}));
|
});
|
||||||
|
|
||||||
export const Node = memo(function Node(props: {
|
export const Node = memo(function Node(props: {
|
||||||
node: NodeDatum;
|
node: NodeDatum;
|
||||||
@ -61,7 +62,7 @@ export const Node = memo(function Node(props: {
|
|||||||
hovering: boolean;
|
hovering: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
|
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
|
||||||
const styles = getStyles(useTheme());
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
if (!(node.x !== undefined && node.y !== undefined)) {
|
if (!(node.x !== undefined && node.y !== undefined)) {
|
||||||
return null;
|
return null;
|
||||||
@ -69,6 +70,7 @@ export const Node = memo(function Node(props: {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<g
|
<g
|
||||||
|
data-node-id={node.id}
|
||||||
className={styles.mainGroup}
|
className={styles.mainGroup}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
onMouseEnter(node.id);
|
onMouseEnter(node.id);
|
||||||
@ -87,9 +89,9 @@ export const Node = memo(function Node(props: {
|
|||||||
<g className={styles.text}>
|
<g className={styles.text}>
|
||||||
<foreignObject x={node.x - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="30">
|
<foreignObject x={node.x - (hovering ? 100 : 35)} y={node.y - 15} width={hovering ? '200' : '70'} height="30">
|
||||||
<div className={cx(styles.statsText, hovering && styles.textHovering)}>
|
<div className={cx(styles.statsText, hovering && styles.textHovering)}>
|
||||||
<span>{node.mainStat}</span>
|
<span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span>
|
||||||
<br />
|
<br />
|
||||||
<span>{node.secondaryStat}</span>
|
<span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span>
|
||||||
</div>
|
</div>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
<foreignObject
|
<foreignObject
|
||||||
@ -114,7 +116,7 @@ export const Node = memo(function Node(props: {
|
|||||||
*/
|
*/
|
||||||
function ColorCircle(props: { node: NodeDatum }) {
|
function ColorCircle(props: { node: NodeDatum }) {
|
||||||
const { node } = props;
|
const { node } = props;
|
||||||
const fullStat = node.arcSections.find((s) => s.value === 1);
|
const fullStat = node.arcSections.find((s) => s.values.get(node.dataFrameRowIndex) === 1);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
if (fullStat) {
|
if (fullStat) {
|
||||||
@ -122,7 +124,7 @@ function ColorCircle(props: { node: NodeDatum }) {
|
|||||||
return (
|
return (
|
||||||
<circle
|
<circle
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke={getColorForTheme(fullStat.color, theme)}
|
stroke={getColorForTheme(fullStat.config.color?.fixedColor || '', theme)}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
r={nodeR}
|
r={nodeR}
|
||||||
cx={node.x}
|
cx={node.x}
|
||||||
@ -131,7 +133,7 @@ function ColorCircle(props: { node: NodeDatum }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nonZero = node.arcSections.filter((s) => s.value !== 0);
|
const nonZero = node.arcSections.filter((s) => s.values.get(node.dataFrameRowIndex) !== 0);
|
||||||
if (nonZero.length === 0) {
|
if (nonZero.length === 0) {
|
||||||
// Fallback if no arc is defined
|
// Fallback if no arc is defined
|
||||||
return <circle fill="none" stroke={node.color} strokeWidth={2} r={nodeR} cx={node.x} cy={node.y} />;
|
return <circle fill="none" stroke={node.color} strokeWidth={2} r={nodeR} cx={node.x} cy={node.y} />;
|
||||||
@ -139,20 +141,22 @@ function ColorCircle(props: { node: NodeDatum }) {
|
|||||||
|
|
||||||
const { elements } = nonZero.reduce(
|
const { elements } = nonZero.reduce(
|
||||||
(acc, section) => {
|
(acc, section) => {
|
||||||
|
const color = section.config.color?.fixedColor || '';
|
||||||
|
const value = section.values.get(node.dataFrameRowIndex);
|
||||||
const el = (
|
const el = (
|
||||||
<ArcSection
|
<ArcSection
|
||||||
key={section.color}
|
key={color}
|
||||||
r={nodeR}
|
r={nodeR}
|
||||||
x={node.x!}
|
x={node.x!}
|
||||||
y={node.y!}
|
y={node.y!}
|
||||||
startPercent={acc.percent}
|
startPercent={acc.percent}
|
||||||
percent={section.value}
|
percent={value}
|
||||||
color={getColorForTheme(section.color, theme)}
|
color={getColorForTheme(color, theme)}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
acc.elements.push(el);
|
acc.elements.push(el);
|
||||||
acc.percent = acc.percent + section.value;
|
acc.percent = acc.percent + value;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{ elements: [] as React.ReactNode[], percent: 0 }
|
{ elements: [] as React.ReactNode[], percent: 0 }
|
@ -4,6 +4,25 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { NodeGraph } from './NodeGraph';
|
import { NodeGraph } from './NodeGraph';
|
||||||
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
import { makeEdgesDataFrame, makeNodesDataFrame } from './utils';
|
||||||
|
|
||||||
|
jest.mock('./layout.worker.js', () => {
|
||||||
|
const { layout } = jest.requireActual('./layout.worker.js');
|
||||||
|
class TestWorker {
|
||||||
|
constructor() {}
|
||||||
|
postMessage(data: any) {
|
||||||
|
const { nodes, edges, config } = data;
|
||||||
|
setTimeout(() => {
|
||||||
|
layout(nodes, edges, config);
|
||||||
|
// @ts-ignore
|
||||||
|
this.onmessage({ data: { nodes, edges } });
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
default: TestWorker,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
describe('NodeGraph', () => {
|
describe('NodeGraph', () => {
|
||||||
it('doesnt fail without any data', async () => {
|
it('doesnt fail without any data', async () => {
|
||||||
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||||
@ -13,9 +32,6 @@ describe('NodeGraph', () => {
|
|||||||
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
render(<NodeGraph dataFrames={[]} getLinks={() => []} />);
|
||||||
const zoomIn = await screen.findByTitle(/Zoom in/);
|
const zoomIn = await screen.findByTitle(/Zoom in/);
|
||||||
const zoomOut = await screen.findByTitle(/Zoom out/);
|
const zoomOut = await screen.findByTitle(/Zoom out/);
|
||||||
const zoomLevel = await screen.findByTitle(/Zoom level/);
|
|
||||||
|
|
||||||
expect(zoomLevel.textContent).toContain('1.00x');
|
|
||||||
|
|
||||||
expect(getScale()).toBe(1);
|
expect(getScale()).toBe(1);
|
||||||
userEvent.click(zoomIn);
|
userEvent.click(zoomIn);
|
||||||
@ -38,7 +54,10 @@ describe('NodeGraph', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await screen.findByLabelText('Node: service:1');
|
||||||
|
|
||||||
panView({ x: 10, y: 10 });
|
panView({ x: 10, y: 10 });
|
||||||
|
screen.debug(getSvg());
|
||||||
// Though we try to pan down 10px we are rendering in straight line 3 nodes so there are bounds preventing
|
// Though we try to pan down 10px we are rendering in straight line 3 nodes so there are bounds preventing
|
||||||
// as panning vertically
|
// as panning vertically
|
||||||
await waitFor(() => expect(getTranslate()).toEqual({ x: 10, y: 0 }));
|
await waitFor(() => expect(getTranslate()).toEqual({ x: 10, y: 0 }));
|
||||||
@ -78,7 +97,7 @@ describe('NodeGraph', () => {
|
|||||||
await screen.findByText(/Edge traces/);
|
await screen.findByText(/Edge traces/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lays out 3 nodes in single line', () => {
|
it('lays out 3 nodes in single line', async () => {
|
||||||
render(
|
render(
|
||||||
<NodeGraph
|
<NodeGraph
|
||||||
dataFrames={[
|
dataFrames={[
|
||||||
@ -92,12 +111,12 @@ describe('NodeGraph', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expectNodePositionCloseTo('service:0', { x: -221, y: 0 });
|
await expectNodePositionCloseTo('service:0', { x: -221, y: 0 });
|
||||||
expectNodePositionCloseTo('service:1', { x: -21, y: 0 });
|
await expectNodePositionCloseTo('service:1', { x: -21, y: 0 });
|
||||||
expectNodePositionCloseTo('service:2', { x: 221, y: 0 });
|
await expectNodePositionCloseTo('service:2', { x: 221, y: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lays out first children on one vertical line', () => {
|
it('lays out first children on one vertical line', async () => {
|
||||||
render(
|
render(
|
||||||
<NodeGraph
|
<NodeGraph
|
||||||
dataFrames={[
|
dataFrames={[
|
||||||
@ -112,19 +131,21 @@ describe('NodeGraph', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Should basically look like <
|
// Should basically look like <
|
||||||
expectNodePositionCloseTo('service:0', { x: -100, y: 0 });
|
await expectNodePositionCloseTo('service:0', { x: -100, y: 0 });
|
||||||
expectNodePositionCloseTo('service:1', { x: 100, y: -100 });
|
await expectNodePositionCloseTo('service:1', { x: 100, y: -100 });
|
||||||
expectNodePositionCloseTo('service:2', { x: 100, y: 100 });
|
await expectNodePositionCloseTo('service:2', { x: 100, y: 100 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('limits the number of nodes shown and shows a warning', () => {
|
it('limits the number of nodes shown and shows a warning', async () => {
|
||||||
render(
|
render(
|
||||||
<NodeGraph
|
<NodeGraph
|
||||||
dataFrames={[
|
dataFrames={[
|
||||||
makeNodesDataFrame(3),
|
makeNodesDataFrame(5),
|
||||||
makeEdgesDataFrame([
|
makeEdgesDataFrame([
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[0, 2],
|
[0, 2],
|
||||||
|
[2, 3],
|
||||||
|
[3, 4],
|
||||||
]),
|
]),
|
||||||
]}
|
]}
|
||||||
getLinks={() => []}
|
getLinks={() => []}
|
||||||
@ -132,20 +153,76 @@ describe('NodeGraph', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodes = screen.getAllByLabelText(/Node: service:\d/);
|
const nodes = await screen.findAllByLabelText(/Node: service:\d/);
|
||||||
expect(nodes.length).toBe(2);
|
expect(nodes.length).toBe(2);
|
||||||
screen.getByLabelText(/Nodes hidden warning/);
|
screen.getByLabelText(/Nodes hidden warning/);
|
||||||
|
|
||||||
|
const markers = await screen.findAllByLabelText(/Hidden nodes marker: \d/);
|
||||||
|
expect(markers.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows expanding the nodes when limiting visible nodes', async () => {
|
||||||
|
render(
|
||||||
|
<NodeGraph
|
||||||
|
dataFrames={[
|
||||||
|
makeNodesDataFrame(5),
|
||||||
|
makeEdgesDataFrame([
|
||||||
|
[0, 1],
|
||||||
|
[1, 2],
|
||||||
|
[2, 3],
|
||||||
|
[3, 4],
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
getLinks={() => []}
|
||||||
|
nodeLimit={3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = await screen.findByLabelText(/Node: service:0/);
|
||||||
|
expect(node).toBeInTheDocument();
|
||||||
|
|
||||||
|
const marker = await screen.findByLabelText(/Hidden nodes marker: 3/);
|
||||||
|
userEvent.click(marker);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(/Node: service:0/)).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Node: service:4/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const nodes = await screen.findAllByLabelText(/Node: service:\d/);
|
||||||
|
expect(nodes.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can switch to grid layout', async () => {
|
||||||
|
render(
|
||||||
|
<NodeGraph
|
||||||
|
dataFrames={[
|
||||||
|
makeNodesDataFrame(3),
|
||||||
|
makeEdgesDataFrame([
|
||||||
|
[0, 1],
|
||||||
|
[1, 2],
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
getLinks={() => []}
|
||||||
|
nodeLimit={3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = await screen.findByTitle(/Grid layout/);
|
||||||
|
userEvent.click(button);
|
||||||
|
|
||||||
|
await expectNodePositionCloseTo('service:0', { x: -180, y: -60 });
|
||||||
|
await expectNodePositionCloseTo('service:1', { x: -60, y: -60 });
|
||||||
|
await expectNodePositionCloseTo('service:2', { x: 60, y: -60 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) {
|
async function expectNodePositionCloseTo(node: string, pos: { x: number; y: number }) {
|
||||||
const nodePos = getNodeXY(node);
|
const nodePos = await getNodeXY(node);
|
||||||
expect(nodePos.x).toBeCloseTo(pos.x, -1);
|
expect(nodePos.x).toBeCloseTo(pos.x, -1);
|
||||||
expect(nodePos.y).toBeCloseTo(pos.y, -1);
|
expect(nodePos.y).toBeCloseTo(pos.y, -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNodeXY(node: string) {
|
async function getNodeXY(node: string) {
|
||||||
const group = screen.getByLabelText(new RegExp(`Node: ${node}`));
|
const group = await screen.findByLabelText(new RegExp(`Node: ${node}`));
|
||||||
const circle = getByText(group, '', { selector: 'circle' });
|
const circle = getByText(group, '', { selector: 'circle' });
|
||||||
return getXY(circle);
|
return getXY(circle);
|
||||||
}
|
}
|
@ -1,26 +1,29 @@
|
|||||||
import React, { memo, MutableRefObject, useCallback, useMemo, useState, MouseEvent } from 'react';
|
import React, { memo, MouseEvent, MutableRefObject, useCallback, useMemo, useState } from 'react';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import useMeasure from 'react-use/lib/useMeasure';
|
import useMeasure from 'react-use/lib/useMeasure';
|
||||||
|
import { Icon, Spinner, useStyles2, useTheme2 } from '@grafana/ui';
|
||||||
import { usePanning } from './usePanning';
|
import { usePanning } from './usePanning';
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
import { EdgeDatum, NodeDatum, NodesMarker } from './types';
|
||||||
import { Node } from './Node';
|
import { Node } from './Node';
|
||||||
import { Edge } from './Edge';
|
import { Edge } from './Edge';
|
||||||
import { ViewControls } from './ViewControls';
|
import { ViewControls } from './ViewControls';
|
||||||
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
|
import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data';
|
||||||
import { useZoom } from './useZoom';
|
import { useZoom } from './useZoom';
|
||||||
import { Bounds, Config, defaultConfig, useLayout } from './layout';
|
import { Config, defaultConfig, useLayout } from './layout';
|
||||||
import { EdgeArrowMarker } from './EdgeArrowMarker';
|
import { EdgeArrowMarker } from './EdgeArrowMarker';
|
||||||
import { stylesFactory, useTheme2 } from '../../themes';
|
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { useCategorizeFrames } from './useCategorizeFrames';
|
import { useCategorizeFrames } from './useCategorizeFrames';
|
||||||
import { EdgeLabel } from './EdgeLabel';
|
import { EdgeLabel } from './EdgeLabel';
|
||||||
import { useContextMenu } from './useContextMenu';
|
import { useContextMenu } from './useContextMenu';
|
||||||
import { processNodes } from './utils';
|
import { processNodes, Bounds } from './utils';
|
||||||
import { Icon } from '..';
|
import { Marker } from './Marker';
|
||||||
import { useNodeLimit } from './useNodeLimit';
|
import { Legend } from './Legend';
|
||||||
|
import { useHighlight } from './useHighlight';
|
||||||
|
import { useFocusPositionOnLayout } from './useFocusPositionOnLayout';
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
wrapper: css`
|
wrapper: css`
|
||||||
|
label: wrapper;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -28,6 +31,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
svg: css`
|
svg: css`
|
||||||
|
label: svg;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@ -36,19 +40,34 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
svgPanning: css`
|
svgPanning: css`
|
||||||
|
label: svgPanning;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
`,
|
`,
|
||||||
|
|
||||||
mainGroup: css`
|
mainGroup: css`
|
||||||
|
label: mainGroup;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
`,
|
`,
|
||||||
|
|
||||||
viewControls: css`
|
viewControls: css`
|
||||||
|
label: viewControls;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 10px;
|
left: 2px;
|
||||||
top: 10px;
|
bottom: 3px;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
`,
|
||||||
|
legend: css`
|
||||||
|
label: legend;
|
||||||
|
background: ${theme.colors.background.secondary};
|
||||||
|
box-shadow: ${theme.shadows.z1};
|
||||||
|
padding-bottom: 5px;
|
||||||
|
margin-right: 10px;
|
||||||
`,
|
`,
|
||||||
alert: css`
|
alert: css`
|
||||||
|
label: alert;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
|
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.2);
|
||||||
@ -60,10 +79,19 @@ const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
|||||||
background: ${theme.colors.warning.main};
|
background: ${theme.colors.warning.main};
|
||||||
color: ${theme.colors.warning.contrastText};
|
color: ${theme.colors.warning.contrastText};
|
||||||
`,
|
`,
|
||||||
}));
|
loadingWrapper: css`
|
||||||
|
label: loadingWrapper;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
// This is mainly for performance reasons.
|
// Limits the number of visible nodes, mainly for performance reasons. Nodes above the limit are accessible by expanding
|
||||||
const defaultNodeCountLimit = 1500;
|
// parts of the graph. The specific number is arbitrary but should be a number of nodes where panning, zooming and other
|
||||||
|
// interactions will be without any lag for most users.
|
||||||
|
const defaultNodeCountLimit = 200;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dataFrames: DataFrame[];
|
dataFrames: DataFrame[];
|
||||||
@ -79,10 +107,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
|||||||
|
|
||||||
// We need hover state here because for nodes we also highlight edges and for edges have labels separate to make
|
// We need hover state here because for nodes we also highlight edges and for edges have labels separate to make
|
||||||
// sure they are visible on top of everything else
|
// sure they are visible on top of everything else
|
||||||
const [nodeHover, setNodeHover] = useState<string | undefined>(undefined);
|
const { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover } = useHover();
|
||||||
const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]);
|
|
||||||
const [edgeHover, setEdgeHover] = useState<string | undefined>(undefined);
|
|
||||||
const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]);
|
|
||||||
|
|
||||||
const firstNodesDataFrame = nodesDataFrames[0];
|
const firstNodesDataFrame = nodesDataFrames[0];
|
||||||
const firstEdgesDataFrame = edgesDataFrames[0];
|
const firstEdgesDataFrame = edgesDataFrames[0];
|
||||||
@ -97,15 +122,38 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
|||||||
theme,
|
theme,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { nodes: rawNodes, edges: rawEdges } = useNodeLimit(processed.nodes, processed.edges, nodeCountLimit);
|
// This is used for navigation from grid to graph view. This node will be centered and briefly highlighted.
|
||||||
const hiddenNodesCount = processed.nodes.length - rawNodes.length;
|
const [focusedNodeId, setFocusedNodeId] = useState<string>();
|
||||||
|
const setFocused = useCallback((e: MouseEvent, m: NodesMarker) => setFocusedNodeId(m.node.id), [setFocusedNodeId]);
|
||||||
|
|
||||||
const { nodes, edges, bounds } = useLayout(rawNodes, rawEdges, config);
|
// May seem weird that we do layout first and then limit the nodes shown but the problem is we want to keep the node
|
||||||
const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
|
// position stable which means we need the full layout first and then just visually hide the nodes. As hiding/showing
|
||||||
bounds
|
// nodes should not have effect on layout it should not be recalculated.
|
||||||
|
const { nodes, edges, markers, bounds, hiddenNodesCount, loading } = useLayout(
|
||||||
|
processed.nodes,
|
||||||
|
processed.edges,
|
||||||
|
config,
|
||||||
|
nodeCountLimit,
|
||||||
|
focusedNodeId
|
||||||
);
|
);
|
||||||
const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(getLinks, nodesDataFrames[0], edgesDataFrames[0]);
|
|
||||||
const styles = getStyles(theme);
|
// If we move from grid to graph layout and we have focused node lets get it's position to center there. We want do
|
||||||
|
// do it specifically only in that case.
|
||||||
|
const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId);
|
||||||
|
const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom(
|
||||||
|
bounds,
|
||||||
|
focusPosition
|
||||||
|
);
|
||||||
|
|
||||||
|
const { onEdgeOpen, onNodeOpen, MenuComponent } = useContextMenu(
|
||||||
|
getLinks,
|
||||||
|
firstNodesDataFrame,
|
||||||
|
firstEdgesDataFrame,
|
||||||
|
config,
|
||||||
|
setConfig,
|
||||||
|
setFocusedNodeId
|
||||||
|
);
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
// This cannot be inline func or it will create infinite render cycle.
|
// This cannot be inline func or it will create infinite render cycle.
|
||||||
const topLevelRef = useCallback(
|
const topLevelRef = useCallback(
|
||||||
@ -116,8 +164,17 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
|||||||
[measureRef, zoomRef]
|
[measureRef, zoomRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const highlightId = useHighlight(focusedNodeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={topLevelRef} className={styles.wrapper}>
|
<div ref={topLevelRef} className={styles.wrapper}>
|
||||||
|
{loading ? (
|
||||||
|
<div className={styles.loadingWrapper}>
|
||||||
|
Computing layout
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
ref={panRef}
|
ref={panRef}
|
||||||
viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`}
|
viewBox={`${-(width / 2)} ${-(height / 2)} ${width} ${height}`}
|
||||||
@ -128,30 +185,55 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
|||||||
style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }}
|
style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }}
|
||||||
>
|
>
|
||||||
<EdgeArrowMarker />
|
<EdgeArrowMarker />
|
||||||
<Edges
|
{!config.gridLayout && (
|
||||||
edges={edges}
|
<Edges
|
||||||
nodeHoveringId={nodeHover}
|
edges={edges}
|
||||||
edgeHoveringId={edgeHover}
|
nodeHoveringId={nodeHover}
|
||||||
onClick={onEdgeOpen}
|
edgeHoveringId={edgeHover}
|
||||||
onMouseEnter={setEdgeHover}
|
onClick={onEdgeOpen}
|
||||||
onMouseLeave={clearEdgeHover}
|
onMouseEnter={setEdgeHover}
|
||||||
/>
|
onMouseLeave={clearEdgeHover}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Nodes
|
<Nodes
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
onMouseEnter={setNodeHover}
|
onMouseEnter={setNodeHover}
|
||||||
onMouseLeave={clearNodeHover}
|
onMouseLeave={clearNodeHover}
|
||||||
onClick={onNodeOpen}
|
onClick={onNodeOpen}
|
||||||
hoveringId={nodeHover}
|
hoveringId={nodeHover || highlightId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Markers markers={markers || []} onClick={setFocused} />
|
||||||
{/*We split the labels from edges so that they are shown on top of everything else*/}
|
{/*We split the labels from edges so that they are shown on top of everything else*/}
|
||||||
<EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />
|
{!config.gridLayout && <EdgeLabels edges={edges} nodeHoveringId={nodeHover} edgeHoveringId={edgeHover} />}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<div className={styles.viewControls}>
|
<div className={styles.viewControls}>
|
||||||
|
{nodes.length && (
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<Legend
|
||||||
|
sortable={config.gridLayout}
|
||||||
|
nodes={nodes}
|
||||||
|
sort={config.sort}
|
||||||
|
onSort={(sort) => {
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
sort: sort,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ViewControls<Config>
|
<ViewControls<Config>
|
||||||
config={config}
|
config={config}
|
||||||
onConfigChange={setConfig}
|
onConfigChange={(cfg) => {
|
||||||
|
if (cfg.gridLayout !== config.gridLayout) {
|
||||||
|
setFocusedNodeId(undefined);
|
||||||
|
}
|
||||||
|
setConfig(cfg);
|
||||||
|
}}
|
||||||
onMinus={onStepDown}
|
onMinus={onStepDown}
|
||||||
onPlus={onStepUp}
|
onPlus={onStepUp}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
@ -171,7 +253,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// These 3 components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom.
|
// These components are here as a perf optimisation to prevent going through all nodes and edges on every pan/zoom.
|
||||||
|
|
||||||
interface NodesProps {
|
interface NodesProps {
|
||||||
nodes: NodeDatum[];
|
nodes: NodeDatum[];
|
||||||
@ -197,6 +279,20 @@ const Nodes = memo(function Nodes(props: NodesProps) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
interface MarkersProps {
|
||||||
|
markers: NodesMarker[];
|
||||||
|
onClick: (event: MouseEvent<SVGElement>, marker: NodesMarker) => void;
|
||||||
|
}
|
||||||
|
const Markers = memo(function Nodes(props: MarkersProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.markers.map((m) => (
|
||||||
|
<Marker key={'marker-' + m.node.id} marker={m} onClick={props.onClick} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
interface EdgesProps {
|
interface EdgesProps {
|
||||||
edges: EdgeDatum[];
|
edges: EdgeDatum[];
|
||||||
nodeHoveringId?: string;
|
nodeHoveringId?: string;
|
||||||
@ -246,12 +342,22 @@ const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function usePanAndZoom(bounds: Bounds) {
|
function usePanAndZoom(bounds: Bounds, focus?: { x: number; y: number }) {
|
||||||
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
|
const { scale, onStepDown, onStepUp, ref, isMax, isMin } = useZoom();
|
||||||
const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
|
const { state: panningState, ref: panRef } = usePanning<SVGSVGElement>({
|
||||||
scale,
|
scale,
|
||||||
bounds,
|
bounds,
|
||||||
|
focus,
|
||||||
});
|
});
|
||||||
const { position, isPanning } = panningState;
|
const { position, isPanning } = panningState;
|
||||||
return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin };
|
return { zoomRef: ref, panRef, position, isPanning, scale, onStepDown, onStepUp, isMaxZoom: isMax, isMinZoom: isMin };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useHover() {
|
||||||
|
const [nodeHover, setNodeHover] = useState<string | undefined>(undefined);
|
||||||
|
const clearNodeHover = useCallback(() => setNodeHover(undefined), [setNodeHover]);
|
||||||
|
const [edgeHover, setEdgeHover] = useState<string | undefined>(undefined);
|
||||||
|
const clearEdgeHover = useCallback(() => setEdgeHover(undefined), [setEdgeHover]);
|
||||||
|
|
||||||
|
return { nodeHover, setNodeHover, clearNodeHover, edgeHover, setEdgeHover, clearEdgeHover };
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { PanelProps } from '@grafana/data';
|
import { PanelProps } from '@grafana/data';
|
||||||
import { Options } from './types';
|
import { Options } from './types';
|
||||||
import { NodeGraph } from '@grafana/ui';
|
import { NodeGraph } from './NodeGraph';
|
||||||
import { useLinks } from '../../../features/explore/utils/links';
|
import { useLinks } from '../../../features/explore/utils/links';
|
||||||
|
|
||||||
export const NodeGraphPanel: React.FunctionComponent<PanelProps<Options>> = ({ width, height, data }) => {
|
export const NodeGraphPanel: React.FunctionComponent<PanelProps<Options>> = ({ width, height, data }) => {
|
||||||
|
@ -1,21 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button } from '../Button';
|
import { Button, HorizontalGroup, VerticalGroup } from '@grafana/ui';
|
||||||
import { stylesFactory, useTheme } from '../../themes';
|
|
||||||
import { GrafanaTheme } from '@grafana/data';
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
import { HorizontalGroup } from '..';
|
|
||||||
|
|
||||||
const getStyles = stylesFactory((theme: GrafanaTheme) => ({
|
|
||||||
scale: css`
|
|
||||||
font-size: ${theme.typography.size.sm};
|
|
||||||
color: ${theme.colors.textFaint};
|
|
||||||
`,
|
|
||||||
|
|
||||||
scrollHelp: css`
|
|
||||||
font-size: ${theme.typography.size.xs};
|
|
||||||
color: ${theme.colors.textFaint};
|
|
||||||
`,
|
|
||||||
}));
|
|
||||||
|
|
||||||
interface Props<Config> {
|
interface Props<Config> {
|
||||||
config: Config;
|
config: Config;
|
||||||
@ -31,38 +15,53 @@ interface Props<Config> {
|
|||||||
* Control buttons for zoom but also some layout config inputs mainly for debugging.
|
* Control buttons for zoom but also some layout config inputs mainly for debugging.
|
||||||
*/
|
*/
|
||||||
export function ViewControls<Config extends Record<string, any>>(props: Props<Config>) {
|
export function ViewControls<Config extends Record<string, any>>(props: Props<Config>) {
|
||||||
const { config, onConfigChange, onPlus, onMinus, scale, disableZoomOut, disableZoomIn } = props;
|
const { config, onConfigChange, onPlus, onMinus, disableZoomOut, disableZoomIn } = props;
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const styles = getStyles(useTheme());
|
|
||||||
|
|
||||||
// For debugging the layout, should be removed here and maybe moved to panel config later on
|
// For debugging the layout, should be removed here and maybe moved to panel config later on
|
||||||
const allowConfiguration = false;
|
const allowConfiguration = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<HorizontalGroup spacing="xs">
|
<VerticalGroup spacing="sm">
|
||||||
<Button
|
<HorizontalGroup spacing="xs">
|
||||||
icon={'plus-circle'}
|
<Button
|
||||||
onClick={onPlus}
|
icon={'plus-circle'}
|
||||||
size={'sm'}
|
onClick={onPlus}
|
||||||
title={'Zoom in'}
|
size={'md'}
|
||||||
variant="secondary"
|
title={'Zoom in'}
|
||||||
disabled={disableZoomIn}
|
variant="secondary"
|
||||||
/>
|
disabled={disableZoomIn}
|
||||||
<Button
|
/>
|
||||||
icon={'minus-circle'}
|
<Button
|
||||||
onClick={onMinus}
|
icon={'minus-circle'}
|
||||||
size={'sm'}
|
onClick={onMinus}
|
||||||
title={'Zoom out'}
|
size={'md'}
|
||||||
variant="secondary"
|
title={'Zoom out'}
|
||||||
disabled={disableZoomOut}
|
variant="secondary"
|
||||||
/>
|
disabled={disableZoomOut}
|
||||||
<span className={styles.scale} title={'Zoom level'}>
|
/>
|
||||||
{' '}
|
</HorizontalGroup>
|
||||||
{scale.toFixed(2)}x
|
<HorizontalGroup spacing="xs">
|
||||||
</span>
|
<Button
|
||||||
</HorizontalGroup>
|
icon={'code-branch'}
|
||||||
<div className={styles.scrollHelp}>Or ctrl/meta + scroll</div>
|
onClick={() => onConfigChange({ ...config, gridLayout: false })}
|
||||||
|
size={'md'}
|
||||||
|
title={'Default layout'}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!config.gridLayout}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={'apps'}
|
||||||
|
onClick={() => onConfigChange({ ...config, gridLayout: true })}
|
||||||
|
size={'md'}
|
||||||
|
title={'Grid layout'}
|
||||||
|
variant="secondary"
|
||||||
|
disabled={config.gridLayout}
|
||||||
|
/>
|
||||||
|
</HorizontalGroup>
|
||||||
|
</VerticalGroup>
|
||||||
|
|
||||||
{allowConfiguration && (
|
{allowConfiguration && (
|
||||||
<Button size={'xs'} variant={'link'} onClick={() => setShowConfig((showConfig) => !showConfig)}>
|
<Button size={'xs'} variant={'link'} onClick={() => setShowConfig((showConfig) => !showConfig)}>
|
||||||
{showConfig ? 'Hide config' : 'Show config'}
|
{showConfig ? 'Hide config' : 'Show config'}
|
||||||
@ -86,6 +85,6 @@ export function ViewControls<Config extends Record<string, any>>(props: Props<Co
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
1
public/app/plugins/panel/nodeGraph/index.ts
Normal file
1
public/app/plugins/panel/nodeGraph/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { NodeGraph } from './NodeGraph';
|
183
public/app/plugins/panel/nodeGraph/layout.ts
Normal file
183
public/app/plugins/panel/nodeGraph/layout.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types';
|
||||||
|
import { Field } from '@grafana/data';
|
||||||
|
import { useNodeLimit } from './useNodeLimit';
|
||||||
|
import useMountedState from 'react-use/lib/useMountedState';
|
||||||
|
import { graphBounds } from './utils';
|
||||||
|
// @ts-ignore
|
||||||
|
import LayoutWorker from './layout.worker.js';
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
linkDistance: number;
|
||||||
|
linkStrength: number;
|
||||||
|
forceX: number;
|
||||||
|
forceXStrength: number;
|
||||||
|
forceCollide: number;
|
||||||
|
tick: number;
|
||||||
|
gridLayout: boolean;
|
||||||
|
sort?: {
|
||||||
|
// Either a arc field or stats field
|
||||||
|
field: Field;
|
||||||
|
ascending: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config mainly for the layout but also some other parts like current layout. The layout variables can be changed only
|
||||||
|
// if you programmatically enable the config editor (for development only) see ViewControls. These could be moved to
|
||||||
|
// panel configuration at some point (apart from gridLayout as that can be switched be user right now.).
|
||||||
|
export const defaultConfig: Config = {
|
||||||
|
linkDistance: 150,
|
||||||
|
linkStrength: 0.5,
|
||||||
|
forceX: 2000,
|
||||||
|
forceXStrength: 0.02,
|
||||||
|
forceCollide: 100,
|
||||||
|
tick: 300,
|
||||||
|
gridLayout: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This will return copy of the nods and edges with x,y positions filled in. Also the layout changes source/target props
|
||||||
|
* in edges from string ids to actual nodes.
|
||||||
|
*/
|
||||||
|
export function useLayout(
|
||||||
|
rawNodes: NodeDatum[],
|
||||||
|
rawEdges: EdgeDatum[],
|
||||||
|
config: Config = defaultConfig,
|
||||||
|
nodeCountLimit: number,
|
||||||
|
rootNodeId?: string
|
||||||
|
) {
|
||||||
|
const [nodesGrid, setNodesGrid] = useState<NodeDatum[]>([]);
|
||||||
|
const [edgesGrid, setEdgesGrid] = useState<EdgeDatumLayout[]>([]);
|
||||||
|
|
||||||
|
const [nodesGraph, setNodesGraph] = useState<NodeDatum[]>([]);
|
||||||
|
const [edgesGraph, setEdgesGraph] = useState<EdgeDatumLayout[]>([]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const isMounted = useMountedState();
|
||||||
|
|
||||||
|
// Also we compute both layouts here. Grid layout should not add much time and we can more easily just cache both
|
||||||
|
// so this should happen only once for a given response data.
|
||||||
|
//
|
||||||
|
// Also important note is that right now this works on all the nodes even if they are not visible. This means that
|
||||||
|
// the node position is stable even when expanding different parts of graph. It seems like a reasonable thing but
|
||||||
|
// implications are that:
|
||||||
|
// - limiting visible nodes count does not have a positive perf effect
|
||||||
|
// - graphs with high node count can seem weird (very sparse or spread out) when we show only some nodes but layout
|
||||||
|
// is done for thousands of nodes but we also do this only once in the graph lifecycle.
|
||||||
|
// We could re-layout this on visible nodes change but this may need smaller visible node limit to keep the perf
|
||||||
|
// (as we would run layout on every click) and also would be very weird without any animation to understand what is
|
||||||
|
// happening as already visible nodes would change positions.
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawNodes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// d3 just modifies the nodes directly, so lets make sure we don't leak that outside
|
||||||
|
let rawNodesCopy = rawNodes.map((n) => ({ ...n }));
|
||||||
|
let rawEdgesCopy = rawEdges.map((e) => ({ ...e }));
|
||||||
|
|
||||||
|
// This is async but as I wanted to still run the sync grid layout and you cannot return promise from effect having
|
||||||
|
// callback seem ok here.
|
||||||
|
defaultLayout(rawNodesCopy, rawEdgesCopy, ({ nodes, edges }) => {
|
||||||
|
// TODO: it would be better to cancel the worker somehow but probably not super important right now.
|
||||||
|
if (isMounted()) {
|
||||||
|
setNodesGraph(nodes);
|
||||||
|
setEdgesGraph(edges as EdgeDatumLayout[]);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rawNodesCopy = rawNodes.map((n) => ({ ...n }));
|
||||||
|
rawEdgesCopy = rawEdges.map((e) => ({ ...e }));
|
||||||
|
gridLayout(rawNodesCopy, config.sort);
|
||||||
|
|
||||||
|
setNodesGrid(rawNodesCopy);
|
||||||
|
setEdgesGrid(rawEdgesCopy as EdgeDatumLayout[]);
|
||||||
|
}, [config.sort, rawNodes, rawEdges, isMounted]);
|
||||||
|
|
||||||
|
// Limit the nodes so we don't show all for performance reasons. Here we don't compute both at the same time so
|
||||||
|
// changing the layout can trash internal memoization at the moment.
|
||||||
|
const { nodes: nodesWithLimit, edges: edgesWithLimit, markers } = useNodeLimit(
|
||||||
|
config.gridLayout ? nodesGrid : nodesGraph,
|
||||||
|
config.gridLayout ? edgesGrid : edgesGraph,
|
||||||
|
nodeCountLimit,
|
||||||
|
config,
|
||||||
|
rootNodeId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get bounds based on current limited number of nodes.
|
||||||
|
const bounds = useMemo(() => graphBounds([...nodesWithLimit, ...(markers || []).map((m) => m.node)]), [
|
||||||
|
nodesWithLimit,
|
||||||
|
markers,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodesWithLimit,
|
||||||
|
edges: edgesWithLimit,
|
||||||
|
markers,
|
||||||
|
bounds,
|
||||||
|
hiddenNodesCount: rawNodes.length - nodesWithLimit.length,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the layout code in a worker as it can take long and we don't want to block the main thread.
|
||||||
|
*/
|
||||||
|
function defaultLayout(
|
||||||
|
nodes: NodeDatum[],
|
||||||
|
edges: EdgeDatum[],
|
||||||
|
done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void
|
||||||
|
) {
|
||||||
|
const worker = new LayoutWorker();
|
||||||
|
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
// These stats needs to be Field class but the data is stringified over the worker boundary
|
||||||
|
event.data.nodes[i] = {
|
||||||
|
...event.data.nodes[i],
|
||||||
|
mainStat: nodes[i].mainStat,
|
||||||
|
secondaryStat: nodes[i].secondaryStat,
|
||||||
|
arcSections: nodes[i].arcSections,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
done(event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.postMessage({ nodes, edges, config: defaultConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the nodes in simple grid layout sorted by some stat.
|
||||||
|
*/
|
||||||
|
function gridLayout(
|
||||||
|
nodes: NodeDatum[],
|
||||||
|
sort?: {
|
||||||
|
field: Field;
|
||||||
|
ascending: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const spacingVertical = 140;
|
||||||
|
const spacingHorizontal = 120;
|
||||||
|
// TODO probably make this based on the width of the screen
|
||||||
|
const perRow = 4;
|
||||||
|
|
||||||
|
if (sort) {
|
||||||
|
nodes.sort((node1, node2) => {
|
||||||
|
const val1 = sort!.field.values.get(node1.dataFrameRowIndex);
|
||||||
|
const val2 = sort!.field.values.get(node2.dataFrameRowIndex);
|
||||||
|
|
||||||
|
// Lets pretend we don't care about type for a while
|
||||||
|
return sort!.ascending ? val2 - val1 : val1 - val2;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, node] of nodes.entries()) {
|
||||||
|
const row = Math.floor(index / perRow);
|
||||||
|
const column = index % perRow;
|
||||||
|
node.x = -180 + column * spacingHorizontal;
|
||||||
|
node.y = -60 + row * spacingVertical;
|
||||||
|
}
|
||||||
|
}
|
176
public/app/plugins/panel/nodeGraph/layout.worker.js
Normal file
176
public/app/plugins/panel/nodeGraph/layout.worker.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { forceSimulation, forceLink, forceCollide, forceX } from 'd3-force';
|
||||||
|
|
||||||
|
addEventListener('message', (event) => {
|
||||||
|
const { nodes, edges, config } = event.data;
|
||||||
|
layout(nodes, edges, config);
|
||||||
|
postMessage({ nodes, edges });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions
|
||||||
|
* and also fills in node references in edges instead of node ids.
|
||||||
|
*/
|
||||||
|
export function layout(nodes, edges, config) {
|
||||||
|
// Start with some hardcoded positions so it starts laid out from left to right
|
||||||
|
let { roots, secondLevelRoots } = initializePositions(nodes, edges);
|
||||||
|
|
||||||
|
// There always seems to be one or more root nodes each with single edge and we want to have them static on the
|
||||||
|
// left neatly in something like grid layout
|
||||||
|
[...roots, ...secondLevelRoots].forEach((n, index) => {
|
||||||
|
n.fx = n.x;
|
||||||
|
});
|
||||||
|
|
||||||
|
const simulation = forceSimulation(nodes)
|
||||||
|
.force(
|
||||||
|
'link',
|
||||||
|
forceLink(edges)
|
||||||
|
.id((d) => d.id)
|
||||||
|
.distance(config.linkDistance)
|
||||||
|
.strength(config.linkStrength)
|
||||||
|
)
|
||||||
|
// to keep the left to right layout we add force that pulls all nodes to right but because roots are fixed it will
|
||||||
|
// apply only to non root nodes
|
||||||
|
.force('x', forceX(config.forceX).strength(config.forceXStrength))
|
||||||
|
// Make sure nodes don't overlap
|
||||||
|
.force('collide', forceCollide(config.forceCollide));
|
||||||
|
|
||||||
|
// 300 ticks for the simulation are recommended but less would probably work too, most movement is done in first
|
||||||
|
// few iterations and then all the forces gets smaller https://github.com/d3/d3-force#simulation_alphaDecay
|
||||||
|
simulation.tick(config.tick);
|
||||||
|
simulation.stop();
|
||||||
|
|
||||||
|
// We do centering here instead of using centering force to keep this more stable
|
||||||
|
centerNodes(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This initializes positions of the graph by going from the root to it's children and laying it out in a grid from left
|
||||||
|
* to right. This works only so, so because service map graphs can have cycles and children levels are not ordered in a
|
||||||
|
* way to minimize the edge lengths. Nevertheless this seems to make the graph easier to nudge with the forces later on
|
||||||
|
* than with the d3 default initial positioning. Also we can fix the root positions later on for a bit more neat
|
||||||
|
* organisation.
|
||||||
|
*
|
||||||
|
* This function directly modifies the nodes given and only returns references to root nodes so they do not have to be
|
||||||
|
* found again later on.
|
||||||
|
*
|
||||||
|
* How the spacing could look like approximately:
|
||||||
|
* 0 - 0 - 0 - 0
|
||||||
|
* \- 0 - 0 |
|
||||||
|
* \- 0 -/
|
||||||
|
* 0 - 0 -/
|
||||||
|
*/
|
||||||
|
function initializePositions(nodes, edges) {
|
||||||
|
// To prevent going in cycles
|
||||||
|
const alreadyPositioned = {};
|
||||||
|
|
||||||
|
const nodesMap = nodes.reduce((acc, node) => {
|
||||||
|
acc[node.id] = node;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const edgesMap = edges.reduce((acc, edge) => {
|
||||||
|
const sourceId = edge.source;
|
||||||
|
acc[sourceId] = [...(acc[sourceId] || []), edge];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
let roots = nodes.filter((n) => n.incoming === 0);
|
||||||
|
|
||||||
|
// For things like service maps we assume there is some root (client) node but if there is none then selecting
|
||||||
|
// any node as a starting point should work the same.
|
||||||
|
if (!roots.length) {
|
||||||
|
roots = [nodes[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
let secondLevelRoots = roots.reduce((acc, r) => {
|
||||||
|
acc.push(...(edgesMap[r.id] ? edgesMap[r.id].map((e) => nodesMap[e.target]) : []));
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rootYSpacing = 300;
|
||||||
|
const nodeYSpacing = 200;
|
||||||
|
const nodeXSpacing = 200;
|
||||||
|
|
||||||
|
let rootY = 0;
|
||||||
|
for (const root of roots) {
|
||||||
|
let graphLevel = [root];
|
||||||
|
let x = 0;
|
||||||
|
while (graphLevel.length > 0) {
|
||||||
|
const nextGraphLevel = [];
|
||||||
|
let y = rootY;
|
||||||
|
for (const node of graphLevel) {
|
||||||
|
if (alreadyPositioned[node.id]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Initialize positions based on the spacing in the grid
|
||||||
|
node.x = x;
|
||||||
|
node.y = y;
|
||||||
|
alreadyPositioned[node.id] = true;
|
||||||
|
|
||||||
|
// Move to next Y position for next node
|
||||||
|
y += nodeYSpacing;
|
||||||
|
if (edgesMap[node.id]) {
|
||||||
|
nextGraphLevel.push(...edgesMap[node.id].map((edge) => nodesMap[edge.target]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
graphLevel = nextGraphLevel;
|
||||||
|
// Move to next X position for next level
|
||||||
|
x += nodeXSpacing;
|
||||||
|
// Reset Y back to baseline for this root
|
||||||
|
y = rootY;
|
||||||
|
}
|
||||||
|
rootY += rootYSpacing;
|
||||||
|
}
|
||||||
|
return { roots, secondLevelRoots };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure that the center of the graph based on it's bound is in 0, 0 coordinates.
|
||||||
|
* Modifies the nodes directly.
|
||||||
|
*/
|
||||||
|
function centerNodes(nodes) {
|
||||||
|
const bounds = graphBounds(nodes);
|
||||||
|
for (let node of nodes) {
|
||||||
|
node.x = node.x - bounds.center.x;
|
||||||
|
node.y = node.y - bounds.center.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bounds of the graph meaning the extent of the nodes in all directions.
|
||||||
|
*/
|
||||||
|
function graphBounds(nodes) {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = nodes.reduce(
|
||||||
|
(acc, node) => {
|
||||||
|
if (node.x > acc.right) {
|
||||||
|
acc.right = node.x;
|
||||||
|
}
|
||||||
|
if (node.x < acc.left) {
|
||||||
|
acc.left = node.x;
|
||||||
|
}
|
||||||
|
if (node.y > acc.bottom) {
|
||||||
|
acc.bottom = node.y;
|
||||||
|
}
|
||||||
|
if (node.y < acc.top) {
|
||||||
|
acc.top = node.y;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
|
||||||
|
);
|
||||||
|
|
||||||
|
const y = bounds.top + (bounds.bottom - bounds.top) / 2;
|
||||||
|
const x = bounds.left + (bounds.right - bounds.left) / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
center: {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -1 +1,41 @@
|
|||||||
|
import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
|
||||||
|
import { Field } from '@grafana/data';
|
||||||
|
|
||||||
export interface Options {}
|
export interface Options {}
|
||||||
|
|
||||||
|
export type NodeDatum = SimulationNodeDatum & {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subTitle: string;
|
||||||
|
dataFrameRowIndex: number;
|
||||||
|
incoming: number;
|
||||||
|
mainStat?: Field;
|
||||||
|
secondaryStat?: Field;
|
||||||
|
arcSections: Field[];
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is the data we have before the graph is laid out with source and target being string IDs.
|
||||||
|
type LinkDatum = SimulationLinkDatum<NodeDatum> & {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is some additional data we expect with the edges.
|
||||||
|
export type EdgeDatum = LinkDatum & {
|
||||||
|
id: string;
|
||||||
|
mainStat: string;
|
||||||
|
secondaryStat: string;
|
||||||
|
dataFrameRowIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// After layout is run D3 will change the string IDs for actual references to the nodes.
|
||||||
|
export type EdgeDatumLayout = EdgeDatum & {
|
||||||
|
source: NodeDatum;
|
||||||
|
target: NodeDatum;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodesMarker = {
|
||||||
|
node: NodeDatum;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
@ -1,13 +1,10 @@
|
|||||||
import React, { MouseEvent, useCallback, useState } from 'react';
|
import React, { MouseEvent, useCallback, useState } from 'react';
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
import { EdgeDatum, NodeDatum } from './types';
|
||||||
import { DataFrame, Field, GrafanaTheme, LinkModel } from '@grafana/data';
|
import { DataFrame, Field, GrafanaTheme, LinkModel } from '@grafana/data';
|
||||||
import { ContextMenu } from '../ContextMenu/ContextMenu';
|
|
||||||
import { useTheme } from '../../themes/ThemeContext';
|
|
||||||
import { stylesFactory } from '../../themes/stylesFactory';
|
|
||||||
import { getEdgeFields, getNodeFields } from './utils';
|
import { getEdgeFields, getNodeFields } from './utils';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { MenuGroup } from '../Menu/MenuGroup';
|
import { Config } from './layout';
|
||||||
import { MenuItem } from '../Menu/MenuItem';
|
import { ContextMenu, MenuGroup, MenuItem, stylesFactory, useTheme } from '@grafana/ui';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
|
* Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when
|
||||||
@ -16,83 +13,113 @@ import { MenuItem } from '../Menu/MenuItem';
|
|||||||
export function useContextMenu(
|
export function useContextMenu(
|
||||||
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[],
|
getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[],
|
||||||
nodes: DataFrame,
|
nodes: DataFrame,
|
||||||
edges: DataFrame
|
edges: DataFrame,
|
||||||
|
config: Config,
|
||||||
|
setConfig: (config: Config) => void,
|
||||||
|
setFocusedNodeId: (id: string) => void
|
||||||
): {
|
): {
|
||||||
onEdgeOpen: (event: MouseEvent<SVGElement>, edge: EdgeDatum) => void;
|
onEdgeOpen: (event: MouseEvent<SVGElement>, edge: EdgeDatum) => void;
|
||||||
onNodeOpen: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
onNodeOpen: (event: MouseEvent<SVGElement>, node: NodeDatum) => void;
|
||||||
MenuComponent: React.ReactNode;
|
MenuComponent: React.ReactNode;
|
||||||
} {
|
} {
|
||||||
const [openedNode, setOpenedNode] = useState<{ node: NodeDatum; event: MouseEvent } | undefined>(undefined);
|
const [menu, setMenu] = useState<JSX.Element | undefined>(undefined);
|
||||||
const onNodeOpen = useCallback((event, node) => setOpenedNode({ node, event }), []);
|
|
||||||
|
|
||||||
const [openedEdge, setOpenedEdge] = useState<{ edge: EdgeDatum; event: MouseEvent } | undefined>(undefined);
|
const onNodeOpen = useCallback(
|
||||||
const onEdgeOpen = useCallback((event, edge) => setOpenedEdge({ edge, event }), []);
|
(event, node) => {
|
||||||
|
const extraNodeItem = config.gridLayout
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: 'Show in Graph layout',
|
||||||
|
onClick: (node: NodeDatum) => {
|
||||||
|
setFocusedNodeId(node.id);
|
||||||
|
setConfig({ ...config, gridLayout: false });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem);
|
||||||
|
|
||||||
let MenuComponent = null;
|
if (renderer) {
|
||||||
|
setMenu(
|
||||||
|
<ContextMenu
|
||||||
|
renderHeader={() => <NodeHeader node={node} nodes={nodes} />}
|
||||||
|
renderMenuItems={renderer}
|
||||||
|
onClose={() => setMenu(undefined)}
|
||||||
|
x={event.pageX}
|
||||||
|
y={event.pageY}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
|
||||||
|
);
|
||||||
|
|
||||||
if (openedNode) {
|
const onEdgeOpen = useCallback(
|
||||||
const items = getItems(getLinks(nodes, openedNode.node.dataFrameRowIndex));
|
(event, edge) => {
|
||||||
const renderMenuGroupItems = () => {
|
const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge);
|
||||||
return items?.map((group, index) => (
|
|
||||||
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
|
|
||||||
{(group.items || []).map((item) => (
|
|
||||||
<MenuItem
|
|
||||||
key={`${item.label}`}
|
|
||||||
url={item.url}
|
|
||||||
label={item.label}
|
|
||||||
ariaLabel={item.label}
|
|
||||||
onClick={item.onClick}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</MenuGroup>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if (items.length) {
|
|
||||||
MenuComponent = (
|
|
||||||
<ContextMenu
|
|
||||||
renderHeader={() => <NodeHeader node={openedNode.node} nodes={nodes} />}
|
|
||||||
renderMenuItems={renderMenuGroupItems}
|
|
||||||
onClose={() => setOpenedNode(undefined)}
|
|
||||||
x={openedNode.event.pageX}
|
|
||||||
y={openedNode.event.pageY}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (openedEdge) {
|
if (renderer) {
|
||||||
const items = getItems(getLinks(edges, openedEdge.edge.dataFrameRowIndex));
|
setMenu(
|
||||||
const renderMenuGroupItems = () => {
|
<ContextMenu
|
||||||
return items?.map((group, index) => (
|
renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
|
||||||
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
|
renderMenuItems={renderer}
|
||||||
{(group.items || []).map((item) => (
|
onClose={() => setMenu(undefined)}
|
||||||
<MenuItem
|
x={event.pageX}
|
||||||
key={item.label}
|
y={event.pageY}
|
||||||
url={item.url}
|
/>
|
||||||
label={item.label}
|
);
|
||||||
ariaLabel={item.label}
|
}
|
||||||
onClick={item.onClick}
|
},
|
||||||
/>
|
[edges, getLinks, setMenu]
|
||||||
))}
|
);
|
||||||
</MenuGroup>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
if (items.length) {
|
|
||||||
MenuComponent = (
|
|
||||||
<ContextMenu
|
|
||||||
renderHeader={() => <EdgeHeader edge={openedEdge.edge} edges={edges} />}
|
|
||||||
renderMenuItems={renderMenuGroupItems}
|
|
||||||
onClose={() => setOpenedEdge(undefined)}
|
|
||||||
x={openedEdge.event.pageX}
|
|
||||||
y={openedEdge.event.pageY}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { onEdgeOpen, onNodeOpen, MenuComponent };
|
return { onEdgeOpen, onNodeOpen, MenuComponent: menu };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
|
||||||
|
links: LinkModel[],
|
||||||
|
item: T,
|
||||||
|
extraItems?: Array<LinkData<T>> | undefined
|
||||||
|
) {
|
||||||
|
if (!(links.length || extraItems?.length)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const items = getItems(links);
|
||||||
|
return () => {
|
||||||
|
let groups = items?.map((group, index) => (
|
||||||
|
<MenuGroup key={`${group.label}${index}`} label={group.label} ariaLabel={group.label}>
|
||||||
|
{(group.items || []).map(mapMenuItem(item))}
|
||||||
|
</MenuGroup>
|
||||||
|
));
|
||||||
|
|
||||||
|
if (extraItems) {
|
||||||
|
groups = [...extraItems.map(mapMenuItem(item)), ...groups];
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapMenuItem<T extends NodeDatum | EdgeDatum>(item: T) {
|
||||||
|
return function NodeGraphMenuItem(link: LinkData<T>) {
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
key={link.label}
|
||||||
|
url={link.url}
|
||||||
|
label={link.label}
|
||||||
|
ariaLabel={link.ariaLabel || link.label}
|
||||||
|
onClick={link.onClick ? () => link.onClick?.(item) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkData<T extends NodeDatum | EdgeDatum> = {
|
||||||
|
label: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
url?: string;
|
||||||
|
onClick?: (item: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
function getItems(links: LinkModel[]) {
|
function getItems(links: LinkModel[]) {
|
||||||
const defaultGroup = 'Open in Explore';
|
const defaultGroup = 'Open in Explore';
|
||||||
const groups = links.reduce<{ [group: string]: Array<{ l: LinkModel; newTitle?: string }> }>((acc, l) => {
|
const groups = links.reduce<{ [group: string]: Array<{ l: LinkModel; newTitle?: string }> }>((acc, l) => {
|
@ -0,0 +1,19 @@
|
|||||||
|
import usePrevious from 'react-use/lib/usePrevious';
|
||||||
|
import { Config } from './layout';
|
||||||
|
import { NodeDatum } from './types';
|
||||||
|
|
||||||
|
export function useFocusPositionOnLayout(config: Config, nodes: NodeDatum[], focusedNodeId: string | undefined) {
|
||||||
|
const prevLayoutGrid = usePrevious(config.gridLayout);
|
||||||
|
let focusPosition;
|
||||||
|
if (prevLayoutGrid === true && !config.gridLayout && focusedNodeId) {
|
||||||
|
const node = nodes.find((n) => n.id === focusedNodeId);
|
||||||
|
if (node) {
|
||||||
|
focusPosition = {
|
||||||
|
x: -node.x!,
|
||||||
|
y: -node.y!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return focusPosition;
|
||||||
|
}
|
19
public/app/plugins/panel/nodeGraph/useHighlight.ts
Normal file
19
public/app/plugins/panel/nodeGraph/useHighlight.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import useMountedState from 'react-use/lib/useMountedState';
|
||||||
|
|
||||||
|
export function useHighlight(focusedNodeId?: string) {
|
||||||
|
const [highlightId, setHighlightId] = useState<string>();
|
||||||
|
const mounted = useMountedState();
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusedNodeId) {
|
||||||
|
setHighlightId(focusedNodeId);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (mounted()) {
|
||||||
|
setHighlightId(undefined);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, [focusedNodeId, mounted]);
|
||||||
|
|
||||||
|
return highlightId;
|
||||||
|
}
|
225
public/app/plugins/panel/nodeGraph/useNodeLimit.ts
Normal file
225
public/app/plugins/panel/nodeGraph/useNodeLimit.ts
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import { fromPairs, uniq } from 'lodash';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { EdgeDatumLayout, NodeDatum, NodesMarker } from './types';
|
||||||
|
import { Config } from './layout';
|
||||||
|
|
||||||
|
type NodesMap = Record<string, NodeDatum>;
|
||||||
|
type EdgesMap = Record<string, EdgeDatumLayout[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limits the number of nodes by going from the roots breadth first until we have desired number of nodes.
|
||||||
|
*/
|
||||||
|
export function useNodeLimit(
|
||||||
|
nodes: NodeDatum[],
|
||||||
|
edges: EdgeDatumLayout[],
|
||||||
|
limit: number,
|
||||||
|
config: Config,
|
||||||
|
rootId?: string
|
||||||
|
): { nodes: NodeDatum[]; edges: EdgeDatumLayout[]; markers?: NodesMarker[] } {
|
||||||
|
// This is pretty expensive also this happens once in the layout code when initializing position but it's a bit
|
||||||
|
// tricky to do it only once and reuse the results because layout directly modifies the nodes.
|
||||||
|
const [edgesMap, nodesMap] = useMemo(() => {
|
||||||
|
// Make sure we don't compute this until we have all the data.
|
||||||
|
if (!(nodes.length && edges.length)) {
|
||||||
|
return [{}, {}];
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgesMap = edges.reduce<EdgesMap>((acc, e) => {
|
||||||
|
acc[e.source.id] = [...(acc[e.source.id] ?? []), e];
|
||||||
|
acc[e.target.id] = [...(acc[e.target.id] ?? []), e];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const nodesMap = nodes.reduce<NodesMap>((acc, node) => {
|
||||||
|
acc[node.id] = node;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
return [edgesMap, nodesMap];
|
||||||
|
}, [edges, nodes]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (nodes.length <= limit) {
|
||||||
|
return { nodes, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.gridLayout) {
|
||||||
|
return limitGridLayout(nodes, limit, rootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return limitGraphLayout(nodes, edges, nodesMap, edgesMap, limit, rootId);
|
||||||
|
}, [edges, edgesMap, limit, nodes, nodesMap, rootId, config.gridLayout]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitGraphLayout(
|
||||||
|
nodes: NodeDatum[],
|
||||||
|
edges: EdgeDatumLayout[],
|
||||||
|
nodesMap: NodesMap,
|
||||||
|
edgesMap: EdgesMap,
|
||||||
|
limit: number,
|
||||||
|
rootId?: string
|
||||||
|
) {
|
||||||
|
let roots;
|
||||||
|
if (rootId) {
|
||||||
|
roots = [nodesMap[rootId]];
|
||||||
|
} else {
|
||||||
|
roots = nodes.filter((n) => n.incoming === 0);
|
||||||
|
// TODO: same code as layout
|
||||||
|
if (!roots.length) {
|
||||||
|
roots = [nodes[0]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { visibleNodes, markers } = collectVisibleNodes(limit, roots, nodesMap, edgesMap);
|
||||||
|
|
||||||
|
const markersWithStats = collectMarkerStats(markers, visibleNodes, nodesMap, edgesMap);
|
||||||
|
const markersMap = fromPairs(markersWithStats.map((m) => [m.node.id, m]));
|
||||||
|
|
||||||
|
for (const marker of markersWithStats) {
|
||||||
|
if (marker.count === 1) {
|
||||||
|
delete markersMap[marker.node.id];
|
||||||
|
visibleNodes[marker.node.id] = marker.node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show all edges between visible nodes or placeholder markers
|
||||||
|
const visibleEdges = edges.filter(
|
||||||
|
(e) =>
|
||||||
|
(visibleNodes[e.source.id] || markersMap[e.source.id]) && (visibleNodes[e.target.id] || markersMap[e.target.id])
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: Object.values(visibleNodes),
|
||||||
|
edges: visibleEdges,
|
||||||
|
markers: Object.values(markersMap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function limitGridLayout(nodes: NodeDatum[], limit: number, rootId?: string) {
|
||||||
|
let start = 0;
|
||||||
|
let stop = limit;
|
||||||
|
let markers: NodesMarker[] = [];
|
||||||
|
|
||||||
|
if (rootId) {
|
||||||
|
const index = nodes.findIndex((node) => node.id === rootId);
|
||||||
|
const prevLimit = Math.floor(limit / 2);
|
||||||
|
let afterLimit = prevLimit;
|
||||||
|
start = index - prevLimit;
|
||||||
|
if (start < 0) {
|
||||||
|
afterLimit += Math.abs(start);
|
||||||
|
start = 0;
|
||||||
|
}
|
||||||
|
stop = index + afterLimit + 1;
|
||||||
|
|
||||||
|
if (stop > nodes.length) {
|
||||||
|
if (start > 0) {
|
||||||
|
start = Math.max(0, start - (stop - nodes.length));
|
||||||
|
}
|
||||||
|
stop = nodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start > 1) {
|
||||||
|
markers.push({ node: nodes[start - 1], count: start });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length - stop > 1) {
|
||||||
|
markers.push({ node: nodes[stop], count: nodes.length - stop });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (nodes.length - limit > 1) {
|
||||||
|
markers = [{ node: nodes[limit], count: nodes.length - limit }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: nodes.slice(start, stop),
|
||||||
|
edges: [],
|
||||||
|
markers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breath first traverse of the graph collecting all the nodes until we reach the limit. It also returns markers which
|
||||||
|
* are nodes on the edges which did not make it into the limit but can be used as clickable markers for manually
|
||||||
|
* expanding the graph.
|
||||||
|
* @param limit
|
||||||
|
* @param roots - Nodes where to start the traversal. In case of exploration this can be any node that user clicked on.
|
||||||
|
* @param nodesMap - Node id to node
|
||||||
|
* @param edgesMap - This is a map of node id to a list of edges (both ingoing and outgoing)
|
||||||
|
*/
|
||||||
|
function collectVisibleNodes(
|
||||||
|
limit: number,
|
||||||
|
roots: NodeDatum[],
|
||||||
|
nodesMap: Record<string, NodeDatum>,
|
||||||
|
edgesMap: Record<string, EdgeDatumLayout[]>
|
||||||
|
): { visibleNodes: Record<string, NodeDatum>; markers: NodeDatum[] } {
|
||||||
|
const visibleNodes: Record<string, NodeDatum> = {};
|
||||||
|
let stack = [...roots];
|
||||||
|
|
||||||
|
while (Object.keys(visibleNodes).length < limit && stack.length > 0) {
|
||||||
|
let current = stack.shift()!;
|
||||||
|
|
||||||
|
// We are already showing this node. This can happen because graphs can be cyclic
|
||||||
|
if (visibleNodes[current!.id]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show this node
|
||||||
|
visibleNodes[current.id] = current;
|
||||||
|
const edges = edgesMap[current.id] || [];
|
||||||
|
|
||||||
|
// Add any nodes that are connected to it on top of the stack to be considered in the next pass
|
||||||
|
const connectedNodes = edges.map((e) => {
|
||||||
|
// We don't care about direction here. Should not make much difference but argument could be made that with
|
||||||
|
// directed graphs it should walk the graph directionally. Problem is when we focus on a node in the middle of
|
||||||
|
// graph (not going from the "natural" root) we also want to show what was "before".
|
||||||
|
const id = e.source.id === current.id ? e.target.id : e.source.id;
|
||||||
|
return nodesMap[id];
|
||||||
|
});
|
||||||
|
stack = stack.concat(connectedNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right now our stack contains all the nodes which are directly connected to the graph but did not make the cut.
|
||||||
|
// Some of them though can be nodes we already are showing so we have to filter them and then use them as markers.
|
||||||
|
const markers = uniq(stack.filter((n) => !visibleNodes[n.id]));
|
||||||
|
|
||||||
|
return { visibleNodes, markers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMarkerStats(
|
||||||
|
markers: NodeDatum[],
|
||||||
|
visibleNodes: Record<string, NodeDatum>,
|
||||||
|
nodesMap: Record<string, NodeDatum>,
|
||||||
|
edgesMap: Record<string, EdgeDatumLayout[]>
|
||||||
|
): NodesMarker[] {
|
||||||
|
return markers.map((marker) => {
|
||||||
|
const nodesToCount: Record<string, NodeDatum> = {};
|
||||||
|
let count = 0;
|
||||||
|
let stack = [marker];
|
||||||
|
while (stack.length > 0 && count <= 101) {
|
||||||
|
let current = stack.shift()!;
|
||||||
|
|
||||||
|
// We are showing this node so not going to count it as hidden.
|
||||||
|
if (visibleNodes[current.id] || nodesToCount[current.id]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!nodesToCount[current.id]) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
nodesToCount[current.id] = current;
|
||||||
|
|
||||||
|
const edges = edgesMap[current.id] || [];
|
||||||
|
|
||||||
|
const connectedNodes = edges.map((e) => {
|
||||||
|
const id = e.source.id === current.id ? e.target.id : e.source.id;
|
||||||
|
return nodesMap[id];
|
||||||
|
});
|
||||||
|
stack = stack.concat(connectedNodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: marker,
|
||||||
|
count: count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef, RefObject, useState } from 'react';
|
import { useEffect, useRef, RefObject, useState, useMemo } from 'react';
|
||||||
import useMountedState from 'react-use/lib/useMountedState';
|
import useMountedState from 'react-use/lib/useMountedState';
|
||||||
|
import { Bounds } from './utils';
|
||||||
|
import usePrevious from 'react-use/lib/usePrevious';
|
||||||
|
|
||||||
export interface State {
|
export interface State {
|
||||||
isPanning: boolean;
|
isPanning: boolean;
|
||||||
@ -11,34 +13,53 @@ export interface State {
|
|||||||
|
|
||||||
interface Options {
|
interface Options {
|
||||||
scale?: number;
|
scale?: number;
|
||||||
bounds?: { top: number; bottom: number; right: number; left: number };
|
bounds?: Bounds;
|
||||||
|
focus?: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Based on https://github.com/streamich/react-use/blob/master/src/useSlider.ts
|
* Based on https://github.com/streamich/react-use/blob/master/src/useSlider.ts
|
||||||
* Returns position x/y coordinates which can be directly used in transform: translate().
|
* Returns position x/y coordinates which can be directly used in transform: translate().
|
||||||
* @param scale Can be used when we want to scale the movement if we are moving a scaled element. We need to do it
|
* @param scale - Can be used when we want to scale the movement if we are moving a scaled element. We need to do it
|
||||||
* here because we don't want to change the pos when scale changes.
|
* here because we don't want to change the pos when scale changes.
|
||||||
* @param bounds If set the panning cannot go outside of those bounds.
|
* @param bounds - If set the panning cannot go outside of those bounds.
|
||||||
|
* @param focus - Position to focus on.
|
||||||
*/
|
*/
|
||||||
export function usePanning<T extends Element>(
|
export function usePanning<T extends Element>({ scale = 1, bounds, focus }: Options = {}): {
|
||||||
{ scale = 1, bounds }: Options = { scale: 1 }
|
state: State;
|
||||||
): { state: State; ref: RefObject<T> } {
|
ref: RefObject<T>;
|
||||||
|
} {
|
||||||
const isMounted = useMountedState();
|
const isMounted = useMountedState();
|
||||||
const isPanning = useRef(false);
|
const isPanning = useRef(false);
|
||||||
const frame = useRef(0);
|
const frame = useRef(0);
|
||||||
const panRef = useRef<T>(null);
|
const panRef = useRef<T>(null);
|
||||||
|
|
||||||
|
const initial = { x: 0, y: 0 };
|
||||||
|
// As we return a diff of the view port to be applied we need as translate coordinates we have to invert the
|
||||||
|
// bounds of the content to get the bounds of the view port diff.
|
||||||
|
const viewBounds = useMemo(
|
||||||
|
() => ({
|
||||||
|
right: bounds ? -bounds.left : Infinity,
|
||||||
|
left: bounds ? -bounds.right : -Infinity,
|
||||||
|
bottom: bounds ? -bounds.top : -Infinity,
|
||||||
|
top: bounds ? -bounds.bottom : Infinity,
|
||||||
|
}),
|
||||||
|
[bounds]
|
||||||
|
);
|
||||||
|
|
||||||
// We need to keep some state so we can compute the position diff and add that to the previous position.
|
// We need to keep some state so we can compute the position diff and add that to the previous position.
|
||||||
const startMousePosition = useRef({ x: 0, y: 0 });
|
const startMousePosition = useRef(initial);
|
||||||
const prevPosition = useRef({ x: 0, y: 0 });
|
const prevPosition = useRef(initial);
|
||||||
// We cannot use the state as that would rerun the effect on each state change which we don't want so we have to keep
|
// We cannot use the state as that would rerun the effect on each state change which we don't want so we have to keep
|
||||||
// separate variable for the state that won't cause useEffect eval
|
// separate variable for the state that won't cause useEffect eval
|
||||||
const currentPosition = useRef({ x: 0, y: 0 });
|
const currentPosition = useRef(initial);
|
||||||
|
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
isPanning: false,
|
isPanning: false,
|
||||||
position: { x: 0, y: 0 },
|
position: initial,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -92,8 +113,8 @@ export function usePanning<T extends Element>(
|
|||||||
|
|
||||||
// Add the diff to the position from the moment we started panning.
|
// Add the diff to the position from the moment we started panning.
|
||||||
currentPosition.current = {
|
currentPosition.current = {
|
||||||
x: inBounds(prevPosition.current.x + xDiff / scale, bounds?.left, bounds?.right),
|
x: inBounds(prevPosition.current.x + xDiff / scale, viewBounds.left, viewBounds.right),
|
||||||
y: inBounds(prevPosition.current.y + yDiff / scale, bounds?.top, bounds?.bottom),
|
y: inBounds(prevPosition.current.y + yDiff / scale, viewBounds.top, viewBounds.bottom),
|
||||||
};
|
};
|
||||||
setState((state) => ({
|
setState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
@ -116,9 +137,45 @@ export function usePanning<T extends Element>(
|
|||||||
ref.removeEventListener('touchstart', onPanStart);
|
ref.removeEventListener('touchstart', onPanStart);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [scale, bounds?.left, bounds?.right, bounds?.top, bounds?.bottom, isMounted]);
|
}, [scale, viewBounds, isMounted]);
|
||||||
|
|
||||||
return { state, ref: panRef };
|
const previousFocus = usePrevious(focus);
|
||||||
|
|
||||||
|
// We need to update the state in case need to focus on something but we want to do it only once when the focus
|
||||||
|
// changes to something new.
|
||||||
|
useEffect(() => {
|
||||||
|
if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) {
|
||||||
|
const position = {
|
||||||
|
x: inBounds(focus.x, viewBounds.left, viewBounds.right),
|
||||||
|
y: inBounds(focus.y, viewBounds.top, viewBounds.bottom),
|
||||||
|
};
|
||||||
|
setState({
|
||||||
|
position,
|
||||||
|
isPanning: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPosition.current = position;
|
||||||
|
prevPosition.current = position;
|
||||||
|
}
|
||||||
|
}, [focus, previousFocus, viewBounds, currentPosition, prevPosition]);
|
||||||
|
|
||||||
|
let position = state.position;
|
||||||
|
// This part prevents an ugly jump from initial position to the focused one as the set state in the effects is after
|
||||||
|
// initial render.
|
||||||
|
if (focus && previousFocus?.x !== focus.x && previousFocus?.y !== focus.y) {
|
||||||
|
position = focus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
position: {
|
||||||
|
x: inBounds(position.x, viewBounds.left, viewBounds.right),
|
||||||
|
y: inBounds(position.y, viewBounds.top, viewBounds.bottom),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ref: panRef,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function inBounds(value: number, min: number | undefined, max: number | undefined) {
|
function inBounds(value: number, min: number | undefined, max: number | undefined) {
|
195
public/app/plugins/panel/nodeGraph/utils.test.ts
Normal file
195
public/app/plugins/panel/nodeGraph/utils.test.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { ArrayVector, createTheme } from '@grafana/data';
|
||||||
|
import { makeEdgesDataFrame, makeNodesDataFrame, processNodes } from './utils';
|
||||||
|
|
||||||
|
describe('processNodes', () => {
|
||||||
|
const theme = createTheme();
|
||||||
|
|
||||||
|
it('handles empty args', async () => {
|
||||||
|
expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns proper nodes and edges', async () => {
|
||||||
|
const { nodes, edges, legend } = processNodes(
|
||||||
|
makeNodesDataFrame(3),
|
||||||
|
makeEdgesDataFrame([
|
||||||
|
[0, 1],
|
||||||
|
[0, 2],
|
||||||
|
[1, 2],
|
||||||
|
]),
|
||||||
|
theme
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nodes).toEqual([
|
||||||
|
{
|
||||||
|
arcSections: [
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
color: {
|
||||||
|
fixedColor: 'green',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'arc__success',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
color: {
|
||||||
|
fixedColor: 'red',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'arc__errors',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color: 'rgb(226, 192, 61)',
|
||||||
|
dataFrameRowIndex: 0,
|
||||||
|
id: '0',
|
||||||
|
incoming: 0,
|
||||||
|
mainStat: {
|
||||||
|
config: {},
|
||||||
|
index: 3,
|
||||||
|
name: 'mainStat',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.1, 0.1, 0.1]),
|
||||||
|
},
|
||||||
|
secondaryStat: {
|
||||||
|
config: {},
|
||||||
|
index: 4,
|
||||||
|
name: 'secondaryStat',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([2, 2, 2]),
|
||||||
|
},
|
||||||
|
subTitle: 'service',
|
||||||
|
title: 'service:0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
arcSections: [
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
color: {
|
||||||
|
fixedColor: 'green',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'arc__success',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
color: {
|
||||||
|
fixedColor: 'red',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'arc__errors',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color: 'rgb(226, 192, 61)',
|
||||||
|
dataFrameRowIndex: 1,
|
||||||
|
id: '1',
|
||||||
|
incoming: 1,
|
||||||
|
mainStat: {
|
||||||
|
config: {},
|
||||||
|
index: 3,
|
||||||
|
name: 'mainStat',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.1, 0.1, 0.1]),
|
||||||
|
},
|
||||||
|
secondaryStat: {
|
||||||
|
config: {},
|
||||||
|
index: 4,
|
||||||
|
name: 'secondaryStat',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([2, 2, 2]),
|
||||||
|
},
|
||||||
|
subTitle: 'service',
|
||||||
|
title: 'service:1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
arcSections: [
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
color: {
|
||||||
|
fixedColor: 'green',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'arc__success',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
color: {
|
||||||
|
fixedColor: 'red',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
name: 'arc__errors',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.5, 0.5, 0.5]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
color: 'rgb(226, 192, 61)',
|
||||||
|
dataFrameRowIndex: 2,
|
||||||
|
id: '2',
|
||||||
|
incoming: 2,
|
||||||
|
mainStat: {
|
||||||
|
config: {},
|
||||||
|
index: 3,
|
||||||
|
name: 'mainStat',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([0.1, 0.1, 0.1]),
|
||||||
|
},
|
||||||
|
secondaryStat: {
|
||||||
|
config: {},
|
||||||
|
index: 4,
|
||||||
|
name: 'secondaryStat',
|
||||||
|
type: 'number',
|
||||||
|
values: new ArrayVector([2, 2, 2]),
|
||||||
|
},
|
||||||
|
subTitle: 'service',
|
||||||
|
title: 'service:2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(edges).toEqual([
|
||||||
|
{
|
||||||
|
dataFrameRowIndex: 0,
|
||||||
|
id: '0--1',
|
||||||
|
mainStat: '',
|
||||||
|
secondaryStat: '',
|
||||||
|
source: '0',
|
||||||
|
target: '1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataFrameRowIndex: 1,
|
||||||
|
id: '0--2',
|
||||||
|
mainStat: '',
|
||||||
|
secondaryStat: '',
|
||||||
|
source: '0',
|
||||||
|
target: '2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataFrameRowIndex: 2,
|
||||||
|
id: '1--2',
|
||||||
|
mainStat: '',
|
||||||
|
secondaryStat: '',
|
||||||
|
source: '1',
|
||||||
|
target: '2',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(legend).toEqual([
|
||||||
|
{
|
||||||
|
color: 'green',
|
||||||
|
name: 'arc__success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
color: 'red',
|
||||||
|
name: 'arc__errors',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
@ -7,9 +7,9 @@ import {
|
|||||||
getFieldColorModeForField,
|
getFieldColorModeForField,
|
||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
MutableDataFrame,
|
MutableDataFrame,
|
||||||
|
NodeGraphDataFrameFieldNames,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
import { EdgeDatum, NodeDatum } from './types';
|
||||||
import { NodeGraphDataFrameFieldNames } from './index';
|
|
||||||
|
|
||||||
type Line = { x1: number; y1: number; x2: number; y2: number };
|
type Line = { x1: number; y1: number; x2: number; y2: number };
|
||||||
|
|
||||||
@ -38,26 +38,26 @@ export function shortenLine(line: Line, length: number): Line {
|
|||||||
export function getNodeFields(nodes: DataFrame) {
|
export function getNodeFields(nodes: DataFrame) {
|
||||||
const fieldsCache = new FieldCache(nodes);
|
const fieldsCache = new FieldCache(nodes);
|
||||||
return {
|
return {
|
||||||
id: fieldsCache.getFieldByName(DataFrameFieldNames.id),
|
id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id),
|
||||||
title: fieldsCache.getFieldByName(DataFrameFieldNames.title),
|
title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title),
|
||||||
subTitle: fieldsCache.getFieldByName(DataFrameFieldNames.subTitle),
|
subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle),
|
||||||
mainStat: fieldsCache.getFieldByName(DataFrameFieldNames.mainStat),
|
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat),
|
||||||
secondaryStat: fieldsCache.getFieldByName(DataFrameFieldNames.secondaryStat),
|
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat),
|
||||||
arc: findFieldsByPrefix(nodes, DataFrameFieldNames.arc),
|
arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc),
|
||||||
details: findFieldsByPrefix(nodes, DataFrameFieldNames.detail),
|
details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail),
|
||||||
color: fieldsCache.getFieldByName(DataFrameFieldNames.color),
|
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEdgeFields(edges: DataFrame) {
|
export function getEdgeFields(edges: DataFrame) {
|
||||||
const fieldsCache = new FieldCache(edges);
|
const fieldsCache = new FieldCache(edges);
|
||||||
return {
|
return {
|
||||||
id: fieldsCache.getFieldByName(DataFrameFieldNames.id),
|
id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id),
|
||||||
source: fieldsCache.getFieldByName(DataFrameFieldNames.source),
|
source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source),
|
||||||
target: fieldsCache.getFieldByName(DataFrameFieldNames.target),
|
target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target),
|
||||||
mainStat: fieldsCache.getFieldByName(DataFrameFieldNames.mainStat),
|
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat),
|
||||||
secondaryStat: fieldsCache.getFieldByName(DataFrameFieldNames.secondaryStat),
|
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat),
|
||||||
details: findFieldsByPrefix(edges, DataFrameFieldNames.detail),
|
details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,19 +65,6 @@ function findFieldsByPrefix(frame: DataFrame, prefix: string) {
|
|||||||
return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix)));
|
return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum DataFrameFieldNames {
|
|
||||||
id = 'id',
|
|
||||||
title = 'title',
|
|
||||||
subTitle = 'subTitle',
|
|
||||||
mainStat = 'mainStat',
|
|
||||||
secondaryStat = 'secondaryStat',
|
|
||||||
source = 'source',
|
|
||||||
target = 'target',
|
|
||||||
detail = 'detail__',
|
|
||||||
arc = 'arc__',
|
|
||||||
color = 'color',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform nodes and edges dataframes into array of objects that the layout code can then work with.
|
* Transform nodes and edges dataframes into array of objects that the layout code can then work with.
|
||||||
*/
|
*/
|
||||||
@ -85,7 +72,14 @@ export function processNodes(
|
|||||||
nodes: DataFrame | undefined,
|
nodes: DataFrame | undefined,
|
||||||
edges: DataFrame | undefined,
|
edges: DataFrame | undefined,
|
||||||
theme: GrafanaTheme2
|
theme: GrafanaTheme2
|
||||||
): { nodes: NodeDatum[]; edges: EdgeDatum[] } {
|
): {
|
||||||
|
nodes: NodeDatum[];
|
||||||
|
edges: EdgeDatum[];
|
||||||
|
legend?: Array<{
|
||||||
|
color: string;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
} {
|
||||||
if (!nodes) {
|
if (!nodes) {
|
||||||
return { nodes: [], edges: [] };
|
return { nodes: [], edges: [] };
|
||||||
}
|
}
|
||||||
@ -103,14 +97,9 @@ export function processNodes(
|
|||||||
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '',
|
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '',
|
||||||
dataFrameRowIndex: index,
|
dataFrameRowIndex: index,
|
||||||
incoming: 0,
|
incoming: 0,
|
||||||
mainStat: nodeFields.mainStat ? statToString(nodeFields.mainStat, index) : '',
|
mainStat: nodeFields.mainStat,
|
||||||
secondaryStat: nodeFields.secondaryStat ? statToString(nodeFields.secondaryStat, index) : '',
|
secondaryStat: nodeFields.secondaryStat,
|
||||||
arcSections: nodeFields.arc.map((f) => {
|
arcSections: nodeFields.arc,
|
||||||
return {
|
|
||||||
value: f.values.get(index),
|
|
||||||
color: f.config.color?.fixedColor || '',
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
color: nodeFields.color ? getColor(nodeFields.color, index, theme) : '',
|
color: nodeFields.color ? getColor(nodeFields.color, index, theme) : '',
|
||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
@ -144,10 +133,16 @@ export function processNodes(
|
|||||||
return {
|
return {
|
||||||
nodes: Object.values(nodesMap),
|
nodes: Object.values(nodesMap),
|
||||||
edges: edgesMapped || [],
|
edges: edgesMapped || [],
|
||||||
|
legend: nodeFields.arc.map((f) => {
|
||||||
|
return {
|
||||||
|
color: f.config.color?.fixedColor ?? '',
|
||||||
|
name: f.config.displayName || f.name,
|
||||||
|
};
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function statToString(field: Field, index: number) {
|
export function statToString(field: Field, index: number) {
|
||||||
if (field.type === FieldType.string) {
|
if (field.type === FieldType.string) {
|
||||||
return field.values.get(index);
|
return field.values.get(index);
|
||||||
} else {
|
} else {
|
||||||
@ -283,3 +278,53 @@ function getColor(field: Field, index: number, theme: GrafanaTheme2): string {
|
|||||||
|
|
||||||
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
|
return getFieldColorModeForField(field).getCalculator(field, theme)(0, field.values.get(index));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Bounds {
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
left: number;
|
||||||
|
center: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get bounds of the graph meaning the extent of the nodes in all directions.
|
||||||
|
*/
|
||||||
|
export function graphBounds(nodes: NodeDatum[]): Bounds {
|
||||||
|
if (nodes.length === 0) {
|
||||||
|
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = nodes.reduce(
|
||||||
|
(acc, node) => {
|
||||||
|
if (node.x! > acc.right) {
|
||||||
|
acc.right = node.x!;
|
||||||
|
}
|
||||||
|
if (node.x! < acc.left) {
|
||||||
|
acc.left = node.x!;
|
||||||
|
}
|
||||||
|
if (node.y! > acc.bottom) {
|
||||||
|
acc.bottom = node.y!;
|
||||||
|
}
|
||||||
|
if (node.y! < acc.top) {
|
||||||
|
acc.top = node.y!;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
|
||||||
|
);
|
||||||
|
|
||||||
|
const y = bounds.top + (bounds.bottom - bounds.top) / 2;
|
||||||
|
const x = bounds.left + (bounds.right - bounds.left) / 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
center: {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
@ -158,6 +158,10 @@ module.exports = {
|
|||||||
loader: 'file-loader',
|
loader: 'file-loader',
|
||||||
options: { name: 'static/img/[name].[hash:8].[ext]' },
|
options: { name: 'static/img/[name].[hash:8].[ext]' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
test: /\.worker\.js$/,
|
||||||
|
use: { loader: 'worker-loader' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3
|
// https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3
|
||||||
|
@ -25547,6 +25547,14 @@ worker-farm@^1.7.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
errno "~0.1.7"
|
errno "~0.1.7"
|
||||||
|
|
||||||
|
worker-loader@^3.0.8:
|
||||||
|
version "3.0.8"
|
||||||
|
resolved "https://registry.yarnpkg.com/worker-loader/-/worker-loader-3.0.8.tgz#5fc5cda4a3d3163d9c274a4e3a811ce8b60dbb37"
|
||||||
|
integrity sha512-XQyQkIFeRVC7f7uRhFdNMe/iJOdO6zxAaR3EWbDp45v3mDhrTi+++oswKNxShUNjPC/1xUp5DB29YKLhFo129g==
|
||||||
|
dependencies:
|
||||||
|
loader-utils "^2.0.0"
|
||||||
|
schema-utils "^3.0.0"
|
||||||
|
|
||||||
worker-rpc@^0.1.0:
|
worker-rpc@^0.1.0:
|
||||||
version "0.1.1"
|
version "0.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/worker-rpc/-/worker-rpc-0.1.1.tgz#cb565bd6d7071a8f16660686051e969ad32f54d5"
|
resolved "https://registry.yarnpkg.com/worker-rpc/-/worker-rpc-0.1.1.tgz#cb565bd6d7071a8f16660686051e969ad32f54d5"
|
||||||
|
Loading…
Reference in New Issue
Block a user