mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-08-17 23:39:26 +02:00
feat(arch): improved group rendering
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import cytoscape from 'cytoscape';
|
import cytoscape from 'cytoscape';
|
||||||
import type { Diagram } from '../../Diagram.js';
|
import type { Diagram } from '../../Diagram.js';
|
||||||
import fcose, {FcoseLayoutOptions} from 'cytoscape-fcose';
|
import fcose, { FcoseLayoutOptions } from 'cytoscape-fcose';
|
||||||
import type { MermaidConfig } from '../../config.type.js';
|
import type { MermaidConfig } from '../../config.type.js';
|
||||||
import { getConfig } from '../../diagram-api/diagramAPI.js';
|
import { getConfig } from '../../diagram-api/diagramAPI.js';
|
||||||
import type { DrawDefinition, SVG } from '../../diagram-api/types.js';
|
import type { DrawDefinition, SVG } from '../../diagram-api/types.js';
|
||||||
@@ -19,7 +19,7 @@ import { select } from 'd3';
|
|||||||
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
|
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
|
||||||
import defaultConfig from '../../defaultConfig.js';
|
import defaultConfig from '../../defaultConfig.js';
|
||||||
import type { D3Element } from '../../mermaidAPI.js';
|
import type { D3Element } from '../../mermaidAPI.js';
|
||||||
import { drawEdges, drawService, getEdgeThicknessCallback } from './svgDraw.js';
|
import { drawEdges, drawGroups, drawService } from './svgDraw.js';
|
||||||
|
|
||||||
cytoscape.use(fcose);
|
cytoscape.use(fcose);
|
||||||
|
|
||||||
@@ -28,14 +28,16 @@ function addServices(services: ArchitectureService[], cy: cytoscape.Core) {
|
|||||||
cy.add({
|
cy.add({
|
||||||
group: 'nodes',
|
group: 'nodes',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'service',
|
||||||
id: service.id,
|
id: service.id,
|
||||||
icon: service.icon,
|
icon: service.icon,
|
||||||
title: service.title,
|
label: service.title,
|
||||||
parent: service.in,
|
parent: service.in,
|
||||||
// TODO: dynamic size
|
// TODO: dynamic size
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 80
|
height: 80,
|
||||||
},
|
},
|
||||||
|
classes: 'node-service'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -54,11 +56,13 @@ function addGroups(groups: ArchitectureGroup[], cy: cytoscape.Core) {
|
|||||||
cy.add({
|
cy.add({
|
||||||
group: 'nodes',
|
group: 'nodes',
|
||||||
data: {
|
data: {
|
||||||
|
type: 'group',
|
||||||
id: group.id,
|
id: group.id,
|
||||||
icon: group.icon,
|
icon: group.icon,
|
||||||
title: group.title,
|
label: group.title,
|
||||||
parent: group.in
|
parent: group.in
|
||||||
},
|
},
|
||||||
|
classes: 'node-group'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -67,6 +71,7 @@ function positionServices(db: ArchitectureDB, cy: cytoscape.Core) {
|
|||||||
cy.nodes().map((node, id) => {
|
cy.nodes().map((node, id) => {
|
||||||
|
|
||||||
const data = node.data();
|
const data = node.data();
|
||||||
|
if (data.type === 'group') return;
|
||||||
data.x = node.position().x;
|
data.x = node.position().x;
|
||||||
data.y = node.position().y;
|
data.y = node.position().y;
|
||||||
|
|
||||||
@@ -104,10 +109,40 @@ function layoutArchitecture(
|
|||||||
selector: 'edge',
|
selector: 'edge',
|
||||||
style: {
|
style: {
|
||||||
'curve-style': 'straight',
|
'curve-style': 'straight',
|
||||||
'source-endpoint': '100% 100%',
|
'source-endpoint': '50% 50%',
|
||||||
'target-endpoint': '100% 100%',
|
'target-endpoint': '50% 50%',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
selector: 'node',
|
||||||
|
style: {
|
||||||
|
//@ts-ignore
|
||||||
|
'compound-sizing-wrt-labels': 'include',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node[label]',
|
||||||
|
style: {
|
||||||
|
'text-valign': 'bottom',
|
||||||
|
'text-halign': 'center',
|
||||||
|
'font-size': '16px',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '.node-service',
|
||||||
|
style: {
|
||||||
|
'label': 'data(label)',
|
||||||
|
'width': 'data(width)',
|
||||||
|
'height': 'data(height)',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: '.node-group',
|
||||||
|
style: {
|
||||||
|
//@ts-ignore
|
||||||
|
"padding": '30px'
|
||||||
|
}
|
||||||
|
}
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
// Remove element after layout
|
// Remove element after layout
|
||||||
@@ -117,53 +152,95 @@ function layoutArchitecture(
|
|||||||
addServices(services, cy);
|
addServices(services, cy);
|
||||||
addEdges(lines, cy);
|
addEdges(lines, cy);
|
||||||
|
|
||||||
// Make cytoscape care about the dimensions of the nodes
|
/**
|
||||||
cy.nodes().forEach(function (n) {
|
* Merge alignment pairs together if they share a common node.
|
||||||
n.layoutDimensions = () => {
|
*
|
||||||
const data = n.data();
|
* Example: [["a", "b"], ["b", "c"], ["d", "e"]] -> [["a", "b", "c"], ["d", "e"]]
|
||||||
return { w: data.width, h: data.height };
|
*/
|
||||||
};
|
const mergeAlignments = (orig: string[][]): string[][] => {
|
||||||
});
|
console.log('Start: ', orig);
|
||||||
|
// Mapping of discovered ids to their index in the new alignment array
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
const newAlignments: string[][] = [orig[0]];
|
||||||
|
map[orig[0][0]] = 0;
|
||||||
|
map[orig[0][1]] = 0;
|
||||||
|
orig = orig.slice(1);
|
||||||
|
while (orig.length > 0) {
|
||||||
|
const pair = orig[0];
|
||||||
|
const pairLHSIdx = map[pair[0]];
|
||||||
|
const pairRHSIdx = map[pair[1]];
|
||||||
|
console.log(pair);
|
||||||
|
console.log(map);
|
||||||
|
console.log(newAlignments);
|
||||||
|
// If neither id appears in the new array, add the pair to the new array
|
||||||
|
if (pairLHSIdx === undefined && pairRHSIdx === undefined) {
|
||||||
|
newAlignments.push(pair);
|
||||||
|
map[pair[0]] = newAlignments.length - 1;
|
||||||
|
map[pair[1]] = newAlignments.length - 1;
|
||||||
|
// If the LHS of the pair doesn't appear in the new array, add the LHS to the existing array it shares an id with
|
||||||
|
} else if (pairLHSIdx === undefined) {
|
||||||
|
newAlignments[pairRHSIdx].push(pair[0]);
|
||||||
|
map[pair[0]] = pairRHSIdx;
|
||||||
|
// If the RHS of the pair doesn't appear in the new array, add the RHS to the existing array it shares an id with
|
||||||
|
} else if (pairRHSIdx === undefined) {
|
||||||
|
newAlignments[pairLHSIdx].push(pair[1]);
|
||||||
|
map[pair[1]] = pairLHSIdx;
|
||||||
|
// If both ids already have been added to the new array and their index is different, merge all 3 arrays
|
||||||
|
} else if (pairLHSIdx != pairRHSIdx) {
|
||||||
|
console.log('ELSE');
|
||||||
|
newAlignments.push(pair);
|
||||||
|
}
|
||||||
|
orig = orig.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
cy.layout({
|
console.log('End: ', newAlignments);
|
||||||
name: 'fcose',
|
return newAlignments;
|
||||||
quality: 'proof',
|
}
|
||||||
styleEnabled: false,
|
|
||||||
animate: false,
|
const horizontalAlignments = cy
|
||||||
alignmentConstraint: {
|
|
||||||
horizontal: cy
|
|
||||||
.edges()
|
.edges()
|
||||||
.filter(
|
.filter(
|
||||||
(edge) =>
|
(edge) =>
|
||||||
isArchitectureDirectionX(edge.data('sourceDir')) &&
|
isArchitectureDirectionX(edge.data('sourceDir')) &&
|
||||||
isArchitectureDirectionX(edge.data('targetDir'))
|
isArchitectureDirectionX(edge.data('targetDir'))
|
||||||
)
|
)
|
||||||
.map((edge) => [edge.data('source'), edge.data('target')]),
|
.map((edge) => [edge.data('source'), edge.data('target')]);
|
||||||
vertical: cy
|
|
||||||
|
const verticalAlignments = cy
|
||||||
.edges()
|
.edges()
|
||||||
.filter(
|
.filter(
|
||||||
(edge) =>
|
(edge) =>
|
||||||
isArchitectureDirectionY(edge.data('sourceDir')) &&
|
isArchitectureDirectionY(edge.data('sourceDir')) &&
|
||||||
isArchitectureDirectionY(edge.data('targetDir'))
|
isArchitectureDirectionY(edge.data('targetDir'))
|
||||||
)
|
)
|
||||||
.map((edge) => [edge.data('source'), edge.data('target')]),
|
.map((edge) => [edge.data('source'), edge.data('target')]);
|
||||||
|
|
||||||
|
cy.layout({
|
||||||
|
name: 'fcose',
|
||||||
|
quality: 'proof',
|
||||||
|
styleEnabled: false,
|
||||||
|
animate: false,
|
||||||
|
nodeDimensionsIncludeLabels: true,
|
||||||
|
alignmentConstraint: {
|
||||||
|
horizontal: mergeAlignments(horizontalAlignments),
|
||||||
|
vertical: mergeAlignments(verticalAlignments)
|
||||||
},
|
},
|
||||||
relativePlacementConstraint: cy.edges().map((edge) => {
|
relativePlacementConstraint: cy.edges().map((edge) => {
|
||||||
const sourceDir = edge.data('sourceDir') as ArchitectureDirection;
|
const sourceDir = edge.data('sourceDir') as ArchitectureDirection;
|
||||||
const targetDir = edge.data('targetDir') as ArchitectureDirection;
|
const targetDir = edge.data('targetDir') as ArchitectureDirection;
|
||||||
const sourceId = edge.data('source') as ArchitectureDirection;
|
const sourceId = edge.data('source') as string;
|
||||||
const targetId = edge.data('target') as ArchitectureDirection;
|
const targetId = edge.data('target') as string;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isArchitectureDirectionX(sourceDir) &&
|
isArchitectureDirectionX(sourceDir) &&
|
||||||
isArchitectureDirectionX(targetDir)
|
isArchitectureDirectionX(targetDir)
|
||||||
) {
|
) {
|
||||||
return {left: sourceDir === 'L' ? sourceId : targetId, right: sourceDir === 'R' ? sourceId : targetId, gap: 180}
|
return { left: sourceDir === 'R' ? sourceId : targetId, right: sourceDir === 'L' ? sourceId : targetId, gap: 180 }
|
||||||
} else if (
|
} else if (
|
||||||
isArchitectureDirectionY(sourceDir) &&
|
isArchitectureDirectionY(sourceDir) &&
|
||||||
isArchitectureDirectionY(targetDir)
|
isArchitectureDirectionY(targetDir)
|
||||||
) {
|
) {
|
||||||
return {top: sourceDir === 'T' ? sourceId : targetId, bottom: sourceDir === 'B' ? sourceId : targetId, gap: 180}
|
return { top: sourceDir === 'B' ? sourceId : targetId, bottom: sourceDir === 'T' ? sourceId : targetId, gap: 180 }
|
||||||
}
|
}
|
||||||
// TODO: fallback case + RB, TL, etc
|
// TODO: fallback case + RB, TL, etc
|
||||||
|
|
||||||
@@ -183,8 +260,9 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
|
|||||||
const services = db.getServices();
|
const services = db.getServices();
|
||||||
const groups = db.getGroups();
|
const groups = db.getGroups();
|
||||||
const lines = db.getLines();
|
const lines = db.getLines();
|
||||||
log.info('Services: ', services);
|
console.log('Services: ', services);
|
||||||
log.info('Lines: ', lines);
|
console.log('Lines: ', lines);
|
||||||
|
console.log('Groups: ', groups);
|
||||||
|
|
||||||
const svg: SVG = selectSvgElement(id);
|
const svg: SVG = selectSvgElement(id);
|
||||||
|
|
||||||
@@ -194,13 +272,15 @@ export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram)
|
|||||||
const servicesElem = svg.append('g');
|
const servicesElem = svg.append('g');
|
||||||
servicesElem.attr('class', 'architecture-services');
|
servicesElem.attr('class', 'architecture-services');
|
||||||
|
|
||||||
|
const groupElem = svg.append('g');
|
||||||
|
groupElem.attr('class', 'architecture-groups');
|
||||||
|
|
||||||
drawServices(db, servicesElem, services, conf);
|
drawServices(db, servicesElem, services, conf);
|
||||||
const getEdgeThickness = getEdgeThicknessCallback(svg);
|
|
||||||
|
|
||||||
const cy = await layoutArchitecture(services, groups, lines);
|
const cy = await layoutArchitecture(services, groups, lines);
|
||||||
|
|
||||||
const edgeThickness = getEdgeThickness();
|
drawEdges(edgesElem, cy);
|
||||||
drawEdges(edgesElem, edgeThickness, cy);
|
drawGroups(groupElem, cy);
|
||||||
positionServices(db, cy);
|
positionServices(db, cy);
|
||||||
|
|
||||||
setupGraphViewbox(
|
setupGraphViewbox(
|
||||||
|
@@ -68,6 +68,13 @@ const getStyles: DiagramStylesProvider = (options: ArchitectureStyleOptions) =>
|
|||||||
.edge {
|
.edge {
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-bkg {
|
||||||
|
fill: none;
|
||||||
|
stroke: #000;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-dasharray: 8;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default getStyles;
|
export default getStyles;
|
||||||
|
@@ -25,6 +25,8 @@ export interface ArchitectureService {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
in?: string;
|
in?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArchitectureGroup {
|
export interface ArchitectureGroup {
|
||||||
|
@@ -1,10 +1,10 @@
|
|||||||
import type { D3Element } from '../../mermaidAPI.js';
|
import type { D3Element } from '../../mermaidAPI.js';
|
||||||
import { createText } from '../../rendering-util/createText.js';
|
import { createText } from '../../rendering-util/createText.js';
|
||||||
import type { ArchitectureDB, ArchitectureService } from './architectureTypes.js';
|
import type { ArchitectureDB, ArchitectureGroup, ArchitectureService } from './architectureTypes.js';
|
||||||
import type { MermaidConfig } from '../../config.type.js';
|
import type { MermaidConfig } from '../../config.type.js';
|
||||||
import type cytoscape from 'cytoscape';
|
import type cytoscape from 'cytoscape';
|
||||||
import { log } from '../../logger.js';
|
import { log } from '../../logger.js';
|
||||||
import {getIcon, isIconNameInUse} from '../../rendering-util/svgRegister.js';
|
import { getIcon, isIconNameInUse } from '../../rendering-util/svgRegister.js';
|
||||||
|
|
||||||
declare module 'cytoscape' {
|
declare module 'cytoscape' {
|
||||||
interface EdgeSingular {
|
interface EdgeSingular {
|
||||||
@@ -20,36 +20,43 @@ declare module 'cytoscape' {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
interface NodeSingular {
|
||||||
|
_private: {
|
||||||
/**
|
bodyBounds: {
|
||||||
* Creates a temporary path which can be used to compute the line thickness.
|
h: number;
|
||||||
* @param root root element to add the temporary path to
|
w: number;
|
||||||
* @returns callback function which gets the bounding box dimensions and removes the path from root
|
x1: number;
|
||||||
*/
|
x2: number;
|
||||||
export const getEdgeThicknessCallback = function (root: D3Element) {
|
y1: number;
|
||||||
const tempPath = root.insert('path')
|
y2: number;
|
||||||
.attr(
|
};
|
||||||
'd',
|
children: cytoscape.NodeSingular[]
|
||||||
`M 10,10 L 10,20`
|
};
|
||||||
)
|
data: () => {
|
||||||
.attr('class', 'edge')
|
type: 'service',
|
||||||
.attr('id', 'temp-thickness-edge');
|
id: string,
|
||||||
|
icon?: string,
|
||||||
return () => {
|
label?: string,
|
||||||
const dims = tempPath.node().getBBox();
|
parent?: string,
|
||||||
tempPath.remove();
|
width: number,
|
||||||
return dims.height as number;
|
height: number,
|
||||||
|
[key: string]: any
|
||||||
|
} | {
|
||||||
|
type: 'group',
|
||||||
|
id: string,
|
||||||
|
icon?: string,
|
||||||
|
label?: string,
|
||||||
|
parent?: string,
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const drawEdges = function (edgesEl: D3Element, edgeThickness: number, cy: cytoscape.Core) {
|
export const drawEdges = function (edgesEl: D3Element, cy: cytoscape.Core) {
|
||||||
cy.edges().map((edge, id) => {
|
cy.edges().map((edge, id) => {
|
||||||
const data = edge.data();
|
const data = edge.data();
|
||||||
if (edge[0]._private.bodyBounds) {
|
if (edge[0]._private.bodyBounds) {
|
||||||
const bounds = edge[0]._private.rscratch;
|
const bounds = edge[0]._private.rscratch;
|
||||||
const translateX = bounds.startX === bounds.endX ? ((edgeThickness + 2) / 1.5) : 0;
|
|
||||||
const translateY = bounds.startY === bounds.endY ? ((edgeThickness + 2) / 1.5) : 0;
|
|
||||||
|
|
||||||
log.trace('Edge: ', id, data);
|
log.trace('Edge: ', id, data);
|
||||||
edgesEl
|
edgesEl
|
||||||
@@ -59,9 +66,40 @@ export const drawEdges = function (edgesEl: D3Element, edgeThickness: number, cy
|
|||||||
`M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} `
|
`M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} `
|
||||||
)
|
)
|
||||||
.attr('class', 'edge')
|
.attr('class', 'edge')
|
||||||
.attr(
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const drawGroups = function (
|
||||||
|
groupsEl: D3Element,
|
||||||
|
cy: cytoscape.Core
|
||||||
|
) {
|
||||||
|
cy.nodes().map((node, id) => {
|
||||||
|
const data = node.data();
|
||||||
|
if (data.type === 'group') {
|
||||||
|
const { h, w, x1, x2, y1, y2 } = node.boundingBox();
|
||||||
|
let bkgElem = groupsEl.append('rect')
|
||||||
|
.attr('x', x1 + 40)
|
||||||
|
.attr('y', y1 + 40)
|
||||||
|
.attr('width', w)
|
||||||
|
.attr('height', h)
|
||||||
|
.attr('class', 'node-bkg');
|
||||||
|
|
||||||
|
const textElem = groupsEl.append('g');
|
||||||
|
createText(textElem, data.title, {
|
||||||
|
useHtmlLabels: false,
|
||||||
|
width: w,
|
||||||
|
classes: 'architecture-service-label',
|
||||||
|
});
|
||||||
|
textElem
|
||||||
|
.attr('dy', '1em')
|
||||||
|
.attr('alignment-baseline', 'middle')
|
||||||
|
.attr('dominant-baseline', 'start')
|
||||||
|
.attr('text-anchor', 'start');
|
||||||
|
|
||||||
|
textElem.attr(
|
||||||
'transform',
|
'transform',
|
||||||
'translate(' + translateX + ', ' + translateY + ')'
|
'translate(' + (x1 + 44) + ', ' + (y1 + 42) + ')'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -79,7 +117,7 @@ export const drawService = function (
|
|||||||
const textElem = serviceElem.append('g');
|
const textElem = serviceElem.append('g');
|
||||||
createText(textElem, service.title, {
|
createText(textElem, service.title, {
|
||||||
useHtmlLabels: false,
|
useHtmlLabels: false,
|
||||||
width: 80,
|
width: 110,
|
||||||
classes: 'architecture-service-label',
|
classes: 'architecture-service-label',
|
||||||
});
|
});
|
||||||
textElem
|
textElem
|
||||||
@@ -90,6 +128,7 @@ export const drawService = function (
|
|||||||
|
|
||||||
textElem.attr(
|
textElem.attr(
|
||||||
'transform',
|
'transform',
|
||||||
|
// TODO: dynamic size
|
||||||
'translate(' + 80 / 2 + ', ' + 80 + ')'
|
'translate(' + 80 / 2 + ', ' + 80 + ')'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -104,16 +143,16 @@ export const drawService = function (
|
|||||||
} else {
|
} else {
|
||||||
bkgElem.append('path').attr('class', 'node-bkg').attr('id', 'node-' + service.id).attr(
|
bkgElem.append('path').attr('class', 'node-bkg').attr('id', 'node-' + service.id).attr(
|
||||||
'd',
|
'd',
|
||||||
`M0 ${80 - 0} v${-80 + 2 * 0} q0,-5 5,-5 h${
|
`M0 ${80 - 0} v${-80 + 2 * 0} q0,-5 5,-5 h${80 - 2 * 0
|
||||||
80 - 2 * 0
|
|
||||||
} q5,0 5,5 v${80 - 0} H0 Z`
|
} q5,0 5,5 v${80 - 0} H0 Z`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceElem.attr('class', 'architecture-service');
|
serviceElem.attr('class', 'architecture-service');
|
||||||
|
|
||||||
const icon = serviceElem.append('foreignObject').attr('height', '80px').attr('width', '80px');
|
const { width, height } = serviceElem._groups[0][0].getBBox();
|
||||||
icon.append('div').attr('class', 'icon-container').append('i').attr('class', 'service-icon fa fa-phone')
|
service.width = width;
|
||||||
|
service.height = height;
|
||||||
|
|
||||||
db.setElementForId(service.id, serviceElem);
|
db.setElementForId(service.id, serviceElem);
|
||||||
return 0;
|
return 0;
|
||||||
|
Reference in New Issue
Block a user