diff --git a/.gitignore b/.gitignore index 7448f2a81..7eb55d5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ coverage/ .idea/ .pnpm-store/ +.instructions/ dist v8-compile-cache-0 diff --git a/cypress/platform/knsv2.html b/cypress/platform/knsv2.html index 934d6f44c..8f5aa428a 100644 --- a/cypress/platform/knsv2.html +++ b/cypress/platform/knsv2.html @@ -105,20 +105,77 @@
-- flowchart LR - AB["apa@apa@"] --> B(("`apa@apa`")) ++ --- + config: + layout: dagre + --- + 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 + Tools + Pen and paper + Mermaid + ++ --- + config: + layout: dagre + --- + mindmap + root((mindmap)) + Origins + Europe + Asia + East + West + Background + Rich + Poor + + + + + +++ --- + config: + layout: elk + --- + flowchart LR + root{mindmap} --- Origins --- Europe + root --- Origins --- Asia + root --- Background --- Rich + root --- Background --- Poor + + + + ++flowchart D(("for D"))-+flowchart LR A e1@==> B e1@{ animate: true}-+flowchart LR A e1@--> B classDef animate stroke-width:2,stroke-dasharray:10\,8,stroke-dashoffset:-180,animation: edge-animation-frame 6s linear infinite, stroke-linecap: round diff --git a/packages/mermaid-layout-elk/src/render.ts b/packages/mermaid-layout-elk/src/render.ts index 4d124c04f..3e0194019 100644 --- a/packages/mermaid-layout-elk/src/render.ts +++ b/packages/mermaid-layout-elk/src/render.ts @@ -51,14 +51,28 @@ export const render = async ( // Add the element to the DOM if (!node.isGroup) { + // Create a clean node object for ELK with only the properties it expects const child: NodeWithVertex = { - ...node, + id: node.id, + width: node.width, + height: node.height, + // Store the original node data for later use + label: node.label, + isGroup: node.isGroup, + shape: node.shape, + padding: node.padding, + cssClasses: node.cssClasses, + cssStyles: node.cssStyles, + look: node.look, + // Include parentId for subgraph processing + parentId: node.parentId, }; graph.children.push(child); nodeDb[node.id] = child; const childNodeEl = await insertNode(nodeEl, node, { config, dir: node.dir }); const boundingBox = childNodeEl.node()!.getBBox(); + // Store the domId separately for rendering, not in the ELK graph child.domId = childNodeEl; child.width = boundingBox.width; child.height = boundingBox.height; @@ -866,11 +880,16 @@ export const render = async ( delete node.height; } }); - elkGraph.edges.forEach((edge: any) => { + log.debug('APA01 processing edges, count:', elkGraph.edges.length); + elkGraph.edges.forEach((edge: any, index: number) => { + log.debug('APA01 processing edge', index, ':', edge); const source = edge.sources[0]; const target = edge.targets[0]; + log.debug('APA01 source:', source, 'target:', target); + log.debug('APA01 nodeDb[source]:', nodeDb[source]); + log.debug('APA01 nodeDb[target]:', nodeDb[target]); - if (nodeDb[source].parentId !== nodeDb[target].parentId) { + if (nodeDb[source] && nodeDb[target] && nodeDb[source].parentId !== nodeDb[target].parentId) { const ancestorId = findCommonAncestor(source, target, parentLookupDb); // an edge that breaks a subgraph has been identified, set configuration accordingly setIncludeChildrenPolicy(source, ancestorId); @@ -878,7 +897,37 @@ export const render = async ( } }); - const g = await elk.layout(elkGraph); + log.debug('APA01 before'); + log.debug('APA01 elkGraph structure:', JSON.stringify(elkGraph, null, 2)); + log.debug('APA01 elkGraph.children length:', elkGraph.children?.length); + log.debug('APA01 elkGraph.edges length:', elkGraph.edges?.length); + + // Validate that all edge references exist as nodes + elkGraph.edges?.forEach((edge: any, index: number) => { + log.debug(`APA01 validating edge ${index}:`, edge); + if (edge.sources) { + edge.sources.forEach((sourceId: any) => { + const sourceExists = elkGraph.children?.some((child: any) => child.id === sourceId); + log.debug(`APA01 source ${sourceId} exists:`, sourceExists); + }); + } + if (edge.targets) { + edge.targets.forEach((targetId: any) => { + const targetExists = elkGraph.children?.some((child: any) => child.id === targetId); + log.debug(`APA01 target ${targetId} exists:`, targetExists); + }); + } + }); + + let g; + try { + g = await elk.layout(elkGraph); + log.debug('APA01 after - success'); + log.debug('APA01 layout result:', JSON.stringify(g, null, 2)); + } catch (error) { + log.error('APA01 ELK layout error:', error); + throw error; + } // debugger; await drawNodes(0, 0, g.children, svg, subGraphsEl, 0); diff --git a/packages/mermaid/src/config.type.ts b/packages/mermaid/src/config.type.ts index 8cd451c16..355e021d5 100644 --- a/packages/mermaid/src/config.type.ts +++ b/packages/mermaid/src/config.type.ts @@ -1065,6 +1065,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 diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts new file mode 100644 index 000000000..c1842ff35 --- /dev/null +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import db from './mindmapDb.js'; +import type { MindmapLayoutNode, MindmapLayoutEdge } from './mindmapDb.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', () => { + beforeEach(() => { + // 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.find((n: any) => n.id === '0') as MindmapLayoutNode; + expect(rootNode).toBeDefined(); + expect(rootNode?.label).toBe('Root Node'); + expect(rootNode?.level).toBe(0); + + // Check child nodes + const child1 = result.nodes.find((n: any) => n.id === '1') as MindmapLayoutNode; + 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: any) => 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); + }); + }); +}); diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts index e7041e9d6..f401881d4 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapDb.ts @@ -4,6 +4,21 @@ 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'; + +// 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; +}; let nodes: MindmapNode[] = []; let cnt = 0; @@ -139,6 +154,164 @@ const type2Str = (type: number) => { } }; +/** + * 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) + */ +const assignSections = (node: MindmapNode, sectionNumber?: number): void => { + // Assign section number to the current node + 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) { + node.children.forEach((child, index) => { + const childSectionNumber = node.level === 0 ? index : sectionNumber; + 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 + */ +const flattenNodes = (node: MindmapNode, processedNodes: MindmapLayoutNode[]): void => { + // Build CSS classes for the node + let cssClasses = 'mindmap-node'; + + // Add section-specific classes + if (node.level === 0) { + // Root node gets special classes + cssClasses += ' section-root section--1'; + } else if (node.section !== undefined) { + // Child nodes get section class based on their section number + cssClasses += ` section-${node.section}`; + } + + // Add any custom classes from the node + if (node.class) { + cssClasses += ` ${node.class}`; + } + + const processedNode: MindmapLayoutNode = { + id: 'node_' + node.id.toString(), + domId: 'node_' + node.id.toString(), + label: node.descr, + isGroup: false, + shape: 'rect', // Default shape, can be customized based on node.type + width: node.width, + height: node.height ?? 0, + padding: node.padding, + cssClasses: cssClasses, + 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) { + node.children.forEach((child) => 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 + */ +const generateEdges = (node: MindmapNode, edges: MindmapLayoutEdge[]): void => { + if (node.children) { + node.children.forEach((child) => { + // 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_' + node.id.toString(), + end: 'node_' + 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 + generateEdges(child, edges); + }); + } +}; + +/** + * Get structured data for layout algorithms + * Following the pattern established by ER diagrams + * @returns Structured data containing nodes, edges, and config + */ +const getData = (): LayoutData => { + const mindmapRoot = getMindmap(); + const config = getConfig(); + + if (!mindmapRoot) { + return { + nodes: [], + edges: [], + config, + }; + } + log.debug('getData: mindmapRoot', mindmapRoot, config); + + // Assign section numbers to all nodes based on their position relative to root + assignSections(mindmapRoot); + + // Convert tree structure to flat arrays + const processedNodes: MindmapLayoutNode[] = []; + const processedEdges: MindmapLayoutEdge[] = []; + + flattenNodes(mindmapRoot, processedNodes); + generateEdges(mindmapRoot, processedEdges); + + log.debug(`getData: processed ${processedNodes.length} nodes and ${processedEdges.length} edges`); + + return { + nodes: processedNodes, + edges: processedEdges, + config, + // Store the root node for mindmap-specific layout algorithms + rootNode: mindmapRoot, + // Properties required by dagre layout algorithm + markers: [], // 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 + }; +}; + // Expose logger to grammar const getLogger = () => log; const getElementById = (id: number) => elements[id]; @@ -154,6 +327,7 @@ const db = { type2Str, getLogger, getElementById, + getData, } as const; export default db; diff --git a/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts b/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts index 708b3cc28..d273fa49e 100644 --- a/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts +++ b/packages/mermaid/src/diagrams/mindmap/mindmapRenderer.ts @@ -1,200 +1,100 @@ -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, MindmapDB, 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, MindmapDB } from './mindmapTypes.js'; +import { drawNode } from './svgDraw.js'; import defaultConfig from '../../defaultConfig.js'; -// Inject the layout algorithm into cytoscape -cytoscape.use(coseBilkent); - -async function drawNodes( +async function _drawNodes( db: MindmapDB, - svg: D3Element, + svg: any, mindmap: FilledMindMapNode, section: number, - conf: MermaidConfig + conf: any ) { 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) + _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 + if (node.children) { + node.children.forEach(updateNode); + } + }; -function layoutMindmap(node: MindmapNode, conf: MermaidConfig): Promise{ - 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); + const { securityLevel, mindmap: conf, layout } = getConfig(); + // 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, securityLevel); + + data4Layout.type = diagObj.type; + data4Layout.layoutAlgorithm = getRegisteredLayoutAlgorithm(layout, { + fallback: 'cose-bilkent', + }); + // For mindmap diagrams, prioritize mindmap-specific layout algorithm configuration + const preferredLayout = conf?.layoutAlgorithm ?? layout ?? 'cose-bilkent'; + log.debug('Mindmap renderer - preferredLayout:', preferredLayout); + log.debug('Mindmap renderer - conf?.layoutAlgorithm:', conf?.layoutAlgorithm); + log.debug('Mindmap renderer - layout:', layout); + + log.debug('Mindmap renderer - selected layoutAlgorithm:', data4Layout.layoutAlgorithm); + log.debug('APA01 Mindmap renderer - data4Layout.rootNode exists:', !!data4Layout.rootNode); + + data4Layout.diagramId = id; + + // Ensure required properties are set for compatibility with different layout algorithms + data4Layout.markers = ['point']; + data4Layout.direction = 'TB'; + const mm = db.getMindmap(); if (!mm) { return; } - const conf = getConfig(); - conf.htmlLabels = false; - - const svg = selectSvgElement(id); - - // 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); - + // Use the unified rendering system + await render(data4Layout, svg); // Setup the view box and size of the svg element - setupGraphViewbox( - undefined, + setupViewPortForSVG( svg, - conf.mindmap?.padding ?? defaultConfig.mindmap.padding, - conf.mindmap?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth + conf?.padding ?? defaultConfig.mindmap.padding, + 'mindmapDiagram', + conf?.useMaxWidth ?? defaultConfig.mindmap.useMaxWidth ); }; diff --git a/packages/mermaid/src/diagrams/mindmap/styles.ts b/packages/mermaid/src/diagrams/mindmap/styles.ts index fffa6e4d9..8731a4ffc 100644 --- a/packages/mermaid/src/diagrams/mindmap/styles.ts +++ b/packages/mermaid/src/diagrams/mindmap/styles.ts @@ -64,6 +64,9 @@ const getStyles: DiagramStylesProvider = (options) => .section-root text { fill: ${options.gitBranchLabel0}; } + .section-root span { + color: ${options.gitBranchLabel0}; + } .icon-container { height:100%; display: flex; diff --git a/packages/mermaid/src/diagrams/mindmap/test.mmd b/packages/mermaid/src/diagrams/mindmap/test.mmd new file mode 100644 index 000000000..f9acaa4e7 --- /dev/null +++ b/packages/mermaid/src/diagrams/mindmap/test.mmd @@ -0,0 +1,22 @@ +--- +config: + theme: redux-color +--- +mindmap + root((mindmap)) + Origins + Long history + ::icon(fa fa-book) + Popularization + British popular psychology author Tony Buzan + Research + On effectiveness
and features + On Automatic creation + Ammmmmmmmmmmmmmmmmmmmmmmm + Uses + Creative techniques + Strategic planning + Argument mapping + Tools + Pen and paper + Mermaid diff --git a/packages/mermaid/src/rendering-util/createGraph.ts b/packages/mermaid/src/rendering-util/createGraph.ts new file mode 100644 index 000000000..02ac6053b --- /dev/null +++ b/packages/mermaid/src/rendering-util/createGraph.ts @@ -0,0 +1,151 @@ +import { insertNode } from './rendering-elements/nodes.js'; +import type { LayoutData } 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= 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 ; + edgePaths: D3Selection ; + edgeLabels: D3Selection ; + nodes: D3Selection ; + rootGroups: D3Selection ; + }; + nodeElements: Map >; +}> { + // 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 >(); + + // 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 ); + node.width = boundingBox.width; + node.height = boundingBox.height; + graph.setNode(node.id, { ...node }); + if (node.parentId) { + // Optionally store the parent relationship (Graphology doesn't have a native parent-child concept) + // e.g., you could update the node attributes or handle it as needed. + } + } + }) + ); + // 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, + parentId: edge.parentId, + ...(edge.dir ? { dir: edge.dir } : {}), + }; + + // 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 ); + 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, + }; +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.test.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.test.ts new file mode 100644 index 000000000..2592b57db --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.test.ts @@ -0,0 +1,281 @@ +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, + }; +}); + +vi.mock('cytoscape-cose-bilkent', () => ({ + default: vi.fn(), +})); + +vi.mock('d3', () => ({ + select: vi.fn(() => ({ + append: vi.fn(() => ({ + attr: vi.fn(() => ({ + attr: vi.fn(() => ({ + remove: vi.fn(), + })), + })), + })), + })), +})); + +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, + }); + }); + }); +}); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.ts new file mode 100644 index 000000000..8fb9b2599 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/cytoscape-setup.ts @@ -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 = { + 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 )[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 = { + 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 )[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 { + 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; + }); +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/index.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/index.ts new file mode 100644 index 000000000..78a82f02b --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/index.ts @@ -0,0 +1,86 @@ +import type { SVG } from '../../../diagram-api/types.js'; +import type { InternalHelpers } from '../../../internals.js'; +import { log } from '../../../logger.js'; +import type { LayoutData } from '../../types.js'; +import type { RenderOptions } from '../../render.js'; +import { executeCoseBilkentLayout } from './layout.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, positions the nodes using Cytoscape with cose-bilkent, + * 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 = async ( + layoutData: LayoutData, + _svg: SVG, + _helpers: InternalHelpers, + _options?: RenderOptions +): Promise => { + log.debug('Cose-bilkent layout algorithm starting'); + log.debug('LayoutData keys:', Object.keys(layoutData)); + + try { + // Validate input data + if (!layoutData.nodes || !Array.isArray(layoutData.nodes)) { + throw new Error('No nodes found in layout data'); + } + + if (!layoutData.edges || !Array.isArray(layoutData.edges)) { + throw new Error('No edges found in layout data'); + } + + log.debug(`Processing ${layoutData.nodes.length} nodes and ${layoutData.edges.length} edges`); + + // Execute the layout algorithm directly with the provided data + const result = await executeCoseBilkentLayout(layoutData, layoutData.config); + + // Update the original layout data with the positioned nodes and edges + layoutData.nodes.forEach((node) => { + const positionedNode = result.nodes.find((n) => n.id === node.id); + if (positionedNode) { + node.x = positionedNode.x; + node.y = positionedNode.y; + log.debug('Updated node position:', node.id, 'at', positionedNode.x, positionedNode.y); + } + }); + + layoutData.edges.forEach((edge) => { + const positionedEdge = result.edges.find((e) => e.id === edge.id); + if (positionedEdge) { + // Update edge with positioning information if needed + const edgeWithPosition = edge as unknown as Record ; + edgeWithPosition.startX = positionedEdge.startX; + edgeWithPosition.startY = positionedEdge.startY; + edgeWithPosition.midX = positionedEdge.midX; + edgeWithPosition.midY = positionedEdge.midY; + edgeWithPosition.endX = positionedEdge.endX; + edgeWithPosition.endY = positionedEdge.endY; + } + }); + + log.debug('Cose-bilkent layout algorithm completed successfully'); + log.debug(`Positioned ${result.nodes.length} nodes and ${result.edges.length} edges`); + } catch (error) { + log.error('Cose-bilkent layout algorithm failed:', error); + throw new Error( + `Layout algorithm failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } +}; diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.test.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.test.ts new file mode 100644 index 000000000..1f8416f35 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// 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.use = vi.fn(); + + return { + default: mockCytoscape, + }; +}); + +vi.mock('cytoscape-cose-bilkent', () => ({ + default: vi.fn(), +})); + +vi.mock('d3', () => ({ + select: vi.fn(() => ({ + append: vi.fn(() => ({ + attr: vi.fn(() => ({ + attr: vi.fn(() => ({ + remove: vi.fn(), + })), + })), + })), + })), +})); + +// Import modules after mocks +import { layout, validateLayoutData } from './index.js'; +import type { MindmapLayoutData, LayoutResult } from './types.js'; +import type { MindmapNode } from '../../../diagrams/mindmap/mindmapTypes.js'; +import type { MermaidConfig } from '../../../config.type.js'; + +describe('Cose-Bilkent Layout Algorithm', () => { + let mockConfig: MermaidConfig; + let mockRootNode: MindmapNode; + let mockLayoutData: MindmapLayoutData; + + 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, + }, + { + id: '2', + nodeId: '2', + level: 1, + descr: 'Child 1', + type: 0, + width: 80, + height: 40, + padding: 10, + }, + ], + 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('Nodes array is required'); + }); + + 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 layout(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 layout(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 layout(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: MindmapLayoutData = { + nodes: [], + edges: [], + config: mockConfig, + rootNode: mockRootNode, + }; + + const result: LayoutResult = await layout(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(layout(invalidData, mockConfig)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.ts new file mode 100644 index 000000000..ba4ec0e12 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/layout.ts @@ -0,0 +1,79 @@ +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 { + log.debug('Starting cose-bilkent layout algorithm'); + + try { + // Validate input data + if (!data.nodes || !Array.isArray(data.nodes)) { + throw new Error('No nodes found in layout data'); + } + + if (!data.edges || !Array.isArray(data.edges)) { + throw new Error('No edges found in layout 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 (!Array.isArray(data.nodes)) { + throw new Error('Nodes array is required in layout data'); + } + + if (!Array.isArray(data.edges)) { + throw new Error('Edges array is required in layout data'); + } + + return true; +} diff --git a/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/types.ts b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/types.ts new file mode 100644 index 000000000..fade24682 --- /dev/null +++ b/packages/mermaid/src/rendering-util/layout-algorithms/cose-bilkent/types.ts @@ -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; +} diff --git a/packages/mermaid/src/rendering-util/render.ts b/packages/mermaid/src/rendering-util/render.ts index b975e7bf9..22a7b96b0 100644 --- a/packages/mermaid/src/rendering-util/render.ts +++ b/packages/mermaid/src/rendering-util/render.ts @@ -39,6 +39,10 @@ const registerDefaultLayoutLoaders = () => { name: 'dagre', loader: async () => await import('./layout-algorithms/dagre/index.js'), }, + { + name: 'cose-bilkent', + loader: async () => await import('./layout-algorithms/cose-bilkent/index.js'), + }, ]); }; diff --git a/packages/mermaid/src/schemas/config.schema.yaml b/packages/mermaid/src/schemas/config.schema.yaml index 6dd21e884..756a55e5d 100644 --- a/packages/mermaid/src/schemas/config.schema.yaml +++ b/packages/mermaid/src/schemas/config.schema.yaml @@ -962,6 +962,7 @@ $defs: # JSON Schema definition (maybe we should move these to a separate file) - useMaxWidth - padding - maxNodeWidth + - layoutAlgorithm properties: padding: type: number @@ -969,6 +970,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