From f0e47f29fde220d0416da0c739e116a7300d6f4d Mon Sep 17 00:00:00 2001 From: yari-dewalt Date: Tue, 21 Jan 2025 11:27:09 -0800 Subject: [PATCH] Create and register requirementBox shape --- .../rendering-elements/shapes.ts | 4 + .../shapes/requirementBox.ts | 178 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index dbfc93677..a2f8b55b2 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -58,6 +58,7 @@ import { waveEdgedRectangle } from './shapes/waveEdgedRectangle.js'; import { waveRectangle } from './shapes/waveRectangle.js'; import { windowPane } from './shapes/windowPane.js'; import { classBox } from './shapes/classBox.js'; +import { requirementBox } from './shapes/requirementBox.js'; import { kanbanItem } from './shapes/kanbanItem.js'; type ShapeHandler = ( @@ -476,6 +477,9 @@ const generateShapeMap = () => { // class diagram classBox, + + // Requirement diagram + requirementBox, } as const; const entries = [ diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts new file mode 100644 index 000000000..42e14a095 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/requirementBox.ts @@ -0,0 +1,178 @@ +import { updateNodeBounds } from './util.js'; +import intersect from '../intersect/index.js'; +import type { Node } from '../../types.js'; +import { userNodeOverrides } from './handDrawnShapeStyles.js'; +import rough from 'roughjs'; +import type { D3Selection } from '../../../types.js'; +import { calculateTextWidth, decodeEntities } from '../../../utils.js'; +import { getConfig, sanitizeText } from '../../../diagram-api/diagramAPI.js'; +import { createText } from '../../createText.js'; +import { select } from 'd3'; +import type { Requirement, Element } from '../../../diagrams/requirement/types.js'; + +export async function requirementBox( + parent: D3Selection, + node: Node +) { + const requirementNode = node as unknown as Requirement; + const elementNode = node as unknown as Element; + const config = getConfig().requirement; + const PADDING = 20; + const GAP = 20; + const isRequirementNode = 'id' in node; + + // Add outer g element + const shapeSvg = parent + .insert('g') + .attr('class', '') + .attr('id', node.domId ?? node.id); + + let typeHeight; + if (isRequirementNode) { + typeHeight = await addText(shapeSvg, `<<${requirementNode.type}>>`, 0); + } else { + typeHeight = await addText(shapeSvg, '<<Element>>', 0); + } + + let accumulativeHeight = typeHeight; + const nameHeight = await addText(shapeSvg, requirementNode.name, accumulativeHeight); + accumulativeHeight += nameHeight + GAP; + + // Requirement + if (isRequirementNode) { + const idHeight = await addText(shapeSvg, `Id: ${requirementNode.id}`, accumulativeHeight); + accumulativeHeight += idHeight; + const textHeight = await addText(shapeSvg, `Text: ${requirementNode.text}`, accumulativeHeight); + accumulativeHeight += textHeight; + const riskHeight = await addText(shapeSvg, `Risk: ${requirementNode.risk}`, accumulativeHeight); + accumulativeHeight += riskHeight; + await addText(shapeSvg, `Verification: ${requirementNode.verifyMethod}`, accumulativeHeight); + } else { + // Element + const typeHeight = await addText( + shapeSvg, + `Type: ${elementNode.type ? elementNode.type : 'Not specified'}`, + accumulativeHeight + ); + accumulativeHeight += typeHeight; + await addText( + shapeSvg, + `Doc Ref: ${elementNode.docRef ? elementNode.docRef : 'None'}`, + accumulativeHeight + ); + } + + const totalWidth = Math.max( + (shapeSvg.node()?.getBBox().width ?? 200) + PADDING, + config?.rect_min_width ?? 200 + ); + const totalHeight = totalWidth; + const x = -totalWidth / 2; + const y = -totalHeight / 2; + + // Setup roughjs + // @ts-ignore TODO: Fix rough typings + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + + if (node.look !== 'handDrawn') { + options.roughness = 0; + options.fillStyle = 'solid'; + } + + // Create and center rectangle + const roughRect = rc.rectangle(x, y, totalWidth, totalHeight, options); + + const rect = shapeSvg.insert(() => roughRect, ':first-child'); + rect.attr('class', 'basic label-container'); + + // Re-translate labels now that rect is centered + // eslint-disable-next-line @typescript-eslint/no-explicit-any + shapeSvg.selectAll('.label').each((_: any, i: number, nodes: any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const text = select(nodes[i]); + + const transform = text.attr('transform'); + let translateX = 0; + let translateY = 0; + if (transform) { + const regex = RegExp(/translate\(([^,]+),([^)]+)\)/); + const translate = regex.exec(transform); + if (translate) { + translateX = parseFloat(translate[1]); + translateY = parseFloat(translate[2]); + } + } + + const newTranslateY = translateY - totalHeight / 2; + let newTranslateX = x + PADDING / 2; + + // Keep type and name labels centered. + if (i === 0 || i === 1) { + newTranslateX = translateX; + } + // Set the updated transform attribute + text.attr('transform', `translate(${newTranslateX}, ${newTranslateY + PADDING})`); + }); + + // Insert divider line + const roughLine = rc.line( + x, + y + typeHeight + nameHeight + GAP, + x + totalWidth, + y + typeHeight + nameHeight + GAP, + options + ); + shapeSvg.insert(() => roughLine); + + updateNodeBounds(node, rect); + + node.intersect = function (point) { + return intersect.rect(node, point); + }; + + return shapeSvg; +} + +async function addText( + parentGroup: D3Selection, + inputText: string, + yOffset: number, + styles: string[] = [] +) { + const textEl = parentGroup.insert('g').attr('class', 'label').attr('style', styles.join('; ')); + const config = getConfig(); + const useHtmlLabels = config.htmlLabels ?? true; + + const text = await createText( + textEl, + sanitizeText(decodeEntities(inputText)), + { + width: calculateTextWidth(inputText, config) + 50, // Add room for error when splitting text into multiple lines + classes: 'markdown-node-label', + useHtmlLabels, + }, + config + ); + let bbox; + + if (!useHtmlLabels) { + const textChild = text.children[0]; + textChild.textContent = inputText.replaceAll('>', '>').replaceAll('<', '<').trim(); + // Get the bounding box after the text update + bbox = text.getBBox(); + // Add extra height so it is similar to the html labels + bbox.height += 6; + } else { + const div = text.children[0]; + const dv = select(text); + + bbox = div.getBoundingClientRect(); + dv.attr('width', bbox.width); + dv.attr('height', bbox.height); + } + + // Center text and offset by yOffset + textEl.attr('transform', `translate(${-bbox.width / 2},${-bbox.height / 2 + yOffset})`); + return bbox.height; +}