Using cose-bilkent layout algorithm for mindmaps

This commit is contained in:
Knut Sveidqvist 2022-10-03 10:05:12 +02:00
parent 383cbcf3a0
commit 5079fa93b4
9 changed files with 247 additions and 164 deletions

View File

@ -12,10 +12,10 @@ async function createServer() {
appType: 'custom', // don't include Vite's default HTML handling middlewares
});
app.use(vite.middlewares);
app.use(express.static('./packages/mermaid/dist'));
app.use(express.static('./packages/mermaid-example-diagram/dist'));
app.use(express.static('./packages/mermaid-mindmap/dist'));
app.use(vite.middlewares);
app.use(express.static('demos'));
app.use(express.static('cypress/platform'));

View File

@ -6,6 +6,10 @@
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<link
href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Noto+Sans+SC&display=swap"
rel="stylesheet"

View File

@ -6,6 +6,10 @@
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"
/>
<link
href="https://cdn.jsdelivr.net/npm/@mdi/font@6.9.96/css/materialdesignicons.min.css"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css?family=Noto+Sans+SC&display=swap"
rel="stylesheet"
@ -63,9 +67,12 @@ mindmap
::icon(mdi mdi-fire)
gc6((grand<br/>child 6))
::icon(mdi mdi-fire)
gc7((grand<br/>grand<br/>child 8))
</pre>
<script src="./mermaid-mindmap-detector.js"></script>
<script src="./mermaid-example-diagram-detector.js"></script>
<div id="cy"></div>
<script src="http://localhost:9000/packages/mermaid-mindmap/dist/mermaid-mindmap-detector.js"></script>
<!-- <script src="./mermaid-example-diagram-detector.js"></script> -->
<!-- <script src="http://localhost:9000/packages/mermaid-mindmap/dist/mermaid-example-diagram-detector.js"></script> -->
<script src="./mermaid.js"></script>
<script>
mermaid.parseError = function (err, hash) {

View File

@ -55,6 +55,8 @@
"dependencies": {
"@braintree/sanitize-url": "^6.0.0",
"cytoscape": "^3.23.0",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.1.0",
"d3": "^7.0.0",
"non-layered-tidy-tree-layout": "^2.0.2"
},

View File

@ -1,8 +0,0 @@
const detector = function detect(txt) {
if (txt.match(/^\s*mindmap/)) {
return 'mindmap';
}
return null;
};
export default detector;

View File

@ -4,9 +4,14 @@ import { log, getConfig, setupGraphViewbox } from './mermaidUtils';
import svgDraw from './svgDraw';
import { BoundingBox, Layout } from 'non-layered-tidy-tree-layout';
import cytoscape from 'cytoscape';
import coseBilkent from 'cytoscape-cose-bilkent';
import fcose from 'cytoscape-fcose';
import clone from 'fast-clone';
import * as db from './mindmapDb';
cytoscape.use(fcose);
cytoscape.use(coseBilkent);
/**
* @param {any} svg The svg element to draw the diagram onto
* @param {object} mindmap The maindmap data and hierarchy
@ -28,17 +33,47 @@ function drawNodes(svg, mindmap, section, conf) {
* @param parent
* @param depth
* @param section
* @param edgesEl
* @param cy
* @param conf
*/
function drawEdges(edgesElem, mindmap, parent, depth, section, conf) {
if (parent) {
svgDraw.drawEdge(edgesElem, mindmap, parent, depth, section, conf);
}
if (mindmap.children) {
mindmap.children.forEach((child, index) => {
drawEdges(edgesElem, child, mindmap, depth + 1, section < 0 ? index : section, conf);
});
}
// edgesElem, cy, conf
function drawEdges(edgesEl, cy, conf) {
cy.edges().map((edge, id) => {
const data = edge.data();
if (edge[0]._private.bodyBounds) {
const bounds = edge[0]._private.rscratch;
log.info(
id,
// 'x:',
// edge.controlPoints(),
// 'y:',
// edge[0]._private.rscratch
// 'w:',
// edge.boundingbox().w,
// 'h:',
// edge.boundingbox().h,
// edge.midPoint()
data
);
// data.el.attr('transform', `translate(${node.position().x}, ${node.position().y})`);
// edgesEl
// .insert('line')
// .attr('x1', bounds.startX)
// .attr('y1', bounds.startY)
// .attr('x2', bounds.endX)
// .attr('y2', bounds.endY)
// .attr('class', 'path');
edgesEl
.insert('path')
// Todo use regular line function
.attr(
'd',
`M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} `
)
.attr('class', 'edge section-edge-' + data.section + ' edge-depth-' + data.depth);
}
});
}
/**
@ -53,159 +88,159 @@ function eachNode(mindmap, callback) {
});
}
}
/** @param {object} mindmap */
function transpose(mindmap) {
eachNode(mindmap, (node) => {
const orgWidth = node.width;
const orgX = node.x;
node.width = node.height;
node.height = orgWidth;
node.x = node.y;
node.y = orgX;
});
return mindmap;
}
/** @param {object} mindmap */
function bottomToUp(mindmap) {
log.debug('bottomToUp', mindmap);
eachNode(mindmap.result, (node) => {
// node.y = node.y - (node.y - bb.top) * 2 - node.height;
node.y = node.y - (node.y - 0) * 2 - node.height;
});
return mindmap;
}
/** @param {object} mindmap The mindmap hierarchy */
function rightToLeft(mindmap) {
eachNode(mindmap.result, (node) => {
// node.y = node.y - (node.y - bb.top) * 2 - node.height;
node.x = node.x - (node.x - 0) * 2 - node.width;
});
return mindmap;
}
/**
* @param mindmap
* @param dir
* @param {any} svg The svg element to draw the diagram onto
* @param {object} mindmap The maindmap data and hierarchy
* @param section
* @param cy
* @param {object} conf The configuration object
* @param level
*/
function layout(mindmap, dir) {
const bb = new BoundingBox(30, 60);
const layout = new Layout(bb);
switch (dir) {
case 'TB':
return layout.layout(mindmap);
case 'BT':
return bottomToUp(layout.layout(mindmap));
case 'RL': {
transpose(mindmap);
let newRes = layout.layout(mindmap);
transpose(newRes.result);
return rightToLeft(newRes);
}
case 'LR': {
transpose(mindmap);
let newRes = layout.layout(mindmap);
transpose(newRes.result);
return newRes;
}
default:
}
}
const dirFromIndex = (index) => {
const dirNum = (index + 2) % 4;
switch (dirNum) {
case 0:
return 'LR';
case 1:
return 'RL';
case 2:
return 'TB';
case 3:
return 'BT';
default:
return 'TB';
}
};
const mergeTrees = (node, trees) => {
node.x = trees[0].result.x;
node.y = trees[0].result.y;
trees.forEach((tree) => {
tree.result.children.forEach((child) => {
const dx = node.x - tree.result.x;
const dy = node.y - tree.result.y;
eachNode(child, (childNode) => {
const orgNode = db.getNodeById(childNode.id);
if (orgNode) {
orgNode.x = childNode.x + dx;
orgNode.y = childNode.y + dy;
}
function addNodes(mindmap, cy, conf, level) {
const node = cy.add({
group: 'nodes',
data: {
id: mindmap.id,
labelText: mindmap.descr,
height: mindmap.height,
width: mindmap.width,
level: level,
nodeId: mindmap.id,
padding: mindmap.padding,
type: mindmap.type,
},
position: {
x: mindmap.x,
y: mindmap.y,
},
});
if (mindmap.children) {
mindmap.children.forEach((child, index) => {
addNodes(child, cy, conf, level + 1);
const edge = cy.add({
group: 'edges',
data: {
id: `${mindmap.id}_${child.id}`,
source: mindmap.id,
target: child.id,
depth: level,
section: child.section,
},
});
});
});
return node;
};
}
}
/**
* @param node
* @param conf
* @param cy
*/
function layoutMindmap(node, conf) {
// BoundingBox(gap, bottomPadding)
// const bb = new BoundingBox(10, 10);
// const layout = new Layout(bb);
// // const layout = new HorizontalLayout(bb);
if (node.children.length === 0) {
return node;
}
const trees = [];
// node.children.forEach((child, index) => {
// const tree = clone(node);
// tree.children = [tree.children[index]];
// trees.push(layout(tree, dirFromIndex(index), conf));
// });
return new Promise((resolve) => {
// BoundingBox(gap, bottomPadding)
// const bb = new BoundingBox(10, 10);
// const layout = new Layout(bb);
// // const layout = new HorizontalLayout(bb);
if (node.children.length === 0) {
return node;
}
let cnt = 0;
// For each direction, create a new tree with the same root, and add a ubset of the children to it.
for (let i = 0; i < 4; i++) {
// Calculate the number of the children of the root node that will be used in this direction
const numChildren =
Math.floor(node.children.length / 4) + (node.children.length % 4 > i ? 1 : 0);
// Copy the original root node
const tree = clone(node);
// Setup the new copy with the children to be rendered in this direction
tree.children = [];
for (let j = 0; j < numChildren; j++) {
tree.children.push(node.children[cnt]);
cnt++;
}
if (tree.children.length > 0) {
trees.push(layout(tree, dirFromIndex(i), conf));
}
}
// Let each node know the direct of its tree for when we draw the branches.
trees.forEach((tree, index) => {
tree.result.direction = dirFromIndex(index);
eachNode(tree.result, (node) => {
node.direction = dirFromIndex(index);
const cy = cytoscape({
// styleEnabled: false,
// animate: false,
// ready: function () {
// log.info('Ready', this);
// },
container: document.getElementById('cy'), // container to render in
style: [
// the stylesheet for the graph
{
selector: 'node',
style: {
'background-color': '#666',
label: 'data(labelText)',
},
},
{
selector: 'edge',
style: {
width: 3,
'line-color': '#ccc',
'target-arrow-color': '#ccc',
'target-arrow-shape': 'triangle',
'curve-style': 'bezier',
label: 'data(id)',
},
},
],
});
addNodes(node, cy, conf, 0);
// Make cytoscape care about the dimensisions of the nodes
cy.nodes().forEach(function (n) {
n.layoutDimensions = () => {
const data = n.data();
// console.log(
// 'id',
// data.id,
// ' node',
// data.nodeId,
// ' layoutDimensions',
// data.width,
// 'x',
// data.height
// );
return { w: data.width, h: data.height };
};
});
// // Merge the trees into a single tree
// mergeTrees(node, trees);
cy.layout({
// name: 'grid',
// name: 'circle',
// name: 'cose',
// name: 'fcose',
name: 'cose-bilkent',
quality: 'proof',
// randomize: false,
// seed: 2,
// name: 'breadthfirst',
// headless: true,
styleEnabled: false,
animate: false,
}).run();
cy.ready((e) => {
log.info('Ready', e);
resolve({ positionedMindmap: node, cy });
});
});
// Merge the trees into a single tree
mergeTrees(node, trees);
return node;
}
/**
* @param node
* @param cy
* @param positionedMindmap
* @param conf
*/
function positionNodes(node, conf) {
svgDraw.positionNode(node, conf);
if (node.children) {
node.children.forEach((child) => {
positionNodes(child, conf);
});
}
function positionNodes(cy, conf) {
cy.nodes().map((node, id) => {
const data = node.data();
data.x = node.position().x;
data.y = node.position().y;
svgDraw.positionNode(data);
const el = db.getElementById(data.nodeId);
log.info('Id:', id, 'Position: (', node.position().x, ', ', node.position().y, ')', data);
el.attr(
'transform',
`translate(${node.position().x - data.width / 2}, ${node.position().y - data.height / 2})`
);
el.attr('attr', `apa-${id})`);
});
}
/**
@ -217,7 +252,7 @@ function positionNodes(node, conf) {
* @param diagObj
*/
export const draw = (text, id, version, diagObj) => {
export const draw = async (text, id, version, diagObj) => {
const conf = getConfig();
// This is done only for throwing the error if the text is not valid.
@ -255,11 +290,11 @@ export const draw = (text, id, version, diagObj) => {
// Next step is to layout the mindmap, giving each node a position
const positionedMindmap = layoutMindmap(mm, conf);
const { positionedMindmap, cy } = await layoutMindmap(mm, conf);
// After this we can draw, first the edges and the then nodes with the correct position
drawEdges(edgesElem, positionedMindmap, null, 0, -1, conf);
positionNodes(positionedMindmap, conf);
// // After this we can draw, first the edges and the then nodes with the correct position
drawEdges(edgesElem, cy, conf);
positionNodes(cy, conf);
// Setup the view box and size of the svg element
setupGraphViewbox(undefined, svg, conf.mindmap.padding, conf.mindmap.useMaxWidth);

View File

@ -164,6 +164,7 @@ const roundedRectBkg = function (elem, node) {
*/
export const drawNode = function (elem, node, section, conf) {
const nodeElem = elem.append('g');
node.section = section;
nodeElem.attr(
'class',
(node.class ? node.class + ' ' : '') +
@ -252,9 +253,9 @@ export const drawNode = function (elem, node, section, conf) {
}
// Position the node to its coordinate
if (typeof node.x !== 'undefined' && typeof node.y !== 'undefined') {
nodeElem.attr('transform', 'translate(' + node.x + ',' + node.y + ')');
}
// if (typeof node.x !== 'undefined' && typeof node.y !== 'undefined') {
// nodeElem.attr('transform', 'translate(' + node.x + ',' + node.y + ')');
// }
db.setElementForId(node.id, nodeElem);
return node.height;
};

View File

@ -308,9 +308,9 @@ const render = async function (
svg.insertBefore(style1, firstChild);
try {
diag.renderer.draw(text, id, pkg.version, diag);
await diag.renderer.draw(text, id, pkg.version, diag);
} catch (e) {
errorRenderer.draw(text, id, pkg.version);
await errorRenderer.draw(text, id, pkg.version);
throw e;
}

42
pnpm-lock.yaml generated
View File

@ -276,12 +276,16 @@ importers:
'@braintree/sanitize-url': ^6.0.0
concurrently: ^7.4.0
cytoscape: ^3.23.0
cytoscape-cose-bilkent: ^4.1.0
cytoscape-fcose: ^2.1.0
d3: ^7.0.0
non-layered-tidy-tree-layout: ^2.0.2
rimraf: ^3.0.2
dependencies:
'@braintree/sanitize-url': 6.0.0
cytoscape: 3.23.0
cytoscape-cose-bilkent: 4.1.0_cytoscape@3.23.0
cytoscape-fcose: 2.1.0_cytoscape@3.23.0
d3: 7.6.1
non-layered-tidy-tree-layout: 2.0.2
devDependencies:
@ -4712,6 +4716,18 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: true
/cose-base/1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
dependencies:
layout-base: 1.0.2
dev: false
/cose-base/2.1.0:
resolution: {integrity: sha512-HTMm07dhxq1dIPGWwpiVrIk9n+DH7KYmqWA786mLe8jDS+1ZjGtJGIIsJVKoseZXS6/FxiUWCJ2B7XzqUCuhPw==}
dependencies:
layout-base: 2.0.1
dev: false
/cosmiconfig-typescript-loader/4.1.0_3owiowz3ujipd4k6pbqn3n7oui:
resolution: {integrity: sha512-HbWIuR5O+XO5Oj9SZ5bzgrD4nN+rfhrm2PMb0FVx+t+XIvC45n8F0oTNnztXtspWGw0i2IzHaUWFD5LzV1JB4A==}
engines: {node: '>=12', npm: '>=6'}
@ -4881,6 +4897,24 @@ packages:
yauzl: 2.10.0
dev: true
/cytoscape-cose-bilkent/4.1.0_cytoscape@3.23.0:
resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==}
peerDependencies:
cytoscape: ^3.2.0
dependencies:
cose-base: 1.0.3
cytoscape: 3.23.0
dev: false
/cytoscape-fcose/2.1.0_cytoscape@3.23.0:
resolution: {integrity: sha512-Q3apPl66jf8/2sMsrCjNP247nbDkyIPjA9g5iPMMWNLZgP3/mn9aryF7EFY/oRPEpv7bKJ4jYmCoU5r5/qAc1Q==}
peerDependencies:
cytoscape: ^3.2.0
dependencies:
cose-base: 2.1.0
cytoscape: 3.23.0
dev: false
/cytoscape/3.23.0:
resolution: {integrity: sha512-gRZqJj/1kiAVPkrVFvz/GccxsXhF3Qwpptl32gKKypO4IlqnKBjTOu+HbXtEggSGzC5KCaHp3/F7GgENrtsFkA==}
engines: {node: '>=0.10'}
@ -8587,6 +8621,14 @@ packages:
engines: {node: '>=12'}
dev: true
/layout-base/1.0.2:
resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==}
dev: false
/layout-base/2.0.1:
resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==}
dev: false
/lazy-ass/1.6.0:
resolution: {integrity: sha512-cc8oEVoctTvsFZ/Oje/kGnHbpWHYBe8IAJe4C0QNc3t8uM/0Y8+erSz/7Y1ALuXTEZTMvxXwO6YbX1ey3ujiZw==}
engines: {node: '> 0.8'}