feat(arch): implemented basic rendering for diagram

This commit is contained in:
NicolasNewman
2024-03-13 09:25:20 -05:00
parent e01acec12b
commit 6c6ce28f7d
4 changed files with 422 additions and 0 deletions

View File

@@ -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,
};

View File

@@ -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 };

View File

@@ -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;

View 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;
};