From ccafc209170e52c747c65be0fa99ecbd00354dc3 Mon Sep 17 00:00:00 2001 From: darshanr0107 Date: Thu, 24 Jul 2025 18:07:25 +0530 Subject: [PATCH] add support for cloud and bang shape on-behalf-of: @Mermaid-Chart --- cypress/integration/rendering/mindmap.spec.ts | 120 ++++++++++++++++++ .../mermaid/src/diagrams/mindmap/mindmapDb.ts | 4 +- .../rendering-elements/shapes.ts | 18 +++ .../rendering-elements/shapes/bang.ts | 77 +++++++++++ .../rendering-elements/shapes/cloud.ts | 81 ++++++++++++ 5 files changed, 298 insertions(+), 2 deletions(-) create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts create mode 100644 packages/mermaid/src/rendering-util/rendering-elements/shapes/cloud.ts diff --git a/cypress/integration/rendering/mindmap.spec.ts b/cypress/integration/rendering/mindmap.spec.ts index 731f861ee..eedf1da1e 100644 --- a/cypress/integration/rendering/mindmap.spec.ts +++ b/cypress/integration/rendering/mindmap.spec.ts @@ -105,6 +105,126 @@ root))bang(( ); }); + it('Bang and cloud shape with dagre layout', () => { + imgSnapshotTest( + `--- + config: + layout: dagre + --- + mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid + `, + {}, + undefined, + shouldHaveRoot + ); + }); + + it('Bang and cloud shape with elk layout', () => { + imgSnapshotTest( + `--- + config: + layout: elk + --- + mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid + `, + {}, + undefined, + shouldHaveRoot + ); + }); + + it('Bang and cloud shape with cose-bilkent layout', () => { + imgSnapshotTest( + `--- + config: + layout: cose-bilkent + --- + mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid + `, + {}, + undefined, + shouldHaveRoot + ); + }); + + it('Bang and cloud shape with tidy-tree layout', () => { + imgSnapshotTest( + `--- + config: + layout: tidy-tree + --- + mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularisation + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid + `, + {}, + undefined, + shouldHaveRoot + ); + }); + it('braches', () => { imgSnapshotTest( `mindmap diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts index 75fbfd27b..8ef70d20c 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts @@ -206,9 +206,9 @@ const flattenNodes = (node: MindmapNode, processedNodes: MindmapLayoutNode[]): v case nodeType.ROUNDED_RECT: return 'rounded'; case nodeType.CLOUD: - return 'rounded'; // Map cloud to rounded for now + return 'cloud'; case nodeType.BANG: - return 'circle'; // Map bang to circle for now + return 'bang'; case nodeType.HEXAGON: return 'hexagon'; case nodeType.DEFAULT: diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts index 829f89a8f..2a25ba559 100644 --- a/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes.ts @@ -61,6 +61,8 @@ import { erBox } from './shapes/erBox.js'; import { classBox } from './shapes/classBox.js'; import { requirementBox } from './shapes/requirementBox.js'; import { kanbanItem } from './shapes/kanbanItem.js'; +import { bang } from './shapes/bang.js'; +import { cloud } from './shapes/cloud.js'; type ShapeHandler = ( parent: D3Selection, @@ -135,6 +137,22 @@ export const shapesDefs = [ aliases: ['circ'], handler: circle, }, + { + semanticName: 'Bang', + name: 'Bang', + shortName: 'bang', + description: 'Bang', + aliases: ['bang'], + handler: bang, + }, + { + semanticName: 'Cloud', + name: 'Cloud', + shortName: 'cloud', + description: 'cloud', + aliases: ['cloud'], + handler: cloud, + }, { semanticName: 'Decision', name: 'Diamond', diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts new file mode 100644 index 000000000..b98917f5a --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/bang.ts @@ -0,0 +1,77 @@ +import { log } from '../../../logger.js'; +import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import intersect from '../intersect/index.js'; +import type { Node } from '../../types.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import rough from 'roughjs'; +import type { D3Selection } from '../../../types.js'; +import { handleUndefinedAttr } from '../../../utils.js'; +import type { Bounds, Point } from '../../../types.js'; + +export async function bang(parent: D3Selection, node: Node) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + const { shapeSvg, bbox, halfPadding, label } = await labelHelper( + parent, + node, + getNodeClasses(node) + ); + + const w = bbox.width + 2 * halfPadding; + const h = bbox.height + 2 * halfPadding; + const r = 0.15 * w; + const { cssStyles } = node; + + // Label centered around (0,0) + label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`); + + let bangElem; + const path = `M0 0 + a${r},${r} 1 0,0 ${w * 0.25},${-1 * h * 0.1} + a${r},${r} 1 0,0 ${w * 0.25},${0} + a${r},${r} 1 0,0 ${w * 0.25},${0} + a${r},${r} 1 0,0 ${w * 0.25},${h * 0.1} + + a${r},${r} 1 0,0 ${w * 0.15},${h * 0.33} + a${r * 0.8},${r * 0.8} 1 0,0 0,${h * 0.34} + a${r},${r} 1 0,0 ${-1 * w * 0.15},${h * 0.33} + + a${r},${r} 1 0,0 ${-1 * w * 0.25},${h * 0.15} + a${r},${r} 1 0,0 ${-1 * w * 0.25},0 + a${r},${r} 1 0,0 ${-1 * w * 0.25},0 + a${r},${r} 1 0,0 ${-1 * w * 0.25},${-1 * h * 0.15} + + a${r},${r} 1 0,0 ${-1 * w * 0.1},${-1 * h * 0.33} + a${r * 0.8},${r * 0.8} 1 0,0 0,${-1 * h * 0.34} + a${r},${r} 1 0,0 ${w * 0.1},${-1 * h * 0.33} + H0 V0 Z`; + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + const roughNode = rc.path(path, options); + bangElem = shapeSvg.insert(() => roughNode, ':first-child'); + bangElem.attr('class', 'basic label-container').attr('style', handleUndefinedAttr(cssStyles)); + } else { + bangElem = shapeSvg + .insert('path', ':first-child') + .attr('class', 'basic label-container') + .attr('style', nodeStyles) + .attr('d', path); + } + + // Translate the path (center the shape) + bangElem.attr('transform', `translate(${-w / 2}, ${-h / 2})`); + + updateNodeBounds(node, bangElem); + node.calcIntersect = function (bounds: Bounds, point: Point) { + return intersect.rect(bounds, point); + }; + node.intersect = function (point) { + log.info('Bang intersect', node, point); + return intersect.rect(node, point); + }; + + return shapeSvg; +} diff --git a/packages/mermaid/src/rendering-util/rendering-elements/shapes/cloud.ts b/packages/mermaid/src/rendering-util/rendering-elements/shapes/cloud.ts new file mode 100644 index 000000000..f7fb36509 --- /dev/null +++ b/packages/mermaid/src/rendering-util/rendering-elements/shapes/cloud.ts @@ -0,0 +1,81 @@ +import { log } from '../../../logger.js'; +import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js'; +import intersect from '../intersect/index.js'; +import type { Node } from '../../types.js'; +import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js'; +import rough from 'roughjs'; +import type { D3Selection } from '../../../types.js'; +import { handleUndefinedAttr } from '../../../utils.js'; +import type { Bounds, Point } from '../../../types.js'; + +export async function cloud(parent: D3Selection, node: Node) { + const { labelStyles, nodeStyles } = styles2String(node); + node.labelStyle = labelStyles; + + const { shapeSvg, bbox, halfPadding, label } = await labelHelper( + parent, + node, + getNodeClasses(node) + ); + + const w = bbox.width + 2 * halfPadding; + const h = bbox.height + 2 * halfPadding; + + // Cloud radii + const r1 = 0.15 * w; + const r2 = 0.25 * w; + const r3 = 0.35 * w; + const r4 = 0.2 * w; + + const { cssStyles } = node; + let cloudElem; + + // Cloud path + const path = `M0 0 + a${r1},${r1} 0 0,1 ${w * 0.25},${-1 * w * 0.1} + a${r3},${r3} 1 0,1 ${w * 0.4},${-1 * w * 0.1} + a${r2},${r2} 1 0,1 ${w * 0.35},${w * 0.2} + + a${r1},${r1} 1 0,1 ${w * 0.15},${h * 0.35} + a${r4},${r4} 1 0,1 ${-1 * w * 0.15},${h * 0.65} + + a${r2},${r1} 1 0,1 ${-1 * w * 0.25},${w * 0.15} + a${r3},${r3} 1 0,1 ${-1 * w * 0.5},0 + a${r1},${r1} 1 0,1 ${-1 * w * 0.25},${-1 * w * 0.15} + + a${r1},${r1} 1 0,1 ${-1 * w * 0.1},${-1 * h * 0.35} + a${r4},${r4} 1 0,1 ${w * 0.1},${-1 * h * 0.65} + H0 V0 Z`; + + if (node.look === 'handDrawn') { + // @ts-expect-error -- Passing a D3.Selection seems to work for some reason + const rc = rough.svg(shapeSvg); + const options = userNodeOverrides(node, {}); + const roughNode = rc.path(path, options); + cloudElem = shapeSvg.insert(() => roughNode, ':first-child'); + cloudElem.attr('class', 'basic label-container').attr('style', handleUndefinedAttr(cssStyles)); + } else { + cloudElem = shapeSvg + .insert('path', ':first-child') + .attr('class', 'basic label-container') + .attr('style', nodeStyles) + .attr('d', path); + } + + label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`); + + // Center the shape + cloudElem.attr('transform', `translate(${-w / 2}, ${-h / 2})`); + + updateNodeBounds(node, cloudElem); + + node.calcIntersect = function (bounds: Bounds, point: Point) { + return intersect.rect(bounds, point); + }; + node.intersect = function (point) { + log.info('Cloud intersect', node, point); + return intersect.rect(node, point); + }; + + return shapeSvg; +}