UNPKG

asl-viewer

Version:

React library for viewing AWS Step Functions workflows in the browser

1,331 lines (1,318 loc) 158 kB
'use strict'; var jsxRuntime = require('react/jsx-runtime'); var React = require('react'); var yaml = require('js-yaml'); var ReactFlow = require('reactflow'); var controls = require('@reactflow/controls'); var background = require('@reactflow/background'); var minimap = require('@reactflow/minimap'); var iconsReact = require('@tabler/icons-react'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var yaml__namespace = /*#__PURE__*/_interopNamespaceDefault(yaml); /** * Validates an ASL definition for syntax and semantic errors */ function validateASLDefinition(definition) { const errors = []; // Early validation for required fields if (!definition.StartAt) { errors.push({ message: "StartAt field is required", path: "StartAt", severity: "error", }); } // Check States existence and cache state names for performance const hasStates = definition.States && typeof definition.States === "object"; const stateNames = hasStates ? Object.keys(definition.States) : []; if (!hasStates || stateNames.length === 0) { errors.push({ message: "States field is required and must contain at least one state", path: "States", severity: "error", }); return errors; // Early return if no states } // Create state name lookup set for O(1) existence checks const stateNameSet = new Set(stateNames); // Validate StartAt references an existing state if (definition.StartAt && !stateNameSet.has(definition.StartAt)) { errors.push({ message: `StartAt references non-existent state: ${definition.StartAt}`, path: "StartAt", severity: "error", }); } // Validate each state with pre-computed state name set for (const [stateName, state] of Object.entries(definition.States)) { const stateErrors = validateState(stateName, state, stateNameSet); errors.push(...stateErrors); } // Check for unreachable states only if basic validation passes if (definition.StartAt && stateNameSet.has(definition.StartAt)) { const reachableStates = findReachableStates(definition); for (const stateName of stateNames) { if (!reachableStates.has(stateName)) { errors.push({ message: `State "${stateName}" is unreachable`, path: `States.${stateName}`, severity: "warning", }); } } } return errors; } function validateState(stateName, state, stateNameSet) { const errors = []; const basePath = `States.${stateName}`; // Check required Type field if (!state.Type) { errors.push({ message: "Type field is required for all states", path: `${basePath}.Type`, severity: "error", }); return errors; } // Pre-compute validation conditions to avoid repeated checks const hasResource = "Resource" in state && state.Resource; const hasChoices = state.Choices && Array.isArray(state.Choices); const hasNext = "Next" in state && state.Next; const hasEnd = "End" in state && state.End; // Validate state type-specific requirements switch (state.Type) { case "Pass": // Pass states can have Result but not Resource if (hasResource) { errors.push({ message: "Pass states cannot have Resource field", path: `${basePath}.Resource`, severity: "error", }); } break; case "Task": // Task states must have Resource if (!hasResource) { errors.push({ message: "Task states must have Resource field", path: `${basePath}.Resource`, severity: "error", }); } break; case "Choice": // Choice states must have Choices array and cannot have End or Next if (!hasChoices || state.Choices.length === 0) { errors.push({ message: "Choice states must have non-empty Choices array", path: `${basePath}.Choices`, severity: "error", }); } if (hasEnd) { errors.push({ message: "Choice states cannot have End field", path: `${basePath}.End`, severity: "error", }); } if (hasNext) { errors.push({ message: "Choice states cannot have Next field", path: `${basePath}.Next`, severity: "error", }); } break; case "Wait": { // Wait states must have exactly one time specification const timeFields = [ "Seconds", "Timestamp", "SecondsPath", "TimestampPath", ]; const presentTimeFields = timeFields.filter((field) => field in state && state[field] !== undefined); if (presentTimeFields.length === 0) { errors.push({ message: "Wait states must have one of: Seconds, Timestamp, SecondsPath, or TimestampPath", path: basePath, severity: "error", }); } else if (presentTimeFields.length > 1) { errors.push({ message: "Wait states can only have one time specification field", path: basePath, severity: "error", }); } break; } case "Parallel": { // Parallel states must have Branches if (!state.Branches || !Array.isArray(state.Branches) || state.Branches.length === 0) { errors.push({ message: "Parallel states must have non-empty Branches array", path: `${basePath}.Branches`, severity: "error", }); } break; } case "Map": { // Map states must have Iterator if (!state.Iterator) { errors.push({ message: "Map states must have Iterator field", path: `${basePath}.Iterator`, severity: "error", }); } break; } case "Fail": { // Fail states cannot have Next and automatically end if (hasNext) { errors.push({ message: "Fail states cannot have Next field", path: `${basePath}.Next`, severity: "error", }); } break; } case "Succeed": { // Succeed states cannot have Next and automatically end if (hasNext) { errors.push({ message: "Succeed states cannot have Next field", path: `${basePath}.Next`, severity: "error", }); } break; } } // Validate Next references with O(1) lookup if (hasNext && !stateNameSet.has(state.Next)) { errors.push({ message: `Next references non-existent state: ${state.Next}`, path: `${basePath}.Next`, severity: "error", }); } // Validate Choice references with batch processing if (hasChoices) { for (let i = 0; i < state.Choices.length; i++) { const choice = state.Choices[i]; if (choice.Next && !stateNameSet.has(choice.Next)) { errors.push({ message: `Choice rule references non-existent state: ${choice.Next}`, path: `${basePath}.Choices[${i}].Next`, severity: "error", }); } } } // Validate Default reference for Choice states if (state.Default && !stateNameSet.has(state.Default)) { errors.push({ message: `Default references non-existent state: ${state.Default}`, path: `${basePath}.Default`, severity: "error", }); } // Validate Catch references with batch processing if (state.Catch && Array.isArray(state.Catch)) { for (let i = 0; i < state.Catch.length; i++) { const catchDef = state.Catch[i]; if (!stateNameSet.has(catchDef.Next)) { errors.push({ message: `Catch references non-existent state: ${catchDef.Next}`, path: `${basePath}.Catch[${i}].Next`, severity: "error", }); } } } return errors; } function findReachableStates(definition) { const reachable = new Set(); // Early return if States is not defined or StartAt is missing if (!definition.States || !definition.StartAt) { return reachable; } // Use iterative approach with stack for better performance const toVisit = [definition.StartAt]; const states = definition.States; while (toVisit.length > 0) { const current = toVisit.pop(); // Skip if already visited or state doesn't exist if (reachable.has(current) || !states[current]) { continue; } reachable.add(current); const state = states[current]; // Batch collect next states to minimize array operations const nextStates = []; // Add direct next state if (state.Next) { nextStates.push(state.Next); } // Add choice targets if (state.Choices) { for (const choice of state.Choices) { if (choice.Next) { nextStates.push(choice.Next); } } } // Add default choice target if (state.Default) { nextStates.push(state.Default); } // Add catch targets if (state.Catch) { for (const catchDef of state.Catch) { nextStates.push(catchDef.Next); } } // Add all collected states at once toVisit.push(...nextStates); // Handle parallel branches recursively (less common, kept separate) if (state.Branches) { for (const branch of state.Branches) { const branchReachable = findReachableStates(branch); for (const stateName of branchReachable) { reachable.add(stateName); } } } // Handle map iterator recursively (less common, kept separate) if (state.Iterator) { const iteratorReachable = findReachableStates(state.Iterator); for (const stateName of iteratorReachable) { reachable.add(stateName); } } } return reachable; } /** * Parses ASL definition from string to object */ function parseASLDefinition(definition) { if (typeof definition === "string") { try { return JSON.parse(definition); } catch (error) { throw new Error(`Invalid JSON: ${error}`); } } return definition; } /** * Determines the size of a state node based on its type and properties */ function getStateSize(type, isStartState = false, isEndState = false) { // All regular state nodes are now rectangular (no more circular end states) switch (type) { case "Choice": return { width: 240, height: 60 }; case "Parallel": case "Map": return { width: 260, height: 60 }; case "Task": return { width: 230, height: 60 }; case "Wait": return { width: 220, height: 60 }; default: return { width: 220, height: 60 }; } } /** * Calculates the bounds needed for a group container with improved spacing */ function calculateGroupBounds(type, children) { if (children.length === 0) { return { width: 350, height: 250 }; // Increased default size } if (type === "Parallel") { // Group children by branch const branches = new Map(); children.forEach((child) => { const branchIndex = child.branchIndex ?? 0; if (!branches.has(branchIndex)) { branches.set(branchIndex, []); } branches.get(branchIndex).push(child); }); const branchCount = branches.size; const maxBranchHeight = Math.max(...Array.from(branches.values()).map((branchChildren) => branchChildren.length * 60 + (branchChildren.length - 1) * 20)); return { width: Math.max(300, branchCount * 240 + (branchCount - 1) * 20 + 40), // Compact width height: Math.max(150, maxBranchHeight + 100), // Compact padding and height }; } else if (type === "Map") { // Map iterator children are arranged vertically with better spacing const totalHeight = children.length * 60 + (children.length - 1) * 20; // Compact spacing return { width: 300, // Compact width height: Math.max(150, totalHeight + 100), // Compact padding }; } return { width: 300, height: 150 }; // Compact default size } /** * Creates a regular state node */ function createStateNode(stateName, state, startAt) { const isStartState = stateName === startAt; const isEndState = state.End === true || state.Type === "Succeed" || state.Type === "Fail"; const size = getStateSize(state.Type, isStartState, isEndState); return { id: stateName, name: stateName, type: state.Type, definition: state, position: { x: 0, y: 0 }, // Will be set by layout algorithm size, connections: [], isStartState, isEndState, }; } /** * Creates a group node that contains parent and child nodes */ function createGroupNode(stateName, state, startAt, children) { const isStartState = stateName === startAt; const isEndState = state.End === true || state.Type === "Succeed" || state.Type === "Fail"; // Calculate group bounds based on children (for expanded state) const groupBounds = calculateGroupBounds(state.Type, children); // Start with collapsed size (same as regular node) const collapsedSize = getStateSize(state.Type, isStartState, isEndState); return { id: stateName, name: stateName, type: state.Type, definition: state, position: { x: 0, y: 0 }, // Will be set by layout algorithm size: collapsedSize, // Start collapsed connections: [], isStartState, isEndState, isGroup: true, groupBounds, children, isExpanded: false, // Start collapsed }; } /** * Creates artificial start and end nodes for the graph */ function createArtificialNodes() { const startNode = { id: "__start__", name: "START", type: "Pass", // Using Pass as a placeholder type definition: { Type: "Pass" }, position: { x: 0, y: 0 }, size: { width: 80, height: 80 }, // Circular node connections: [], isStartState: false, isEndState: false, }; const endNode = { id: "__end__", name: "END", type: "Pass", // Using Pass as a placeholder type definition: { Type: "Pass" }, position: { x: 0, y: 0 }, size: { width: 80, height: 80 }, // Circular node connections: [], isStartState: false, isEndState: false, }; return { start: startNode, end: endNode }; } /** * Creates connections between states based on state definition */ function createConnections(stateName, state) { const connections = []; // Next connection if (state.Next) { connections.push({ from: stateName, to: state.Next, type: "next", }); } // Choice connections if (state.Choices) { state.Choices.forEach((choice, index) => { connections.push({ from: stateName, to: choice.Next, type: "choice", label: `Choice ${index + 1}`, condition: formatChoiceCondition(choice), }); }); } // Default connection for Choice states if (state.Default) { connections.push({ from: stateName, to: state.Default, type: "default", label: "Default", }); } // Catch connections if (state.Catch) { state.Catch.forEach((catchDef, index) => { connections.push({ from: stateName, to: catchDef.Next, type: "error", label: `Catch ${index + 1}`, condition: catchDef.ErrorEquals.join(", "), }); }); } return connections; } /** * Formats choice condition into human-readable string */ function formatChoiceCondition(choice) { // Create a human-readable condition string if (choice.Variable) { const variable = choice.Variable; if (choice.StringEquals !== undefined) return `${variable} == "${choice.StringEquals}"`; if (choice.StringLessThan !== undefined) return `${variable} < "${choice.StringLessThan}"`; if (choice.StringGreaterThan !== undefined) return `${variable} > "${choice.StringGreaterThan}"`; if (choice.NumericEquals !== undefined) return `${variable} == ${choice.NumericEquals}`; if (choice.NumericLessThan !== undefined) return `${variable} < ${choice.NumericLessThan}`; if (choice.NumericGreaterThan !== undefined) return `${variable} > ${choice.NumericGreaterThan}`; if (choice.BooleanEquals !== undefined) return `${variable} == ${choice.BooleanEquals}`; } return "condition"; } /** * Creates child nodes for Parallel state branches */ function createParallelChildNodes(parentId, branches) { const childNodes = []; branches.forEach((branch, branchIndex) => { // Create nodes for each state in the branch Object.entries(branch.States).forEach(([stateName, state]) => { const childId = `${parentId}_branch${branchIndex}_${stateName}`; const childNode = { id: childId, name: stateName, type: state.Type, definition: state, position: { x: 0, y: 0 }, // Will be set by layout algorithm size: getStateSize(state.Type), connections: [], isStartState: stateName === branch.StartAt, isEndState: state.End === true || state.Type === "Succeed" || state.Type === "Fail", parentId, branchIndex, }; childNodes.push(childNode); }); // Create connections within the branch Object.entries(branch.States).forEach(([stateName, state]) => { if (state.Next) { const fromId = `${parentId}_branch${branchIndex}_${stateName}`; const toId = `${parentId}_branch${branchIndex}_${state.Next}`; // Only add if the target state exists in this branch const targetExists = childNodes.some((node) => node.id === toId); if (targetExists) { const fromNode = childNodes.find((node) => node.id === fromId); if (fromNode) { fromNode.connections.push({ from: fromId, to: toId, type: "next", }); } } } }); }); return childNodes; } /** * Creates child nodes for Map state iterator */ function createMapChildNodes(parentId, iterator) { const childNodes = []; // Create nodes for each state in the iterator Object.entries(iterator.States).forEach(([stateName, state]) => { const childId = `${parentId}_iterator_${stateName}`; const childNode = { id: childId, name: stateName, type: state.Type, definition: state, position: { x: 0, y: 0 }, // Will be set by layout algorithm size: getStateSize(state.Type), connections: [], isStartState: stateName === iterator.StartAt, isEndState: state.End === true || state.Type === "Succeed" || state.Type === "Fail", parentId, branchIndex: 0, // Map has only one iterator }; childNodes.push(childNode); }); // Create connections within the iterator Object.entries(iterator.States).forEach(([stateName, state]) => { if (state.Next) { const fromId = `${parentId}_iterator_${stateName}`; const toId = `${parentId}_iterator_${state.Next}`; // Only add if the target state exists in the iterator const targetExists = childNodes.some((node) => node.id === toId); if (targetExists) { const fromNode = childNodes.find((node) => node.id === fromId); if (fromNode) { fromNode.connections.push({ from: fromId, to: toId, type: "next", }); } } } }); return childNodes; } /** * Positions child nodes around their parent node with improved spacing */ function positionChildNodes(parentNode, children, nodeSpacing) { if (children.length === 0) return; const childSpacing = 140; // Compact spacing between branches const verticalOffset = 60; // Compact vertical offset from parent const verticalSpacing = 60; // Compact spacing between nodes in same branch if (parentNode.type === "Parallel") { // Group children by branch const branches = new Map(); children.forEach((child) => { const branchIndex = child.branchIndex ?? 0; if (!branches.has(branchIndex)) { branches.set(branchIndex, []); } branches.get(branchIndex).push(child); }); // Position each branch with improved spacing let branchOffset = 0; const totalBranchWidth = (branches.size - 1) * childSpacing; const startOffset = -totalBranchWidth / 2; branches.forEach((branchChildren, branchIndex) => { const branchStartX = parentNode.position.x + startOffset + branchOffset; branchChildren.forEach((child, childIndex) => { child.position = { x: branchStartX, y: parentNode.position.y + verticalOffset + childIndex * verticalSpacing, }; }); branchOffset += childSpacing; }); } else if (parentNode.type === "Map") { // Position iterator children in a vertical line with improved spacing const startX = parentNode.position.x; children.forEach((child, index) => { child.position = { x: startX, y: parentNode.position.y + verticalOffset + index * verticalSpacing, }; }); } } /** * Calculates reactive layout adjustments when group nodes are expanded/collapsed */ function calculateReactiveLayout(nodes, edges, expandedNodeIds, layoutCache, direction = "TB") { // Make a copy of nodes to avoid mutation const updatedNodes = nodes.map((node) => ({ ...node })); // Store original positions if not already cached if (layoutCache.originalPositions.size === 0) { updatedNodes.forEach((node) => { layoutCache.originalPositions.set(node.id, { ...node.position }); }); } // Check if expansion state has changed const hasChanges = !setsEqual(expandedNodeIds, layoutCache.expandedNodes); if (!hasChanges) { return updatedNodes; } // Update cache layoutCache.expandedNodes = new Set(expandedNodeIds); // Separate group nodes and regular nodes const groupNodes = updatedNodes.filter((node) => node.isGroup); const regularNodes = updatedNodes.filter((node) => !node.isGroup && !node.parentId); // Sort nodes by position for proper adjustment calculation const sortedNodes = [...groupNodes, ...regularNodes].sort((a, b) => { if (direction === "TB") { return a.position.y - b.position.y; } else { return a.position.x - b.position.x; } }); // Calculate space requirements for each expanded group const spaceRequirements = new Map(); groupNodes.forEach((groupNode) => { if (expandedNodeIds.has(groupNode.id)) { const requiredSpace = calculateExpandedGroupSpace(groupNode, direction); spaceRequirements.set(groupNode.id, requiredSpace); } }); // Adjust positions based on expanded groups adjustNodePositions(sortedNodes, spaceRequirements, layoutCache.originalPositions, expandedNodeIds, direction); // Position child nodes for expanded groups groupNodes.forEach((groupNode) => { if (expandedNodeIds.has(groupNode.id) && groupNode.children) { // Update group size for expanded state if (groupNode.groupBounds) { groupNode.size = { ...groupNode.groupBounds }; } // Position child nodes relative to the group positionChildNodes(groupNode, groupNode.children); // Update existing child nodes in the result instead of adding duplicates groupNode.children.forEach((child) => { const existingIndex = updatedNodes.findIndex((n) => n.id === child.id); if (existingIndex >= 0) { // Update existing child node with new position updatedNodes[existingIndex] = { ...updatedNodes[existingIndex], ...child, }; } // Don't add new children nodes to updatedNodes as they should only be rendered inside the group }); } else { // Restore original collapsed size const originalSize = getCollapsedSize(groupNode.type); groupNode.size = originalSize; } }); return updatedNodes; } /** * Adjusts node positions to accommodate expanded groups */ function adjustNodePositions(sortedNodes, spaceRequirements, originalPositions, expandedNodeIds, direction) { let cumulativeOffset = 0; const processedNodes = new Set(); sortedNodes.forEach((node) => { if (processedNodes.has(node.id)) return; const originalPos = originalPositions.get(node.id); if (!originalPos) return; // Apply cumulative offset to position based on direction if (direction === "TB") { node.position = { x: originalPos.x, y: originalPos.y + cumulativeOffset, }; } else { node.position = { x: originalPos.x + cumulativeOffset, y: originalPos.y, }; } processedNodes.add(node.id); // If this is an expanded group, add its space requirement to the offset if (node.isGroup && expandedNodeIds.has(node.id)) { const requiredSpace = spaceRequirements.get(node.id) || 0; cumulativeOffset += requiredSpace; } }); } /** * Calculates the additional space needed when a group is expanded */ function calculateExpandedGroupSpace(groupNode, direction) { if (!groupNode.groupBounds) return 0; const collapsedSize = getCollapsedSize(groupNode.type); const expandedSize = direction === "TB" ? groupNode.groupBounds.height : groupNode.groupBounds.width; const collapsedDimension = direction === "TB" ? collapsedSize.height : collapsedSize.width; // Return the additional space needed (expanded size - collapsed size) return Math.max(0, expandedSize - collapsedDimension + 80); // Extra 80px for spacing } /** * Gets the collapsed size for a group node type */ function getCollapsedSize(type) { switch (type) { case "Parallel": case "Map": return { width: 260, height: 60 }; default: return { width: 220, height: 60 }; } } /** * Utility function to check if two sets are equal */ function setsEqual(setA, setB) { if (setA.size !== setB.size) return false; for (const item of setA) { if (!setB.has(item)) return false; } return true; } /** * Calculates improved spacing between levels and nodes */ function calculateImprovedSpacing(nodes, edges) { // Analyze edge complexity to determine spacing const hasLabeledEdges = edges.some((edge) => edge.label && edge.label.trim().length > 0); const hasMultipleChoices = edges.filter((edge) => edge.type === "choice").length > 1; const hasErrorHandling = edges.some((edge) => edge.type === "error"); // Base spacing values let nodeSpacing = 60; // Horizontal spacing between nodes let levelSpacing = 60; // Vertical spacing between levels // Adjust spacing based on complexity if (hasLabeledEdges) { nodeSpacing += 20; levelSpacing += 20; } if (hasMultipleChoices) { nodeSpacing += 20; levelSpacing += 10; } if (hasErrorHandling) { nodeSpacing += 10; levelSpacing += 10; } // Check for group nodes that might need extra space const hasGroupNodes = nodes.some((node) => node.isGroup); if (hasGroupNodes) { nodeSpacing += 20; levelSpacing += 20; } return { nodeSpacing: Math.min(nodeSpacing, 200), // Cap at reasonable maximum levelSpacing: Math.min(levelSpacing, 150), }; } /** * Calculates hierarchical layout for nodes using BFS algorithm */ function calculateHierarchicalLayout(nodes, edges, startAt, direction = "TB") { const nodeMap = new Map(nodes.map((n) => [n.id, n])); const visited = new Set(); const levels = []; // Separate parent nodes from child nodes const parentNodes = nodes.filter((node) => !node.parentId); nodes.filter((node) => node.parentId); // Build adjacency list (only for parent nodes initially) const adjacency = new Map(); edges.forEach((edge) => { // Only process edges between parent nodes for the main layout const fromNode = nodeMap.get(edge.from); const toNode = nodeMap.get(edge.to); if (fromNode && toNode && !fromNode.parentId && !toNode.parentId) { if (!adjacency.has(edge.from)) { adjacency.set(edge.from, []); } adjacency.get(edge.from).push(edge.to); } }); // BFS to determine levels (only for parent nodes) const queue = [{ id: startAt, level: 0 }]; visited.add(startAt); while (queue.length > 0) { const { id, level } = queue.shift(); if (!levels[level]) { levels[level] = []; } levels[level].push(id); const neighbors = adjacency.get(id) || []; neighbors.forEach((neighbor) => { if (!visited.has(neighbor)) { visited.add(neighbor); queue.push({ id: neighbor, level: level + 1 }); } }); } // Add any remaining parent nodes (unreachable) to the last level parentNodes.forEach((node) => { if (!visited.has(node.id)) { if (levels.length === 0) { levels.push([]); } levels[levels.length - 1].push(node.id); } }); // Calculate improved spacing based on graph complexity const spacingConfig = calculateImprovedSpacing(parentNodes, edges); // Position nodes with dynamic spacing based on labeled edges const layout = positionNodesInLevels(levels, nodeMap, edges, direction, spacingConfig); return { nodes: Array.from(nodeMap.values()), width: Math.max(layout.width, 600), height: Math.max(layout.height, 400), }; } /** * Positions nodes in their calculated levels with proper spacing */ function positionNodesInLevels(levels, nodeMap, edges, direction, spacingConfig) { // Use provided spacing or fall back to defaults const gapBetweenNodes = spacingConfig?.nodeSpacing || 60; const gapBetweenLevels = spacingConfig?.levelSpacing || 60; const labeledEdgeSpacing = 40; // Extra spacing for labeled edges const startX = 50; const startY = 50; // Build incoming edges map for fast lookup of parents const incomingEdges = new Map(); edges.forEach((edge) => { if (!incomingEdges.has(edge.to)) { incomingEdges.set(edge.to, []); } incomingEdges.get(edge.to).push(edge.from); }); // Store calculated CENTER positions (X for TB, Y for LR) const calculatedCenterPositions = new Map(); // Track current level position (Y for TB, X for LR) let currentFlowPos = direction === "TB" ? startY : startX; // Helper to get node dimension in the non-flow direction (Width for TB) const getNodeSize = (id) => { const node = nodeMap.get(id); if (!node) return 0; return direction === "TB" ? node.size.width : node.size.height; }; // Helper to get node dimension in the flow direction (Height for TB) const getNodeFlowSize = (id) => { const node = nodeMap.get(id); if (!node) return 0; return direction === "TB" ? node.size.height : node.size.width; }; // Check if there are edges with labels between levels const hasLabeledEdgesBetweenLevels = (fromLevel, toLevel) => { if (fromLevel >= levels.length || toLevel >= levels.length) return false; for (const fromNode of levels[fromLevel]) { for (const toNode of levels[toLevel]) { const edge = edges.find((e) => e.from === fromNode && e.to === toNode); if (edge && edge.label && edge.label.trim().length > 0) { return true; } } } return false; }; levels.forEach((level, levelIndex) => { // 1. Calculate desired positions based on parents const nodeData = level.map((nodeId) => { const parents = incomingEdges.get(nodeId) || []; // Filter parents that have been positioned (should be all from previous levels) const positionedParents = parents.filter((p) => calculatedCenterPositions.has(p)); let desiredPos = 0; if (positionedParents.length > 0) { const sum = positionedParents.reduce((acc, p) => acc + (calculatedCenterPositions.get(p) || 0), 0); desiredPos = sum / positionedParents.length; } else if (levelIndex > 0) { // If no parents (e.g. disconnected), try to stay near 0 (center) desiredPos = 0; } return { id: nodeId, desiredPos, width: getNodeSize(nodeId), flowSize: getNodeFlowSize(nodeId), }; }); // 2. Sort nodes by desired position to preserve relative order nodeData.sort((a, b) => a.desiredPos - b.desiredPos); // 3. Resolve overlaps (Left-to-Right pass) const placedPositions = []; nodeData.forEach((node, i) => { let pos = node.desiredPos; // Constraint: Must be to the right of previous node if (i > 0) { const prevPos = placedPositions[i - 1]; const prevHalfWidth = nodeData[i - 1].width / 2; const currHalfWidth = node.width / 2; const minPos = prevPos + prevHalfWidth + gapBetweenNodes + currHalfWidth; if (pos < minPos) { pos = minPos; } } placedPositions.push(pos); }); // The L->R pass pushes everything right. // We need to center the group relative to the desired positions. let totalDeviation = 0; nodeData.forEach((node, i) => { totalDeviation += placedPositions[i] - node.desiredPos; }); const avgDeviation = totalDeviation / nodeData.length; // Shift back by average deviation const finalPositions = placedPositions.map((p) => p - avgDeviation); // Store results nodeData.forEach((node, i) => { calculatedCenterPositions.set(node.id, finalPositions[i]); }); // 4. Assign positions to nodes (converting center to top-left) nodeData.forEach((node, i) => { const centerPos = finalPositions[i]; const topLeftPos = centerPos - node.width / 2; const stateNode = nodeMap.get(node.id); if (direction === "TB") { stateNode.position = { x: topLeftPos, y: currentFlowPos }; } else { stateNode.position = { x: currentFlowPos, y: topLeftPos }; } }); // 5. Update Flow Position (Y) const maxFlowSize = nodeData.length > 0 ? Math.max(...nodeData.map((n) => n.flowSize)) : 0; // Calculate spacing for next level if (levelIndex < levels.length - 1) { let spacingToNext = gapBetweenLevels + maxFlowSize; // Check if there are labeled edges between this level and the next if (hasLabeledEdgesBetweenLevels(levelIndex, levelIndex + 1)) { spacingToNext += labeledEdgeSpacing; } currentFlowPos += spacingToNext; } else { currentFlowPos += maxFlowSize; } }); // 6. Normalize coordinates (shift so min X/Y is startX/startY) let minPos = Infinity; let maxPos = -Infinity; nodeMap.forEach((node) => { const pos = direction === "TB" ? node.position.x : node.position.y; const size = direction === "TB" ? node.size.width : node.size.height; minPos = Math.min(minPos, pos); maxPos = Math.max(maxPos, pos + size); }); const shift = (direction === "TB" ? startX : startY) - minPos; nodeMap.forEach((node) => { if (direction === "TB") { node.position.x += shift; } else { node.position.y += shift; } }); const totalWidth = direction === "TB" ? maxPos - minPos + startX * 2 : currentFlowPos + startX; const totalHeight = direction === "TB" ? currentFlowPos + startY : maxPos - minPos + startY * 2; return { width: totalWidth, height: totalHeight }; } /** * Converts ASL definition to graph layout using a hierarchical approach optimized for React Flow */ function createGraphLayout(definition, direction = "TB") { const nodes = []; const edges = []; // Create artificial start and end nodes const { start: startNode, end: endNode } = createArtificialNodes(); nodes.push(startNode, endNode); // Create connection from artificial start to actual start state edges.push({ from: "__start__", to: definition.StartAt, type: "next", }); // Find end states and create connections to artificial end node const endStates = findEndStates(definition); // Create nodes for all states Object.entries(definition.States).forEach(([stateName, state]) => { if (state.Type === "Parallel" && state.Branches) { // Create a group node for Parallel state const childNodes = createParallelChildNodes(stateName, state.Branches); const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes); nodes.push(groupNode); } else if (state.Type === "Map" && state.Iterator) { // Create a group node for Map state const childNodes = createMapChildNodes(stateName, state.Iterator); const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes); nodes.push(groupNode); } else { // Regular state node const node = createStateNode(stateName, state, definition.StartAt); nodes.push(node); } }); // Create edges between states Object.entries(definition.States).forEach(([stateName, state]) => { const connections = createConnections(stateName, state); edges.push(...connections); }); // Add connections from end states to artificial end node endStates.forEach((endState) => { edges.push({ from: endState, to: "__end__", type: "next", }); }); // Calculate hierarchical layout const layout = calculateHierarchicalLayout(nodes, edges, "__start__", direction); return { nodes: layout.nodes, edges, width: layout.width, height: layout.height, }; } /** * Creates a simplified layout for basic use cases without complex dependencies */ function createSimpleLayout(definition) { const nodes = []; const edges = []; // Calculate improved spacing first const tempNodes = Object.entries(definition.States).map(([stateName, state]) => createStateNode(stateName, state, definition.StartAt)); // Create temporary edges to analyze spacing needs const tempEdges = []; Object.entries(definition.States).forEach(([stateName, state]) => { const connections = createConnections(stateName, state); tempEdges.push(...connections); }); const spacingConfig = calculateImprovedSpacing(tempNodes, tempEdges); let currentY = 50; const baseSpacing = spacingConfig.levelSpacing; const labeledEdgeSpacing = 50; // Extra spacing for labeled edges const centerX = 200; // Create artificial start node const { start: startNode, end: endNode } = createArtificialNodes(); startNode.position = { x: centerX - 40, y: currentY }; startNode.size = { width: 40, height: 40 }; nodes.push(startNode); // Create connection from artificial start to actual start state edges.push({ from: "__start__", to: definition.StartAt, type: "next", }); // Find end states const endStates = findEndStates(definition); // Create edges between states first to check for labels Object.entries(definition.States).forEach(([stateName, state]) => { const connections = createConnections(stateName, state); edges.push(...connections); }); // Add connections from end states to artificial end node endStates.forEach((endState) => { edges.push({ from: endState, to: "__end__", type: "next", }); }); // Helper function to check if there are labeled edges from a specific node const hasLabeledEdgesFrom = (nodeId) => { return edges.some((edge) => edge.from === nodeId && edge.label && edge.label.trim().length > 0); }; // Position start node currentY += baseSpacing; if (hasLabeledEdgesFrom("__start__")) { currentY += labeledEdgeSpacing; } // Create nodes in simple vertical layout with dynamic spacing const stateEntries = Object.entries(definition.States); stateEntries.forEach(([stateName, state], index) => { let currentNodeHeight = 0; if (state.Type === "Parallel" && state.Branches) { // Create a group node for Parallel state const childNodes = createParallelChildNodes(stateName, state.Branches); const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes); groupNode.position = { x: centerX - groupNode.size.width / 2, y: currentY, }; nodes.push(groupNode); currentNodeHeight = groupNode.size.height; } else if (state.Type === "Map" && state.Iterator) { // Create a group node for Map state const childNodes = createMapChildNodes(stateName, state.Iterator); const groupNode = createGroupNode(stateName, state, definition.StartAt, childNodes); groupNode.position = { x: centerX - groupNode.size.width / 2, y: currentY, }; nodes.push(groupNode); currentNodeHeight = groupNode.size.height; } else { // Regular state node const node = createStateNode(stateName, state, definition.StartAt); node.position = { x: centerX - node.size.width / 2, y: currentY }; nodes.push(node); currentNodeHeight = node.size.height; } // Calculate spacing for next node if (index < stateEntries.length - 1) { let spacingToNext = baseSpacing; if (hasLabeledEdgesFrom(stateName)) { spacingToNext += labeledEdgeSpacing; } // Add extra space for group nodes if ((state.Type === "Parallel" && state.Branches) || (state.Type === "Map" && state.Iterator)) { spacingToNext += 20; } currentY += currentNodeHeight + spacingToNext; } else { // For the last state, check if it has labeled edges to end let spacingToEnd = baseSpacing; if (hasLabeledEdgesFrom(stateName)) { spacingToEnd += labeledEdgeSpacing; } // Add extra space for group nodes if ((state.Type === "Parallel" && state.Branches) || (state.Type === "Map" && state.Iterator)) { spacingToEnd += 20; } currentY += currentNodeHeight + spacingToEnd; } }); // Create artificial end node endNode.position = { x: centerX - 40, y: currentY }; endNode.size = { width: 40, height: 40 }; nodes.push(endNode); return { nodes, edges, width: 400, height: currentY + 100, }; } /** * Finds all end states in the ASL definition */ function findEndStates(definition) { const endStates = []; Object.entries(definition.States).forEach(([stateName, state]) => { if (state.End === true || state.Type === "Succeed" || state.Type === "Fail") { endStates.push(stateName); } }); return endStates; } /** * CSS transition styles for smooth node movement */ const nodeTransitionStyles = { transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", transitionProperty: "transform, opacity, width, height", }; /** * Animation timing constants */ const ANIMATION_DURATION = 300; // milliseconds /** * Easing functions for smooth animations */ const easingFunctions = { easeOutCubic: (t) => 1 - Math.pow(1 - t, 3), easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2, easeOutBack: (t) => { const c1 = 1.70158; const c3 = c1 + 1; return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); }, }; /** * Manages animation state for node transitions */ class NodeAnimationManager { constructor() { this.animationState = null; this.animationFrame = null; /** * Animation loop */ this.animate = () => { if (!this.animationState) return; const now = performance.now(); const elapsed = now - this.animationState.startTime; const progress = Math.min(elapsed / this.animationState.duration, 1); // Apply easing const easedProgress = easingFunctions.easeOutCubic(progress); // Calculate current positions const currentPositions = new Map(); this.animationState.fromPositions.forEach((fromPos, nodeId) => { const toPos = this.animationState.toPositions.get(nodeId); if (toPos) { currentPositions.set(nodeId, { x: fromPos.x + (toPos.x - fromPos.x) * easedProgress, y: fromPos.y + (toPos.y - fromPos.y) * easedProgress, }); } }); // Call update callback if (this.onUpdate) { this.onUpdate(easedProgress, currentPositions); } // Continue animation or finish if (progress < 1) { this.animationFrame = requestAnimationFrame(this.animate); }