mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-14 04:49:44 +02:00

Some optional description over here if you need to add more info on-behalf-of: @Mermaid-Chart <hello@mermaidchart.com>
547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
import type { LayoutOptions, Position } from 'cytoscape';
|
|
import cytoscape from 'cytoscape';
|
|
import fcose from 'cytoscape-fcose';
|
|
import { select } from 'd3';
|
|
import type { DrawDefinition, SVG } from '../../diagram-api/types.js';
|
|
import type { Diagram } from '../../Diagram.js';
|
|
import { log } from '../../logger.js';
|
|
import { registerIconPacks } from '../../rendering-util/icons.js';
|
|
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
|
|
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
|
|
import type { ArchitectureDB } from './architectureDb.js';
|
|
import { architectureIcons } from './architectureIcons.js';
|
|
import type {
|
|
ArchitectureAlignment,
|
|
ArchitectureDataStructures,
|
|
ArchitectureGroupAlignments,
|
|
ArchitectureJunction,
|
|
ArchitectureSpatialMap,
|
|
EdgeSingular,
|
|
EdgeSingularData,
|
|
NodeSingularData,
|
|
} from './architectureTypes.js';
|
|
import {
|
|
type ArchitectureDirection,
|
|
type ArchitectureEdge,
|
|
type ArchitectureGroup,
|
|
type ArchitectureService,
|
|
ArchitectureDirectionName,
|
|
edgeData,
|
|
getOppositeArchitectureDirection,
|
|
isArchitectureDirectionXY,
|
|
isArchitectureDirectionY,
|
|
nodeData,
|
|
} from './architectureTypes.js';
|
|
import { drawEdges, drawGroups, drawJunctions, drawServices } from './svgDraw.js';
|
|
|
|
registerIconPacks([
|
|
{
|
|
name: architectureIcons.prefix,
|
|
icons: architectureIcons,
|
|
},
|
|
]);
|
|
cytoscape.use(fcose as any);
|
|
|
|
function addServices(services: ArchitectureService[], cy: cytoscape.Core, db: ArchitectureDB) {
|
|
services.forEach((service) => {
|
|
cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
type: 'service',
|
|
id: service.id,
|
|
icon: service.icon,
|
|
label: service.title,
|
|
parent: service.in,
|
|
width: db.getConfigField('iconSize'),
|
|
height: db.getConfigField('iconSize'),
|
|
} as NodeSingularData,
|
|
classes: 'node-service',
|
|
});
|
|
});
|
|
}
|
|
|
|
function addJunctions(junctions: ArchitectureJunction[], cy: cytoscape.Core, db: ArchitectureDB) {
|
|
junctions.forEach((junction) => {
|
|
cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
type: 'junction',
|
|
id: junction.id,
|
|
parent: junction.in,
|
|
width: db.getConfigField('iconSize'),
|
|
height: db.getConfigField('iconSize'),
|
|
} as NodeSingularData,
|
|
classes: 'node-junction',
|
|
});
|
|
});
|
|
}
|
|
|
|
function positionNodes(db: ArchitectureDB, cy: cytoscape.Core) {
|
|
cy.nodes().map((node) => {
|
|
const data = nodeData(node);
|
|
if (data.type === 'group') {
|
|
return;
|
|
}
|
|
data.x = node.position().x;
|
|
data.y = node.position().y;
|
|
|
|
const nodeElem = db.getElementById(data.id);
|
|
nodeElem.attr('transform', 'translate(' + (data.x || 0) + ',' + (data.y || 0) + ')');
|
|
});
|
|
}
|
|
|
|
function addGroups(groups: ArchitectureGroup[], cy: cytoscape.Core) {
|
|
groups.forEach((group) => {
|
|
cy.add({
|
|
group: 'nodes',
|
|
data: {
|
|
type: 'group',
|
|
id: group.id,
|
|
icon: group.icon,
|
|
label: group.title,
|
|
parent: group.in,
|
|
} as NodeSingularData,
|
|
classes: 'node-group',
|
|
});
|
|
});
|
|
}
|
|
|
|
function addEdges(edges: ArchitectureEdge[], cy: cytoscape.Core) {
|
|
edges.forEach((parsedEdge) => {
|
|
const { lhsId, rhsId, lhsInto, lhsGroup, rhsInto, lhsDir, rhsDir, rhsGroup, title } =
|
|
parsedEdge;
|
|
const edgeType = isArchitectureDirectionXY(parsedEdge.lhsDir, parsedEdge.rhsDir)
|
|
? 'segments'
|
|
: 'straight';
|
|
const edge: EdgeSingularData = {
|
|
id: `${lhsId}-${rhsId}`,
|
|
label: title,
|
|
source: lhsId,
|
|
sourceDir: lhsDir,
|
|
sourceArrow: lhsInto,
|
|
sourceGroup: lhsGroup,
|
|
sourceEndpoint:
|
|
lhsDir === 'L'
|
|
? '0 50%'
|
|
: lhsDir === 'R'
|
|
? '100% 50%'
|
|
: lhsDir === 'T'
|
|
? '50% 0'
|
|
: '50% 100%',
|
|
target: rhsId,
|
|
targetDir: rhsDir,
|
|
targetArrow: rhsInto,
|
|
targetGroup: rhsGroup,
|
|
targetEndpoint:
|
|
rhsDir === 'L'
|
|
? '0 50%'
|
|
: rhsDir === 'R'
|
|
? '100% 50%'
|
|
: rhsDir === 'T'
|
|
? '50% 0'
|
|
: '50% 100%',
|
|
};
|
|
cy.add({
|
|
group: 'edges',
|
|
data: edge,
|
|
classes: edgeType,
|
|
});
|
|
});
|
|
}
|
|
|
|
function getAlignments(
|
|
db: ArchitectureDB,
|
|
spatialMaps: ArchitectureSpatialMap[],
|
|
groupAlignments: ArchitectureGroupAlignments
|
|
): fcose.FcoseAlignmentConstraint {
|
|
/**
|
|
* Flattens the alignment object so nodes in different groups will be in the same alignment array IFF their groups don't connect in a conflicting alignment
|
|
*
|
|
* i.e., two groups which connect horizontally should not have nodes with vertical alignments to one another
|
|
*
|
|
* See: #5952
|
|
*
|
|
* @param alignmentObj - alignment object with the outer key being the row/col # and the inner key being the group name mapped to the nodes on that axis in the group
|
|
* @param alignmentDir - alignment direction
|
|
* @returns flattened alignment object with an arbitrary key mapping to nodes in the same row/col
|
|
*/
|
|
const flattenAlignments = (
|
|
alignmentObj: Record<number, Record<string, string[]>>,
|
|
alignmentDir: ArchitectureAlignment
|
|
): Record<string, string[]> => {
|
|
return Object.entries(alignmentObj).reduce(
|
|
(prev, [dir, alignments]) => {
|
|
// prev is the mapping of x/y coordinate to an array of the nodes in that row/column
|
|
let cnt = 0;
|
|
const arr = Object.entries(alignments); // [group name, array of nodes within the group on axis dir]
|
|
if (arr.length === 1) {
|
|
// If only one group exists in the row/column, we don't need to do anything else
|
|
prev[dir] = arr[0][1];
|
|
return prev;
|
|
}
|
|
for (let i = 0; i < arr.length - 1; i++) {
|
|
for (let j = i + 1; j < arr.length; j++) {
|
|
const [aGroupId, aNodeIds] = arr[i];
|
|
const [bGroupId, bNodeIds] = arr[j];
|
|
const alignment = groupAlignments[aGroupId]?.[bGroupId]; // Get how the two groups are intended to align (undefined if they aren't)
|
|
|
|
if (alignment === alignmentDir) {
|
|
// If the intended alignment between the two groups is the same as the alignment we are parsing
|
|
prev[dir] ??= [];
|
|
prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds]; // add the node ids of both groups to the axis array in prev
|
|
} else if (aGroupId === 'default' || bGroupId === 'default') {
|
|
// If either of the groups are in the default space (not in a group), use the same behavior as above
|
|
prev[dir] ??= [];
|
|
prev[dir] = [...prev[dir], ...aNodeIds, ...bNodeIds];
|
|
} else {
|
|
// Otherwise, the nodes in the two groups are not intended to align
|
|
const keyA = `${dir}-${cnt++}`;
|
|
prev[keyA] = aNodeIds;
|
|
const keyB = `${dir}-${cnt++}`;
|
|
prev[keyB] = bNodeIds;
|
|
}
|
|
}
|
|
}
|
|
|
|
return prev;
|
|
},
|
|
{} as Record<string, string[]>
|
|
);
|
|
};
|
|
|
|
const alignments = spatialMaps.map((spatialMap) => {
|
|
const horizontalAlignments: Record<number, Record<string, string[]>> = {};
|
|
const verticalAlignments: Record<number, Record<string, string[]>> = {};
|
|
|
|
// Group service ids in an object with their x and y coordinate as the key
|
|
Object.entries(spatialMap).forEach(([id, [x, y]]) => {
|
|
const nodeGroup = db.getNode(id)?.in ?? 'default';
|
|
|
|
horizontalAlignments[y] ??= {};
|
|
horizontalAlignments[y][nodeGroup] ??= [];
|
|
horizontalAlignments[y][nodeGroup].push(id);
|
|
|
|
verticalAlignments[x] ??= {};
|
|
verticalAlignments[x][nodeGroup] ??= [];
|
|
verticalAlignments[x][nodeGroup].push(id);
|
|
});
|
|
|
|
// Merge the values of each object into a list if the inner list has at least 2 elements
|
|
return {
|
|
horiz: Object.values(flattenAlignments(horizontalAlignments, 'horizontal')).filter(
|
|
(arr) => arr.length > 1
|
|
),
|
|
vert: Object.values(flattenAlignments(verticalAlignments, 'vertical')).filter(
|
|
(arr) => arr.length > 1
|
|
),
|
|
};
|
|
});
|
|
|
|
// Merge the alignment lists for each spatial map into one 2d array per axis
|
|
const [horizontal, vertical] = alignments.reduce(
|
|
([prevHoriz, prevVert], { horiz, vert }) => {
|
|
return [
|
|
[...prevHoriz, ...horiz],
|
|
[...prevVert, ...vert],
|
|
];
|
|
},
|
|
[[] as string[][], [] as string[][]]
|
|
);
|
|
|
|
return {
|
|
horizontal,
|
|
vertical,
|
|
};
|
|
}
|
|
|
|
function getRelativeConstraints(
|
|
spatialMaps: ArchitectureSpatialMap[],
|
|
db: ArchitectureDB
|
|
): fcose.FcoseRelativePlacementConstraint[] {
|
|
const relativeConstraints: fcose.FcoseRelativePlacementConstraint[] = [];
|
|
const posToStr = (pos: number[]) => `${pos[0]},${pos[1]}`;
|
|
const strToPos = (pos: string) => pos.split(',').map((p) => parseInt(p));
|
|
|
|
spatialMaps.forEach((spatialMap) => {
|
|
const invSpatialMap = Object.fromEntries(
|
|
Object.entries(spatialMap).map(([id, pos]) => [posToStr(pos), id])
|
|
);
|
|
|
|
// perform BFS
|
|
const queue = [posToStr([0, 0])];
|
|
const visited: Record<string, number> = {};
|
|
const directions: Record<ArchitectureDirection, number[]> = {
|
|
L: [-1, 0],
|
|
R: [1, 0],
|
|
T: [0, 1],
|
|
B: [0, -1],
|
|
};
|
|
while (queue.length > 0) {
|
|
const curr = queue.shift();
|
|
if (curr) {
|
|
visited[curr] = 1;
|
|
const currId = invSpatialMap[curr];
|
|
if (currId) {
|
|
const currPos = strToPos(curr);
|
|
Object.entries(directions).forEach(([dir, shift]) => {
|
|
const newPos = posToStr([currPos[0] + shift[0], currPos[1] + shift[1]]);
|
|
const newId = invSpatialMap[newPos];
|
|
// If there is an adjacent service to the current one and it has not yet been visited
|
|
if (newId && !visited[newPos]) {
|
|
queue.push(newPos);
|
|
// @ts-ignore cannot determine if left/right or top/bottom are paired together
|
|
relativeConstraints.push({
|
|
[ArchitectureDirectionName[dir as ArchitectureDirection]]: newId,
|
|
[ArchitectureDirectionName[
|
|
getOppositeArchitectureDirection(dir as ArchitectureDirection)
|
|
]]: currId,
|
|
gap: 1.5 * db.getConfigField('iconSize'),
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
return relativeConstraints;
|
|
}
|
|
|
|
function layoutArchitecture(
|
|
services: ArchitectureService[],
|
|
junctions: ArchitectureJunction[],
|
|
groups: ArchitectureGroup[],
|
|
edges: ArchitectureEdge[],
|
|
db: ArchitectureDB,
|
|
{ spatialMaps, groupAlignments }: ArchitectureDataStructures
|
|
): Promise<cytoscape.Core> {
|
|
return new Promise((resolve) => {
|
|
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
|
|
const cy = cytoscape({
|
|
container: document.getElementById('cy'),
|
|
style: [
|
|
{
|
|
selector: 'edge',
|
|
style: {
|
|
'curve-style': 'straight',
|
|
label: 'data(label)',
|
|
'source-endpoint': 'data(sourceEndpoint)',
|
|
'target-endpoint': 'data(targetEndpoint)',
|
|
},
|
|
},
|
|
{
|
|
selector: 'edge.segments',
|
|
style: {
|
|
'curve-style': 'segments',
|
|
'segment-weights': '0',
|
|
'segment-distances': [0.5],
|
|
// @ts-ignore Incorrect library types
|
|
'edge-distances': 'endpoints',
|
|
'source-endpoint': 'data(sourceEndpoint)',
|
|
'target-endpoint': 'data(targetEndpoint)',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node',
|
|
style: {
|
|
// @ts-ignore Incorrect library types
|
|
'compound-sizing-wrt-labels': 'include',
|
|
},
|
|
},
|
|
{
|
|
selector: 'node[label]',
|
|
style: {
|
|
'text-valign': 'bottom',
|
|
'text-halign': 'center',
|
|
'font-size': `${db.getConfigField('fontSize')}px`,
|
|
},
|
|
},
|
|
{
|
|
selector: '.node-service',
|
|
style: {
|
|
label: 'data(label)',
|
|
width: 'data(width)',
|
|
height: 'data(height)',
|
|
},
|
|
},
|
|
{
|
|
selector: '.node-junction',
|
|
style: {
|
|
width: 'data(width)',
|
|
height: 'data(height)',
|
|
},
|
|
},
|
|
{
|
|
selector: '.node-group',
|
|
style: {
|
|
// @ts-ignore Incorrect library types
|
|
padding: `${db.getConfigField('padding')}px`,
|
|
},
|
|
},
|
|
],
|
|
layout: {
|
|
name: 'grid',
|
|
boundingBox: {
|
|
x1: 0,
|
|
x2: 100,
|
|
y1: 0,
|
|
y2: 100,
|
|
},
|
|
},
|
|
});
|
|
// Remove element after layout
|
|
renderEl.remove();
|
|
|
|
addGroups(groups, cy);
|
|
addServices(services, cy, db);
|
|
addJunctions(junctions, cy, db);
|
|
addEdges(edges, cy);
|
|
// Use the spatial map to create alignment arrays for fcose
|
|
const alignmentConstraint = getAlignments(db, spatialMaps, groupAlignments);
|
|
|
|
// Create the relative constraints for fcose by using an inverse of the spatial map and performing BFS on it
|
|
const relativePlacementConstraint = getRelativeConstraints(spatialMaps, db);
|
|
|
|
const layout = cy.layout({
|
|
name: 'fcose',
|
|
quality: 'proof',
|
|
styleEnabled: false,
|
|
animate: false,
|
|
nodeDimensionsIncludeLabels: false,
|
|
// Adjust the edge parameters if it passes through the border of a group
|
|
// Hacky fix for: https://github.com/iVis-at-Bilkent/cytoscape.js-fcose/issues/67
|
|
idealEdgeLength(edge: EdgeSingular) {
|
|
const [nodeA, nodeB] = edge.connectedNodes();
|
|
const { parent: parentA } = nodeData(nodeA);
|
|
const { parent: parentB } = nodeData(nodeB);
|
|
const elasticity =
|
|
parentA === parentB
|
|
? 1.5 * db.getConfigField('iconSize')
|
|
: 0.5 * db.getConfigField('iconSize');
|
|
return elasticity;
|
|
},
|
|
edgeElasticity(edge: EdgeSingular) {
|
|
const [nodeA, nodeB] = edge.connectedNodes();
|
|
const { parent: parentA } = nodeData(nodeA);
|
|
const { parent: parentB } = nodeData(nodeB);
|
|
const elasticity = parentA === parentB ? 0.45 : 0.001;
|
|
return elasticity;
|
|
},
|
|
alignmentConstraint,
|
|
relativePlacementConstraint,
|
|
} as LayoutOptions);
|
|
|
|
// Once the diagram has been generated and the service's position cords are set, adjust the XY edges to have a 90deg bend
|
|
layout.one('layoutstop', () => {
|
|
function getSegmentWeights(
|
|
source: Position,
|
|
target: Position,
|
|
pointX: number,
|
|
pointY: number
|
|
) {
|
|
let W, D;
|
|
const { x: sX, y: sY } = source;
|
|
const { x: tX, y: tY } = target;
|
|
|
|
D =
|
|
(pointY - sY + ((sX - pointX) * (sY - tY)) / (sX - tX)) /
|
|
Math.sqrt(1 + Math.pow((sY - tY) / (sX - tX), 2));
|
|
W = Math.sqrt(Math.pow(pointY - sY, 2) + Math.pow(pointX - sX, 2) - Math.pow(D, 2));
|
|
|
|
const distAB = Math.sqrt(Math.pow(tX - sX, 2) + Math.pow(tY - sY, 2));
|
|
W = W / distAB;
|
|
|
|
//check whether the point (pointX, pointY) is on right or left of the line src to tgt. for instance : a point C(X, Y) and line (AB). d=(xB-xA)(yC-yA)-(yB-yA)(xC-xA). if d>0, then C is on left of the line. if d<0, it is on right. if d=0, it is on the line.
|
|
let delta1 = (tX - sX) * (pointY - sY) - (tY - sY) * (pointX - sX);
|
|
switch (true) {
|
|
case delta1 >= 0:
|
|
delta1 = 1;
|
|
break;
|
|
case delta1 < 0:
|
|
delta1 = -1;
|
|
break;
|
|
}
|
|
//check whether the point (pointX, pointY) is "behind" the line src to tgt
|
|
let delta2 = (tX - sX) * (pointX - sX) + (tY - sY) * (pointY - sY);
|
|
switch (true) {
|
|
case delta2 >= 0:
|
|
delta2 = 1;
|
|
break;
|
|
case delta2 < 0:
|
|
delta2 = -1;
|
|
break;
|
|
}
|
|
|
|
D = Math.abs(D) * delta1; //ensure that sign of D is same as sign of delta1. Hence we need to take absolute value of D and multiply by delta1
|
|
W = W * delta2;
|
|
|
|
return {
|
|
distances: D,
|
|
weights: W,
|
|
};
|
|
}
|
|
cy.startBatch();
|
|
for (const edge of Object.values(cy.edges())) {
|
|
if (edge.data?.()) {
|
|
const { x: sX, y: sY } = edge.source().position();
|
|
const { x: tX, y: tY } = edge.target().position();
|
|
if (sX !== tX && sY !== tY) {
|
|
const sEP = edge.sourceEndpoint();
|
|
const tEP = edge.targetEndpoint();
|
|
const { sourceDir } = edgeData(edge);
|
|
const [pointX, pointY] = isArchitectureDirectionY(sourceDir)
|
|
? [sEP.x, tEP.y]
|
|
: [tEP.x, sEP.y];
|
|
const { weights, distances } = getSegmentWeights(sEP, tEP, pointX, pointY);
|
|
edge.style('segment-distances', distances);
|
|
edge.style('segment-weights', weights);
|
|
}
|
|
}
|
|
}
|
|
cy.endBatch();
|
|
layout.run();
|
|
});
|
|
layout.run();
|
|
|
|
cy.ready((e) => {
|
|
log.info('Ready', e);
|
|
resolve(cy);
|
|
});
|
|
});
|
|
}
|
|
|
|
export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram) => {
|
|
// TODO: Add title support for architecture diagrams
|
|
|
|
const db = diagObj.db as ArchitectureDB;
|
|
|
|
const services = db.getServices();
|
|
const junctions = db.getJunctions();
|
|
const groups = db.getGroups();
|
|
const edges = db.getEdges();
|
|
const ds = db.getDataStructures();
|
|
|
|
const svg: SVG = selectSvgElement(id);
|
|
|
|
const edgesElem = svg.append('g');
|
|
edgesElem.attr('class', 'architecture-edges');
|
|
|
|
const servicesElem = svg.append('g');
|
|
servicesElem.attr('class', 'architecture-services');
|
|
|
|
const groupElem = svg.append('g');
|
|
groupElem.attr('class', 'architecture-groups');
|
|
|
|
await drawServices(db, servicesElem, services);
|
|
drawJunctions(db, servicesElem, junctions);
|
|
|
|
const cy = await layoutArchitecture(services, junctions, groups, edges, db, ds);
|
|
|
|
await drawEdges(edgesElem, cy, db);
|
|
await drawGroups(groupElem, cy, db);
|
|
positionNodes(db, cy);
|
|
|
|
setupGraphViewbox(undefined, svg, db.getConfigField('padding'), db.getConfigField('useMaxWidth'));
|
|
};
|
|
|
|
export const renderer = { draw };
|