UNPKG

asl-viewer

Version:

React library for viewing AWS Step Functions workflows in the browser

1,404 lines (1,391 loc) 132 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 * 90 + (branchChildren.length - 1) * 20)); return { width: Math.max(450, branchCount * 200 + (branchCount - 1) * 40 + 60), // Increased width height: Math.max(250, maxBranchHeight + 150), // Increased padding and height }; } else if (type === "Map") { // Map iterator children are arranged vertically with better spacing const totalHeight = children.length * 90 + (children.length - 1) * 20; // Increased spacing return { width: 350, // Increased width height: Math.max(250, totalHeight + 150), // Increased padding }; } return { width: 350, height: 250 }; // Increased 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 = 180; // Increased spacing between branches const verticalOffset = 100; // Increased vertical offset from parent const verticalSpacing = 90; // Increased 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) { // 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 vertical position for proper adjustment calculation const sortedNodes = [...groupNodes, ...regularNodes].sort((a, b) => a.position.y - b.position.y); // Calculate space requirements for each expanded group const spaceRequirements = new Map(); groupNodes.forEach((groupNode) => { if (expandedNodeIds.has(groupNode.id)) { const requiredSpace = calculateExpandedGroupSpace(groupNode); spaceRequirements.set(groupNode.id, requiredSpace); } }); // Adjust positions based on expanded groups adjustNodePositions(sortedNodes, spaceRequirements, layoutCache.originalPositions, expandedNodeIds); // 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) { 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 vertical position node.position = { x: originalPos.x, y: originalPos.y + cumulativeOffset, }; 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) { if (!groupNode.groupBounds) return 0; const collapsedSize = getCollapsedSize(groupNode.type); const expandedHeight = groupNode.groupBounds.height; // Return the additional space needed (expanded height - collapsed height) return Math.max(0, expandedHeight - collapsedSize.height + 50); // Extra 50px 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 = 300; // Horizontal spacing between nodes let levelSpacing = 120; // Vertical spacing between levels // Adjust spacing based on complexity if (hasLabeledEdges) { nodeSpacing += 40; levelSpacing += 30; } if (hasMultipleChoices) { nodeSpacing += 60; levelSpacing += 20; } if (hasErrorHandling) { nodeSpacing += 30; levelSpacing += 25; } // Check for group nodes that might need extra space const hasGroupNodes = nodes.some((node) => node.isGroup); if (hasGroupNodes) { nodeSpacing += 50; levelSpacing += 40; } return { nodeSpacing: Math.min(nodeSpacing, 500), // Cap at reasonable maximum levelSpacing: Math.min(levelSpacing, 200), }; } /** * Calculates hierarchical layout for nodes using BFS algorithm */ function calculateHierarchicalLayout(nodes, edges, startAt) { 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, 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, spacingConfig) { // Use provided spacing or fall back to defaults const nodeSpacing = spacingConfig?.nodeSpacing || 340; const baseLevelSpacing = spacingConfig?.levelSpacing || 150; const labeledEdgeSpacing = 50; // Extra spacing for labeled edges const startX = 100; const startY = 100; let maxWidth = 0; let currentY = startY; // 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) => { const levelWidth = level.length * nodeSpacing; maxWidth = Math.max(maxWidth, levelWidth); const startXForLevel = startX + (maxWidth - levelWidth) / 2; level.forEach((nodeId, nodeIndex) => { const node = nodeMap.get(nodeId); if (node) { node.position = { x: startXForLevel + nodeIndex * nodeSpacing, y: currentY, }; } }); // Calculate spacing for next level if (levelIndex < levels.length - 1) { let spacingToNext = baseLevelSpacing; // Check if there are labeled edges between this level and the next if (hasLabeledEdgesBetweenLevels(levelIndex, levelIndex + 1)) { spacingToNext += labeledEdgeSpacing; } // Add extra space if current level has group nodes const hasGroupNodes = level.some((nodeId) => { const node = nodeMap.get(nodeId); return node && node.isGroup; }); if (hasGroupNodes) { spacingToNext += 50; // Extra space for group nodes } currentY += spacingToNext; } }); const totalWidth = maxWidth + 200; const totalHeight = currentY + 100; return { width: totalWidth, height: totalHeight }; } /** * Converts ASL definition to graph layout using a hierarchical approach optimized for React Flow */ function createGraphLayout(definition) { 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__"); 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) => { 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); } 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); } else { // Regular state node const node = createStateNode(stateName, state, definition.StartAt); node.position = { x: centerX - node.size.width / 2, y: currentY }; nodes.push(node); } // 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 += 50; } currentY += 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 += 50; } currentY += 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); } else { this.stopAnimation(); } }; } /** * Starts a new animation between node positions */ startAnimation(fromNodes, toNodes, duration = ANIMATION_DURATION, onUpdate) { // Stop any existing animation this.stopAnimation(); // Create position maps const fromPositions = new Map(); const toPositions = new Map(); fromNodes.forEach((node) => { fromPositions.set(node.id, { ...node.position }); }); toNodes.forEach((node) => { toPositions.set(node.id, { ...node.position }); }); // Initialize animation state this.animationState = { isAnimating: true, startTime: performance.now(), duration, fromPositions, toPositions, }; this.onUpdate = onUpdate; // Start animation loop this.animate(); } /** * Stops the current animation */ stopAnimation() { if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } this.animationState = null; this.onUpdate = undefined; } /** * Checks if animation is currently running */ isAnimating() { return this.animationState?.isAnimating ?? false; } } /** * Modern Light Theme - Clean and professional with subtle shadows */ const lightTheme = { name: "light", background: "#fafbfc", surfaceColor: "#ffffff", overlayColor: "rgba(255, 255, 255, 0.95)", nodeColors: { pass: "#e8f4fd", task: "#f0f9f0", choice: "#fff8e1", wait: "#f8f4ff", succeed: "#e8f5e8", fail: "#ffebee", parallel: "#e0f7fa", map: "#f1f8e9", }, nodeBorderColors: { pass: "#2196f3", task: "#4caf50", choice: "#ff9800", wait: "#9c27b0", succeed: "#4caf50", fail: "#f44336", parallel: "#00bcd4", map: "#8bc34a", }, nodeHoverColors: { pass: "#d4ecfc", task: "#e6f5e6", choice: "#ffecb3", wait: "#ede7f6", succeed: "#d4edda", fail: "#f5c6cb", parallel: "#b2ebf2", map: "#dcedc8", }, textColor: "#1a1a1a", textColorSecondary: "#4a4a4a", textColorMuted: "#757575", borderColor: "#e1e4e8", borderColorHover: "#c6cbd1", connectionColor: "#6a737d", connectionHoverColor: "#24292e", connectionLabelColor: "#586069", startNodeColor: "#28a745", endNodeColor: "#dc3545", selectedNodeColor: "#0366d6", shadowColor: "rgba(27, 31, 35, 0.15)", errorColor: "#d73a49", warningColor: "#f66a0a", infoColor: "#0366d6", successColor: "#28a745", gridColor: "#f0f0f0", miniMapBackground: "#ffffff", controlsBackground: "#ffffff", tooltipBackground: "#24292e", tooltipTextColor: "#ffffff", }; /** * Modern Dark Theme - Sleek and elegant with high contrast */ const darkTheme = { name: "dark", background: "#0d1117", surfaceColor: "#161b22", overlayColor: "rgba(22, 27, 34, 0.95)", nodeColors: { pass: "#1f2937", task: "#064e3b", choice: "#451a03", wait: "#581c87", succeed: "#064e3b", fail: "#7f1d1d", parallel: "#164e63", map: "#365314", }, nodeBorderColors: { pass: "#3b82f6", task: "#10b981", choice: "#f59e0b", wait: "#a855f7", succeed: "#10b981", fail: "#ef4444", parallel: "#06b6d4", map: "#84cc16", }, nodeHoverColors: { pass: "#374151", task: "#065f46", choice: "#78350f", wait: "#6b21a8", succeed: "#065f46", fail: "#991b1b", parallel: "#0e7490", map: "#4d7c0f", }, textColor: "#f0f6fc", textColorSecondary: "#c9d1d9", textColorMuted: "#8b949e", borderColor: "#30363d", borderColorHover: "#484f58", connectionColor: "#8b949e", connectionHoverColor: "#f0f6fc", connectionLabelColor: "#c9d1d9", startNodeColor: "#22c55e", endNodeColor: "#ef4444", selectedNodeColor: "#3b82f6", shadowColor: "rgba(0, 0, 0, 0.5)", errorColor: "#f85149", warningColor: "#ff8700", infoColor: "#58a6ff", successColor: "#3fb950", gridColor: "#21262d", miniMapBackground: "#161b22", controlsBackground: "#21262d", tooltipBackground: "#484f58", tooltipTextColor: "#f0f6fc", }; /** * High Contrast Theme - Optimized for accessibility */ const highContrastTheme = { name: "highContrast", background: "#000000", surfaceColor: "#1a1a1a", overlayColor: "rgba(26, 26, 26, 0.95)", nodeColors: { pass: "#000080", task: "#008000", choice: "#ff8c00", wait: "#8b008b", succeed: "#228b22", fail: "#dc143c", para