#1295 Moving graph operations into mermaid-graplib and adding tests

This commit is contained in:
Knut Sveidqvist 2020-04-10 07:27:04 +02:00
parent 857c860952
commit 8455db6fae
6 changed files with 527 additions and 12 deletions

View File

@ -31,7 +31,7 @@
G-->H
G-->c
</div>
<div class="mermaid" style="width: 50%; height: 20%;">
<div class="mermaid2" style="width: 50%; height: 20%;">
stateDiagram-v2
[*] --> monkey
state monkey {
@ -70,7 +70,7 @@
Moving --> Crash
Crash --> [*]
</div>
<div class="mermaid2" style="width: 100%; height: 100%;">
<div class="mermaid" style="width: 100%; height: 100%;">
stateDiagram-v2
[*] --> First
First --> Second

View File

@ -1,3 +1,55 @@
# Cluster handling
Dagre does not support edges between nodes and clusters or between clusters to other clusters. In order to remedy this shortcoming the dagre wrapper implements a few work-arounds.
In the diagram below there are two clusters and there are no edges to nodes outside the own cluster.
```mermaid
flowchart
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
```
In this case the dagre-wrapper will transform the graph to the graph below.
```mermaid
flowchart
C1 --> C2
```
The new nodes C1 and C2 are a special type of nodes, clusterNodes. ClusterNodes have have the nodes in the cluster including the cluster attached in a graph object.
When rendering this diagram it it beeing rendered recursivly. The diagram is rendered by the dagre-mermaid:render function which in turn will be used to render the node C1 and the node C2. The result of those renderings will be inserted as nodes in the "root" diagram. With this recursive approach it would be possible to have different layout direction for each cluster.
```
{ clusterNode: true, graph }
```
*Data for a clusterNode*
When a cluster has edges to or from some of its nodes leading outside the cluster the approach of recursive rendering can not be used as the layout of the graph needs to take responsibility for nodes outside of the cluster.
```mermaid
flowchart
subgraph C1
a
end
subgraph C2
b
end
a --> C2
```
To handle this case a special type of edge is inserted. The edge to/from the cluster is replaced with an edge to/from a node in the cluster which is tagged with toCluster/fromCluster. When rendering this edge the intersection between the edge and the border of the cluster is calculated making the edge start/stop there. In practice this renders like an an edge to/from the cluster.
In the diagram above the root diagram would be rendered with C1 whereas C2 would be rendered recursively.
Of these two approaches the top one renders better and is used when possible. When this is not possible, ie an edge is added crossing the border the non recursive approach is used.
# Graph objects and their properties
Explains the representation of various objects used to render the flow charts and what the properties mean. This ofc from the perspective of the dagre-wrapper.

View File

@ -1,11 +1,12 @@
import dagre from 'dagre';
import insertMarkers from './markers';
import { clear as cleargraphlib, clusterDb, adjustClustersAndEdges } from './mermaid-graphlib';
import { insertNode, positionNode, clear as clearNodes } from './nodes';
import { insertCluster, clear as clearClusters } from './clusters';
import { insertEdgeLabel, positionEdgeLabel, insertEdge, clear as clearEdges } from './edges';
import { logger } from '../logger';
let clusterDb = {};
// let clusterDb = {};
const getAnchorId = id => {
// Only insert an achor once
@ -50,11 +51,12 @@ const findNonClusterChild = (id, graph) => {
export const render = (elem, graph, markers, diagramtype, id) => {
insertMarkers(elem, markers, diagramtype, id);
clusterDb = {};
clearNodes();
clearEdges();
clearClusters();
adjustClustersAndEdges(graph);
const clusters = elem.insert('g').attr('class', 'clusters'); // eslint-disable-line
const edgePaths = elem.insert('g').attr('class', 'edgePaths');
const edgeLabels = elem.insert('g').attr('class', 'edgeLabels');
@ -68,13 +70,9 @@ export const render = (elem, graph, markers, diagramtype, id) => {
if (node.type !== 'group') {
insertNode(nodes, graph.node(v));
} else {
// const width = getClusterTitleWidth(clusters, node);
const children = graph.children(v);
logger.info('Cluster identified', node.id, children[0], findNonClusterChild(node.id, graph));
// nodes2expand.push({ id: children[0], width });
clusterDb[node.id] = { id: findNonClusterChild(node.id, graph) };
// clusterDb[node.id] = { id: node.id + '_anchor' };
// const children = graph.children(v);
// logger.info('Cluster identified', node.id, children[0], findNonClusterChild(node.id, graph));
// clusterDb[node.id] = { id: findNonClusterChild(node.id, graph) };
}
});
logger.info('Clusters ', clusterDb);

View File

@ -0,0 +1,237 @@
/**
* Decorates with functions required by mermaids dagre-wrapper.
*/
import { logger } from '../logger';
import graphlib from 'graphlib';
export let clusterDb = {};
let decendants = {};
export const clear = () => {
decendants = {};
clusterDb = {};
};
const copy = (clusterId, graph, newGraph, rootId) => {
logger.trace('Copying ', clusterId);
const nodes = graph.children(clusterId);
nodes.forEach(node => {
if (graph.children(node).length > 0) {
copy(node, graph, newGraph, rootId);
}
const data = graph.node(node);
logger.trace(node, data, ' parent is ', clusterId);
newGraph.setNode(node, data);
newGraph.setParent(node, clusterId);
const edges = graph.edges(node);
graph.removeNode(node);
logger.trace('Edges', edges);
edges.forEach(edge => {
const data = graph.edge(edge);
// Do not copy edges in and out of the root cluster, they belong to the parent graph
if (!(edge.v === rootId || edge.w === rootId)) {
logger.trace('Copying as ', rootId, edge.v, edge.w, clusterId);
newGraph.setEdge(edge.v, edge.w, data);
} else {
logger.trace('Skipping copy of edge as ', rootId, edge.v, edge.w, clusterId);
}
});
});
newGraph.setNode(clusterId, graph.node(clusterId));
};
const extractDecendants = (id, graph) => {
const children = graph.children(id);
let res = [].concat(children);
for (let i = 0; i < children.length; i++) {
res = res.concat(extractDecendants(children[i], graph));
}
return res;
};
export const extractGraphFromCluster = (clusterId, graph) => {
const clusterGraph = new graphlib.Graph({
multigraph: true,
compound: true
})
.setGraph({
rankdir: 'TB',
// Todo: set proper spacing
nodesep: 10,
ranksep: 10,
marginx: 8,
marginy: 8
})
.setDefaultEdgeLabel(function() {
return {};
});
copy(clusterId, graph, clusterGraph, clusterId);
return clusterGraph;
};
/**
* Validates the graph, checking that all parent child relation points to existing nodes and that
* edges between nodes also ia correct. When not correct the function logs the discrepancies.
* @param {graphlib graph} g
*/
export const validate = graph => {
const edges = graph.edges();
logger.trace('Edges: ', edges);
for (let i = 0; i < edges.length; i++) {
if (graph.children(edges[i].v).length > 0) {
logger.trace('The node ', edges[i].v, ' is part of and edge even though it has children');
return false;
}
if (graph.children(edges[i].w).length > 0) {
logger.trace('The node ', edges[i].w, ' is part of and edge even though it has children');
return false;
}
}
return true;
};
/**
* Finds a child that is not a cluster. When faking a edge between a node and a cluster.
* @param {Finds a } id
* @param {*} graph
*/
const findNonClusterChild = (id, graph) => {
// const node = graph.node(id);
logger.trace('Searching', id);
const children = graph.children(id);
if (children.length < 1) {
logger.trace('This is a valid node', id);
return id;
}
for (let i = 0; i < children.length; i++) {
const _id = findNonClusterChild(children[i], graph);
if (_id) {
logger.trace('Found replacement for', id, ' => ', _id);
return _id;
}
}
};
const getAnchorId = id => {
if (!clusterDb[id]) {
return id;
}
// If the cluster has no external connections
if (!clusterDb[id].externalConnections) {
return id;
}
// Return the replacement node
if (clusterDb[id]) {
return clusterDb[id].id;
}
return id;
};
export const adjustClustersAndEdges = graph => {
// calc decendants, sa
// Go through the nodes and for each cluster found, save a replacment node, this can be used when
// faking a link to a cluster
graph.nodes().forEach(function(id) {
const children = graph.children(id);
if (children.length > 0) {
logger.trace(
'Cluster identified',
id,
' Replacement id in edges: ',
findNonClusterChild(id, graph)
);
decendants[id] = extractDecendants(id, graph);
clusterDb[id] = { id: findNonClusterChild(id, graph) };
}
});
// Check incoming and outgoing edges for each cluster
graph.nodes().forEach(function(id) {
const children = graph.children(id);
const edges = graph.edges();
if (children.length > 0) {
logger.trace('Cluster identified', id);
edges.forEach(edge => {
logger.trace('Edge: ', edge, decendants[id]);
// Check if any edge leaves the cluster (not the actual cluster, thats a link from the box)
if (edge.v !== id && edge.w !== id) {
if (decendants[id].indexOf(edge.v) < 0 || decendants[id].indexOf(edge.w) < 0) {
logger.trace('Edge: ', edge, ' leaves cluster ', id);
clusterDb[id].externalConnections = true;
}
}
});
}
});
// For clusters without incoming and/or outgoing edges, create a new cluster-node
// containing the nodes and edges in the custer in a new graph
// for (let i = 0;)
const clusters = Object.keys(clusterDb);
// clusters.forEach(clusterId => {
for (let i = 0; i < clusters.length; i++) {
const clusterId = clusters[i];
if (!clusterDb[clusterId].externalConnections) {
logger.trace('Cluster without external connections', clusterId);
const edges = graph.nodeEdges(clusterId);
// New graph with the nodes in the cluster
const clusterGraph = extractGraphFromCluster(clusterId, graph);
logger.trace('Cluster graph', clusterGraph.nodes());
logger.trace('Graph', graph.nodes());
// Create a new node in the original graph, this new node is not a cluster
// but a regular node with the cluster dontent as a new attached graph
graph.setNode(clusterId, { clusterNode: true, graph: clusterGraph });
// The original edges in and out of the cluster is applied
edges.forEach(edge => {
logger.trace('Setting edge', edge);
const data = graph.edge(edge);
graph.setEdge(edge.v, edge.w, data);
});
}
}
logger.trace('Graph', graph.nodes());
// For clusters with incoming and/or outgoing edges translate those edges to a real node
// in the cluster inorder to fake the edge
graph.edges().forEach(function(e) {
const edge = graph.edge(e);
logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e));
logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e)));
let v = e.v;
let w = e.w;
// Check if link is either from or to a cluster
logger.trace(
'Fix',
clusterDb,
'ids:',
e.v,
e.w,
'Translateing: ',
clusterDb[e.v],
clusterDb[e.w]
);
if (clusterDb[e.v] || clusterDb[e.w]) {
logger.trace('Fixing and trixing - removing', e.v, e.w, e.name);
v = getAnchorId(e.v);
w = getAnchorId(e.w);
graph.removeEdge(e.v, e.w, e.name);
if (v !== e.v) edge.fromCluster = e.v;
if (w !== e.w) edge.toCluster = e.w;
logger.trace('Replacing with', v, w, e.name);
graph.setEdge(v, w, edge, e.name);
}
});
logger.trace('Graph', graph.nodes());
logger.trace(clusterDb);
};

