UNPKG

@ichigo_san/graphing

Version:

A lightweight UML-style diagram editor built with React Flow and Tailwind CSS

659 lines (620 loc) 25.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.layoutAlgorithms = exports.autoLayoutNodes = exports.applyLayoutAlgorithm = void 0; /** * Enhanced Auto Layout System - Based on draw.io's spacing algorithms * Prevents node overlap and maintains proper spacing using collision detection */ /** * Check if two rectangles overlap with optional margin */ const rectanglesOverlap = (rect1, rect2, margin = 0) => { return !(rect1.x + rect1.width + margin <= rect2.x || rect2.x + rect2.width + margin <= rect1.x || rect1.y + rect1.height + margin <= rect2.y || rect2.y + rect2.height + margin <= rect1.y); }; /** * Calculate dynamic spacing based on node dimensions (draw.io approach) */ const calculateDynamicSpacing = (nodeWidth, nodeHeight, baseSpacing = 20) => { // Use larger dimension + percentage for dynamic spacing const maxDimension = Math.max(nodeWidth, nodeHeight); return Math.max(baseSpacing, maxDimension * 0.3); }; /** * Smart layout algorithm selection based on graph structure */ const selectLayoutAlgorithm = nodes => { const containers = nodes.filter(n => n.type === 'container'); const components = nodes.filter(n => n.type === 'component'); const totalNodes = nodes.length; // Hierarchical for container-heavy structures if (containers.length > components.length) { return 'hierarchical'; } // Grid for many small components if (totalNodes > 15 && components.length > containers.length * 3) { return 'grid'; } // Force-directed for mixed structures if (totalNodes > 5 && totalNodes <= 15) { return 'force'; } // Smart circular for smaller sets return 'smart-circular'; }; /** * Hierarchical Layout (draw.io style) - Enhanced */ const hierarchicalLayout = (nodes, viewportWidth = 1400, viewportHeight = 800) => { const containers = nodes.filter(n => n.type === 'container' && !n.parentNode); const components = nodes.filter(n => n.type === 'component' && !n.parentNode); // Layout parameters based on draw.io const intraCellSpacing = 80; // Increased horizontal spacing const interRankCellSpacing = 140; // Increased vertical spacing const margin = 60; let currentY = margin; // For containers, use intelligent multi-row layout if (containers.length > 0) { // Calculate optimal layout grid for containers const containerWidths = containers.map(c => { var _c$style; return ((_c$style = c.style) === null || _c$style === void 0 ? void 0 : _c$style.width) || 300; }); const containerHeights = containers.map(c => { var _c$style2; return ((_c$style2 = c.style) === null || _c$style2 === void 0 ? void 0 : _c$style2.height) || 200; }); const avgWidth = containerWidths.reduce((a, b) => a + b, 0) / containerWidths.length; const maxWidth = Math.max(...containerWidths); // Determine containers per row based on viewport and container sizes const minSpacingNeeded = maxWidth + intraCellSpacing; const maxContainersPerRow = Math.max(2, Math.floor((viewportWidth - 2 * margin) / minSpacingNeeded)); const containersPerRow = Math.min(maxContainersPerRow, Math.ceil(Math.sqrt(containers.length))); let currentX = margin; let rowMaxHeight = 0; containers.forEach((container, idx) => { var _container$style, _container$style2; // Start new row if needed if (idx > 0 && idx % containersPerRow === 0) { currentY += rowMaxHeight + interRankCellSpacing; currentX = margin; rowMaxHeight = 0; } const containerWidth = ((_container$style = container.style) === null || _container$style === void 0 ? void 0 : _container$style.width) || 300; const containerHeight = ((_container$style2 = container.style) === null || _container$style2 === void 0 ? void 0 : _container$style2.height) || 200; const spacing = calculateDynamicSpacing(containerWidth, containerHeight, intraCellSpacing); container.position = { x: currentX, y: currentY }; currentX += containerWidth + spacing; rowMaxHeight = Math.max(rowMaxHeight, containerHeight); }); // Move Y position for next level currentY += rowMaxHeight + interRankCellSpacing; } // For components, use grid layout with proper spacing if (components.length > 0) { const componentWidths = components.map(c => { var _c$style3; return ((_c$style3 = c.style) === null || _c$style3 === void 0 ? void 0 : _c$style3.width) || 200; }); const componentHeights = components.map(c => { var _c$style4; return ((_c$style4 = c.style) === null || _c$style4 === void 0 ? void 0 : _c$style4.height) || 100; }); const avgCompWidth = componentWidths.reduce((a, b) => a + b, 0) / componentWidths.length; const maxCompWidth = Math.max(...componentWidths); const minCompSpacing = maxCompWidth + intraCellSpacing; const maxComponentsPerRow = Math.max(3, Math.floor((viewportWidth - 2 * margin) / minCompSpacing)); const componentsPerRow = Math.min(maxComponentsPerRow, Math.ceil(Math.sqrt(components.length))); let currentX = margin; let rowMaxHeight = 0; components.forEach((component, idx) => { var _component$style, _component$style2; // Start new row if needed if (idx > 0 && idx % componentsPerRow === 0) { currentY += rowMaxHeight + 100; // Smaller spacing for components currentX = margin; rowMaxHeight = 0; } const componentWidth = ((_component$style = component.style) === null || _component$style === void 0 ? void 0 : _component$style.width) || 200; const componentHeight = ((_component$style2 = component.style) === null || _component$style2 === void 0 ? void 0 : _component$style2.height) || 100; const spacing = calculateDynamicSpacing(componentWidth, componentHeight, intraCellSpacing); component.position = { x: currentX, y: currentY }; currentX += componentWidth + spacing; rowMaxHeight = Math.max(rowMaxHeight, componentHeight); }); } return nodes; }; /** * Smart Grid Layout with collision detection */ const gridLayout = (nodes, viewportWidth = 1400, viewportHeight = 800) => { const margin = 50; const baseSpacing = 40; // Sort nodes by size (larger first) const sortedNodes = [...nodes].sort((a, b) => { var _a$style, _a$style2, _b$style, _b$style2; const aSize = (((_a$style = a.style) === null || _a$style === void 0 ? void 0 : _a$style.width) || 150) * (((_a$style2 = a.style) === null || _a$style2 === void 0 ? void 0 : _a$style2.height) || 100); const bSize = (((_b$style = b.style) === null || _b$style === void 0 ? void 0 : _b$style.width) || 150) * (((_b$style2 = b.style) === null || _b$style2 === void 0 ? void 0 : _b$style2.height) || 100); return bSize - aSize; }); // Calculate optimal grid dimensions const avgWidth = sortedNodes.reduce((sum, n) => { var _n$style; return sum + (((_n$style = n.style) === null || _n$style === void 0 ? void 0 : _n$style.width) || 150); }, 0) / sortedNodes.length; const avgHeight = sortedNodes.reduce((sum, n) => { var _n$style2; return sum + (((_n$style2 = n.style) === null || _n$style2 === void 0 ? void 0 : _n$style2.height) || 100); }, 0) / sortedNodes.length; const cols = Math.max(2, Math.floor((viewportWidth - 2 * margin) / (avgWidth + baseSpacing))); const rows = Math.ceil(sortedNodes.length / cols); sortedNodes.forEach((node, idx) => { var _node$style, _node$style2; const col = idx % cols; const row = Math.floor(idx / cols); const nodeWidth = ((_node$style = node.style) === null || _node$style === void 0 ? void 0 : _node$style.width) || 150; const nodeHeight = ((_node$style2 = node.style) === null || _node$style2 === void 0 ? void 0 : _node$style2.height) || 100; const spacing = calculateDynamicSpacing(nodeWidth, nodeHeight, baseSpacing); node.position = { x: margin + col * (avgWidth + spacing), y: margin + row * (avgHeight + spacing) }; }); return sortedNodes; }; /** * Force-directed layout with collision avoidance (WebCola inspired) */ const forceDirectedLayout = (nodes, viewportWidth = 1400, viewportHeight = 800) => { const center = { x: viewportWidth / 2, y: viewportHeight / 2 }; const iterations = 50; const cellSpacing = 30; // Base collision margin // Initialize positions randomly but avoid edges nodes.forEach(node => { if (!node.position) { node.position = { x: 100 + Math.random() * (viewportWidth - 200), y: 100 + Math.random() * (viewportHeight - 200) }; } }); // Force-directed simulation with collision detection for (let iter = 0; iter < iterations; iter++) { const forces = nodes.map(() => ({ x: 0, y: 0 })); // Repulsion forces (prevent overlap) for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { var _node1$style, _node1$style2, _node2$style, _node2$style2; const node1 = nodes[i]; const node2 = nodes[j]; const dx = node2.position.x - node1.position.x; const dy = node2.position.y - node1.position.y; const distance = Math.sqrt(dx * dx + dy * dy) || 1; // Calculate minimum safe distance const w1 = ((_node1$style = node1.style) === null || _node1$style === void 0 ? void 0 : _node1$style.width) || 150; const h1 = ((_node1$style2 = node1.style) === null || _node1$style2 === void 0 ? void 0 : _node1$style2.height) || 100; const w2 = ((_node2$style = node2.style) === null || _node2$style === void 0 ? void 0 : _node2$style.width) || 150; const h2 = ((_node2$style2 = node2.style) === null || _node2$style2 === void 0 ? void 0 : _node2$style2.height) || 100; const minDistance = Math.max(w1, h1, w2, h2) / 2 + cellSpacing; if (distance < minDistance * 2) { const force = (minDistance * 2 - distance) / distance * 0.1; const fx = dx / distance * force; const fy = dy / distance * force; forces[i].x -= fx; forces[i].y -= fy; forces[j].x += fx; forces[j].y += fy; } } // Attraction to center (weak) const centerAttraction = 0.01; forces[i].x += (center.x - nodes[i].position.x) * centerAttraction; forces[i].y += (center.y - nodes[i].position.y) * centerAttraction; } // Apply forces with damping const damping = 0.8; nodes.forEach((node, i) => { var _node$style3, _node$style4; node.position.x += forces[i].x * damping; node.position.y += forces[i].y * damping; // Keep within viewport bounds const nodeWidth = ((_node$style3 = node.style) === null || _node$style3 === void 0 ? void 0 : _node$style3.width) || 150; const nodeHeight = ((_node$style4 = node.style) === null || _node$style4 === void 0 ? void 0 : _node$style4.height) || 100; node.position.x = Math.max(50, Math.min(viewportWidth - nodeWidth - 50, node.position.x)); node.position.y = Math.max(50, Math.min(viewportHeight - nodeHeight - 50, node.position.y)); }); } return nodes; }; /** * Smart Circular Layout (enhanced) */ const smartCircularLayout = (nodes, viewportWidth = 1400, viewportHeight = 800) => { const center = { x: viewportWidth / 2, y: viewportHeight / 2 }; if (nodes.length === 1) { var _nodes$0$style, _nodes$0$style2; nodes[0].position = { x: center.x - (((_nodes$0$style = nodes[0].style) === null || _nodes$0$style === void 0 ? void 0 : _nodes$0$style.width) || 150) / 2, y: center.y - (((_nodes$0$style2 = nodes[0].style) === null || _nodes$0$style2 === void 0 ? void 0 : _nodes$0$style2.height) || 100) / 2 }; return nodes; } // Calculate optimal radius based on node sizes and spacing const avgNodeSize = nodes.reduce((sum, n) => { var _n$style3, _n$style4; const width = ((_n$style3 = n.style) === null || _n$style3 === void 0 ? void 0 : _n$style3.width) || 150; const height = ((_n$style4 = n.style) === null || _n$style4 === void 0 ? void 0 : _n$style4.height) || 100; return sum + Math.max(width, height); }, 0) / nodes.length; const totalPerimeter = nodes.reduce((sum, n) => { var _n$style5, _n$style6; const width = ((_n$style5 = n.style) === null || _n$style5 === void 0 ? void 0 : _n$style5.width) || 150; const height = ((_n$style6 = n.style) === null || _n$style6 === void 0 ? void 0 : _n$style6.height) || 100; const nodeSpacing = calculateDynamicSpacing(width, height, 40); return sum + Math.max(width, height) + nodeSpacing; }, 0); const radius = Math.max(200, totalPerimeter / (2 * Math.PI)); const angleStep = 2 * Math.PI / nodes.length; nodes.forEach((node, idx) => { var _node$style5, _node$style6; const angle = idx * angleStep; const nodeWidth = ((_node$style5 = node.style) === null || _node$style5 === void 0 ? void 0 : _node$style5.width) || 150; const nodeHeight = ((_node$style6 = node.style) === null || _node$style6 === void 0 ? void 0 : _node$style6.height) || 100; node.position = { x: center.x + radius * Math.cos(angle) - nodeWidth / 2, y: center.y + radius * Math.sin(angle) - nodeHeight / 2 }; }); return nodes; }; /** * Post-layout collision detection and adjustment */ const resolveCollisions = (nodes, maxIterations = 10) => { const cellSpacing = 20; for (let iter = 0; iter < maxIterations; iter++) { let hasCollisions = false; for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { var _node1$style3, _node1$style4, _node2$style3, _node2$style4; const node1 = nodes[i]; const node2 = nodes[j]; const rect1 = { x: node1.position.x, y: node1.position.y, width: ((_node1$style3 = node1.style) === null || _node1$style3 === void 0 ? void 0 : _node1$style3.width) || 150, height: ((_node1$style4 = node1.style) === null || _node1$style4 === void 0 ? void 0 : _node1$style4.height) || 100 }; const rect2 = { x: node2.position.x, y: node2.position.y, width: ((_node2$style3 = node2.style) === null || _node2$style3 === void 0 ? void 0 : _node2$style3.width) || 150, height: ((_node2$style4 = node2.style) === null || _node2$style4 === void 0 ? void 0 : _node2$style4.height) || 100 }; if (rectanglesOverlap(rect1, rect2, cellSpacing)) { hasCollisions = true; // Calculate separation vector const centerX1 = rect1.x + rect1.width / 2; const centerY1 = rect1.y + rect1.height / 2; const centerX2 = rect2.x + rect2.width / 2; const centerY2 = rect2.y + rect2.height / 2; const dx = centerX2 - centerX1; const dy = centerY2 - centerY1; const distance = Math.sqrt(dx * dx + dy * dy) || 1; const minDistance = (Math.max(rect1.width, rect1.height) + Math.max(rect2.width, rect2.height)) / 2 + cellSpacing; const separation = (minDistance - distance) / 2; const moveX = dx / distance * separation; const moveY = dy / distance * separation; node1.position.x -= moveX; node1.position.y -= moveY; node2.position.x += moveX; node2.position.y += moveY; } } } if (!hasCollisions) break; } return nodes; }; /** * Organization Chart Layouts (from draw.io) */ const orgChartLayouts = { linear: (nodes, viewportWidth = 1400, viewportHeight = 800) => { const margin = 60; const spacing = 100; let currentY = margin; nodes.forEach(node => { var _node$style7, _node$style8; const nodeWidth = ((_node$style7 = node.style) === null || _node$style7 === void 0 ? void 0 : _node$style7.width) || 200; const nodeHeight = ((_node$style8 = node.style) === null || _node$style8 === void 0 ? void 0 : _node$style8.height) || 100; node.position = { x: (viewportWidth - nodeWidth) / 2, // Center horizontally y: currentY }; currentY += nodeHeight + spacing; }); return nodes; }, hanger2: (nodes, viewportWidth = 1400, viewportHeight = 800) => { const margin = 60; const spacing = 80; const cols = 2; nodes.forEach((node, idx) => { var _node$style9, _node$style0; const col = idx % cols; const row = Math.floor(idx / cols); const nodeWidth = ((_node$style9 = node.style) === null || _node$style9 === void 0 ? void 0 : _node$style9.width) || 200; const nodeHeight = ((_node$style0 = node.style) === null || _node$style0 === void 0 ? void 0 : _node$style0.height) || 100; node.position = { x: margin + col * (nodeWidth + spacing), y: margin + row * (nodeHeight + spacing) }; }); return nodes; }, hanger4: (nodes, viewportWidth = 1400, viewportHeight = 800) => { const margin = 60; const spacing = 80; const cols = 4; nodes.forEach((node, idx) => { var _node$style1, _node$style10; const col = idx % cols; const row = Math.floor(idx / cols); const nodeWidth = ((_node$style1 = node.style) === null || _node$style1 === void 0 ? void 0 : _node$style1.width) || 200; const nodeHeight = ((_node$style10 = node.style) === null || _node$style10 === void 0 ? void 0 : _node$style10.height) || 100; node.position = { x: margin + col * (nodeWidth + spacing), y: margin + row * (nodeHeight + spacing) }; }); return nodes; }, fishbone1: (nodes, viewportWidth = 1400, viewportHeight = 800) => { const centerX = viewportWidth / 2; const centerY = viewportHeight / 2; const spacing = 120; nodes.forEach((node, idx) => { var _node$style11, _node$style12; const nodeWidth = ((_node$style11 = node.style) === null || _node$style11 === void 0 ? void 0 : _node$style11.width) || 200; const nodeHeight = ((_node$style12 = node.style) === null || _node$style12 === void 0 ? void 0 : _node$style12.height) || 100; // Alternate sides (left/right of center) const side = idx % 2 === 0 ? -1 : 1; const level = Math.floor(idx / 2); node.position = { x: centerX + side * (nodeWidth + spacing) - nodeWidth / 2, y: centerY + level * (nodeHeight + spacing) - nodeHeight / 2 }; }); return nodes; }, fishbone2: (nodes, viewportWidth = 1400, viewportHeight = 800) => { const centerX = viewportWidth / 2; const centerY = viewportHeight / 2; const spacing = 100; nodes.forEach((node, idx) => { var _node$style13, _node$style14; const nodeWidth = ((_node$style13 = node.style) === null || _node$style13 === void 0 ? void 0 : _node$style13.width) || 200; const nodeHeight = ((_node$style14 = node.style) === null || _node$style14 === void 0 ? void 0 : _node$style14.height) || 100; // Four quadrant layout const quadrant = idx % 4; const level = Math.floor(idx / 4); let x, y; switch (quadrant) { case 0: // Top-left x = centerX - nodeWidth - spacing * (level + 1); y = centerY - nodeHeight - spacing * (level + 1); break; case 1: // Top-right x = centerX + spacing * (level + 1); y = centerY - nodeHeight - spacing * (level + 1); break; case 2: // Bottom-right x = centerX + spacing * (level + 1); y = centerY + spacing * (level + 1); break; case 3: // Bottom-left x = centerX - nodeWidth - spacing * (level + 1); y = centerY + spacing * (level + 1); break; } node.position = { x, y }; }); return nodes; } }; /** * All available layout algorithms (draw.io complete set) */ const layoutAlgorithms = exports.layoutAlgorithms = { // Basic layouts 'hierarchical': { name: 'Hierarchical', category: 'Basic', func: hierarchicalLayout }, 'grid': { name: 'Grid', category: 'Basic', func: gridLayout }, 'circular': { name: 'Circular', category: 'Basic', func: smartCircularLayout }, 'force-directed': { name: 'Force Directed', category: 'Basic', func: forceDirectedLayout }, // Organization charts 'org-linear': { name: 'Linear', category: 'Organization', func: orgChartLayouts.linear }, 'org-hanger2': { name: 'Hanger (2 columns)', category: 'Organization', func: orgChartLayouts.hanger2 }, 'org-hanger4': { name: 'Hanger (4 columns)', category: 'Organization', func: orgChartLayouts.hanger4 }, 'org-fishbone1': { name: 'Fishbone 1', category: 'Organization', func: orgChartLayouts.fishbone1 }, 'org-fishbone2': { name: 'Fishbone 2', category: 'Organization', func: orgChartLayouts.fishbone2 }, // Tree layouts 'tree-vertical': { name: 'Vertical Tree', category: 'Tree', func: hierarchicalLayout }, 'tree-horizontal': { name: 'Horizontal Tree', category: 'Tree', func: (nodes, w, h) => { // Transpose hierarchical layout const result = hierarchicalLayout(nodes, h, w); return result.map(node => ({ ...node, position: { x: node.position.y, y: node.position.x } })); } }, // Special layouts 'radial': { name: 'Radial', category: 'Special', func: smartCircularLayout }, 'compact': { name: 'Compact Tree', category: 'Special', func: gridLayout }, 'organic': { name: 'Organic', category: 'Special', func: forceDirectedLayout } }; /** * Apply layout with specific algorithm */ const applyLayoutAlgorithm = (nodes, algorithm = 'hierarchical', viewportWidth = 1400, viewportHeight = 800) => { const updated = [...nodes]; // Apply text-based sizing to all nodes const estimateTextSize = (text, minW = 80, minH = 50, maxW = 300, maxH = 180) => { if (!text) return { width: minW, height: minH }; const words = text.split(/\s+/); const avgCharWidth = 8; const lineHeight = 20; const padding = 24; const totalChars = text.length; const charsPerLine = Math.floor((maxW - padding) / avgCharWidth); const lines = Math.max(1, Math.ceil(totalChars / charsPerLine)); const width = Math.min(maxW, Math.max(minW, totalChars * avgCharWidth / lines + padding)); const height = Math.min(maxH, Math.max(minW, lines * lineHeight + padding)); return { width, height }; }; updated.forEach(node => { var _node$data, _node$data2; const text = `${((_node$data = node.data) === null || _node$data === void 0 ? void 0 : _node$data.label) || ''} ${((_node$data2 = node.data) === null || _node$data2 === void 0 ? void 0 : _node$data2.description) || ''}`.trim(); const textSize = estimateTextSize(text, node.type === 'container' ? 200 : 120, node.type === 'container' ? 120 : 80, node.type === 'container' ? 350 : 300, node.type === 'container' ? 200 : 180); if (!node.style) node.style = {}; node.style.width = textSize.width; node.style.height = textSize.height; }); // Handle container layouts (nested nodes) const containers = updated.filter(n => n.type === 'container'); containers.forEach(container => { const children = updated.filter(n => n.parentNode === container.id); if (children.length > 0) { const gridNodes = gridLayout(children, container.style.width - 40, container.style.height - 60); if (gridNodes.length > 0) { const bounds = gridNodes.reduce((acc, node) => { var _node$style15, _node$style16; return { minX: Math.min(acc.minX, node.position.x), minY: Math.min(acc.minY, node.position.y), maxX: Math.max(acc.maxX, node.position.x + (((_node$style15 = node.style) === null || _node$style15 === void 0 ? void 0 : _node$style15.width) || 150)), maxY: Math.max(acc.maxY, node.position.y + (((_node$style16 = node.style) === null || _node$style16 === void 0 ? void 0 : _node$style16.height) || 100)) }; }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); container.style.width = Math.max(container.style.width, bounds.maxX - bounds.minX + 80); container.style.height = Math.max(container.style.height, bounds.maxY - bounds.minY + 100); } } }); // Apply selected layout algorithm const topLevelNodes = updated.filter(n => !n.parentNode); const layoutConfig = layoutAlgorithms[algorithm]; let layoutResult; if (layoutConfig && layoutConfig.func) { layoutResult = layoutConfig.func(topLevelNodes, viewportWidth, viewportHeight); } else { console.warn(`Unknown layout algorithm: ${algorithm}, falling back to hierarchical`); layoutResult = hierarchicalLayout(topLevelNodes, viewportWidth, viewportHeight); } // Final collision resolution pass const finalNodes = resolveCollisions(layoutResult); console.log(`Applied ${algorithm} layout algorithm with collision detection`); return updated; }; // Legacy function for backward compatibility exports.applyLayoutAlgorithm = applyLayoutAlgorithm; const autoLayoutNodes = (nodes, algorithm = null) => { const selectedAlgorithm = algorithm || selectLayoutAlgorithm(nodes.filter(n => !n.parentNode)); return applyLayoutAlgorithm(nodes, selectedAlgorithm); }; exports.autoLayoutNodes = autoLayoutNodes;