Merge branch 'develop' into 6638-sequence-diagram-additional-messages

This commit is contained in:
omkarht
2025-09-04 12:12:03 +05:30
committed by GitHub
89 changed files with 5133 additions and 910 deletions

View File

@@ -229,7 +229,6 @@
- [#5999](https://github.com/mermaid-js/mermaid/pull/5999) [`742ad7c`](https://github.com/mermaid-js/mermaid/commit/742ad7c130964df1fb5544e909d9556081285f68) Thanks [@knsv](https://github.com/knsv)! - Adding Kanban board, a new diagram type
- [#5880](https://github.com/mermaid-js/mermaid/pull/5880) [`bdf145f`](https://github.com/mermaid-js/mermaid/commit/bdf145ffe362462176d9c1e68d5f3ff5c9d962b0) Thanks [@yari-dewalt](https://github.com/yari-dewalt)! - Class diagram changes:
- Updates the class diagram to the new unified way of rendering.
- Includes a new "classBox" shape to be used in diagrams
- Other updates such as:

View File

@@ -82,7 +82,7 @@
"katex": "^0.16.22",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.0.0",
"marked": "^15.0.7",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
@@ -123,8 +123,8 @@
"rimraf": "^6.0.1",
"start-server-and-test": "^2.0.10",
"type-fest": "^4.35.0",
"typedoc": "^0.27.8",
"typedoc-plugin-markdown": "^4.4.2",
"typedoc": "^0.28.9",
"typedoc-plugin-markdown": "^4.8.0",
"typescript": "~5.7.3",
"unist-util-flatmap": "^1.0.0",
"unist-util-visit": "^5.0.0",

View File

@@ -171,7 +171,9 @@ This Markdown should be kept.
expect(buildShapeDoc()).toMatchInlineSnapshot(`
"| **Semantic Name** | **Shape Name** | **Short Name** | **Description** | **Alias Supported** |
| --------------------------------- | ---------------------- | -------------- | ------------------------------ | ---------------------------------------------------------------- |
| Bang | Bang | \`bang\` | Bang | \`bang\` |
| Card | Notched Rectangle | \`notch-rect\` | Represents a card | \`card\`, \`notched-rectangle\` |
| Cloud | Cloud | \`cloud\` | cloud | \`cloud\` |
| Collate | Hourglass | \`hourglass\` | Represents a collate operation | \`collate\`, \`hourglass\` |
| Com Link | Lightning Bolt | \`bolt\` | Communication link | \`com-link\`, \`lightning-bolt\` |
| Comment | Curly Brace | \`brace\` | Adds a comment | \`brace-l\`, \`comment\` |

View File

@@ -1075,6 +1075,10 @@ export interface ArchitectureDiagramConfig extends BaseDiagramConfig {
export interface MindmapDiagramConfig extends BaseDiagramConfig {
padding?: number;
maxNodeWidth?: number;
/**
* Layout algorithm to use for positioning mindmap nodes
*/
layoutAlgorithm?: string;
}
/**
* The object containing configurations specific for kanban diagrams

View File

@@ -1,5 +1,3 @@
// tests to check that comments are removed
import { cleanupComments } from './comments.js';
import { describe, it, expect } from 'vitest';
@@ -10,12 +8,12 @@ describe('comments', () => {
%% This is a comment
%% This is another comment
graph TD
A-->B
A-->B
%% This is a comment
`;
expect(cleanupComments(text)).toMatchInlineSnapshot(`
"graph TD
A-->B
A-->B
"
`);
});
@@ -29,9 +27,9 @@ graph TD
%%{ init: {'theme': 'space before init'}}%%
%%{init: {'theme': 'space after ending'}}%%
graph TD
A-->B
A-->B
B-->C
B-->C
%% This is a comment
`;
expect(cleanupComments(text)).toMatchInlineSnapshot(`
@@ -39,9 +37,9 @@ graph TD
%%{ init: {'theme': 'space before init'}}%%
%%{init: {'theme': 'space after ending'}}%%
graph TD
A-->B
A-->B
B-->C
B-->C
"
`);
});
@@ -50,14 +48,14 @@ graph TD
const text = `
%% This is a comment
graph TD
A-->B
%% This is a comment
C-->D
A-->B
%% This is a comment
C-->D
`;
expect(cleanupComments(text)).toMatchInlineSnapshot(`
"graph TD
A-->B
C-->D
A-->B
C-->D
"
`);
});
@@ -70,11 +68,11 @@ graph TD
%% This is a comment
graph TD
A-->B
A-->B
`;
expect(cleanupComments(text)).toMatchInlineSnapshot(`
"graph TD
A-->B
A-->B
"
`);
});
@@ -82,12 +80,12 @@ graph TD
it('should remove comments at end of text with no newline', () => {
const text = `
graph TD
A-->B
A-->B
%% This is a comment`;
expect(cleanupComments(text)).toMatchInlineSnapshot(`
"graph TD
A-->B
A-->B
"
`);
});

View File

