mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-12-02 10:34:22 +01:00
feat(arch): implemented basic rendering for diagram
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import type { DiagramDefinition } from '../../diagram-api/types.js';
|
||||
// @ts-ignore: JISON doesn't support types
|
||||
import parser from './parser/architecture.jison';
|
||||
import { db } from './architectureDb.js';
|
||||
import styles from './architectureStyles.js';
|
||||
import { renderer } from './architectureRenderer.js';
|
||||
|
||||
export const diagram: DiagramDefinition = {
|
||||
parser,
|
||||
db,
|
||||
renderer,
|
||||
styles,
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import cytoscape from 'cytoscape';
|
||||
import type { Diagram } from '../../Diagram.js';
|
||||
import fcose, {FcoseLayoutOptions} from 'cytoscape-fcose';
|
||||
import type { MermaidConfig } from '../../config.type.js';
|
||||
import { getConfig } from '../../diagram-api/diagramAPI.js';
|
||||
import type { DrawDefinition, SVG } from '../../diagram-api/types.js';
|
||||
import { log } from '../../logger.js';
|
||||
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
|
||||
import {
|
||||
isArchitectureDirectionX,
|
||||
type ArchitectureDB,
|
||||
type ArchitectureDirection,
|
||||
type ArchitectureGroup,
|
||||
type ArchitectureLine,
|
||||
type ArchitectureService,
|
||||
isArchitectureDirectionY,
|
||||
} from './architectureTypes.js';
|
||||
import { select } from 'd3';
|
||||
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
|
||||
import defaultConfig from '../../defaultConfig.js';
|
||||
import type { D3Element } from '../../mermaidAPI.js';
|
||||
import { drawEdges, drawService, getEdgeThicknessCallback } from './svgDraw.js';
|
||||
|
||||
cytoscape.use(fcose);
|
||||
|
||||
function addServices(services: ArchitectureService[], cy: cytoscape.Core) {
|
||||
services.forEach((service) => {
|
||||
cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: service.id,
|
||||
icon: service.icon,
|
||||
title: service.title,
|
||||
parent: service.in,
|
||||
// TODO: dynamic size
|
||||
width: 80,
|
||||
height: 80
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function drawServices(
|
||||
db: ArchitectureDB,
|
||||
svg: D3Element,
|
||||
services: ArchitectureService[],
|
||||
conf: MermaidConfig
|
||||
) {
|
||||
services.forEach((service) => drawService(db, svg, service, conf));
|
||||
}
|
||||
|
||||
function addGroups(groups: ArchitectureGroup[], cy: cytoscape.Core) {
|
||||
groups.forEach((group) => {
|
||||
cy.add({
|
||||
group: 'nodes',
|
||||
data: {
|
||||
id: group.id,
|
||||
icon: group.icon,
|
||||
title: group.title,
|
||||
parent: group.in
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function positionServices(db: ArchitectureDB, cy: cytoscape.Core) {
|
||||
cy.nodes().map((node, id) => {
|
||||
|
||||
const data = node.data();
|
||||
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 addEdges(lines: ArchitectureLine[], cy: cytoscape.Core) {
|
||||
lines.forEach((line) => {
|
||||
cy.add({
|
||||
group: 'edges',
|
||||
data: {
|
||||
id: `${line.lhs_id}-${line.rhs_id}`,
|
||||
source: line.lhs_id,
|
||||
sourceDir: line.lhs_dir,
|
||||
target: line.rhs_id,
|
||||
targetDir: line.rhs_dir,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function layoutArchitecture(
|
||||
services: ArchitectureService[],
|
||||
groups: ArchitectureGroup[],
|
||||
lines: ArchitectureLine[]
|
||||
): 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',
|
||||
'source-endpoint': '100% 100%',
|
||||
'target-endpoint': '100% 100%',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Remove element after layout
|
||||
renderEl.remove();
|
||||
|
||||
addGroups(groups, cy);
|
||||
addServices(services, cy);
|
||||
addEdges(lines, cy);
|
||||
|
||||
// Make cytoscape care about the dimensions of the nodes
|
||||
cy.nodes().forEach(function (n) {
|
||||
n.layoutDimensions = () => {
|
||||
const data = n.data();
|
||||
return { w: data.width, h: data.height };
|
||||
};
|
||||
});
|
||||
|
||||
cy.layout({
|
||||
name: 'fcose',
|
||||
quality: 'proof',
|
||||
styleEnabled: false,
|
||||
animate: false,
|
||||
alignmentConstraint: {
|
||||
horizontal: cy
|
||||
.edges()
|
||||
.filter(
|
||||
(edge) =>
|
||||
isArchitectureDirectionX(edge.data('sourceDir')) &&
|
||||
isArchitectureDirectionX(edge.data('targetDir'))
|
||||
)
|
||||
.map((edge) => [edge.data('source'), edge.data('target')]),
|
||||
vertical: cy
|
||||
.edges()
|
||||
.filter(
|
||||
(edge) =>
|
||||
isArchitectureDirectionY(edge.data('sourceDir')) &&
|
||||
isArchitectureDirectionY(edge.data('targetDir'))
|
||||
)
|
||||
.map((edge) => [edge.data('source'), edge.data('target')]),
|
||||
},
|
||||
relativePlacementConstraint: cy.edges().map((edge) => {
|
||||
const sourceDir = edge.data('sourceDir') as ArchitectureDirection;
|
||||
const targetDir = edge.data('targetDir') as ArchitectureDirection;
|
||||
const sourceId = edge.data('source') as ArchitectureDirection;
|
||||
const targetId = edge.data('target') as ArchitectureDirection;
|
||||
|
||||
if (
|
||||
isArchitectureDirectionX(sourceDir) &&
|
||||
isArchitectureDirectionX(targetDir)
|
||||
) {
|
||||
return {left: sourceDir === 'L' ? sourceId : targetId, right: sourceDir === 'R' ? sourceId : targetId, gap: 180}
|
||||
} else if (
|
||||
isArchitectureDirectionY(sourceDir) &&
|
||||
isArchitectureDirectionY(targetDir)
|
||||
) {
|
||||
return {top: sourceDir === 'T' ? sourceId : targetId, bottom: sourceDir === 'B' ? sourceId : targetId, gap: 180}
|
||||
}
|
||||
// TODO: fallback case + RB, TL, etc
|
||||
|
||||
}),
|
||||
} as FcoseLayoutOptions).run();
|
||||
cy.ready((e) => {
|
||||
log.info('Ready', e);
|
||||
resolve(cy);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const draw: DrawDefinition = async (text, id, _version, diagObj: Diagram) => {
|
||||
const db = diagObj.db as ArchitectureDB;
|
||||
const conf: MermaidConfig = getConfig();
|
||||
|
||||
const services = db.getServices();
|
||||
const groups = db.getGroups();
|
||||
const lines = db.getLines();
|
||||
log.info('Services: ', services);
|
||||
log.info('Lines: ', lines);
|
||||
|
||||
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');
|
||||
|
||||
drawServices(db, servicesElem, services, conf);
|
||||
const getEdgeThickness = getEdgeThicknessCallback(svg);
|
||||
|
||||
const cy = await layoutArchitecture(services, groups, lines);
|
||||
|
||||
const edgeThickness = getEdgeThickness();
|
||||
drawEdges(edgesElem, edgeThickness, cy);
|
||||
positionServices(db, cy);
|
||||
|
||||
setupGraphViewbox(
|
||||
undefined,
|
||||
svg,
|
||||
conf.architecture?.padding ?? defaultConfig.architecture.padding,
|
||||
conf.architecture?.useMaxWidth ?? defaultConfig.architecture.useMaxWidth
|
||||
);
|
||||
|
||||
|
||||
};
|
||||
|
||||
export const renderer = { draw };
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { DiagramStylesProvider } from '../../diagram-api/types.js';
|
||||
import type { ArchitectureStyleOptions } from './architectureTypes.js';
|
||||
// @ts-expect-error Incorrect khroma types
|
||||
import { darken, lighten, isDark } from 'khroma';
|
||||
|
||||
const genSections: DiagramStylesProvider = (options) => {
|
||||
let sections = '';
|
||||
|
||||
for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) {
|
||||
options['lineColor' + i] = options['lineColor' + i] || options['cScaleInv' + i];
|
||||
if (isDark(options['lineColor' + i])) {
|
||||
options['lineColor' + i] = lighten(options['lineColor' + i], 20);
|
||||
} else {
|
||||
options['lineColor' + i] = darken(options['lineColor' + i], 20);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < options.THEME_COLOR_LIMIT; i++) {
|
||||
const sw = '' + (17 - 3 * i);
|
||||
sections += `
|
||||
.section-${i - 1} rect, .section-${i - 1} path, .section-${i - 1} circle, .section-${
|
||||
i - 1
|
||||
} polygon, .section-${i - 1} path {
|
||||
fill: ${options['cScale' + i]};
|
||||
}
|
||||
.section-${i - 1} text {
|
||||
fill: ${options['cScaleLabel' + i]};
|
||||
}
|
||||
.node-icon-${i - 1} {
|
||||
font-size: 40px;
|
||||
color: ${options['cScaleLabel' + i]};
|
||||
}
|
||||
.section-edge-${i - 1}{
|
||||
stroke: ${options['cScale' + i]};
|
||||
}
|
||||
.edge-depth-${i - 1}{
|
||||
stroke-width: ${sw};
|
||||
}
|
||||
.section-${i - 1} line {
|
||||
stroke: ${options['cScaleInv' + i]} ;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.disabled, .disabled circle, .disabled text {
|
||||
fill: lightgray;
|
||||
}
|
||||
.disabled text {
|
||||
fill: #efefef;
|
||||
}
|
||||
`;
|
||||
}
|
||||
return sections;
|
||||
};
|
||||
|
||||
const getStyles: DiagramStylesProvider = (options: ArchitectureStyleOptions) =>
|
||||
`
|
||||
.edge {
|
||||
stroke-width: 3;
|
||||
stroke: #777;
|
||||
}
|
||||
.section-root rect, .section-root path, .section-root circle, .section-root polygon {
|
||||
fill: #333;
|
||||
}
|
||||
.section-root text {
|
||||
fill:#333;
|
||||
}
|
||||
${genSections(options)}
|
||||
.edge {
|
||||
fill: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export default getStyles;
|
||||
120
packages/mermaid/src/diagrams/architecture/svgDraw.ts
Normal file
120
packages/mermaid/src/diagrams/architecture/svgDraw.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type { D3Element } from '../../mermaidAPI.js';
|
||||
import { createText } from '../../rendering-util/createText.js';
|
||||
import type { ArchitectureDB, ArchitectureService } from './architectureTypes.js';
|
||||
import type { MermaidConfig } from '../../config.type.js';
|
||||
import type cytoscape from 'cytoscape';
|
||||
import { log } from '../../logger.js';
|
||||
import {getIcon, isIconNameInUse} from '../../rendering-util/svgRegister.js';
|
||||
|
||||
declare module 'cytoscape' {
|
||||
interface EdgeSingular {
|
||||
_private: {
|
||||
bodyBounds: unknown;
|
||||
rscratch: {
|
||||
startX: number;
|
||||
startY: number;
|
||||
midX: number;
|
||||
midY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a temporary path which can be used to compute the line thickness.
|
||||
* @param root root element to add the temporary path to
|
||||
* @returns callback function which gets the bounding box dimensions and removes the path from root
|
||||
*/
|
||||
export const getEdgeThicknessCallback = function (root: D3Element) {
|
||||
const tempPath = root.insert('path')
|
||||
.attr(
|
||||
'd',
|
||||
`M 10,10 L 10,20`
|
||||
)
|
||||
.attr('class', 'edge')
|
||||
.attr('id', 'temp-thickness-edge');
|
||||
|
||||
return () => {
|
||||
const dims = tempPath.node().getBBox();
|
||||
tempPath.remove();
|
||||
return dims.height as number;
|
||||
}
|
||||
}
|
||||
|
||||
export const drawEdges = function (edgesEl: D3Element, edgeThickness: number, cy: cytoscape.Core) {
|
||||
cy.edges().map((edge, id) => {
|
||||
const data = edge.data();
|
||||
if (edge[0]._private.bodyBounds) {
|
||||
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);
|
||||
edgesEl
|
||||
.insert('path')
|
||||
.attr(
|
||||
'd',
|
||||
`M ${bounds.startX},${bounds.startY} L ${bounds.midX},${bounds.midY} L${bounds.endX},${bounds.endY} `
|
||||
)
|
||||
.attr('class', 'edge')
|
||||
.attr(
|
||||
'transform',
|
||||
'translate(' + translateX + ', ' + translateY + ')'
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const drawService = function (
|
||||
db: ArchitectureDB,
|
||||
elem: D3Element,
|
||||
service: ArchitectureService,
|
||||
conf: MermaidConfig
|
||||
): number {
|
||||
const serviceElem = elem.append('g');
|
||||
|
||||
if (service.title) {
|
||||
const textElem = serviceElem.append('g');
|
||||
createText(textElem, service.title, {
|
||||
useHtmlLabels: false,
|
||||
width: 80,
|
||||
classes: 'architecture-service-label',
|
||||
});
|
||||
textElem
|
||||
.attr('dy', '1em')
|
||||
.attr('alignment-baseline', 'middle')
|
||||
.attr('dominant-baseline', 'middle')
|
||||
.attr('text-anchor', 'middle');
|
||||
|
||||
textElem.attr(
|
||||
'transform',
|
||||
'translate(' + 80 / 2 + ', ' + 80 + ')'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
let bkgElem = serviceElem.append('g');
|
||||
if (service.icon) {
|
||||
if (!isIconNameInUse(service.icon)) {
|
||||
throw new Error(`Invalid SVG Icon name: "${service.icon}"`)
|
||||
}
|
||||
bkgElem = getIcon(service.icon)?.(bkgElem);
|
||||
} else {
|
||||
bkgElem.append('path').attr('class', 'node-bkg').attr('id', 'node-' + service.id).attr(
|
||||
'd',
|
||||
`M0 ${80 - 0} v${-80 + 2 * 0} q0,-5 5,-5 h${
|
||||
80 - 2 * 0
|
||||
} q5,0 5,5 v${80 - 0} H0 Z`
|
||||
);
|
||||
}
|
||||
|
||||
serviceElem.attr('class', 'architecture-service');
|
||||
|
||||
const icon = serviceElem.append('foreignObject').attr('height', '80px').attr('width', '80px');
|
||||
icon.append('div').attr('class', 'icon-container').append('i').attr('class', 'service-icon fa fa-phone')
|
||||
|
||||
db.setElementForId(service.id, serviceElem);
|
||||
return 0;
|
||||
};
|
||||
Reference in New Issue
Block a user