Skip to content

Commit 5f326e9

Browse files
authored
NodeGraph: Add msagl and the layered layout code (grafana#88375)
1 parent 07debd6 commit 5f326e9

File tree

10 files changed

+398
-5
lines changed

10 files changed

+398
-5
lines changed

jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const esModules = [
1717
'monaco-promql',
1818
'@kusto/monaco-kusto',
1919
'monaco-editor',
20+
'@msagl',
2021
'lodash-es',
2122
'vscode-languageserver-types',
2223
].join('|');

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@
271271
"@locker/near-membrane-dom": "0.13.6",
272272
"@locker/near-membrane-shared": "0.13.6",
273273
"@locker/near-membrane-shared-dom": "0.13.6",
274+
"@msagl/core": "^1.1.19",
275+
"@msagl/parser": "^1.1.19",
274276
"@opentelemetry/api": "1.8.0",
275277
"@opentelemetry/exporter-collector": "0.25.0",
276278
"@opentelemetry/semantic-conventions": "1.24.1",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import { CorsWorker as Worker } from 'app/core/utils/CorsWorker';
22

33
export const createWorker = () => new Worker(new URL('./layout.worker.js', import.meta.url));
4+
export const createMsaglWorker = () => new Worker(new URL('./layeredLayout.worker.js', import.meta.url));
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import {
2+
GeomGraph,
3+
GeomEdge,
4+
GeomNode,
5+
Point,
6+
CurveFactory,
7+
SugiyamaLayoutSettings,
8+
LayerDirectionEnum,
9+
layoutGeomGraph,
10+
} from '@msagl/core';
11+
import { parseDot } from '@msagl/parser';
12+
13+
/**
14+
* Use d3 force layout to lay the nodes in a sensible way. This function modifies the nodes adding the x,y positions
15+
* and also fills in node references in edges instead of node ids.
16+
*/
17+
export function layout(nodes, edges) {
18+
const { mappedEdges, DOTToIdMap } = createMappings(nodes, edges);
19+
20+
const dot = graphToDOT(mappedEdges, DOTToIdMap);
21+
const graph = parseDot(dot);
22+
const geomGraph = new GeomGraph(graph);
23+
for (const e of graph.deepEdges) {
24+
new GeomEdge(e);
25+
}
26+
27+
for (const n of graph.nodesBreadthFirst) {
28+
const gn = new GeomNode(n);
29+
gn.boundaryCurve = CurveFactory.mkCircle(50, new Point(0, 0));
30+
}
31+
geomGraph.layoutSettings = new SugiyamaLayoutSettings();
32+
geomGraph.layoutSettings.layerDirection = LayerDirectionEnum.LR;
33+
geomGraph.layoutSettings.LayerSeparation = 60;
34+
geomGraph.layoutSettings.commonSettings.NodeSeparation = 40;
35+
layoutGeomGraph(geomGraph);
36+
37+
const nodesMap = {};
38+
for (const node of geomGraph.nodesBreadthFirst) {
39+
nodesMap[DOTToIdMap[node.id]] = {
40+
obj: node,
41+
};
42+
}
43+
44+
for (const node of nodes) {
45+
nodesMap[node.id] = {
46+
...nodesMap[node.id],
47+
datum: {
48+
...node,
49+
x: nodesMap[node.id].obj.center.x,
50+
y: nodesMap[node.id].obj.center.y,
51+
},
52+
};
53+
}
54+
const edgesMapped = edges.map((e) => {
55+
return {
56+
...e,
57+
source: nodesMap[e.source].datum,
58+
target: nodesMap[e.target].datum,
59+
};
60+
});
61+
62+
// This section checks if there are separate disjointed subgraphs. If so it groups nodes for each and then aligns
63+
// each subgraph, so it starts on a single vertical line. Otherwise, they are laid out randomly from left to right.
64+
const subgraphs = [];
65+
for (const e of edgesMapped) {
66+
const sourceGraph = subgraphs.find((g) => g.nodes.has(e.source));
67+
const targetGraph = subgraphs.find((g) => g.nodes.has(e.target));
68+
if (sourceGraph && targetGraph) {
69+
// if the node sets are not the same we merge them
70+
if (sourceGraph !== targetGraph) {
71+
targetGraph.nodes.forEach(sourceGraph.nodes.add, sourceGraph.nodes);
72+
subgraphs.splice(subgraphs.indexOf(targetGraph), 1);
73+
sourceGraph.top = Math.min(sourceGraph.top, targetGraph.top);
74+
sourceGraph.bottom = Math.max(sourceGraph.bottom, targetGraph.bottom);
75+
sourceGraph.left = Math.min(sourceGraph.left, targetGraph.left);
76+
sourceGraph.right = Math.max(sourceGraph.right, targetGraph.right);
77+
}
78+
// if the sets are the same nothing to do.
79+
} else if (sourceGraph) {
80+
sourceGraph.nodes.add(e.target);
81+
sourceGraph.top = Math.min(sourceGraph.top, e.target.y);
82+
sourceGraph.bottom = Math.max(sourceGraph.bottom, e.target.y);
83+
sourceGraph.left = Math.min(sourceGraph.left, e.target.x);
84+
sourceGraph.right = Math.max(sourceGraph.right, e.target.x);
85+
} else if (targetGraph) {
86+
targetGraph.nodes.add(e.source);
87+
targetGraph.top = Math.min(targetGraph.top, e.source.y);
88+
targetGraph.bottom = Math.max(targetGraph.bottom, e.source.y);
89+
targetGraph.left = Math.min(targetGraph.left, e.source.x);
90+
targetGraph.right = Math.max(targetGraph.right, e.source.x);
91+
} else {
92+
// we don't have these nodes
93+
subgraphs.push({
94+
top: Math.min(e.source.y, e.target.y),
95+
bottom: Math.max(e.source.y, e.target.y),
96+
left: Math.min(e.source.x, e.target.x),
97+
right: Math.max(e.source.x, e.target.x),
98+
nodes: new Set([e.source, e.target]),
99+
});
100+
}
101+
}
102+
103+
let top = 0;
104+
let left = 0;
105+
for (const g of subgraphs) {
106+
if (top === 0) {
107+
top = g.bottom + 200;
108+
left = g.left;
109+
} else {
110+
const topDiff = top - g.top;
111+
const leftDiff = left - g.left;
112+
for (const n of g.nodes) {
113+
n.x += leftDiff;
114+
n.y += topDiff;
115+
}
116+
top += g.bottom - g.top + 200;
117+
}
118+
}
119+
120+
const finalNodes = Object.values(nodesMap).map((v) => v.datum);
121+
122+
centerNodes(finalNodes);
123+
return [finalNodes, edgesMapped];
124+
}
125+
126+
// We create mapping because the DOT language we use later to create the graph doesn't support arbitrary IDs. So we
127+
// map our IDs to just an index of the node so the IDs are safe for the DOT parser and also create and inverse mapping
128+
// for quick lookup.
129+
function createMappings(nodes, edges) {
130+
// Edges where the source and target IDs are the indexes we use for layout
131+
const mappedEdges = [];
132+
133+
// Key is an ID of the node and value is new ID which is just iteration index
134+
const idToDOTMap = {};
135+
136+
// Key is an iteration index and value is actual ID of the node
137+
const DOTToIdMap = {};
138+
139+
let index = 0;
140+
for (const node of nodes) {
141+
idToDOTMap[node.id] = index.toString(10);
142+
DOTToIdMap[index.toString(10)] = node.id;
143+
index++;
144+
}
145+
146+
for (const edge of edges) {
147+
mappedEdges.push({ source: idToDOTMap[edge.source], target: idToDOTMap[edge.target] });
148+
}
149+
150+
return {
151+
mappedEdges,
152+
DOTToIdMap,
153+
idToDOTMap,
154+
};
155+
}
156+
157+
function graphToDOT(edges, nodeIDsMap) {
158+
let dot = `
159+
digraph G {
160+
rankdir="LR"; TBbalance="min"
161+
`;
162+
for (const edge of edges) {
163+
dot += edge.source + '->' + edge.target + ' ' + '[ minlen=3 ]\n';
164+
}
165+
dot += nodesDOT(nodeIDsMap);
166+
dot += '}';
167+
return dot;
168+
}
169+
170+
function nodesDOT(nodeIdsMap) {
171+
let dot = '';
172+
for (const node of Object.keys(nodeIdsMap)) {
173+
dot += node + ' [fixedsize=true, width=1.2, height=1.7] \n';
174+
}
175+
return dot;
176+
}
177+
178+
/**
179+
* Makes sure that the center of the graph based on its bound is in 0, 0 coordinates.
180+
* Modifies the nodes directly.
181+
*/
182+
function centerNodes(nodes) {
183+
const bounds = graphBounds(nodes);
184+
for (let node of nodes) {
185+
node.x = node.x - bounds.center.x;
186+
node.y = node.y - bounds.center.y;
187+
}
188+
}
189+
190+
/**
191+
* Get bounds of the graph meaning the extent of the nodes in all directions.
192+
*/
193+
function graphBounds(nodes) {
194+
if (nodes.length === 0) {
195+
return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } };
196+
}
197+
198+
const bounds = nodes.reduce(
199+
(acc, node) => {
200+
if (node.x > acc.right) {
201+
acc.right = node.x;
202+
}
203+
if (node.x < acc.left) {
204+
acc.left = node.x;
205+
}
206+
if (node.y > acc.bottom) {
207+
acc.bottom = node.y;
208+
}
209+
if (node.y < acc.top) {
210+
acc.top = node.y;
211+
}
212+
return acc;
213+
},
214+
{ top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity }
215+
);
216+
217+
const y = bounds.top + (bounds.bottom - bounds.top) / 2;
218+
const x = bounds.left + (bounds.right - bounds.left) / 2;
219+
220+
return {
221+
...bounds,
222+
center: {
223+
x,
224+
y,
225+
},
226+
};
227+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { layout } from './layeredLayout';
2+
3+
describe('layout', () => {
4+
it('can render single node', () => {
5+
const nodes = [{ id: 'A', incoming: 0 }];
6+
const edges: unknown[] = [];
7+
const graph = layout(nodes, edges);
8+
expect(graph).toEqual([[{ id: 'A', incoming: 0, x: 0, y: 0 }], []]);
9+
});
10+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { layout } from './layeredLayout';
2+
3+
// Separate from main implementation so it does not trip out tests
4+
addEventListener('message', async (event) => {
5+
const { nodes, edges, config } = event.data;
6+
const [newNodes, newEdges] = layout(nodes, edges, config);
7+
postMessage({ nodes: newNodes, edges: newEdges });
8+
});

public/app/plugins/panel/nodeGraph/layout.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ jest.mock('./createLayoutWorker', () => {
2727
};
2828
return worker;
2929
},
30+
createMsaglWorker: () => {
31+
onmessage = jest.fn();
32+
postMessage = jest.fn();
33+
terminate = jest.fn();
34+
worker = {
35+
onmessage: onmessage,
36+
postMessage: postMessage,
37+
terminate: terminate,
38+
};
39+
return worker;
40+
},
3041
};
3142
});
3243