@@ -1,6 +1,5 @@
import type { Position } from 'cytoscape';
import type { LayoutOptions, Position } from 'cytoscape';
import cytoscape from 'cytoscape';
import type { FcoseLayoutOptions } from 'cytoscape-fcose';
import fcose from 'cytoscape-fcose';
import { select } from 'd3';
import type { DrawDefinition, SVG } from '../../diagram-api/types.js';
@@ -41,7 +40,7 @@ registerIconPacks([
icons: architectureIcons,
},
]);
cytoscape.use(fcose);
cytoscape.use(fcose as any);
function addServices(services: ArchitectureService[], cy: cytoscape.Core, db: ArchitectureDB) {
services.forEach((service) => {
@@ -429,7 +428,7 @@ function layoutArchitecture(
},
alignmentConstraint,
relativePlacementConstraint,
} as FcoseLayoutOptions);
} as LayoutOptions);
// Once the diagram has been generated and the service's position cords are set, adjust the XY edges to have a 90deg bend
layout.one('layoutstop', () => {

View File

@@ -0,0 +1,297 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MindmapDB } from './mindmapDb.js';
import type { MindmapLayoutNode, MindmapLayoutEdge } from './mindmapDb.js';
import type { Edge } from '../../rendering-util/types.js';
// Mock the getConfig function
vi.mock('../../diagram-api/diagramAPI.js', () => ({
getConfig: vi.fn(() => ({
mindmap: {
layoutAlgorithm: 'cose-bilkent',
padding: 10,
maxNodeWidth: 200,
useMaxWidth: true,
},
})),
}));
describe('MindmapDb getData function', () => {
let db: MindmapDB;
beforeEach(() => {
db = new MindmapDB();
// Clear the database before each test
db.clear();
});
describe('getData', () => {
it('should return empty data when no mindmap is set', () => {
const result = db.getData();
expect(result.nodes).toEqual([]);
expect(result.edges).toEqual([]);
expect(result.config).toBeDefined();
expect(result.rootNode).toBeUndefined();
});
it('should return structured data for simple mindmap', () => {
// Create a simple mindmap structure
db.addNode(0, 'root', 'Root Node', 0);
db.addNode(1, 'child1', 'Child 1', 0);
db.addNode(1, 'child2', 'Child 2', 0);
const result = db.getData();
expect(result.nodes).toHaveLength(3);
expect(result.edges).toHaveLength(2);
expect(result.config).toBeDefined();
expect(result.rootNode).toBeDefined();
// Check root node
const rootNode = (result.nodes as MindmapLayoutNode[]).find((n) => n.id === '0');
expect(rootNode).toBeDefined();
expect(rootNode?.label).toBe('Root Node');
expect(rootNode?.level).toBe(0);
// Check child nodes
const child1 = (result.nodes as MindmapLayoutNode[]).find((n) => n.id === '1');
expect(child1).toBeDefined();
expect(child1?.label).toBe('Child 1');
expect(child1?.level).toBe(1);
// Check edges
expect(result.edges).toContainEqual(
expect.objectContaining({
start: '0',
end: '1',
depth: 0,
})
);
});
it('should return structured data for hierarchical mindmap', () => {
// Create a hierarchical mindmap structure
db.addNode(0, 'root', 'Root Node', 0);
db.addNode(1, 'child1', 'Child 1', 0);
db.addNode(2, 'grandchild1', 'Grandchild 1', 0);
db.addNode(2, 'grandchild2', 'Grandchild 2', 0);
db.addNode(1, 'child2', 'Child 2', 0);
const result = db.getData();
expect(result.nodes).toHaveLength(5);
expect(result.edges).toHaveLength(4);
// Check that all levels are represented
const levels = result.nodes.map((n) => (n as MindmapLayoutNode).level);
expect(levels).toContain(0); // root
expect(levels).toContain(1); // children
expect(levels).toContain(2); // grandchildren
// Check edge relationships
const edgeRelations = result.edges.map(
(e) => `${(e as MindmapLayoutEdge).start}->${(e as MindmapLayoutEdge).end}`
);
expect(edgeRelations).toContain('0->1'); // root to child1
expect(edgeRelations).toContain('1->2'); // child1 to grandchild1
expect(edgeRelations).toContain('1->3'); // child1 to grandchild2
expect(edgeRelations).toContain('0->4'); // root to child2
});
it('should preserve node properties in processed data', () => {
// Add a node with specific properties
db.addNode(0, 'root', 'Root Node', 2); // type 2 = rectangle
// Set additional properties
const mindmap = db.getMindmap();
if (mindmap) {
mindmap.width = 150;
mindmap.height = 75;
mindmap.padding = 15;
mindmap.section = 1;
mindmap.class = 'custom-class';
mindmap.icon = 'star';
}
const result = db.getData();
expect(result.nodes).toHaveLength(1);
const node = result.nodes[0] as MindmapLayoutNode;
expect(node.type).toBe(2);
expect(node.width).toBe(150);
expect(node.height).toBe(75);
expect(node.padding).toBe(15);
expect(node.section).toBeUndefined(); // Root node has undefined section
expect(node.cssClasses).toBe('mindmap-node section-root section--1 custom-class');
expect(node.icon).toBe('star');
});
it('should generate unique edge IDs', () => {
db.addNode(0, 'root', 'Root Node', 0);
db.addNode(1, 'child1', 'Child 1', 0);
db.addNode(1, 'child2', 'Child 2', 0);
db.addNode(1, 'child3', 'Child 3', 0);
const result = db.getData();
const edgeIds = result.edges.map((e: Edge) => e.id);
const uniqueIds = new Set(edgeIds);
expect(edgeIds).toHaveLength(3);
expect(uniqueIds.size).toBe(3); // All IDs should be unique
});
it('should handle nodes with missing optional properties', () => {
db.addNode(0, 'root', 'Root Node', 0);
const result = db.getData();
const node = result.nodes[0] as MindmapLayoutNode;
// Should handle undefined/missing properties gracefully
expect(node.section).toBeUndefined(); // Root node has undefined section
expect(node.cssClasses).toBe('mindmap-node section-root section--1'); // Root node gets special classes
expect(node.icon).toBeUndefined();
expect(node.x).toBeUndefined();
expect(node.y).toBeUndefined();
});
it('should assign correct section classes based on sibling position', () => {
// Create the example mindmap structure:
// A
// a0
// aa0
// a1
// aaa
// a2
db.addNode(0, 'A', 'A', 0); // Root
db.addNode(1, 'a0', 'a0', 0); // First child of root
db.addNode(2, 'aa0', 'aa0', 0); // Child of a0
db.addNode(1, 'a1', 'a1', 0); // Second child of root
db.addNode(2, 'aaa', 'aaa', 0); // Child of a1
db.addNode(1, 'a2', 'a2', 0); // Third child of root
const result = db.getData();
// Find nodes by their labels
const nodeA = result.nodes.find((n) => n.label === 'A') as MindmapLayoutNode;
const nodeA0 = result.nodes.find((n) => n.label === 'a0') as MindmapLayoutNode;
const nodeAa0 = result.nodes.find((n) => n.label === 'aa0') as MindmapLayoutNode;
const nodeA1 = result.nodes.find((n) => n.label === 'a1') as MindmapLayoutNode;
const nodeAaa = result.nodes.find((n) => n.label === 'aaa') as MindmapLayoutNode;
const nodeA2 = result.nodes.find((n) => n.label === 'a2') as MindmapLayoutNode;
// Check section assignments
expect(nodeA.section).toBeUndefined(); // Root has undefined section
expect(nodeA0.section).toBe(0); // First child of root
expect(nodeAa0.section).toBe(0); // Inherits from parent a0
expect(nodeA1.section).toBe(1); // Second child of root
expect(nodeAaa.section).toBe(1); // Inherits from parent a1
expect(nodeA2.section).toBe(2); // Third child of root
// Check CSS classes
expect(nodeA.cssClasses).toBe('mindmap-node section-root section--1');
expect(nodeA0.cssClasses).toBe('mindmap-node section-0');
expect(nodeAa0.cssClasses).toBe('mindmap-node section-0');
expect(nodeA1.cssClasses).toBe('mindmap-node section-1');
expect(nodeAaa.cssClasses).toBe('mindmap-node section-1');
expect(nodeA2.cssClasses).toBe('mindmap-node section-2');
});
it('should preserve custom classes while adding section classes', () => {
db.addNode(0, 'root', 'Root Node', 0);
db.addNode(1, 'child', 'Child Node', 0);
// Add custom classes to nodes
const mindmap = db.getMindmap();
if (mindmap) {
mindmap.class = 'custom-root-class';
if (mindmap.children?.[0]) {
mindmap.children[0].class = 'custom-child-class';
}
}
const result = db.getData();
const rootNode = result.nodes.find((n) => n.label === 'Root Node') as MindmapLayoutNode;
const childNode = result.nodes.find((n) => n.label === 'Child Node') as MindmapLayoutNode;
// Should include both section classes and custom classes
expect(rootNode.cssClasses).toBe('mindmap-node section-root section--1 custom-root-class');
expect(childNode.cssClasses).toBe('mindmap-node section-0 custom-child-class');
});
it('should not create any fake root nodes', () => {
// Create a simple mindmap
db.addNode(0, 'A', 'A', 0);
db.addNode(1, 'a0', 'a0', 0);
db.addNode(1, 'a1', 'a1', 0);
const result = db.getData();
// Check that we only have the expected nodes
expect(result.nodes).toHaveLength(3);
expect(result.nodes.map((n) => n.label)).toEqual(['A', 'a0', 'a1']);
// Check that there's no node with label "mindmap" or any other fake root
const mindmapNode = result.nodes.find((n) => n.label === 'mindmap');
expect(mindmapNode).toBeUndefined();
// Verify the root node has the correct classes
const rootNode = result.nodes.find((n) => n.label === 'A') as MindmapLayoutNode;
expect(rootNode.cssClasses).toBe('mindmap-node section-root section--1');
expect(rootNode.level).toBe(0);
});
it('should assign correct section classes to edges', () => {
// Create the example mindmap structure:
// A
// a0
// aa0
// a1
// aaa
// a2
db.addNode(0, 'A', 'A', 0); // Root
db.addNode(1, 'a0', 'a0', 0); // First child of root
db.addNode(2, 'aa0', 'aa0', 0); // Child of a0
db.addNode(1, 'a1', 'a1', 0); // Second child of root
db.addNode(2, 'aaa', 'aaa', 0); // Child of a1
db.addNode(1, 'a2', 'a2', 0); // Third child of root
const result = db.getData();
// Should have 5 edges: A->a0, a0->aa0, A->a1, a1->aaa, A->a2
expect(result.edges).toHaveLength(5);
// Find edges by their start and end nodes
const edgeA_a0 = result.edges.find(
(e) => e.start === '0' && e.end === '1'
) as MindmapLayoutEdge;
const edgeA0_aa0 = result.edges.find(
(e) => e.start === '1' && e.end === '2'
) as MindmapLayoutEdge;
const edgeA_a1 = result.edges.find(
(e) => e.start === '0' && e.end === '3'
) as MindmapLayoutEdge;
const edgeA1_aaa = result.edges.find(
(e) => e.start === '3' && e.end === '4'
) as MindmapLayoutEdge;
const edgeA_a2 = result.edges.find(
(e) => e.start === '0' && e.end === '5'
) as MindmapLayoutEdge;
// Check edge classes
expect(edgeA_a0.classes).toBe('edge section-edge-0 edge-depth-1'); // A->a0: section-0, depth-1
expect(edgeA0_aa0.classes).toBe('edge section-edge-0 edge-depth-2'); // a0->aa0: section-0, depth-2
expect(edgeA_a1.classes).toBe('edge section-edge-1 edge-depth-1'); // A->a1: section-1, depth-1
expect(edgeA1_aaa.classes).toBe('edge section-edge-1 edge-depth-2'); // a1->aaa: section-1, depth-2
expect(edgeA_a2.classes).toBe('edge section-edge-2 edge-depth-1'); // A->a2: section-2, depth-1
// Check section assignments match the child nodes
expect(edgeA_a0.section).toBe(0);
expect(edgeA0_aa0.section).toBe(0);
expect(edgeA_a1.section).toBe(1);
expect(edgeA1_aaa.section).toBe(1);
expect(edgeA_a2.section).toBe(2);
});
});
});

View File

@@ -1,9 +1,26 @@
import { getConfig } from '../../diagram-api/diagramAPI.js';
import { v4 } from 'uuid';
import type { D3Element } from '../../types.js';
import { sanitizeText } from '../../diagrams/common/common.js';
import { log } from '../../logger.js';
import type { MindmapNode } from './mindmapTypes.js';
import defaultConfig from '../../defaultConfig.js';
import type { LayoutData, Node, Edge } from '../../rendering-util/types.js';
import { getUserDefinedConfig } from '../../config.js';
// Extend Node type for mindmap-specific properties
export type MindmapLayoutNode = Node & {
level: number;
nodeId: string;
type: number;
section?: number;
};
// Extend Edge type for mindmap-specific properties
export type MindmapLayoutEdge = Edge & {
depth: number;
section?: number;
};
const nodeType = {
DEFAULT: 0,
@@ -27,7 +44,6 @@ export class MindmapDB {
this.nodeType = nodeType;
this.clear();
this.getType = this.getType.bind(this);
this.getMindmap = this.getMindmap.bind(this);
this.getElementById = this.getElementById.bind(this);
this.getParent = this.getParent.bind(this);
this.getMindmap = this.getMindmap.bind(this);
@@ -156,6 +172,223 @@ export class MindmapDB {
}
}
/**
* Assign section numbers to nodes based on their position relative to root
* @param node - The mindmap node to process
* @param sectionNumber - The section number to assign (undefined for root)
*/
public assignSections(node: MindmapNode, sectionNumber?: number): void {
// For root node, section should be undefined (not -1)
if (node.level === 0) {
node.section = undefined;
} else {
// For non-root nodes, assign the section number
node.section = sectionNumber;
}
// For root node's children, assign section numbers based on their index
// For other nodes, inherit parent's section number
if (node.children) {
for (const [index, child] of node.children.entries()) {
const childSectionNumber = node.level === 0 ? index : sectionNumber;
this.assignSections(child, childSectionNumber);
}
}
}
/**
* Convert mindmap tree structure to flat array of nodes
* @param node - The mindmap node to process
* @param processedNodes - Array to collect processed nodes
*/
public flattenNodes(node: MindmapNode, processedNodes: MindmapLayoutNode[]): void {
// Build CSS classes for the node
const cssClasses = ['mindmap-node'];
// Add section-specific classes
if (node.level === 0) {
// Root node gets special classes
cssClasses.push('section-root', 'section--1');
} else if (node.section !== undefined) {
// Child nodes get section class based on their section number
cssClasses.push(`section-${node.section}`);
}
// Add any custom classes from the node
if (node.class) {
cssClasses.push(node.class);
}
const classes = cssClasses.join(' ');
// Map mindmap node type to valid shape name
const getShapeFromType = (type: number) => {
switch (type) {
case nodeType.CIRCLE:
return 'mindmapCircle';
case nodeType.RECT:
return 'rect';
case nodeType.ROUNDED_RECT:
return 'rounded';
case nodeType.CLOUD:
return 'cloud';
case nodeType.BANG:
return 'bang';
case nodeType.HEXAGON:
return 'hexagon';
case nodeType.DEFAULT:
return 'defaultMindmapNode';
case nodeType.NO_BORDER:
default:
return 'rect';
}
};
const processedNode: MindmapLayoutNode = {
id: node.id.toString(),
domId: 'node_' + node.id.toString(),
label: node.descr,
isGroup: false,
shape: getShapeFromType(node.type),
width: node.width,
height: node.height ?? 0,
padding: node.padding,
cssClasses: classes,
cssStyles: [],
look: 'default',
icon: node.icon,
x: node.x,
y: node.y,
// Mindmap-specific properties
level: node.level,
nodeId: node.nodeId,
type: node.type,
section: node.section,
};
processedNodes.push(processedNode);
// Recursively process children
if (node.children) {
for (const child of node.children) {
this.flattenNodes(child, processedNodes);
}
}
}
/**
* Generate edges from parent-child relationships in mindmap tree
* @param node - The mindmap node to process
* @param edges - Array to collect edges
*/
public generateEdges(node: MindmapNode, edges: MindmapLayoutEdge[]): void {
if (!node.children) {
return;
}
for (const child of node.children) {
// Build CSS classes for the edge
let edgeClasses = 'edge';
// Add section-specific classes based on the child's section
if (child.section !== undefined) {
edgeClasses += ` section-edge-${child.section}`;
}
// Add depth class based on the parent's level + 1 (depth of the edge)
const edgeDepth = node.level + 1;
edgeClasses += ` edge-depth-${edgeDepth}`;
const edge: MindmapLayoutEdge = {
id: `edge_${node.id}_${child.id}`,
start: node.id.toString(),
end: child.id.toString(),
type: 'normal',
curve: 'basis',
thickness: 'normal',
look: 'default',
classes: edgeClasses,
// Store mindmap-specific data
depth: node.level,
section: child.section,
};
edges.push(edge);
// Recursively process child edges
this.generateEdges(child, edges);
}
}
/**
* Get structured data for layout algorithms
* Following the pattern established by ER diagrams
* @returns Structured data containing nodes, edges, and config
*/
public getData(): LayoutData {
const mindmapRoot = this.getMindmap();
const config = getConfig();
const userDefinedConfig = getUserDefinedConfig();
const hasUserDefinedLayout = userDefinedConfig.layout !== undefined;
const finalConfig = config;
if (!hasUserDefinedLayout) {
finalConfig.layout = 'cose-bilkent';
}
if (!mindmapRoot) {
return {
nodes: [],
edges: [],
config: finalConfig,
};
}
log.debug('getData: mindmapRoot', mindmapRoot, config);
// Assign section numbers to all nodes based on their position relative to root
this.assignSections(mindmapRoot);
// Convert tree structure to flat arrays
const processedNodes: MindmapLayoutNode[] = [];
const processedEdges: MindmapLayoutEdge[] = [];
this.flattenNodes(mindmapRoot, processedNodes);
this.generateEdges(mindmapRoot, processedEdges);
log.debug(
`getData: processed ${processedNodes.length} nodes and ${processedEdges.length} edges`
);
// Create shapes map for ELK compatibility
const shapes = new Map<string, any>();
for (const node of processedNodes) {
shapes.set(node.id, {
shape: node.shape,
width: node.width,
height: node.height,
padding: node.padding,
});
}
return {
nodes: processedNodes,
edges: processedEdges,
config: finalConfig,
// Store the root node for mindmap-specific layout algorithms
rootNode: mindmapRoot,
// Properties required by dagre layout algorithm
markers: ['point'], // Mindmaps don't use markers
direction: 'TB', // Top-to-bottom direction for mindmaps
nodeSpacing: 50, // Default spacing between nodes
rankSpacing: 50, // Default spacing between ranks
// Add shapes for ELK compatibility
shapes: Object.fromEntries(shapes),
// Additional properties that layout algorithms might expect
type: 'mindmap',
diagramId: 'mindmap-' + v4(),
};
}
// Expose logger to grammar
public getLogger() {
return log;
}

View File

@@ -1,200 +1,83 @@
import cytoscape from 'cytoscape';
// @ts-expect-error No types available
import coseBilkent from 'cytoscape-cose-bilkent';
import { select } from 'd3';
import type { MermaidConfig } from '../../config.type.js';
import { getConfig } from '../../diagram-api/diagramAPI.js';
import type { DrawDefinition } from '../../diagram-api/types.js';
import { log } from '../../logger.js';
import type { D3Element } from '../../types.js';
import { selectSvgElement } from '../../rendering-util/selectSvgElement.js';
import { setupGraphViewbox } from '../../setupGraphViewbox.js';
import type { FilledMindMapNode, MindmapNode } from './mindmapTypes.js';
import { drawNode, positionNode } from './svgDraw.js';
import { getDiagramElement } from '../../rendering-util/insertElementsForSize.js';
import { getRegisteredLayoutAlgorithm, render } from '../../rendering-util/render.js';
import { setupViewPortForSVG } from '../../rendering-util/setupViewPortForSVG.js';
import type { LayoutData } from '../../rendering-util/types.js';
import type { FilledMindMapNode } from './mindmapTypes.js';
import defaultConfig from '../../defaultConfig.js';
import type { MindmapDB } from './mindmapDb.js';
// Inject the layout algorithm into cytoscape
cytoscape.use(coseBilkent);
async function drawNodes(
db: MindmapDB,
svg: D3Element,
mindmap: FilledMindMapNode,
section: number,
conf: MermaidConfig
) {
await drawNode(db, svg, mindmap, section, conf);
if (mindmap.children) {
await Promise.all(
mindmap.children.map((child, index) =>
drawNodes(db, svg, child, section < 0 ? index : section, conf)
)
);
}
}
declare module 'cytoscape' {
interface EdgeSingular {
_private: {
bodyBounds: unknown;
rscratch: {
startX: number;
startY: number;
midX: number;
midY: number;
endX: number;
endY: number;
};
};
}
}
function drawEdges(edgesEl: D3Element, cy: cytoscape.Core) {
cy.edges().map((edge, id) => {
const data = edge.data();
if (edge[0]._private.bodyBounds) {
const bounds = edge[0]._private.rscratch;
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 section-edge-' + data.section + ' edge-depth-' + data.depth);
/**
* Update the layout data with actual node dimensions after drawing
*/
function _updateNodeDimensions(data4Layout: LayoutData, mindmapRoot: FilledMindMapNode) {
const updateNode = (node: FilledMindMapNode) => {
// Find the corresponding node in the layout data
const layoutNode = data4Layout.nodes.find((n) => n.id === node.id.toString());
if (layoutNode) {
// Update with the actual dimensions calculated by drawNode
layoutNode.width = node.width;
layoutNode.height = node.height;
log.debug('Updated node dimensions:', node.id, 'width:', node.width, 'height:', node.height);
}
});
}
function addNodes(mindmap: MindmapNode, cy: cytoscape.Core, conf: MermaidConfig, level: number) {
cy.add({
group: 'nodes',
data: {
id: mindmap.id.toString(),
labelText: mindmap.descr,
height: mindmap.height,
width: mindmap.width,
level: level,
nodeId: mindmap.id,
padding: mindmap.padding,
type: mindmap.type,
},
position: {
x: mindmap.x!,
y: mindmap.y!,
},
});
if (mindmap.children) {
mindmap.children.forEach((child) => {
addNodes(child, cy, conf, level + 1);
cy.add({
group: 'edges',
data: {
id: `${mindmap.id}_${child.id}`,
source: mindmap.id,
target: child.id,
depth: level,
section: child.section,
},
});
});
}
}
// Recursively update children
node.children?.forEach(updateNode);
};
function layoutMindmap(node: MindmapNode, conf: MermaidConfig): Promise<cytoscape.Core> {
return new Promise((resolve) => {
// Add temporary render element
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
const cy = cytoscape({
container: document.getElementById('cy'), // container to render in
style: [
{
selector: 'edge',
style: {
'curve-style': 'bezier',
},
},
],
});
// Remove element after layout
renderEl.remove();
addNodes(node, cy, conf, 0);
// 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: 'cose-bilkent',
// @ts-ignore Types for cose-bilkent are not correct?
quality: 'proof',
styleEnabled: false,
animate: false,
}).run();
cy.ready((e) => {
log.info('Ready', e);
resolve(cy);
});
});
}
function positionNodes(db: MindmapDB, cy: cytoscape.Core) {
cy.nodes().map((node, id) => {
const data = node.data();
data.x = node.position().x;
data.y = node.position().y;
positionNode(db, data);
const el = db.getElementById(data.nodeId);
log.info('id:', id, 'Position: (', node.position().x, ', ', node.position().y, ')', data);
el.attr(
'transform',
`translate(${node.position().x - data.width / 2}, ${node.position().y - data.height / 2})`
);
el.attr('attr', `apa-${id})`);
});
updateNode(mindmapRoot);
}
export const draw: DrawDefinition = async (text, id, _version, diagObj) => {
log.debug('Rendering mindmap diagram\n' + text);
// Draw the nodes first to get their dimensions, then update the layout data
const db = diagObj.db as MindmapDB;
// The getData method provided in all supported diagrams is used to extract the data from the parsed structure
// into the Layout data format
const data4Layout = db.getData();
// Create the root SVG - the element is the div containing the SVG element
const svg = getDiagramElement(id, data4Layout.config.securityLevel);
data4Layout.type = diagObj.type;
data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(data4Layout.config.layout, {
fallback: 'cose-bilkent',
});
data4Layout.diagramId = id;
const mm = db.getMindmap();
if (!mm) {
return;
}
const conf = getConfig();
conf.htmlLabels = false;
data4Layout.nodes.forEach((node) => {
if (node.shape === 'rounded') {
node.radius = 15;
node.taper = 15;
node.stroke = 'none';
node.width = 0;
node.padding = 15;
} else if (node.shape === 'circle') {
node.padding = 10;
} else if (node.shape === 'rect') {
node.width = 0;
node.padding = 10;
}
});
const svg = selectSvgElement(id);
// Use the unified rendering system
await render(data4Layout, svg);
// Draw the graph and start with drawing the nodes without proper position
// this gives us the size of the nodes and we can set the positions later
const edgesElem = svg.append('g');
edgesElem.attr('class', 'mindmap-edges');
const nodesElem = svg.append('g');
nodesElem.attr('class', 'mindmap-nodes');
await drawNodes(db, nodesElem, mm as FilledMindMapNode, -1, conf);
// Next step is to layout the mindmap, giving each node a position
const cy = await layoutMindmap(mm, conf);
// After this we can draw, first the edges and the then nodes with the correct position
drawEdges(edgesElem, cy);
positionNodes(db, cy);
// Setup the view box and size of the svg element
setupGraphViewbox(
undefined,
// Setup the view box and size of the svg element using config from data4Layout
setupViewPortForSVG(
svg,
conf.mindmap?.padding ?? defaultConfig.mindmap.padding,
conf.mindmap?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth
data4Layout.config.mindmap?.padding ?? defaultConfig.mindmap.padding,
'mindmapDiagram',
data4Layout.config.mindmap?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth
);
};

View File

@@ -64,6 +64,12 @@ const getStyles: DiagramStylesProvider = (options) =>
.section-root text {
fill: ${options.gitBranchLabel0};
}
.section-root span {
color: ${options.gitBranchLabel0};
}
.section-2 span {
color: ${options.gitBranchLabel0};
}
.icon-container {
height:100%;
display: flex;

View File

@@ -1622,7 +1622,7 @@ link a: Tests @ https://tests.contoso.com/?svc=alice@contoso.com
it('should handle box without description', async () => {
const diagram = await Diagram.fromText(`
sequenceDiagram
box Aqua
box aqua
participant a as Alice
participant b as Bob
end
@@ -1638,7 +1638,7 @@ link a: Tests @ https://tests.contoso.com/?svc=alice@contoso.com
const boxes = diagram.db.getBoxes();
expect(boxes[0].name).toBeFalsy();
expect(boxes[0].actorKeys).toEqual(['a', 'b']);
expect(boxes[0].fill).toEqual('Aqua');
expect(boxes[0].fill).toEqual('aqua');
});
it('should handle simple actor creation', async () => {

View File

@@ -203,6 +203,7 @@ function sidebarConfig() {
{ text: 'Accessibility', link: '/config/accessibility' },
{ text: 'Mermaid CLI', link: '/config/mermaidCLI' },
{ text: 'FAQ', link: '/config/faq' },
{ text: 'Layouts', link: '/config/layouts' },
],
},
];

View File

@@ -0,0 +1,24 @@
# Layouts
This page lists the available layout algorithms supported in Mermaid diagrams.
## Supported Layouts
- **elk**: [ELK (Eclipse Layout Kernel)](https://www.eclipse.org/elk/)
- **tidy-tree**: Tidy tree layout for hierarchical diagrams [Tidy Tree Configuration](/config/tidy-tree)
- **cose-bilkent**: Cose Bilkent layout for force-directed graphs
- **dagre**: Dagre layout for layered graphs
## How to Use
You can specify the layout in your diagram's YAML config or initialization options. For example:
```mermaid
---
config:
layout: elk
---
graph TD;
A-->B;
B-->C;
```

View File

@@ -0,0 +1,49 @@
# Tidy-tree Layout
The **tidy-tree** layout arranges nodes in a hierarchical, tree-like structure. It is especially useful for diagrams where parent-child relationships are important, such as mindmaps.
## Features
- Organizes nodes in a tidy, non-overlapping tree
- Ideal for mindmaps and hierarchical data
- Automatically adjusts spacing for readability
## Example Usage
```mermaid-example
---
config:
layout: tidy-tree
---
mindmap
root((mindmap is a long thing))
A
B
C
D
```
```mermaid-example
---
config:
layout: tidy-tree
---
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
```
## Note
- Currently, tidy-tree is primarily supported for mindmap diagrams.

View File

@@ -209,3 +209,22 @@ You can also refer the [implementation in the live editor](https://github.com/me
cspell:locale en,en-gb
cspell:ignore Buzan
--->
## Layouts
Mermaid also supports a Tidy Tree layout for mindmaps.
```
---
config:
layout: tidy-tree
---
mindmap
root((mindmap is a long thing))
A
B
C
D
```
Instructions to add and register tidy-tree layout are present in [Tidy Tree Configuration](/config/tidy-tree)

View File

@@ -126,7 +126,7 @@ xychart
## Chart Theme Variables
Themes for xychart resides inside xychart attribute so to set the variables use this syntax:
Themes for xychart reside inside the `xychart` attribute, allowing customization through the following syntax:
```yaml
---
@@ -151,6 +151,31 @@ config:
| yAxisLineColor | Color of the y-axis line |
| plotColorPalette | String of colors separated by comma e.g. "#f3456, #43445" |
### Setting Colors for Lines and Bars
To set the color for lines and bars, use the `plotColorPalette` parameter. Colors in the palette will correspond sequentially to the elements in your chart (e.g., first bar/line will use the first color specified in the palette).
```mermaid-example
---
config:
themeVariables:
xyChart:
plotColorPalette: '#000000, #0000FF, #00FF00, #FF0000'
---
xychart
title "Different Colors in xyChart"
x-axis "categoriesX" ["Category 1", "Category 2", "Category 3", "Category 4"]
y-axis "valuesY" 0 --> 50
%% Black line
line [10,20,30,40]
%% Blue bar
bar [20,30,25,35]
%% Green bar
bar [15,25,20,30]
%% Red line
line [5,15,25,35]
```
## Example on config and theme
```mermaid-example

View File

@@ -26,6 +26,7 @@ const processFrontmatter = (code: string) => {
}
config.gantt.displayMode = displayMode;
}
return { title, config, text };
};

View File

@@ -0,0 +1,148 @@
import { insertNode } from './rendering-elements/nodes.js';
import type { LayoutData, NonClusterNode } from './types.ts';
import type { Selection } from 'd3';
import { getConfig } from '../diagram-api/diagramAPI.js';
import * as graphlib from 'dagre-d3-es/src/graphlib/index.js';
// Update type:
type D3Selection<T extends SVGElement = SVGElement> = Selection<
T,
unknown,
Element | null,
unknown
>;
/**
* Creates a graph by merging the graph construction and DOM element insertion.
*
* This function creates the graph, inserts the SVG groups (clusters, edgePaths, edgeLabels, nodes)
* into the provided element, and uses `insertNode` to add nodes to the diagram. Node dimensions
* are computed using each node's bounding box.
*
* @param element - The D3 selection in which the SVG groups are inserted.
* @param data4Layout - The layout data containing nodes and edges.
* @returns A promise resolving to an object containing the Graphology graph and the inserted groups.
*/
export async function createGraphWithElements(
element: D3Selection,
data4Layout: LayoutData
): Promise<{
graph: graphlib.Graph;
groups: {
clusters: D3Selection<SVGGElement>;
edgePaths: D3Selection<SVGGElement>;
edgeLabels: D3Selection<SVGGElement>;
nodes: D3Selection<SVGGElement>;
rootGroups: D3Selection<SVGGElement>;
};
nodeElements: Map<string, D3Selection<SVGElement | SVGGElement>>;
}> {
// Create a directed, multi graph.
const graph = new graphlib.Graph({
multigraph: true,
compound: true,
});
const edgesToProcess = [...data4Layout.edges];
const config = getConfig();
// Create groups for clusters, edge paths, edge labels, and nodes.
const clusters = element.insert('g').attr('class', 'clusters');
const edgePaths = element.insert('g').attr('class', 'edges edgePath');
const edgeLabels = element.insert('g').attr('class', 'edgeLabels');
const nodesGroup = element.insert('g').attr('class', 'nodes');
const rootGroups = element.insert('g').attr('class', 'root');
const nodeElements = new Map<string, D3Selection<SVGElement | SVGGElement>>();
// Insert nodes into the DOM and add them to the graph.
await Promise.all(
data4Layout.nodes.map(async (node) => {
if (node.isGroup) {
graph.setNode(node.id, { ...node });
} else {
const childNodeEl = await insertNode(nodesGroup, node, { config, dir: node.dir });
const boundingBox = childNodeEl.node()?.getBBox() ?? { width: 0, height: 0 };
nodeElements.set(node.id, childNodeEl as D3Selection<SVGElement | SVGGElement>);
node.width = boundingBox.width;
node.height = boundingBox.height;
graph.setNode(node.id, { ...node });
}
})
);
// Add edges to the graph.
for (const edge of edgesToProcess) {
if (edge.label && edge.label?.length > 0) {
// Create a label node for the edge
const labelNodeId = `edge-label-${edge.start}-${edge.end}-${edge.id}`;
const labelNode = {
id: labelNodeId,
label: edge.label,
edgeStart: edge.start,
edgeEnd: edge.end,
shape: 'labelRect',
width: 0, // Will be updated after insertion
height: 0, // Will be updated after insertion
isEdgeLabel: true,
isDummy: true,
isGroup: false,
parentId: edge.parentId,
...(edge.dir ? { dir: edge.dir } : {}),
} as NonClusterNode;
// Insert the label node into the DOM
const labelNodeEl = await insertNode(nodesGroup, labelNode, { config, dir: edge.dir });
const boundingBox = labelNodeEl.node()?.getBBox() ?? { width: 0, height: 0 };
// Update node dimensions
labelNode.width = boundingBox.width;
labelNode.height = boundingBox.height;
// Add to graph and tracking maps
graph.setNode(labelNodeId, { ...labelNode });
nodeElements.set(labelNodeId, labelNodeEl as D3Selection<SVGElement | SVGGElement>);
data4Layout.nodes.push(labelNode);
// Create two edges to replace the original one
const edgeToLabel = {
...edge,
id: `${edge.id}-to-label`,
end: labelNodeId,
label: undefined,
isLabelEdge: true,
arrowTypeEnd: 'none',
arrowTypeStart: 'none',
};
const edgeFromLabel = {
...edge,
id: `${edge.id}-from-label`,
start: labelNodeId,
end: edge.end,
label: undefined,
isLabelEdge: true,
arrowTypeStart: 'none',
arrowTypeEnd: 'arrow_point',
};
graph.setEdge(edgeToLabel.id, edgeToLabel.start, edgeToLabel.end, { ...edgeToLabel });
graph.setEdge(edgeFromLabel.id, edgeFromLabel.start, edgeFromLabel.end, { ...edgeFromLabel });
data4Layout.edges.push(edgeToLabel, edgeFromLabel);
const edgeIdToRemove = edge.id;
data4Layout.edges = data4Layout.edges.filter((edge) => edge.id !== edgeIdToRemove);
const indexInOriginal = data4Layout.edges.findIndex((e) => e.id === edge.id);
if (indexInOriginal !== -1) {
data4Layout.edges.splice(indexInOriginal, 1);
}
} else {
// Regular edge without label
graph.setEdge(edge.id, edge.start, edge.end, { ...edge });
const edgeExists = data4Layout.edges.some((existingEdge) => existingEdge.id === edge.id);
if (!edgeExists) {
data4Layout.edges.push(edge);
}
}
}
return {
graph,
groups: { clusters, edgePaths, edgeLabels, nodes: nodesGroup, rootGroups },
nodeElements,
};
}

View File

@@ -0,0 +1,265 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
addNodes,
addEdges,
extractPositionedNodes,
extractPositionedEdges,
} from './cytoscape-setup.js';
import type { Node, Edge } from '../../types.js';
// Mock cytoscape
const mockCy = {
add: vi.fn(),
nodes: vi.fn(),
edges: vi.fn(),
};
vi.mock('cytoscape', () => {
const mockCytoscape = vi.fn(() => mockCy) as any;
mockCytoscape.use = vi.fn();
return {
default: mockCytoscape,
};
});
describe('Cytoscape Setup', () => {
let mockNodes: Node[];
let mockEdges: Edge[];
beforeEach(() => {
vi.clearAllMocks();
mockNodes = [
{
id: '1',
label: 'Root',
isGroup: false,
shape: 'rect',
width: 100,
height: 50,
padding: 10,
x: 100,
y: 100,
cssClasses: '',
cssStyles: [],
look: 'default',
},
{
id: '2',
label: 'Child 1',
isGroup: false,
shape: 'rect',
width: 80,
height: 40,
padding: 10,
x: 150,
y: 150,
cssClasses: '',
cssStyles: [],
look: 'default',
},
];
mockEdges = [
{
id: '1_2',
start: '1',
end: '2',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
];
});
describe('addNodes', () => {
it('should add nodes to cytoscape', () => {
addNodes([mockNodes[0]], mockCy as unknown as any);
expect(mockCy.add).toHaveBeenCalledWith({
group: 'nodes',
data: {
id: '1',
labelText: 'Root',
height: 50,
width: 100,
padding: 10,
isGroup: false,
shape: 'rect',
cssClasses: '',
cssStyles: [],
look: 'default',
},
position: {
x: 100,
y: 100,
},
});
});
it('should add multiple nodes to cytoscape', () => {
addNodes(mockNodes, mockCy as unknown as any);
expect(mockCy.add).toHaveBeenCalledTimes(2);
expect(mockCy.add).toHaveBeenCalledWith({
group: 'nodes',
data: {
id: '1',
labelText: 'Root',
height: 50,
width: 100,
padding: 10,
isGroup: false,
shape: 'rect',
cssClasses: '',
cssStyles: [],
look: 'default',
},
position: {
x: 100,
y: 100,
},
});
expect(mockCy.add).toHaveBeenCalledWith({
group: 'nodes',
data: {
id: '2',
labelText: 'Child 1',
height: 40,
width: 80,
padding: 10,
isGroup: false,
shape: 'rect',
cssClasses: '',
cssStyles: [],
look: 'default',
},
position: {
x: 150,
y: 150,
},
});
});
});
describe('addEdges', () => {
it('should add edges to cytoscape', () => {
addEdges(mockEdges, mockCy as unknown as any);
expect(mockCy.add).toHaveBeenCalledWith({
group: 'edges',
data: {
id: '1_2',
source: '1',
target: '2',
type: 'edge',
classes: '',
style: [],
animate: false,
arrowTypeEnd: 'arrow_point',
arrowTypeStart: 'none',
},
});
});
});
describe('extractPositionedNodes', () => {
it('should extract positioned nodes from cytoscape', () => {
const mockCytoscapeNodes = [
{
data: () => ({
id: '1',
labelText: 'Root',
width: 100,
height: 50,
padding: 10,
isGroup: false,
shape: 'rect',
}),
position: () => ({ x: 100, y: 100 }),
},
{
data: () => ({
id: '2',
labelText: 'Child 1',
width: 80,
height: 40,
padding: 10,
isGroup: false,
shape: 'rect',
}),
position: () => ({ x: 150, y: 150 }),
},
];
mockCy.nodes.mockReturnValue({
map: (fn: unknown) => mockCytoscapeNodes.map(fn as any),
});
const result = extractPositionedNodes(mockCy as unknown as any);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
id: '1',
x: 100,
y: 100,
labelText: 'Root',
width: 100,
height: 50,
padding: 10,
isGroup: false,
shape: 'rect',
});
});
});
describe('extractPositionedEdges', () => {
it('should extract positioned edges from cytoscape', () => {
const mockCytoscapeEdges = [
{
data: () => ({
id: '1_2',
source: '1',
target: '2',
type: 'edge',
}),
_private: {
rscratch: {
startX: 100,
startY: 100,
midX: 125,
midY: 125,
endX: 150,
endY: 150,
},
},
},
];
mockCy.edges.mockReturnValue({
map: (fn: unknown) => mockCytoscapeEdges.map(fn as any),
});
const result = extractPositionedEdges(mockCy as unknown as any);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
id: '1_2',
source: '1',
target: '2',
type: 'edge',
startX: 100,
startY: 100,
midX: 125,
midY: 125,
endX: 150,
endY: 150,
});
});
});
});

View File

@@ -0,0 +1,207 @@
import cytoscape from 'cytoscape';
import coseBilkent from 'cytoscape-cose-bilkent';
import { select } from 'd3';
import { log } from '../../../logger.js';
import type { LayoutData, Node, Edge } from '../../types.js';
import type { CytoscapeLayoutConfig, PositionedNode, PositionedEdge } from './types.js';
// Inject the layout algorithm into cytoscape
cytoscape.use(coseBilkent);
/**
* Declare module augmentation for cytoscape edge types
*/
declare module 'cytoscape' {
interface EdgeSingular {
_private: {
bodyBounds: unknown;
rscratch: {
startX: number;
startY: number;
midX: number;
midY: number;
endX: number;
endY: number;
};
};
}
}
/**
* Add nodes to cytoscape instance from provided node array
* This function processes only the nodes provided in the data structure
* @param nodes - Array of nodes to add
* @param cy - The cytoscape instance
*/
export function addNodes(nodes: Node[], cy: cytoscape.Core): void {
nodes.forEach((node) => {
const nodeData: Record<string, unknown> = {
id: node.id,
labelText: node.label,
height: node.height,
width: node.width,
padding: node.padding ?? 0,
};
// Add any additional properties from the node
Object.keys(node).forEach((key) => {
if (!['id', 'label', 'height', 'width', 'padding', 'x', 'y'].includes(key)) {
nodeData[key] = (node as unknown as Record<string, unknown>)[key];
}
});
cy.add({
group: 'nodes',
data: nodeData,
position: {
x: node.x ?? 0,
y: node.y ?? 0,
},
});
});
}
/**
* Add edges to cytoscape instance from provided edge array
* This function processes only the edges provided in the data structure
* @param edges - Array of edges to add
* @param cy - The cytoscape instance
*/
export function addEdges(edges: Edge[], cy: cytoscape.Core): void {
edges.forEach((edge) => {
const edgeData: Record<string, unknown> = {
id: edge.id,
source: edge.start,
target: edge.end,
};
// Add any additional properties from the edge
Object.keys(edge).forEach((key) => {
if (!['id', 'start', 'end'].includes(key)) {
edgeData[key] = (edge as unknown as Record<string, unknown>)[key];
}
});
cy.add({
group: 'edges',
data: edgeData,
});
});
}
/**
* Create and configure cytoscape instance
* @param data - Layout data containing nodes and edges
* @returns Promise resolving to configured cytoscape instance
*/
export function createCytoscapeInstance(data: LayoutData): Promise<cytoscape.Core> {
return new Promise((resolve) => {
// Add temporary render element
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
const cy = cytoscape({
container: document.getElementById('cy'), // container to render in
style: [
{
selector: 'edge',
style: {
'curve-style': 'bezier',
},
},
],
});
// Remove element after layout
renderEl.remove();
// Add all nodes and edges to cytoscape using the generic functions
addNodes(data.nodes, cy);
addEdges(data.edges, cy);
// Make cytoscape care about the dimensions of the nodes
cy.nodes().forEach(function (n) {
n.layoutDimensions = () => {
const nodeData = n.data();
return { w: nodeData.width, h: nodeData.height };
};
});
// Configure and run the cose-bilkent layout
const layoutConfig: CytoscapeLayoutConfig = {
name: 'cose-bilkent',
// @ts-ignore Types for cose-bilkent are not correct?
quality: 'proof',
styleEnabled: false,
animate: false,
};
cy.layout(layoutConfig).run();
cy.ready((e) => {
log.info('Cytoscape ready', e);
resolve(cy);
});
});
}
/**
* Extract positioned nodes from cytoscape instance
* @param cy - The cytoscape instance after layout
* @returns Array of positioned nodes
*/
export function extractPositionedNodes(cy: cytoscape.Core): PositionedNode[] {
return cy.nodes().map((node) => {
const data = node.data();
const position = node.position();
// Create a positioned node with all original data plus position
const positionedNode: PositionedNode = {
id: data.id,
x: position.x,
y: position.y,
};
// Add all other properties from the original data
Object.keys(data).forEach((key) => {
if (key !== 'id') {
positionedNode[key] = data[key];
}
});
return positionedNode;
});
}
/**
* Extract positioned edges from cytoscape instance
* @param cy - The cytoscape instance after layout
* @returns Array of positioned edges
*/
export function extractPositionedEdges(cy: cytoscape.Core): PositionedEdge[] {
return cy.edges().map((edge) => {
const data = edge.data();
const rscratch = edge._private.rscratch;
// Create a positioned edge with all original data plus position
const positionedEdge: PositionedEdge = {
id: data.id,
source: data.source,
target: data.target,
startX: rscratch.startX,
startY: rscratch.startY,
midX: rscratch.midX,
midY: rscratch.midY,
endX: rscratch.endX,
endY: rscratch.endY,
};
// Add all other properties from the original data
Object.keys(data).forEach((key) => {
if (!['id', 'source', 'target'].includes(key)) {
positionedEdge[key] = data[key];
}
});
return positionedEdge;
});
}

View File

@@ -0,0 +1,25 @@
import { render as renderWithCoseBilkent } from './render.js';
/**
* Cose-Bilkent Layout Algorithm for Generic Diagrams
*
* This module provides a layout algorithm implementation using Cytoscape
* with the cose-bilkent algorithm for positioning nodes and edges.
*
* The algorithm follows the unified rendering pattern and can be used
* by any diagram type that provides compatible LayoutData.
*/
/**
* Render function for the cose-bilkent layout algorithm
*
* This function follows the unified rendering pattern used by all layout algorithms.
* It takes LayoutData, inserts nodes into DOM, runs the cose-bilkent layout algorithm,
* and renders the positioned elements to the SVG.
*
* @param layoutData - Layout data containing nodes, edges, and configuration
* @param svg - SVG element to render to
* @param helpers - Internal helper functions for rendering
* @param options - Rendering options
*/
export const render = renderWithCoseBilkent;

View File

@@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { validateLayoutData, executeCoseBilkentLayout } from './layout.js';
import type { LayoutResult } from './types.js';
import type { MindmapNode } from '../../../diagrams/mindmap/mindmapTypes.js';
import type { MermaidConfig } from '../../../config.type.js';
import type { LayoutData } from '../../types.js';
// Mock cytoscape and cytoscape-cose-bilkent before importing the modules
vi.mock('cytoscape', () => {
const mockCy = {
add: vi.fn(),
nodes: vi.fn(() => ({
forEach: vi.fn(),
map: vi.fn((fn) => [
fn({
data: () => ({
id: '1',
nodeId: '1',
labelText: 'Root',
level: 0,
type: 0,
width: 100,
height: 50,
padding: 10,
}),
position: () => ({ x: 100, y: 100 }),
}),
]),
})),
edges: vi.fn(() => ({
map: vi.fn((fn) => [
fn({
data: () => ({
id: '1_2',
source: '1',
target: '2',
depth: 0,
}),
_private: {
rscratch: {
startX: 100,
startY: 100,
midX: 150,
midY: 150,
endX: 200,
endY: 200,
},
},
}),
]),
})),
layout: vi.fn(() => ({
run: vi.fn(),
})),
ready: vi.fn((callback) => callback({})),
};
const mockCytoscape = vi.fn(() => mockCy);
(mockCytoscape as any).use = vi.fn();
return {
default: mockCytoscape,
};
});
describe('Cose-Bilkent Layout Algorithm', () => {
let mockConfig: MermaidConfig;
let mockRootNode: MindmapNode;
let mockLayoutData: LayoutData;
beforeEach(() => {
mockConfig = {
mindmap: {
layoutAlgorithm: 'cose-bilkent',
padding: 10,
maxNodeWidth: 200,
useMaxWidth: true,
},
} as MermaidConfig;
mockRootNode = {
id: 1,
nodeId: '1',
level: 0,
descr: 'Root',
type: 0,
width: 100,
height: 50,
padding: 10,
x: 0,
y: 0,
children: [
{
id: 2,
nodeId: '2',
level: 1,
descr: 'Child 1',
type: 0,
width: 80,
height: 40,
padding: 10,
x: 0,
y: 0,
},
],
} as MindmapNode;
mockLayoutData = {
nodes: [
{
id: '1',
nodeId: '1',
level: 0,
descr: 'Root',
type: 0,
width: 100,
height: 50,
padding: 10,
isGroup: false,
},
{
id: '2',
nodeId: '2',
level: 1,
descr: 'Child 1',
type: 0,
width: 80,
height: 40,
padding: 10,
isGroup: false,
},
],
edges: [
{
id: '1_2',
source: '1',
target: '2',
depth: 0,
},
],
config: mockConfig,
rootNode: mockRootNode,
};
});
describe('validateLayoutData', () => {
it('should validate correct layout data', () => {
expect(() => validateLayoutData(mockLayoutData)).not.toThrow();
});
it('should throw error for missing data', () => {
expect(() => validateLayoutData(null as any)).toThrow('Layout data is required');
});
it('should throw error for missing root node', () => {
const invalidData = { ...mockLayoutData, rootNode: null as any };
expect(() => validateLayoutData(invalidData)).toThrow('Root node is required');
});
it('should throw error for missing config', () => {
const invalidData = { ...mockLayoutData, config: null as any };
expect(() => validateLayoutData(invalidData)).toThrow('Configuration is required');
});
it('should throw error for invalid nodes array', () => {
const invalidData = { ...mockLayoutData, nodes: null as any };
expect(() => validateLayoutData(invalidData)).toThrow('No nodes found in layout data');
});
it('should throw error for invalid edges array', () => {
const invalidData = { ...mockLayoutData, edges: null as any };
expect(() => validateLayoutData(invalidData)).toThrow('Edges array is required');
});
});
describe('layout function', () => {
it('should execute layout algorithm successfully', async () => {
const result: LayoutResult = await executeCoseBilkentLayout(mockLayoutData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toBeDefined();
expect(result.edges).toBeDefined();
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.edges)).toBe(true);
});
it('should return positioned nodes with coordinates', async () => {
const result: LayoutResult = await executeCoseBilkentLayout(mockLayoutData, mockConfig);
expect(result.nodes.length).toBeGreaterThan(0);
result.nodes.forEach((node) => {
expect(node.x).toBeDefined();
expect(node.y).toBeDefined();
expect(typeof node.x).toBe('number');
expect(typeof node.y).toBe('number');
});
});
it('should return positioned edges with coordinates', async () => {
const result: LayoutResult = await executeCoseBilkentLayout(mockLayoutData, mockConfig);
expect(result.edges.length).toBeGreaterThan(0);
result.edges.forEach((edge) => {
expect(edge.startX).toBeDefined();
expect(edge.startY).toBeDefined();
expect(edge.midX).toBeDefined();
expect(edge.midY).toBeDefined();
expect(edge.endX).toBeDefined();
expect(edge.endY).toBeDefined();
});
});
it('should handle empty mindmap data gracefully', async () => {
const emptyData: LayoutData = {
nodes: [],
edges: [],
config: mockConfig,
rootNode: mockRootNode,
};
const result: LayoutResult = await executeCoseBilkentLayout(emptyData, mockConfig);
expect(result).toBeDefined();
expect(result.nodes).toBeDefined();
expect(result.edges).toBeDefined();
expect(Array.isArray(result.nodes)).toBe(true);
expect(Array.isArray(result.edges)).toBe(true);
});
it('should throw error for invalid data', async () => {
const invalidData = { ...mockLayoutData, rootNode: null as any };
await expect(executeCoseBilkentLayout(invalidData, mockConfig)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,77 @@
import type { MermaidConfig } from '../../../config.type.js';
import { log } from '../../../logger.js';
import type { LayoutData } from '../../types.js';
import type { LayoutResult } from './types.js';
import {
createCytoscapeInstance,
extractPositionedNodes,
extractPositionedEdges,
} from './cytoscape-setup.js';
/**
* Execute the cose-bilkent layout algorithm on generic layout data
*
* This function takes layout data and uses Cytoscape with the cose-bilkent
* algorithm to calculate optimal node positions and edge paths.
*
* @param data - The layout data containing nodes, edges, and configuration
* @param config - Mermaid configuration object
* @returns Promise resolving to layout result with positioned nodes and edges
*/
export async function executeCoseBilkentLayout(
data: LayoutData,
_config: MermaidConfig
): Promise<LayoutResult> {
log.debug('Starting cose-bilkent layout algorithm');
try {
// Validate layout data structure
validateLayoutData(data);
// Create and configure cytoscape instance
const cy = await createCytoscapeInstance(data);
// Extract positioned nodes and edges after layout
const positionedNodes = extractPositionedNodes(cy);
const positionedEdges = extractPositionedEdges(cy);
log.debug(`Layout completed: ${positionedNodes.length} nodes, ${positionedEdges.length} edges`);
return {
nodes: positionedNodes,
edges: positionedEdges,
};
} catch (error) {
log.error('Error in cose-bilkent layout algorithm:', error);
throw error;
}
}
/**
* Validate layout data structure
* @param data - The data to validate
* @returns True if data is valid, throws error otherwise
*/
export function validateLayoutData(data: LayoutData): boolean {
if (!data) {
throw new Error('Layout data is required');
}
if (!data.config) {
throw new Error('Configuration is required in layout data');
}
if (!data.rootNode) {
throw new Error('Root node is required');
}
if (!data.nodes || !Array.isArray(data.nodes)) {
throw new Error('No nodes found in layout data');
}
if (!Array.isArray(data.edges)) {
throw new Error('Edges array is required in layout data');
}
return true;
}

View File

@@ -0,0 +1,197 @@
import type { InternalHelpers, LayoutData, RenderOptions, SVG, SVGGroup } from 'mermaid';
import { executeCoseBilkentLayout } from './layout.js';
import type { D3Selection } from '../../../types.js';
type Node = Record<string, unknown>;
interface NodeWithPosition extends Node {
x?: number;
y?: number;
domId?: string | SVGGroup | D3Selection<SVGAElement>;
width?: number;
height?: number;
id?: string;
}
/**
* Render function for cose-bilkent layout algorithm
*
* This follows the same pattern as ELK and dagre renderers:
* 1. Insert nodes into DOM to get their actual dimensions
* 2. Run the layout algorithm to calculate positions
* 3. Position the nodes and edges based on layout results
*/
export const render = async (
data4Layout: LayoutData,
svg: SVG,
{
insertCluster,
insertEdge,
insertEdgeLabel,
insertMarkers,
insertNode,
log,
positionEdgeLabel,
}: InternalHelpers,
{ algorithm: _algorithm }: RenderOptions
) => {
const nodeDb: Record<string, NodeWithPosition> = {};
const clusterDb: Record<string, any> = {};
// Insert markers for edges
const element = svg.select('g');
insertMarkers(element, data4Layout.markers, data4Layout.type, data4Layout.diagramId);
// Create container groups
const subGraphsEl = element.insert('g').attr('class', 'subgraphs');
const edgePaths = element.insert('g').attr('class', 'edgePaths');
const edgeLabels = element.insert('g').attr('class', 'edgeLabels');
const nodes = element.insert('g').attr('class', 'nodes');
// Step 1: Insert nodes into DOM to get their actual dimensions
log.debug('Inserting nodes into DOM for dimension calculation');
await Promise.all(
data4Layout.nodes.map(async (node) => {
if (node.isGroup) {
// Handle subgraphs/clusters
const clusterNode: NodeWithPosition = { ...node };
clusterDb[node.id] = clusterNode;
nodeDb[node.id] = clusterNode;
// Insert cluster to get dimensions
await insertCluster(subGraphsEl, node);
} else {
// Handle regular nodes
const nodeWithPosition: NodeWithPosition = { ...node };
nodeDb[node.id] = nodeWithPosition;
// Insert node to get actual dimensions
const nodeEl = await insertNode(nodes, node, {
config: data4Layout.config,
dir: data4Layout.direction || 'TB',
});
// Get the actual bounding box after insertion
const boundingBox = nodeEl.node()!.getBBox();
nodeWithPosition.width = boundingBox.width;
nodeWithPosition.height = boundingBox.height;
nodeWithPosition.domId = nodeEl;
log.debug(`Node ${node.id} dimensions: ${boundingBox.width}x${boundingBox.height}`);
}
})
);
// Step 2: Run the cose-bilkent layout algorithm
log.debug('Running cose-bilkent layout algorithm');
// Update the layout data with actual dimensions
const updatedLayoutData = {
...data4Layout,
nodes: data4Layout.nodes.map((node) => {
const nodeWithDimensions = nodeDb[node.id];
return {
...node,
width: nodeWithDimensions.width,
height: nodeWithDimensions.height,
};
}),
};
const layoutResult = await executeCoseBilkentLayout(updatedLayoutData, data4Layout.config);
// Step 3: Position the nodes based on layout results
log.debug('Positioning nodes based on layout results');
layoutResult.nodes.forEach((positionedNode) => {
const node = nodeDb[positionedNode.id];
if (node?.domId) {
// Position the node at the calculated coordinates
// The positionedNode.x/y represents the center of the node, so use directly
(node.domId as D3Selection<SVGAElement>).attr(
'transform',
`translate(${positionedNode.x}, ${positionedNode.y})`
);
// Store the final position
node.x = positionedNode.x;
node.y = positionedNode.y;
log.debug(`Positioned node ${node.id} at center (${positionedNode.x}, ${positionedNode.y})`);
}
});
layoutResult.edges.forEach((positionedEdge) => {
const edge = data4Layout.edges.find((e) => e.id === positionedEdge.id);
if (edge) {
// Update the edge data with positioned coordinates
edge.points = [
{ x: positionedEdge.startX, y: positionedEdge.startY },
{ x: positionedEdge.midX, y: positionedEdge.midY },
{ x: positionedEdge.endX, y: positionedEdge.endY },
];
}
});
// Step 4: Insert and position edges
log.debug('Inserting and positioning edges');
await Promise.all(
data4Layout.edges.map(async (edge) => {
// Insert edge label first
const _edgeLabel = await insertEdgeLabel(edgeLabels, edge);
// Get start and end nodes
const startNode = nodeDb[edge.start ?? ''];
const endNode = nodeDb[edge.end ?? ''];
if (startNode && endNode) {
// Find the positioned edge data
const positionedEdge = layoutResult.edges.find((e) => e.id === edge.id);
if (positionedEdge) {
log.debug('APA01 positionedEdge', positionedEdge);
// Create edge path with positioned coordinates
const edgeWithPath = { ...edge };
// Insert the edge path
const paths = insertEdge(
edgePaths,
edgeWithPath,
clusterDb,
data4Layout.type,
startNode,
endNode,
data4Layout.diagramId
);
// Position the edge label
positionEdgeLabel(edgeWithPath, paths);
} else {
// Fallback: create a simple straight line between nodes
const edgeWithPath = {
...edge,
points: [
{ x: startNode.x || 0, y: startNode.y || 0 },
{ x: endNode.x || 0, y: endNode.y || 0 },
],
};
const paths = insertEdge(
edgePaths,
edgeWithPath,
clusterDb,
data4Layout.type,
startNode,
endNode,
data4Layout.diagramId
);
positionEdgeLabel(edgeWithPath, paths);
}
}
})
);
log.debug('Cose-bilkent rendering completed');
};

View File

@@ -0,0 +1,43 @@
/**
* Positioned node after layout calculation
*/
export interface PositionedNode {
id: string;
x: number;
y: number;
[key: string]: unknown; // Allow additional properties
}
/**
* Positioned edge after layout calculation
*/
export interface PositionedEdge {
id: string;
source: string;
target: string;
startX: number;
startY: number;
midX: number;
midY: number;
endX: number;
endY: number;
[key: string]: unknown; // Allow additional properties
}
/**
* Result of layout algorithm execution
*/
export interface LayoutResult {
nodes: PositionedNode[];
edges: PositionedEdge[];
}
/**
* Cytoscape layout configuration
*/
export interface CytoscapeLayoutConfig {
name: 'cose-bilkent';
quality: 'proof';
styleEnabled: boolean;
animate: boolean;
}

View File

@@ -39,6 +39,14 @@ const registerDefaultLayoutLoaders = () => {
name: 'dagre',
loader: async () => await import('./layout-algorithms/dagre/index.js'),
},
...(includeLargeFeatures
? [
{
name: 'cose-bilkent',
loader: async () => await import('./layout-algorithms/cose-bilkent/index.js'),
},
]
: []),
]);
};

View File

@@ -438,7 +438,6 @@ const fixCorners = function (lineData) {
}
return newLineData;
};
export const insertEdge = function (elem, edge, clusterDb, diagramType, startNode, endNode, id) {
const { handDrawnSeed } = getConfig();
let points = edge.points;
@@ -622,9 +621,9 @@ export const insertEdge = function (elem, edge, clusterDb, diagramType, startNod
// lineData.forEach((point) => {
// elem
// .append('circle')
// .style('stroke', 'blue')
// .style('fill', 'blue')
// .attr('r', 3)
// .style('stroke', 'red')
// .style('fill', 'red')
// .attr('r', 1)
// .attr('cx', point.x)
// .attr('cy', point.y);
// });

View File

@@ -2,64 +2,63 @@
* Returns the point at which two lines, p and q, intersect or returns undefined if they do not intersect.
*/
function intersectLine(p1, p2, q1, q2) {
// Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994,
// p7 and p473.
{
// Algorithm from J. Avro, (ed.) Graphics Gems, No 2, Morgan Kaufmann, 1994,
// p7 and p473.
var a1, a2, b1, b2, c1, c2;
var r1, r2, r3, r4;
var denom, offset, num;
var x, y;
// Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x +
// b1 y + c1 = 0.
const a1 = p2.y - p1.y;
const b1 = p1.x - p2.x;
const c1 = p2.x * p1.y - p1.x * p2.y;
// Compute a1, b1, c1, where line joining points 1 and 2 is F(x,y) = a1 x +
// b1 y + c1 = 0.
a1 = p2.y - p1.y;
b1 = p1.x - p2.x;
c1 = p2.x * p1.y - p1.x * p2.y;
// Compute r3 and r4.
const r3 = a1 * q1.x + b1 * q1.y + c1;
const r4 = a1 * q2.x + b1 * q2.y + c1;
// Compute r3 and r4.
r3 = a1 * q1.x + b1 * q1.y + c1;
r4 = a1 * q2.x + b1 * q2.y + c1;
const epsilon = 1e-6;
// Check signs of r3 and r4. If both point 3 and point 4 lie on
// same side of line 1, the line segments do not intersect.
if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) {
return /*DON'T_INTERSECT*/;
// Check signs of r3 and r4. If both point 3 and point 4 lie on
// same side of line 1, the line segments do not intersect.
if (r3 !== 0 && r4 !== 0 && sameSign(r3, r4)) {
return /*DON'T_INTERSECT*/;
}
// Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0
const a2 = q2.y - q1.y;
const b2 = q1.x - q2.x;
const c2 = q2.x * q1.y - q1.x * q2.y;
// Compute r1 and r2
const r1 = a2 * p1.x + b2 * p1.y + c2;
const r2 = a2 * p2.x + b2 * p2.y + c2;
// Check signs of r1 and r2. If both point 1 and point 2 lie
// on same side of second line segment, the line segments do
// not intersect.
if (Math.abs(r1) < epsilon && Math.abs(r2) < epsilon && sameSign(r1, r2)) {
return /*DON'T_INTERSECT*/;
}
// Line segments intersect: compute intersection point.
const denom = a1 * b2 - a2 * b1;
if (denom === 0) {
return /*COLLINEAR*/;
}
const offset = Math.abs(denom / 2);
// The denom/2 is to get rounding instead of truncating. It
// is added or subtracted to the numerator, depending upon the
// sign of the numerator.
let num = b1 * c2 - b2 * c1;
const x = num < 0 ? (num - offset) / denom : (num + offset) / denom;
num = a2 * c1 - a1 * c2;
const y = num < 0 ? (num - offset) / denom : (num + offset) / denom;
return { x: x, y: y };
}
// Compute a2, b2, c2 where line joining points 3 and 4 is G(x,y) = a2 x + b2 y + c2 = 0
a2 = q2.y - q1.y;
b2 = q1.x - q2.x;
c2 = q2.x * q1.y - q1.x * q2.y;
// Compute r1 and r2
r1 = a2 * p1.x + b2 * p1.y + c2;
r2 = a2 * p2.x + b2 * p2.y + c2;
// Check signs of r1 and r2. If both point 1 and point 2 lie
// on same side of second line segment, the line segments do
// not intersect.
if (r1 !== 0 && r2 !== 0 && sameSign(r1, r2)) {
return /*DON'T_INTERSECT*/;
}
// Line segments intersect: compute intersection point.
denom = a1 * b2 - a2 * b1;
if (denom === 0) {
return /*COLLINEAR*/;
}
offset = Math.abs(denom / 2);
// The denom/2 is to get rounding instead of truncating. It
// is added or subtracted to the numerator, depending upon the
// sign of the numerator.
num = b1 * c2 - b2 * c1;
x = num < 0 ? (num - offset) / denom : (num + offset) / denom;
num = a2 * c1 - a1 * c2;
y = num < 0 ? (num - offset) / denom : (num + offset) / denom;
return { x: x, y: y };
}
function sameSign(r1, r2) {

View File

@@ -61,6 +61,10 @@ 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';
import { defaultMindmapNode } from './shapes/defaultMindmapNode.js';
import { mindmapCircle } from './shapes/mindmapCircle.js';
type ShapeHandler = <T extends SVGGraphicsElement>(
parent: D3Selection<T>,
@@ -135,6 +139,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',
@@ -476,6 +496,9 @@ const generateShapeMap = () => {
// Kanban diagram
kanbanItem,
//Mindmap diagram
mindmapCircle,
defaultMindmapNode,
// class diagram
classBox,

View File

@@ -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 bang<T extends SVGGraphicsElement>(parent: D3Selection<T>, 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 + 10 * halfPadding;
const h = bbox.height + 8 * halfPadding;
const r = 0.15 * w;
const { cssStyles } = node;
const minWidth = bbox.width + 20;
const minHeight = bbox.height + 20;
const effectiveWidth = Math.max(w, minWidth);
const effectiveHeight = Math.max(h, minHeight);
label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`);
let bangElem;
const path = `M0 0
a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${-1 * effectiveHeight * 0.1}
a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${0}
a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${0}
a${r},${r} 1 0,0 ${effectiveWidth * 0.25},${effectiveHeight * 0.1}
a${r},${r} 1 0,0 ${effectiveWidth * 0.15},${effectiveHeight * 0.33}
a${r * 0.8},${r * 0.8} 1 0,0 0,${effectiveHeight * 0.34}
a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.15},${effectiveHeight * 0.33}
a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},${effectiveHeight * 0.15}
a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},0
a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},0
a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.25},${-1 * effectiveHeight * 0.15}
a${r},${r} 1 0,0 ${-1 * effectiveWidth * 0.1},${-1 * effectiveHeight * 0.33}
a${r * 0.8},${r * 0.8} 1 0,0 0,${-1 * effectiveHeight * 0.34}
a${r},${r} 1 0,0 ${effectiveWidth * 0.1},${-1 * effectiveHeight * 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(${-effectiveWidth / 2}, ${-effectiveHeight / 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;
}

View File

@@ -1,18 +1,22 @@
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 { log } from '../../../logger.js';
import type { Bounds, D3Selection, Point } from '../../../types.js';
import { handleUndefinedAttr } from '../../../utils.js';
import type { MindmapOptions, Node, ShapeRenderOptions } from '../../types.js';
import intersect from '../intersect/index.js';
import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js';
export async function circle<T extends SVGGraphicsElement>(parent: D3Selection<T>, node: Node) {
export async function circle<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
node: Node,
options?: MindmapOptions | ShapeRenderOptions
) {
const { labelStyles, nodeStyles } = styles2String(node);
node.labelStyle = labelStyles;
const { shapeSvg, bbox, halfPadding } = await labelHelper(parent, node, getNodeClasses(node));
const radius = bbox.width / 2 + halfPadding;
const padding = options?.padding ?? halfPadding;
const radius = bbox.width / 2 + padding;
let circleElem;
const { cssStyles } = node;
@@ -35,7 +39,10 @@ export async function circle<T extends SVGGraphicsElement>(parent: D3Selection<T
}
updateNodeBounds(node, circleElem);
node.calcIntersect = function (bounds: Bounds, point: Point) {
const radius = bounds.width / 2;
return intersect.circle(bounds, radius, point);
};
node.intersect = function (point) {
log.info('Circle intersect', node, radius, point);
return intersect.circle(node, radius, point);

View File

@@ -0,0 +1,80 @@
import rough from 'roughjs';
import { log } from '../../../logger.js';
import type { Bounds, D3Selection, Point } from '../../../types.js';
import { handleUndefinedAttr } from '../../../utils.js';
import type { Node } from '../../types.js';
import intersect from '../intersect/index.js';
import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js';
export async function cloud<T extends SVGGraphicsElement>(parent: D3Selection<T>, 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;
}

View File

@@ -0,0 +1,64 @@
import type { Bounds, D3Selection, Point } from '../../../types.js';
import type { Node } from '../../types.js';
import intersect from '../intersect/index.js';
import { styles2String } from './handDrawnShapeStyles.js';
import { getNodeClasses, labelHelper, updateNodeBounds } from './util.js';
export async function defaultMindmapNode<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
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 + 8 * halfPadding;
const h = bbox.height + 2 * halfPadding;
const rd = 5;
const rectPath = `
M${-w / 2} ${h / 2 - rd}
v${-h + 2 * rd}
q0,-${rd} ${rd},-${rd}
h${w - 2 * rd}
q${rd},0 ${rd},${rd}
v${h - 2 * rd}
q0,${rd} -${rd},${rd}
h${-w + 2 * rd}
q-${rd},0 -${rd},-${rd}
Z
`;
const bg = shapeSvg
.append('path')
.attr('id', 'node-' + node.id)
.attr('class', 'node-bkg node-' + node.type)
.attr('style', nodeStyles)
.attr('d', rectPath);
shapeSvg
.append('line')
.attr('class', 'node-line-')
.attr('x1', -w / 2)
.attr('y1', h / 2)
.attr('x2', w / 2)
.attr('y2', h / 2);
label.attr('transform', `translate(${-bbox.width / 2}, ${-bbox.height / 2})`);
shapeSvg.append(() => label.node());
updateNodeBounds(node, bg);
node.calcIntersect = function (bounds: Bounds, point: Point) {
return intersect.rect(bounds, point);
};
node.intersect = function (point) {
return intersect.rect(node, point);
};
return shapeSvg;
}

View File

@@ -6,6 +6,7 @@ import { userNodeOverrides, styles2String } 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 drawRect<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
@@ -62,6 +63,10 @@ export async function drawRect<T extends SVGGraphicsElement>(
updateNodeBounds(node, rect);
node.calcIntersect = function (bounds: Bounds, point: Point) {
return intersect.rect(bounds, point);
};
node.intersect = function (point) {
return intersect.rect(node, point);
};

View File

@@ -0,0 +1,13 @@
import { circle } from './circle.js';
import type { Node, MindmapOptions } from '../../types.js';
import type { D3Selection } from '../../../types.js';
export async function mindmapCircle<T extends SVGGraphicsElement>(
parent: D3Selection<T>,
node: Node
) {
const options = {
padding: node.padding ?? 0,
} as MindmapOptions;
return circle(parent, node, options);
}

View File

@@ -1,4 +1,3 @@
import { log } from '../../../logger.js';
import { labelHelper, updateNodeBounds, getNodeClasses } from './util.js';
import intersect from '../intersect/index.js';
import type { Node } from '../../types.js';
@@ -6,6 +5,7 @@ import { styles2String, userNodeOverrides } from './handDrawnShapeStyles.js';
import rough from 'roughjs';
import { insertPolygonShape } from './insertPolygonShape.js';
import type { D3Selection } from '../../../types.js';
import type { Bounds, Point } from '../../../types.js';
export const createDecisionBoxPathD = (x: number, y: number, size: number): string => {
return [
@@ -61,17 +61,26 @@ export async function question<T extends SVGGraphicsElement>(parent: D3Selection
}
updateNodeBounds(node, polygon);
node.calcIntersect = function (bounds: Bounds, point: Point) {
const s = bounds.width;
// Define polygon points
const points = [
{ x: s / 2, y: 0 },
{ x: s, y: -s / 2 },
{ x: s / 2, y: -s },
{ x: 0, y: -s / 2 },
];
// Calculate the intersection point
const res = intersect.polygon(bounds, points, point);
return { x: res.x - 0.5, y: res.y - 0.5 }; // Adjusted result
};
node.intersect = function (point) {
log.debug(
'APA12 Intersect called SPLIT\npoint:',
point,
'\nnode:\n',
node,
'\nres:',
intersect.polygon(node, points, point)
);
return intersect.polygon(node, points, point);
// @ts-ignore TODO fix this (KNSV)
return this.calcIntersect(node as Bounds, point);
};
return shapeSvg;

View File

@@ -98,18 +98,19 @@ export async function roundedRect<T extends SVGGraphicsElement>(
const w = (node?.width ? node?.width : bbox.width) + labelPaddingX * 2;
const h = (node?.height ? node?.height : bbox.height) + labelPaddingY * 2;
const radius = 5;
const taper = 5; // Taper width for the rounded corners
const radius = node.radius || 5;
const taper = node.taper || 5; // Taper width for the rounded corners
const { cssStyles } = node;
// @ts-expect-error -- Passing a D3.Selection seems to work for some reason
const rc = rough.svg(shapeSvg);
const options = userNodeOverrides(node, {});
if (node.stroke) {
options.stroke = node.stroke;
}
if (node.look !== 'handDrawn') {
options.roughness = 0;
options.fillStyle = 'solid';
}
const points = [
// Top edge (left to right)
{ x: -w / 2 + taper, y: -h / 2 }, // Top-left corner start (1)

View File

@@ -7,7 +7,7 @@ export async function squareRect<T extends SVGGraphicsElement>(parent: D3Selecti
rx: 0,
ry: 0,
classes: '',
labelPaddingX: (node?.padding || 0) * 2,
labelPaddingX: node.labelPaddingX ?? (node?.padding || 0) * 2,
labelPaddingY: (node?.padding || 0) * 1,
} as RectOptions;
return drawRect(parent, node, options);

View File

@@ -2,6 +2,7 @@ export type MarkdownWordType = 'normal' | 'strong' | 'em';
import type { MermaidConfig } from '../config.type.js';
import type { ClusterShapeID } from './rendering-elements/clusters.js';
import type { ShapeID } from './rendering-elements/shapes.js';
import type { Bounds, Point } from '../types.js';
export interface MarkdownWord {
content: string;
type: MarkdownWordType;
@@ -38,11 +39,12 @@ interface BaseNode {
linkTarget?: string;
tooltip?: string;
padding?: number; //REMOVE?, use from LayoutData.config - Keep, this could be shape specific
isGroup: boolean;
isGroup?: boolean;
width?: number;
height?: number;
// Specific properties for State Diagram nodes TODO remove and use generic properties
intersect?: (point: any) => any;
calcIntersect?: (bounds: Bounds, point: Point) => any;
// Non-generic properties
rx?: number; // Used for rounded corners in Rect, Ellipse, etc.Maybe it to specialized RectNode, EllipseNode, etc.
@@ -58,6 +60,8 @@ interface BaseNode {
borderStyle?: string;
borderWidth?: number;
labelTextColor?: string;
labelPaddingX?: number;
labelPaddingY?: number;
// Flowchart specific properties
x?: number;
@@ -72,16 +76,25 @@ interface BaseNode {
defaultWidth?: number;
imageAspectRatio?: number;
constraint?: 'on' | 'off';
children?: NodeChildren;
nodeId?: string;
level?: number;
descr?: string;
type?: number;
radius?: number;
taper?: number;
stroke?: string;
}
/**
* Group/cluster nodes, e.g. nodes that contain other nodes.
*/
export type NodeChildren = Node[];
export interface ClusterNode extends BaseNode {
shape?: ClusterShapeID;
isGroup: true;
}
export interface NonClusterNode extends BaseNode {
shape?: ShapeID;
isGroup: false;
@@ -113,7 +126,7 @@ export interface Edge {
start?: string;
stroke?: string;
text?: string;
type: string;
type?: string;
// Class Diagram specific properties
startLabelRight?: string;
endLabelLeft?: string;
@@ -126,6 +139,12 @@ export interface Edge {
thickness?: 'normal' | 'thick' | 'invisible' | 'dotted';
look?: string;
isUserDefinedId?: boolean;
points?: Point[];
parentId?: string;
dir?: string;
source?: string;
target?: string;
depth?: number;
}
export interface RectOptions {
@@ -136,6 +155,10 @@ export interface RectOptions {
classes: string;
}
export interface MindmapOptions {
padding: number;
}
// Extending the Node interface for specific types if needed
export type ClassDiagramNode = Node & {
memberData: any; // Specific property for class diagram nodes
@@ -171,6 +194,7 @@ export interface ShapeRenderOptions {
config: MermaidConfig;
/** Some shapes render differently if a diagram has a direction `LR` */
dir?: Node['dir'];
padding?: number;
}
export type KanbanNode = Node & {

View File

@@ -977,6 +977,7 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
- useMaxWidth
- padding
- maxNodeWidth
- layoutAlgorithm
properties:
padding:
type: number
@@ -984,6 +985,10 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file)
maxNodeWidth:
type: number
default: 200
layoutAlgorithm:
description: Layout algorithm to use for positioning mindmap nodes
type: string
default: 'cose-bilkent'
KanbanDiagramConfig:
title: Kanban Diagram Config

View File

@@ -48,6 +48,10 @@ export interface Point {
x: number;
y: number;
}
export interface Bounds extends Point {
width: number;
height: number;
}
export interface TextDimensionConfig {
fontSize?: number;

View File

@@ -0,0 +1,4 @@
declare module 'cytoscape-cose-bilkent' {
const coseBilkent: any;
export default coseBilkent;
}

View File

@@ -3,7 +3,7 @@ import type { EdgeData, Point } from '../types.js';
// We need to draw the lines a bit shorter to avoid drawing
// under any transparent markers.
// The offsets are calculated from the markers' dimensions.
const markerOffsets = {
export const markerOffsets = {
aggregation: 18,
extension: 18,
composition: 18,
@@ -104,7 +104,6 @@ export const getLineFunctionsWithOffset = (
adjustment *= DIRECTION === 'right' ? -1 : 1;
offset += adjustment;
}
return pointTransformer(d).x + offset;
},
y: function (