From 27d3584a52dce6ba1353a18802fbb1e89c433582 Mon Sep 17 00:00:00 2001 From: Kshitiz Agrawal Date: Sat, 26 Jul 2025 01:27:34 +0530 Subject: [PATCH 1/2] feat: implement dagre for improved layout when importing --- src/components/JsonImporter.js | 480 +++++++++++++++++++++++++++++---- 1 file changed, 421 insertions(+), 59 deletions(-) diff --git a/src/components/JsonImporter.js b/src/components/JsonImporter.js index 40a0b62..0f278cc 100644 --- a/src/components/JsonImporter.js +++ b/src/components/JsonImporter.js @@ -3,6 +3,18 @@ import { X, Upload, FileText } from 'lucide-react'; import { v4 as uuidv4 } from 'uuid'; import './JsonImporter.css'; +// Helper function to get start state name (handles both string and object formats) +function getStartStateName(start) { + if (typeof start === 'string') { + return start; + } + if (start && typeof start === 'object' && start.stateName) { + return start.stateName; + } + return null; +} + + const JsonImporter = ({ onImport, onClose }) => { const [jsonInput, setJsonInput] = useState(''); const [error, setError] = useState(''); @@ -272,7 +284,7 @@ const JsonImporter = ({ onImport, onClose }) => { function convertWorkflowToReactFlow(workflowData, retryPolicyNameToId = {}) { const nodes = []; const edges = []; - const nodePositions = calculateNodePositions(workflowData.states); + const nodePositions = calculateNodePositions(workflowData.states, workflowData); // Helper function to get next state from transition (handles both string and object formats) const getNextState = (transition) => { @@ -285,22 +297,13 @@ function convertWorkflowToReactFlow(workflowData, retryPolicyNameToId = {}) { return null; }; - // Helper function to get start state name (handles both string and object formats) - const getStartStateName = (start) => { - if (typeof start === 'string') { - return start; - } - if (start && typeof start === 'object' && start.stateName) { - return start.stateName; - } - return null; - }; + // Use the standalone getStartStateName helper function defined below - // Create start node positioned above the workflow + // Create start node positioned to the left of the workflow const startNode = { id: 'start-1', type: 'start', - position: { x: 50, y: 0 }, // Position above the main workflow + position: { x: 20, y: 400 }, // Left side at center height data: { label: 'Start' }, }; nodes.push(startNode); @@ -536,8 +539,8 @@ function convertWorkflowToReactFlow(workflowData, retryPolicyNameToId = {}) { id: endNodeId, type: 'end', position: { - x: sourcePosition.x + 450, // More spacing to the right - y: sourcePosition.y + 50, // Slight offset down for visual clarity + x: sourcePosition.x + 400, // Consistent spacing to the right + y: sourcePosition.y + 40, // Slight offset down for visual clarity }, data: { label: 'End' }, }; @@ -661,58 +664,417 @@ function convertStateToNodeData(state, retryPolicyNameToId = {}) { } } -function calculateNodePositions(states) { +function calculateNodePositions(states, workflowData = null) { + // Use advanced layered layout algorithm inspired by Mermaid/Dagre + return calculateLayeredLayout(states, workflowData); +} + +// Advanced layered layout algorithm inspired by Mermaid's Dagre approach +function calculateLayeredLayout(states, workflowData = null) { + if (!states || states.length === 0) return {}; + + const config = { + nodeWidth: 200, + nodeHeight: 80, + horizontalSpacing: 400, // Increased for more breathing room between nodes + verticalSpacing: 250, // Increased for better vertical separation + layerSpacing: 500, // Increased for cleaner horizontal flow + startX: 150, // Slightly more margin from left edge + startY: 150, // More top margin for better centering + branchSpacing: 300, // More space for branch layouts + }; + + try { + // Step 1: Build directed graph representation + const { graph, nodeTypes } = buildDirectedGraph(states, workflowData); + + // Step 2: Create layered hierarchy using topological sorting + const layers = createHierarchicalLayers(graph, nodeTypes, workflowData); + + // Step 3: Order nodes within layers to minimize edge crossings + const orderedLayers = minimizeEdgeCrossings(layers, graph); + + // Step 4: Position nodes with adaptive spacing + const positions = positionNodesInLayers(orderedLayers, config, nodeTypes); + + return positions; + + } catch (error) { + console.warn('Advanced layout failed, using improved fallback:', error); + return createImprovedFallbackLayout(states, workflowData, config); + } +} + +// Build comprehensive directed graph representation +function buildDirectedGraph(states, workflowData) { + const graph = {}; + const nodeTypes = {}; + const reverseGraph = {}; // For finding incoming connections + + // Initialize graph structure + states.forEach(state => { + graph[state.name] = { + type: state.type, + outgoing: [], + incoming: [], + branches: [], + errors: [], + isEnd: !!state.end + }; + nodeTypes[state.name] = state.type; + reverseGraph[state.name] = []; + }); + + const getNextState = (transition) => { + if (typeof transition === 'string') return transition; + return transition?.nextState || null; + }; + + // Build connections + states.forEach(state => { + const node = graph[state.name]; + + // Main transitions + if (state.transition) { + const next = getNextState(state.transition); + if (next && graph[next]) { + node.outgoing.push({ target: next, type: 'main', weight: 1 }); + graph[next].incoming.push({ source: state.name, type: 'main', weight: 1 }); + reverseGraph[next].push(state.name); + } + } + + // Switch conditions (branches) + if (state.type === 'switch') { + let branchWeight = 0.8; // Slightly less weight than main transitions + + if (state.dataConditions) { + state.dataConditions.forEach((condition, index) => { + const next = getNextState(condition.transition); + if (next && graph[next]) { + const branch = { + target: next, + type: 'condition', + name: condition.name || `condition-${index}`, + weight: branchWeight + }; + node.branches.push(branch); + node.outgoing.push(branch); + graph[next].incoming.push({ source: state.name, type: 'condition', weight: branchWeight }); + reverseGraph[next].push(state.name); + } + }); + } + + if (state.eventConditions) { + state.eventConditions.forEach((condition, index) => { + const next = getNextState(condition.transition); + if (next && graph[next]) { + const branch = { + target: next, + type: 'event', + name: condition.name || `event-${index}`, + weight: branchWeight + }; + node.branches.push(branch); + node.outgoing.push(branch); + graph[next].incoming.push({ source: state.name, type: 'event', weight: branchWeight }); + reverseGraph[next].push(state.name); + } + }); + } + + if (state.defaultCondition?.transition) { + const next = getNextState(state.defaultCondition.transition); + if (next && graph[next]) { + const branch = { + target: next, + type: 'default', + name: 'default', + weight: 0.9 + }; + node.branches.push(branch); + node.outgoing.push(branch); + graph[next].incoming.push({ source: state.name, type: 'default', weight: 0.9 }); + reverseGraph[next].push(state.name); + } + } + } + + // Error transitions + if (state.onErrors) { + state.onErrors.forEach((errorHandler, index) => { + const next = getNextState(errorHandler.transition); + if (next && graph[next]) { + const error = { + target: next, + type: 'error', + name: errorHandler.errorRef || `error-${index}`, + weight: 0.3 + }; + node.errors.push(error); + node.outgoing.push(error); + graph[next].incoming.push({ source: state.name, type: 'error', weight: 0.3 }); + reverseGraph[next].push(state.name); + } + }); + } + }); + + return { graph, nodeTypes, reverseGraph }; +} + +// Create hierarchical layers using modified topological sorting +function createHierarchicalLayers(graph, nodeTypes, workflowData) { + const layers = []; + const visited = new Set(); + const visiting = new Set(); + const nodeToLayer = {}; + + // Find start state + const startStateName = findStartState(Object.keys(graph), workflowData); + + // Calculate layer for each node using DFS with cycle detection + function assignLayer(nodeName, currentLayer = 0) { + if (visiting.has(nodeName)) { + // Cycle detected - assign to current layer + 1 + return currentLayer + 1; + } + + if (visited.has(nodeName)) { + return nodeToLayer[nodeName]; + } + + visiting.add(nodeName); + + let maxChildLayer = currentLayer; + const node = graph[nodeName]; + + if (node && node.outgoing.length > 0) { + node.outgoing.forEach(edge => { + const childLayer = assignLayer(edge.target, currentLayer + 1); + maxChildLayer = Math.max(maxChildLayer, childLayer); + }); + } + + visiting.delete(nodeName); + visited.add(nodeName); + + const finalLayer = node?.isEnd ? maxChildLayer + 1 : currentLayer; + nodeToLayer[nodeName] = finalLayer; + + return finalLayer; + } + + // Start from the start state + if (startStateName) { + assignLayer(startStateName, 0); + } + + // Assign layers to any remaining unvisited nodes + Object.keys(graph).forEach(nodeName => { + if (!visited.has(nodeName)) { + assignLayer(nodeName, 0); + } + }); + + // Group nodes by layer + const layerMap = {}; + Object.entries(nodeToLayer).forEach(([nodeName, layerIndex]) => { + if (!layerMap[layerIndex]) { + layerMap[layerIndex] = []; + } + layerMap[layerIndex].push(nodeName); + }); + + // Convert to array of layers + const maxLayer = Math.max(...Object.keys(layerMap).map(Number)); + for (let i = 0; i <= maxLayer; i++) { + layers[i] = layerMap[i] || []; + } + + return layers.filter(layer => layer.length > 0); +} + +// Minimize edge crossings using barycenter heuristic +function minimizeEdgeCrossings(layers, graph) { + if (layers.length <= 1) return layers; + + const orderedLayers = [...layers]; + const maxIterations = 3; + + for (let iteration = 0; iteration < maxIterations; iteration++) { + // Forward pass - order based on predecessors + for (let i = 1; i < orderedLayers.length; i++) { + orderedLayers[i] = orderByBarycenter(orderedLayers[i], orderedLayers[i - 1], graph, 'incoming'); + } + + // Backward pass - order based on successors + for (let i = orderedLayers.length - 2; i >= 0; i--) { + orderedLayers[i] = orderByBarycenter(orderedLayers[i], orderedLayers[i + 1], graph, 'outgoing'); + } + } + + return orderedLayers; +} + +// Order nodes within a layer using barycenter heuristic +function orderByBarycenter(currentLayer, referenceLayer, graph, direction) { + if (!currentLayer || currentLayer.length <= 1) return currentLayer; + + const nodePositions = {}; + referenceLayer.forEach((node, index) => { + nodePositions[node] = index; + }); + + const nodeWithBarycenter = currentLayer.map(nodeName => { + const node = graph[nodeName]; + let barycenter = 0; + let connectionCount = 0; + + const connections = direction === 'incoming' ? node.incoming : node.outgoing; + + connections.forEach(connection => { + const connectedNode = direction === 'incoming' ? connection.source : connection.target; + if (nodePositions.hasOwnProperty(connectedNode)) { + barycenter += nodePositions[connectedNode] * (connection.weight || 1); + connectionCount += (connection.weight || 1); + } + }); + + return { + name: nodeName, + barycenter: connectionCount > 0 ? barycenter / connectionCount : currentLayer.indexOf(nodeName), + originalIndex: currentLayer.indexOf(nodeName) + }; + }); + + // Sort by barycenter, maintaining stability + nodeWithBarycenter.sort((a, b) => { + if (Math.abs(a.barycenter - b.barycenter) < 0.001) { + return a.originalIndex - b.originalIndex; + } + return a.barycenter - b.barycenter; + }); + + return nodeWithBarycenter.map(item => item.name); +} + +// Position nodes in their layers with adaptive spacing +function positionNodesInLayers(layers, config, nodeTypes) { const positions = {}; - // Improved spacing configuration - const nodeWidth = 280; - const nodeHeight = 180; - const horizontalSpacing = 400; // More horizontal space between nodes - const verticalSpacing = 250; // More vertical space between rows - const startX = 50; - const startY = 50; - - // Calculate better grid layout - const totalNodes = states.length; - - // For better visual distribution, use different strategies based on node count - if (totalNodes <= 4) { - // Small workflows: arrange horizontally - states.forEach((state, index) => { - positions[state.name] = { - x: startX + index * horizontalSpacing, - y: startY + 100, - }; + layers.forEach((layer, layerIndex) => { + const layerX = config.startX + layerIndex * config.layerSpacing; + + // Calculate optimal spacing for this layer + const nodeCount = layer.length; + const totalRequiredHeight = nodeCount * config.nodeHeight + (nodeCount - 1) * config.verticalSpacing; + + // Center the layer vertically + const startY = config.startY + Math.max(0, (layers.length * config.nodeHeight - totalRequiredHeight) / 2); + + layer.forEach((nodeName, nodeIndex) => { + const nodeType = nodeTypes[nodeName]; + + // Calculate Y position with even distribution + const y = startY + nodeIndex * (config.nodeHeight + config.verticalSpacing); + + // Adjust X position based on node type + let x = layerX; + if (nodeType === 'start') { + x -= config.nodeWidth * 0.5; // Pull start nodes slightly left + } else if (nodeType === 'end') { + x += config.nodeWidth * 0.5; // Push end nodes slightly right + } + + positions[nodeName] = { x, y }; }); - } else if (totalNodes <= 9) { - // Medium workflows: use a balanced grid - const columns = Math.min(3, Math.ceil(Math.sqrt(totalNodes))); - states.forEach((state, index) => { - const row = Math.floor(index / columns); - const col = index % columns; - positions[state.name] = { - x: startX + col * horizontalSpacing, - y: startY + row * verticalSpacing, + }); + + return positions; +} + +// Improved fallback layout for error cases +function createImprovedFallbackLayout(states, workflowData, config) { + const positions = {}; + const startStateName = findStartState(states.map(s => s.name), workflowData); + + // Group states by type for better organization + const stateGroups = { + start: [], + operation: [], + switch: [], + event: [], + sleep: [], + end: [] + }; + + states.forEach(state => { + const type = state.type === 'start' ? 'start' : + state.end ? 'end' : state.type; + if (stateGroups[type]) { + stateGroups[type].push(state.name); + } else { + stateGroups.operation.push(state.name); + } + }); + + // Position start state first + if (startStateName) { + positions[startStateName] = { x: config.startX, y: config.startY + 200 }; + } + + let currentX = config.startX + config.layerSpacing; + let currentY = config.startY; + + // Position each group + ['operation', 'switch', 'event', 'sleep', 'end'].forEach(groupType => { + const group = stateGroups[groupType]; + if (group.length === 0) return; + + group.forEach((stateName, index) => { + if (stateName === startStateName) return; // Skip if already positioned + + positions[stateName] = { + x: currentX, + y: currentY + index * (config.nodeHeight + config.verticalSpacing) }; }); + + currentX += config.layerSpacing; + if (group.length > 3) { + currentY += config.verticalSpacing; + } + }); + + return positions; +} + +// Find the start state +function findStartState(stateNames, workflowData) { + // According to serverless workflow spec, start property is required + if (workflowData?.start) { + // Use the same helper function as used in edge creation for consistency + const startName = getStartStateName(workflowData.start); + + if (startName) { + // Validate that the start state actually exists in the states array + const startStateExists = stateNames.includes(startName); + if (startStateExists) { + return startName; + } else { + console.warn(`Start state "${startName}" not found in workflow states. Using first state as fallback.`); + } + } } else { - // Large workflows: use a wider grid with more columns - const columns = Math.min(3, Math.ceil(Math.sqrt(totalNodes * 1.2))); - states.forEach((state, index) => { - const row = Math.floor(index / columns); - const col = index % columns; - - // Add some staggering for visual appeal - const staggerOffset = (row % 2) * (horizontalSpacing * 0.3); - - positions[state.name] = { - x: startX + col * horizontalSpacing + staggerOffset, - y: startY + row * verticalSpacing, - }; - }); + console.warn('No start state defined in workflow. Using first state as fallback.'); } - return positions; + // Fallback only if start state is not defined or invalid + return stateNames[0]; } + + export default JsonImporter; From 8c510ec56e7a3bf1f509c3ec16b7f4d04c7e1f69 Mon Sep 17 00:00:00 2001 From: Kshitiz Agrawal Date: Sat, 26 Jul 2025 01:48:36 +0530 Subject: [PATCH 2/2] Refactor JsonImporter layout algorithm for improved readability - Updated the layout algorithm to implement a tree-based hierarchical structure, enhancing the visual clarity of node positions. - Adjusted configuration parameters for node dimensions and spacing to optimize layout aesthetics. - Introduced a main flow path identification method to prioritize node transitions, improving workflow representation. - Enhanced error handling layout to better separate error paths visually. - Refactored fallback layout to ensure better organization of states in error scenarios. --- src/components/JsonImporter.js | 311 ++++++++++++++------------------- 1 file changed, 131 insertions(+), 180 deletions(-) diff --git a/src/components/JsonImporter.js b/src/components/JsonImporter.js index 0f278cc..96ed507 100644 --- a/src/components/JsonImporter.js +++ b/src/components/JsonImporter.js @@ -669,39 +669,37 @@ function calculateNodePositions(states, workflowData = null) { return calculateLayeredLayout(states, workflowData); } -// Advanced layered layout algorithm inspired by Mermaid's Dagre approach +// Tree-based hierarchical layout for maximum readability function calculateLayeredLayout(states, workflowData = null) { if (!states || states.length === 0) return {}; const config = { - nodeWidth: 200, - nodeHeight: 80, - horizontalSpacing: 400, // Increased for more breathing room between nodes - verticalSpacing: 250, // Increased for better vertical separation - layerSpacing: 500, // Increased for cleaner horizontal flow - startX: 150, // Slightly more margin from left edge - startY: 150, // More top margin for better centering - branchSpacing: 300, // More space for branch layouts + nodeWidth: 220, + nodeHeight: 100, + mainFlowSpacing: 800, // Much larger spacing for main flow + branchSpacing: 600, // Large spacing for branches + verticalSpacing: 400, // Generous vertical spacing + startX: 300, // More left margin + startY: 300, // More top margin + errorOffset: 1000, // Separate area for error paths + switchBranchOffset: 500, // Offset for switch branches }; try { - // Step 1: Build directed graph representation + // Step 1: Build directed graph with flow analysis const { graph, nodeTypes } = buildDirectedGraph(states, workflowData); - // Step 2: Create layered hierarchy using topological sorting - const layers = createHierarchicalLayers(graph, nodeTypes, workflowData); + // Step 2: Identify main flow path (most important path through workflow) + const mainFlow = identifyMainFlowPath(graph, workflowData); - // Step 3: Order nodes within layers to minimize edge crossings - const orderedLayers = minimizeEdgeCrossings(layers, graph); - - // Step 4: Position nodes with adaptive spacing - const positions = positionNodesInLayers(orderedLayers, config, nodeTypes); + // Step 3: Create tree-based layout with clear separation + const positions = createTreeBasedLayout(graph, nodeTypes, mainFlow, config); return positions; } catch (error) { - console.warn('Advanced layout failed, using improved fallback:', error); - return createImprovedFallbackLayout(states, workflowData, config); + console.warn('Tree layout failed, using improved fallback:', error); + return createTreeFallbackLayout(states, workflowData, config); } } @@ -824,185 +822,149 @@ function buildDirectedGraph(states, workflowData) { return { graph, nodeTypes, reverseGraph }; } -// Create hierarchical layers using modified topological sorting -function createHierarchicalLayers(graph, nodeTypes, workflowData) { - const layers = []; - const visited = new Set(); - const visiting = new Set(); - const nodeToLayer = {}; - - // Find start state +// Identify the main flow path through the workflow +function identifyMainFlowPath(graph, workflowData) { const startStateName = findStartState(Object.keys(graph), workflowData); + if (!startStateName) return []; - // Calculate layer for each node using DFS with cycle detection - function assignLayer(nodeName, currentLayer = 0) { - if (visiting.has(nodeName)) { - // Cycle detected - assign to current layer + 1 - return currentLayer + 1; - } + const mainPath = []; + const visited = new Set(); + let current = startStateName; - if (visited.has(nodeName)) { - return nodeToLayer[nodeName]; - } + // Follow the main path using priority rules + while (current && !visited.has(current)) { + visited.add(current); + mainPath.push(current); - visiting.add(nodeName); + const node = graph[current]; + if (!node || node.isEnd) break; - let maxChildLayer = currentLayer; - const node = graph[nodeName]; + // Priority: 1) Direct transition, 2) Default branch, 3) First branch + let nextNode = null; - if (node && node.outgoing.length > 0) { - node.outgoing.forEach(edge => { - const childLayer = assignLayer(edge.target, currentLayer + 1); - maxChildLayer = Math.max(maxChildLayer, childLayer); - }); + // Look for direct main transition first + const mainTransition = node.outgoing.find(edge => edge.type === 'main'); + if (mainTransition) { + nextNode = mainTransition.target; } - - visiting.delete(nodeName); - visited.add(nodeName); - - const finalLayer = node?.isEnd ? maxChildLayer + 1 : currentLayer; - nodeToLayer[nodeName] = finalLayer; - - return finalLayer; - } - - // Start from the start state - if (startStateName) { - assignLayer(startStateName, 0); - } - - // Assign layers to any remaining unvisited nodes - Object.keys(graph).forEach(nodeName => { - if (!visited.has(nodeName)) { - assignLayer(nodeName, 0); + // For switch nodes, prefer default or first branch + else if (node.branches && node.branches.length > 0) { + const defaultBranch = node.branches.find(b => b.type === 'default'); + const preferredBranch = defaultBranch || node.branches[0]; + nextNode = preferredBranch.target; } - }); - // Group nodes by layer - const layerMap = {}; - Object.entries(nodeToLayer).forEach(([nodeName, layerIndex]) => { - if (!layerMap[layerIndex]) { - layerMap[layerIndex] = []; - } - layerMap[layerIndex].push(nodeName); - }); - - // Convert to array of layers - const maxLayer = Math.max(...Object.keys(layerMap).map(Number)); - for (let i = 0; i <= maxLayer; i++) { - layers[i] = layerMap[i] || []; + current = nextNode; } - return layers.filter(layer => layer.length > 0); + return mainPath; } -// Minimize edge crossings using barycenter heuristic -function minimizeEdgeCrossings(layers, graph) { - if (layers.length <= 1) return layers; +// Create tree-based layout with clear visual separation +function createTreeBasedLayout(graph, nodeTypes, mainFlow, config) { + const positions = {}; + const positioned = new Set(); - const orderedLayers = [...layers]; - const maxIterations = 3; + // Step 1: Position main flow vertically down the center + mainFlow.forEach((nodeName, index) => { + positions[nodeName] = { + x: config.startX + config.mainFlowSpacing, + y: config.startY + index * config.mainFlowSpacing + }; + positioned.add(nodeName); + }); - for (let iteration = 0; iteration < maxIterations; iteration++) { - // Forward pass - order based on predecessors - for (let i = 1; i < orderedLayers.length; i++) { - orderedLayers[i] = orderByBarycenter(orderedLayers[i], orderedLayers[i - 1], graph, 'incoming'); - } + // Step 2: Position switch branches to the right of their parent switch nodes + mainFlow.forEach((nodeName, index) => { + const node = graph[nodeName]; + if (nodeTypes[nodeName] === 'switch' && node?.branches) { + const switchPos = positions[nodeName]; + + node.branches.forEach((branch, branchIndex) => { + if (!positioned.has(branch.target)) { + positions[branch.target] = { + x: switchPos.x + config.switchBranchOffset + (branchIndex * 300), + y: switchPos.y + (branchIndex - Math.floor(node.branches.length / 2)) * config.verticalSpacing + }; + positioned.add(branch.target); - // Backward pass - order based on successors - for (let i = orderedLayers.length - 2; i >= 0; i--) { - orderedLayers[i] = orderByBarycenter(orderedLayers[i], orderedLayers[i + 1], graph, 'outgoing'); + // Position any nodes that follow this branch + positionBranchSubtree(branch.target, graph, positions, positioned, config, switchPos.x + config.switchBranchOffset + 300); + } + }); } - } - - return orderedLayers; -} - -// Order nodes within a layer using barycenter heuristic -function orderByBarycenter(currentLayer, referenceLayer, graph, direction) { - if (!currentLayer || currentLayer.length <= 1) return currentLayer; - - const nodePositions = {}; - referenceLayer.forEach((node, index) => { - nodePositions[node] = index; }); - const nodeWithBarycenter = currentLayer.map(nodeName => { + // Step 3: Position error handling nodes to the left + Object.keys(graph).forEach(nodeName => { const node = graph[nodeName]; - let barycenter = 0; - let connectionCount = 0; - - const connections = direction === 'incoming' ? node.incoming : node.outgoing; - - connections.forEach(connection => { - const connectedNode = direction === 'incoming' ? connection.source : connection.target; - if (nodePositions.hasOwnProperty(connectedNode)) { - barycenter += nodePositions[connectedNode] * (connection.weight || 1); - connectionCount += (connection.weight || 1); + if (node?.errors && node.errors.length > 0) { + const sourcePos = positions[nodeName]; + if (sourcePos) { + node.errors.forEach((errorTarget, errorIndex) => { + if (!positioned.has(errorTarget)) { + positions[errorTarget] = { + x: config.startX - config.errorOffset, + y: sourcePos.y + errorIndex * config.verticalSpacing + }; + positioned.add(errorTarget); + } + }); } - }); - - return { - name: nodeName, - barycenter: connectionCount > 0 ? barycenter / connectionCount : currentLayer.indexOf(nodeName), - originalIndex: currentLayer.indexOf(nodeName) - }; + } }); - // Sort by barycenter, maintaining stability - nodeWithBarycenter.sort((a, b) => { - if (Math.abs(a.barycenter - b.barycenter) < 0.001) { - return a.originalIndex - b.originalIndex; - } - return a.barycenter - b.barycenter; + // Step 4: Position any remaining unpositioned nodes + const unpositioned = Object.keys(graph).filter(name => !positioned.has(name)); + unpositioned.forEach((nodeName, index) => { + const row = Math.floor(index / 3); + const col = index % 3; + positions[nodeName] = { + x: config.startX + config.mainFlowSpacing * 2 + col * 400, + y: config.startY + row * config.verticalSpacing + }; }); - return nodeWithBarycenter.map(item => item.name); + return positions; } -// Position nodes in their layers with adaptive spacing -function positionNodesInLayers(layers, config, nodeTypes) { - const positions = {}; - - layers.forEach((layer, layerIndex) => { - const layerX = config.startX + layerIndex * config.layerSpacing; - - // Calculate optimal spacing for this layer - const nodeCount = layer.length; - const totalRequiredHeight = nodeCount * config.nodeHeight + (nodeCount - 1) * config.verticalSpacing; - - // Center the layer vertically - const startY = config.startY + Math.max(0, (layers.length * config.nodeHeight - totalRequiredHeight) / 2); - - layer.forEach((nodeName, nodeIndex) => { - const nodeType = nodeTypes[nodeName]; +// Position nodes in a branch subtree +function positionBranchSubtree(rootNode, graph, positions, positioned, config, baseX) { + const node = graph[rootNode]; + if (!node) return; - // Calculate Y position with even distribution - const y = startY + nodeIndex * (config.nodeHeight + config.verticalSpacing); + let currentY = positions[rootNode]?.y || config.startY; + let depth = 0; - // Adjust X position based on node type - let x = layerX; - if (nodeType === 'start') { - x -= config.nodeWidth * 0.5; // Pull start nodes slightly left - } else if (nodeType === 'end') { - x += config.nodeWidth * 0.5; // Push end nodes slightly right - } + // Position direct children of this branch + node.outgoing.forEach((edge, index) => { + if (!positioned.has(edge.target)) { + positions[edge.target] = { + x: baseX + depth * 400, + y: currentY + index * config.verticalSpacing + }; + positioned.add(edge.target); - positions[nodeName] = { x, y }; - }); + // Recursively position children + positionBranchSubtree(edge.target, graph, positions, positioned, config, baseX + 400); + } }); - - return positions; } -// Improved fallback layout for error cases -function createImprovedFallbackLayout(states, workflowData, config) { + + +// Tree-based fallback layout for error cases +function createTreeFallbackLayout(states, workflowData, config) { const positions = {}; const startStateName = findStartState(states.map(s => s.name), workflowData); - // Group states by type for better organization + // Position start state + if (startStateName) { + positions[startStateName] = { x: config.startX, y: config.startY }; + } + + // Group remaining states by type const stateGroups = { - start: [], operation: [], switch: [], event: [], @@ -1011,8 +973,9 @@ function createImprovedFallbackLayout(states, workflowData, config) { }; states.forEach(state => { - const type = state.type === 'start' ? 'start' : - state.end ? 'end' : state.type; + if (state.name === startStateName) return; // Skip start state + + const type = state.end ? 'end' : state.type; if (stateGroups[type]) { stateGroups[type].push(state.name); } else { @@ -1020,32 +983,20 @@ function createImprovedFallbackLayout(states, workflowData, config) { } }); - // Position start state first - if (startStateName) { - positions[startStateName] = { x: config.startX, y: config.startY + 200 }; - } - - let currentX = config.startX + config.layerSpacing; - let currentY = config.startY; + // Position groups in vertical columns with massive spacing + let currentX = config.startX + config.mainFlowSpacing; - // Position each group - ['operation', 'switch', 'event', 'sleep', 'end'].forEach(groupType => { - const group = stateGroups[groupType]; + Object.entries(stateGroups).forEach(([groupType, group]) => { if (group.length === 0) return; group.forEach((stateName, index) => { - if (stateName === startStateName) return; // Skip if already positioned - positions[stateName] = { x: currentX, - y: currentY + index * (config.nodeHeight + config.verticalSpacing) + y: config.startY + config.mainFlowSpacing + index * config.verticalSpacing }; }); - currentX += config.layerSpacing; - if (group.length > 3) { - currentY += config.verticalSpacing; - } + currentX += config.branchSpacing; }); return positions;