import type cytoscape from 'cytoscape'; import { getConfig } from '../../diagram-api/diagramAPI.js'; import { createText } from '../../rendering-util/createText.js'; import { getIconSVG } from '../../rendering-util/icons.js'; import type { D3Element } from '../../types.js'; import { sanitizeText } from '../common/common.js'; import type { ArchitectureDB } from './architectureDb.js'; import { architectureIcons } from './architectureIcons.js'; import { ArchitectureDirectionArrow, ArchitectureDirectionArrowShift, edgeData, getArchitectureDirectionPair, getArchitectureDirectionXYFactors, isArchitectureDirectionX, isArchitectureDirectionXY, isArchitectureDirectionY, isArchitecturePairXY, nodeData, type ArchitectureJunction, type ArchitectureService, } from './architectureTypes.js'; import { getEdgeId } from '../../utils.js'; export const drawEdges = async function ( edgesEl: D3Element, cy: cytoscape.Core, db: ArchitectureDB ) { const padding = db.getConfigField('padding'); const iconSize = db.getConfigField('iconSize'); const halfIconSize = iconSize / 2; const arrowSize = iconSize / 6; const halfArrowSize = arrowSize / 2; await Promise.all( cy.edges().map(async (edge) => { const { source, sourceDir, sourceArrow, sourceGroup, target, targetDir, targetArrow, targetGroup, label, } = edgeData(edge); let { x: startX, y: startY } = edge[0].sourceEndpoint(); const { x: midX, y: midY } = edge[0].midpoint(); let { x: endX, y: endY } = edge[0].targetEndpoint(); // Adjust the edge distance if it has the {group} modifier const groupEdgeShift = padding + 4; // +18 comes from the service label height that extends the padding on the bottom side of each group if (sourceGroup) { if (isArchitectureDirectionX(sourceDir)) { startX += sourceDir === 'L' ? -groupEdgeShift : groupEdgeShift; } else { startY += sourceDir === 'T' ? -groupEdgeShift : groupEdgeShift + 18; } } if (targetGroup) { if (isArchitectureDirectionX(targetDir)) { endX += targetDir === 'L' ? -groupEdgeShift : groupEdgeShift; } else { endY += targetDir === 'T' ? -groupEdgeShift : groupEdgeShift + 18; } } // Adjust the edge distance if it doesn't have the {group} modifier and the endpoint is a junction node if (!sourceGroup && db.getNode(source)?.type === 'junction') { if (isArchitectureDirectionX(sourceDir)) { startX += sourceDir === 'L' ? halfIconSize : -halfIconSize; } else { startY += sourceDir === 'T' ? halfIconSize : -halfIconSize; } } if (!targetGroup && db.getNode(target)?.type === 'junction') { if (isArchitectureDirectionX(targetDir)) { endX += targetDir === 'L' ? halfIconSize : -halfIconSize; } else { endY += targetDir === 'T' ? halfIconSize : -halfIconSize; } } if (edge[0]._private.rscratch) { // const bounds = edge[0]._private.rscratch; const g = edgesEl.insert('g'); g.insert('path') .attr('d', `M ${startX},${startY} L ${midX},${midY} L${endX},${endY} `) .attr('class', 'edge') .attr('id', getEdgeId(source, target, { prefix: 'L' })); if (sourceArrow) { const xShift = isArchitectureDirectionX(sourceDir) ? ArchitectureDirectionArrowShift[sourceDir](startX, arrowSize) : startX - halfArrowSize; const yShift = isArchitectureDirectionY(sourceDir) ? ArchitectureDirectionArrowShift[sourceDir](startY, arrowSize) : startY - halfArrowSize; g.insert('polygon') .attr('points', ArchitectureDirectionArrow[sourceDir](arrowSize)) .attr('transform', `translate(${xShift},${yShift})`) .attr('class', 'arrow'); } if (targetArrow) { const xShift = isArchitectureDirectionX(targetDir) ? ArchitectureDirectionArrowShift[targetDir](endX, arrowSize) : endX - halfArrowSize; const yShift = isArchitectureDirectionY(targetDir) ? ArchitectureDirectionArrowShift[targetDir](endY, arrowSize) : endY - halfArrowSize; g.insert('polygon') .attr('points', ArchitectureDirectionArrow[targetDir](arrowSize)) .attr('transform', `translate(${xShift},${yShift})`) .attr('class', 'arrow'); } if (label) { const axis = !isArchitectureDirectionXY(sourceDir, targetDir) ? isArchitectureDirectionX(sourceDir) ? 'X' : 'Y' : 'XY'; let width = 0; if (axis === 'X') { width = Math.abs(startX - endX); } else if (axis === 'Y') { // Reduce width by a factor of 1.5 to avoid overlapping service labels width = Math.abs(startY - endY) / 1.5; } else { width = Math.abs(startX - endX) / 2; } const textElem = g.append('g'); await createText( textElem, label, { useHtmlLabels: false, width, classes: 'architecture-service-label', }, getConfig() ); textElem .attr('dy', '1em') .attr('alignment-baseline', 'middle') .attr('dominant-baseline', 'middle') .attr('text-anchor', 'middle'); if (axis === 'X') { textElem.attr('transform', 'translate(' + midX + ', ' + midY + ')'); } else if (axis === 'Y') { textElem.attr('transform', 'translate(' + midX + ', ' + midY + ') rotate(-90)'); } else if (axis === 'XY') { const pair = getArchitectureDirectionPair(sourceDir, targetDir); if (pair && isArchitecturePairXY(pair)) { const bboxOrig = textElem.node().getBoundingClientRect(); const [x, y] = getArchitectureDirectionXYFactors(pair); textElem .attr('dominant-baseline', 'auto') .attr('transform', `rotate(${-1 * x * y * 45})`); // Calculate the new width/height with the rotation applied, and transform to the proper position const bboxNew = textElem.node().getBoundingClientRect(); textElem.attr( 'transform', ` translate(${midX}, ${midY - bboxOrig.height / 2}) translate(${(x * bboxNew.width) / 2}, ${(y * bboxNew.height) / 2}) rotate(${-1 * x * y * 45}, 0, ${bboxOrig.height / 2}) ` ); } } } } }) ); }; export const drawGroups = async function ( groupsEl: D3Element, cy: cytoscape.Core, db: ArchitectureDB ) { const padding = db.getConfigField('padding'); const groupIconSize = padding * 0.75; const fontSize = db.getConfigField('fontSize'); const iconSize = db.getConfigField('iconSize'); const halfIconSize = iconSize / 2; await Promise.all( cy.nodes().map(async (node) => { const data = nodeData(node); if (data.type === 'group') { const { h, w, x1, y1 } = node.boundingBox(); const groupsNode = groupsEl.append('rect'); groupsNode .attr('id', `group-${data.id}`) .attr('x', x1 + halfIconSize) .attr('y', y1 + halfIconSize) .attr('width', w) .attr('height', h) .attr('class', 'node-bkg'); const groupLabelContainer = groupsEl.append('g'); let shiftedX1 = x1; let shiftedY1 = y1; if (data.icon) { const bkgElem = groupLabelContainer.append('g'); bkgElem.html( `${await getIconSVG(data.icon, { height: groupIconSize, width: groupIconSize, fallbackPrefix: architectureIcons.prefix })}` ); bkgElem.attr( 'transform', 'translate(' + (shiftedX1 + halfIconSize + 1) + ', ' + (shiftedY1 + halfIconSize + 1) + ')' ); shiftedX1 += groupIconSize; // TODO: test with more values // - 1 - 2 comes from the Y axis transform of the icon and label shiftedY1 += fontSize / 2 - 1 - 2; } if (data.label) { const textElem = groupLabelContainer.append('g'); await createText( textElem, data.label, { useHtmlLabels: false, width: w, classes: 'architecture-service-label', }, getConfig() ); textElem .attr('dy', '1em') .attr('alignment-baseline', 'middle') .attr('dominant-baseline', 'start') .attr('text-anchor', 'start'); textElem.attr( 'transform', 'translate(' + (shiftedX1 + halfIconSize + 4) + ', ' + (shiftedY1 + halfIconSize + 2) + ')' ); } db.setElementForId(data.id, groupsNode); } }) ); }; export const drawServices = async function ( db: ArchitectureDB, elem: D3Element, services: ArchitectureService[] ): Promise { const config = getConfig(); for (const service of services) { const serviceElem = elem.append('g'); const iconSize = db.getConfigField('iconSize'); if (service.title) { const textElem = serviceElem.append('g'); await createText( textElem, service.title, { useHtmlLabels: false, width: iconSize * 1.5, classes: 'architecture-service-label', }, config ); textElem .attr('dy', '1em') .attr('alignment-baseline', 'middle') .attr('dominant-baseline', 'middle') .attr('text-anchor', 'middle'); textElem.attr('transform', 'translate(' + iconSize / 2 + ', ' + iconSize + ')'); } const bkgElem = serviceElem.append('g'); if (service.icon) { // TODO: should a warning be given to end-users saying which icon names are available? // if (!isIconNameInUse(service.icon)) { // throw new Error(`Invalid SVG Icon name: "${service.icon}"`); // } bkgElem.html( `${await getIconSVG(service.icon, { height: iconSize, width: iconSize, fallbackPrefix: architectureIcons.prefix })}` ); } else if (service.iconText) { bkgElem.html( `${await getIconSVG('blank', { height: iconSize, width: iconSize, fallbackPrefix: architectureIcons.prefix })}` ); const textElemContainer = bkgElem.append('g'); const fo = textElemContainer .append('foreignObject') .attr('width', iconSize) .attr('height', iconSize); const divElem = fo .append('div') .attr('class', 'node-icon-text') .attr('style', `height: ${iconSize}px;`) .append('div') .html(sanitizeText(service.iconText, config)); const fontSize = parseInt( window .getComputedStyle(divElem.node(), null) .getPropertyValue('font-size') .replace(/\D/g, '') ) ?? 16; divElem.attr('style', `-webkit-line-clamp: ${Math.floor((iconSize - 2) / fontSize)};`); } else { bkgElem .append('path') .attr('class', 'node-bkg') .attr('id', 'node-' + service.id) .attr( 'd', `M0 ${iconSize} v${-iconSize} q0,-5 5,-5 h${iconSize} q5,0 5,5 v${iconSize} H0 Z` ); } serviceElem.attr('id', `service-${service.id}`).attr('class', 'architecture-service'); const { width, height } = serviceElem.node().getBBox(); service.width = width; service.height = height; db.setElementForId(service.id, serviceElem); } return 0; }; export const drawJunctions = function ( db: ArchitectureDB, elem: D3Element, junctions: ArchitectureJunction[] ) { junctions.forEach((junction) => { const junctionElem = elem.append('g'); const iconSize = db.getConfigField('iconSize'); const bkgElem = junctionElem.append('g'); bkgElem .append('rect') .attr('id', 'node-' + junction.id) .attr('fill-opacity', '0') .attr('width', iconSize) .attr('height', iconSize); junctionElem.attr('class', 'architecture-junction'); const { width, height } = junctionElem._groups[0][0].getBBox(); junctionElem.width = width; junctionElem.height = height; db.setElementForId(junction.id, junctionElem); }); };