View File

@ -0,0 +1,228 @@
import graphlib from 'graphlib';
import dagre from 'dagre';
import { validate, adjustClustersAndEdges, extractGraphFromCluster } from './mermaid-graphlib';
import { setLogLevel, logger } from '../logger';
describe('Graphlib decorations', () => {
let g;
beforeEach(function () {
setLogLevel(1);
g = new graphlib.Graph({
multigraph: true,
compound: true
});
g.setGraph({
rankdir: 'TB',
nodesep: 10,
ranksep: 10,
marginx: 8,
marginy: 8
});
g.setDefaultEdgeLabel(function () {
return {};
});
// // Add node 'a' to the graph with no label
// g.setNode('a');
// // Add node 'b' to the graph with a String label
// g.setNode('b', 'b's value');
// // Add node 'c' to the graph with an Object label
// g.setNode('c', { k: 123 });
// // What nodes are in the graph?
// g.nodes();
// // => `[ 'a', 'b', 'c' ]`
// // Add a directed edge from 'a' to 'b', but assign no label
// g.setEdge('a', 'b');
// // Add a directed edge from 'c' to 'd' with an Object label.
// // Since 'd' did not exist prior to this call it is automatically
// // created with an undefined label.
// g.setEdge('c', 'd', { k: 456 });
// // What edges are in the graph?
// g.edges();
// // => `[ { v: 'a', w: 'b' },
// // { v: 'c', w: 'd' } ]`.
// // Which edges leave node 'a'?
// g.outEdges('a');
// // => `[ { v: 'a', w: 'b' } ]`
// // Which edges enter and leave node 'd'?
// g.nodeEdges('d');
// // => `[ { v: 'c', w: 'd' } ]`
});
describe('validate', function () {
it('Validate should detect edges between clusters', function () {
/*
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
*/
g.setNode('a', { data:1});
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C1');
g.setParent('b', 'C1');
g.setParent('c', 'C2');
g.setEdge('a', 'b');
g.setEdge('C1', 'C2');
console.log(g.nodes())
expect(validate(g)).toBe(false);
});
it('Validate should not detect edges between clusters after adjustment', function () {
/*
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
*/
g.setNode('a', {});
g.setNode('b', {});
g.setNode('c', {});
g.setParent('a', 'C1');
g.setParent('b', 'C1');
g.setParent('c', 'C2');
g.setEdge('a', 'b');
g.setEdge('C1', 'C2');
console.log(g.nodes())
adjustClustersAndEdges(g);
logger.info(g.edges())
expect(validate(g)).toBe(true);
});
it('It is possible to copy a cluster to a new graph 1', function () {
/*
a --> b
subgraph C1
subgraph C2
a
end
b
end
C1 --> c
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C2');
g.setParent('b', 'C1');
g.setParent('C2', 'C1');
g.setEdge('a', 'b', { name: 'C1-internal-link' });
g.setEdge('C1', 'c', { name: 'C1-external-link' });
const newGraph = extractGraphFromCluster('C1', g);
expect(newGraph.nodes().length).toBe(4);
expect(newGraph.edges().length).toBe(1);
logger.info(newGraph.children('C1'));
expect(newGraph.children('C2')).toEqual(['a']);
expect(newGraph.children('C1')).toEqual(['b', 'C2']);
expect(newGraph.edges('a')).toEqual([{ v: 'a', w: 'b' }]);
});
it('It is possible to extract a clusters to a new graph 2', function () {
/*
subgraph C1
a --> b
end
subgraph C2
c
end
C1 --> C2
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C1');
g.setParent('b', 'C1');
g.setParent('c', 'C2');
g.setEdge('a', 'b', { name: 'C1-internal-link' });
g.setEdge('C1', 'C2', { name: 'C1-external-link' });
const C1 = extractGraphFromCluster('C1', g);
const C2 = extractGraphFromCluster('C2', g);
expect(g.nodes()).toEqual(['C1', 'C2']);
expect(g.children('C1')).toEqual([]);
expect(g.children('C2')).toEqual([]);
expect(g.edges()).toEqual([{ v: 'C1', w: 'C2' }]);
logger.info(C1.nodes());
expect(C1.nodes()).toEqual(['a', 'C1', 'b']);
expect(C1.children('C1')).toEqual(['a', 'b']);
expect(C1.edges()).toEqual([{ v: 'a', w: 'b' }]);
expect(C2.nodes()).toEqual(['c', 'C2']);
expect(C2.edges()).toEqual([]);
});
it('It is possible to extract a cluster from a graph so that the nodes are removed from original graph', function () {
/*
a --> b
subgraph C1
subgraph C2
a
end
b
end
C1 --> c
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C2');
g.setParent('b', 'C1');
g.setParent('C2', 'C1');
g.setEdge('a', 'b', { name: 'C1-internal-link' });
g.setEdge('C1', 'c', { name: 'C1-external-link' });
const newGraph = extractGraphFromCluster('C1', g);
logger.info(g.nodes());
expect(g.nodes()).toEqual(['c','C1']);
expect(g.edges().length).toBe(1);
expect(g.children()).toEqual(['c','C1']);
expect(g.children('C1')).toEqual([]);
});
});
it('Validate should detect edges between clusters and transform clusters', function () {
/*
a --> b
subgraph C1
subgraph C2
a
end
b
end
C1 --> c
*/
g.setNode('a', { data: 1 });
g.setNode('b', { data: 2 });
g.setNode('c', { data: 3 });
g.setParent('a', 'C2');
g.setParent('b', 'C1');
g.setParent('C2', 'C1');
g.setEdge('a', 'b', { name: 'C1-internal-link' });
g.setEdge('C1', 'c', { name: 'C1-external-link' });
adjustClustersAndEdges(g);
logger.info(g.nodes())
expect(g.nodes().length).toBe(2);
expect(validate(g)).toBe(true);
});
});

View File

@ -1,5 +1,5 @@
import moment from 'moment-mini';
//
export const LEVELS = {
debug: 1,
info: 2,