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