public/app/plugins/panel/nodeGraph/layout.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { useUnmount } from 'react-use';
44
import useMountedState from 'react-use/lib/useMountedState';
55

66
import { Field } from '@grafana/data';
7+
import { config as grafanaConfig } from '@grafana/runtime';
78

8-
import { createWorker } from './createLayoutWorker';
9+
import { createWorker, createMsaglWorker } from './createLayoutWorker';
910
import { EdgeDatum, EdgeDatumLayout, NodeDatum } from './types';
1011
import { useNodeLimit } from './useNodeLimit';
1112
import { graphBounds } from './utils';
@@ -102,10 +103,14 @@ export function useLayout(
102103
return;
103104
}
104105

106+
// Layered layout is better but also more expensive, so we switch to default force based layout for bigger graphs.
107+
const layoutType =
108+
grafanaConfig.featureToggles.nodeGraphDotLayout && rawNodes.length <= 500 ? 'layered' : 'default';
109+
105110
setLoading(true);
106111
// This is async but as I wanted to still run the sync grid layout, and you cannot return promise from effect so
107112
// having callback seems ok here.
108-
const cancel = layout(rawNodes, rawEdges, ({ nodes, edges }) => {
113+
const cancel = layout(rawNodes, rawEdges, layoutType, ({ nodes, edges }) => {
109114
if (isMounted()) {
110115
setNodesGraph(nodes);
111116
setEdgesGraph(edges);
@@ -167,11 +172,10 @@ export function useLayout(
167172
function layout(
168173
nodes: NodeDatum[],
169174
edges: EdgeDatum[],
175+
engine: 'default' | 'layered',
170176
done: (data: { nodes: NodeDatum[]; edges: EdgeDatumLayout[] }) => void
171177
) {
172-
// const worker = engine === 'default' ? createWorker() : createMsaglWorker();
173-
// TODO: temp fix because of problem with msagl library https://github.com/grafana/grafana/issues/83318
174-
const worker = createWorker();
178+
const worker = engine === 'default' ? createWorker() : createMsaglWorker();
175179

176180
worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => {
177181
const nodesMap = fromPairs(nodes.map((node) => [node.id, node]));

scripts/webpack/webpack.common.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,13 @@ module.exports = {
109109
test: /(unicons|mono|custom)[\\/].*\.svg$/,
110110
type: 'asset/source',
111111
},
112+
{
113+
// Required for msagl library (used in Nodegraph panel) to work
114+
test: /\.m?js$/,
115+
resolve: {
116+
fullySpecified: false,
117+
},
118+
},
112119
],
113120
},
114121
// https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3

0 commit comments

Comments
 (0)