mirror of
https://github.com/grafana/grafana.git
synced 2025-07-29 14:52:26 +08: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:
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,
|
||||
},
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user