asl-viewer
Version:
React library for viewing AWS Step Functions workflows in the browser
1,331 lines (1,318 loc) • 158 kB
JavaScript
'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);
}