mirror of
https://github.com/mermaid-js/mermaid.git
synced 2025-09-18 06:49:47 +02:00
Updated renderinig flow for mindmaps
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,6 +4,7 @@ node_modules/
|
||||
coverage/
|
||||
.idea/
|
||||
.pnpm-store/
|
||||
.instructions/
|
||||
|
||||
dist
|
||||
v8-compile-cache-0
|
||||
|
@@ -105,20 +105,77 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<pre id="diagram4" class="mermaid">
|
||||
flowchart LR
|
||||
AB["apa@apa@"] --> B(("`apa@apa`"))
|
||||
<pre id="diagram4" class="mermaid2">
|
||||
---
|
||||
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
|
||||
|
||||
|
||||
</pre>
|
||||
<pre id="diagram4" class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: dagre
|
||||
---
|
||||
mindmap
|
||||
root((mindmap))
|
||||
Origins
|
||||
Europe
|
||||
Asia
|
||||
East
|
||||
West
|
||||
Background
|
||||
Rich
|
||||
Poor
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</pre>
|
||||
<pre id="diagram4" class="mermaid">
|
||||
---
|
||||
config:
|
||||
layout: elk
|
||||
---
|
||||
flowchart LR
|
||||
root{mindmap} --- Origins --- Europe
|
||||
root --- Origins --- Asia
|
||||
root --- Background --- Rich
|
||||
root --- Background --- Poor
|
||||
|
||||
|
||||
|
||||
|
||||
</pre>
|
||||
<pre id="diagram4" class="mermaid2">
|
||||
flowchart
|
||||
D(("for D"))
|
||||
</pre>
|
||||
<pre id="diagram4" class="mermaid">
|
||||
<pre id="diagram4" class="mermaid2">
|
||||
flowchart LR
|
||||
A e1@==> B
|
||||
e1@{ animate: true}
|
||||
</pre>
|
||||
<pre id="diagram4" class="mermaid">
|
||||
<pre id="diagram4" class="mermaid2">
|
||||
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
|
||||
|
@@ -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);
|
||||
|
@@ -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
|
||||
|
293
packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts
Normal file
293
packages/mermaid/src/diagrams/mindmap/mindmapDb.getData.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
@@ -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;
|
||||
|
@@ -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<cytoscape.Core> {
|
||||
return new Promise((resolve) => {
|
||||
// Add temporary render element
|
||||
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
|
||||
const cy = cytoscape({
|
||||
container: document.getElementById('cy'), // container to render in
|
||||
style: [
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'curve-style': 'bezier',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// Remove element after layout
|
||||
renderEl.remove();
|
||||
addNodes(node, cy, conf, 0);
|
||||
|
||||
// Make cytoscape care about the dimensions of the nodes
|
||||
cy.nodes().forEach(function (n) {
|
||||
n.layoutDimensions = () => {
|
||||
const data = n.data();
|
||||
return { w: data.width, h: data.height };
|
||||
};
|
||||
});
|
||||
|
||||
cy.layout({
|
||||
name: 'cose-bilkent',
|
||||
// @ts-ignore Types for cose-bilkent are not correct?
|
||||
quality: 'proof',
|
||||
styleEnabled: false,
|
||||
animate: false,
|
||||
}).run();
|
||||
cy.ready((e) => {
|
||||
log.info('Ready', e);
|
||||
resolve(cy);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function positionNodes(db: MindmapDB, cy: cytoscape.Core) {
|
||||
cy.nodes().map((node, id) => {
|
||||
const data = node.data();
|
||||
data.x = node.position().x;
|
||||
data.y = node.position().y;
|
||||
positionNode(db, data);
|
||||
const el = db.getElementById(data.nodeId);
|
||||
log.info('id:', id, 'Position: (', node.position().x, ', ', node.position().y, ')', data);
|
||||
el.attr(
|
||||
'transform',
|
||||
`translate(${node.position().x - data.width / 2}, ${node.position().y - data.height / 2})`
|
||||
);
|
||||
el.attr('attr', `apa-${id})`);
|
||||
});
|
||||
updateNode(mindmapRoot);
|
||||
}
|
||||
|
||||
export const draw: DrawDefinition = async (text, id, _version, diagObj) => {
|
||||
log.debug('Rendering mindmap diagram\n' + text);
|
||||
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
|
||||
);
|
||||
};
|
||||
|
||||
|
@@ -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;
|
||||
|
22
packages/mermaid/src/diagrams/mindmap/test.mmd
Normal file
22
packages/mermaid/src/diagrams/mindmap/test.mmd
Normal file
@@ -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<br/>and features
|
||||
On Automatic creation
|
||||
Ammmmmmmmmmmmmmmmmmmmmmmm
|
||||
Uses
|
||||
Creative techniques
|
||||
Strategic planning
|
||||
Argument mapping
|
||||
Tools
|
||||
Pen and paper
|
||||
Mermaid
|
151
packages/mermaid/src/rendering-util/createGraph.ts
Normal file
151
packages/mermaid/src/rendering-util/createGraph.ts
Normal file
@@ -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<T extends SVGElement = SVGElement> = Selection<
|
||||
T,
|
||||
unknown,
|
||||
Element | null,
|
||||
unknown
|
||||
>;
|
||||
|
||||
/**
|
||||
* Creates a graph by merging the graph construction and DOM element insertion.
|
||||
*
|
||||
* This function creates the graph, inserts the SVG groups (clusters, edgePaths, edgeLabels, nodes)
|
||||
* into the provided element, and uses `insertNode` to add nodes to the diagram. Node dimensions
|
||||
* are computed using each node's bounding box.
|
||||
*
|
||||
* @param element - The D3 selection in which the SVG groups are inserted.
|
||||
* @param data4Layout - The layout data containing nodes and edges.
|
||||
* @returns A promise resolving to an object containing the Graphology graph and the inserted groups.
|
||||
*/
|
||||
export async function createGraphWithElements(
|
||||
element: D3Selection,
|
||||
data4Layout: LayoutData
|
||||
): Promise<{
|
||||
graph: graphlib.Graph;
|
||||
groups: {
|
||||
clusters: D3Selection<SVGGElement>;
|
||||
edgePaths: D3Selection<SVGGElement>;
|
||||
edgeLabels: D3Selection<SVGGElement>;
|
||||
nodes: D3Selection<SVGGElement>;
|
||||
rootGroups: D3Selection<SVGGElement>;
|
||||
};
|
||||
nodeElements: Map<string, D3Selection<SVGElement | SVGGElement>>;
|
||||
}> {
|
||||
// Create a directed, multi graph.
|
||||
const graph = new graphlib.Graph({
|
||||
multigraph: true,
|
||||
compound: true,
|
||||
});
|
||||
const edgesToProcess = [...data4Layout.edges];
|
||||
const config = getConfig();
|
||||
// Create groups for clusters, edge paths, edge labels, and nodes.
|
||||
const clusters = element.insert('g').attr('class', 'clusters');
|
||||
const edgePaths = element.insert('g').attr('class', 'edges edgePath');
|
||||
const edgeLabels = element.insert('g').attr('class', 'edgeLabels');
|
||||
const nodesGroup = element.insert('g').attr('class', 'nodes');
|
||||
const rootGroups = element.insert('g').attr('class', 'root');
|
||||
|
||||
const nodeElements = new Map<string, D3Selection<SVGElement | SVGGElement>>();
|
||||
|
||||
// Insert nodes into the DOM and add them to the graph.
|
||||
await Promise.all(
|
||||
data4Layout.nodes.map(async (node) => {
|
||||
if (node.isGroup) {
|
||||
graph.setNode(node.id, { ...node });
|
||||
} else {
|
||||
const childNodeEl = await insertNode(nodesGroup, node, { config, dir: node.dir });
|
||||
const boundingBox = childNodeEl.node()?.getBBox() ?? { width: 0, height: 0 };
|
||||
nodeElements.set(node.id, childNodeEl as D3Selection<SVGElement | SVGGElement>);
|
||||
node.width = boundingBox.width;
|
||||
node.height = boundingBox.height;
|
||||
graph.setNode(node.id, { ...node });
|
||||
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<SVGElement | SVGGElement>);
|
||||
data4Layout.nodes.push(labelNode);
|
||||
|
||||
// Create two edges to replace the original one
|
||||
const edgeToLabel = {
|
||||
...edge,
|
||||
id: `${edge.id}-to-label`,
|
||||
end: labelNodeId,
|
||||
label: undefined,
|
||||
isLabelEdge: true,
|
||||
arrowTypeEnd: 'none',
|
||||
arrowTypeStart: 'none',
|
||||
};
|
||||
const edgeFromLabel = {
|
||||
...edge,
|
||||
id: `${edge.id}-from-label`,
|
||||
start: labelNodeId,
|
||||
end: edge.end,
|
||||
label: undefined,
|
||||
isLabelEdge: true,
|
||||
arrowTypeStart: 'none',
|
||||
arrowTypeEnd: 'arrow_point',
|
||||
};
|
||||
graph.setEdge(edgeToLabel.id, edgeToLabel.start, edgeToLabel.end, { ...edgeToLabel });
|
||||
graph.setEdge(edgeFromLabel.id, edgeFromLabel.start, edgeFromLabel.end, { ...edgeFromLabel });
|
||||
data4Layout.edges.push(edgeToLabel, edgeFromLabel);
|
||||
const edgeIdToRemove = edge.id;
|
||||
data4Layout.edges = data4Layout.edges.filter((edge) => edge.id !== edgeIdToRemove);
|
||||
const indexInOriginal = data4Layout.edges.findIndex((e) => e.id === edge.id);
|
||||
if (indexInOriginal !== -1) {
|
||||
data4Layout.edges.splice(indexInOriginal, 1);
|
||||
}
|
||||
} else {
|
||||
// Regular edge without label
|
||||
graph.setEdge(edge.id, edge.start, edge.end, { ...edge });
|
||||
const edgeExists = data4Layout.edges.some((existingEdge) => existingEdge.id === edge.id);
|
||||
if (!edgeExists) {
|
||||
data4Layout.edges.push(edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
graph,
|
||||
groups: { clusters, edgePaths, edgeLabels, nodes: nodesGroup, rootGroups },
|
||||
nodeElements,
|
||||
};
|
||||
}
|
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@@ -0,0 +1,207 @@
|
||||
import cytoscape from 'cytoscape';
|
||||
import coseBilkent from 'cytoscape-cose-bilkent';
|
||||
import { select } from 'd3';
|
||||
import { log } from '../../../logger.js';
|
||||
import type { LayoutData, Node, Edge } from '../../types.js';
|
||||
import type { CytoscapeLayoutConfig, PositionedNode, PositionedEdge } from './types.js';
|
||||
|
||||
// Inject the layout algorithm into cytoscape
|
||||
cytoscape.use(coseBilkent);
|
||||
|
||||
/**
|
||||
* Declare module augmentation for cytoscape edge types
|
||||
*/
|
||||
declare module 'cytoscape' {
|
||||
interface EdgeSingular {
|
||||
_private: {
|
||||
bodyBounds: unknown;
|
||||
rscratch: {
|
||||
startX: number;
|
||||
startY: number;
|
||||
midX: number;
|
||||
midY: number;
|
||||
endX: number;
|
||||
endY: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add nodes to cytoscape instance from provided node array
|
||||
* This function processes only the nodes provided in the data structure
|
||||
* @param nodes - Array of nodes to add
|
||||
* @param cy - The cytoscape instance
|
||||
*/
|
||||
export function addNodes(nodes: Node[], cy: cytoscape.Core): void {
|
||||
nodes.forEach((node) => {
|
||||
const nodeData: Record<string, unknown> = {
|
||||
id: node.id,
|
||||
labelText: node.label,
|
||||
height: node.height,
|
||||
width: node.width,
|
||||
padding: node.padding ?? 0,
|
||||
};
|
||||
|
||||
// Add any additional properties from the node
|
||||
Object.keys(node).forEach((key) => {
|
||||
if (!['id', 'label', 'height', 'width', 'padding', 'x', 'y'].includes(key)) {
|
||||
nodeData[key] = (node as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
cy.add({
|
||||
group: 'nodes',
|
||||
data: nodeData,
|
||||
position: {
|
||||
x: node.x ?? 0,
|
||||
y: node.y ?? 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add edges to cytoscape instance from provided edge array
|
||||
* This function processes only the edges provided in the data structure
|
||||
* @param edges - Array of edges to add
|
||||
* @param cy - The cytoscape instance
|
||||
*/
|
||||
export function addEdges(edges: Edge[], cy: cytoscape.Core): void {
|
||||
edges.forEach((edge) => {
|
||||
const edgeData: Record<string, unknown> = {
|
||||
id: edge.id,
|
||||
source: edge.start,
|
||||
target: edge.end,
|
||||
};
|
||||
|
||||
// Add any additional properties from the edge
|
||||
Object.keys(edge).forEach((key) => {
|
||||
if (!['id', 'start', 'end'].includes(key)) {
|
||||
edgeData[key] = (edge as unknown as Record<string, unknown>)[key];
|
||||
}
|
||||
});
|
||||
|
||||
cy.add({
|
||||
group: 'edges',
|
||||
data: edgeData,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and configure cytoscape instance
|
||||
* @param data - Layout data containing nodes and edges
|
||||
* @returns Promise resolving to configured cytoscape instance
|
||||
*/
|
||||
export function createCytoscapeInstance(data: LayoutData): Promise<cytoscape.Core> {
|
||||
return new Promise((resolve) => {
|
||||
// Add temporary render element
|
||||
const renderEl = select('body').append('div').attr('id', 'cy').attr('style', 'display:none');
|
||||
|
||||
const cy = cytoscape({
|
||||
container: document.getElementById('cy'), // container to render in
|
||||
style: [
|
||||
{
|
||||
selector: 'edge',
|
||||
style: {
|
||||
'curve-style': 'bezier',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Remove element after layout
|
||||
renderEl.remove();
|
||||
|
||||
// Add all nodes and edges to cytoscape using the generic functions
|
||||
addNodes(data.nodes, cy);
|
||||
addEdges(data.edges, cy);
|
||||
|
||||
// Make cytoscape care about the dimensions of the nodes
|
||||
cy.nodes().forEach(function (n) {
|
||||
n.layoutDimensions = () => {
|
||||
const nodeData = n.data();
|
||||
return { w: nodeData.width, h: nodeData.height };
|
||||
};
|
||||
});
|
||||
|
||||
// Configure and run the cose-bilkent layout
|
||||
const layoutConfig: CytoscapeLayoutConfig = {
|
||||
name: 'cose-bilkent',
|
||||
// @ts-ignore Types for cose-bilkent are not correct?
|
||||
quality: 'proof',
|
||||
styleEnabled: false,
|
||||
animate: false,
|
||||
};
|
||||
|
||||
cy.layout(layoutConfig).run();
|
||||
|
||||
cy.ready((e) => {
|
||||
log.info('Cytoscape ready', e);
|
||||
resolve(cy);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract positioned nodes from cytoscape instance
|
||||
* @param cy - The cytoscape instance after layout
|
||||
* @returns Array of positioned nodes
|
||||
*/
|
||||
export function extractPositionedNodes(cy: cytoscape.Core): PositionedNode[] {
|
||||
return cy.nodes().map((node) => {
|
||||
const data = node.data();
|
||||
const position = node.position();
|
||||
|
||||
// Create a positioned node with all original data plus position
|
||||
const positionedNode: PositionedNode = {
|
||||
id: data.id,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
};
|
||||
|
||||
// Add all other properties from the original data
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (key !== 'id') {
|
||||
positionedNode[key] = data[key];
|
||||
}
|
||||
});
|
||||
|
||||
return positionedNode;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract positioned edges from cytoscape instance
|
||||
* @param cy - The cytoscape instance after layout
|
||||
* @returns Array of positioned edges
|
||||
*/
|
||||
export function extractPositionedEdges(cy: cytoscape.Core): PositionedEdge[] {
|
||||
return cy.edges().map((edge) => {
|
||||
const data = edge.data();
|
||||
const rscratch = edge._private.rscratch;
|
||||
|
||||
// Create a positioned edge with all original data plus position
|
||||
const positionedEdge: PositionedEdge = {
|
||||
id: data.id,
|
||||
source: data.source,
|
||||
target: data.target,
|
||||
startX: rscratch.startX,
|
||||
startY: rscratch.startY,
|
||||
midX: rscratch.midX,
|
||||
midY: rscratch.midY,
|
||||
endX: rscratch.endX,
|
||||
endY: rscratch.endY,
|
||||
};
|
||||
|
||||
// Add all other properties from the original data
|
||||
Object.keys(data).forEach((key) => {
|
||||
if (!['id', 'source', 'target'].includes(key)) {
|
||||
positionedEdge[key] = data[key];
|
||||
}
|
||||
});
|
||||
|
||||
return positionedEdge;
|
||||
});
|
||||
}
|
@@ -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<void> => {
|
||||
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<string, unknown>;
|
||||
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'}`
|
||||
);
|
||||
}
|
||||
};
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@@ -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<LayoutResult> {
|
||||
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;
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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'),
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user