From 365c7418641af4c92c4148cc8b346c9147d0ecb6 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 2 Apr 2020 19:35:12 +0200 Subject: [PATCH 01/12] #1295 Fix for intersection calculation for edges to clusters and adding concurrency in stateDiagrams as clusters --- cypress/platform/current.html | 70 ++++++++++++++++++----- src/dagre-wrapper/GraphObjects.md | 22 +++---- src/dagre-wrapper/createLabel.js | 6 +- src/dagre-wrapper/edges.js | 47 +++++---------- src/dagre-wrapper/index.js | 64 ++++++++++++++++----- src/diagrams/flowchart/flowRenderer-v2.js | 4 -- src/diagrams/state/stateDb.js | 41 ++++++++++++- src/diagrams/state/stateRenderer-v2.js | 22 +++---- src/utils.js | 16 +++++- 9 files changed, 194 insertions(+), 98 deletions(-) diff --git a/cypress/platform/current.html b/cypress/platform/current.html index cc4b9e862..6b52388a7 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -32,13 +32,31 @@ G-->c
- flowchart LR - subgraph id1 [Test] - b - end - a-->id1 + stateDiagram-v2 + [*] --> monkey + state monkey { + Sitting + -- + Eating + }
-
+
+ stateDiagram-v2 + state Active { + [*] --> NumLockOff + NumLockOff --> NumLockOn : EvNumLockPressed + NumLockOn --> NumLockOff : EvNumLockPressed + -- + [*] --> CapsLockOff + CapsLockOff --> CapsLockOn : EvCapsLockPressed + CapsLockOn --> CapsLockOff : EvCapsLockPressed + -- + [*] --> ScrollLockOff + ScrollLockOff --> ScrollLockOn : EvCapsLockPressed + ScrollLockOn --> ScrollLockOff : EvCapsLockPressed + } +
+
stateDiagram [*] --> Still Still --> [*] @@ -52,15 +70,39 @@ Moving --> Crash Crash --> [*]
+
+stateDiagram-v2 + [*] --> First + First --> Second +% First --> Third + + state First { + [*] --> fir + fir --> [*] + } + state Second { + [*] --> sec + sec --> [*] + } +
- stateDiagram-v2 - State1: The state with a note - note right of State1 - Important information! You can write - notes. - end note - State1 --> State2 - note left of State2 : This is the note to the left. +stateDiagram-v2 + [*] --> First + First --> Second + First --> Third + + state First { + [*] --> fir + fir --> [*] + } + state Second { + [*] --> sec + sec --> [*] + } + state Third { + [*] --> thi + thi --> [*] + }
stateDiagram-v2 diff --git a/src/dagre-wrapper/GraphObjects.md b/src/dagre-wrapper/GraphObjects.md index 8821c7c36..b21d7efef 100644 --- a/src/dagre-wrapper/GraphObjects.md +++ b/src/dagre-wrapper/GraphObjects.md @@ -7,12 +7,10 @@ Explains the representation of various objects used to render the flow charts an Sample object: ```json { - "labelType":"svg", - "labelStyle":"", "shape":"rect", - "label":{}, "labelText":"Test", - "rx":0,"ry":0, + "rx":0, + "ry":0, "class":"default", "style":"", "id":"Test", @@ -24,18 +22,16 @@ This is set by the renderer of the diagram and insert the data that the wrapper | property | description | | ---------- | ----------------------------------------------------------------------------------------------------------- | -| labelType | If the label should be html label or a svg label. Should we continue to support both? | -| labelStyle | Css styles for the label. Not currently used. | -| shape | The shape of the node. Currently on rect is suppoerted. This will change. | -| label | ?? | +| labelStyle | Css styles for the label. User for instance for stylling the labels for clusters | +| shape | The shape of the node. | | labelText | The text on the label | -| rx | The corner radius - maybe part of the shape instead? | -| ry | The corner radius - maybe part of the shape instead? | -| class | Class to be set for the shape | +| rx | The corner radius - maybe part of the shape instead? Used for rects. | +| ry | The corner radius - maybe part of the shape instead? Used for rects. | +| classes | Classes to be set for the shape. Not used | | style | Css styles for the actual shape | | id | id of the shape | -| type | if set to group then this node indicates *a cluster*. | -| padding | Padding. Passed from the renderr as this might differ between react for different diagrams. Maybe obsolete. | +| type | if set to group then this node indicates *a cluster*. | +| padding | Padding. Passed from the render as this might differ between different diagrams. Maybe obsolete. | # edge diff --git a/src/dagre-wrapper/createLabel.js b/src/dagre-wrapper/createLabel.js index b7edb1572..a029f59fd 100644 --- a/src/dagre-wrapper/createLabel.js +++ b/src/dagre-wrapper/createLabel.js @@ -1,8 +1,10 @@ const createLabel = (vertexText, style) => { const svgLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); svgLabel.setAttribute('style', style.replace('color:', 'fill:')); - - const rows = vertexText.split(/\n|/gi); + let rows = []; + if (vertexText) { + rows = vertexText.split(/\n|/gi); + } for (let j = 0; j < rows.length; j++) { const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan'); diff --git a/src/dagre-wrapper/edges.js b/src/dagre-wrapper/edges.js index ecf4254a8..6b141a863 100644 --- a/src/dagre-wrapper/edges.js +++ b/src/dagre-wrapper/edges.js @@ -63,34 +63,17 @@ const outsideNode = (node, point) => { return false; }; -// const intersection = (node, outsidePoint, insidePoint) => { -// const x = node.x; -// const y = node.y; - -// const dx = Math.abs(x - insidePoint.x); -// const w = node.width / 2; -// let r = w - dx; -// const dy = Math.abs(y - insidePoint.y); -// const h = node.height / 2; -// const q = h - dy; - -// const Q = Math.abs(outsidePoint.y - insidePoint.y); -// const R = Math.abs(outsidePoint.x - insidePoint.x); -// r = (R * q) / Q; - -// return { x: insidePoint.x + r, y: insidePoint.y + q }; -// }; const intersection = (node, outsidePoint, insidePoint) => { - // logger.info('intersection', outsidePoint, insidePoint, node); + logger.info('intersection o:', outsidePoint, ' i:', insidePoint, node); const x = node.x; const y = node.y; const dx = Math.abs(x - insidePoint.x); const w = node.width / 2; - let r = w - dx; + let r = insidePoint.x < outsidePoint.x ? w - dx : w + dx; const dy = Math.abs(y - insidePoint.y); const h = node.height / 2; - let q = h - dy; + let q = insidePoint.y < outsidePoint.y ? h - dy : h - dy; const Q = Math.abs(outsidePoint.y - insidePoint.y); const R = Math.abs(outsidePoint.x - insidePoint.x); @@ -105,9 +88,10 @@ const intersection = (node, outsidePoint, insidePoint) => { }; } else { q = (Q * r) / R; + r = (R * q) / Q; return { - x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x - r, + x: insidePoint.x < outsidePoint.x ? insidePoint.x + r : insidePoint.x + dx - w, y: insidePoint.y < outsidePoint.y ? insidePoint.y + q : insidePoint.y - q }; } @@ -117,8 +101,8 @@ export const insertEdge = function(elem, edge, clusterDb, diagramType) { logger.info('\n\n\n\n'); let points = edge.points; if (edge.toCluster) { - // logger.info('edge', edge); - // logger.info('to cluster', clusterDb[edge.toCluster]); + logger.info('edge', edge); + logger.info('to cluster', clusterDb[edge.toCluster]); points = []; let lastPointOutside; let isInside = false; @@ -126,13 +110,12 @@ export const insertEdge = function(elem, edge, clusterDb, diagramType) { const node = clusterDb[edge.toCluster].node; if (!outsideNode(node, point) && !isInside) { - // logger.info('inside', edge.toCluster, point); + logger.info('inside', edge.toCluster, point, lastPointOutside); // First point inside the rect const insterection = intersection(node, lastPointOutside, point); - // logger.info('intersect', inter.rect(node, lastPointOutside)); + logger.info('intersect', insterection); points.push(insterection); - // points.push(insterection); isInside = true; } else { if (!isInside) points.push(point); @@ -142,8 +125,8 @@ export const insertEdge = function(elem, edge, clusterDb, diagramType) { } if (edge.fromCluster) { - // logger.info('edge', edge); - // logger.info('from cluster', clusterDb[edge.toCluster]); + logger.info('edge', edge); + logger.info('from cluster', clusterDb[edge.toCluster]); const updatedPoints = []; let lastPointOutside; let isInside = false; @@ -152,7 +135,7 @@ export const insertEdge = function(elem, edge, clusterDb, diagramType) { const node = clusterDb[edge.fromCluster].node; if (!outsideNode(node, point) && !isInside) { - // logger.info('inside', edge.toCluster, point); + logger.info('inside', edge.toCluster, point); // First point inside the rect const insterection = intersection(node, lastPointOutside, point); @@ -162,7 +145,7 @@ export const insertEdge = function(elem, edge, clusterDb, diagramType) { isInside = true; } else { // at the outside - // logger.info('Outside point', point); + logger.info('Outside point', point); if (!isInside) updatedPoints.unshift(point); } lastPointOutside = point; @@ -170,10 +153,6 @@ export const insertEdge = function(elem, edge, clusterDb, diagramType) { points = updatedPoints; } - // logger.info('Poibts', points); - - // logger.info('Edge', edge); - // The data for our line const lineData = points.filter(p => !Number.isNaN(p.y)); diff --git a/src/dagre-wrapper/index.js b/src/dagre-wrapper/index.js index 8f5a5c731..be024b4d1 100644 --- a/src/dagre-wrapper/index.js +++ b/src/dagre-wrapper/index.js @@ -7,8 +7,28 @@ import { logger } from '../logger'; let clusterDb = {}; -const translateClusterId = id => { - if (clusterDb[id]) return clusterDb[id].id; +const getAnchorId = (id, graph, nodes) => { + // Only insert an achor once + if (clusterDb[id]) { + // if (!clusterDb[id].inserted) { + // // Create anchor node for cluster + // const anchorData = { + // shape: 'start', + // labelText: '', + // classes: '', + // style: '', + // id: id + '_anchor', + // type: 'anchor', + // padding: 0 + // }; + // insertNode(nodes, anchorData); + + // graph.setNode(anchorData.id, anchorData); + // graph.setParent(anchorData.id, id); + // clusterDb[id].inserted = true; + // } + return clusterDb[id].id; + } return id; }; @@ -24,24 +44,24 @@ export const render = (elem, graph, markers, diagramtype, id) => { const edgeLabels = elem.insert('g').attr('class', 'edgeLabels'); const nodes = elem.insert('g').attr('class', 'nodes'); - logger.warn('graph', graph); - // Insert nodes, this will insert them into the dom and each node will get a size. The size is updated // to the abstract node and is later used by dagre for the layout graph.nodes().forEach(function(v) { const node = graph.node(v); - logger.warn('Node ' + v + ': ' + JSON.stringify(graph.node(v))); + logger.info('Node ' + v + ': ' + JSON.stringify(graph.node(v))); 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]); // nodes2expand.push({ id: children[0], width }); clusterDb[node.id] = { id: children[0] }; - logger.info('Clusters ', clusterDb); + // clusterDb[node.id] = { id: node.id + '_anchor' }; } }); + logger.info('Clusters ', clusterDb); // Insert labels, this will insert them into the dom so that the width can be calculated // Also figure out which edges point to/from clusters and adjust them accordingly @@ -49,21 +69,37 @@ export const render = (elem, graph, markers, diagramtype, id) => { // TODO: pick optimal child in the cluster to us as link anchor graph.edges().forEach(function(e) { const edge = graph.edge(e); - logger.warn('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); - // logger.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e))); - const v = translateClusterId(e.v); - const w = translateClusterId(e.w); - if (v !== e.v || w !== e.w) { + logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); + logger.info('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.info( + 'Fix', + clusterDb, + 'ids:', + e.v, + e.w, + 'Translateing: ', + clusterDb[e.v], + clusterDb[e.w] + ); + if (clusterDb[e.v] || clusterDb[e.w]) { + logger.info('Fixing and trixing - rwemoving', e.v, e.w, e.name); + v = getAnchorId(e.v, graph, nodes); + w = getAnchorId(e.w, graph, nodes); 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.info('Fixing Replacing with', v, w, e.name); graph.setEdge(v, w, edge, e.name); } insertEdgeLabel(edgeLabels, edge); }); graph.edges().forEach(function(e) { - logger.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); + logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); }); logger.info('#############################################'); logger.info('### Layout ###'); @@ -74,7 +110,7 @@ export const render = (elem, graph, markers, diagramtype, id) => { // Move the nodes to the correct place graph.nodes().forEach(function(v) { const node = graph.node(v); - logger.info('Node ' + v + ': ' + JSON.stringify(graph.node(v))); + logger.trace('Node ' + v + ': ' + JSON.stringify(graph.node(v))); if (node.type !== 'group') { positionNode(node); } else { @@ -86,7 +122,7 @@ export const render = (elem, graph, markers, diagramtype, id) => { // Move the edge labels to the correct place after layout graph.edges().forEach(function(e) { const edge = graph.edge(e); - logger.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); + logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); insertEdge(edgePaths, edge, clusterDb, diagramtype); positionEdgeLabel(edge); diff --git a/src/diagrams/flowchart/flowRenderer-v2.js b/src/diagrams/flowchart/flowRenderer-v2.js index e8915fc08..e0cce8df8 100644 --- a/src/diagrams/flowchart/flowRenderer-v2.js +++ b/src/diagrams/flowchart/flowRenderer-v2.js @@ -130,10 +130,8 @@ export const addVertices = function(vert, g, svgId) { } // Add the node g.setNode(vertex.id, { - labelType: 'svg', labelStyle: styles.labelStyle, shape: _shape, - label: vertexNode, labelText: vertexText, rx: radious, ry: radious, @@ -146,10 +144,8 @@ export const addVertices = function(vert, g, svgId) { }); logger.info('setNode', { - labelType: 'svg', labelStyle: styles.labelStyle, shape: _shape, - label: vertexNode, labelText: vertexText, rx: radious, ry: radious, diff --git a/src/diagrams/state/stateDb.js b/src/diagrams/state/stateDb.js index d72e8f00c..c56dcd1a3 100644 --- a/src/diagrams/state/stateDb.js +++ b/src/diagrams/state/stateDb.js @@ -1,4 +1,7 @@ import { logger } from '../../logger'; +import { generateId } from '../../utils'; + +const clone = o => JSON.parse(JSON.stringify(o)); let rootDoc = []; const setRootDoc = o => { @@ -22,6 +25,34 @@ const docTranslator = (parent, node, first) => { } if (node.doc) { + const doc = []; + // Check for concurrency + let i = 0; + let currentDoc = []; + for (i = 0; i < node.doc.length; i++) { + if (node.doc[i].type === 'divider') { + // debugger; + const newNode = clone(node.doc[i]); + newNode.doc = clone(currentDoc); + doc.push(newNode); + currentDoc = []; + } else { + currentDoc.push(node.doc[i]); + } + } + + // If any divider was encountered + if (doc.length > 0 && currentDoc.length > 0) { + const newNode = { + stmt: 'state', + id: generateId(), + type: 'divider', + doc: clone(currentDoc) + }; + doc.push(clone(newNode)); + node.doc = doc; + } + node.doc.forEach(docNode => docTranslator(node, docNode, true)); } } @@ -31,8 +62,14 @@ const getRootDocV2 = () => { return { id: 'root', doc: rootDoc }; }; -const extract = doc => { +const extract = _doc => { // const res = { states: [], relations: [] }; + let doc; + if (_doc.doc) { + doc = _doc.doc; + } else { + doc = _doc; + } // let doc = root.doc; // if (!doc) { // doc = root; @@ -40,6 +77,8 @@ const extract = doc => { logger.info(doc); clear(); + logger.info('Extract', doc); + doc.forEach(item => { if (item.stmt === 'state') { addState(item.id, item.type, item.doc, item.description, item.note); diff --git a/src/diagrams/state/stateRenderer-v2.js b/src/diagrams/state/stateRenderer-v2.js index 76dc2f186..f69d94ed3 100644 --- a/src/diagrams/state/stateRenderer-v2.js +++ b/src/diagrams/state/stateRenderer-v2.js @@ -72,10 +72,8 @@ const setupNode = (g, parent, node, altFlag) => { } const nodeData = { - labelType: 'svg', labelStyle: '', shape: nodeDb[node.id].shape, - label: node.id, labelText: nodeDb[node.id].description, classes: nodeDb[node.id].classes, //classStr, style: '', //styles.style, @@ -87,10 +85,8 @@ const setupNode = (g, parent, node, altFlag) => { if (node.note) { // Todo: set random id const noteData = { - labelType: 'svg', labelStyle: '', shape: 'note', - label: node.id, labelText: node.note.text, classes: 'statediagram-note', //classStr, style: '', //styles.style, @@ -99,10 +95,8 @@ const setupNode = (g, parent, node, altFlag) => { padding: 15 //getConfig().flowchart.padding }; const groupData = { - labelType: 'svg', labelStyle: '', shape: 'noteGroup', - label: node.id + '----parent', labelText: node.note.text, classes: nodeDb[node.id].classes, //classStr, style: '', //styles.style, @@ -133,8 +127,7 @@ const setupNode = (g, parent, node, altFlag) => { classes: 'note-edge', arrowheadStyle: 'fill: #333', labelpos: 'c', - labelType: 'text', - label: '' + labelType: 'text' }); } else { g.setNode(node.id, nodeData); @@ -143,12 +136,12 @@ const setupNode = (g, parent, node, altFlag) => { if (parent) { if (parent.id !== 'root') { - logger.trace('Setting node ', node.id, ' to be child of its parent ', parent.id); + logger.info('Setting node ', node.id, ' to be child of its parent ', parent.id); g.setParent(node.id, parent.id); } } if (node.doc) { - logger.trace('Adding nodes children '); + logger.info('Adding nodes children '); setupDoc(g, node, node.doc, !altFlag); } }; @@ -168,8 +161,7 @@ const setupDoc = (g, parent, doc, altFlag) => { labelStyle: '', arrowheadStyle: 'fill: #333', labelpos: 'c', - labelType: 'text', - label: '' + labelType: 'text' }; let startId = item.state1.id; let endId = item.state2.id; @@ -214,7 +206,7 @@ export const draw = function(text, id) { compound: true }) .setGraph({ - rankdir: 'LR', + rankdir: 'TB', nodesep: nodeSpacing, ranksep: rankSpacing, marginx: 8, @@ -224,8 +216,8 @@ export const draw = function(text, id) { return {}; }); - // logger.info(stateDb.getRootDoc()); - stateDb.extract(stateDb.getRootDocV2().doc); + logger.info(stateDb.getRootDocV2()); + stateDb.extract(stateDb.getRootDocV2()); logger.info(stateDb.getRootDocV2()); setupNode(g, undefined, stateDb.getRootDocV2(), true); diff --git a/src/utils.js b/src/utils.js index 823191ffc..1adcb86f0 100644 --- a/src/utils.js +++ b/src/utils.js @@ -210,6 +210,19 @@ export const getStylesFromArray = arr => { return { style: style, labelStyle: labelStyle }; }; +let cnt = 0; +export const generateId = () => { + cnt++; + return ( + 'id-' + + Math.random() + .toString(36) + .substr(2, 12) + + '-' + + cnt + ); +}; + export default { detectType, isSubstringInArray, @@ -217,5 +230,6 @@ export default { calcLabelPosition, calcCardinalityPosition, formatUrl, - getStylesFromArray + getStylesFromArray, + generateId }; From cff68fc06212b4822c8df2ad6bd34796ecadae92 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 2 Apr 2020 19:48:28 +0200 Subject: [PATCH 02/12] #1295 Better way of finding suitable child in cluster to point to --- cypress/platform/current.html | 4 ++-- src/dagre-wrapper/index.js | 20 ++++++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cypress/platform/current.html b/cypress/platform/current.html index 6b52388a7..4031bf0de 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -31,7 +31,7 @@ G-->H G-->c
-
+
stateDiagram-v2 [*] --> monkey state monkey { @@ -85,7 +85,7 @@ stateDiagram-v2 sec --> [*] }
-
+
stateDiagram-v2 [*] --> First First --> Second diff --git a/src/dagre-wrapper/index.js b/src/dagre-wrapper/index.js index be024b4d1..6f8b9f7e3 100644 --- a/src/dagre-wrapper/index.js +++ b/src/dagre-wrapper/index.js @@ -32,6 +32,22 @@ const getAnchorId = (id, graph, nodes) => { return id; }; +const findNonClusterChild = (id, graph) => { + const node = graph.node(id); + logger.info('identified node', node); + if (node.type !== 'group') { + return node.id; + } + logger.info('identified node Not', node.id); + const children = graph.children(id); + for (let i = 0; i < children.length; i++) { + const _id = findNonClusterChild(children[i], graph); + if (_id) { + return _id; + } + } +}; + export const render = (elem, graph, markers, diagramtype, id) => { insertMarkers(elem, markers, diagramtype, id); clusterDb = {}; @@ -55,9 +71,9 @@ export const render = (elem, graph, markers, diagramtype, id) => { // const width = getClusterTitleWidth(clusters, node); const children = graph.children(v); - logger.info('Cluster identified', node.id, children[0]); + logger.info('Cluster identified', node.id, children[0], findNonClusterChild(node.id, graph)); // nodes2expand.push({ id: children[0], width }); - clusterDb[node.id] = { id: children[0] }; + clusterDb[node.id] = { id: findNonClusterChild(node.id, graph) }; // clusterDb[node.id] = { id: node.id + '_anchor' }; } }); From 857c86095222cad2262042ed4877675587b35760 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Thu, 2 Apr 2020 19:50:21 +0200 Subject: [PATCH 03/12] #1295 Lint fix --- src/dagre-wrapper/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dagre-wrapper/index.js b/src/dagre-wrapper/index.js index 6f8b9f7e3..332f005b7 100644 --- a/src/dagre-wrapper/index.js +++ b/src/dagre-wrapper/index.js @@ -7,7 +7,7 @@ import { logger } from '../logger'; let clusterDb = {}; -const getAnchorId = (id, graph, nodes) => { +const getAnchorId = id => { // Only insert an achor once if (clusterDb[id]) { // if (!clusterDb[id].inserted) { From 8455db6fae4c671f0a488e3498ed6f49dd5fb0ee Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Fri, 10 Apr 2020 07:27:04 +0200 Subject: [PATCH 04/12] #1295 Moving graph operations into mermaid-graplib and adding tests --- cypress/platform/current.html | 4 +- src/dagre-wrapper/GraphObjects.md | 52 +++++ src/dagre-wrapper/index.js | 16 +- src/dagre-wrapper/mermaid-graphlib.js | 237 +++++++++++++++++++++ src/dagre-wrapper/mermaid-graphlib.spec.js | 228 ++++++++++++++++++++ src/logger.js | 2 +- 6 files changed, 527 insertions(+), 12 deletions(-) create mode 100644 src/dagre-wrapper/mermaid-graphlib.js create mode 100644 src/dagre-wrapper/mermaid-graphlib.spec.js diff --git a/cypress/platform/current.html b/cypress/platform/current.html index 4031bf0de..abc63ea48 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -31,7 +31,7 @@ G-->H G-->c
-
+
stateDiagram-v2 [*] --> monkey state monkey { @@ -70,7 +70,7 @@ Moving --> Crash Crash --> [*]
-
+
stateDiagram-v2 [*] --> First First --> Second diff --git a/src/dagre-wrapper/GraphObjects.md b/src/dagre-wrapper/GraphObjects.md index b21d7efef..cde45591d 100644 --- a/src/dagre-wrapper/GraphObjects.md +++ b/src/dagre-wrapper/GraphObjects.md @@ -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. diff --git a/src/dagre-wrapper/index.js b/src/dagre-wrapper/index.js index 332f005b7..eb0427215 100644 --- a/src/dagre-wrapper/index.js +++ b/src/dagre-wrapper/index.js @@ -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); diff --git a/src/dagre-wrapper/mermaid-graphlib.js b/src/dagre-wrapper/mermaid-graphlib.js new file mode 100644 index 000000000..ae4e4086c --- /dev/null +++ b/src/dagre-wrapper/mermaid-graphlib.js @@ -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); +}; diff --git a/src/dagre-wrapper/mermaid-graphlib.spec.js b/src/dagre-wrapper/mermaid-graphlib.spec.js new file mode 100644 index 000000000..b842a455b --- /dev/null +++ b/src/dagre-wrapper/mermaid-graphlib.spec.js @@ -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); + }); +}); diff --git a/src/logger.js b/src/logger.js index b54ce6937..3b6b60f16 100644 --- a/src/logger.js +++ b/src/logger.js @@ -1,5 +1,5 @@ import moment from 'moment-mini'; - +// export const LEVELS = { debug: 1, info: 2, From 22e17172dd4e5dfbc54e832f5364f157f176f687 Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Sat, 11 Apr 2020 17:16:01 +0200 Subject: [PATCH 05/12] #1295 Recursive rendering, draft --- cypress/platform/current.html | 8 +- src/dagre-wrapper/clusters.js | 17 ++- src/dagre-wrapper/index.js | 162 +++++++++++---------- src/dagre-wrapper/mermaid-graphlib.js | 73 +++++++--- src/dagre-wrapper/mermaid-graphlib.spec.js | 31 +++- src/dagre-wrapper/nodes.js | 3 + src/diagrams/flowchart/flowRenderer-v2.js | 40 ++--- 7 files changed, 211 insertions(+), 123 deletions(-) diff --git a/cypress/platform/current.html b/cypress/platform/current.html index abc63ea48..50cb3ed3c 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -70,7 +70,7 @@ Moving --> Crash Crash --> [*]
-
+
stateDiagram-v2 [*] --> First First --> Second @@ -84,6 +84,12 @@ stateDiagram-v2 [*] --> sec sec --> [*] } +
+
+flowchart TD + subgraph Apa + a --> b + end
stateDiagram-v2 diff --git a/src/dagre-wrapper/clusters.js b/src/dagre-wrapper/clusters.js index 55db0b32e..02da85c58 100644 --- a/src/dagre-wrapper/clusters.js +++ b/src/dagre-wrapper/clusters.js @@ -1,8 +1,10 @@ import intersectRect from './intersect/intersect-rect'; -import { logger } from '../logger'; // eslint-disable-line +import { logger as log } from '../logger'; // eslint-disable-line import createLabel from './createLabel'; const rect = (parent, node) => { + log.info('Creating subgraph rect for ', node.id, node); + // Add outer g element const shapeSvg = parent .insert('g') @@ -22,7 +24,10 @@ const rect = (parent, node) => { const padding = 0 * node.padding; const halfPadding = padding / 2; + const width = node.width || 50; + const height = node.height || 50; + log.info('Data ', node, JSON.stringify(node)); // center the rect around its coordinate rect .attr('rx', node.rx) @@ -32,7 +37,7 @@ const rect = (parent, node) => { .attr('width', node.width + padding) .attr('height', node.height + padding); - // logger.info('bbox', bbox.width, node.x, node.width); + // log.info('bbox', bbox.width, node.x, node.width); // Center the label // label.attr('transform', 'translate(' + adj + ', ' + (node.y - node.height / 2) + ')'); label.attr( @@ -127,7 +132,7 @@ const roundedWithTitle = (parent, node) => { .attr('width', node.width + padding) .attr('height', node.height + padding - bbox.height - 3); - // logger.info('bbox', bbox.width, node.x, node.width); + // log.info('bbox', bbox.width, node.x, node.width); // Center the label // label.attr('transform', 'translate(' + adj + ', ' + (node.y - node.height / 2) + ')'); label.attr( @@ -155,7 +160,9 @@ const shapes = { rect, roundedWithTitle, noteGroup }; let clusterElems = {}; export const insertCluster = (elem, node) => { - clusterElems[node.id] = shapes[node.shape](elem, node); + log.info('Inserting cluster'); + const shape = node.shape || 'rect'; + clusterElems[node.id] = shapes[shape](elem, node); }; export const getClusterTitleWidth = (elem, node) => { const label = createLabel(node.labelText, node.labelStyle); @@ -170,6 +177,8 @@ export const clear = () => { }; export const positionCluster = node => { + log.info('Position cluster'); const el = clusterElems[node.id]; + el.attr('transform', 'translate(' + node.x + ', ' + node.y + ')'); }; diff --git a/src/dagre-wrapper/index.js b/src/dagre-wrapper/index.js index eb0427215..f3dd6c885 100644 --- a/src/dagre-wrapper/index.js +++ b/src/dagre-wrapper/index.js @@ -1,62 +1,38 @@ import dagre from 'dagre'; +import graphlib from 'graphlib'; 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 { updateNodeBounds } from './shapes/util'; +import { + clear as clearGraphlib, + clusterDb, + adjustClustersAndEdges, + findNonClusterChild +} from './mermaid-graphlib'; +import { insertNode, positionNode, clear as clearNodes, setNodeElem } from './nodes'; +import { insertCluster, clear as clearClusters, positionCluster } from './clusters'; import { insertEdgeLabel, positionEdgeLabel, insertEdge, clear as clearEdges } from './edges'; -import { logger } from '../logger'; +import { logger as log } from '../logger'; // let clusterDb = {}; const getAnchorId = id => { // Only insert an achor once if (clusterDb[id]) { - // if (!clusterDb[id].inserted) { - // // Create anchor node for cluster - // const anchorData = { - // shape: 'start', - // labelText: '', - // classes: '', - // style: '', - // id: id + '_anchor', - // type: 'anchor', - // padding: 0 - // }; - // insertNode(nodes, anchorData); - - // graph.setNode(anchorData.id, anchorData); - // graph.setParent(anchorData.id, id); - // clusterDb[id].inserted = true; - // } return clusterDb[id].id; } return id; }; -const findNonClusterChild = (id, graph) => { - const node = graph.node(id); - logger.info('identified node', node); - if (node.type !== 'group') { - return node.id; +const recursiveRender = (_elem, graph, diagramtype) => { + const elem = _elem.insert('g').attr('class', 'root'); // eslint-disable-line + if (!graph.nodes()) { + log.info('No nodes found for', graph); + } else { + log.info('Recursive render', graph.edges()); } - logger.info('identified node Not', node.id); - const children = graph.children(id); - for (let i = 0; i < children.length; i++) { - const _id = findNonClusterChild(children[i], graph); - if (_id) { - return _id; - } + if (graph.edges().length > 0) { + log.info('Recursive edges', graph.edge(graph.edges()[0])); } -}; - -export const render = (elem, graph, markers, diagramtype, id) => { - insertMarkers(elem, markers, diagramtype, id); - 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'); @@ -66,81 +42,115 @@ export const render = (elem, graph, markers, diagramtype, id) => { // to the abstract node and is later used by dagre for the layout graph.nodes().forEach(function(v) { const node = graph.node(v); - logger.info('Node ' + v + ': ' + JSON.stringify(graph.node(v))); - if (node.type !== 'group') { - insertNode(nodes, graph.node(v)); - } else { + log.info('(Insert) Node ' + v + ': ' + JSON.stringify(graph.node(v))); + if (node.clusterNode) { // 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) }; + log.info('Cluster identified', v, node, graph.node(v)); + const newEl = recursiveRender(clusters, node.graph, diagramtype); + updateNodeBounds(node, newEl); + setNodeElem(newEl, node); + + log.info('Recursice render complete', newEl, node); + } else { + if (graph.children(v).length > 0) { + // This is a cluster but not to be rendered recusively + // Render as before + log.info('Cluster - the non recursive path', v, node.id, node, graph); + log.info(findNonClusterChild(node.id, graph)); + clusterDb[node.id] = { id: findNonClusterChild(node.id, graph), node }; + // insertCluster(clusters, graph.node(v)); + } else { + log.info('Node - the non recursive path', v, node.id, node); + insertNode(nodes, graph.node(v)); + } } }); - logger.info('Clusters ', clusterDb); + log.info('Clusters ', clusterDb); // Insert labels, this will insert them into the dom so that the width can be calculated // Also figure out which edges point to/from clusters and adjust them accordingly // Edges from/to clusters really points to the first child in the cluster. // TODO: pick optimal child in the cluster to us as link anchor graph.edges().forEach(function(e) { - const edge = graph.edge(e); - logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); - logger.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e))); + const edge = graph.edge(e.v, e.w, e.name); + log.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); + log.info( + 'Edge ' + e.v + ' -> ' + e.w + ': ', + e, + ' ', + +JSON.stringify(graph.edge(e.v, e.w, e.name)) + ); let v = e.v; let w = e.w; // Check if link is either from or to a cluster - logger.info( - 'Fix', - clusterDb, - 'ids:', - e.v, - e.w, - 'Translateing: ', - clusterDb[e.v], - clusterDb[e.w] - ); + log.info('Fix', clusterDb, 'ids:', e.v, e.w, 'Translateing: ', clusterDb[e.v], clusterDb[e.w]); if (clusterDb[e.v] || clusterDb[e.w]) { - logger.info('Fixing and trixing - rwemoving', e.v, e.w, e.name); + log.info('Fixing and trixing - removing', e.v, e.w, e.name); v = getAnchorId(e.v, graph, nodes); w = getAnchorId(e.w, graph, nodes); 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.info('Fixing Replacing with', v, w, e.name); + log.info('Fixing Replacing with', v, w, e.name); graph.setEdge(v, w, edge, e.name); } insertEdgeLabel(edgeLabels, edge); }); graph.edges().forEach(function(e) { - logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); + log.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); }); - logger.info('#############################################'); - logger.info('### Layout ###'); - logger.info('#############################################'); - logger.info(graph); + log.info('#############################################'); + log.info('### Layout ###'); + log.info('#############################################'); + log.info(graph); dagre.layout(graph); // Move the nodes to the correct place graph.nodes().forEach(function(v) { const node = graph.node(v); - logger.trace('Node ' + v + ': ' + JSON.stringify(graph.node(v))); - if (node.type !== 'group') { - positionNode(node); + log.info('Position ' + v + ': ' + JSON.stringify(graph.node(v))); + if (node && node.clusterNode) { + // clusterDb[node.id].node = node; + // positionNode(node); } else { - insertCluster(clusters, node); - clusterDb[node.id].node = node; + // Non cluster node + if (graph.children(v).length > 0) { + // A cluster in the non-recurive way + // positionCluster(node); + insertCluster(clusters, node); + clusterDb[node.id].node = node; + } else { + positionNode(node); + } } }); // Move the edge labels to the correct place after layout graph.edges().forEach(function(e) { const edge = graph.edge(e); - logger.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); + log.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); insertEdge(edgePaths, edge, clusterDb, diagramtype); positionEdgeLabel(edge); }); + + return elem; +}; + +export const render = (elem, graph, markers, diagramtype, id) => { + insertMarkers(elem, markers, diagramtype, id); + clearNodes(); + clearEdges(); + clearClusters(); + clearGraphlib(); + + log.warn('Graph before:', graphlib.json.write(graph)); + adjustClustersAndEdges(graph); + log.warn('Graph after:', graphlib.json.write(graph)); + + recursiveRender(elem, graph, diagramtype); }; // const shapeDefinitions = {}; diff --git a/src/dagre-wrapper/mermaid-graphlib.js b/src/dagre-wrapper/mermaid-graphlib.js index ae4e4086c..a232c09db 100644 --- a/src/dagre-wrapper/mermaid-graphlib.js +++ b/src/dagre-wrapper/mermaid-graphlib.js @@ -13,7 +13,7 @@ export const clear = () => { }; const copy = (clusterId, graph, newGraph, rootId) => { - logger.trace('Copying ', clusterId); + logger.info('Copying ', clusterId, graph.node(clusterId)); const nodes = graph.children(clusterId); nodes.forEach(node => { if (graph.children(node).length > 0) { @@ -21,22 +21,29 @@ const copy = (clusterId, graph, newGraph, rootId) => { } const data = graph.node(node); - logger.trace(node, data, ' parent is ', clusterId); + logger.info(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); + logger.info('Copying 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); + logger.info('Edge', edge); + const data = graph.edge(edge.v, edge.w, edge.name); + logger.info('Edge data', data, rootId); + try { + // 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.info('Copying as ', edge.v, edge.w, data, edge.name); + newGraph.setEdge(edge.v, edge.w, data, edge.name); + logger.info('newgrapg edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0])); + } else { + logger.info('Skipping copy of edge as ', rootId, edge.v, edge.w, clusterId); + } + } catch (e) { + logger.error(e); } }); + graph.removeNode(node); }); newGraph.setNode(clusterId, graph.node(clusterId)); }; @@ -60,8 +67,8 @@ export const extractGraphFromCluster = (clusterId, graph) => { .setGraph({ rankdir: 'TB', // Todo: set proper spacing - nodesep: 10, - ranksep: 10, + nodesep: 50, + ranksep: 50, marginx: 8, marginy: 8 }) @@ -69,6 +76,26 @@ export const extractGraphFromCluster = (clusterId, graph) => { return {}; }); + // const conf = getConfig().flowchart; + // const nodeSpacing = conf.nodeSpacing || 50; + // const rankSpacing = conf.rankSpacing || 50; + + // // Create the input mermaid.graph + // const g = new graphlib.Graph({ + // multigraph: true, + // compound: true + // }) + // .setGraph({ + // rankdir: 'TB', + // nodesep: nodeSpacing, + // ranksep: rankSpacing, + // marginx: 8, + // marginy: 8 + // }) + // .setDefaultEdgeLabel(function() { + // return {}; + // }); + copy(clusterId, graph, clusterGraph, clusterId); return clusterGraph; @@ -100,7 +127,7 @@ export const validate = graph => { * @param {Finds a } id * @param {*} graph */ -const findNonClusterChild = (id, graph) => { +export const findNonClusterChild = (id, graph) => { // const node = graph.node(id); logger.trace('Searching', id); const children = graph.children(id); @@ -141,7 +168,7 @@ export const adjustClustersAndEdges = graph => { graph.nodes().forEach(function(id) { const children = graph.children(id); if (children.length > 0) { - logger.trace( + logger.info( 'Cluster identified', id, ' Replacement id in edges: ', @@ -157,13 +184,13 @@ export const adjustClustersAndEdges = graph => { const children = graph.children(id); const edges = graph.edges(); if (children.length > 0) { - logger.trace('Cluster identified', id); + logger.info('Cluster identified', id); edges.forEach(edge => { - logger.trace('Edge: ', edge, decendants[id]); + logger.info('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); + logger.info('Edge: ', edge, ' leaves cluster ', id); clusterDb[id].externalConnections = true; } } @@ -189,7 +216,13 @@ export const adjustClustersAndEdges = graph => { // 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 }); + graph.setNode(clusterId, { + clusterNode: true, + id: clusterId, + clusterData: clusterDb[clusterId], + labelText: clusterDb[clusterId].labelText, + graph: clusterGraph + }); // The original edges in and out of the cluster is applied edges.forEach(edge => { @@ -205,7 +238,7 @@ export const adjustClustersAndEdges = graph => { 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))); + logger.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e))); let v = e.v; let w = e.w; diff --git a/src/dagre-wrapper/mermaid-graphlib.spec.js b/src/dagre-wrapper/mermaid-graphlib.spec.js index b842a455b..cd0c29b34 100644 --- a/src/dagre-wrapper/mermaid-graphlib.spec.js +++ b/src/dagre-wrapper/mermaid-graphlib.spec.js @@ -135,7 +135,7 @@ describe('Graphlib decorations', () => { expect(newGraph.edges('a')).toEqual([{ v: 'a', w: 'b' }]); }); - it('It is possible to extract a clusters to a new graph 2', function () { + it('It is possible to extract a clusters to a new graph 2 GLB1', function () { /* subgraph C1 a --> b @@ -163,7 +163,7 @@ describe('Graphlib decorations', () => { expect(g.children('C2')).toEqual([]); expect(g.edges()).toEqual([{ v: 'C1', w: 'C2' }]); - logger.info(C1.nodes()); + logger.info(g.nodes()); expect(C1.nodes()).toEqual(['a', 'C1', 'b']); expect(C1.children('C1')).toEqual(['a', 'b']); expect(C1.edges()).toEqual([{ v: 'a', w: 'b' }]); @@ -200,7 +200,7 @@ describe('Graphlib decorations', () => { expect(g.children('C1')).toEqual([]); }); }); - it('Validate should detect edges between clusters and transform clusters', function () { + it('Validate should detect edges between clusters and transform clusters GLB4', function () { /* a --> b subgraph C1 @@ -225,4 +225,29 @@ describe('Graphlib decorations', () => { expect(g.nodes().length).toBe(2); expect(validate(g)).toBe(true); }); + it('Validate should detect edges between clusters and transform clusters GLB5', function () { + /* + a --> b + subgraph C1 + a + end + subgraph C2 + b + end + C1 --> + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setParent('a', 'C1'); + g.setParent('b', 'C2'); + g.setParent('C1', 'C2'); + // g.setEdge('a', 'b', { name: 'C1-internal-link' }); + g.setEdge('C1', 'C2', { name: 'C1-external-link' }); + + logger.info(g.nodes()) + adjustClustersAndEdges(g); + logger.info(g.nodes()) + expect(g.nodes().length).toBe(2); + expect(validate(g)).toBe(true); + }); }); diff --git a/src/dagre-wrapper/nodes.js b/src/dagre-wrapper/nodes.js index f1db09a43..47be56991 100644 --- a/src/dagre-wrapper/nodes.js +++ b/src/dagre-wrapper/nodes.js @@ -387,6 +387,9 @@ let nodeElems = {}; export const insertNode = (elem, node) => { nodeElems[node.id] = shapes[node.shape](elem, node); }; +export const setNodeElem = (elem, node) => { + nodeElems[node.id] = elem; +}; export const clear = () => { nodeElems = {}; }; diff --git a/src/diagrams/flowchart/flowRenderer-v2.js b/src/diagrams/flowchart/flowRenderer-v2.js index e0cce8df8..f66bf947c 100644 --- a/src/diagrams/flowchart/flowRenderer-v2.js +++ b/src/diagrams/flowchart/flowRenderer-v2.js @@ -314,8 +314,10 @@ export const draw = function(text, id) { let subG; const subGraphs = flowDb.getSubGraphs(); + logger.info('Subgraphs - ', subGraphs); for (let i = subGraphs.length - 1; i >= 0; i--) { subG = subGraphs[i]; + logger.info('Subgraph - ', subG); flowDb.addVertex(subG.id, subG.title, 'group', undefined, subG.classes); } @@ -347,7 +349,7 @@ export const draw = function(text, id) { // Run the renderer. This is what draws the final graph. const element = d3.select('#' + id + ' g'); render(element, g, ['point', 'circle', 'cross'], 'flowchart', id); - dagre.layout(g); + // dagre.layout(g); element.selectAll('g.node').attr('title', function() { return flowDb.getTooltip(this.id); @@ -378,27 +380,27 @@ export const draw = function(text, id) { // Index nodes flowDb.indexNodes('subGraph' + i); - // reposition labels - for (i = 0; i < subGraphs.length; i++) { - subG = subGraphs[i]; + // // reposition labels + // for (i = 0; i < subGraphs.length; i++) { + // subG = subGraphs[i]; - if (subG.title !== 'undefined') { - const clusterRects = document.querySelectorAll('#' + id + ' [id="' + subG.id + '"] rect'); - const clusterEl = document.querySelectorAll('#' + id + ' [id="' + subG.id + '"]'); + // if (subG.title !== 'undefined') { + // const clusterRects = document.querySelectorAll('#' + id + ' [id="' + subG.id + '"] rect'); + // const clusterEl = document.querySelectorAll('#' + id + ' [id="' + subG.id + '"]'); - const xPos = clusterRects[0].x.baseVal.value; - const yPos = clusterRects[0].y.baseVal.value; - const width = clusterRects[0].width.baseVal.value; - const cluster = d3.select(clusterEl[0]); - const te = cluster.select('.label'); - te.attr('transform', `translate(${xPos + width / 2}, ${yPos + 14})`); - te.attr('id', id + 'Text'); + // const xPos = clusterRects[0].x.baseVal.value; + // const yPos = clusterRects[0].y.baseVal.value; + // const width = clusterRects[0].width.baseVal.value; + // const cluster = d3.select(clusterEl[0]); + // const te = cluster.select('.label'); + // te.attr('transform', `translate(${xPos + width / 2}, ${yPos + 14})`); + // te.attr('id', id + 'Text'); - for (let j = 0; j < subG.classes.length; j++) { - clusterEl[0].classList.add(subG.classes[j]); - } - } - } + // for (let j = 0; j < subG.classes.length; j++) { + // clusterEl[0].classList.add(subG.classes[j]); + // } + // } + // } // Add label rects for non html labels if (!conf.htmlLabels) { From 704d56d193d97b3423b27edeba23c3bb3513db9d Mon Sep 17 00:00:00 2001 From: Knut Sveidqvist Date: Mon, 13 Apr 2020 16:25:10 +0200 Subject: [PATCH 06/12] #1295 Updates mermaid-graphlib --- cypress/platform/current.html | 14 +- src/dagre-wrapper/clusters.js | 2 - src/dagre-wrapper/index.js | 53 ++- src/dagre-wrapper/mermaid-graphlib.js | 505 +++++++++++++++++---- src/dagre-wrapper/mermaid-graphlib.spec.js | 348 +++++++++----- src/dagre-wrapper/nodes.js | 2 + src/diagrams/flowchart/flowRenderer-v2.js | 1 - 7 files changed, 701 insertions(+), 224 deletions(-) diff --git a/cypress/platform/current.html b/cypress/platform/current.html index 50cb3ed3c..dd32f0be0 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -87,9 +87,19 @@ stateDiagram-v2
flowchart TD - subgraph Apa - a --> b + subgraph A + a end + subgraph B + b + end + subgraph C + subgraph D + d + end + end + A -- oAo --> B + A --> C
stateDiagram-v2 diff --git a/src/dagre-wrapper/clusters.js b/src/dagre-wrapper/clusters.js index 02da85c58..ff65496a1 100644 --- a/src/dagre-wrapper/clusters.js +++ b/src/dagre-wrapper/clusters.js @@ -24,8 +24,6 @@ const rect = (parent, node) => { const padding = 0 * node.padding; const halfPadding = padding / 2; - const width = node.width || 50; - const height = node.height || 50; log.info('Data ', node, JSON.stringify(node)); // center the rect around its coordinate diff --git a/src/dagre-wrapper/index.js b/src/dagre-wrapper/index.js index f3dd6c885..c1b2b7933 100644 --- a/src/dagre-wrapper/index.js +++ b/src/dagre-wrapper/index.js @@ -9,7 +9,7 @@ import { findNonClusterChild } from './mermaid-graphlib'; import { insertNode, positionNode, clear as clearNodes, setNodeElem } from './nodes'; -import { insertCluster, clear as clearClusters, positionCluster } from './clusters'; +import { insertCluster, clear as clearClusters } from './clusters'; import { insertEdgeLabel, positionEdgeLabel, insertEdge, clear as clearEdges } from './edges'; import { logger as log } from '../logger'; @@ -24,11 +24,12 @@ const getAnchorId = id => { }; const recursiveRender = (_elem, graph, diagramtype) => { + log.info('Graph in recursive render:', graphlib.json.write(graph)); const elem = _elem.insert('g').attr('class', 'root'); // eslint-disable-line if (!graph.nodes()) { log.info('No nodes found for', graph); } else { - log.info('Recursive render', graph.edges()); + log.info('Recursive render', graph.nodes()); } if (graph.edges().length > 0) { log.info('Recursive edges', graph.edge(graph.edges()[0])); @@ -50,7 +51,7 @@ const recursiveRender = (_elem, graph, diagramtype) => { updateNodeBounds(node, newEl); setNodeElem(newEl, node); - log.info('Recursice render complete', newEl, node); + log.warn('Recursive render complete', newEl, node); } else { if (graph.children(v).length > 0) { // This is a cluster but not to be rendered recusively @@ -65,7 +66,6 @@ const recursiveRender = (_elem, graph, diagramtype) => { } } }); - log.info('Clusters ', clusterDb); // Insert labels, this will insert them into the dom so that the width can be calculated // Also figure out which edges point to/from clusters and adjust them accordingly @@ -74,27 +74,24 @@ const recursiveRender = (_elem, graph, diagramtype) => { graph.edges().forEach(function(e) { const edge = graph.edge(e.v, e.w, e.name); log.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); - log.info( - 'Edge ' + e.v + ' -> ' + e.w + ': ', - e, - ' ', - +JSON.stringify(graph.edge(e.v, e.w, e.name)) - ); + log.info('Edge ' + e.v + ' -> ' + e.w + ': ', e, ' ', JSON.stringify(graph.edge(e))); let v = e.v; let w = e.w; // Check if link is either from or to a cluster log.info('Fix', clusterDb, 'ids:', e.v, e.w, 'Translateing: ', clusterDb[e.v], clusterDb[e.w]); - if (clusterDb[e.v] || clusterDb[e.w]) { - log.info('Fixing and trixing - removing', e.v, e.w, e.name); - v = getAnchorId(e.v, graph, nodes); - w = getAnchorId(e.w, graph, nodes); - graph.removeEdge(e.v, e.w, e.name); - if (v !== e.v) edge.fromCluster = e.v; - if (w !== e.w) edge.toCluster = e.w; - log.info('Fixing Replacing with', v, w, e.name); - graph.setEdge(v, w, edge, e.name); - } + // Todo handle case with links + + // if (clusterDb[e.v] || clusterDb[e.w]) { + // log.info('Fixing and trixing - removing', e.v, e.w, e.name); + // v = getAnchorId(e.v, graph, nodes); + // w = getAnchorId(e.w, graph, nodes); + // graph.removeEdge(e.v, e.w, e.name); + // if (v !== e.v) edge.fromCluster = e.v; + // if (w !== e.w) edge.toCluster = e.w; + // log.info('Fixing Replacing with', v, w, e.name); + // graph.setEdge(v, w, edge, e.name); + // } insertEdgeLabel(edgeLabels, edge); }); @@ -106,14 +103,22 @@ const recursiveRender = (_elem, graph, diagramtype) => { log.info('#############################################'); log.info(graph); dagre.layout(graph); - + log.warn('Graph after layout:', graphlib.json.write(graph)); // Move the nodes to the correct place graph.nodes().forEach(function(v) { const node = graph.node(v); - log.info('Position ' + v + ': ' + JSON.stringify(graph.node(v))); + // log.info('Position ' + v + ': ' + JSON.stringify(graph.node(v))); + log.info( + 'Position ' + v + ': (' + node.x, + ',' + node.y, + ') width: ', + node.width, + ' height: ', + node.height + ); if (node && node.clusterNode) { // clusterDb[node.id].node = node; - // positionNode(node); + positionNode(node); } else { // Non cluster node if (graph.children(v).length > 0) { @@ -130,7 +135,7 @@ const recursiveRender = (_elem, graph, diagramtype) => { // Move the edge labels to the correct place after layout graph.edges().forEach(function(e) { const edge = graph.edge(e); - log.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); + log.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(edge), edge); insertEdge(edgePaths, edge, clusterDb, diagramtype); positionEdgeLabel(edge); diff --git a/src/dagre-wrapper/mermaid-graphlib.js b/src/dagre-wrapper/mermaid-graphlib.js index a232c09db..30f69dbd9 100644 --- a/src/dagre-wrapper/mermaid-graphlib.js +++ b/src/dagre-wrapper/mermaid-graphlib.js @@ -1,58 +1,152 @@ /** * Decorates with functions required by mermaids dagre-wrapper. */ -import { logger } from '../logger'; +import { logger as log } from '../logger'; import graphlib from 'graphlib'; export let clusterDb = {}; let decendants = {}; +let parents = {}; +let graphs = {}; export const clear = () => { decendants = {}; + parents = {}; clusterDb = {}; + graphs = {}; }; -const copy = (clusterId, graph, newGraph, rootId) => { - logger.info('Copying ', clusterId, graph.node(clusterId)); +const isDecendant = (id, ancenstorId) => { + // if (id === ancenstorId) return true; + + log.info('In isDecendant', ancenstorId, ' ', id, ' = ', decendants[ancenstorId].indexOf(id) >= 0); + if (decendants[ancenstorId].indexOf(id) >= 0) return true; + + return false; +}; + +const edgeInCluster = (edge, clusterId) => { + // Edges to/from the cluster is not in the cluster, they are in the parent + if (!(edge.v === clusterId || edge.w === clusterId)) return false; + + if (!decendants[clusterId]) { + log.info('Tilt, ', clustedId, ',not in decendants'); + return false; + } + + if (decendants[clusterId].indexOf(edge.v) >= 0) return true; + if (isDecendant(edge.v, clusterId)) return true; + if (isDecendant(edge.w, clusterId)) return true; + if (decendants[clusterId].indexOf(edge.w) >= 0) return true; + + return false; +}; + +const copyOld = (clusterId, graph, newGraph, rootId) => { + log.info('Copying to', rootId, ' from ', clusterId, graph.node(clusterId), rootId); const nodes = graph.children(clusterId); + log.info('Copying (nodes)', nodes); + if (nodes) { + nodes.forEach(node => { + if (graph.children(node).length > 0) { + copy(node, graph, newGraph, rootId); + } else { + // if (clusterId === rootId) { + const data = graph.node(node); + log.info('cp ', node, ' to ', rootId, ' with parent ', clusterId); //,node, data, ' parent is ', clusterId); + newGraph.setNode(node, data); + newGraph.setParent(node, clusterId); + const edges = graph.edges(node); + log.trace('Copying Edges', edges); + edges.forEach(edge => { + log.trace('Edge', edge); + const data = graph.edge(edge.v, edge.w, edge.name); + log.trace('Edge data', data, rootId); + try { + // Do not copy edges in and out of the root cluster, they belong to the parent graph + if (edgeInCluster(edge, rootId)) { + log.trace('Copying as ', edge.v, edge.w, data, edge.name); + newGraph.setEdge(edge.v, edge.w, data, edge.name); + log.trace('newGraph edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0])); + } else { + log.trace('Skipping copy of edge as ', rootId, edge.v, edge.w, clusterId); + } + } catch (e) { + log.error(e); + } + }); + // } else { + // log.info('Skipping leaf as root ', rootId, ' !== ', clusterId, ' leaf id = ', node); + // } + } + // log.info('Removing node', node, graphlib.json.write(graph)); + log.info('Removing node', node); + graph.removeNode(node); + }); + } + // newGraph.setNode(clusterId, graph.node(clusterId)); +}; +const copy = (clusterId, graph, newGraph, rootId) => { + log.trace( + 'Copying children of ', + clusterId, + rootId, + ' from ', + clusterId, + graph.node(clusterId), + rootId + ); + const nodes = graph.children(clusterId) || []; + + // Include cluster node if it is not the root + if (clusterId !== rootId) { + nodes.push(clusterId); + } + + log.info('Copying (nodes)', nodes); + nodes.forEach(node => { if (graph.children(node).length > 0) { copy(node, graph, newGraph, rootId); - } - - const data = graph.node(node); - logger.info(node, data, ' parent is ', clusterId); - newGraph.setNode(node, data); - newGraph.setParent(node, clusterId); - const edges = graph.edges(node); - logger.info('Copying Edges', edges); - edges.forEach(edge => { - logger.info('Edge', edge); - const data = graph.edge(edge.v, edge.w, edge.name); - logger.info('Edge data', data, rootId); - try { - // 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.info('Copying as ', edge.v, edge.w, data, edge.name); - newGraph.setEdge(edge.v, edge.w, data, edge.name); - logger.info('newgrapg edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0])); - } else { - logger.info('Skipping copy of edge as ', rootId, edge.v, edge.w, clusterId); - } - } catch (e) { - logger.error(e); + } else { + const data = graph.node(node); + log.trace('cp ', node, ' to ', rootId, ' with parent ', clusterId); //,node, data, ' parent is ', clusterId); + newGraph.setNode(node, data); + if (clusterId !== rootId && node !== clusterId) { + log.info('Setting parent', node, clusterId); + newGraph.setParent(node, clusterId); } - }); + const edges = graph.edges(node); + log.info('Copying Edges', edges); + edges.forEach(edge => { + log.trace('Edge', edge); + const data = graph.edge(edge.v, edge.w, edge.name); + log.trace('Edge data', data, rootId); + try { + // Do not copy edges in and out of the root cluster, they belong to the parent graph + if (edgeInCluster(edge, rootId)) { + log.trace('Copying as ', edge.v, edge.w, data, edge.name); + newGraph.setEdge(edge.v, edge.w, data, edge.name); + log.trace('newGraph edges ', newGraph.edges(), newGraph.edge(newGraph.edges()[0])); + } else { + log.trace('Skipping copy of edge as ', rootId, edge.v, edge.w, clusterId); + } + } catch (e) { + log.error(e); + } + }); + } + log.info('Removing node', node); graph.removeNode(node); }); - newGraph.setNode(clusterId, graph.node(clusterId)); }; - -const extractDecendants = (id, graph) => { +export const extractDecendants = (id, graph) => { + // log.info('Extracting ', id); const children = graph.children(id); let res = [].concat(children); for (let i = 0; i < children.length; i++) { + parents[children[i]] = id; res = res.concat(extractDecendants(children[i], graph)); } @@ -60,6 +154,7 @@ const extractDecendants = (id, graph) => { }; export const extractGraphFromCluster = (clusterId, graph) => { + log.info('Extracting graph ', clusterId); const clusterGraph = new graphlib.Graph({ multigraph: true, compound: true @@ -96,8 +191,25 @@ export const extractGraphFromCluster = (clusterId, graph) => { // return {}; // }); + log.trace('Extracting before copy', graphlib.json.write(graph)); + log.trace('Extracting before copy', graphlib.json.write(graph)); copy(clusterId, graph, clusterGraph, clusterId); + log.trace('Extracting after copy', graphlib.json.write(graph)); + log.trace('Extracting after copy', clusterGraph.nodes()); + graphs[clusterId] = clusterGraph; + // Remove references to extracted cluster + // graph.edges().forEach(edge => { + // if (isDecendant(edge.v, clusterId) || isDecendant(edge.w, clusterId)) { + // graph.removeEdge(edge); + // } + // }); + // graph.nodes().forEach(node => { + // if (isDecendant(node, clusterId)) { + // log.info('Removing ', node, ' from ', clusterId); + // graph.removeNode(node); + // } + // }); return clusterGraph; }; @@ -108,14 +220,14 @@ export const extractGraphFromCluster = (clusterId, graph) => { */ export const validate = graph => { const edges = graph.edges(); - logger.trace('Edges: ', edges); + log.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'); + log.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'); + log.trace('The node ', edges[i].w, ' is part of and edge even though it has children'); return false; } } @@ -129,16 +241,16 @@ export const validate = graph => { */ export const findNonClusterChild = (id, graph) => { // const node = graph.node(id); - logger.trace('Searching', id); + log.trace('Searching', id); const children = graph.children(id); if (children.length < 1) { - logger.trace('This is a valid node', id); + log.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); + log.trace('Found replacement for', id, ' => ', _id); return _id; } } @@ -160,15 +272,19 @@ const getAnchorId = id => { return id; }; -export const adjustClustersAndEdges = graph => { - // calc decendants, sa - +export const adjustClustersAndEdges = (graph, depth) => { + if (!graph || depth > 10) { + log.info('Opting out, no graph '); + return; + } else { + log.info('Opting in, graph '); + } // 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.info( + log.trace( 'Cluster identified', id, ' Replacement id in edges: ', @@ -184,13 +300,22 @@ export const adjustClustersAndEdges = graph => { const children = graph.children(id); const edges = graph.edges(); if (children.length > 0) { - logger.info('Cluster identified', id); + log.info('Cluster identified', id, decendants); edges.forEach(edge => { - logger.info('Edge: ', edge, decendants[id]); + // log.info('Edge, decendants: ', 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.info('Edge: ', edge, ' leaves cluster ', id); + // Any edge where either the one of the nodes is decending to the cluster but not the other + // if (decendants[id].indexOf(edge.v) < 0 && decendants[id].indexOf(edge.w) < 0) { + + const d1 = isDecendant(edge.v, id); + const d2 = isDecendant(edge.w, id); + + // d1 xor d2 - if either d1 is true and d2 is false or the other way around + if (d1 ^ d2) { + log.info('Edge: ', edge, ' leaves cluster ', id); + log.info('Decendants of ', id, ': ', decendants[id]); clusterDb[id].externalConnections = true; } } @@ -198,73 +323,265 @@ export const adjustClustersAndEdges = graph => { } }); - // 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); + extractor(graph, 0); - // 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, - id: clusterId, - clusterData: clusterDb[clusterId], - labelText: clusterDb[clusterId].labelText, - 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.info('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(graph.edge(e))); + log.trace('Edge ' + e.v + ' -> ' + e.w + ': ' + JSON.stringify(e)); + log.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] - ); + log.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); + log.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); + log.trace('Replacing with', v, w, e.name); graph.setEdge(v, w, edge, e.name); } }); - logger.trace('Graph', graph.nodes()); + log.info('Adjusted Graph', graphlib.json.write(graph)); - logger.trace(clusterDb); + log.trace(clusterDb); + + // Remove references to extracted cluster + // graph.edges().forEach(edge => { + // if (isDecendant(edge.v, clusterId) || isDecendant(edge.w, clusterId)) { + // graph.removeEdge(edge); + // } + // }); +}; + +export const transformClustersToNodes = (graph, depth) => { + log.info('transformClustersToNodes - ', depth); + if (depth > 10) { + log.error('Bailing out'); + return; + } + // 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 nodes = graph.nodes(); + let hasChildren = false; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const children = graph.children(node); + hasChildren = hasChildren || children.length > 0; + } + + if (!hasChildren) { + log.info('Done, no node has children', graph.nodes()); + return; + } + // const clusters = Object.keys(clusterDb); + // clusters.forEach(clusterId => { + log.info('Nodes = ', nodes); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + log.info( + 'Handling node', + node, + clusterDb, + clusterDb[node] && !clusterDb[node].externalConnections, + !graph.parent(node), + graph.node(node) + ); + // Note that the node might have been removed after the Object.keys call so better check + // that it still is in the game + if (clusterDb[node]) { + if ( + !clusterDb[node].externalConnections && + !graph.parent(node) && + graph.children(node) && + graph.children(node).length > 0 + ) { + log.info('Cluster without external connections', node); + // const parentGraph = parent && graphs[parent] ? graphs[parent] : graph; + // New graph with the nodes in the cluster + log.info('before Extracting ', node, ' parent is ', graph.parent(node)); + const clusterGraph = extractGraphFromCluster(node, graph); + + if (clusterGraph) { + log.trace('Cluster graph', clusterGraph.nodes()); + log.trace('Graph', graph.edges()); + + log.info('Creating node in original', node, clusterGraph); + + // Create a new node in the original graph, this new node is not a cluster + // but a regular node with the cluster content as a new attached graph + graph.setNode(node, { + clusterNode: true, + id: node, + clusterData: clusterDb[node], + labelText: clusterDb[node].labelText, + graph: clusterGraph + }); + + // if any node in the clusterGraph still has children + transformClustersToNodes(clusterGraph, depth + 1); + } + + // The original edges in and out of the cluster is applied + // edges.forEach(edge => { + // log.info('Setting edge', edge); + // const data = graph.edge(edge); + // graph.setEdge(edge.v, edge.w, data); + // }); + } + } else { + log.info('Not a cluster ', node); + } + } +}; + +export const extractor = (graph, depth) => { + log.info('extractor - ', depth, graphlib.json.write(graph), graph.children('D')); + if (depth > 10) { + log.error('Bailing out'); + return; + } + // 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;) + let nodes = graph.nodes(); + let hasChildren = false; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const children = graph.children(node); + hasChildren = hasChildren || children.length > 0; + } + + if (!hasChildren) { + log.info('Done, no node has children', graph.nodes()); + return; + } + // const clusters = Object.keys(clusterDb); + // clusters.forEach(clusterId => { + log.info('Nodes = ', nodes, depth); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + log.info( + 'Extracting node', + node, + clusterDb, + clusterDb[node] && !clusterDb[node].externalConnections, + !graph.parent(node), + graph.node(node), + graph.children('D'), + ' Depth ', + depth + ); + // Note that the node might have been removed after the Object.keys call so better check + // that it still is in the game + if (!clusterDb[node]) { + // Skip if the node is not a cluster + log.info('Not a cluster', node, depth); + // break; + } else if ( + !clusterDb[node].externalConnections && + !graph.parent(node) && + graph.children(node) && + graph.children(node).length > 0 + ) { + log.info( + 'Cluster without external connections, without a parent and with children', + node, + depth + ); + + const clusterGraph = new graphlib.Graph({ + multigraph: true, + compound: true + }) + .setGraph({ + rankdir: 'TB', + // Todo: set proper spacing + nodesep: 50, + ranksep: 50, + marginx: 8, + marginy: 8 + }) + .setDefaultEdgeLabel(function() { + return {}; + }); + + copy(node, graph, clusterGraph, node); + graph.setNode(node, { + clusterNode: true, + id: node, + clusterData: clusterDb[node], + labelText: clusterDb[node].labelText, + graph: clusterGraph + }); + log.info('New graph after copy', graphlib.json.write(clusterGraph)); + log.info('Old graph after copy', graphlib.json.write(graph)); + + /* + // New graph with the nodes in the cluster + log.info('before Extracting ', node, ' parent is ', graph.parent(node)); + const clusterGraph = extractGraphFromCluster(node, graph); + + if (clusterGraph) { + log.trace('Cluster graph', clusterGraph.nodes()); + log.trace('Graph', graph.edges()); + + log.info('Creating node in original', node, clusterGraph); + + // Create a new node in the original graph, this new node is not a cluster + // but a regular node with the cluster content as a new attached graph + graph.setNode(node, { + clusterNode: true, + id: node, + clusterData: clusterDb[node], + labelText: clusterDb[node].labelText, + graph: clusterGraph + }); + + // if any node in the clusterGraph still has children + transformClustersToNodes(clusterGraph, depth + 1); + } + + // The original edges in and out of the cluster is applied + // edges.forEach(edge => { + // log.info('Setting edge', edge); + // const data = graph.edge(edge); + // graph.setEdge(edge.v, edge.w, data); + // }); + */ + } else { + log.info( + 'Cluster ** ', + node, + ' **not meeting the criteria !externalConnections:', + !clusterDb[node].externalConnections, + ' no parent: ', + !graph.parent(node), + ' children ', + graph.children(node) && graph.children(node).length > 0, + graph.children('D'), + depth + ); + log.info(clusterDb); + } + } + + nodes = graph.nodes(); + log.info('New list of nodes', nodes); + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const data = graph.node(node); + log.info(' Now next leveƶ', node, data); + if (data.clusterNode) { + extractor(data.graph, depth + 1); + } + } }; diff --git a/src/dagre-wrapper/mermaid-graphlib.spec.js b/src/dagre-wrapper/mermaid-graphlib.spec.js index cd0c29b34..ad483eea7 100644 --- a/src/dagre-wrapper/mermaid-graphlib.spec.js +++ b/src/dagre-wrapper/mermaid-graphlib.spec.js @@ -1,6 +1,6 @@ import graphlib from 'graphlib'; import dagre from 'dagre'; -import { validate, adjustClustersAndEdges, extractGraphFromCluster } from './mermaid-graphlib'; +import { validate, adjustClustersAndEdges, extractGraphFromCluster, extractDecendants } from './mermaid-graphlib'; import { setLogLevel, logger } from '../logger'; describe('Graphlib decorations', () => { @@ -21,40 +21,6 @@ describe('Graphlib decorations', () => { 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 () { @@ -135,71 +101,6 @@ describe('Graphlib decorations', () => { expect(newGraph.edges('a')).toEqual([{ v: 'a', w: 'b' }]); }); - it('It is possible to extract a clusters to a new graph 2 GLB1', 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(g.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 GLB4', function () { /* a --> b @@ -214,6 +115,8 @@ describe('Graphlib decorations', () => { g.setNode('a', { data: 1 }); g.setNode('b', { data: 2 }); g.setNode('c', { data: 3 }); + g.setNode('C1', { data: 4 }); + g.setNode('C2', { data: 5 }); g.setParent('a', 'C2'); g.setParent('b', 'C1'); g.setParent('C2', 'C1'); @@ -240,7 +143,6 @@ describe('Graphlib decorations', () => { g.setNode('b', { data: 2 }); g.setParent('a', 'C1'); g.setParent('b', 'C2'); - g.setParent('C1', 'C2'); // g.setEdge('a', 'b', { name: 'C1-internal-link' }); g.setEdge('C1', 'C2', { name: 'C1-external-link' }); @@ -250,4 +152,248 @@ describe('Graphlib decorations', () => { expect(g.nodes().length).toBe(2); expect(validate(g)).toBe(true); }); + it('adjustClustersAndEdges GLB6', function () { + /* + subgraph C1 + a + end + C1 --> b + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('C1', { data: 3 }); + g.setParent('a', 'C1'); + g.setEdge('C1', 'b', { data: 'link1' }, '1'); + + // logger.info(g.edges()) + adjustClustersAndEdges(g); + logger.info(g.edges()) + expect(g.nodes()).toEqual(['b', 'C1']); + expect(g.edges().length).toBe(1); + expect(validate(g)).toBe(true); + expect(g.node('C1').clusterNode).toBe(true); + + const C1Graph = g.node('C1').graph; + expect(C1Graph.nodes()).toEqual(['a']); + }); + it('adjustClustersAndEdges GLB7', function () { + /* + subgraph C1 + a + end + C1 --> b + C1 --> c + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'C1'); + g.setNode('C1', { data: 4 }); + g.setEdge('C1', 'b', { data: 'link1' }, '1'); + g.setEdge('C1', 'c', { data: 'link2' }, '2'); + + logger.info(g.node('C1')) + adjustClustersAndEdges(g); + logger.info(g.edges()) + expect(g.nodes()).toEqual(['b', 'c', 'C1']); + expect(g.nodes().length).toBe(3); + expect(g.edges().length).toBe(2); + + expect(g.edges().length).toBe(2); + const edgeData = g.edge(g.edges()[1]); + expect(edgeData.data).toBe('link2'); + expect(validate(g)).toBe(true); + + const C1Graph = g.node('C1').graph; + expect(C1Graph.nodes()).toEqual(['a']); + }); + it('adjustClustersAndEdges GLB8', function () { + /* + subgraph A + a + end + subgraph B + b + end + subgraph C + c + end + A --> B + A --> C + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'A'); + g.setParent('b', 'B'); + g.setParent('c', 'C'); + g.setEdge('A', 'B', { data: 'link1' }, '1'); + g.setEdge('A', 'C', { data: 'link2' }, '2'); + + // logger.info(g.edges()) + adjustClustersAndEdges(g); + expect(g.nodes()).toEqual(['A', 'B', 'C']); + expect(g.edges().length).toBe(2); + + expect(g.edges().length).toBe(2); + const edgeData = g.edge(g.edges()[1]); + expect(edgeData.data).toBe('link2'); + expect(validate(g)).toBe(true); + + const CGraph = g.node('C').graph; + expect(CGraph.nodes()).toEqual(['c']); + + }); + + it('adjustClustersAndEdges the extracted graphs shall contain the correct data GLB10', function () { + /* + subgraph C + subgraph D + d + end + end + */ + + g.setNode('C', { data: 1 }); + g.setNode('D', { data: 2 }); + g.setNode('d', { data: 3 }); + g.setParent('d', 'D'); + g.setParent('D', 'C'); + + // logger.info('Graph before', g.node('D')) + // logger.info('Graph before', graphlib.json.write(g)) + adjustClustersAndEdges(g); + // logger.info('Graph after', graphlib.json.write(g), g.node('C').graph) + + const CGraph = g.node('C').graph; + const DGraph = CGraph.node('D').graph; + + expect(CGraph.nodes()).toEqual(['D']); + expect(DGraph.nodes()).toEqual(['d']); + + expect(g.nodes()).toEqual(['C']); + expect(g.nodes().length).toBe(1); + }); + + it('adjustClustersAndEdges the extracted graphs shall contain the correct data GLB11', function () { + /* + subgraph A + a + end + subgraph B + b + end + subgraph C + subgraph D + d + end + end + A --> B + A --> C + */ + + g.setNode('C', { data: 1 }); + g.setNode('D', { data: 2 }); + g.setNode('d', { data: 3 }); + g.setNode('B', { data: 4 }); + g.setNode('b', { data: 5 }); + g.setNode('A', { data: 6 }); + g.setNode('a', { data: 7 }); + g.setParent('a', 'A'); + g.setParent('b', 'B'); + g.setParent('d', 'D'); + g.setParent('D', 'C'); + g.setEdge('A', 'B', { data: 'link1' }, '1'); + g.setEdge('A', 'C', { data: 'link2' }, '2'); + + logger.info('Graph before', g.node('D')) + logger.info('Graph before', graphlib.json.write(g)) + adjustClustersAndEdges(g); + logger.trace('Graph after', graphlib.json.write(g)) + expect(g.nodes()).toEqual(['C', 'B', 'A']); + expect(g.nodes().length).toBe(3); + expect(g.edges().length).toBe(2); + + const AGraph = g.node('A').graph; + const BGraph = g.node('B').graph; + const CGraph = g.node('C').graph; + // logger.info(CGraph.nodes()); + const DGraph = CGraph.node('D').graph; + // logger.info('DG', CGraph.children('D')); + + logger.info('A', AGraph.nodes()); + expect(AGraph.nodes().length).toBe(1); + expect(AGraph.nodes()).toEqual(['a']); + logger.trace('Nodes', BGraph.nodes()) + expect(BGraph.nodes().length).toBe(1); + expect(BGraph.nodes()).toEqual(['b']); + expect(CGraph.nodes()).toEqual(['D']); + expect(CGraph.nodes().length).toEqual(1); + + expect(AGraph.edges().length).toBe(0); + expect(BGraph.edges().length).toBe(0); + expect(CGraph.edges().length).toBe(0); + expect(DGraph.nodes()).toEqual(['d']); + expect(DGraph.edges().length).toBe(0); + // expect(CGraph.node('D')).toEqual({ data: 2 }); + expect(g.edges().length).toBe(2); + + // expect(g.edges().length).toBe(2); + // const edgeData = g.edge(g.edges()[1]); + // expect(edgeData.data).toBe('link2'); + // expect(validate(g)).toBe(true); + }); +}); +}); +describe('extractDecendants', function () { + 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 {}; + }); + }); + it('Simple case of one level decendants GLB9', function () { + /* + subgraph A + a + end + subgraph B + b + end + subgraph C + c + end + A --> B + A --> C + */ + g.setNode('a', { data: 1 }); + g.setNode('b', { data: 2 }); + g.setNode('c', { data: 3 }); + g.setParent('a', 'A'); + g.setParent('b', 'B'); + g.setParent('c', 'C'); + g.setEdge('A', 'B', { data: 'link1' }, '1'); + g.setEdge('A', 'C', { data: 'link2' }, '2'); + + // logger.info(g.edges()) + const d1 = extractDecendants('A',g) + const d2 = extractDecendants('B',g) + const d3 = extractDecendants('C',g) + + expect(d1).toEqual(['a']); + expect(d2).toEqual(['b']); + expect(d3).toEqual(['c']); + }); }); diff --git a/src/dagre-wrapper/nodes.js b/src/dagre-wrapper/nodes.js index 47be56991..ec45764e7 100644 --- a/src/dagre-wrapper/nodes.js +++ b/src/dagre-wrapper/nodes.js @@ -396,5 +396,7 @@ export const clear = () => { export const positionNode = node => { const el = nodeElems[node.id]; + logger.debug('Transforming node', node); el.attr('transform', 'translate(' + node.x + ', ' + node.y + ')'); + // el.attr('transform', 'translate(' + node.x / 2 + ', ' + 0 + ')'); }; diff --git a/src/diagrams/flowchart/flowRenderer-v2.js b/src/diagrams/flowchart/flowRenderer-v2.js index b76ff361a..9e781a2b5 100644 --- a/src/diagrams/flowchart/flowRenderer-v2.js +++ b/src/diagrams/flowchart/flowRenderer-v2.js @@ -1,6 +1,5 @@ import graphlib from 'graphlib'; import * as d3 from 'd3'; -import dagre from 'dagre'; import flowDb from './flowDb'; import flow from './parser/flow'; From d228769b1bf464da4c8dd8f7b2594da4e8e019c9 Mon Sep 17 00:00:00 2001 From: Aaron Dutton Date: Tue, 14 Apr 2020 15:15:10 -0700 Subject: [PATCH 07/12] Show source code in later examples The first few examples on the page show source code but the later ones didn't --- docs/examples.md | 63 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 02985474d..127173384 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -5,7 +5,7 @@ pie title NETFLIX "Time spent looking for movie" : 90 "Time spent watching it" : 10 ``` -``` mermaid +```mermaid pie title NETFLIX "Time spent looking for movie" : 90 "Time spent watching it" : 10 @@ -35,8 +35,6 @@ sequenceDiagram Bob-->Alice: Checking with John... Alice->John: Yes... John, how are you? ``` - - ```mermaid sequenceDiagram Alice ->> Bob: Hello Bob, how are you? @@ -49,9 +47,15 @@ sequenceDiagram Alice->John: Yes... John, how are you? ``` - ## Basic flowchart +``` +graph LR + A[Square Rect] -- Link text --> B((Circle)) + A --> C(Round Rect) + B --> D{Rhombus} + C --> D +``` ```mermaid graph LR A[Square Rect] -- Link text --> B((Circle)) @@ -63,6 +67,29 @@ graph LR ## Larger flowchart with some styling +``` +graph TB + sq[Square shape] --> ci((Circle shape)) + + subgraph A + od>Odd shape]-- Two line
edge comment --> ro + di{Diamond with
line break} -.-> ro(Rounded
square
shape) + di==>ro2(Rounded square shape) + end + + %% Notice that no text in shape are added here instead that is appended further down + e --> od3>Really long text with linebreak
in an Odd shape] + + %% Comments after double percent signs + e((Inner / circle
and some odd
special characters)) --> f(,.?!+-*Ų²) + + cyr[Cyrillic]-->cyr2((Circle shape ŠŠ°Ń‡Š°Š»Š¾)); + + classDef green fill:#9f6,stroke:#333,stroke-width:2px; + classDef orange fill:#f96,stroke:#333,stroke-width:4px; + class sq,e green + class di orange +``` ```mermaid graph TB sq[Square shape] --> ci((Circle shape)) @@ -90,6 +117,21 @@ graph TB ## Loops, alt and opt +``` +sequenceDiagram + loop Daily query + Alice->>Bob: Hello Bob, how are you? + alt is sick + Bob->>Alice: Not so good :( + else is well + Bob->>Alice: Feeling fresh like a daisy + end + + opt Extra response + Bob->>Alice: Thanks for asking + end + end +``` ```mermaid sequenceDiagram loop Daily query @@ -109,6 +151,19 @@ sequenceDiagram ## Message to self in loop +``` +sequenceDiagram + participant Alice + participant Bob + Alice->>John: Hello John, how are you? + loop Healthcheck + John->>John: Fight against hypochondria + end + Note right of John: Rational thoughts
prevail... + John-->>Alice: Great! + John->>Bob: How about you? + Bob-->>John: Jolly good! +``` ```mermaid sequenceDiagram participant Alice From 12d26d5a3df44916c9d71ca3dffea85ddbd6e425 Mon Sep 17 00:00:00 2001 From: Tom Wright Date: Wed, 15 Apr 2020 19:29:17 +0100 Subject: [PATCH 08/12] Add HTTP Server implementation to related projects. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 83fe09ec7..345c6622e 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ pie - [Command Line Interface](https://github.com/mermaid-js/mermaid.cli) - [Live Editor](https://github.com/mermaid-js/mermaid-live-editor) +- [HTTP Server](https://github.com/TomWright/mermaid-server) # Contributors [![Help wanted](https://img.shields.io/github/labels/mermaid-js/mermaid/Help%20wanted!)](https://github.com/mermaid-js/mermaid/issues?q=is%3Aissue+is%3Aopen+label%3A%22Help+wanted%21%22) [![Contributors](https://img.shields.io/github/contributors/mermaid-js/mermaid)](https://github.com/mermaid-js/mermaid/graphs/contributors) [![Commits](https://img.shields.io/github/commit-activity/m/mermaid-js/mermaid)](https://github.com/mermaid-js/mermaid/graphs/contributors) From 7c82e4b6c16615205f8bbbd0bb82a965aecd0a94 Mon Sep 17 00:00:00 2001 From: Tom Wright Date: Wed, 15 Apr 2020 21:00:47 +0100 Subject: [PATCH 09/12] Add mermaid-server to integrations.md --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index d01f7c35c..33f424a98 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -137,3 +137,4 @@ The following is a list of different integrations and plugins where mermaid is b - [bisheng-plugin-mermaid](https://github.com/yct21/bisheng-plugin-mermaid) - [Reveal CK](https://github.com/jedcn/reveal-ck) - [reveal-ck-mermaid-plugin](https://github.com/tmtm/reveal-ck-mermaid-plugin) +- [mermaid-server: Generate diagrams using a HTTP request](https://github.com/TomWright/mermaid-server) From 4e4c80b7fa88c432b0970db13d10ba17024cbc28 Mon Sep 17 00:00:00 2001 From: Adrian Hall Date: Thu, 16 Apr 2020 12:19:04 +0100 Subject: [PATCH 10/12] Update docs to include ER diagram descriptions --- docs/README.md | 18 +++++++++++++++--- docs/entityRelationshipDiagram.md | 4 ++-- docs/img/er.png | Bin 0 -> 56576 bytes docs/img/simple-er.png | Bin 0 -> 8080 bytes 4 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 docs/img/er.png create mode 100644 docs/img/simple-er.png diff --git a/docs/README.md b/docs/README.md index 4fb40e62b..f8e5046df 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,11 +16,11 @@ Check out the list of [Integrations and Usages of Mermaid](./integrations.md) **Mermaid was nominated and won the JS Open Source Awards (2019) in the category "The most exciting use of technology"!!! Thanks to all involved, people committing pull requests, people answering questions and special thanks to Tyler Long who is helping me maintain the project.** -## New diagrams in 8.4 +## New diagrams in 8.5 -With version 8.4 class diagrams have got some new features, bug fixes and documentation. Another new feature in 8.4 is the new diagram type, state diagrams. +With version 8.5 there are some bug fixes and enhancements, plus a new diagram type, entity relationship diagrams. -![Image show the two new diagram types](./img/new-diagrams.png) +![Image showing the new ER diagram type](./img/er.png) ## Special note regarding version 8.2 @@ -137,6 +137,18 @@ merge newbranch ![Git graph](./img/git.png) +### Entity Relationship Diagram - :exclamation: experimental + +``` +erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains + CUSTOMER }|..|{ DELIVERY-ADDRESS : uses + +``` + +![ER diagram](./img/simple-er.png) + ## Installation ### CDN diff --git a/docs/entityRelationshipDiagram.md b/docs/entityRelationshipDiagram.md index 8a3924d3b..042adae6d 100644 --- a/docs/entityRelationshipDiagram.md +++ b/docs/entityRelationshipDiagram.md @@ -9,13 +9,13 @@ Mermaid can render ER diagrams erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE-ITEM : contains - CUSTOMER }|..|{ : DELIVERY-ADDRESS : uses + CUSTOMER }|..|{ DELIVERY-ADDRESS : uses ``` ```mermaid erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE-ITEM : contains - CUSTOMER }|..|{ : DELIVERY-ADDRESS : uses + CUSTOMER }|..|{ DELIVERY-ADDRESS : uses ``` Entity names are often capitalised, although there is no accepted standard on this, and it is not required in Mermaid. diff --git a/docs/img/er.png b/docs/img/er.png new file mode 100644 index 0000000000000000000000000000000000000000..21c44c257a3e0ee3e782dd5b6696602f6d9f5172 GIT binary patch literal 56576 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 Date: Thu, 16 Apr 2020 19:44:11 +0200 Subject: [PATCH 11/12] #1295 Render fix for flowchart, correct marker handling and some cleanup --- cypress/platform/current.html | 15 +- src/dagre-wrapper/clusters.js | 10 +- src/dagre-wrapper/edges.js | 23 +- src/dagre-wrapper/index.js | 64 +++-- src/dagre-wrapper/markers.js | 24 +- src/dagre-wrapper/mermaid-graphlib.js | 283 +++------------------- src/dagre-wrapper/nodes.js | 23 +- src/diagrams/flowchart/flowRenderer-v2.js | 2 + 8 files changed, 126 insertions(+), 318 deletions(-) diff --git a/cypress/platform/current.html b/cypress/platform/current.html index dd32f0be0..01ce04d88 100644 --- a/cypress/platform/current.html +++ b/cypress/platform/current.html @@ -7,7 +7,7 @@